feat(parquet): DictionaryFallback::Adaptive — measured, checkpointed dictionary fallback decision - #10780
feat(parquet): DictionaryFallback::Adaptive — measured, checkpointed dictionary fallback decision#10780adriangb wants to merge 4 commits into
DictionaryFallback::Adaptive — measured, checkpointed dictionary fallback decision#10780Conversation
Adds a DictionaryFallback policy enum to WriterProperties controlling when
dictionary encoding falls back to the column's fallback encoding:
- OnPageSizeLimit (default): fall back when the dictionary page exceeds
dictionary_page_size_limit. This is the current behavior and remains
bit-identical.
- WhenProfitable { worth_ratio, max_dictionary_page_size }: keep the
dictionary past the page size limit while it stays profitable relative
to the PLAIN-encoded size of the values appended so far, with a hard
cap protecting reader memory.
Supported both file-wide and as a per-column override, following the
existing dictionary_page_size_limit plumbing. Both dictionary encoders
(the generic one and the arrow byte-array one) now track the
PLAIN-encoded size of appended values, exposed through a defaulted
ColumnValueEncoder::estimated_plain_encoded_bytes method.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d of flushing them dictionary-encoded When the dictionary encoder overflows its configured size limit part-way through a column chunk, the writer used to flush the values buffered for the in-progress page as one more dictionary-encoded data page and always write the dictionary page out - even when no other data page referenced it, handing readers a dictionary page that can vastly exceed the configured dictionary_page_size_limit. Following parquet-java's FallbackValuesWriter, dictionary fallback now re-encodes the buffered dictionary ids through the fallback encoder (resolving them against the in-memory dictionary), and only writes the dictionary page when previously flushed data pages actually reference it. When the dictionary overflows before the first data page is completed - the common case, since RLE ids grow much more slowly than the dictionary itself - the chunk is now uniformly encoded with the fallback encoding and contains no dictionary page at all. Closes apache#9739 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Combines the DictionaryFallback policy (apache#10775) with the re-encoding fallback (apache#10777). Semantic resolution: with re-encoding, a fallback that happens before any data page was flushed discards the dictionary instead of writing a dictionary-encoded prefix, so test_dictionary_fallback_explicit_default_policy_unchanged now expects no dictionary page (its byte-identity assertion is unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…allback Adds a third DictionaryFallback policy that replaces WhenProfitable's PLAIN-bound ratio heuristic with a measured decision. Whenever the dictionary page size crosses a geometric checkpoint (the dictionary_page_size_limit grace floor, then 2x, 4x, ... up to the policy's hard cap), the writer compares the measured cost of the dictionary (dictionary page scaled by the compressibility of a sampled prefix, plus an upper-bound estimate of the RLE indices) against the measured cost of the fallback encoding (a bounded sample of the buffered values resolved through the dictionary, re-encoded with a scratch fallback encoder, compressed with the chunk's codec, and scaled to the PLAIN bytes appended so far). Each measurement is bounded to 1 MiB of sampled values, and the geometric spacing bounds the number of checkpoints per chunk by log2(cap / floor) + 1. The decision is not final at the first crossing: a near-tie with repeats already observed defers to the next checkpoint, and a losing-but-close dictionary whose windowed hit rate is rising or at break-even keeps deferring. This captures columns drawing from a bounded pool of values in shuffled order (almost no repeats are visible when the floor is crossed), while columns of fresh values fall back within a bounded number of checkpoints, and sorted keys whose fallback encoding measures smaller -- especially after compression -- fall back exactly like the default policy. Both dictionary encoders implement the sampling (generic typed and arrow byte-array), via a new defaulted ColumnValueEncoder::sample_dictionary_cost; encoders without measurement support preserve the absolute-limit behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Some read-side evidence for this policy, measured on the branch as pushed (all public datasets; same methodology as the PR body, ZSTD-1, identical writer settings, only the fallback policy differing between file sets). ClickBench query A/B (all 100
|
| stock rewrite | adaptive rewrite | Δ | |
|---|---|---|---|
| Total bytes | 9,982,078,788 | 9,055,616,486 | −9.28% |
| Query geomean (43 queries) | 153.4 ms | 145.5 ms | −5.19% |
As a harness sanity check, the untouched original dataset's geomean lands within ~2% of the stock rewrite.
Consistent movers (≥5% and same sign in ≥4 of 5 rounds), which line up exactly with the columns whose dictionary retention changed (Title −40% bytes, URL −17%, Referer −12%):
- Faster: Q22 −29.9% (
TitleLIKE), Q27 −17.5% (URL full scan), Q20 −17.3% (URL LIKE '%google%'), Q21 −17.3%, Q5 −16.6% (COUNT(DISTINCT SearchPhrase)), Q31 −12.0%, Q23 −10.3% (SELECT *with URL filter), Q30 −8.7%. - Slower: Q38 +32.7% and Q39 +6.9% — highly selective filters that read
URL/Refererfor a few thousand matching rows, so the reader decompresses and materializes a much larger dictionary page to serve very few values. This is the policy's read-side trade stated plainly: full scans of retained-dictionary columns get faster, highly selective point-ish reads of them get slower. (Q32 also moved −13.9% but touches only byte-identical columns; we attribute it to environment residue, not the policy.)
Row counts were identical across arms for all 43 queries in every iteration; two apparent value diffs (Q32/Q38) reproduce between runs on the same files and are LIMIT-over-tied-counts order nondeterminism — order-insensitive aggregation checksums are exactly equal.
Decode microbenchmark (the repetitive-blob class)
Full-column scans (single leaf projected, single-threaded, release, medians of warmed runs) of the PR's runs-reproducer val column (16 KiB blobs, 1000-value pool):
| Variant | Input bytes stock → adaptive | Scan speedup (adaptive) |
|---|---|---|
| ZSTD-1 | 50.7 MB → 31.0 MB | 1.7–2.0× |
| Uncompressed | 1,074 MB → 41.4 MB | 2.4–2.8× |
The uncompressed variant isolates the mechanism: the speedup grows with no decompression in the picture, so the win is dominated by having ~26× fewer input bytes to move and parse — the fallen-back file re-reads every 16 KiB repeat in full, the dictionary file expands from a hot ~10 MB dictionary. On general string columns (ClickBench Title/URL) the same microbenchmark shows roughly read-neutral results for a one-shot local full-column scan (the retained dictionary must be materialized per chunk, offsetting the 16–18% byte savings), consistent with the query-level picture above where parallel query execution does cash the bytes in.
Caveats: warm-cache medians on a busy 12-core laptop (interleaving cancels drift, but treat individual sub-10% per-query deltas as noise); local NVMe, so the −9.3% bytes understates the benefit for cold/object-store reads, while the Q38-style regression would likely shrink there (I/O-dominated).
🤖 Generated with Claude Code
Which issue does this PR close?
Important
Stacked PR (draft): this builds on #10775 (
DictionaryFallbackpolicy) and #10777 (re-encode on fallback) and includes both as its base. Please review only the top commits (the merge of the two branches plus one feature commit); this PR will be rebased as those merge.Rationale for this change
#10775 added an opt-in
DictionaryFallback::WhenProfitable { worth_ratio, .. }policy that keeps a dictionary pastdictionary_page_size_limitwhile the dictionary page stays belowworth_ratio× the PLAIN-encoded size of the appended values. That PR disclosed two structural weaknesses of the ratio heuristic, both reproduced in its benchmarks:DELTA_BINARY_PACKED(or PLAIN + compression) crushes a dictionary of distinct keys. Atworth_ratio: 0.5ClickBench gained 8.5% overall but paid +13.2 MB onUserIDand +8.3 MB onFUniqID, and TPC-H regressed +2.1% onl_orderkey/ps_partkey; the suggested default had to be a conservative 0.1, which captures only a fraction of the available wins.floor / value_sizevalues — e.g. 64 samples of 16 KiB values — before repeats are even visible. On the disclosed uniformly-shuffled reproducer every policy behaved exactly like stock.This PR adds the measured variant that #10775's API documentation anticipated: no ratio to tune, decisions based on measuring the actual encodings (including compression), and deferral with a trend signal instead of a one-shot decision.
How the decision works
Whenever the dictionary page size crosses a checkpoint — the grace floor (
dictionary_page_size_limit), then 2×, 4×, … the floor, up to the hard cap — the writer compares:Compressing both sides with the chunk's codec is what fixes the sorted-key class: a dictionary of distinct keys barely compresses, while the PLAIN/delta encoding of sorted-with-repeats keys compresses extremely well, so the dictionary correctly loses even when it is nominally 2–4× smaller before compression. The two compression samples use matched input sizes, since codec efficiency varies strongly with input size.
The decision is not final at the first crossing:
DictionaryFallbackpolicy for dictionary encoding fallback #10775 already maintains) is below 1% always falls back: the repeat fraction bounds what a dictionary can possibly win, so below it a measured win is sampling noise. In particular, a column with no repeats at all produces output identical to stock.Bounded cost: each checkpoint re-encodes and compresses at most 1 MiB (PLAIN size) of sampled values plus a ≤1 MiB dictionary prefix — about one extra data-page encode — and the geometric spacing bounds the number of checkpoints per column chunk by
log2(cap / floor) + 1(7 for the 1 MiB floor / 64 MiB cap defaults). Between checkpoints the policy does no work. There is no continuously-running parallel encoder.Benchmarks
Methodology as in #10775: every file rewritten with
ArrowWriter, ZSTD level 1, defaults otherwise, only the fallback policy differing; totals are summed output bytes. "Stock" is the default policy on this branch (i.e. including #10777's fallback improvements, which is why totals differ slightly from the numbers in #10775).WhenProfitablearms use the cap 64 MiB;Adaptive { max_dictionary_page_size: 64 MiB }.WhenProfitable0.1WhenProfitable0.5AdaptiveDictionaryFallbackpolicy for dictionary encoding fallback #10775): 2 of the 4 seeded files flip from 198.7 MB to 12.3 MB each — the dictionary is deferred at the floor, wins once repeats accumulate, and the 1000-value pool exhausts at a 16.4 MB dictionary, far below the cap. The other 2 files happen to cross the grace floor with zero repeats among the first ~64 samples and fall back exactly like stock — that is the deliberate price of keeping unique-value columns byte-identical to stock, and it is a missed win, never a regression.Title−183.9 MB,URL−92.2 MB,Referer−54.4 MB; theWhenProfitable{0.5}regressionsUserID+13.0 MB → +1.1 MB andFUniqID+8.2 MB → +2.4 MB. Aggregate: −334.0 MB of wins vs +11.7 MB of regressions (largest single:RefererHash+2.9 MB).l_orderkey,ps_partkey) that cost the ratio heuristic +2.1% now measure as losing post-compression and fall back exactly like stock.Generator for the shuffled reproducer (pyarrow, seed 42; the runs/sorted variants from #10775 differ only in the
idxline):What changes are included in this PR?
DictionaryFallback::Adaptive { max_dictionary_page_size }infile/properties.rs(the enum is#[non_exhaustive]; plumbing was added by feat(parquet): configurableDictionaryFallbackpolicy for dictionary encoding fallback #10775).ColumnValueEncoder::sample_dictionary_cost, implemented by both dictionary encoders (generic typed and arrow byte-array): resolves a bounded sample of the buffered ids through the dictionary, measures it through a scratch fallback encoder, and returns the dictionary/indices/PLAIN byte counts plus a dictionary-prefix compressibility sample. Encoders without measurement support keep the absolute-limit behavior.GenericColumnWriter(adaptive_should_fallback), with the thresholds documented as implementation details.WhenProfitable{0.5}keeps the dictionary and produces a 2×+ larger file; repetitive large values keep the dictionary (no fallback pages, less than half the stock size); a uniformly-shuffled bounded pool keeps the dictionary where stock falls back (deterministic LCG sampling, less than half the stock size); unique values are byte-identical to stock; a trickle-of-repeats column defers exactly one checkpoint and then falls back with a chunk ≈ stock; the hard cap forces fallback on a clearly-winning dictionary; the non-byte-array encoder path; property plumbing incl.into_builder()roundtrip; roundtrip equality in every scenario.Are these changes tested?
Yes, as above;
cargo test -p parquet --all-featurespasses (1791 tests), fmt and clippy clean.Are there any user-facing changes?
New opt-in
DictionaryFallback::Adaptivevariant. Default behavior is unchanged (OnPageSizeLimitremains the default; with the policy enabled, columns with no repeated values still produce byte-identical output to the default).🤖 Generated with Claude Code