Skip to content

bench: parquet scan with a table schema narrower than a nested column - #23397

Merged
adriangb merged 2 commits into
apache:mainfrom
pydantic:parquet-nested-pruning-bench
Jul 20, 2026
Merged

bench: parquet scan with a table schema narrower than a nested column#23397
adriangb merged 2 commits into
apache:mainfrom
pydantic:parquet-nested-pruning-bench

Conversation

@adriangb

@adriangb adriangb commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

When a table's declared schema is narrower than a parquet file's nested column (logical events: LIST<STRUCT<x, y>> over a physical LIST<STRUCT<x, y, +8 pads>>), the reader currently fetches and decodes every leaf of the column and discards the extra subfields in memory via the adapter-inserted cast. This is how engines like Spark (via Comet) communicate nested projection pruning to the scan — as a clipped read schema — and it is where Comet measured reading 1.35 TB where Spark read 30.9 GB for the same pruned ReadSchema.

This PR adds a benchmark that documents the current behavior as a checked-in baseline, independent of any fix:

list_struct_narrow_schema:      bytes_scanned=25.19 MB   3.45 ms
list_struct_full_schema:        bytes_scanned=25.19 MB   3.36 ms   <- narrow == full today
list_struct_physically_narrow:  bytes_scanned= 3.32 KB    164 µs   <- the floor

What changes are included in this PR?

A criterion benchmark, datafusion/core/benches/parquet_nested_schema_pruning.rs, that registers the same wide list<struct> (and top-level struct) parquet file with both its full schema and a narrower declared schema, plus a physically-narrow file as the floor, and prints the scans' bytes_scanned at setup so the IO pattern is visible alongside wall time.

Are these changes tested?

It is a benchmark; it compiles under cargo bench --no-run and runs green.

Are there any user-facing changes?

No.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KuMaRtFSPDQesuzjN5Koyd

Registers the same wide list<struct> / struct parquet file with the
file's full schema and with a narrower declared schema, plus a
physically-narrow file as the floor. On main the narrow declared
schema reads exactly as many bytes as the full schema (the whole
column is fetched and clipped in memory by the adapter-inserted cast):

    list_struct_narrow_schema:      bytes_scanned=25.19 MB
    list_struct_full_schema:        bytes_scanned=25.19 MB
    list_struct_physically_narrow:  bytes_scanned=3.32 KB

    select_events_narrow_schema      3.45 ms
    select_events_full_schema        3.36 ms
    select_events_physically_narrow  164 µs

