Skip to content

feat(parquet): create a subset of column writers with per-row-group properties - #10917

Open
adriangb wants to merge 2 commits into
apache:mainfrom
pydantic:chunk-probe-writers
Open

feat(parquet): create a subset of column writers with per-row-group properties#10917
adriangb wants to merge 2 commits into
apache:mainfrom
pydantic:chunk-probe-writers

Conversation

@adriangb

@adriangb adriangb commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Measured results

Externally measured with /usr/bin/time -v, release build, ZSTD, five interleaved runs per arm per file, reported net of a read-only baseline arm that stops after loading the input. CPU is user+sys as median ± half the observed min-max range (five runs, not a standard deviation); peak memory is maximum resident set size; MB is 10^6 bytes.

input arm size cpu peak mem
TPC-H SF1 orders stock 45.8 MB 1.20 ± 0.05 s 12.3 MB
probe 33.6 MB (-26.6%) 0.87 ± 0.03 s 11.7 MB
TPC-H SF1 lineitem stock 183.3 MB 5.6 ± 0.9 s 14.2 MB
probe 140.3 MB (-23.4%) 4.3 ± 0.3 s 19.9 MB
ClickBench hits_0 stock 85.5 MB 5.1 ± 0.7 s 95.6 MB
probe 79.3 MB (-7.2%) 4.3 ± 0.4 s 143.8 MB
ClickBench hits_1 stock 121.1 MB 6.3 ± 0.5 s 113.5 MB
probe 114.6 MB (-5.4%) 5.1 ± 0.2 s 160.3 MB
ClickBench hits_2 stock 166.7 MB 6.4 ± 0.4 s 135.2 MB
probe 155.3 MB (-6.9%) 5.2 ± 0.5 s 162.6 MB

The probe writer costs less CPU than the stock writer on every file, because racing a prefix of rows per candidate is cheaper than the dictionary builds the stock writer starts and then abandons. It costs more peak memory on the wider schemas, where it holds a throwaway writer per candidate alongside the committed ones while a column is being probed; a caller that probes columns sequentially rather than all at once bounds that to one column's candidates. Every output file was read back and compared row for row against the input, and the synthetic timestamp and f64 results from the included example (-84.8% and -20.5%) reproduce deterministically.

@github-actions github-actions Bot added the parquet Changes to the parquet crate label Aug 30, 2026
…roperties

`ArrowRowGroupWriterFactory` can only build column writers for every leaf
column of a row group, at the properties the file writer was created with.
This adds two accessors:

* `create_column_writer(row_group_index, column_index, props)` builds the
  writer for one leaf column with the given `WriterProperties`. `column_index`
  indexes the parquet leaf columns in schema order, the order of
  `SchemaDescriptor::columns()` and of the `ArrowLeafColumn`s that
  `compute_leaves` yields, not the Arrow schema's top level fields; an index
  past the last leaf column is an error naming the valid range. The writer
  shares the factory's page store factory and, with the `encryption` feature,
  its file encryptor, so the chunk it produces is appendable to the same file
  writer.
* `page_store_factory()` returns the configured `PageStoreFactory`, so a caller
  that builds some of a row group's column chunks by another route can buffer
  them through the same page store.

Both this accessor and `create_column_writers` go through one private
construction path, of which they are the two degenerate cases: one column, or
every column at the file writer's own properties. The default write path is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
@adriangb
adriangb force-pushed the chunk-probe-writers branch from 554c930 to 8554400 Compare August 30, 2026 17:07
Comment thread parquet/examples/chunk_probe_writer.rs Outdated
Comment on lines +35 to +41
//! The cost model is deliberately simple. A column that is still deciding
//! encodes its probe prefix K + 1 times: K throwaway passes plus the real one.
//! The prefix is one data page worth of rows rather than a whole row group, so
//! the extra work is bounded by the page size and not by the data. A column
//! that has settled costs nothing extra at all: no probe writer is built for
//! it, so no page store is allocated for it either. That is what addressing one
//! column at a time buys over building every column writer at once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can we clarify how this interacts w/ dictionary pages, and in general how this adaptive writer handles "is the dictionary encoding earning it's keep"? How does it measure "is this dictionary worth it"? Does it fall back to avoid dictionary pages being larger than memory (there can only be 1)?

