Skip to content

Commit 35e2f6e

Browse files
dfa1claude
andcommitted
perf(reader): untranspose fastlanes.delta straight into the output window
Takes the better half of #343, which fixed #338 independently and in parallel — I merged #345 for the same issue without checking for an open PR first, so this reconciles the two rather than discarding one. From #343: - scatterChunk writes each untransposed value straight to its output index, dropping the chunk-sized `untransposed` buffer #345 staged it in and the separate pass that sliced it. The leading chunk of an offset-sliced array maps to a negative index and the trailing chunk runs past the row count; one `Long.compareUnsigned` covers both, since a negative index reads as a huge unsigned value. The stores are a permutation scatter and never vectorize regardless, so the compare costs nothing the untranspose was not already paying. - The returned segment is read-only. - Element-indexed `getAtIndex` instead of hand-computed byte offsets. - Reader-module tests that build the delta wire form directly, mirroring DeltaEncodingEncoder's layout. These cover offset slicing where it belongs — the writer is not on the reader's test classpath and never emits a non-zero offset, so #345 had to reach into the integration module to cover the same shape. Kept from #345: - Only chunks overlapping the row window are reconstructed. #343 walked every chunk and discarded the out-of-window stores per element, so a one-chunk slice of a thousand-chunk column did a thousand chunks of work. - The metadata range guard. Without it a `deltas_len` of Long.MAX_VALUE drives the chunk loop ~9e15 times — a hang, which is worse than the OutOfMemoryError it replaced. Closes #343. Co-Authored-By: Davide Angelocola <davide.angelocola@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 539aa14 commit 35e2f6e

2 files changed

Lines changed: 240 additions & 54 deletions

File tree

reader/src/main/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoder.java

Lines changed: 69 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,13 @@ public Array decode(DecodeContext ctx) {
8383
long basesCap = SegmentBroadcast.capacity(basesSeg, elemBytes);
8484
long deltasCap = SegmentBroadcast.capacity(deltasSeg, elemBytes);
8585

86+
// The only row-scaled allocation: the output itself, off-heap and at the column's own
87+
// width. Everything below is fixed-size scratch — one chunk's worth, cache-resident,
88+
// reused across chunks.
8689
MemorySegment out = ctx.arena().allocate(rowCount * elemBytes);
8790
long[] chunkBases = new long[lanes];
8891
long[] chunkDeltas = new long[FastLanes.CHUNK];
8992
long[] chunkUndelta = new long[FastLanes.CHUNK];
90-
long[] untransposed = new long[FastLanes.CHUNK];
9193

9294
// Each chunk carries its own lane bases, so chunks are independent and only those
9395
// overlapping the requested window are reconstructed — reading the tail of a long
@@ -99,16 +101,65 @@ public Array decode(DecodeContext ctx) {
99101
readElements(basesSeg, ptype, basesCap, chunk * lanes, lanes, chunkBases);
100102
readElements(deltasSeg, ptype, deltasCap, chunk * FastLanes.CHUNK, FastLanes.CHUNK, chunkDeltas);
101103
undeltaChunk(chunkDeltas, chunkBases, lanes, typeBits, mask, chunkUndelta);
102-
for (int i = 0; i < FastLanes.CHUNK; i++) {
103-
untransposed[FastLanes.transposeIndex(i)] = chunkUndelta[i];
104+
scatterChunk(out, ptype, chunkUndelta, chunk * FastLanes.CHUNK - offset, rowCount);
105+
}
106+
return array(ctx, ptype, rowCount, out.asReadOnly());
107+
}
108+
109+
/// Untransposes one chunk straight into the output window.
110+
///
111+
/// The value at in-chunk position `i` belongs at logical index
112+
/// `base + FastLanes#transposeIndex(i)`, so untransposing and window-shifting happen in the
113+
/// same store — no second chunk-sized buffer, and no separate pass to slice it.
114+
///
115+
/// `base` is negative for the leading chunk of an offset-sliced array, and the trailing
116+
/// chunk can run past `rowCount`. One unsigned comparison covers both: a negative index
117+
/// reads as a huge unsigned value and fails the same test as an overrun. The stores are a
118+
/// permutation scatter, so they never vectorize regardless, and the compare costs nothing
119+
/// the untranspose was not already paying. The ptype switch is hoisted out of the loop so
120+
/// each body stays uniform (CLAUDE.md hot-loop rule).
121+
///
122+
/// @param out output segment of `rowCount` elements
123+
/// @param ptype output element type
124+
/// @param values one chunk of reconstructed values, in transposed order
125+
/// @param base output index the chunk's logical position 0 maps to; may be negative
126+
/// @param rowCount number of rows in the output window
127+
private static void scatterChunk(MemorySegment out, PType ptype, long[] values, long base, long rowCount) {
128+
switch (ptype) {
129+
case I8, U8 -> {
130+
for (int i = 0; i < FastLanes.CHUNK; i++) {
131+
long at = base + FastLanes.transposeIndex(i);
132+
if (Long.compareUnsigned(at, rowCount) < 0) {
133+
out.set(ValueLayout.JAVA_BYTE, at, (byte) values[i]);
134+
}
135+
}
104136
}
105-
// The window clips only the first and last chunk; the ones between copy whole.
106-
long chunkStart = chunk * FastLanes.CHUNK;
107-
int from = (int) Math.max(0, offset - chunkStart);
108-
int to = (int) Math.min(FastLanes.CHUNK, offset + rowCount - chunkStart);
109-
writeElements(out, ptype, chunkStart + from - offset, untransposed, from, to - from);
137+
case I16, U16 -> {
138+
for (int i = 0; i < FastLanes.CHUNK; i++) {
139+
long at = base + FastLanes.transposeIndex(i);
140+
if (Long.compareUnsigned(at, rowCount) < 0) {
141+
out.setAtIndex(VortexFormat.LE_SHORT, at, (short) values[i]);
142+
}
143+
}
144+
}
145+
case I32, U32 -> {
146+
for (int i = 0; i < FastLanes.CHUNK; i++) {
147+
long at = base + FastLanes.transposeIndex(i);
148+
if (Long.compareUnsigned(at, rowCount) < 0) {
149+
out.setAtIndex(VortexFormat.LE_INT, at, (int) values[i]);
150+
}
151+
}
152+
}
153+
case I64, U64 -> {
154+
for (int i = 0; i < FastLanes.CHUNK; i++) {
155+
long at = base + FastLanes.transposeIndex(i);
156+
if (Long.compareUnsigned(at, rowCount) < 0) {
157+
out.setAtIndex(VortexFormat.LE_LONG, at, values[i]);
158+
}
159+
}
160+
}
161+
default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype);
110162
}
111-
return array(ctx, ptype, rowCount, out);
112163
}
113164

