Skip to content

parquet: drop the leaf scan in parquet_column; add reusable ArrowReaderMetadata primitives - #9882

Draft
adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:adrian/wide-schema-perf
Draft

adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:adrian/wide-schema-perf

Conversation

@adriangb

@adriangb adriangb commented May 2, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Part of #9722 and apache/datafusion#21968.

Rationale for this change

Two per-file costs in parquet scale with schema width even when a reader touches only a few columns.

1. parquet_column resolves a name with two linear scans. First Fields::find over the arrow schema, then a scan over the parquet leaves to find the first one descended from the matched root. The second scan is pure waste: the root→first-leaf mapping is fixed for a given SchemaDescriptor and can be computed once when it is built.

2. Every file open re-walks every parquet leaf to build the arrow Schema and dremel field levels (ArrowReaderMetadata::try_new). A reader that already caches per-file metadata has no way to cache the derived arrow view alongside it, so the walk repeats on every open of the same file.

Scope — what this PR does not do

It does not make parquet_column O(1), and it does not remove the O(N²) that callers hit when resolving every column of a file. Fields::find lives in arrow-schema and is still a linear scan with string comparisons; StatisticsConverter::try_new calls it twice (once directly, once inside parquet_column). Those two scans dominate, and eliminating them needs name indexing on Fields, which is not attempted here.

This PR removes the third scan — the cheapest of the three, being a sweep over a flat Vec<usize> — and the repeated schema walks. Callers that want the quadratic term gone must hoist the name lookup themselves; apache/datafusion#21987 does exactly that, and no longer depends on this PR (it builds on StatisticsConverter::from_column_index, released in 59.1.0). So this PR should be judged on its own merits, not as the fix for that workload.

What changes are included in this PR?

Four commits, each green on its own:

  1. Drop the leaf scan in parquet_column — SchemaDescriptor precomputes a root_to_first_leaf map, exposed as root_first_leaf_index. Costs 8 bytes per root field, reflected in the updated memory_size expectations (+40 bytes for the 5-root test schema).
  2. Reusable arrow-reader-metadata primitives — parquet_to_arrow_schema_and_field_levels (one schema walk → (Schema, FieldLevels)), ArrowReaderMetadata::from_field_levels, and public ArrowReaderOptions accessors.
  3. StatisticsConverter::from_arrow_field — constructor taking an already-resolved (field, Option<leaf index>). Overlaps with from_column_index (introduce StatisticsConverter::from_column_index #9540, merged since this PR was opened) but is strictly more expressive: from_column_index takes a bare usize and so cannot represent a column with no parquet leaf — nested types, or schema evolution — which is precisely the case that forces callers back onto the O(N) try_new.
  4. AsyncFileReader::get_arrow_reader_metadata — new trait method, default delegating to try_new, with load_async routed through it, so a cache-aware reader can return a fully-built ArrowReaderMetadata and skip the per-leaf walk.

Are these changes tested?

Existing parquet tests pass with --all-features, including the memory_size expectations updated for the new SchemaDescriptor field. parquet_column's behaviour is unchanged and is covered by the existing suite — the new map reproduces its tie-break (lowest leaf index for a root wins).

Are there any user-facing changes?

New public API only, no breaking changes. SchemaDescriptor grows by 8 bytes per root field, which is visible through ParquetMetaData::memory_size — about 0.65% for a 1024-column schema.

@github-actions github-actions Bot added the parquet Changes to the parquet crate label May 2, 2026
@adriangb
adriangb force-pushed the adrian/wide-schema-perf branch 3 times, most recently from 97e0b4b to 0173350 Compare June 8, 2026 18:56
@adriangb adriangb changed the title [do-not-merge] Wide-schema parquet read primitives — visibility for datafusion#21968 Wide-schema parquet: O(1) parquet_column + cacheable arrow-reader-metadata primitives Jun 8, 2026
@adriangb
adriangb marked this pull request as ready for review June 8, 2026 18:57
@adriangb
adriangb force-pushed the adrian/wide-schema-perf branch from 0173350 to 41d7b4a Compare June 8, 2026 19:50
@Jefffrey

Copy link
Copy Markdown
Contributor

are we still keen on this PR? i'm not as familiar with the context but can help review if needed

@adriangb
adriangb force-pushed the adrian/wide-schema-perf branch from 41d7b4a to 20382c4 Compare August 8, 2026 19:23
@adriangb adriangb changed the title Wide-schema parquet: O(1) parquet_column + cacheable arrow-reader-metadata primitives parquet: drop the leaf scan in parquet_column; add reusable ArrowReaderMetadata primitives Aug 8, 2026
adriangb added a commit to pydantic/datafusion that referenced this pull request Aug 9, 2026
Building an `ArrowReaderMetadata` walks every leaf of the parquet schema
to produce the arrow `Schema` + dremel field levels. For wide-schema
files this O(N_columns) walk runs on every file open, once per query,
even though the result is identical across queries for the same file.

Cache it on `CachedParquetMetaData` (which already lives in the file
metadata cache):

- `arrow_reader_metadata()` lazily builds the base
  `ArrowReaderMetadata` via `parquet_to_arrow_schema_and_field_levels` +
  `ArrowReaderMetadata::from_field_levels` and memoises it in a
  `OnceLock`; warm hits are a cheap `Arc`-bump clone.
- `coerced_arrow_reader_metadata()` memoises a single post-coercion
  build keyed by the supplied schema's `Arc` identity (the common case:
  every file coerces to the same table schema).
