Repository navigation
feat(parquet): DictionaryFallback::Adaptive — measured, checkpointed dictionary fallback decision - #10780
feat(parquet): DictionaryFallback::Adaptive — measured, checkpointed dictionary fallback decision#10780adriangb wants to merge 6 commits into
DictionaryFallback::Adaptive — measured, checkpointed dictionary fallback decision#10780Conversation
|
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
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 <[email protected]>
Review feedback: the variant documentation was too long. Keep the behavior, the motivating column shape, and suggested values; drop the derivations. Co-Authored-By: Claude Fable 5 <[email protected]>
`test_dictionary_fallback_explicit_default_policy_unchanged` asserted that data pages encoded with the dictionary are present after the writer falls back. That is a property of what the fallback does with the values buffered at the point it fires, which this policy does not control: it decides only when the fallback happens. A writer that re-encodes those buffered values rather than sealing them as one more dictionary-encoded page leaves a chunk with no dictionary-encoded page at all, and the assertion would fail without anything this policy governs having changed. The test's actual subject, that an explicit `OnPageSizeLimit` is byte identical to the default properties on data that does trigger the fallback, is unaffected: the presence of fallback-encoded pages still shows the fallback fired. Also adds a test for the complementary half of the profitability decision. The dictionary is judged against the values appended so far, so the same pool of values decides either way depending on the order it arrives in: cycled so that every distinct value is seen before any repeat, the dictionary has deduplicated barely more than its own size when it reaches the page size limit, and `WhenProfitable` falls back exactly where the default policy does. The existing test covers the same pool ordered into runs, where the dictionary is kept. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
…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 <[email protected]>
Combines the DictionaryFallback policy (apache#10775) with the re-encoding fallback (apache#10777). With re-encoding, a fallback that happens before any data page was flushed discards the dictionary instead of writing a dictionary-encoded prefix; test_dictionary_fallback_explicit_default_policy_unchanged already tolerates this since apache#10775 no longer asserts that dictionary-encoded pages survive the fallback. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…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 <[email protected]>
b239e3e to
5974e83
Compare
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