ohad-definity opened a new pull request, #59706:
URL: https://github.com/apache/spark/pull/59706

   ### What changes were proposed in this pull request?
   
   Nested schema pruning stops at a `Generate`: `SchemaPruning` (V1) and 
`V2ScanRelationPushDown` only see the `Project`/`Filter` chain directly over a 
scan, and the only path through an `explode` is 
`GeneratorNestedColumnAliasing`, which pushes a *single* field accessor into 
the generator input. So:
   
   - reading two or more fields of an exploded element reads the whole element 
(SPARK-34956, the `TODO` in `GeneratorNestedColumnAliasing`);
   - with two chained explodes, e.g. `explode(items) AS item` then 
`explode(item.parts) AS part`, reading `part.pid` reads all of `parts` 
(SPARK-47230);
   - an exploded element that is not read at all still reads the whole array 
(SPARK-47230).
   
   This PR lets both scan pruning rules look through a chain of `explode` / 
`posexplode` generators (inner or outer) over a scan:
   
   1. **Collect.** Going down from the top generator, the fields read from each 
generator's element (above it, through nested field accesses, in projections 
and filters) become a requirement on its input, built with 
`SelectedField.withDataType`. The input is a scan column, or the element of a 
generator below, which then adds those fields to its own requirement. An 
element that is not read keeps its smallest field, for the array sizes (as in 
`GenerateOptimization`); an element used whole needs its input whole.
   2. **Narrow.** The scan is pruned by the existing code 
(`prunePhysicalColumns` for V1, `PushDownUtils.pruneColumns` for V2), with the 
generators' root fields as extra requested fields.
   3. **Re-resolve.** The chain is re-resolved bottom-up against the narrower 
types: references take their child's new type, nested field accesses are 
re-resolved with `ProjectionOverSchema`, generator outputs take the new element 
type. If the result is unresolved or the output schema would change, the plan 
is left unchanged.
   
   The last commit handles the `size(arr) > 0 AND isnotnull(arr)` filters 
inferred for a non-outer explode, which otherwise read the whole element of a 
generator in the chain (`RewriteSizeOfArrayStruct` only rewrites scan columns) 
or a whole nested field like `items.parts`:
   - within the chain, `size(x)` reads the smallest field of the outermost 
array of structs of the same length, e.g. `size(items.parts)` → 
`size(items.qty)`;
   - `identifyRootFields` takes the generators' root fields, so they also drop 
optional reads such as `isnotnull(items.parts)`.
   
   Without it, a plain (non-outer) `explode(items) … explode(item.parts)` 
reading `part.pid` still reads all of `parts`: 63 MB without it vs 0.2 MB with 
it (two inner fields: 63 MB vs 0.4 MB). Outer chains and single explodes do not 
need it.
   
   It can be split into a follow-up PR if preferred.
   
   The behavior is guarded by a new internal flag 
`spark.sql.optimizer.nestedSchemaPruning.throughGenerate.enabled` (default 
`true`). `GeneratorNestedColumnAliasing` is unchanged; for a single field it 
still rewrites the generator first, and this path then has nothing left to 
prune.
   
   #### Related work
   
   - Builds on `SelectedField.withDataType` from SPARK-57022 (nested pruning 
through `transform`) to express what a generator's input needs, and on 
`ProjectionOverSchema` to re-resolve.
   - Closes the `TODO(SPARK-34956)` left in `GeneratorNestedColumnAliasing` 
(SPARK-34638, which itself follows SPARK-27707).
   - Uses the same "smallest field" choice as `GenerateOptimization` and 
`RewriteSizeOfArrayStruct` (SPARK-58735) when only array sizes are needed.
   - Earlier attempts at SPARK-47230, all closed without review: #45953 (a 
`Project`/`Filter`/`Generate` pattern in `NestedColumnAliasing`, one field per 
attribute), and #52288, #52598, #54283 by @IgorBerman. #52288 and #54283 
rebuild the generator input per row (`transform`/`named_struct`, or a new 
`arrays_zip`-style expression); #52598 narrows the scan like this PR, but 
repairs the plan with a separate ordinal-fixing rule and runtime name-based 
ordinal lookup in codegen. This PR keeps the change inside the two existing 
scan pruning rules, adds no expression, and re-resolves with the existing 
`ProjectionOverSchema`. Co-authored with @IgorBerman: many of the test shapes 
here follow his suites in #52598 and #54283.
   
   #### Alternatives considered
   
   The `TODO(SPARK-34956)` in `GeneratorNestedColumnAliasing` suggests 
rebuilding an array of structs from the fields read: `explode(items)` → 
`explode(transform(items, x -> named_struct('a', x.a, 'b', x.b)))`, which 
nested pruning through `transform` (SPARK-57022) would then narrow at the scan. 
We did not take that route because:
   
   - **It costs work on every row.** `transform` builds a new struct for every 
