feat: prune unread Parquet leaves when a nested column is cast to a narrower type - #24090
Conversation
| # Nested projection pruning: a table whose declared nested type is narrower | ||
| # than the Parquet file's physical type reads only the declared leaves. | ||
| # The bytes-scanned assertions live in the Rust tests | ||
| # (datafusion/core/tests/parquet/expr_adapter.rs); this file covers the | ||
| # end-to-end SQL correctness path. |
There was a problem hiding this comment.
Can we somehow assert that the pruning worked / the pad columns where not read?
There was a problem hiding this comment.
Added a full_schema table (no cast) and pinned bytes_scanned as a literal: 172 narrow vs 312 full. Rest stays masked, like limit_pruning.slt:103.
There was a problem hiding this comment.
Thank you. I'll try to check that we have full coverage via SLT tests and if there is any low hanging fruit etsting we can add as SLTs.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24090 +/- ##
==========================================
+ Coverage 80.91% 81.02% +0.10%
==========================================
Files 1103 1105 +2
Lines 377219 379704 +2485
Branches 377219 379704 +2485
==========================================
+ Hits 305244 307663 +2419
- Misses 53775 53821 +46
- Partials 18200 18220 +20 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
adriangb
left a comment
There was a problem hiding this comment.
This generally looks good.
One high level concern I had was why this doesn't work / isn't enabled for filter pushdown (it would seem valuable there, although maybe Spark/Comet don't use filter pushdown so it's a mute point). TLDR is filter pushdown would not accept a filter like struct_col = struct(...). It will however accept s['x'] = 5 and if s has different fields in the logical and physical schema we end up with get_field(cast(s as <logical type>), 'x') = 5 which (1) disables the optimization where we do leaf = 5 directly and (2) brings us back to the cast piercing this PR is doing. But it feels like we can handle that in a followup, even if it results in some large refactoring of this PRs code.
I've kicked off some checks for coverage and a couple review areas, I'll report back tomorrow. We should leave this open for feedback for a bit longer as well.
| /// Whole-column casts to a narrower nested type | ||
| /// (`CAST(col AS narrower_struct)`). Only collected when | ||
| /// [`Self::with_cast_collection`] enables it (projection analysis); | ||
| /// filter pushdown leaves this off. |
There was a problem hiding this comment.
Is it intentional that this not work for filter push down? I don't immediately see any reason why it couldn't work.
| # Nested projection pruning: a table whose declared nested type is narrower | ||
| # than the Parquet file's physical type reads only the declared leaves. | ||
| # The bytes-scanned assertions live in the Rust tests | ||
| # (datafusion/core/tests/parquet/expr_adapter.rs); this file covers the | ||
| # end-to-end SQL correctness path. |
There was a problem hiding this comment.
Thank you. I'll try to check that we have full coverage via SLT tests and if there is any low hanging fruit etsting we can add as SLTs.
|
I spent some time stress-testing this (mutation testing, randomised differential testing against the arrow-rs reader, and benchmarks on wide schemas) and found one correctness bug plus a few smaller things. I've opened mbutrovich#1 against your branch with fixes and tests — fold it in however you like. Two cast targets on one column read too few leaves
if whole_roots.contains(&root)
|| fallback_roots.contains(&root)
|| clipped_by_root.contains_key(&root) // <-- second cast on this root: dropped
{ continue; }If a projection consumes one column through two different narrowing casts, only the first target's leaves reach the mask, and the second cast then evaluates against a struct missing the children it names:
I originally assumed this needed a custom adapter, but it's reachable from plain SQL: -- file: s Struct<x Int64, y Utf8, pad Utf8>
SELECT CAST(s AS STRUCT<x BIGINT>), CAST(s AS STRUCT<x BIGINT, y VARCHAR>) FROM t;
-- {x: 100} {x: 100, y: NULL} <- y should be 's1'
SELECT CAST(s AS STRUCT<x BIGINT>), CAST(s AS STRUCT<pad VARCHAR>) FROM t;
-- Error: Cannot cast struct with 1 fields to 1 fields because there is no field name overlapBoth return correct results with Smaller things
PerformanceThree things weren't
New benchmark
The feature holds up well at that width: 90.8 ms narrowed vs 166.2 ms full, ~45% less wall time. Coverage
I also added two seeded randomised differential harnesses — one checking SLT coverage went from 110 to ~380 lines, moving most of the Rust-only assertions into SQL: One thing that isn't yoursWhile writing the SLT tests I hit a pre-existing soundness bug: with Generated by Claude Code |
Does it address the earlier review comments? |
`build_read_plan_with_cast_clipping` skipped every cast access on a root
it had already clipped:
if whole_roots.contains(&root)
|| fallback_roots.contains(&root)
|| clipped_by_root.contains_key(&root) // <-- second cast: dropped
{ continue; }
When a projection consumes one column through two *different* narrowing
casts, only the first target's leaves reach the mask. The second cast
then evaluates against a struct missing the children it names: for
overlapping targets `cast_column` null-fills them (silently wrong
results), and for disjoint targets `validate_struct_compatibility`
rejects the cast so the query fails where it would have succeeded
without pruning.
This is reachable from plain SQL, not only through a custom
`PhysicalExprAdapter`: `ProjectionExec` is merged into the scan via
`ParquetSource::try_pushdown_projection`, so a query-level
`CAST(col AS STRUCT<...>)` lands in the scan's projection and reaches
the same analysis as an adapter-inserted one.
A second cast with a *different* target now demotes the root to a full
read. Identical repeated targets -- the shape the expression adapter
produces when one column is referenced several times -- still clip.
The `clipped_by_root` test in the `get_field` filter becomes a
`debug_assert`: a root carrying a `get_field` access is put into
`fallback_roots` before any clip is attempted, so it can never also be
clipped, and asserting that catches a future reordering instead of
silently changing which leaves are read.
Covered entirely in `parquet_nested_schema_pruning.slt` -- the query-level
cast path is reachable from SQL and the assertions are exact result
comparisons, so there is no reason to also carry Rust copies of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defensive fixes in the same area.
`clip_type` could return `Struct([])` for a name-matched child whose own
children shared no name with their target, while still keeping that child
in the emitted type. arrow-rs *drops* a struct child whose leaves are all
masked out (`parquet/src/arrow/schema/complex.rs`,
`if children.is_empty() { return Ok(None) }`), so `projected_schema`
promised a field the decoder does not produce.
This is not reachable through the default stack:
`validate_struct_compatibility` rejects such a cast at planning time and
the logical planner rejects the user-written equivalent. But the module
doc's safety argument depends on a *caller* invariant, and the motivating
use case is a custom `PhysicalExprAdapter`, so `clip_for_cast` now
detects the empty level and declines to clip. A test pins the arrow-rs
behaviour the argument rests on, and the module doc is updated to say
what is now enforced here rather than assumed.
Separately, `leaves_by_root[root]` is a panicking `BTreeMap` index on a
path where the same function already guards the "root with no parquet
leaves" case forty lines earlier (`.map_or(&[][..], ...)` plus the
`count_leaves` guard) before routing the fallback root straight into the
index. I could not construct a file that reaches it, so it is latent, but
the surrounding code already treats the case as possible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three steps in the projection analysis were not proportional to the projected columns. `clip_type` matched struct fields with a linear `find` per physical child -- Theta(N*M) `String` comparisons per struct level, about 375k for a 1000 -> 500 subfield struct. Build a name map above a small width, which is what Spark's `ParquetReadSupport.clipParquetGroupFields` does (unconditionally; the threshold here avoids the allocation for the narrow structs that dominate in practice). The secondary fast-path gate scanned every field of the *file* schema. Widening it from `matches!(.., Struct(_))` to `contains_struct` is the right fix for the `List<Struct>` hole, but it also made `Map`, `Dictionary<_, Struct>` and every array-of-records column send the whole projection down the `PushdownChecker` path, whose `Schema::index_of` is a linear name scan per column node -- O(|exprs| * |schema|), paid once per file opened. Gate on the *projected* roots instead: O(projected), and the `List<Struct>` fix is preserved. Columns whose `index` does not line up with the file schema fall through to the name-resolving path, so stale `Column` indices are still handled. The `get_field` root loop re-looked-up each root by name in the schema `build_filter_schema` had just built, O(G^2). That schema emits one field per accessed root in ascending root order, which is the order the root set iterates in, so pair them positionally instead. This also removes a latent wrong-field pick when two roots share a name. Measured with a 1000-column / 1000-subfield benchmark over 32 small files, so per-file planning dominates: -13.6% for a wide struct present but not projected, -7.2% for a 1000 -> 500 subfield clip, and no change on two untouched-path controls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test-only; no production code changes. ## Rust e2e tests -> SLT The whole `nested_projection_pruning` module in `datafusion/core/tests/parquet/expr_adapter.rs` is reachable from SQL, so it moves into `parquet_nested_schema_pruning.slt` and the Rust copies go away (-668 lines). Every test keeps an equal or stronger assertion: | Rust test | SLT replacement | | --- | --- | | `prunes_list_of_struct` | `select events from narrow` values + 172 vs 312 bytes | | `prunes_top_level_struct` | `select s from narrow` values + 146 vs 219 bytes | | `preserves_struct_nullability` | `s IS NULL` per row, incl. a NULL struct row | | `prunes_get_field_on_narrowed_struct` | `select s['x']` values + 146 vs the full-leaf 219 baseline | | `prunes_mixed_struct_and_subfield_access` | `select s, s['y']` values + 146 vs 219 bytes | | `prunes_with_filter_pushdown` | `pushdown_filters = true` section, 219 vs 292 bytes | | `mixed_files_narrow_and_wide` | narrow+wide files in one scan (values; the Rust test asserted no bytes either) | | `comet_4859_two_level_nested_list_regression` | the same two-level shape + 381 vs 1.05 K bytes | Two things get better in the move. The comet#4859 fixture gives its dropped siblings (`feature_map`, `diagnostics`, `latency_parts`, `pad`, and the dropped top-level columns) real data instead of NULLs, so the byte gap is attributable to the clip rather than to NULL columns being cheap; the resulting 381 vs 1.05 K is a wider margin than the Rust `narrow * 2 < full` ratio it replaces. And the surviving struct fields at both nesting levels are now asserted by printing the values, rather than by `assert_eq!(fields().len(), 3)`. The tradeoff: a literal `bytes_scanned` is more sensitive to encoding changes in arrow-rs than a ratio. That is deliberate here -- the file's existing assertions already work this way, and a silent widening of a clipped read should fail loudly. ## Mutation-testing gaps `cargo-mutants` over `nested_schema_pruning.rs` and `projection_read_plan.rs`: 121 mutants, 82 viable, 16 survivors. Eight were real gaps; each now has a test, verified by re-applying the mutation by hand: * `start + o` -> `start - o` when rebasing clip offsets onto absolute leaf indices. Every existing clip test cast to the struct's *first* field, where the only offset is 0 and the two are identical. * The entire `get_field_accesses` branch of `build_read_plan_with_cast_clipping` was dead in tests: nothing combined a cast on one root with a field access on another. * Dropping `!whole_roots` from the `get_field` filter needs a root referenced both as a whole column and via `get_field` while another root is clipped. * `contains_struct` had no direct test; `count_leaves`'s dictionary assertion was vacuous (`Dictionary(Int32, Utf8)` counts 1 with or without the arm) and `RunEndEncoded` was untested. Both now use struct values, where dropping the arm misaligns every later leaf index. * Duplicate physical field names under one target field. Three survivors around `LINEAR_FIELD_SCAN_MAX` are equivalent by construction: the map and linear matching paths agree, which is what their survival demonstrates. One correction to an existing comment: `full_schema` says no cast is inserted. One *is* -- `VARCHAR` maps to `Utf8View` in the SLT context while the file holds `Utf8` -- it is just not a narrowing cast, so every leaf is still read and the bytes baseline is valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It addresses the coverage aspect, it doesn't implement this for filter pushdown, I think we should defer that to followups. If we merge mbutrovich#1 into here we can then merge this PR into main and track a couple of followups, namely:
|
4aab9fc to
9f7fb5a
Compare
…-m2xw4u fix(parquet): correct nested clipping when one column has two cast targets, plus coverage
Which issue does this PR close?
Rationale for this change
When a table declares a nested column narrower than the Parquet file's physical type, DataFusion reads every leaf of the column and drops the extra subfields in memory instead of skipping them at read time.
This is a port of #23398 onto current main. #23398 (stacked on the merged #23396 and #23397, superseding an earlier attempt at #23392) implements the fix and was reviewed favorably, but has merge conflicts against main since a follow-up refactor moved PushdownChecker and PushdownColumns into projection_read_plan.rs, and has four unanswered review comments. This PR reimplements the same approach against current main and resolves those four comments by construction:
What changes are included in this PR?
Are these changes tested?
Are there any user-facing changes?
No API changes and no new configuration option. Behavior is IO reduction only, results are unchanged.