feat(parquet): configurable DictionaryFallback policy for dictionary encoding fallback - #10775
feat(parquet): configurable DictionaryFallback policy for dictionary encoding fallback#10775adriangb wants to merge 3 commits into
DictionaryFallback policy for dictionary encoding fallback#10775Conversation
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>
| /// | ||
| /// This is the default, and the historical behavior of this crate. | ||
| OnPageSizeLimit, | ||
| /// Keep the dictionary past [`WriterProperties::dictionary_page_size_limit`] |
There was a problem hiding this comment.
This dives too deep in the weeds and has become a TL;DR 😅 Please ask claude to summarize a bit.
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 <noreply@anthropic.com>
Update the LOC accounting, the public-item list and the mechanism descriptions in `PAGE_API_DESIGN.md`, `MERGED_API_DESIGN.md` and the `BAKEOFF.md` generator in `bakeoff.rs`, and regenerate `BAKEOFF.md`. Option A: 1 601 -> 1 507 library production lines, 30 -> 23 public items in `page_grain`, 6 -> 5 added `ColumnValueEncoder` methods. The docs no longer describe `try_new_page_candidate`, the `defer_page_flush` / `stop_at_page_boundary` pair, or the removed accessors, and the "what it buys" table now points at the reproducible bakeoff instead of quoting a four-dataset run the trimmed example no longer performs. Option B's library figures are unchanged and deliberately so: those three commits are faithful ports of apache#10775 and apache#10777. `MERGED_API_DESIGN.md` records that its 837 line harness was deleted, why, and that 837 is still the number to read as Option B's consumer cost. Every byte count in `BAKEOFF.md` is unchanged; only the timing columns and the complexity section moved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
`PAGE_API_DESIGN.md` was a record of how the API got here. It is now what an upstream reader needs: the surface as a list, the four invariants and how each is held, what a caller owns and what it costs them, the four places the library carries load-bearing complexity (accumulator lending, truncated versus untruncated statistics travelling with the page, the eight-arm type erasure, the deferred-flush enum and its hot-path branch), the page cadence limitation with its measurement and its two mitigations, and the accounting. Design history is kept where it explains a decision and dropped where it only explains the author. The cadence limitation was re-examined rather than restated: removing it needs the boundary-deciding candidate to survive across leaves, which makes an alternative's rows multi-leaf, so the builder would retain the level data of every leaf a page spans and the cursor would stop being the caller's unit of progress. Still a genuine design change, and not worth the last point. `BAKEOFF.md` is regenerated. Its seam list now records two of the three as fixed, its complexity table separates the page-grain library cost from the Option B ports it no longer overlaps, and its harness table reports the shipped harness split into the policy half and the plumbing half, counted from that file's own section markers so it cannot drift. Numbers: every Option B and Option C cell and every cell of both public corpora (TPC-H orders and lineitem, ClickBench hits_0/1/2) is byte-identical to the previously published run. Four Option A synthetic cells moved by at most 0.016% because the merged harness counts dictionary traffic from each page rather than from a library counter; that is noted in the report's anomalies. `MERGED_API_DESIGN.md` is untouched: it documents the apache#10775 and apache#10777 ports and stays a faithful record of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
Three arms are added to the bakeoff, all the same writer and all built only on `ArrowRowGroupWriterFactory::create_selected_column_writers`, the `DictionaryFallback` port and otherwise-existing public API. None of them touches `page_grain`. Per leaf, per row group, a settled leaf writes its whole row group through one ordinary `ArrowColumnWriter`; a deciding leaf first has its first 20000 rows encoded K ways through K throwaway single-leaf writers, the winner is chosen from their `ColumnCloseResult` compressed sizes by Option B's rule, the probe chunks are dropped, and the leaf writes the whole row group at that winner. The probe span is the tier's only overhead knob. `tier 0 (probe)` assumes both heuristic ports. `tier 0 minimal (no apache#10775)` assumes neither `DictionaryFallback` nor any library dictionary rule: the harness owns that itself, at chunk grain, by reading the closed chunk's `ColumnChunkMetaData` and withholding the dictionary candidate from a column that paid for a dictionary its data pages did not fully use. The third, `tier 0 floor`, is measured in a worktree at the merge base and is recorded in BAKEOFF.md rather than run from here. On the five public files under ZSTD the probe arm beats Option C on four and trails it by 0.10 points on the fifth, at 0.79x to 0.85x the branch default's wall clock against Option B's 1.78x to 2.66x. It loses 10 to 15 points to Option C only on uncompressed data that changes character inside a row group, which is the capability the page grain actually buys. apache#10775 is worth nothing on the synthetic suite and 0.10 to 0.24 points on ClickBench. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
|
I finally got around to testing this with my dict breaker dataset. This is some fake HR data with 200M rows. One column, "Address", has a cardinality of 80000, but fairly large records, so the 1MB dictionary limit is hit fairly soon (after about 33k rows). So this sits in a pretty diabolical place: by the time a decision wants to be made, there aren't really enough samples available to make a firm determination as to whether the dictionary is buying you anything. I added some prints at fallback time, and found that when the 1MB limit is hit, the dictionary size is With the current behavior, fallback will occur, and result in a 7GB file. If I crank up the dict size limit to 4MB, then fallback never occurs and the file size is 2.5GB. I tested this with the new So to sum up, I guess I'm still not seeing what the benefit is here. I can currently set the dict size limit for the problematic column to something large enough, or with this change I can instead set a different strategy after some testing to get the same result. In either case, I still need to know my data and tune appropriately if I want to prioritize file size over dictionary size. I think I still need to be convinced it's the Parquet writer's job to figure out the best encoding options for a file, vs a user experimenting with their data to determine the same (a la https://github.com/XiangpengHao/parquet-linter). I this the motivation for #10917? |
Yeah, this is my personal suggestion too - put more effort into organizing / teaching people how to choose the writer configuration. I think some sort of sampling library to auto tune parquet settings would be super helpful If there is any missing API to allow users control over how it works, then adding it t the parquet crate makes sense to me, but adding new policies here is less obvious to me. There are all sorts of tradeoffs picking heuristics (memory, cpu, etc) that I think any new policies that we added would need to demonstrate somehow a benefit to a large number of other users. |
|
The initial motivation for #9699 was to add an option for bringing the output closer to one produced by parquet-java, so that our native parquet writer does not produce drastically larger storage load than Spark, which would negatively affect customers migrating from it. Telling the customers to perform tuning to get compression that Spark achieves automatically (albeit with its own pitfalls) is not a satisfactory option. |
Which issue does this PR close?
This revives the direction of #9700 by @mzabaluev (closed as stale), whose design discussion shaped this API: a configurable fallback policy enum with per-variant parameters (as proposed by @etseidl in the #9700 discussion) rather than a change to the default heuristics (per @alamb's feedback there). Full credit to @mzabaluev for the prior art and the
DictionaryFallbackname.Rationale for this change
Today the writer abandons dictionary encoding for a column chunk as soon as the dictionary page exceeds
dictionary_page_size_limit(1 MiB default), regardless of whether the dictionary is paying for itself. For columns whose values are large but highly repetitive — e.g. multi-KiB metadata/payload blobs where a few thousand distinct values recur throughout the file — a handful of distinct values overflows the limit, and every subsequent repeat is written out in full by the fallback encoding. Raisingdictionary_page_size_limitfile-wide is a blunt instrument: it also grows dictionaries on columns where the dictionary is not profitable, and the limit exists for a reason (readers must decompress and materialize the entire dictionary page; the format allows at most one dictionary page per column chunk, so it cannot be split).This PR adds an opt-in policy that keeps the dictionary past the limit only while it remains profitable:
The PLAIN-encoded size is a cheap, pessimistic upper bound for the fallback encoding: the spec requires the delta encodings to not exceed PLAIN for the same values (as noted by @etseidl and @mzabaluev in #9700). The profitability estimate is documented as an implementation detail so a future PR can replace it with a measured comparison without changing this API. Suggested opt-in values:
worth_ratio: 0.1,max_dictionary_page_size: 64 * 1024 * 1024.The default is
OnPageSizeLimitand default output is bit-identical to today.What changes are included in this PR?
DictionaryFallbackenum infile/properties.rs, plumbed file-wide (set_dictionary_fallback) and per-column (set_column_dictionary_fallback), following the existingdictionary_page_size_limitpattern.DictEncoderand the arrow byte-arrayDictEncoder) accumulate the PLAIN-encoded size of appended values (one add per value), exposed via a defaultedColumnValueEncoder::estimated_plain_encoded_bytes.should_dict_fallbackimplements the policy: hard cap first, then the grace floor, then the profitability ratio; encoders with no estimate preserve the absolute-limit behavior.OnPageSizeLimitbyte-identical to default properties on fallback-triggering data; opt-in keeps the dictionary past the limit on a repetitive column (no fallback-encoded pages, dictionary page present, less than half the default-policy file size, roundtrip equality); the hard cap forces fallback even when profitable; a high-cardinality Int64 column produces byte-identical output to stock under the opt-in policy; property plumbing incl.into_builder()roundtrip andworth_ratiovalidation.Benchmarks
Methodology: every file of each dataset is rewritten with
ArrowWriter, ZSTD level 1, otherwise default properties, with only the fallback policy differing between arms; numbers are the sum of output file bytes over the same file set.Repetitive large values (seeded reproducer)
A pool of 1000 distinct 16 KiB random blobs over 16384 rows × 4 files (seed 42), values recurring in short runs of 16 — bursty local repeats that recur file-wide, farther apart than a codec window. Generator (pyarrow):
OnPageSizeLimit)WhenProfitable(ratio 0.1, cap 64 MiB)Disabling dictionary encoding does not help here — every repeat is still written in full — which addresses the "why not just disable the dictionary" question from #9700: the win comes from keeping the dictionary, not from avoiding it.
Known limitation (disclosed): with the same data uniformly shuffled, the policy does not diverge from stock. The decision is made when the dictionary crosses the grace floor (after ~64 values here), before any repeats are visible, so the ratio test fails and the writer falls back exactly as stock does. Fixing this requires deferring the decision until more values have been sampled — future work below. Stock behaves identically on the shuffled data, so this is a missed win, not a regression.
ClickBench (hits_0..hits_29, ~4.1 GB of parquet)
WhenProfitableratio 0.1WhenProfitableratio 0.5At ratio 0.5 the top movers are
Title−143.4 MB,URL−70.5 MB,Referer−38.9 MB; the regressions areUserID+13.2 MB andFUniqID+8.3 MB — sorted/high-cardinality keys where DELTA_BINARY_PACKED beats a nominally "profitable" dictionary. That regression class is exactly why the conservative 0.1 is the suggested default: PLAIN size is a pessimistic bound for the delta fallbacks, so a small ratio keeps the policy honest where delta would win.Controls
l_orderkey/ps_partkey, the same sorted-key class as above).Are there any user-facing changes?
New opt-in API:
DictionaryFallback(marked#[non_exhaustive]),WriterPropertiesBuilder::set_dictionary_fallback/set_column_dictionary_fallback,WriterProperties::dictionary_fallback/column_dictionary_fallback, andDEFAULT_DICTIONARY_FALLBACK. Default behavior is unchanged (bit-identical output with default properties).Future work
DictionaryFallbackvariant handle the shuffled-data case above without changing this API.🤖 Generated with Claude Code