element and a new array for every row, on top of reading the narrowed column. 
Narrowing the scan type and re-resolving the plan costs nothing per row: the 
scan returns rows already in their final shape.
   - **It grows with depth.** Two chained explodes need a nested `transform` 
and a struct rebuilt at each level, and every rebuilt struct's output type must 
still match what the operators above expect.
   - **The plan changes for everyone.** The rewrite inserts new expressions 
into the user's plan even where the scan cannot prune (e.g. a source without 
nested pushdown), whereas this change only alters types when the scan is 
actually narrowed.
   - **It does not cover the remaining shapes.** An unread element (only array 
sizes needed) and the inferred `size()` / `isnotnull()` filters would still 
need the handling in this PR.
   
   Measured on master today, a hand-written `explode(transform(...))` is not 
pruned at all: nested pruning through `transform` only applies in a projection 
directly over the scan, not in a generator's input. Two fields of one explode 
read 179 MB with the rewrite vs 27 MB with this change; one field through two 
chained explodes reads 63 MB vs 0.2 MB. So the rewrite would also need to move 
the `transform` into a projection below the generator. Even written that way by 
hand:
   
   | Query (Parquet, bytes read, median of 3) | `transform` in a projection 
over the scan | This change |
   |---|---|---|
   | two fields of one `explode` | 27.1 MB, 1016 ms | 27.1 MB, 734 ms |
   | one field through two chained explodes (nested `transform`) | 63 MB, 1618 
ms | 0.2 MB, 608 ms |
   
   At equal bytes the struct rebuild costs time (about 1.4× here, on a shared 
CI runner, so treat it as indicative), and the nested `transform` is not pruned 
below the first level.
   
   
   
   ### Why are the changes needed?
   
   Exploding arrays of structs is common (event and log data, nested ML 
features), and the inner structs are often wide. Today such queries read every 
field of the exploded structs. With this change (Parquet, `/proc/self/io` bytes 
read, flag off → on):
   
   | Query | Bytes read | Time |
   |---|---|---|
   | two fields of one `explode`, 1M rows × 5 | 179 MB → 27 MB | 2.7 s → 0.7 s |
   | `explode` output unused | 183 MB → 14 MB | 1.6 s → 0.4 s |
   | one field through two chained explodes, 200k × 5 × 5 | 63 MB → 0.2 MB | 
1.2 s → 0.6 s |
   | two inner fields through two chained explodes | 63 MB → 0.4 MB | 1.1 s → 
0.6 s |
   
   On cloud storage, where time is dominated by bytes read, the same pruning 
(implemented as an optimizer extension, not this patch) gave on Databricks 
Runtime, Delta tables on S3 with the disk cache off, for two fields read across 
two chained explodes (median of 3):
   
   | Runtime | Bytes read | Time |
   |---|---|---|
   | DBR 13.3 (Spark 3.4) | 557 MB → 15.6 MB | 7.08 s → 1.18 s |
   | DBR 17.3 (Spark 4.0) | 314 MB → 4.9 MB | 3.60 s → 1.05 s |
   | DBR 18.0 (Spark 4.1) | 314 MB → 4.9 MB | 2.84 s → 1.10 s |
   
   ### Does this PR introduce _any_ user-facing change?
   
   No change in query results. Queries that read nested fields through 
`explode` / `posexplode` read fewer columns from Parquet and ORC (V1 and V2), 
visible in `ReadSchema`. The existing SPARK-34638 / SPARK-41961 tests that 
documented "Currently we don't prune multiple field case" now expect the 
narrower schema.
   
   ### How was this patch tested?
   
   New tests in `SchemaPruningSuite` (run for Parquet and ORC, V1 and V2, 
vectorized and not):
   - one level: multiple fields, a filter between the generator and the 
projection, outer `posexplode`, unused element (outer and not), aggregate, 
case-insensitive resolution, element used whole (not pruned), flag off, single 
field still rewritten by `GeneratorNestedColumnAliasing`;
   - two chained levels (`orders.items[].parts[]`): one inner field (outer and 
not), multiple and non-adjacent inner fields, fields of both levels, two 
`posexplode`s and mixed `explode`/`posexplode` chains, aggregate, inner element 
used whole and outer element used whole (not pruned), a generator whose input 
is not a field path (`explode(array(item.sku, 'x'))`);
   - references written in another case than the generator alias 
(`FRIEND.middle` for `friend`), at one and two levels, since re-resolution 
matches references by `exprId`;
   - other shapes: a filter on the exploded column itself, an 
`array<array<struct>>` exploded twice, an array inside a struct, an explode 
above an aggregate (not pruned through), an explode over a CTE selecting all 
columns, and a streaming query over a file source. Several of these follow test 
cases from #52598 and #54283.
   
   Updated expectations in the SPARK-34638 / SPARK-41961 tests. New `Explode` 
cases in `NestedSchemaPruningBenchmark` (flag off / on). Benchmark result files 
will be regenerated with the `Run benchmarks` workflow.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Code (Claude Opus 5.5)
   
   🤖 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]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to