- `CachedParquetFileReader` overrides
  `AsyncFileReader::get_arrow_reader_metadata` to serve both from cache,
  falling back to a fetch + `try_new` on miss.

Depends on the arrow-rs primitives in apache/arrow-rs#9882
(`from_field_levels`, `parquet_to_arrow_schema_and_field_levels`,
`ArrowReaderOptions` accessors, `AsyncFileReader::get_arrow_reader_metadata`).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
adriangb added a commit to pydantic/datafusion that referenced this pull request Aug 9, 2026
This PR uses parquet API that only exists on apache/arrow-rs#9882, which
has not merged and so is in no parquet release. Without a patch the
workspace does not compile and CI cannot say anything useful about the
change.

The patch points at a throwaway branch that is the parquet 59.2.0 release
tag plus apache#9882's four commits — deliberately not arrow-rs `main`, which
has post-release changes (notably `arrow_schema::Metadata` replacing
`HashMap<String, String>`) that DataFusion has not absorbed and which
break `datafusion-common` before parquet is even reached.

Revert this commit once apache#9882 has merged and DataFusion has bumped to a
parquet release containing it. Green CI on this PR means "the change
works against that API", not "this is mergeable".

Co-Authored-By: Claude Opus 5 <[email protected]>
adriangb and others added 4 commits September 28, 2026 16:46
`parquet_column` previously did an O(N) linear scan over all parquet
leaf columns to find the leaf belonging to a given root field. For wide
schemas (hundreds/thousands of columns) this is called once per column,
making per-file statistics setup O(N^2).

Precompute a `root_to_first_leaf` map on `SchemaDescriptor` at
construction so `parquet_column` is O(1). Expose it via
`SchemaDescriptor::root_first_leaf_index`. Update the `memory_size`
tests to account for the extra cache field.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add building blocks so callers (e.g. a metadata-caching reader) can
construct an `ArrowReaderMetadata` from precomputed parts instead of
re-walking every parquet leaf on each file open:

- `parquet_to_arrow_schema_and_field_levels`: produce the arrow
  `Schema` and `FieldLevels` in a single schema walk.
- `ArrowReaderMetadata::from_field_levels`: build an
  `ArrowReaderMetadata` from already-computed `(Schema, FieldLevels)`.
- Public accessors on `ArrowReaderOptions` (`virtual_columns`,
  `supplied_schema`, `skip_arrow_metadata`) so a cache-aware reader can
  decide whether its cached arrow view applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add a low-overhead `StatisticsConverter` constructor that takes an
already-resolved `(arrow field, parquet leaf index)` pair, skipping the
name-based `parquet_column` lookup that `try_new` performs. Lets callers
that have precomputed the logical->leaf mapping build converters in O(1).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Add a `get_arrow_reader_metadata` method to `AsyncFileReader` whose
default implementation delegates to `ArrowReaderMetadata::try_new`, and
route `load_async` through it. Cache-aware readers can override it to
return a precomputed/cached `ArrowReaderMetadata` (built via
`from_field_levels`) instead of re-walking the parquet schema per open.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@adriangb
adriangb force-pushed the adrian/wide-schema-perf branch from 20382c4 to 6cc09fa Compare September 28, 2026 21:49
@alamb

alamb commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Is this PR still useful? Should I try and find time to review it? Are there benchmarks we can run to show it performing?

@adriangb

adriangb commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

I think hold off for now, I should invest some time into it before you do

@alamb
alamb marked this pull request as draft October 9, 2026 19:38
@alamb

alamb commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

I think hold off for now, I should invest some time into it before you do

Thanks, converting to draft for now

