Skip to content

Commit 032f371

Browse files
dfa1claude
andcommitted
feat(encoding): cascading compressor with ALP+Bitpacked, FoR+Bitpacked, Dict+Bitpacked
Implements BtrBlocks-style cascading compression (TODO #13): - CompressorContext, ChildSlot, CascadeStep infrastructure - Encoding.encodeCascade default + overrides in AlpEncoding, FoR, DictEncoding - CascadingCompressor: sample-based winner selection + buffer splicing - WriteOptions.cascading(depth) factory; VortexWriter branches at depth>0 - DictEncoding.decodeLegacyJava updated to decode codes via registry (required for bitpacked codes in cascade format) - VortexVsCsvFileSizeTest: assert Vortex < CSV at depth=2 Result: 100K-row OHLC Vortex=4.3MB vs CSV=4.6MB (1.08× ratio) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6d0e785 commit 032f371

13 files changed

Lines changed: 722 additions & 130 deletions

File tree

TODO.md

Lines changed: 8 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -16,90 +16,14 @@
1616
- Capture JMH JSON + JFR profile alongside README table; cite hardware (CPU model), JDK build (`java -version`),
1717
and benchmark commit SHA so numbers don't rot silently.
1818

19-
- [ ] **#13 BtrBlocks-style cascading compressor** (`core`, `writer`)
20-
- Reference: https://vortex.dev/blog/btrblocks-compressor
21-
- Rust source: `vortex-sampling-compressor` crate in spiraldb/vortex
22-
- **Scope (first cut)**: integers + floats only. Strings stay on current first-match.
23-
- **Rollout**: opt-in via `WriteOptions.cascading(int allowedCascading)`. Default `allowedCascading=0`
24-
preserves today's `findEncoding()` first-match behaviour so existing tests are unaffected.
25-
- Exclusion rule: don't apply dict encode to dict encode, FoR to FoR, etc. — encoding ids exclude
26-
themselves on recursion via `CompressorContext.excluded`.
27-
28-
**Sub-tasks**
29-
30-
- [ ] **a. `CompressorContext` record** (`core/encoding`)
31-
- Fields: `int allowedCascading`, `Set<EncodingId> excluded`, `long sampleSeed`,
32-
`int minSampleSize` (default 1024), `double sampleFraction` (default 0.01).
33-
- Immutable; `withDecrementedDepth()` and `withExcluded(EncodingId)` helpers.
34-
35-
- [ ] **b. Cascade-aware encode API** (`core/encoding/Encoding.java`)
36-
- Add default method:
37-
```java
38-
default CascadeStep encodeCascade(DType dtype, Object data, CompressorContext ctx) {
39-
EncodeResult r = encode(dtype, data);
40-
return CascadeStep.terminal(r);
41-
}
42-
```
43-
- New record `CascadeStep(EncodeNode partialRoot, List<ByteBuffer> ownedBuffers,
44-
List<ChildSlot> openChildren, byte[] statsMin, byte[] statsMax)`.
45-
- `ChildSlot(DType childDtype, Object childData, int parentChildIdx)` — slot the
46-
compressor recursively encodes, then splices result into `partialRoot.children[parentChildIdx]`
47-
while remapping its `bufferIndices` to follow `ownedBuffers`.
48-
- Encodings that don't expose intermediates use the default (terminal); they get tried as
49-
leaf candidates only.
50-
51-
- [ ] **c. Refactor cascading-friendly encodings to emit children**
52-
- `AlpEncoding`: F64 → `(metadata{expE,expF,patches}, child long[] encodedInts)` +
53-
optional patch children. Today's hard-coded `EncodeNode.leaf(VORTEX_PRIMITIVE, 0)` for
54-
the I64 child becomes an open `ChildSlot(I64, encodedArr)`.
55-
- `DictEncoding`: codes child (currently emitted as `VORTEX_PRIMITIVE` leaf) becomes an
56-
open `ChildSlot(unsigned int, codes[])` so cascade can bit-pack it. Values child stays
57-
primitive (or recurses too if `allowedCascading>0`).
58-
- `FrameOfReferenceEncoding`: **implement encode** (currently decode-only, throws on encode).
59-
Produces `(reference, child long[] deltas)` → `ChildSlot(I64, deltas)`. Pure intermediate;
60-
requires `allowedCascading>0` because raw FoR without downstream pack is no win.
61-
- `DeltaEncoding`: refactor to emit `(bases child, deltas child)` matching Rust's 2-child
62-
wire format (currently 1-buffer flat — incompatible with Rust reader, see commit 09685a2).
63-
Blocks cross-compat for delta cascades; track as sub-task c.4, can land separately.
64-
- `BitpackedEncoding`: stays terminal (no open children). Patch indices/values already handled.
65-
- `PrimitiveEncoding`: terminal.
66-
67-
- [ ] **d. `CascadingCompressor`** (`core/encoding`)
68-
- Constructor: `(List<Encoding> encodings, CompressorContext rootCtx)`.
69-
- `EncodeResult encode(DType dtype, Object data)` algorithm:
70-
1. Build sample: stratified contiguous slices, total ≈ `max(minSampleSize,
71-
sampleFraction * n)`. Fixed-seed `Random` for reproducibility.
72-
2. Build candidate list: every encoding where `accepts(dtype)` and id ∉ `ctx.excluded`.
73-
3. For each candidate: invoke `encodeCascade(dtype, sample, ctx)`. If the step has
74-
open children, recurse on each with `ctx.withDecrementedDepth().withExcluded(thisEncoding.id())`.
75-
Sum total byte size across all owned buffers.
76-
4. Pick winner by smallest total size; require ratio ≥ 1.0 vs raw primitive baseline,
77-
else fall through to `PrimitiveEncoding`.
78-
5. Re-run winner on **full** data (sample-time stats are estimates only).
79-
- Splicing: when a child recursion returns its own `EncodeResult`, append its buffers to
80-
the parent's flat list and remap the child's `bufferIndices` by the offset.
81-
- Terminal stop: when `ctx.allowedCascading == 0`, only encodings whose default-API
82-
`encodeCascade` returns no open children are considered.
83-
84-
- [ ] **e. `WriteOptions.cascading(int)`** (`writer/WriteOptions.java`)
85-
- Add field `int allowedCascading` (default `0`).
86-
- `WriteOptions.cascading(int n)` factory mirrors `defaults()`.
87-
- `VortexWriter.writeSegment` (writer/VortexWriter.java:212) branches:
88-
if `options.allowedCascading() > 0`, use `CascadingCompressor`; else current `findEncoding()`.
89-
90-
- [ ] **f. Tests** (`core` + `writer` + `integration`)
91-
- Unit: `CascadingCompressorTest` — sample stratification, exclusion list propagation,
92-
ratio-based selection, depth=0 terminates, depth>0 picks cascade.
93-
- Round-trip: each cascade combo (ALP+Bitpacked F64, FoR+Bitpacked I64, Dict+Bitpacked I32,
94-
Delta+Bitpacked I64) round-trips bit-exact through `VortexReader`.
95-
- Cross-compat: `JavaWritesRustReadsIntegrationTest` runs with `allowedCascading=3`,
96-
confirms Rust JNI reader decodes the same OHLC data (gates DeltaEncoding wire-format fix).
97-
- `WriteFileSizeIntegrationTest`: assert Java file ≤ JNI file size with cascade on.
98-
99-
- [ ] **g. Benchmark**
100-
- Extend `RustVsJavaWriteBenchmark` (TODO #10b) with cascading-off vs cascading-on variants.
101-
- Capture: ratio of compressed bytes (Java cascade / JNI), write throughput, decode
102-
throughput on the resulting file.
19+
- [ ] **#13 Cascading compressor — remaining follow-ups**
20+
- [ ] **DeltaEncoding wire-format fix**: refactor to emit `(bases child, deltas child)` matching
21+
Rust's 2-child wire format (currently 1-buffer flat — incompatible with Rust reader, see commit 09685a2).
22+
Unblocks cross-compat for delta cascades.
23+
- [ ] **Cross-compat test**: `JavaWritesRustReadsIntegrationTest` with `allowedCascading=3`
24+
confirms Rust JNI reader decodes the same OHLC data (gates DeltaEncoding wire-format fix).
25+
- [ ] **Benchmark**: extend `RustVsJavaWriteBenchmark` with cascading-off vs cascading-on variants;
26+
capture compressed-bytes ratio (Java cascade / JNI), write throughput, decode throughput.
10327

10428
## Tooling
10529

core/src/main/java/io/github/dfa1/vortex/encoding/AlpEncoding.java

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,21 @@ public EncodeResult encode(DType dtype, Object data) {
105105
throw new UnsupportedOperationException("ALP encode not yet supported for " + ptype);
106106
}
107107

108+
@Override
109+
public CascadeStep encodeCascade(DType dtype, Object data, CompressorContext ctx) {
110+
PType ptype = ((DType.Primitive) dtype).ptype();
111+
if (ptype == PType.F64) {
112+
return encodeCascadeF64((double[]) data);
113+
}
114+
return CascadeStep.terminal(encode(dtype, data));
115+
}
116+
108117
// ── F64 encode ────────────────────────────────────────────────────────────
109118

119+
private record AlpF64Data(int expE, int expF, long[] encodedArr,
120+
List<Integer> patchIndices, List<Double> patchValues,
121+
byte[] statsMin, byte[] statsMax) {}
122+
110123
private static int[] findExponentsF64(double[] values) {
111124
int sampleLen = Math.min(SAMPLE_SIZE, values.length);
112125
int bestExpE = 0, bestExpF = 0, bestExceptions = sampleLen + 1;
@@ -136,7 +149,7 @@ private static int[] findExponentsF64(double[] values) {
136149
return new int[]{bestExpE, bestExpF};
137150
}
138151

139-
private static EncodeResult encodeF64(double[] values, DType dtype) {
152+
private static AlpF64Data computeF64(double[] values) {
140153
int n = values.length;
141154
int[] exps = findExponentsF64(values);
142155
int expE = exps[0], expF = exps[1];
@@ -169,45 +182,92 @@ private static EncodeResult encodeF64(double[] values, DType dtype) {
169182

170183
byte[] statsMin = n > 0 ? scalarF64(min) : null;
171184
byte[] statsMax = n > 0 ? scalarF64(max) : null;
185+
return new AlpF64Data(expE, expF, encodedArr, patchIndices, patchValues, statsMin, statsMax);
186+
}
187+
188+
private static EncodeResult encodeF64(double[] values, DType dtype) {
189+
AlpF64Data d = computeF64(values);
190+
int n = values.length;
172191

173192
MemorySegment encodedBuf = Arena.ofAuto().allocate((long) n * 8, 8);
174193
for (int i = 0; i < n; i++) {
175-
encodedBuf.setAtIndex(PTypeIO.LE_LONG, i, encodedArr[i]);
194+
encodedBuf.setAtIndex(PTypeIO.LE_LONG, i, d.encodedArr()[i]);
176195
}
177196

178197
EncodeNode encodedNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 0);
179198

180-
if (patchIndices.isEmpty()) {
199+
if (d.patchIndices().isEmpty()) {
181200
byte[] metaBytes = EncodingProtos.ALPMetadata.newBuilder()
182-
.setExpE(expE).setExpF(expF).build().toByteArray();
201+
.setExpE(d.expE()).setExpF(d.expF()).build().toByteArray();
183202
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ALP,
184203
ByteBuffer.wrap(metaBytes), new EncodeNode[]{encodedNode}, new int[0]);
185-
return new EncodeResult(root, List.of(encodedBuf), statsMin, statsMax);
204+
return new EncodeResult(root, List.of(encodedBuf), d.statsMin(), d.statsMax());
186205
}
187206

188-
int numPatches = patchIndices.size();
207+
int numPatches = d.patchIndices().size();
189208
MemorySegment idxBuf = Arena.ofAuto().allocate((long) numPatches * 4, 4);
190209
MemorySegment valBuf = Arena.ofAuto().allocate((long) numPatches * 8, 8);
191210
for (int i = 0; i < numPatches; i++) {
192-
idxBuf.setAtIndex(PTypeIO.LE_INT, i, patchIndices.get(i));
193-
valBuf.setAtIndex(PTypeIO.LE_DOUBLE, i, patchValues.get(i));
211+
idxBuf.setAtIndex(PTypeIO.LE_INT, i, d.patchIndices().get(i));
212+
valBuf.setAtIndex(PTypeIO.LE_DOUBLE, i, d.patchValues().get(i));
194213
}
195214

196-
EncodingProtos.PatchesMetadata patches = EncodingProtos.PatchesMetadata.newBuilder()
197-
.setLen(numPatches)
198-
.setOffset(0)
199-
.setIndicesPtype(DTypeProtos.PType.forNumber(PType.U32.ordinal()))
200-
.build();
215+
EncodingProtos.PatchesMetadata patches = buildPatchesMeta(numPatches);
201216
byte[] metaBytes = EncodingProtos.ALPMetadata.newBuilder()
202-
.setExpE(expE).setExpF(expF).setPatches(patches).build().toByteArray();
217+
.setExpE(d.expE()).setExpF(d.expF()).setPatches(patches).build().toByteArray();
203218

204219
EncodeNode idxNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 1);
205220
EncodeNode valNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 2);
206221
EncodeNode root = new EncodeNode(EncodingId.VORTEX_ALP,
207222
ByteBuffer.wrap(metaBytes),
208223
new EncodeNode[]{encodedNode, idxNode, valNode},
209224
new int[0]);
210-
return new EncodeResult(root, List.of(encodedBuf, idxBuf, valBuf), statsMin, statsMax);
225+
return new EncodeResult(root, List.of(encodedBuf, idxBuf, valBuf), d.statsMin(), d.statsMax());
226+
}
227+
228+
/// Cascade-aware F64 encode: I64 child is an open ChildSlot so the compressor
229+
/// can bitpack it. Patch idx/val buffers (if any) stay in ownedBuffers.
230+
private static CascadeStep encodeCascadeF64(double[] values) {
231+
AlpF64Data d = computeF64(values);
232+
int n = values.length;
233+
234+
if (d.patchIndices().isEmpty()) {
235+
byte[] metaBytes = EncodingProtos.ALPMetadata.newBuilder()
236+
.setExpE(d.expE()).setExpF(d.expF()).build().toByteArray();
237+
// children[0] will be filled by the compressor after recursing on the ChildSlot
238+
EncodeNode partialRoot = new EncodeNode(EncodingId.VORTEX_ALP,
239+
ByteBuffer.wrap(metaBytes), new EncodeNode[1], new int[0]);
240+
ChildSlot slot = new ChildSlot(I64_DTYPE, d.encodedArr(), 0);
241+
return new CascadeStep(partialRoot, List.of(), List.of(slot), d.statsMin(), d.statsMax());
242+
}
243+
244+
int numPatches = d.patchIndices().size();
245+
MemorySegment idxBuf = Arena.ofAuto().allocate((long) numPatches * 4, 4);
246+
MemorySegment valBuf = Arena.ofAuto().allocate((long) numPatches * 8, 8);
247+
for (int i = 0; i < numPatches; i++) {
248+
idxBuf.setAtIndex(PTypeIO.LE_INT, i, d.patchIndices().get(i));
249+
valBuf.setAtIndex(PTypeIO.LE_DOUBLE, i, d.patchValues().get(i));
250+
}
251+
252+
EncodingProtos.PatchesMetadata patches = buildPatchesMeta(numPatches);
253+
byte[] metaBytes = EncodingProtos.ALPMetadata.newBuilder()
254+
.setExpE(d.expE()).setExpF(d.expF()).setPatches(patches).build().toByteArray();
255+
256+
// ownedBuffers[0]=idxBuf, [1]=valBuf; child I64 will be appended after (starting at idx 2)
257+
EncodeNode idxNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 0);
258+
EncodeNode valNode = EncodeNode.leaf(EncodingId.VORTEX_PRIMITIVE, 1);
259+
EncodeNode partialRoot = new EncodeNode(EncodingId.VORTEX_ALP,
260+
ByteBuffer.wrap(metaBytes), new EncodeNode[]{null, idxNode, valNode}, new int[0]);
261+
ChildSlot slot = new ChildSlot(I64_DTYPE, d.encodedArr(), 0);
262+
return new CascadeStep(partialRoot, List.of(idxBuf, valBuf), List.of(slot), d.statsMin(), d.statsMax());
263+
}
264+
265+
private static EncodingProtos.PatchesMetadata buildPatchesMeta(int numPatches) {
266+
return EncodingProtos.PatchesMetadata.newBuilder()
267+
.setLen(numPatches)
268+
.setOffset(0)
269+
.setIndicesPtype(DTypeProtos.PType.forNumber(PType.U32.ordinal()))
270+
.build();
211271
}
212272

213273
private static byte[] scalarF64(double v) {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package io.github.dfa1.vortex.encoding;
2+
3+
import java.lang.foreign.MemorySegment;
4+
import java.util.List;
5+
6+
/// One step in cascade-aware encoding: a partially-assembled node tree plus open child slots.
7+
///
8+
/// <p>Terminal steps have no open children and carry a fully-resolved {@link EncodeResult}.
9+
/// Intermediate steps have open children that the {@link CascadingCompressor} recursively fills.
10+
///
11+
/// <p>Buffer layout: {@code ownedBuffers} holds buffers belonging to the partial root
12+
/// (e.g. patch index/value buffers for ALP). Child recursion results are appended after these;
13+
/// child {@code bufferIndices} are remapped by {@code +ownedBuffers.size()}.
14+
public record CascadeStep(
15+
EncodeNode partialRoot,
16+
List<MemorySegment> ownedBuffers,
17+
List<ChildSlot> openChildren,
18+
byte[] statsMin,
19+
byte[] statsMax
20+
) {
21+
/// Convenience: terminal step — no open children, result is final.
22+
public static CascadeStep terminal(EncodeResult result) {
23+
return new CascadeStep(result.rootNode(), result.buffers(), List.of(), result.statsMin(), result.statsMax());
24+
}
25+
26+
public boolean isTerminal() {
27+
return openChildren.isEmpty();
28+
}
29+
30+
/// Total byte size of owned buffers (used for size-based winner selection on samples).
31+
public long ownedBytes() {
32+
long total = 0;
33+
for (MemorySegment seg : ownedBuffers) {
34+
total += seg.byteSize();
35+
}
36+
return total;
37+
}
38+
}

0 commit comments

Comments
 (0)