Skip to content

fix(parquet): re-encode buffered values on dictionary fallback when the dictionary would be unreferenced - #10777

Closed
adriangb wants to merge 3 commits into
apache:mainfrom
pydantic:dict-fallback-reencode
Closed

fix(parquet): re-encode buffered values on dictionary fallback when the dictionary would be unreferenced#10777
adriangb wants to merge 3 commits into
apache:mainfrom
pydantic:dict-fallback-reencode

Conversation

@adriangb

@adriangb adriangb commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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 single DictionaryState enum 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.

@etseidl

etseidl commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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. retain_dictionary is false).

I'll also admit I find the presence of two Option<DictEncoder>s a bit confusing. Maybe a boolean to indicate if fallback has occurred would be better?

// 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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

adriangb pushed a commit to pydantic/arrow-rs that referenced this pull request Aug 28, 2026
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
adriangb pushed a commit to pydantic/arrow-rs that referenced this pull request Aug 30, 2026
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
adriangb pushed a commit to pydantic/arrow-rs that referenced this pull request Aug 30, 2026
`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
adriangb and others added 2 commits August 30, 2026 15:38
…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
@adriangb
adriangb force-pushed the dict-fallback-reencode branch from 345fe6b to dd8e1b4 Compare August 30, 2026 15:58
@adriangb adriangb changed the title fix(parquet): re-encode buffered values on dictionary fallback instead of writing an unreferenced dictionary page fix(parquet): re-encode buffered values on dictionary fallback when the dictionary would be unreferenced Aug 30, 2026
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>
@adriangb

Copy link
Copy Markdown
Contributor Author

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.

@adriangb adriangb closed this Aug 30, 2026
@etseidl

etseidl commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Thanks @adriangb

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

parquet Changes to the parquet crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parquet dictionary encoding fallback is sub-optimal, may violate writer parameters

4 participants