Skip to content

Commit cf43fd3

Browse files
dfa1claude
andcommitted
docs: ADR 0021 — cardinality-bounded global dict buffering
Proposes replacing the byte-budget demotion heuristic (shipped in the OOM fix + its configurability follow-up) with cardinality-bounded buffering: track a deduplicated value->code map capped at GLOBAL_DICT_MAX_CARDINALITY instead of raw per-row values, and buffer per-chunk code arrays instead of raw value arrays. Motivated by measuring the byte-budget heuristic's actual cost on nyc-311 (every column demoted, 1.67x file size vs jni) and by checking the Rust reference's compressor, which estimates via sampling rather than buffering full raw data -- a different strategy that doesn't port directly given our streaming writeChunk API, but confirms byte-size is the wrong eviction signal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4cbc12d commit cf43fd3

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# ADR 0021: Cardinality-bounded buffering for global dictionary candidates
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-07-18
5+
- **Deciders:** project maintainer
6+
- **Supersedes:**
7+
- **Superseded by:**
8+
- **Related:** [ADR 0009 — Write API ergonomics](0009-write-api-ergonomics.md)
9+
10+
## Context
11+
12+
`VortexWriter` builds one shared dictionary per low-cardinality column across
13+
every chunk, instead of re-emitting a per-chunk dictionary — closing the file
14+
size gap to the Rust reference on real-world nullable/categorical columns
15+
(commit `5fe8b544`). Because the writer's `writeChunk` API is streaming (a
16+
caller pushes row batches incrementally; the writer never sees the whole
17+
column up front — see ADR 0009), a shared dictionary can only be built once
18+
every chunk has been seen. Until then, a candidate column's **raw values**
19+
are buffered from its first chunk until `close()`.
20+
21+
This raw buffering has no bound proportional to what actually matters. A
22+
column is admitted as a dict candidate based on its first chunk's cardinality
23+
(`GLOBAL_DICT_MAX_CARDINALITY = 2048`), but nothing re-checks that gate as
24+
more chunks arrive — a column whose distinct set only grows past the cap
25+
after millions of later rows keeps accumulating raw duplicate values in the
26+
meantime. On a wide, high-row-count file (18.5M rows × 38 string columns,
27+
the NYC 311 slug from the Raincloud conformance corpus) the aggregate raw
28+
buffering reached several GB and threw `OutOfMemoryError`, independent of
29+
available heap — memory scaled with file size × column count, not with a
30+
bounded quantity.
31+
32+
The immediate fix (`fix: bound VortexWriter's global-dict retained memory to
33+
avoid OOM on huge files`) added an aggregate **byte** budget across all
34+
buffering candidates (`WriteOptions#globalDictMaxRetainedBytes`, default
35+
256 MB, since made configurable). When the running total crosses the budget,
36+
the largest-retained columns are demoted to per-chunk encoding until back
37+
under it. This stopped the OOM, but has two real costs:
38+
39+
1. **Wrong eviction signal.** Byte size is not the same as dict-worthiness.
40+
A huge column with genuinely tiny cardinality (exactly the case a shared
41+
dictionary helps most) is evicted *first* under this policy, purely
42+
because it is the biggest byte contributor — not because it stopped being
43+
a good candidate. Measured on the NYC 311 file: every one of its 38
44+
string columns was demoted under the 256 MB default, costing 1.67× the
45+
Rust reference's file size, because the aggregate raw-byte total for that
46+
many wide columns crosses 256 MB almost immediately regardless of any
47+
individual column's actual cardinality.
48+
2. **No principled default.** The budget is a workload-shaped guess users
49+
must tune per host/file (`withGlobalDictMaxRetainedBytes`); there is no
50+
value that is simultaneously safe on constrained hosts and lossless on
51+
wide, low-cardinality files.
52+
53+
The Rust reference does not solve this the same way — its compressor
54+
(`vortex-compressor/src/scheme/estimate.rs`) estimates a scheme's
55+
compression ratio by compressing a **sample**, not by buffering full raw
56+
data, because its compressor operates on arrays it already holds in memory
57+
rather than accepting a caller's incremental row-batch stream. There is no
58+
single heuristic to port directly; the fix has to fit our own streaming
59+
constraint.
60+
61+
## Decision
62+
63+
Replace raw-value buffering with **cardinality-bounded** buffering:
64+
65+
1. Per dict-candidate column, maintain a deduplicated `value → code` map
66+
incrementally as chunks stream in, capped at `GLOBAL_DICT_MAX_CARDINALITY`
67+
entries. The moment inserting a new distinct value would exceed the cap,
68+
demote immediately — this is the same, already-correct disqualifying
69+
condition the admission gate uses, just checked continuously instead of
70+
once.
71+
2. Buffer per-chunk **code arrays** (`U16`, or `U32` past 65 536 distinct
72+
values) instead of raw value arrays. Codes are cheap regardless of file
73+
size — 18.5M rows × 2 bytes ≈ 36 MB, versus potentially many GB of raw
74+
duplicated strings today.
75+
3. At `close()`, build the dictionary payload directly from the
76+
already-deduplicated map (no re-scan of raw data needed) and write the
77+
buffered code arrays as the column's chunked codes segment.
78+
79+
Memory for a surviving candidate is then bounded by
80+
`cardinality × avg_value_size + row_count × code_width` — proportional to
81+
the *actual* quantities that make a dictionary worthwhile, not to raw file
82+
size. A column only gets demoted when its real cardinality disqualifies it,
83+
never because of its byte footprint alone.
84+
85+
`WriteOptions#globalDictMaxRetainedBytes` stays as a secondary safety net
86+
(the code-array total across many wide columns is still technically
87+
unbounded by row count, just far smaller in practice) but stops being the
88+
primary demotion signal.
89+
90+
## Consequences
91+
92+
### Positive
93+
94+
- Correct-by-construction memory bound tied to configured cardinality, not a
95+
workload-dependent byte guess.
96+
- Demotion order now matches dict-worthiness: a column is only evicted when
97+
its cardinality actually disqualifies it, never for being byte-heavy while
98+
genuinely low-cardinality.
99+
- Removes the NYC-311-shaped regression (1.67× file size vs. Rust) without
100+
needing a larger configured budget — every column that is genuinely
101+
low-cardinality keeps its shared dictionary regardless of row count.
102+
- `close()`-time dictionary construction gets simpler (dedup map is already
103+
the dictionary; no re-scan of buffered raw chunks to compute codes).
104+
105+
### Negative
106+
107+
- Real rework of the buffering path: admission, per-chunk ingest, and the
108+
`close()`-time build all change. Bigger than the byte-budget PRs it
109+
supersedes.
110+
- Chunks buffered before a column's cardinality was known to be growing
111+
toward the cap need their already-computed codes kept consistent if a
112+
later demotion discards the shared dictionary — codes computed against a
113+
dictionary that turns out incomplete must be convertible back to raw
114+
per-chunk encoding, or the column must re-derive per-chunk encoding from
115+
scratch for its already-buffered chunks. The exact mechanics need to be
116+
worked out during implementation, not assumed here.
117+
- `U16` vs `U32` code width must be decided per column before any chunk is
118+
written (or upgraded in place if cardinality crosses 65 536 while staying
119+
under `GLOBAL_DICT_MAX_CARDINALITY`, if that constant is ever raised past
120+
a `U16` code's range) — matches the existing narrowest-ptype-selection
121+
precedent (`PType.narrowestUnsigned`) but the growing-cardinality writer
122+
path is a different call site.
123+
124+
### Risks to manage
125+
126+
- `GLOBAL_DICT_MAX_CARDINALITY` currently caps at 2048 (fits `U16` easily);
127+
if it is ever raised, code-width selection needs to stay correct.
128+
- Must not regress the existing demotion test coverage
129+
(`GlobalDictUtf8Test#retainedBytesBudgetExceeded_utf8_demotesToPerChunkChunkedLayout`)
130+
— cardinality-based demotion needs its own equivalent test, and the
131+
byte-budget test stays relevant as the secondary safety net's coverage.
132+
133+
## Alternatives considered
134+
135+
- **Keep the byte-budget heuristic as-is (status quo).** Simple, already
136+
shipped, and configurable. Rejected as the long-term design because the
137+
eviction signal is wrong (see Context) and there is no single default that
138+
works across host memory sizes and file widths — a real fix should not
139+
need per-workload tuning to get correct behavior on a file shape this
140+
common (many wide categorical columns).
141+
- **Reservoir-sample values for cardinality estimation only, still buffer
142+
raw data for the final dictionary build.** Bounds the *estimation* step
143+
cheaply, but does not solve the actual memory cost — building the real
144+
dictionary still needs the full distinct set and per-row codes, which the
145+
cardinality-bounded map already gives directly and exactly (no sampling
146+
error, no separate estimation pass).
147+
- **Two-pass write: require the whole column before writing (matching how
148+
the Rust reference's compressor operates).** Would let the writer measure
149+
exact cardinality with zero buffering complexity, but breaks the streaming
150+
`writeChunk` API's ergonomics (ADR 0009) — callers would have to hand the
151+
writer a fully materialized column instead of streaming batches, a much
152+
larger and unrelated behavior change.
153+
154+
## References
155+
156+
- OOM fix: `fix: bound VortexWriter's global-dict retained memory to avoid
157+
OOM on huge files`
158+
- Configurability follow-up:
159+
`feat: make writer's global-dict retained-memory budget configurable`
160+
- Rust reference sample-based estimation:
161+
[`vortex-compressor/src/scheme/estimate.rs`](https://github.com/spiraldb/vortex/blob/main/vortex-compressor/src/scheme/estimate.rs)
162+
- Original global-dict-across-chunks fix: commit `5fe8b544`

adr/ADR.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ the decision shipped in (blank = not yet shipped).
3535
| 0018 | Apache Calcite SQL adapter — push-down source | Accepted | |
3636
| 0019 | Columnar transducer façade for compute | Proposed | |
3737
| 0020 | Jazzer fuzz testing infrastructure | Proposed | |
38+
| 0021 | Cardinality-bounded global dict buffering | Proposed | |

0 commit comments

Comments
 (0)