diff --git a/CHANGELOG.md b/CHANGELOG.md index ddad979d..704d40e7 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 af5ac581..bb3eaf9c 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 1fd7b1ea..c512666e 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