Skip to content

Commit 9aa9803

Browse files
dfa1claude
andcommitted
docs(adr): ADR 0010 — lazy decode for 1:1 transform encodings
Eager decode wastes work on filter-rejected or unread rows. Proposes lazy materialization + compute pushdown for ALP, FoR, ZigZag; gates work behind Phase 0 filter/take/projection benches so existing javaReadClose stops biasing every decision. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2692343 commit 9aa9803

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

docs/adr/0010-lazy-decode.md

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# ADR 0010: Lazy decode for 1:1 transform encodings
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-06-13
5+
- **Deciders:** project maintainer
6+
- **Supersedes:**
7+
- **Superseded by:**
8+
- **Related:** [ADR 0005 — Vector API adoption](0005-vector-api-adoption.md),
9+
[CLAUDE.md §Memory model](../../CLAUDE.md)
10+
11+
## Context
12+
13+
Today every encoding decoder is **eager**. `AlpEncodingDecoder.decode()`
14+
walks all `n` rows, computes `(double) src[i] * scale`, writes the result
15+
into a fresh `MemorySegment`, and returns a `DoubleArray` backed by that
16+
materialized buffer. `FrameOfReferenceEncodingDecoder` does the same with
17+
`+ ref`. `ZigZagEncodingDecoder` does the same with `(u >>> 1) ^ -(u & 1)`.
18+
19+
The current `RustVsJavaReadBenchmark.javaReadClose` reads every decoded
20+
value via `close.fold(0.0, Double::sum)`. The fold touches every row, so
21+
eager decode looks optimal — each value is computed once, summed once.
22+
23+
That benchmark shape rewards eager materialization. **Most real analytics
24+
workloads do not have this shape.** Common cases:
25+
26+
| Workload | Rows accessed | Eager decode cost | Lazy decode cost |
27+
|----------|---------------|-------------------|------------------|
28+
| Full fold (bench) | 100% | n transforms + n loads | n loads + n transforms (per-access) |
29+
| `WHERE close > 100`, 1% selectivity | 1% | n transforms + n compares | n int compares (no transform) |
30+
| Projection ignoring column | 0% | n transforms | 0 |
31+
| `LIMIT 100` slice | ~0% | n transforms | 100 transforms |
32+
| `take(idx[])` (random access) | k rows | n transforms | k transforms |
33+
| `min` / `max` / `sum` aggregations | 100% | n transforms + reduce | reduce on encoded + 1 scale at end |
34+
35+
Rust's `vortex-array` ALP implementation is lazy: `ALPArray` stores the
36+
encoded `i64` child + exponents, and `compute/` ships kernels —
37+
`compare.rs`, `filter.rs`, `take.rs`, `slice.rs`, `between.rs`, `nan_count.rs`
38+
— that operate **directly on the encoded form**. For `compare`, Rust
39+
encodes the scalar into the ALP integer domain and compares ints, never
40+
materializing doubles. Decode only happens when materialization is forced
41+
(e.g., handing rows to an Arrow consumer that does not implement the
42+
kernel).
43+
44+
vortex-java has no equivalent. The eager model is a hidden assumption
45+
inherited from early scaffolding, not a deliberate choice.
46+
47+
### Why the current benchmark biases optimization
48+
49+
Every optimization landed in this codebase so far is measured against
50+
`javaReadClose` (full sum). That benchmark is **strictly hostile to
51+
laziness** because it accesses 100% of rows. Two consequences:
52+
53+
1. Every micro-optimization (hoist `scale`, FoR in-place, byte-offset
54+
loop) is judged on full-materialization throughput. Lazy decode looks
55+
like a regression in this metric even when it would be a huge win on
56+
any selective workload.
57+
2. The bench is the only public number in the README, so external
58+
consumers see "Java 1.3× faster than Rust on close" — true for full
59+
fold, false for filter pushdown, where Rust crushes Java by skipping
60+
decode entirely.
61+
62+
### Generalization
63+
64+
The lazy idea is not ALP-specific. Any encoding that is a 1:1 transform
65+
of a single child is a candidate:
66+
67+
- **ALP** — encoded int, `value = (double) int * 10^(f - e)`
68+
- **FoR** — encoded int, `value = encoded + ref`
69+
- **ZigZag** — encoded uint, `value = (u >>> 1) ^ -(u & 1)`
70+
- **Composition** — ALP(FoR(Bitpacked)) is still a 1:1 closed form: read
71+
the bitpacked int, add `ref`, multiply by `scale`. Three transforms
72+
fused into one expression.
73+
74+
`Bitpacked`, `Pco`, `Zstd`, `Fsst` are **not** candidates — their output
75+
shape differs from their input (compact compressed bytes → wider element
76+
array), so element-at-i requires unpacking a window. They must remain
77+
eager. `Dict` is a special case (lazy is trivial — `getDouble(i) =
78+
values[indices[i]]`) but is already O(1) per access.
79+
80+
## Decision
81+
82+
**Adopt lazy decode + compute pushdown in two phases.** Phase 0 (bench)
83+
gates the work; phases 1 and 2 are sequential.
84+
85+
### Phase 0 — bench shape (blocks 1 and 2)
86+
87+
Add benchmarks that reward laziness. Without these, phase 1 will look
88+
like a regression on the only number we measure.
89+
90+
- `RustVsJavaReadBenchmark.javaFilterClose``WHERE close > X` with
91+
selectivity sweeps at 0.1% / 1% / 10% / 100%. Reports rows-matched/s
92+
*and* full-scan throughput as control.
93+
- `RustVsJavaReadBenchmark.javaTakeClose``take` with k random indices
94+
for k ∈ {100, 10k, 1M}.
95+
- `RustVsJavaReadBenchmark.javaSliceClose``LIMIT 100` semantics.
96+
- `RustVsJavaReadBenchmark.javaProjectionClose` — request `close`,
97+
iterate without touching `getDouble`. Measures decode cost paid for
98+
nothing.
99+
100+
Keep the existing `javaReadClose` (full fold) as the **negative test**:
101+
phase 1 must not regress it more than 10%, phase 2 must not regress it
102+
at all.
103+
104+
### Phase 1 — lazy materialization (no compute pushdown)
105+
106+
Change `AlpEncodingDecoder.decode()` to return a `DoubleArray` view that
107+
holds the encoded `MemorySegment` + `double scale` + (optional)
108+
`PatchesIndex`. `getDouble(i)` becomes `(double) src.get(LE_LONG, i) *
109+
scale`, with O(log p) patch lookup if patches exist (binary search the
110+
sorted patch indices for `i`).
111+
112+
Two implementation options for the patch fast path:
113+
114+
- **Sparse bitmap**: `BitSet` of `n` bits over patched indices. O(1)
115+
lookup. Memory: `n / 8` bytes per chunk. For 1M-row chunks: 125 KB.
116+
- **Sorted index array + binary search**: `long[] patchIdx`. O(log p)
117+
per access. Memory: `p * 8` bytes. For p = 1% of n, this is `n / 12.5`
118+
bytes — slightly larger than bitmap.
119+
120+
For phase 1 use the bitmap. It costs more memory but is O(1) and
121+
predictable.
122+
123+
`DoubleArray` becomes a sealed interface; existing eager array is
124+
`DirectDoubleArray`, the lazy variant is `AlpDoubleArray`. Same for
125+
`LongArray` to support lazy FoR and ZigZag.
126+
127+
### Phase 2 — compute pushdown
128+
129+
Add a `Kernel` SPI that operates on encoded arrays. Initial kernels:
130+
131+
- `CompareKernel`: `compare(arr, scalar, op) → BoolArray`. For ALP,
132+
encode the scalar to the int domain and compare ints. For FoR,
133+
subtract the reference and compare ints. Falls back to materialization
134+
when the scalar does not round-trip through the encoding.
135+
- `BetweenKernel`: `between(arr, lo, hi) → BoolArray`. Same approach.
136+
- `TakeKernel`: `take(arr, indices)` — decode only the requested
137+
indices.
138+
- `SumKernel`, `MinKernel`, `MaxKernel`: `sum(ALP) = sum(int) * scale +
139+
patch_correction`. Min/max derivable when `scale > 0`.
140+
141+
`ScanIterator` already has a `RowFilter`; route it through the kernel
142+
SPI before falling back to materialization.
143+
144+
## Consequences
145+
146+
### Positive
147+
148+
- **Filter pushdown becomes possible.** Selective filters (the dominant
149+
shape in OLAP) skip decode entirely. Expected 10–50× on 1%-selective
150+
filters based on Rust's published numbers.
151+
- **Projection-only reads cost zero.** Today a column included in scan
152+
options but never read still pays full decode.
153+
- **Aggregation pushdown.** Sum/min/max over encoded form is one scale
154+
multiplication at the end, not n per row.
155+
- **The README benchmark stops biasing every decision.** Phase 0 makes
156+
realistic workloads visible.
157+
- **Closes the gap with Rust on the workloads that actually matter for
158+
analytics.**
159+
160+
### Negative
161+
162+
- **`javaReadClose` (full fold) will likely regress.** Per-element
163+
access goes from `seg.getDouble(i)` to `(double) seg.getLong(i) *
164+
scale`, plus a virtual call on the sealed-interface dispatch. Expect
165+
5–10% regression. This is the price of laziness for workloads that
166+
touch every row.
167+
- **API surface grows.** `DoubleArray` and `LongArray` become sealed
168+
interfaces with multiple variants. Downstream consumers that
169+
pattern-matched on the concrete type need updates.
170+
- **Patch lookup is now per-access.** Today patches are applied once at
171+
decode time, then never touched. Lazy needs an index structure (cost
172+
above) and pays per-row. For full fold over an ALP column with 1%
173+
patches that's `n * O(1)` bitmap checks — measurable but small.
174+
- **Kernel SPI is a non-trivial design.** Initial scope must be small:
175+
compare, between, take. Sum/min/max can wait.
176+
177+
### Risks to manage
178+
179+
- **Bimorphic dispatch.** With two `DoubleArray` impls (direct, alp), C2
180+
inlines both at bimorphic call sites. Adding a third (FoR-on-long-via-
181+
cast? dict-decoded?) makes it megamorphic and slow. Cap the
182+
implementations at two unless evidence forces more.
183+
- **Patches edge case.** Patch handling in kernels is the hard part:
184+
filter must AND in patch presence/value correctness. Easy to get
185+
wrong. Integration tests against Rust output are mandatory before
186+
shipping phase 2.
187+
- **Lifetime tangle.** Lazy array holds an encoded segment from a child
188+
decoder. That segment lives on the chunk arena. If the array escapes
189+
the chunk's `try-with-resources`, it dereferences freed memory. The
190+
existing `Chunk.close()` contract already covers this; phase 1 must
191+
not introduce a `DoubleArray` that survives its chunk.
192+
- **Benchmark integrity.** Phase 0 benchmarks must compare against the
193+
Rust JNI reader on the same workloads, not just Java-vs-Java. The
194+
point is to close the gap with Rust, not to look good against an
195+
artificial baseline.
196+
197+
## Alternatives considered
198+
199+
### A — Stay eager, optimize the existing path
200+
201+
Continue micro-optimizing eager decode (Vector API, better SIMD, fused
202+
multiply-add). Status-quo on API.
203+
204+
Pros: zero risk, zero API churn, the current optimization budget keeps
205+
flowing.
206+
Cons: hard ceiling. Eager decode on a filter-rejected row is **always**
207+
wasted work. No amount of SIMD turns wasted work into useful work. Caps
208+
the library at "fast columnar reader for full scans" instead of "fast
209+
OLAP-style engine."
210+
211+
Rejected: ceiling is too low for the project's stated use case (JVM
212+
analytics engines, OLAP systems).
213+
214+
### B — Lazy only for ALP, not the general pattern
215+
216+
Pursue lazy ALP because the benchmark called it out; skip FoR / ZigZag.
217+
218+
Pros: smaller scope.
219+
Cons: leaves the same waste in every other 1:1 transform encoding. ALP
220+
on its own is not the long pole — `ALP(FoR(Bitpacked))` is. Lazy ALP
221+
that still forces FoR materialization recovers only part of the win.
222+
223+
Rejected: the pattern is general; solving it once for the family is
224+
cheaper than three separate one-off lazy implementations.
225+
226+
### C — Compute pushdown without lazy materialization
227+
228+
Add kernels (filter, take, sum) that re-decode internally when called.
229+
Skip the `DoubleArray` polymorphism.
230+
231+
Pros: no API change.
232+
Cons: re-decoding internally means the chunk got eagerly decoded once
233+
already at scan time. The kernel pays the decode cost a second time.
234+
Net negative.
235+
236+
Rejected: only works if scan does not eagerly decode — which is exactly
237+
phase 1.
238+
239+
## References
240+
241+
- Rust reference: `https://github.com/spiraldb/vortex/tree/main/encodings/alp/src/alp/compute`
242+
- Rust ALP `CompareKernel`: encodes scalar into ALP int domain, compares
243+
ints. No decode.
244+
- Rust ALPArray definition: `https://github.com/spiraldb/vortex/blob/main/encodings/alp/src/alp/array.rs`
245+
- Local: `AlpEncodingDecoder.decodeF64` (current eager path),
246+
`FrameOfReferenceEncodingDecoder.applyReference` (recently made
247+
in-place when src writable — small win on the eager path; lazy would
248+
obsolete this code)
249+
- [ADR 0005](0005-vector-api-adoption.md) — Vector API is an
250+
optimization on top of an eager loop; lazy makes most of those loops
251+
conditional, changing what is even worth vectorizing.
252+
- [CLAUDE.md §Memory model — Encoding output allocation rule](../../CLAUDE.md)
253+
— current rule mandates arena allocation for decode output. Phase 1
254+
changes this rule: lazy arrays do not allocate decode output, they
255+
hold the input.

0 commit comments

Comments
 (0)