Skip to content

Commit 8bcbedc

Browse files
dfa1claude
andcommitted
fix: VarBinArray.DictMode reads dict offsets in a uniform loop, unblocking vectorization (#243)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 44a706d commit 8bcbedc

3 files changed

Lines changed: 103 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `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))
13+
1014
## [0.12.1] — 2026-07-08
1115

1216
**Null validity** and **real-world file compatibility** are the two themes of this release. The

reader/src/main/java/io/github/dfa1/vortex/reader/array/VarBinArray.java

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,77 @@ public int getByteLength(long i) {
299299

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

320+
/// Fast path of [#forEachByteLength(IntConsumer)] for I32 dict-value offsets: the
321+
/// code-ptype switch is hoisted out of the loop so each specialized loop body reads
322+
/// codes at a single fixed stride and computes lengths from a constant 4-byte offset
323+
/// stride, leaving the per-row body uniform and vectorizable.
324+
///
325+
/// @param c consumer called once per row with the byte length at that index
326+
private void forEachI32OffsetByteLength(IntConsumer c) {
327+
long n = length;
328+
switch (dictCodesPType) {
329+
case U8 -> {
330+
for (long i = 0; i < n; i++) {
331+
c.accept(i32OffsetLength(
332+
Byte.toUnsignedLong(dictCodesSegs.get(ValueLayout.JAVA_BYTE, i))));
333+
}
334+
}
335+
case U16 -> {
336+
for (long i = 0; i < n; i++) {
337+
c.accept(i32OffsetLength(
338+
Short.toUnsignedLong(dictCodesSegs.getAtIndex(VortexFormat.LE_SHORT, i))));
339+
}
340+
}
341+
case U32 -> {
342+
for (long i = 0; i < n; i++) {
343+
c.accept(i32OffsetLength(
344+
Integer.toUnsignedLong(dictCodesSegs.getAtIndex(VortexFormat.LE_INT, i))));
345+
}
346+
}
347+
case I32 -> {
348+
for (long i = 0; i < n; i++) {
349+
c.accept(i32OffsetLength(dictCodesSegs.getAtIndex(VortexFormat.LE_INT, i)));
350+
}
351+
}
352+
case I64, U64 -> {
353+
for (long i = 0; i < n; i++) {
354+
c.accept(i32OffsetLength(dictCodesSegs.getAtIndex(VortexFormat.LE_LONG, i)));
355+
}
356+
}
357+
default -> throw new VortexException("unsupported codes ptype: " + dictCodesPType);
358+
}
359+
}
360+
361+
/// Byte length of the dictionary entry `code` when the value offsets are I32:
362+
/// `offsets[code + 1] - offsets[code]` read at a constant 4-byte stride. The
363+
/// segment access itself bounds-checks, so an out-of-range code is caught without
364+
/// the per-row width recomputation that [#dictReadOff(long)] performs.
365+
///
366+
/// @param code zero-based dictionary entry index (in `[0, dictSize)`)
367+
/// @return the byte length of dictionary entry `code`
368+
private int i32OffsetLength(long code) {
369+
return dictValOffsets.getAtIndex(VortexFormat.LE_INT, code + 1)
370+
- dictValOffsets.getAtIndex(VortexFormat.LE_INT, code);
371+
}
372+
308373
@Override
309374
public VarBinArray limited(long rows) {
310375
if (rows >= length) {

reader/src/test/java/io/github/dfa1/vortex/reader/array/VarBinArrayTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,37 @@ void forEachByteLength_resolvesViaDict() {
186186
assertThat(lengths).containsExactly(3, 1, 3);
187187
}
188188

189+
/// Exercises the I32-offset fast path of `forEachByteLength` (#243): the common
190+
/// FSST/dict shape where value offsets are 32-bit, read at a constant 4-byte stride.
191+
@Test
192+
void forEachByteLength_i32Offsets_resolvesViaDict() {
193+
// Given — dict=["foo","bar"] with I32 offsets, codes=[1,0,1]
194+
VarBinArray sut = ofDictWithOffsets(new String[]{"foo", "bar"}, PType.I32, new int[]{1, 0, 1});
195+
List<Integer> lengths = new ArrayList<>();
196+
197+
// When
198+
sut.forEachByteLength(lengths::add);
199+
200+
// Then
201+
assertThat(lengths).containsExactly(3, 3, 3);
202+
}
203+
204+
/// Exercises the wide-offset general path of `forEachByteLength` (#243): 64-bit value
205+
/// offsets take the per-row `dictReadOff` branch rather than the I32 fast path.
206+
@Test
207+
void forEachByteLength_i64Offsets_resolvesViaDict() {
208+
// Given — dict=["hi","there"] with I64 offsets, codes=[0,1,0,1]
209+
VarBinArray sut = ofDictWithOffsets(new String[]{"hi", "there"}, PType.I64,
210+
new int[]{0, 1, 0, 1});
211+
List<Integer> lengths = new ArrayList<>();
212+
213+
// When
214+
sut.forEachByteLength(lengths::add);
215+
216+
// Then
217+
assertThat(lengths).containsExactly(2, 5, 2, 5);
218+
}
219+
189220
/// The dict-value offsets can arrive at any integer width: FSST-decompressed
190221
/// values carry I32 offsets, legacy dicts use I64, and narrow sequence-encoded
191222
/// offsets keep their U8/U16 ptype. `dictReadOff` must read each at its true

0 commit comments

Comments
 (0)