fix(parquet): re-encode buffered values on dictionary fallback when the dictionary would be unreferenced - #10777
fix(parquet): re-encode buffered values on dictionary fallback when the dictionary would be unreferenced#10777adriangb wants to merge 3 commits into
Conversation
|
I need some time to mull this over. One issue as I see it is that the fallback here always re-encodes the buffered values. But I see many instances where the dictionary is effective, but too many keys accrete within a large column chunk, so fallback occurs anyway. The current page would still benefit from the dictionary, but with this change that partial page will be re-encoded with PLAIN. Perhaps this re-encode should only occur if it's the first page in the chunk (i.e. I'll also admit I find the presence of two |
| // The dictionary ids held only a fraction of the page size budget; the | ||
| // same values re-encoded with the fallback encoding may already exceed | ||
| // it. | ||
| if self.should_add_data_page() { |
There was a problem hiding this comment.
Could this let fallback bypass the data-page size limit? fall_back_from_dictionary re-encodes the entire buffered dictionary-id buffer before should_add_data_page() runs. Since add_data_page() then flushes the buffer as a whole, it can’t split it back into page-sized chunks.
With 1 KiB dictionary/data page limits and write_batch_size = 1, I get a 41,612-byte PLAIN page with this PR, while the parent keeps pages under 4 KiB. Should we re-encode the buffered ids incrementally during fallback so the page limit is still enforced?
The baseline arm measured in this tree is not a stock upstream writer: this branch carries the apache#10777 dictionary-fallback re-encode port, which changes what a default `ArrowWriter` emits on any column whose dictionary overflows. Every percentage in the report was therefore measuring each option against a moved reference, folding apache#10777's effect into each option's result. Vanilla upstream main byte counts (merge base 2567a32, identical data, identical properties, same preserved row group boundaries) are now carried in the generator and are the primary reference. Every table gains a `vanilla base` column, a `vs vanilla` column, and a leading `vanilla main (reference)` row; the branch default is kept as an explicitly labelled secondary reference in `vs branch base`, and the baseline arm is relabelled `baseline (branch default)`. Percentages are computed from the byte counts at render time rather than transcribed. Interpretation prose that cited baseline-relative figures is restated: the shifting-strings headline is -37.34% for A against B's -18.10% where it previously read -45.52% and -28.80%, and the records-like headline is -50.16% against -37.77%. Adds a section attributing the gap between the two baselines. Overflow after a page has been sealed makes the branch default larger, because the buffered page is re-encoded to PLAIN where vanilla kept RLE_DICTIONARY indices, and every delta column is by definition a dictionary-overflow column; this is the common case and the larger effect. Overflow before the first seal makes it smaller, because no dictionary page is written at all: TPC-H `orders` is 0.81% smaller on the branch, its `o_comment` column dropping a nearly 1 MiB barely-referenced dictionary page in 10 of 16 row groups. The section records the upstream implication, that apache#10777 could potentially be narrowed to re-encode only when the dictionary would otherwise be left unreferenced, and marks it as a suggestion from these measurements rather than a tested change. No arm was changed. A, B and C bytes are byte-for-byte identical to the previous commit in all 15 measured cells; only the reference and the percentages moved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
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
…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>
…ry would be unreferenced Narrows the re-encode introduced by the previous commit to the case it was meant for, and bounds the pages it produces. Re-encoding the buffered dictionary ids only pays off when no data page references the dictionary yet, because then the dictionary page exists solely to serve the buffer and can be dropped with it. Once a dictionary-encoded data page has been sealed, the dictionary page has to be written whatever happens next, every value it covers is already paid for, and re-encoding the buffer pays for those values a second time. Measurements on a range of files agree: unconditional re-encoding makes common files whose dictionary overflows after several sealed pages 3 to 15 percent larger, while the narrow case is where the saving is, with a TPC-H orders column dropping a roughly 1 MB dictionary whose only reference was the re-encoded buffer, for a 0.8 percent smaller file. So the fallback now keeps the previous behaviour exactly whenever a dictionary-encoded data page has been written: seal the buffered ids as one more dictionary-encoded page, write the dictionary page, then flush the data pages buffered behind it. Only a fallback with no sealed dictionary-encoded page re-encodes and discards the dictionary. While dictionary encoding is active a data page holds small RLE indices, so it can buffer far more bytes worth of values than the data page size limit before the limit is consulted. Re-encoding that buffer in one go could produce a single page many times the configured limit. The re-encode now drains the buffer in increments, sized from the bytes the previous increment produced, and seals a page whenever the limit is reached. Sealing a page part-way through means splitting the levels already buffered for it, which the streaming level encoders cannot do, so this applies to columns with no repetition or definition levels; other columns re-encode as before. The two `Option<DictEncoder>` fields are gone. There is one dictionary encoder and a boolean recording that the chunk has fallen back, after which the encoder is only a source for the ids still to be re-encoded and is dropped as soon as they are drained. Tests: a fallback after a sealed dictionary-encoded page keeps the dictionary page and seals a partial dictionary-encoded page rather than re-encoding it; a fallback before any sealed page still omits the dictionary page; and a column of large values whose re-encoded buffer previously landed in one 1 MB page against a 4 KiB limit is now split into pages at the limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
345fe6b to
dd8e1b4
Compare
The chunk's dictionary encoding state was a `bool` alongside an `Option<DictEncoder>`, with every reader of the dictionary going through an accessor that combined the two. A `DictionaryState` enum holds the three states that actually exist -- encoding, draining the buffered ids after a fallback, and gone -- so the combination that means nothing cannot be written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
I'm going to close this as it's not a clear win and I don't want to consume more reviewer time on it without clear evidence of a global improvement in behavior. |
|
Thanks @adriangb |
Closes #9739.
When the dictionary overflows its size limit, the writer currently flushes the buffered dictionary ids as one more dictionary-encoded page and writes the dictionary page. If no data page has been sealed yet, that dictionary page exists solely to serve the values still buffered, and the chunk pays for those values twice: once in the dictionary, once in the ids that index it.
This changes the fallback to re-encode the buffered ids through the fallback encoder and drop the dictionary, but only in that case. Once a dictionary-encoded data page has been sealed, the dictionary page has to be written whatever happens next and every value it covers is already paid for, so re-encoding would pay for them a second time. Measurements agree: re-encoding unconditionally makes common files whose dictionary overflows after several sealed pages 3 to 15 percent larger, while the narrow case is where the saving is, with a TPC-H orders column dropping a roughly 1 MB dictionary whose only reference was the re-encoded buffer, for a 0.8 percent smaller file.
While dictionary encoding is active a data page holds small RLE indices, so it can buffer far more bytes worth of values than the data page size limit before the limit is consulted, and re-encoding that buffer in one go could produce a single page many times the limit. The re-encode drains the buffer in increments sized from the bytes the previous increment produced, sealing a page whenever the limit is reached. Sealing a page part-way through means splitting the levels already buffered for it, which the streaming level encoders cannot do, so this applies to columns with no repetition or definition levels; other columns re-encode into a single page as before.
The dual
Option<DictEncoder>bookkeeping is gone. A singleDictionaryStateenum holds the three states a chunk can be in — encoding, draining the ids buffered at the fallback point, or without a dictionary — so the encoder cannot be consulted as an encoder once it is only a source of values to re-encode.