114165
/// Wraps a decoded segment in the `Materialized*Array` matching `ptype`.
@@ -159,7 +210,7 @@ private static void undeltaChunk(long[] deltas, long[] bases, int lanes, int typ
159210
private static void readElements(MemorySegment buf, PType ptype, long cap, long firstIdx,
160211
int count, long[] out) {
161212
if (firstIdx + count <= cap) {
162-
readContiguous(buf, ptype, firstIdx * ptype.byteSize(), count, out);
213+
readContiguous(buf, ptype, firstIdx, count, out);
163214
return;
164215
}
165216
if (cap == 0) {
@@ -169,41 +220,41 @@ private static void readElements(MemorySegment buf, PType ptype, long cap, long
169220
readBroadcast(buf, ptype, cap, firstIdx, count, out);
170221
}
171222

172-
private static void readContiguous(MemorySegment buf, PType ptype, long base, int count, long[] out) {
223+
private static void readContiguous(MemorySegment buf, PType ptype, long from, int count, long[] out) {
173224
switch (ptype) {
174225
case I8 -> {
175226
for (int i = 0; i < count; i++) {
176-
out[i] = buf.get(ValueLayout.JAVA_BYTE, base + i);
227+
out[i] = buf.get(ValueLayout.JAVA_BYTE, from + i);
177228
}
178229
}
179230
case U8 -> {
180231
for (int i = 0; i < count; i++) {
181-
out[i] = Byte.toUnsignedLong(buf.get(ValueLayout.JAVA_BYTE, base + i));
232+
out[i] = Byte.toUnsignedLong(buf.get(ValueLayout.JAVA_BYTE, from + i));
182233
}
183234
}
184235
case I16 -> {
185236
for (int i = 0; i < count; i++) {
186-
out[i] = buf.get(VortexFormat.LE_SHORT, base + i * 2L);
237+
out[i] = buf.getAtIndex(VortexFormat.LE_SHORT, from + i);
187238
}
188239
}
189240
case U16 -> {
190241
for (int i = 0; i < count; i++) {
191-
out[i] = Short.toUnsignedLong(buf.get(VortexFormat.LE_SHORT, base + i * 2L));
242+
out[i] = Short.toUnsignedLong(buf.getAtIndex(VortexFormat.LE_SHORT, from + i));
192243
}
193244
}
194245
case I32 -> {
195246
for (int i = 0; i < count; i++) {
196-
out[i] = buf.get(VortexFormat.LE_INT, base + i * 4L);
247+
out[i] = buf.getAtIndex(VortexFormat.LE_INT, from + i);
197248
}
198249
}
199250
case U32 -> {
200251
for (int i = 0; i < count; i++) {
201-
out[i] = Integer.toUnsignedLong(buf.get(VortexFormat.LE_INT, base + i * 4L));
252+
out[i] = Integer.toUnsignedLong(buf.getAtIndex(VortexFormat.LE_INT, from + i));
202253
}
203254
}
204255
case I64, U64 -> {
205256
for (int i = 0; i < count; i++) {
206-
out[i] = buf.get(VortexFormat.LE_LONG, base + i * 8L);
257+
out[i] = buf.getAtIndex(VortexFormat.LE_LONG, from + i);
207258
}
208259
}
209260
default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype);
@@ -233,41 +284,5 @@ private static long readOne(MemorySegment buf, PType ptype, long off) {
233284
};
234285
}
235286

236-
/// Writes `src[from, from + count)` into `out` at element index `dstIdx`, narrowed to
237-
/// `ptype`'s width. The ptype switch is hoisted out of the loop so each body stays uniform.
238-
///
239-
/// @param out destination segment, at `ptype`'s width
240-
/// @param ptype element type
241-
/// @param dstIdx element index in `out` to write the first value at
242-
/// @param src reconstructed values, widened to `long`
243-
/// @param from first index in `src` to write
244-
/// @param count number of elements to write
245-
/// @throws VortexException if `ptype` is not an integer ptype
246-
private static void writeElements(MemorySegment out, PType ptype, long dstIdx, long[] src,
247-
int from, int count) {
248-
switch (ptype) {
249-
case I8, U8 -> {
250-
for (int i = 0; i < count; i++) {
251-
out.set(ValueLayout.JAVA_BYTE, dstIdx + i, (byte) src[from + i]);
252-
}
253-
}
254-
case I16, U16 -> {
255-
for (int i = 0; i < count; i++) {
256-
out.setAtIndex(VortexFormat.LE_SHORT, dstIdx + i, (short) src[from + i]);
257-
}
258-
}
259-
case I32, U32 -> {
260-
for (int i = 0; i < count; i++) {
261-
out.setAtIndex(VortexFormat.LE_INT, dstIdx + i, (int) src[from + i]);
262-
}
263-
}
264-
case I64, U64 -> {
265-
for (int i = 0; i < count; i++) {
266-
out.setAtIndex(VortexFormat.LE_LONG, dstIdx + i, src[from + i]);
267-
}
268-
}
269-
default -> throw new VortexException(EncodingId.FASTLANES_DELTA, "unsupported ptype: " + ptype);
270-
}
271-
}
272287

273288
}

reader/src/test/java/io/github/dfa1/vortex/reader/decode/DeltaEncodingDecoderTest.java

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,23 @@
33
import io.github.dfa1.vortex.core.error.VortexException;
44
import io.github.dfa1.vortex.core.model.DType;
55
import io.github.dfa1.vortex.core.model.PType;
6+
import io.github.dfa1.vortex.core.compute.FastLanes;
7+
import io.github.dfa1.vortex.core.compute.PrimitiveArrays;
68
import io.github.dfa1.vortex.core.model.EncodingId;
79
import io.github.dfa1.vortex.core.io.VortexFormat;
810
import io.github.dfa1.vortex.core.testing.TestSegments;
911
import io.github.dfa1.vortex.core.proto.ProtoDeltaMetadata;
1012
import io.github.dfa1.vortex.reader.ReadRegistry;
1113
import io.github.dfa1.vortex.reader.array.Array;
14+
import io.github.dfa1.vortex.reader.array.ByteArray;
15+
import io.github.dfa1.vortex.reader.array.IntArray;
1216
import io.github.dfa1.vortex.reader.array.LongArray;
17+
import io.github.dfa1.vortex.reader.array.ShortArray;
1318
import org.junit.jupiter.api.Test;
1419
import org.junit.jupiter.params.ParameterizedTest;
1520
import org.junit.jupiter.params.provider.CsvSource;
1621
import org.junit.jupiter.params.provider.EnumSource;
22+
import org.junit.jupiter.params.provider.ValueSource;
1723

1824
import java.lang.foreign.Arena;
1925
import java.lang.foreign.MemorySegment;
@@ -51,6 +57,171 @@ void decode_nullMetadata_returnsEmptyArray(PType ptype) {
5157
assertThat(result.length()).isZero();
5258
}
5359

60+
/// Round-trips a known sequence through the wire form, for every integer width. The values
61+
/// step by a per-lane amount so the prefix sum is non-trivial and a lane mix-up shows up.
62+
///
63+
/// The previous decoder staged this through four row-scaled heap `long[]`s (bases, deltas,
64+
/// a full-length `decoded`, and a `result` slice of it), widening every value to 8 bytes
65+
/// whatever the column's width; values now go straight into one arena segment at the
66+
/// column's own width (#338). The reconstruction is what must not change.
67+
@ParameterizedTest
68+
@EnumSource(value = PType.class, names = {"I8", "I16", "I32", "I64", "U8", "U16", "U32", "U64"})
69+
void decode_roundTripsASingleChunk(PType ptype) {
70+
// Given — 1024 values, small enough to survive I8's 8-bit width
71+
long[] values = new long[FL_CHUNK_SIZE];
72+
for (int i = 0; i < values.length; i++) {
73+
values[i] = (i * 3) & 0x3F;
74+
}
75+
76+
// When
77+
LongArray result = decodeDelta(ptype, values, 0, values.length);
78+
79+
// Then
80+
assertValues(result, values, 0, values.length);
81+
}
82+
83+
/// Multi-chunk: the per-chunk scratch is reused across iterations, so a chunk boundary is
84+
/// where a stale-scratch or wrong-base bug would surface. Two chunks plus a partial third.
85+
@Test
86+
void decode_roundTripsAcrossChunkBoundaries() {
87+
// Given
88+
long[] values = new long[FL_CHUNK_SIZE * 2 + 100];
89+
for (int i = 0; i < values.length; i++) {
90+
values[i] = i * 7L;
91+
}
92+
93+
// When
94+
LongArray result = decodeDelta(PType.I64, values, 0, values.length);
95+
96+
// Then
97+
assertValues(result, values, 0, values.length);
98+
}
99+
100+
/// A non-zero `offset` slices the decoded values, and nothing covered it before. It is the
101+
/// sharp edge of writing chunks straight into the output: the leading chunk now maps to a
102+
/// negative output index and the trailing chunk runs past the row count, both of which the
103+
/// scatter has to drop rather than write out of bounds. The encoder always emits offset 0,
104+
/// so this shape only arrives from a sliced array written elsewhere.
105+
@ParameterizedTest
106+
@ValueSource(ints = {1, 7, 1023, 1024, 1025, 2000})
107+
void decode_offsetSlicesTheWindow(int offset) {
108+
// Given
109+
long[] values = new long[FL_CHUNK_SIZE * 3];
110+
for (int i = 0; i < values.length; i++) {
111+
values[i] = i * 11L;
112+
}
113+
int rowCount = 500;
114+
115+
// When
116+
LongArray result = decodeDelta(PType.I64, values, offset, rowCount);
117+
118+
// Then — rows are values[offset .. offset + rowCount)
119+
assertValues(result, values, offset, rowCount);
120+
}
121+
122+
/// The window may stop short of the chunk it lands in, so the trailing chunk is only
123+
/// partially written. Rows past `rowCount` must not be stored at all.
124+
@Test
125+
void decode_rowCountShorterThanTheDecodedLength() {
126+
// Given
127+
long[] values = new long[FL_CHUNK_SIZE * 2];
128+
for (int i = 0; i < values.length; i++) {
129+
values[i] = i * 5L;
130+
}
131+
132+
// When
133+
LongArray result = decodeDelta(PType.I64, values, 0, 3);
134+
135+
// Then
136+
assertThat(result.length()).isEqualTo(3L);
137+
assertValues(result, values, 0, 3);
138+
}
139+
140+
private static void assertValues(LongArray actual, long[] expected, int offset, int count) {
141+
assertThat(actual.length()).isEqualTo((long) count);
142+
for (int i = 0; i < count; i++) {
143+
assertThat(actual.getLong(i)).as("row %d", i).isEqualTo(expected[offset + i]);
144+
}
145+
}
146+
147+
/// Decodes `values` through the `fastlanes.delta` wire form, mirroring
148+
/// `DeltaEncodingEncoder`'s transpose-then-per-lane-delta layout. Built here rather than
149+
/// called: the writer module is not on the reader's test classpath, and the encoder never
150+
/// emits a non-zero `offset`, which is precisely the case worth covering.
151+
private static LongArray decodeDelta(PType ptype, long[] values, int offset, int rowCount) {
152+
int lanes = FastLanes.lanes(ptype);
153+
int typeBits = ptype.bits();
154+
long mask = FastLanes.lowMask(typeBits);
155+
int numChunks = (values.length + FastLanes.CHUNK - 1) / FastLanes.CHUNK;
156+
long paddedLen = (long) numChunks * FastLanes.CHUNK;
157+
158+
long[] basesAll = new long[numChunks * lanes];
159+
long[] deltasAll = new long[(int) paddedLen];
160+
long[] transposed = new long[FastLanes.CHUNK];
161+
162+
for (int chunk = 0; chunk < numChunks; chunk++) {
163+
long[] chunkBuf = new long[FastLanes.CHUNK];
164+
int start = chunk * FastLanes.CHUNK;
165+
int end = Math.min(start + FastLanes.CHUNK, values.length);
166+
for (int i = start; i < end; i++) {
167+
chunkBuf[i - start] = values[i] & mask;
168+
}
169+
for (int i = 0; i < FastLanes.CHUNK; i++) {
170+
transposed[i] = chunkBuf[FastLanes.transposeIndex(i)];
171+
}
172+
System.arraycopy(transposed, 0, basesAll, chunk * lanes, lanes);
173+
for (int lane = 0; lane < lanes; lane++) {
174+
long prev = basesAll[chunk * lanes + lane] & mask;
175+
for (int row = 0; row < typeBits; row++) {
176+
int idx = FastLanes.iterateIndex(row, lane);
177+
long next = transposed[idx] & mask;
178+
deltasAll[chunk * FastLanes.CHUNK + idx] = (next - prev) & mask;
179+
prev = next;
180+
}
181+
}
182+
}
183+
184+
MemorySegment meta = MemorySegment.ofArray(new ProtoDeltaMetadata(paddedLen, offset).encode());
185+
ArrayNode bases = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null, new ArrayNode[0], new int[]{0});
186+
ArrayNode deltas = new ArrayNode(EncodingId.VORTEX_PRIMITIVE, null, new ArrayNode[0], new int[]{1});
187+
ArrayNode node = new ArrayNode(EncodingId.FASTLANES_DELTA, meta, new ArrayNode[]{bases, deltas}, new int[0]);
188+
189+
MemorySegment[] segs = {toSegment(basesAll, ptype), toSegment(deltasAll, ptype)};
190+
DecodeContext ctx = new DecodeContext(node, new DType.Primitive(ptype, false), rowCount, segs,
191+
REGISTRY, Arena.ofAuto());
192+
Array decoded = SUT.decode(ctx);
193+
return new WidenedLongView(decoded);
194+
}
195+
196+
private static MemorySegment toSegment(long[] longs, PType ptype) {
197+
return PrimitiveArrays.fromLongs(longs, ptype, Arena.ofAuto());
198+
}
199+
200+
/// Reads any narrow decoded array as `long` so one assertion helper covers every width.
201+
private record WidenedLongView(Array inner) implements LongArray {
202+
203+
@Override
204+
public DType dtype() {
205+
return inner.dtype();
206+
}
207+
208+
@Override
209+
public long length() {
210+
return inner.length();
211+
}
212+
213+
@Override
214+
public long getLong(long i) {
215+
return switch (inner) {
216+
case ByteArray ba -> ba.getInt(i);
217+
case ShortArray sa -> sa.getInt(i);
218+
case IntArray ia -> ia.getInt(i);
219+
case LongArray la -> la.getLong(i);
220+
default -> throw new IllegalStateException("unexpected array type " + inner.getClass());
221+
};
222+
}
223+
}
224+
54225
@Test
55226
void decode_constantChildren_broadcastsAcrossChunk() {
56227
// Given a single delta chunk (1024 rows) whose bases and deltas children each hold

0 commit comments

Comments
 (0)