Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `VarBinArray.DictMode` reads dict-value offsets in a ptype-uniform loop: the I32 fast path eliminates the per-row `switch(dictValOffPType)` that blocked C2 vectorization (introduced by #215). ([#243](https://github.com/dfa1/vortex-java/issues/243))

## [0.12.1] — 2026-07-08

**Null validity** and **real-world file compatibility** are the two themes of this release. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,12 +299,77 @@ public int getByteLength(long i) {

@Override
public void forEachByteLength(IntConsumer c) {
for (long i = 0; i < length; i++) {
long code = dictReadCode(i);
c.accept((int) (dictReadOff(code + 1) - dictReadOff(code)));
// Hot loop: hoist both ptype dispatches out of the per-row body so C2 sees a
// uniform, fixed-stride loop (CLAUDE.md hot-loop rule). A variable-target
// switch(dictValOffPType)/switch(dictCodesPType) per element blocks C2
// superword vectorization and adds invariant width/bounds arithmetic on every
// row (regression introduced by #215). I32 dict-value offsets are by far the
// most common (FSST + most dict encodings emit 32-bit offsets), so the fast
// path reads offsets at a constant 4-byte stride and branch-splits the code
// read once; wider offset ptypes take the general per-row path.
if (dictValOffPType == PType.I32) {
forEachI32OffsetByteLength(c);
} else {
for (long i = 0; i < length; i++) {
long code = dictReadCode(i);
c.accept((int) (dictReadOff(code + 1) - dictReadOff(code)));
}
}
}

/// Fast path of [#forEachByteLength(IntConsumer)] for I32 dict-value offsets: the
/// code-ptype switch is hoisted out of the loop so each specialized loop body reads
/// codes at a single fixed stride and computes lengths from a constant 4-byte offset
/// stride, leaving the per-row body uniform and vectorizable.
///
/// @param c consumer called once per row with the byte length at that index
private void forEachI32OffsetByteLength(IntConsumer c) {
long n = length;
switch (dictCodesPType) {
case U8 -> {
for (long i = 0; i < n; i++) {
c.accept(i32OffsetLength(
Byte.toUnsignedLong(dictCodesSegs.get(ValueLayout.JAVA_BYTE, i))));
}
}
case U16 -> {
for (long i = 0; i < n; i++) {
c.accept(i32OffsetLength(
Short.toUnsignedLong(dictCodesSegs.getAtIndex(VortexFormat.LE_SHORT, i))));
}
}
case U32 -> {
for (long i = 0; i < n; i++) {
c.accept(i32OffsetLength(
Integer.toUnsignedLong(dictCodesSegs.getAtIndex(VortexFormat.LE_INT, i))));
}
}
case I32 -> {
for (long i = 0; i < n; i++) {
c.accept(i32OffsetLength(dictCodesSegs.getAtIndex(VortexFormat.LE_INT, i)));
}
}
case I64, U64 -> {
for (long i = 0; i < n; i++) {
c.accept(i32OffsetLength(dictCodesSegs.getAtIndex(VortexFormat.LE_LONG, i)));
}
}
default -> throw new VortexException("unsupported codes ptype: " + dictCodesPType);
}
}

/// Byte length of the dictionary entry `code` when the value offsets are I32:
/// `offsets[code + 1] - offsets[code]` read at a constant 4-byte stride. The
/// segment access itself bounds-checks, so an out-of-range code is caught without
/// the per-row width recomputation that [#dictReadOff(long)] performs.
///
/// @param code zero-based dictionary entry index (in `[0, dictSize)`)
/// @return the byte length of dictionary entry `code`
private int i32OffsetLength(long code) {
return dictValOffsets.getAtIndex(VortexFormat.LE_INT, code + 1)
- dictValOffsets.getAtIndex(VortexFormat.LE_INT, code);
}

@Override
public VarBinArray limited(long rows) {
if (rows >= length) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,37 @@ void forEachByteLength_resolvesViaDict() {
assertThat(lengths).containsExactly(3, 1, 3);
}

/// Exercises the I32-offset fast path of `forEachByteLength` (#243): the common
/// FSST/dict shape where value offsets are 32-bit, read at a constant 4-byte stride.
@Test
void forEachByteLength_i32Offsets_resolvesViaDict() {
// Given — dict=["foo","bar"] with I32 offsets, codes=[1,0,1]
VarBinArray sut = ofDictWithOffsets(new String[]{"foo", "bar"}, PType.I32, new int[]{1, 0, 1});
List<Integer> lengths = new ArrayList<>();

// When
sut.forEachByteLength(lengths::add);

// Then
assertThat(lengths).containsExactly(3, 3, 3);
}

/// Exercises the wide-offset general path of `forEachByteLength` (#243): 64-bit value
/// offsets take the per-row `dictReadOff` branch rather than the I32 fast path.
@Test
void forEachByteLength_i64Offsets_resolvesViaDict() {
// Given — dict=["hi","there"] with I64 offsets, codes=[0,1,0,1]
VarBinArray sut = ofDictWithOffsets(new String[]{"hi", "there"}, PType.I64,
new int[]{0, 1, 0, 1});
List<Integer> lengths = new ArrayList<>();

// When
sut.forEachByteLength(lengths::add);

// Then
assertThat(lengths).containsExactly(2, 5, 2, 5);
}

/// The dict-value offsets can arrive at any integer width: FSST-decompressed
/// values carry I32 offsets, legacy dicts use I64, and narrow sequence-encoded
/// offsets keep their U8/U16 ptype. `dictReadOff` must read each at its true
Expand Down
Loading