Baseline for schema-driven nested projection pruning
(see apache/datafusion-comet#4859).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuMaRtFSPDQesuzjN5Koyd
@github-actions github-actions Bot added the core Core DataFusion crate label Jul 8, 2026
adriangb added a commit to pydantic/datafusion that referenced this pull request Jul 9, 2026
…pache#23396)

## Which issue does this PR close?

- N/A — pure code movement, split out of a larger change (see
rationale). First of a 3-PR stack: this refactor, apache#23397 (benchmark),
and the feature PR (nested schema pruning for the parquet reader,
upstream work for apache/datafusion-comet#4859).

## Rationale for this change

`row_filter.rs` is 2,100+ lines and contains two distinct concerns:
row-filter/`ArrowPredicate` construction, and the shared "expressions →
parquet leaf indices → `ProjectionMask` + projected schema" resolution
used by both the row filter and the opener's projection handling. This
PR moves the second concern into its own module,
`projection_read_plan.rs`, so upcoming changes to projection-mask
derivation can be reviewed without wading through the row-filter
machinery.

This is mergeable as standalone cleanup regardless of whether the
follow-up feature is accepted.

## What changes are included in this PR?

A move-only refactor (best reviewed with `git diff --color-moved`):

- `ParquetReadPlan`, `StructFieldAccess`, `build_projection_read_plan`,
`leaf_indices_for_roots`, `resolve_struct_field_leaves`,
`build_filter_schema`, `prune_struct_type` and the path-grouping helpers
move from `row_filter.rs` to a new `projection_read_plan.rs`, together
with their test.
- `PushdownChecker` / `PushdownColumns` become `pub(crate)` so the new
module can keep using them.
- `decoder_projection.rs` imports `build_projection_read_plan` from its
new home.

No behavior change; net +57 lines (module docs, imports, visibility).

## Are these changes tested?

Covered by existing tests (the moved unit test plus the
`datafusion-datasource-parquet` and core parquet integration suites,
which all pass unchanged).

## Are there any user-facing changes?

No.

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

https://claude.ai/code/session_01KuMaRtFSPDQesuzjN5Koyd

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

@mbutrovich mbutrovich left a comment

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.

Thanks for this. The module doc (lines 40-60) lays out the narrow/full/floor comparison clearly, and disabling dictionary encoding with distinct pad values is the right call for making the wide case actually pay the IO. I also like that query borrows &SessionContext/&str instead of cloning every iteration, which is cleaner than what the sibling bench does.

The core idea is solid and I want to see it land. My one real ask before it does is around report_bytes_scanned: the value of this PR is a checked-in baseline, but right now that baseline is printed rather than enforced, and it's read by scraping display text. If we assert the narrow == full relationship through the metrics API, this stops being a diagnostic and becomes a real regression guard, and PR-3's fix will flip the bench where you can see it. I left the specifics inline. The rest of my comments are quality suggestions I'd recommend but leave to you.


/// Print the parquet scan's `bytes_scanned` for a query so the IO pattern is
/// visible alongside the wall-time measurements.
fn report_bytes_scanned(ctx: &SessionContext, rt: &Runtime, label: &str, sql: &str) {

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.

The baseline claim, that narrow scans the same bytes as full today, is printed but never enforced, so a later change could shift it and nobody would notice. The sibling bench asserts its physical-read invariants (parquet_struct_projection.rs:167-178). Can we do the same on the relationship here?

assert_eq!(narrow_bytes, full_bytes, "narrow declared schema still reads the full column today");
assert!(physically_narrow_bytes < narrow_bytes);

The nice part: when PR-3 lands, the bench fails right here and prompts you to flip it to assert!(narrow_bytes < full_bytes), so the fix documents itself. Asserting the relationship instead of an absolute byte count keeps it from breaking on formatting changes.

(See the related note on line 233 about reading the value from the metrics API, which gives you a typed number to assert on.)

let batches = rt.block_on(df.collect()).unwrap();
let text = pretty_format_batches(&batches).unwrap().to_string();
let bytes_scanned = text
.split("bytes_scanned=")

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.

bytes_scanned is a real Count metric, so we can read it in process instead of scraping pretty_format_batches output. The current parse returns <not found> if the display format ever shifts, which is easy to miss.

let plan = df.create_physical_plan().await?;
let _ = collect(plan.clone(), task_ctx).await?;
let bytes = plan.metrics().unwrap()
    .aggregate_by_name()
    .sum_by_name("bytes_scanned")
    .map(|v| v.as_usize());

plan.metrics().unwrap().aggregate_by_name() is already used this way at tests/sql/explain_analyze.rs:162. It hands back a typed usize, which also makes it easy to assert the narrow == full baseline instead of just printing it.

.unwrap()
}

fn generate_file(

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.

This is close to parquet_struct_projection.rs:137-188, and there's a third copy in parquet_struct_query.rs. Benches can share code across criterion binaries here: benches/data_utils/mod.rs is reused by around nine benches via mod data_utils; use data_utils::.... A small shared module for generate_file, query, and a metrics-based bytes reader would keep the three parquet_struct* benches in sync. Only worth it if the duplication bugs you; just noting the option.

ctx.register_table(table, Arc::new(provider)).unwrap();
}

fn query(ctx: &SessionContext, rt: &Runtime, sql: &str) {

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.

This helper is duplicated in both sibling benches (parquet_struct_projection.rs, parquet_struct_query.rs), so it's a candidate for the shared bench module too. If it does get hoisted, keep this borrowing version rather than the cloning one.

use tempfile::NamedTempFile;
use tokio::runtime::Runtime;

const NUM_BATCHES: usize = 2;

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.

NUM_BATCHES and ROW_GROUP_ROW_COUNT (line 63) match parquet_struct_projection.rs:45,47, and ROWS_PER_BATCH is that file's WRITE_RECORD_BATCH_SIZE. Natural to fold into a shared bench module if you decide to dedupe the helpers.

/// `NUM_PAD_FIELDS` fat string fields the table schema does not mention.
fn wide_item_fields() -> Fields {
let mut fields = vec![
Field::new("x", DataType::Int64, false),

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.

x is Int64, false here but Int64, true in narrow_item_fields (line 71), while y is true in both. Reads like unintended drift for the same logical column. It's harmless today, but deriving wide from narrow keeps the shared prefix aligned by construction:

fn wide_item_fields() -> Fields {
    let mut fields: Vec<Field> =
        narrow_item_fields().iter().map(|f| f.as_ref().clone()).collect();
    for i in 0..NUM_PAD_FIELDS {
        fields.push(Field::new(format!("pad_{i}"), DataType::Utf8, false));
    }
    Fields::from(fields)
}

"y" => Arc::new(StringArray::from_iter_values(
(0..count).map(|j| format!("y-{}", seed + j)),
)) as ArrayRef,
_ => pad_values(count, seed * (i + 1)),

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.

pad_values already makes columns distinct through its per-element index, so the seed * (i + 1) multiplier isn't doing anything load-bearing. seed + i matches the additive convention used elsewhere and reads more plainly. An explicit name if name.starts_with("pad_") arm would also state the intent instead of leaning on the _ catch-all. Minor.

name = "parquet_struct_query"
required-features = ["parquet"]

[[bench]]

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.

More of a question than a request. This measures the same thing as parquet_struct_projection.rs at a complementary altitude, schema-level narrowing versus expression-level pruning, so a schema_narrowed group inside that file would put the whole story in one place and land PR-3's fix next to the assert it flips. The trade-off is a longer file and generalizing the helpers (register_parquet versus ListingTableConfig::with_schema). Fine either way; just worth a look before committing to a second binary.

Address review feedback on the parquet_nested_schema_pruning benchmark:

- Assert the checked-in baseline instead of only printing it. narrow ==
  full bytes_scanned is now enforced per dataset shape, with a comment to
  flip it to `narrow < full` once nested projection pruning lands, so the
  bench becomes a regression guard rather than a diagnostic.
- Read bytes_scanned from the typed metrics API
  (MetricsSet::aggregate_by_name().sum_by_name) by executing the physical
  plan and walking its metrics, rather than scraping EXPLAIN ANALYZE
  display text that would silently break if the format changed.
- Derive wide_item_fields from narrow_item_fields so the shared x/y
  columns match by construction, fixing the nullability drift on x.
- Seed pad columns with `seed + i` instead of `seed * (i + 1)`, which
  collapsed to the same seed for every pad column in the first batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHGBxLhJ922jnJh4X6jgRX
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@c2e3473). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #23397   +/-   ##
=======================================
  Coverage        ?   80.67%           
=======================================
  Files           ?     1088           
  Lines           ?   367591           
  Branches        ?   367591           
=======================================
  Hits            ?   296549           
  Misses          ?    53382           
  Partials        ?    17660           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mbutrovich mbutrovich left a comment

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.

Thanks @adriangb!

@mbutrovich
mbutrovich added this pull request to the merge queue Jul 20, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 20, 2026
@adriangb

Copy link
Copy Markdown
Contributor Author

Sorry I never replied, I was mulling over merging the benchmarks, pulling out helpers. TLDR: I think lets keep it split for now. If there's more duplication in the future we can merge / extract.

@adriangb
adriangb added this pull request to the merge queue Jul 20, 2026
Merged via the queue into apache:main with commit 840da05 Jul 20, 2026
38 checks passed
@adriangb
adriangb deleted the parquet-nested-pruning-bench branch July 20, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants