|
| 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