Skip to content

Commit 5efca29

Browse files
dfa1claude
andcommitted
feat(zstd): multi-frame encode
Add ZstdEncodingEncoder(valuesPerFrame): split the payload into independently compressed zstd frames of valuesPerFrame values each (the last frame holds the remainder), emitting one ZstdFrameMetadata per frame so a slice scan can decompress only the frames overlapping its row range. The no-arg constructor keeps the single-frame behaviour. Frame boundaries fall on value boundaries: fixed stride for primitives, length-prefix walk for varbin. Works for the non-nullable and nullable (frames over packed valid values, validity child trailing the frame buffers) paths alike. The decoder already iterates frames, so this is encode-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ef97417 commit 5efca29

5 files changed

Lines changed: 225 additions & 48 deletions

File tree

CHANGELOG.md

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

1212
- `DType.isUnsigned()``true` for the unsigned integer primitives (`U8``U64`), `false` otherwise. ([#159](https://github.com/dfa1/vortex-java/issues/159))
1313
- The `vortex.zstd` encoder now writes nullable columns (primitive and utf8/binary): null positions are stripped before compression and validity is emitted as a Bool child, matching the Rust reference layout. When `vortex.zstd` is the configured encoder, nullable primitive columns route to it directly instead of being wrapped in `vortex.masked`.
14+
- `new ZstdEncodingEncoder(valuesPerFrame)` splits the payload into independently compressed frames of `valuesPerFrame` values each (one `ZstdFrameMetadata` per frame), letting a slice scan decompress only the frames overlapping its row range. The no-arg constructor still emits a single frame. ([#170](https://github.com/dfa1/vortex-java/pull/170))
1415

1516
### Changed
1617

TODO.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,3 @@ Per-encoding gotchas:
101101

102102
See [docs/compatibility.md](docs/compatibility.md) for the full encoding support table and S3 fixture status.
103103

104-
### `vortex.zstd` known limitations
105-
106-
- [ ] **Multi-frame encode**`ZstdEncoding.Encoder` always produces a single frame for the whole array.
107-
Fix: accept a `valuesPerFrame` parameter (default: all values in one frame). Split the raw byte buffer at frame
108-
boundaries (`valuesPerFrame * byteWidth`), compress each slice independently, emit one `ZstdFrameMetadata` per frame.
109-
Enables partial decompression during slice scans.
110-

integration/src/test/java/io/github/dfa1/vortex/integration/JavaWritesRustReadsIntegrationTest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,6 +1209,25 @@ void javaWriter_rustReader_zstd_utf8(@TempDir Path tmp) throws IOException {
12091209
assertThat(decoded).containsExactly(data);
12101210
}
12111211

1212+
@Test
1213+
void javaWriter_rustReader_zstd_multiFrameI64(@TempDir Path tmp) throws IOException {
1214+
// Given — ZstdEncoding split into frames of 3 values: 7 values -> 3 frames (3, 3, 1), each
1215+
// an independently compressed zstd frame with its own ZstdFrameMetadata. Verifies the
1216+
// multi-frame wire layout against the Rust reader.
1217+
Path file = tmp.resolve("java_zstd_multiframe_i64.vtx");
1218+
long[] data = {1L, 2L, 3L, 4L, 5L, 6L, 7L};
1219+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
1220+
var sut = VortexWriter.create(ch, TS_SCHEMA, WriteOptions.defaults(),
1221+
List.of(new ZstdEncodingEncoder(3)))) {
1222+
// When
1223+
sut.writeChunk(Map.of("ts", data));
1224+
}
1225+
1226+
// Then
1227+
long[] decoded = readLongColumn(file, "ts");
1228+
assertThat(decoded).containsExactly(data);
1229+
}
1230+
12121231
@Test
12131232
void javaWriter_rustReader_zstd_nullableI64(@TempDir Path tmp) throws IOException {
12141233
// Given — nullable primitive I64 written with ZstdEncoding. A configured zstd encoder

writer/src/main/java/io/github/dfa1/vortex/writer/encode/ZstdEncodingEncoder.java

Lines changed: 123 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,28 @@
1717
import java.util.List;
1818

1919
/// Write-only encoder for `vortex.zstd`.
20+
///
21+
/// By default the whole array compresses into a single zstd frame. Construct with a positive
22+
/// `valuesPerFrame` to split the payload into independently compressed frames of that many values
23+
/// each (the last frame holds the remainder), emitting one `ZstdFrameMetadata` per frame. Multiple
24+
/// frames let a slice scan decompress only the frames overlapping its row range.
2025
public final class ZstdEncodingEncoder implements EncodingEncoder {
2126

22-
/// Public no-arg constructor required by [java.util.ServiceLoader].
27+
/// Values per zstd frame; `0` (or any non-positive value) means a single frame for the whole array.
28+
private final long valuesPerFrame;
29+
30+
/// Public no-arg constructor required by [java.util.ServiceLoader]; compresses each array into
31+
/// a single frame.
2332
public ZstdEncodingEncoder() {
33+
this(0);
34+
}
35+
36+
/// Creates an encoder that splits the payload into frames of `valuesPerFrame` values each.
37+
///
38+
/// @param valuesPerFrame the number of values per zstd frame; non-positive means a single frame
39+
/// for the whole array
40+
public ZstdEncodingEncoder(long valuesPerFrame) {
41+
this.valuesPerFrame = valuesPerFrame;
2442
}
2543

2644
@Override
@@ -66,78 +84,142 @@ public EncodeResult encode(DType dtype, Object data, EncodeContext ctx) {
6684
throw new VortexException(EncodingId.VORTEX_ZSTD, "unsupported dtype: " + dtype);
6785
}
6886

69-
private static EncodeResult encodePrimitive(DType.Primitive dt, Object data, Arena arena) {
87+
private EncodeResult encodePrimitive(DType.Primitive dt, Object data, Arena arena) {
88+
int byteWidth = dt.ptype().byteSize();
7089
MemorySegment raw = primitiveToLeBytes(dt.ptype(), data, arena);
7190
long n = primitiveLength(dt.ptype(), data);
72-
return buildResult(raw, n, arena);
91+
return buildResult(raw, uniformLayout(n, byteWidth), arena);
7392
}
7493

75-
private static EncodeResult encodeVarBin(String[] strings, Arena arena) {
94+
private EncodeResult encodeVarBin(String[] strings, Arena arena) {
7695
MemorySegment raw = buildLengthPrefixed(strings, arena);
77-
return buildResult(raw, strings.length, arena);
96+
return buildResult(raw, varBinLayout(raw, strings.length), arena);
7897
}
7998

80-
private static EncodeResult buildResult(MemorySegment raw, long n, Arena arena) {
81-
// Zero-copy: compress the arena-native raw segment straight into another arena segment,
82-
// no heap byte[] bounce on either side. The compressed slice is owned by the caller arena.
83-
MemorySegment compressed;
84-
try (ZstdCompressCtx cctx = new ZstdCompressCtx()) {
85-
compressed = cctx.compress(arena, raw);
86-
}
87-
byte[] meta = new ProtoZstdMetadata(
88-
0,
89-
List.of(new ProtoZstdFrameMetadata(raw.byteSize(), n))
90-
).encode();
91-
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ZSTD, MemorySegment.ofArray(meta),
92-
new EncodeNode[0], new int[]{0});
93-
return new EncodeResult(root, List.of(compressed), null, null);
99+
private EncodeResult buildResult(MemorySegment raw, FrameLayout layout, Arena arena) {
100+
// Zero-copy: each frame is an arena-native slice of raw, compressed straight into another
101+
// arena segment. A single-value-per-array config yields one frame (the prior behaviour).
102+
Frames frames = compressFrames(raw, layout, arena);
103+
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ZSTD, MemorySegment.ofArray(frames.metadata()),
104+
new EncodeNode[0], frameBufferIndices(frames.compressed().size(), 0));
105+
return new EncodeResult(root, List.copyOf(frames.compressed()), null, null);
94106
}
95107

96-
private static EncodeResult encodeNullablePrimitive(DType.Primitive dt, NullableData nd, EncodeContext ctx) {
108+
private EncodeResult encodeNullablePrimitive(DType.Primitive dt, NullableData nd, EncodeContext ctx) {
97109
Arena arena = ctx.arena();
98110
int byteWidth = dt.ptype().byteSize();
99111
boolean[] validity = nd.validity();
100112
// Strip null positions: only valid values reach the compressed payload (mirrors the Rust
101113
// reference). The decoder scatters them back over the validity mask carried by child[0].
102114
MemorySegment full = primitiveToLeBytes(dt.ptype(), nd.values(), arena);
103115
MemorySegment packed = packValidBytes(full, validity, byteWidth, arena);
104-
return buildNullableResult(packed, countValid(validity), validity, ctx);
116+
return buildNullableResult(packed, uniformLayout(countValid(validity), byteWidth), validity, ctx);
105117
}
106118

107-
private static EncodeResult encodeNullableVarBin(NullableData nd, EncodeContext ctx) {
119+
private EncodeResult encodeNullableVarBin(NullableData nd, EncodeContext ctx) {
108120
// Strip null positions: only valid strings reach the compressed payload (mirrors the Rust
109121
// reference). The decoder scatters them back over the validity mask carried by child[0].
110122
String[] valid = stripNulls((String[]) nd.values());
111123
MemorySegment packed = buildLengthPrefixed(valid, ctx.arena());
112-
return buildNullableResult(packed, valid.length, nd.validity(), ctx);
124+
return buildNullableResult(packed, varBinLayout(packed, valid.length), nd.validity(), ctx);
113125
}
114126

115-
private static EncodeResult buildNullableResult(
116-
MemorySegment raw, long nValues, boolean[] validity, EncodeContext ctx) {
117-
// Zero-copy: compress the arena-native packed segment into another arena segment.
118-
MemorySegment compressed;
119-
try (ZstdCompressCtx cctx = new ZstdCompressCtx()) {
120-
compressed = cctx.compress(ctx.arena(), raw);
121-
}
122-
byte[] meta = new ProtoZstdMetadata(
123-
0,
124-
List.of(new ProtoZstdFrameMetadata(raw.byteSize(), nValues))
125-
).encode();
127+
private EncodeResult buildNullableResult(
128+
MemorySegment raw, FrameLayout layout, boolean[] validity, EncodeContext ctx) {
129+
Frames frames = compressFrames(raw, layout, ctx.arena());
130+
int frameCount = frames.compressed().size();
126131

127132
EncodeResult validityResult = new BoolEncodingEncoder().encode(DType.BOOL, validity, ctx);
128-
// The frame payload owns buffer[0]; the validity child's buffers follow, so shift its
129-
// buffer indices by one.
130-
EncodeNode validityNode = EncodeNode.remapBufferIndices(validityResult.rootNode(), 1);
133+
// The frame payloads own buffer[0..frameCount-1]; the validity child's buffers follow, so
134+
// shift its buffer indices past them.
135+
EncodeNode validityNode = EncodeNode.remapBufferIndices(validityResult.rootNode(), frameCount);
131136

132-
List<MemorySegment> buffers = new ArrayList<>(1 + validityResult.buffers().size());
133-
buffers.add(compressed);
137+
List<MemorySegment> buffers = new ArrayList<>(frameCount + validityResult.buffers().size());
138+
buffers.addAll(frames.compressed());
134139
buffers.addAll(validityResult.buffers());
135140

136-
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ZSTD, MemorySegment.ofArray(meta),
137-
new EncodeNode[]{validityNode}, new int[]{0});
141+
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ZSTD, MemorySegment.ofArray(frames.metadata()),
142+
new EncodeNode[]{validityNode}, frameBufferIndices(frameCount, 0));
138143
return new EncodeResult(root, buffers, null, null);
139144
}
140145

146+
/// Byte spans and value counts of each frame; spans sum to the payload size.
147+
private record FrameLayout(long[] byteLengths, long[] valueCounts) {
148+
}
149+
150+
/// Compressed frame payloads paired with the encoded `ZstdMetadata` describing them.
151+
private record Frames(List<MemorySegment> compressed, byte[] metadata) {
152+
}
153+
154+
private static int[] frameBufferIndices(int frameCount, int base) {
155+
int[] indices = new int[frameCount];
156+
for (int i = 0; i < frameCount; i++) {
157+
indices[i] = base + i;
158+
}
159+
return indices;
160+
}
161+
162+
/// Frame layout for `n` fixed-width values (`byteWidth` bytes each): `valuesPerFrame` values
163+
/// per frame, the last frame holding the remainder. One frame when framing is disabled.
164+
private FrameLayout uniformLayout(long n, int byteWidth) {
165+
if (valuesPerFrame <= 0 || n <= valuesPerFrame) {
166+
return new FrameLayout(new long[]{n * byteWidth}, new long[]{n});
167+
}
168+
int frameCount = (int) ((n + valuesPerFrame - 1) / valuesPerFrame);
169+
long[] byteLengths = new long[frameCount];
170+
long[] valueCounts = new long[frameCount];
171+
long remaining = n;
172+
for (int f = 0; f < frameCount; f++) {
173+
long count = Math.min(valuesPerFrame, remaining);
174+
valueCounts[f] = count;
175+
byteLengths[f] = count * byteWidth;
176+
remaining -= count;
177+
}
178+
return new FrameLayout(byteLengths, valueCounts);
179+
}
180+
181+
/// Frame layout for a length-prefixed varbin payload: `valuesPerFrame` values per frame, with
182+
/// each frame's byte span found by walking the 4-byte length prefixes to a value boundary.
183+
private FrameLayout varBinLayout(MemorySegment raw, long nValues) {
184+
if (valuesPerFrame <= 0 || nValues <= valuesPerFrame) {
185+
return new FrameLayout(new long[]{raw.byteSize()}, new long[]{nValues});
186+
}
187+
int frameCount = (int) ((nValues + valuesPerFrame - 1) / valuesPerFrame);
188+
long[] byteLengths = new long[frameCount];
189+
long[] valueCounts = new long[frameCount];
190+
long pos = 0;
191+
long valueIdx = 0;
192+
for (int f = 0; f < frameCount; f++) {
193+
long count = Math.min(valuesPerFrame, nValues - valueIdx);
194+
long start = pos;
195+
for (long k = 0; k < count; k++) {
196+
int len = raw.get(PTypeIO.LE_INT, pos);
197+
pos += 4L + len;
198+
}
199+
byteLengths[f] = pos - start;
200+
valueCounts[f] = count;
201+
valueIdx += count;
202+
}
203+
return new FrameLayout(byteLengths, valueCounts);
204+
}
205+
206+
private static Frames compressFrames(MemorySegment raw, FrameLayout layout, Arena arena) {
207+
int frameCount = layout.byteLengths().length;
208+
List<MemorySegment> compressed = new ArrayList<>(frameCount);
209+
List<ProtoZstdFrameMetadata> metas = new ArrayList<>(frameCount);
210+
long offset = 0;
211+
try (ZstdCompressCtx cctx = new ZstdCompressCtx()) {
212+
for (int f = 0; f < frameCount; f++) {
213+
long len = layout.byteLengths()[f];
214+
compressed.add(cctx.compress(arena, raw.asSlice(offset, len)));
215+
metas.add(new ProtoZstdFrameMetadata(len, layout.valueCounts()[f]));
216+
offset += len;
217+
}
218+
}
219+
byte[] metadata = new ProtoZstdMetadata(0, List.copyOf(metas)).encode();
220+
return new Frames(compressed, metadata);
221+
}
222+
141223
private static MemorySegment packValidBytes(
142224
MemorySegment full, boolean[] validity, int byteWidth, Arena arena) {
143225
long validBytes = (long) countValid(validity) * byteWidth;

writer/src/test/java/io/github/dfa1/vortex/writer/encode/ZstdEncodingEncoderTest.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,4 +427,86 @@ void encode_i32_metadata_framesCount_isNonZero() throws Exception {
427427
assertThat(meta.frames()).isNotEmpty();
428428
}
429429
}
430+
431+
@Nested
432+
class MultiFrame {
433+
434+
private static final ZstdEncodingEncoder FRAMED = new ZstdEncodingEncoder(4);
435+
436+
@Test
437+
void encode_i32_splitsIntoFrames_andRoundTrips() throws Exception {
438+
// Given — 10 values, 4 per frame: 3 frames (4, 4, 2), one compressed buffer each.
439+
int[] data = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
440+
441+
// When
442+
EncodeResult result = FRAMED.encode(DTypes.I32, data, EncodeTestHelper.testCtx());
443+
444+
// Then
445+
var metaSeg = result.rootNode().metadata();
446+
ProtoZstdMetadata meta = ProtoZstdMetadata.decode(metaSeg, 0, metaSeg.byteSize());
447+
assertThat(meta.frames()).hasSize(3);
448+
assertThat(meta.frames().get(0).n_values()).isEqualTo(4);
449+
assertThat(meta.frames().get(2).n_values()).isEqualTo(2);
450+
assertThat(result.buffers()).hasSize(3);
451+
452+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(result, data.length, DTypes.I32, ReadRegistry.empty());
453+
IntArray decoded = (IntArray) DECODER.decode(ctx);
454+
for (int i = 0; i < data.length; i++) {
455+
assertThat(decoded.getInt(i)).as("index %d", i).isEqualTo(data[i]);
456+
}
457+
}
458+
459+
@Test
460+
void encode_varBin_splitsOnValueBoundaries_andRoundTrips() throws Exception {
461+
// Given — 5 strings, 2 per frame: 3 frames (2, 2, 1). Entries vary in length, so the
462+
// frame byte spans must be found by walking the length prefixes, not a fixed stride.
463+
ZstdEncodingEncoder framedByTwo = new ZstdEncodingEncoder(2);
464+
String[] data = {"a", "bb", "ccc", "d", "eeeee"};
465+
466+
// When
467+
EncodeResult result = framedByTwo.encode(DTypes.UTF8, data, EncodeTestHelper.testCtx());
468+
469+
// Then
470+
var metaSeg = result.rootNode().metadata();
471+
ProtoZstdMetadata meta = ProtoZstdMetadata.decode(metaSeg, 0, metaSeg.byteSize());
472+
assertThat(meta.frames()).hasSize(3);
473+
474+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(result, data.length, DTypes.UTF8, ReadRegistry.empty());
475+
VarBinArray decoded = (VarBinArray) DECODER.decode(ctx);
476+
for (int i = 0; i < data.length; i++) {
477+
assertThat(decoded.getString(i)).as("index %d", i).isEqualTo(data[i]);
478+
}
479+
}
480+
481+
@Test
482+
void encode_nullablePrimitive_framesOverValidValues_andRoundTrips() throws Exception {
483+
// Given — 7 rows, 5 valid. Frames cover only the packed valid values (4 + 1), and the
484+
// validity child's buffers must trail the two frame buffers.
485+
int[] storage = {10, 0, 20, 30, 0, 40, 50};
486+
boolean[] validity = {true, false, true, true, false, true, true};
487+
DType i32Nullable = new DType.Primitive(PType.I32, true);
488+
NullableData data = new NullableData(storage, validity);
489+
490+
// When
491+
EncodeResult result = FRAMED.encode(i32Nullable, data, EncodeTestHelper.testCtx());
492+
493+
// Then
494+
var metaSeg = result.rootNode().metadata();
495+
ProtoZstdMetadata meta = ProtoZstdMetadata.decode(metaSeg, 0, metaSeg.byteSize());
496+
assertThat(meta.frames()).hasSize(2);
497+
assertThat(meta.frames().get(0).n_values()).isEqualTo(4);
498+
assertThat(meta.frames().get(1).n_values()).isEqualTo(1);
499+
500+
DecodeContext ctx = DecodeTestHelper.toDecodeContext(
501+
result, validity.length, i32Nullable, TestRegistry.ofDecoders(new BoolEncodingDecoder()));
502+
MaskedArray decoded = (MaskedArray) DECODER.decode(ctx);
503+
assertThat(decoded.length()).isEqualTo(7);
504+
assertThat(decoded.isValid(1)).isFalse();
505+
assertThat(decoded.isValid(4)).isFalse();
506+
IntArray child = (IntArray) decoded.inner();
507+
assertThat(child.getInt(0)).isEqualTo(10);
508+
assertThat(child.getInt(2)).isEqualTo(20);
509+
assertThat(child.getInt(6)).isEqualTo(50);
510+
}
511+
}
430512
}

0 commit comments

Comments
 (0)