adriangb added a commit to pydantic/datafusion that referenced this pull request Oct 16, 2026
…on (apache#26355)

## Which issue does this PR close?

- Part of apache#21968.

This revives apache#21987 by @adriangb, which was closed while stale and never
reviewed. The two commits are his, rebased onto current `main`. The fix
was never carried into apache#22829 or apache#22830, and the quadratic lookup is
still on `main`.

## Rationale for this change

Planning a query against a wide-schema Parquet table takes time
quadratic in the number of columns, even when the query reads only one
or two of them.

`DFParquetMetadata::statistics_from_parquet_metadata` resolves every
field in the logical file schema to a Parquet leaf column. Both branches
resolve each field with an O(N) lookup:

- When the file has statistics, it calls `StatisticsConverter::try_new`.
That does a linear `Schema::column_with_name`, then calls
`parquet_column`, which does a linear `Fields::find` and a linear scan
for the first leaf under the matched root.
- When the file has no statistics, it calls `parquet_column` directly.

This happens once per field, so the cost is O(N²) per file, on every
query plan, for every file in the table.

## What changes are included in this PR?

- A new crate-private `LeafResolver`
(`datafusion/datasource-parquet/src/leaf_resolver.rs`). It builds two
maps once per file: Arrow field name to root index, and Parquet root
index to first leaf index. Both call sites use it in place of the
per-field linear lookups, which makes the statistics pass O(N) per file.
- `LeafResolver` gives the same results as `parquet_column`, including
its tie-breaks: the first Arrow field with a given name wins, and the
lowest leaf index for a root wins. When a leaf exists, it builds the
converter with `StatisticsConverter::from_column_index`, which yields
the same converter `try_new` would (same leaf index, same Arrow field,
same physical type).
- A field with no Parquet leaf (a nested type, or a name missing from
the file schema) falls back to `StatisticsConverter::try_new`. This
reproduces its exact error for an unknown column, and it is needed
because `from_column_index` cannot express "this column has no Parquet
leaf".
- The `parquet_metadata_statistics` benchmark now also covers 1024
columns, because the quadratic term only shows up past a few hundred
columns.

Rebasing onto `main` conflicted in `statistics_from_parquet_metadata`.
The resolution keeps every change made on `main` since apache#21987, including
the omitted-null-count handling and the untrusted min/max order checks,
and changes only how each field's converter is built.

**Known limitation:** schemas with many nested fields still take the
O(N) fallback for those fields. apache/arrow-rs#9882 proposes a
constructor that takes an `Option<usize>` leaf, which would remove the
fallback. Flat wide schemas never take it.

## What is the testing strategy for this PR?

- Seven unit tests in `leaf_resolver.rs`. Five of them check, for every
field name plus some absent names, that `LeafResolver::leaf_index`
returns exactly what `parquet_column` returns and that
`LeafResolver::converter` matches `StatisticsConverter::try_new` (same
leaf index, same Arrow field, same error text). The tie-break rules are
the risky part: getting either backwards would silently attribute one
column's statistics to another, and nothing in the existing suite covers
duplicate names. The other two check specific leaf indices, and that a
duplicated name uses the first field's type. The schemas cover:
- primitives, a nested struct, a nested list, and a duplicate field name
with a different type from the first
- a primitive Arrow field over a multi-leaf Parquet group (lowest leaf
wins)
  - a Parquet root with no leaves (an empty group)
  - more Arrow fields than Parquet roots
  - an empty schema
- No `.slt` test: this changes how columns are looked up, not the
statistics produced, so the unit tests compare directly against
`parquet_column` and `try_new`.
- Existing suites: `-p datafusion-datasource-parquet --lib`, `-p
datafusion --test parquet_integration`, and the `parquet_statistics`,
`listing_table_statistics`, `parquet_sorted_statistics` and
`parquet_nested_schema_pruning` sqllogictest files.

### Benchmarks

`wide_schema` (1024 vs 8 columns × 256 files), run locally on an AMD
Ryzen 9 9950X3D. These are Criterion medians, `main` (e732212) against
this branch:

| Query | main | this PR | change |
|---|---:|---:|---:|
| Q01 wide | 694.5 ms | 522.9 ms | −24.7% |
| Q02 wide | 528.0 ms | 350.3 ms | −33.7% |
| Q03 wide | 529.2 ms | 353.4 ms | −33.2% |
| Q04 wide | 617.2 ms | 445.5 ms | −27.8% |
| Q01–Q04 narrow | — | — | within ±0.5% |

For every wide query the confidence intervals do not overlap. The two
sides ran one after the other on the same machine, not interleaved.

## Are there any user-facing changes?

No. `LeafResolver` is crate-private and the statistics produced are
unchanged. Planning against wide-schema Parquet tables is faster.

---

This PR was prepared with AI assistance (Claude Code).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Adrian Garcia Badaracco <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
alamb pushed a commit to rxdrccq/datafusion that referenced this pull request Oct 16, 2026
…on (apache#26355)

## Which issue does this PR close?

- Part of apache#21968.

This revives apache#21987 by @adriangb, which was closed while stale and never
reviewed. The two commits are his, rebased onto current `main`. The fix
was never carried into apache#22829 or apache#22830, and the quadratic lookup is
still on `main`.

## Rationale for this change

Planning a query against a wide-schema Parquet table takes time
quadratic in the number of columns, even when the query reads only one
or two of them.

`DFParquetMetadata::statistics_from_parquet_metadata` resolves every
field in the logical file schema to a Parquet leaf column. Both branches
resolve each field with an O(N) lookup:

- When the file has statistics, it calls `StatisticsConverter::try_new`.
That does a linear `Schema::column_with_name`, then calls
`parquet_column`, which does a linear `Fields::find` and a linear scan
for the first leaf under the matched root.
- When the file has no statistics, it calls `parquet_column` directly.

This happens once per field, so the cost is O(N²) per file, on every
query plan, for every file in the table.

## What changes are included in this PR?

- A new crate-private `LeafResolver`
(`datafusion/datasource-parquet/src/leaf_resolver.rs`). It builds two
maps once per file: Arrow field name to root index, and Parquet root
index to first leaf index. Both call sites use it in place of the
per-field linear lookups, which makes the statistics pass O(N) per file.
- `LeafResolver` gives the same results as `parquet_column`, including
its tie-breaks: the first Arrow field with a given name wins, and the
lowest leaf index for a root wins. When a leaf exists, it builds the
converter with `StatisticsConverter::from_column_index`, which yields
the same converter `try_new` would (same leaf index, same Arrow field,
same physical type).
- A field with no Parquet leaf (a nested type, or a name missing from
the file schema) falls back to `StatisticsConverter::try_new`. This
reproduces its exact error for an unknown column, and it is needed
because `from_column_index` cannot express "this column has no Parquet
leaf".
- The `parquet_metadata_statistics` benchmark now also covers 1024
columns, because the quadratic term only shows up past a few hundred
columns.

Rebasing onto `main` conflicted in `statistics_from_parquet_metadata`.
The resolution keeps every change made on `main` since apache#21987, including
the omitted-null-count handling and the untrusted min/max order checks,
and changes only how each field's converter is built.

**Known limitation:** schemas with many nested fields still take the
O(N) fallback for those fields. apache/arrow-rs#9882 proposes a
constructor that takes an `Option<usize>` leaf, which would remove the
fallback. Flat wide schemas never take it.

## What is the testing strategy for this PR?

- Seven unit tests in `leaf_resolver.rs`. Five of them check, for every
field name plus some absent names, that `LeafResolver::leaf_index`
returns exactly what `parquet_column` returns and that
`LeafResolver::converter` matches `StatisticsConverter::try_new` (same
leaf index, same Arrow field, same error text). The tie-break rules are
the risky part: getting either backwards would silently attribute one
column's statistics to another, and nothing in the existing suite covers
duplicate names. The other two check specific leaf indices, and that a
duplicated name uses the first field's type. The schemas cover:
- primitives, a nested struct, a nested list, and a duplicate field name
with a different type from the first
- a primitive Arrow field over a multi-leaf Parquet group (lowest leaf
wins)
  - a Parquet root with no leaves (an empty group)
  - more Arrow fields than Parquet roots
  - an empty schema
- No `.slt` test: this changes how columns are looked up, not the
statistics produced, so the unit tests compare directly against
`parquet_column` and `try_new`.
- Existing suites: `-p datafusion-datasource-parquet --lib`, `-p
datafusion --test parquet_integration`, and the `parquet_statistics`,
`listing_table_statistics`, `parquet_sorted_statistics` and
`parquet_nested_schema_pruning` sqllogictest files.

### Benchmarks

`wide_schema` (1024 vs 8 columns × 256 files), run locally on an AMD
Ryzen 9 9950X3D. These are Criterion medians, `main` (e732212) against
this branch:

| Query | main | this PR | change |
|---|---:|---:|---:|
| Q01 wide | 694.5 ms | 522.9 ms | −24.7% |
| Q02 wide | 528.0 ms | 350.3 ms | −33.7% |
| Q03 wide | 529.2 ms | 353.4 ms | −33.2% |
| Q04 wide | 617.2 ms | 445.5 ms | −27.8% |
| Q01–Q04 narrow | — | — | within ±0.5% |

For every wide query the confidence intervals do not overlap. The two
sides ran one after the other on the same machine, not interleaved.

## Are there any user-facing changes?

No. `LeafResolver` is crate-private and the statistics produced are
unchanged. Planning against wide-schema Parquet tables is faster.

---

This PR was prepared with AI assistance (Claude Code).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Adrian Garcia Badaracco <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>

This branch has not been deployed

No deployments
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.

3 participants