diff --git a/CHANGELOG.md b/CHANGELOG.md index be3c6e0c..0b1a0efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/reader/src/main/java/io/github/dfa1/vortex/reader/array/VarBinArray.java b/reader/src/main/java/io/github/dfa1/vortex/reader/array/VarBinArray.java index 1c2e6320..36a9ccd3 100644 --- a/reader/src/main/java/io/github/dfa1/vortex/reader/array/VarBinArray.java +++ b/reader/src/main/java/io/github/dfa1/vortex/reader/array/VarBinArray.java @@ -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) { diff --git a/reader/src/test/java/io/github/dfa1/vortex/reader/array/VarBinArrayTest.java b/reader/src/test/java/io/github/dfa1/vortex/reader/array/VarBinArrayTest.java index 50705b19..613b2ae3 100644 --- a/reader/src/test/java/io/github/dfa1/vortex/reader/array/VarBinArrayTest.java +++ b/reader/src/test/java/io/github/dfa1/vortex/reader/array/VarBinArrayTest.java @@ -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 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 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