adriangb opened a new pull request, #10780:
URL: https://github.com/apache/arrow-rs/pull/10780
# Which issue does this PR close?
- Part of #9699.
- Related to #8378: implements measured sampling for the dictionary-fallback
decision specifically.
> [!IMPORTANT]
> **Stacked PR (draft)**: this builds on #10775 (`DictionaryFallback`
policy) 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 past `dictionary_page_size_limit` while the
dictionary page stays below `worth_ratio` × the PLAIN-encoded size of the
appended values. That PR disclosed two structural weaknesses of the ratio
heuristic, both reproduced in its benchmarks:
1. **The ratio has to be tuned timidly.** PLAIN size is a pessimistic bound
for the fallback encoding, so a "profitable" dictionary can still lose to the
real fallback — most visibly on sorted/high-cardinality keys where
`DELTA_BINARY_PACKED` (or PLAIN + compression) crushes a dictionary of distinct
keys. At `worth_ratio: 0.5` ClickBench gained 8.5% overall but paid +13.2 MB on
`UserID` and +8.3 MB on `FUniqID`, and TPC-H regressed +2.1% on
`l_orderkey`/`ps_partkey`; the suggested default had to be a conservative 0.1,
which captures only a fraction of the available wins.
2. **The decision is blind to shuffled data.** The decision is made when the
dictionary crosses the grace floor, after only ~`floor / value_size` values —
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.
```rust
#[non_exhaustive]
pub enum DictionaryFallback {
OnPageSizeLimit, //
default, unchanged
WhenProfitable { worth_ratio: f64, max_dictionary_page_size: usize },
/// Keep the dictionary past the grace floor while a *measured*
/// comparison shows it beating the fallback encoding, re-evaluated at
/// geometric checkpoints; fall back unconditionally at the hard cap.
Adaptive { max_dictionary_page_size: usize },
}
```
# 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:
- **dictionary cost**: the dictionary page, scaled by the measured
compressibility of a sampled prefix, plus an upper-bound estimate of the
RLE/bit-packed indices for every value appended so far;
- **fallback cost**: a bounded sample of the values buffered for the
in-progress page, resolved through the in-memory dictionary (the machinery from
#10777) and re-encoded through a scratch fallback encoder, compressed with the
chunk's codec, and scaled to the PLAIN bytes appended so far.
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:
- A column whose repeat fraction (share of appended PLAIN bytes that were
already in the dictionary — measured exactly, at zero cost, from two counters
#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**.
- A measured near-tie at the first checkpoint defers to the next checkpoint:
the floor is typically crossed after too few values to judge a shuffled
distribution.
- At later checkpoints, a losing-but-close dictionary keeps deferring while
the windowed hit rate is rising (repeats are arriving) or at break-even;
otherwise it falls back. Thanks to #10777 the buffered values re-encode
cleanly, and the dictionary page is only written if already-flushed pages
reference it.
**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). `WhenProfitable` arms use the cap 64 MiB;
`Adaptive { max_dictionary_page_size: 64 MiB }`.
| Dataset | Stock | `WhenProfitable` 0.1 | `WhenProfitable` 0.5 | `Adaptive`
|
|---|---:|---:|---:|---:|
| Repetitive 16 KiB blobs, short runs (seed 42) | 51,015,911 | −38.9% |
−38.9% | **−38.9%** |
| Same pool, uniformly **shuffled** | 794,807,940 | ±0 | ±0 | **−46.9%** |
| Same pool, sorted | 61,447,608 | −19.9% | −19.9% | **−19.9%** |
| ClickBench (hits_0..29, ~4.1 GB) | 2,666,195,053 | −0.35% | −8.69% |
**−12.09%** |
| TPC-H SF1 | 242,833,808 | — | +2.12% | **+0.01%** |
| TPC-DS SF1 | 301,005,084 | — | — | **+0.01%** |
| large_values (unique 16 KiB values) | 803,839,649 | — | — |
**byte-identical** |
- **Shuffled reproducer** (the case disclosed as a miss in #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.
- **ClickBench**: total −322.4 MB (−12.09%), beating both ratio arms while
*also* removing their regression class. Top wins `Title` −183.9 MB, `URL` −92.2
MB, `Referer` −54.4 MB; the `WhenProfitable{0.5}` regressions `UserID` +13.0 MB
→ +1.1 MB and `FUniqID` +8.2 MB → +2.4 MB. Aggregate: −334.0 MB of wins vs
+11.7 MB of regressions (largest single: `RefererHash` +2.9 MB).
- **TPC-H / TPC-DS**: +0.01% each (no per-column mover above 0.15 MB) — the
sorted-key columns (`l_orderkey`, `ps_partkey`) that cost the ratio heuristic
+2.1% now measure as losing post-compression and fall back exactly like stock.
- **Wall time**: ClickBench 53.6 s (stock) → 58.3 s (+8.8%, the measurement
bound in action); TPC-H 4.0 s → 3.8 s; the shuffled reproducer is *faster* than
stock (1.7 s → 1.6 s) since far fewer bytes get compressed.
Generator for the shuffled reproducer (pyarrow, seed 42; the runs/sorted
variants from #10775 differ only in the `idx` line):
```python
import numpy as np, pyarrow as pa, pyarrow.parquet as pq, os, string
OUT = "repetitive_large_values"
os.makedirs(OUT, exist_ok=True)
rng = np.random.default_rng(42)
alphabet = np.frombuffer(bytes(string.ascii_letters + string.digits,
"ascii"), dtype=np.uint8)
pool = ["".join(map(chr, rng.choice(alphabet, size=16384))) for _ in
range(1000)]
for f in range(4):
idx = rng.integers(0, 1000, size=16384) # runs:
np.repeat(rng.integers(0, 1000, 1024), 16); sorted: np.sort(...)
t = pa.table({
"id": pa.array(np.arange(f * 16384, (f + 1) * 16384), pa.int64()),
"key": pa.array(idx, pa.int64()),
"val": pa.array([pool[i] for i in idx], pa.string()),
})
pq.write_table(t, f"{OUT}/part-{f}.parquet", compression="zstd")
```
# What changes are included in this PR?
- `DictionaryFallback::Adaptive { max_dictionary_page_size }` in
`file/properties.rs` (the enum is `#[non_exhaustive]`; plumbing was added by
#10775).
- A defaulted crate-private `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.
- The checkpoint state machine and decision procedure in
`GenericColumnWriter` (`adaptive_should_fallback`), with the thresholds
documented as implementation details.
- Tests: sorted keys with repeats fall back byte-identically to stock while
`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-features` passes (1791 tests),
fmt and clippy clean.
# Are there any user-facing changes?
New opt-in `DictionaryFallback::Adaptive` variant. Default behavior is
unchanged (`OnPageSizeLimit` remains the default; with the policy enabled,
columns with no repeated values still produce byte-identical output to the
default).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]