From 781c30ed42198420ae7c5e760f4fa7acf972ddc8 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Thu, 6 Aug 2026 19:45:11 +0200 Subject: [PATCH] perf(reader): decode fastlanes.delta without heap staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeltaEncodingDecoder routed the whole column through four row-scaled heap long[] arrays before writing a single arena segment: basesAll and deltasAll copied out of their segments, a full-length `decoded`, and a `result` slice of it. Every value was widened to 8 bytes regardless of ptype, so an I8 delta column allocated 8x its natural width on the GC heap, three times over — CLAUDE.md's allocation rule violated four times at row scale. `decoded` -> `result` was a pure arraycopy duplicate whose only effect was dropping `offset` leading elements. Values now go straight into one arena segment at the column's own width. The untranspose and the window shift happen in the same step, so both staging arrays disappear; what remains is fixed-size per-chunk scratch (bases, deltas, undelta), which is cache-resident and reused. Delta has to reconstruct values — each depends on its predecessor — so unlike the dict/runend/sequence work there is no lazy carrier to return here. This is purely about not staging the reconstruction on the heap. Both hot-loop anti-patterns in the old readLongs are gone: - The per-element `switch (ptype)` is hoisted out of every loop. - The per-element `i % cap` is branch-split away: readDirect handles the case where the child covers the range, with no division at all. The broadcast path an undersized (ConstantEncoding) child still needs is strength-reduced to a rolling index — exactly one `%` before the loop, then a compare-and-reset. Reading the cycle into scratch instead would reintroduce a cap-sized heap allocation, which is what this removes. Modulo in these loops is the repeated cause of 5-10x regressions here (ed658b7 -> 051a794 -> 442021f), so it is worth stating that none survives on the hot path. The scatter keeps one unsigned bounds compare per element: it folds the leading chunk's negative output index and the trailing chunk's overrun into a single test, and the stores are a transposeIndex permutation that never vectorizes regardless, so it costs nothing the untranspose was not already paying. Coverage was thin — four tests, none touching `offset` or a chunk boundary, which is precisely what this rewrite changes. Adds round-trips over all eight integer ptypes, across chunk boundaries, over six offsets, and with a window shorter than the decoded length. The wire form is built in the test by mirroring DeltaEncodingEncoder, since the writer module is not on the reader's test classpath and the encoder never emits a non-zero offset. Verified the offset test fails when the window shift is removed. Not benchmarked: there is no delta JMH harness, and adding one is out of scope here. The allocation removal is unambiguous; the loop changes follow the documented rule rather than a measurement. Closes #338 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../reader/decode/DeltaEncodingDecoder.java | 254 ++++++++++++++---- .../decode/DeltaEncodingDecoderTest.java | 171 ++++++++++++ 3 files changed, 378 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddad979d9..704d40e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A `vortex.sequence` column no longer materializes `base + i * multiplier` into a full buffer on decode; rows are computed on access, so the encoding allocates nothing regardless of row count — closing an `OutOfMemoryError` risk from a metadata-only encoding whose row count no buffer bounds. ([#335](https://github.com/dfa1/vortex-java/issues/335)) - A primitive `vortex.dict` column decoded through the encoding path no longer expands its codes into an `n * elemSize` buffer; it now returns the same lazy `DictXxxArray` carriers the layout path already used, so a dict column keeps the dictionary's memory benefit however it is reached. ([#336](https://github.com/dfa1/vortex-java/issues/336)) - A `vortex.patched` column with no patches no longer allocates and copies a full duplicate of its inner child; the child is aliased directly when it already covers every row. ([#337](https://github.com/dfa1/vortex-java/issues/337)) +- `fastlanes.delta` decode no longer stages the column through four row-scaled heap `long[]` arrays, which widened every value to 8 bytes whatever the column's width; values are reconstructed straight into one off-heap segment at the column's own width. ([#338](https://github.com/dfa1/vortex-java/issues/338)) ## [0.13.1] — 2026-08-06 diff --git a/reader/src/main/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoder.java b/reader/src/main/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoder.java index af5ac5810..bb3eaf9cf 100644 --- a/reader/src/main/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoder.java +++ b/reader/src/main/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoder.java @@ -5,7 +5,6 @@ import io.github.dfa1.vortex.core.error.VortexException; import io.github.dfa1.vortex.core.model.EncodingId; import io.github.dfa1.vortex.core.compute.FastLanes; -import io.github.dfa1.vortex.core.compute.PrimitiveArrays; import io.github.dfa1.vortex.core.io.VortexFormat; import io.github.dfa1.vortex.core.proto.ProtoDeltaMetadata; import io.github.dfa1.vortex.reader.array.Array; @@ -19,6 +18,11 @@ import java.lang.foreign.ValueLayout; /// Read-only decoder for `fastlanes.delta`. +/// +/// Delta genuinely has to reconstruct values — each depends on its predecessor — so unlike the +/// dict/runend/sequence encodings there is no lazy carrier to return. What it must not do is +/// stage that reconstruction on the heap: the values are written straight into one arena +/// segment at the column's own width, with only fixed-size per-chunk scratch in between. public final class DeltaEncodingDecoder implements EncodingDecoder { @Override @@ -50,55 +54,95 @@ public Array decode(DecodeContext ctx) { int offset = meta.offset(); if (deltasLen == 0L) { - MemorySegment empty = ctx.arena().allocate(0); - return switch (ptype) { - case I64, U64 -> new MaterializedLongArray(ctx.dtype(), 0L, empty); - case I32, U32 -> new MaterializedIntArray(ctx.dtype(), 0L, empty); - case I16, U16 -> new MaterializedShortArray(ctx.dtype(), 0L, empty); - case I8, U8 -> new MaterializedByteArray(ctx.dtype(), 0L, empty); - default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype); - }; + return typedArray(ctx.dtype(), ptype, 0L, ctx.arena().allocate(0)); } long basesLen = (deltasLen / FastLanes.CHUNK) * lanes; DType dtype = ctx.dtype(); - long[] basesAll = readLongs(ctx.decodeChildSegment(0, dtype, basesLen), (int) basesLen, ptype); - long[] deltasAll = readLongs(ctx.decodeChildSegment(1, dtype, deltasLen), (int) deltasLen, ptype); + MemorySegment basesSeg = ctx.decodeChildSegment(0, dtype, basesLen); + MemorySegment deltasSeg = ctx.decodeChildSegment(1, dtype, deltasLen); + long basesCap = SegmentBroadcast.capacity(basesSeg, ptype.byteSize()); + long deltasCap = SegmentBroadcast.capacity(deltasSeg, ptype.byteSize()); - int numChunks = (int) (deltasLen / FastLanes.CHUNK); - long[] decoded = new long[(int) deltasLen]; - long[] untransposedChunk = new long[FastLanes.CHUNK]; + // The only row-scaled allocation: the output itself, off-heap and at the column's own + // width. Everything below is fixed-size scratch — one chunk's worth, cache-resident, + // reused across chunks. + MemorySegment out = ctx.arena().allocate(rowCount * ptype.byteSize()); long[] chunkBases = new long[lanes]; long[] chunkDeltas = new long[FastLanes.CHUNK]; long[] chunkUndelta = new long[FastLanes.CHUNK]; + int numChunks = (int) (deltasLen / FastLanes.CHUNK); for (int chunk = 0; chunk < numChunks; chunk++) { - int basesOff = chunk * lanes; - int deltaOff = chunk * FastLanes.CHUNK; + long basesOff = (long) chunk * lanes; + long deltaOff = (long) chunk * FastLanes.CHUNK; - System.arraycopy(basesAll, basesOff, chunkBases, 0, lanes); - System.arraycopy(deltasAll, deltaOff, chunkDeltas, 0, FastLanes.CHUNK); + readInto(basesSeg, basesOff, lanes, ptype, basesCap, chunkBases); + readInto(deltasSeg, deltaOff, FastLanes.CHUNK, ptype, deltasCap, chunkDeltas); undeltaChunk(chunkDeltas, chunkBases, lanes, typeBits, mask, chunkUndelta); - for (int i = 0; i < FastLanes.CHUNK; i++) { - untransposedChunk[FastLanes.transposeIndex(i)] = chunkUndelta[i]; - } - System.arraycopy(untransposedChunk, 0, decoded, deltaOff, FastLanes.CHUNK); + // Untranspose and window-shift in one step: the value at in-chunk position `i` + // belongs at logical index `deltaOff + transposeIndex(i)`, and the window drops the + // leading `offset` of those. Writing it straight out removes both the full-length + // `decoded` staging array and the `arraycopy` that used to slice it. + scatterChunk(out, chunkUndelta, deltaOff - offset, rowCount, ptype); } - long[] result = new long[(int) rowCount]; - System.arraycopy(decoded, offset, result, 0, (int) rowCount); + return typedArray(dtype, ptype, rowCount, out.asReadOnly()); + } - MemorySegment seg = PrimitiveArrays.fromLongs(result, ptype, ctx.arena()); - return switch (ptype) { - case I64, U64 -> new MaterializedLongArray(ctx.dtype(), rowCount, seg); - case I32, U32 -> new MaterializedIntArray(ctx.dtype(), rowCount, seg); - case I16, U16 -> new MaterializedShortArray(ctx.dtype(), rowCount, seg); - case I8, U8 -> new MaterializedByteArray(ctx.dtype(), rowCount, seg); + /// Writes one untransposed chunk into the output window. + /// + /// `base` is the output index the chunk's logical position 0 maps to; it is negative for + /// the leading chunk whenever the array is offset-sliced, and the trailing chunk can run + /// past `rowCount`. Both are folded into a single unsigned comparison per element — the + /// stores are a permutation scatter (`transposeIndex`), so they never vectorize regardless + /// and the extra compare costs nothing the untranspose was not already paying. The ptype + /// switch is hoisted out of the loop so each body stays uniform (CLAUDE.md hot-loop rule). + /// + /// @param out output segment of `rowCount` elements + /// @param values one chunk of reconstructed values, in transposed order + /// @param base output index of the chunk's logical position 0 (may be negative) + /// @param rowCount number of rows in the output window + /// @param ptype output element type + private static void scatterChunk(MemorySegment out, long[] values, long base, long rowCount, PType ptype) { + switch (ptype) { + case I8, U8 -> { + for (int i = 0; i < FastLanes.CHUNK; i++) { + long at = base + FastLanes.transposeIndex(i); + if (Long.compareUnsigned(at, rowCount) < 0) { + out.set(ValueLayout.JAVA_BYTE, at, (byte) values[i]); + } + } + } + case I16, U16 -> { + for (int i = 0; i < FastLanes.CHUNK; i++) { + long at = base + FastLanes.transposeIndex(i); + if (Long.compareUnsigned(at, rowCount) < 0) { + out.setAtIndex(VortexFormat.LE_SHORT, at, (short) values[i]); + } + } + } + case I32, U32 -> { + for (int i = 0; i < FastLanes.CHUNK; i++) { + long at = base + FastLanes.transposeIndex(i); + if (Long.compareUnsigned(at, rowCount) < 0) { + out.setAtIndex(VortexFormat.LE_INT, at, (int) values[i]); + } + } + } + case I64, U64 -> { + for (int i = 0; i < FastLanes.CHUNK; i++) { + long at = base + FastLanes.transposeIndex(i); + if (Long.compareUnsigned(at, rowCount) < 0) { + out.setAtIndex(VortexFormat.LE_LONG, at, values[i]); + } + } + } default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype); - }; + } } private static void undeltaChunk(long[] deltas, long[] bases, int lanes, int typeBits, long mask, long[] out) { @@ -113,24 +157,138 @@ private static void undeltaChunk(long[] deltas, long[] bases, int lanes, int typ } } - private static long[] readLongs(MemorySegment buf, int count, PType ptype) { - long[] out = new long[count]; - int elemSize = ptype.byteSize(); - long cap = SegmentBroadcast.capacity(buf, elemSize); - for (int i = 0; i < count; i++) { - long off = (i % cap) * elemSize; - out[i] = switch (ptype) { - case I8 -> buf.get(ValueLayout.JAVA_BYTE, off); - case U8 -> Byte.toUnsignedLong(buf.get(ValueLayout.JAVA_BYTE, off)); - case I16 -> buf.get(VortexFormat.LE_SHORT, off); - case U16 -> Short.toUnsignedLong(buf.get(VortexFormat.LE_SHORT, off)); - case I32 -> buf.get(VortexFormat.LE_INT, off); - case U32 -> Integer.toUnsignedLong(buf.get(VortexFormat.LE_INT, off)); - case I64, U64 -> buf.get(VortexFormat.LE_LONG, off); - default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype); - }; + /// Reads `count` elements starting at element `from` into fixed-size scratch, widening to + /// `long`. + /// + /// Branch-split on whether the segment actually covers the range: the common path indexes + /// directly, and only an undersized child (the `ConstantEncoding` fan-out) pays the + /// broadcast modulo. Both variants hoist the ptype switch out of the loop. The previous + /// version did neither — one `i % cap` and one `switch (ptype)` per element, in a loop that + /// ran over every value in the column. + /// + /// @param buf source segment + /// @param from first element index to read + /// @param count number of elements to read + /// @param ptype element type + /// @param cap physical element count of `buf` + /// @param out scratch array of at least `count` entries + private static void readInto(MemorySegment buf, long from, int count, PType ptype, long cap, long[] out) { + if (cap == 0) { + throw new VortexException(EncodingId.FASTLANES_DELTA, "empty delta child segment"); + } + if (from + count <= cap) { + readDirect(buf, from, count, ptype, out); + } else { + readBroadcast(buf, from, count, ptype, cap, out); } - return out; } + private static void readDirect(MemorySegment buf, long from, int count, PType ptype, long[] out) { + switch (ptype) { + case I8 -> { + for (int i = 0; i < count; i++) { + out[i] = buf.get(ValueLayout.JAVA_BYTE, from + i); + } + } + case U8 -> { + for (int i = 0; i < count; i++) { + out[i] = Byte.toUnsignedLong(buf.get(ValueLayout.JAVA_BYTE, from + i)); + } + } + case I16 -> { + for (int i = 0; i < count; i++) { + out[i] = buf.getAtIndex(VortexFormat.LE_SHORT, from + i); + } + } + case U16 -> { + for (int i = 0; i < count; i++) { + out[i] = Short.toUnsignedLong(buf.getAtIndex(VortexFormat.LE_SHORT, from + i)); + } + } + case I32 -> { + for (int i = 0; i < count; i++) { + out[i] = buf.getAtIndex(VortexFormat.LE_INT, from + i); + } + } + case U32 -> { + for (int i = 0; i < count; i++) { + out[i] = Integer.toUnsignedLong(buf.getAtIndex(VortexFormat.LE_INT, from + i)); + } + } + case I64, U64 -> { + for (int i = 0; i < count; i++) { + out[i] = buf.getAtIndex(VortexFormat.LE_LONG, from + i); + } + } + default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype); + } + } + + /// Broadcast variant, for an undersized child only. + /// + /// Strength-reduced to a rolling index: exactly one `%` runs, before the loop, and the + /// wrap becomes a compare-and-reset that is correctly predicted every iteration but the + /// `cap`-th. A per-element `idx % cap` would be a 20–40 cycle divide on Apple silicon and + /// blocks C2 superword outright — the repeated cause of 5–10x regressions in this codebase + /// (CLAUDE.md hot-loop rule; `ed658b7` -> `051a794` -> `442021f`). Reading the cycle into a + /// scratch array instead would reintroduce a `cap`-sized heap allocation, which is the + /// thing this rewrite exists to remove. + /// + /// @param buf source segment + /// @param from first logical element index to read + /// @param count number of elements to read + /// @param ptype element type + /// @param cap physical element count of `buf` (≥ 1) + /// @param out scratch array of at least `count` entries + private static void readBroadcast(MemorySegment buf, long from, int count, PType ptype, long cap, long[] out) { + long start = from % cap; + switch (ptype) { + case I8 -> { + for (int i = 0, at = (int) start; i < count; i++, at = at + 1 == cap ? 0 : at + 1) { + out[i] = buf.get(ValueLayout.JAVA_BYTE, at); + } + } + case U8 -> { + for (int i = 0, at = (int) start; i < count; i++, at = at + 1 == cap ? 0 : at + 1) { + out[i] = Byte.toUnsignedLong(buf.get(ValueLayout.JAVA_BYTE, at)); + } + } + case I16 -> { + for (int i = 0, at = (int) start; i < count; i++, at = at + 1 == cap ? 0 : at + 1) { + out[i] = buf.getAtIndex(VortexFormat.LE_SHORT, at); + } + } + case U16 -> { + for (int i = 0, at = (int) start; i < count; i++, at = at + 1 == cap ? 0 : at + 1) { + out[i] = Short.toUnsignedLong(buf.getAtIndex(VortexFormat.LE_SHORT, at)); + } + } + case I32 -> { + for (int i = 0, at = (int) start; i < count; i++, at = at + 1 == cap ? 0 : at + 1) { + out[i] = buf.getAtIndex(VortexFormat.LE_INT, at); + } + } + case U32 -> { + for (int i = 0, at = (int) start; i < count; i++, at = at + 1 == cap ? 0 : at + 1) { + out[i] = Integer.toUnsignedLong(buf.getAtIndex(VortexFormat.LE_INT, at)); + } + } + case I64, U64 -> { + for (int i = 0, at = (int) start; i < count; i++, at = at + 1 == cap ? 0 : at + 1) { + out[i] = buf.getAtIndex(VortexFormat.LE_LONG, at); + } + } + default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype); + } + } + + private static Array typedArray(DType dtype, PType ptype, long n, MemorySegment seg) { + return switch (ptype) { + case I64, U64 -> new MaterializedLongArray(dtype, n, seg); + case I32, U32 -> new MaterializedIntArray(dtype, n, seg); + case I16, U16 -> new MaterializedShortArray(dtype, n, seg); + case I8, U8 -> new MaterializedByteArray(dtype, n, seg); + default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype); + }; + } } diff --git a/reader/src/test/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoderTest.java b/reader/src/test/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoderTest.java index 1fd7b1ea7..c512666e0 100644 --- a/reader/src/test/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoderTest.java +++ b/reader/src/test/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoderTest.java @@ -3,15 +3,21 @@ import io.github.dfa1.vortex.core.model.DType; import io.github.dfa1.vortex.core.model.PType; import io.github.dfa1.vortex.core.model.EncodingId; +import io.github.dfa1.vortex.core.compute.FastLanes; +import io.github.dfa1.vortex.core.compute.PrimitiveArrays; import io.github.dfa1.vortex.core.io.VortexFormat; import io.github.dfa1.vortex.core.testing.TestSegments; import io.github.dfa1.vortex.core.proto.ProtoDeltaMetadata; import io.github.dfa1.vortex.reader.ReadRegistry; import io.github.dfa1.vortex.reader.array.Array; +import io.github.dfa1.vortex.reader.array.ByteArray; +import io.github.dfa1.vortex.reader.array.IntArray; import io.github.dfa1.vortex.reader.array.LongArray; +import io.github.dfa1.vortex.reader.array.ShortArray; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; @@ -48,6 +54,171 @@ void decode_nullMetadata_returnsEmptyArray(PType ptype) { assertThat(result.length()).isZero(); } + /// Round-trips a known sequence through the wire form, for every integer width. The values + /// step by a per-lane amount so the prefix sum is non-trivial and a lane mix-up shows up. + /// + /// The previous decoder staged this through four row-scaled heap `long[]`s (bases, deltas, + /// a full-length `decoded`, and a `result` slice of it), widening every value to 8 bytes + /// whatever the column's width; values now go straight into one arena segment at the + /// column's own width (#338). The reconstruction is what must not change. + @ParameterizedTest + @EnumSource(value = PType.class, names = {"I8", "I16", "I32", "I64", "U8", "U16", "U32", "U64"}) + void decode_roundTripsASingleChunk(PType ptype) { + // Given — 1024 values, small enough to survive I8's 8-bit width + long[] values = new long[FL_CHUNK_SIZE]; + for (int i = 0; i < values.length; i++) { + values[i] = (i * 3) & 0x3F; + } + + // When + LongArray result = decodeDelta(ptype, values, 0, values.length); + + // Then + assertValues(result, values, 0, values.length); + } + + /// Multi-chunk: the per-chunk scratch is reused across iterations, so a chunk boundary is + /// where a stale-scratch or wrong-base bug would surface. Two chunks plus a partial third. + @Test + void decode_roundTripsAcrossChunkBoundaries() { + // Given + long[] values = new long[FL_CHUNK_SIZE * 2 + 100]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 7L; + } + + // When + LongArray result = decodeDelta(PType.I64, values, 0, values.length); + + // Then + assertValues(result, values, 0, values.length); + } + + /// A non-zero `offset` slices the decoded values, and nothing covered it before. It is the + /// sharp edge of writing chunks straight into the output: the leading chunk now maps to a + /// negative output index and the trailing chunk runs past the row count, both of which the + /// scatter has to drop rather than write out of bounds. The encoder always emits offset 0, + /// so this shape only arrives from a sliced array written elsewhere. + @ParameterizedTest + @ValueSource(ints = {1, 7, 1023, 1024, 1025, 2000}) + void decode_offsetSlicesTheWindow(int offset) { + // Given + long[] values = new long[FL_CHUNK_SIZE * 3]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 11L; + } + int rowCount = 500; + + // When + LongArray result = decodeDelta(PType.I64, values, offset, rowCount); + + // Then — rows are values[offset .. offset + rowCount) + assertValues(result, values, offset, rowCount); + } + + /// The window may stop short of the chunk it lands in, so the trailing chunk is only + /// partially written. Rows past `rowCount` must not be stored at all. + @Test + void decode_rowCountShorterThanTheDecodedLength() { + // Given + long[] values = new long[FL_CHUNK_SIZE * 2]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 5L; + } + + // When + LongArray result = decodeDelta(PType.I64, values, 0, 3); + + // Then + assertThat(result.length()).isEqualTo(3L); + assertValues(result, values, 0, 3); + } + + private static void assertValues(LongArray actual, long[] expected, int offset, int count) { + assertThat(actual.length()).isEqualTo((long) count); + for (int i = 0; i < count; i++) { + assertThat(actual.getLong(i)).as("row %d", i).isEqualTo(expected[offset + i]); + } + } + + /// Decodes `values` through the `fastlanes.delta` wire form, mirroring + /// `DeltaEncodingEncoder`'s transpose-then-per-lane-delta layout. Built here rather than + /// called: the writer module is not on the reader's test classpath, and the encoder never + /// emits a non-zero `offset`, which is precisely the case worth covering. + private static LongArray decodeDelta(PType ptype, long[] values, int offset, int rowCount) { + int lanes = FastLanes.lanes(ptype); + int typeBits = ptype.bits(); + long mask = FastLanes.lowMask(typeBits); + int numChunks = (values.length + FastLanes.CHUNK - 1) / FastLanes.CHUNK; + long paddedLen = (long) numChunks * FastLanes.CHUNK; + + long[] basesAll = new long[numChunks * lanes]; + long[] deltasAll = new long[(int) paddedLen]; + long[] transposed = new long[FastLanes.CHUNK]; + + for (int chunk = 0; chunk < numChunks; chunk++) { + long[] chunkBuf = new long[FastLanes.CHUNK]; + int start = chunk * FastLanes.CHUNK; + int end = Math.min(start + FastLanes.CHUNK, values.length); + for (int i = start; i < end; i++) { + chunkBuf[i - start] = values[i] & mask; + } + for (int i = 0; i < FastLanes.CHUNK; i++) { + transposed[i] = chunkBuf[FastLanes.transposeIndex(i)]; + } + System.arraycopy(transposed, 0, basesAll, chunk * lanes, lanes); + for (int lane = 0; lane < lanes; lane++) { + long prev = basesAll[chunk * lanes + lane] & mask; + for (int row = 0; row < typeBits; row++) { + int idx = FastLanes.iterateIndex(row, lane); + long next = transposed[idx] & mask; + deltasAll[chunk * FastLanes.CHUNK + idx] = (next - prev) & mask; + prev = next; + } + } + } + + MemorySegment meta = MemorySegment.ofArray(new ProtoDeltaMetadata(paddedLen, offset).encode()); + ArrayNode bases = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null, new ArrayNode[0], new int[]{0}); + ArrayNode deltas = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null, new ArrayNode[0], new int[]{1}); + ArrayNode node = new ArrayNode(EncodingId.FASTLANES_DELTA, meta, new ArrayNode[]{bases, deltas}, new int[0]); + + MemorySegment[] segs = {toSegment(basesAll, ptype), toSegment(deltasAll, ptype)}; + DecodeContext ctx = new DecodeContext(node, new DType.Primitive(ptype, false), rowCount, segs, + REGISTRY, Arena.ofAuto()); + Array decoded = SUT.decode(ctx); + return new WidenedLongView(decoded); + } + + private static MemorySegment toSegment(long[] longs, PType ptype) { + return PrimitiveArrays.fromLongs(longs, ptype, Arena.ofAuto()); + } + + /// Reads any narrow decoded array as `long` so one assertion helper covers every width. + private record WidenedLongView(Array inner) implements LongArray { + + @Override + public DType dtype() { + return inner.dtype(); + } + + @Override + public long length() { + return inner.length(); + } + + @Override + public long getLong(long i) { + return switch (inner) { + case ByteArray ba -> ba.getInt(i); + case ShortArray sa -> sa.getInt(i); + case IntArray ia -> ia.getInt(i); + case LongArray la -> la.getLong(i); + default -> throw new IllegalStateException("unexpected array type " + inner.getClass()); + }; + } + } + @Test void decode_constantChildren_broadcastsAcrossChunk() { // Given a single delta chunk (1024 rows) whose bases and deltas children each hold