@adriangb
adriangb force-pushed the chunk-probe-writers branch 2 times, most recently from af4dcc2 to c5c3e72 Compare August 30, 2026 17:47

@adriangb adriangb left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

some suggestions for the example

Comment thread parquet/examples/chunk_probe_writer.rs Outdated
Comment thread parquet/examples/chunk_probe_writer.rs Outdated
Comment thread parquet/examples/chunk_probe_writer.rs
@adriangb
adriangb force-pushed the chunk-probe-writers branch 2 times, most recently from b060d08 to 0a5ba75 Compare August 31, 2026 03:33
An adaptive writer built on `create_column_writer`: for each row group, each
column that is due to race encodes a probe of its leading rows through several
throwaway single-column writers, one per candidate property set (dictionary,
delta where the physical type has one, and plain). The compressed sizes on the
returned `ColumnCloseResult`s pick a winner, the probe chunks are dropped, and
the row group is then written through one ordinary column writer per column at
its current choice.

Two small mechanisms make the measurement pay for itself only where it is
worth it. `DECISIVE_MARGIN` is the fraction by which the leading chunk must
beat the runner-up for the race to count as decided.

Closer than `NEAR_TIE_BAND`, the sizes are not telling the candidates apart at
all, and the tie is broken toward the page a reader decodes most cheaply,
dictionary and plain ahead of the delta encodings, rather than toward a
nominally smaller chunk. The band is a separate and much narrower constant than
`DECISIVE_MARGIN` for a measured reason: at 10 percent it gives away real wins,
since delta beats plain or dictionary by 5 to 10 percent on many TPC-H columns,
and at 2 percent the tie-break is free on that data. The tie-break is
deterministic and not random, because the example promises that the same input
produces the same file.

The interval before a column races again scales with the margin it won by,
doubling after each decisive race up to `MAX_RERACE_INTERVAL` row groups and
dropping back to one row group after a race the leader did not win decisively.
A settled column then costs nothing at all: no probe writer is built for it, so
no page store is allocated for it either.

The probe itself stays one data page long. Growing it a page at a time until
the leader is decisive was tried and measured: over TPC-H and ClickBench it
bought under a point of compression for 10 to 30 percent more write CPU, since
a chunk's compressed size can only be read once the chunk is closed, so each
extra page re-encodes the ones before it. The module documentation records that
result and leaves adaptive probe length to callers whose data rewards it.

The example generates a deterministic dataset whose best encoding differs per
column, writes it both with the probe writer and with a stock `ArrowWriter` at
identical properties and row group boundaries, verifies that both read back
exactly, and prints the total bytes, elapsed time and peak allocation of each
arm alongside the encodings each column ended on.

The two measured numbers are there because the technique trades work for size,
so the report should show both sides of that trade. Neither needs a dependency:
elapsed time is `std::time::Instant`, standing in for CPU time, which the
standard library cannot read, and peak allocation comes from a short counting
`#[global_allocator]` in the example itself, sampled as each arm's increment
over the bytes live when it started so that the input batches do not drown the
signal. Both arms are measured after the input is materialized and each runs
once, so the byte totals are deterministic while the time and memory figures
are indicative.

The module documentation also spells out how a dictionary candidate is handled,
since that is the case where a measured comparison differs most from a guess: a
probe's compressed size covers the dictionary page as well as the data pages
indexing into it, so a dictionary is charged its full cost and wins only by
being smallest; and a dictionary that a short probe flattered is corrected by
the writer's own single-dictionary-page fallback and by the next re-race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MwnUAoPDMQaYaQPVP2iUcz
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.

2 participants