sunchao commented on code in PR #59706:
URL: https://github.com/apache/spark/pull/59706#discussion_r4363269349
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/SchemaPruning.scala:
##########
@@ -133,6 +134,53 @@ object SchemaPruning extends SQLConfHelper {
} ++ rootFields
}
+ /**
+ * Gets the root field that the array `input` of an `explode` or
`posexplode` needs, when the
+ * generator's element output `element` is only read by `exprs` through
nested field accesses.
+ * For example, for `explode(items) AS item` read as `item.a` and `item.b`,
with
+ * `items: array<struct<a, b, c>>`, it returns `items: array<struct<a, b>>`.
+ *
+ * `elementFields` are fields of the element read by other means, e.g. as
the input of a
+ * generator above this one, named like the element.
+ *
+ * If nothing of the element is read, only the array sizes are needed, so a
single smallest
+ * field is kept, as in
[[org.apache.spark.sql.catalyst.optimizer.GenerateOptimization]].
+ *
+ * If the element is used whole, `input` is needed whole, e.g. `items.parts`
for
+ * `explode(items.parts)`, which still narrows `items` to `parts`.
+ *
+ * `input` must be expressed over the scan's output. Returns None if `input`
is not an access
+ * path [[SelectedField]] can narrow.
+ */
+ def getGeneratorInputRootField(
+ input: Expression,
+ element: Attribute,
+ exprs: Seq[Expression],
+ elementFields: Seq[StructField] = Nil): Option[RootField] =
input.dataType match {
+ case ArrayType(elementType, containsNull) =>
+ // Rename the element so that its fields can be told apart from other
columns by name.
+ val name = s"${element.name}#${element.exprId.id}"
+ val renamed = exprs.map(_.transform {
+ case a: Attribute if a.exprId == element.exprId => a.withName(name)
+ })
+ val fields = identifyRootFields(Nil, renamed,
+ elementFields.map(f => RootField(f.copy(name = name), derivedFromAtt =
false)))
+ .filter(_.field.name == name)
+ val prunedElementType = (fields, elementType) match {
+ // not read: only the array sizes are needed
+ case (Seq(), StructType(structFields)) if structFields.length > 1 =>
+ StructType(Array(structFields.minBy(_.dataType.defaultSize)))
+ case (Seq(), _) => elementType
+ // used whole: the input is needed whole, which still narrows what it
is extracted from
+ case _ if fields.exists(_.derivedFromAtt) => elementType
+ case _ =>
+ pruneSchema(StructType(Array(StructField(name, elementType))),
fields).head.dataType
+ }
+ SelectedField.withDataType(input, ArrayType(prunedElementType,
containsNull))
Review Comment:
[P1] Preserve lambda requirements inside the generator input
`SelectedField.withDataType` also walks through `ArrayFilter` and
`ArraySort`, but this helper only collects reads of the generator's output.
Unlike the ordinary `getRootFields` path, it never adds
`getArrayReturningHigherOrderFunctionRootFields(input)`. Whole-element
predicates/comparators can therefore silently change meaning when
`ProjectionOverSchema` rebinds their lambda variables to the narrower element
type.
For example, use a Parquet/ORC table `t` with `items:
array<struct<a:int,b:int,c:int>>` containing one row with `[{a:2,b:10,c:100},
{a:1,b:20,c:200}]`:
```sql
SELECT pos, item.b, item.c
FROM t
LATERAL VIEW OUTER posexplode(array_sort(items)) v AS pos, item
```
The default comparator must sort by `a` first, producing `(0,20,200)` and
`(1,10,100)`. This path requests only `items<b,c>`; both comparator variables
are rebuilt as `struct<b:int,c:int>`, so it sorts by `b` instead, producing
`(0,10,100)` and `(1,20,200)`. The position column makes this a changed result,
independently of final row ordering. Selecting two fields avoids the old
single-field generator rewrite. There is no inferred nonempty-array filter in
this example to retain the missing requirements.
The rebuilt plan remains resolved with the same output schema, so the
fallback does not catch this. The same omission affects whole-element `filter`
predicates and the full-value error parameters of strict nullable sort
comparators, which the existing collector explicitly protects.
Please include all reads performed by accepted input wrappers, or
conservatively decline those wrappers until supported. Add V1/V2
outer-generator regressions for default struct sorting and whole-element
filtering, plus a field-specific lambda that should still prune. This
counterexample is established by source tracing at `11a1fbbd`; I did not
execute it locally.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -990,7 +991,26 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
}
def pruneColumns(plan: LogicalPlan): LogicalPlan = plan.transform {
+ case op @ SchemaPruning.GeneratorsOverScan(_, _, sHolder:
ScanBuilderHolder) =>
+ // Fields read from the generators' elements above them narrow the scan
below them. If the
+ // plan above cannot be re-resolved, the scan is pruned again on its own
below, which calls
+ // `pruneColumns` on the scan builder a second time.
+ SchemaPruning.pruneThroughGenerators(op, sHolder) { (scan,
otherProjects, inputRootFields) =>
+ Some(pruneScan(scan.projects, scan.filtersStayUp,
scan.filtersPushDown, sHolder,
Review Comment:
[P2] Avoid retaining the size projection when its array stays unpruned
This callback always returns `Some`, even when `pruneScan` returns the
original nested schema. Consequently the speculative `lengthOnly` rewrites are
retained on a successful no-op V2 path.
For a V2 Parquet/ORC table with `items:
array<struct<id:int,parts:array<struct<a:int,b:int>>>>`, consider:
```sql
SELECT item, size(item.parts)
FROM t
LATERAL VIEW OUTER explode(items) v AS item
```
Selecting `item` whole requires every field, so there is no nested scan
saving. However, the rewrite leaves `size(item.parts.a)`. `Size(item.parts)`
just reads `numElements()`; `GetArrayStructFields(item.parts, a, ...)` first
allocates an array and loops over every part, in both interpreted and generated
code. This adds O(number of parts) work and temporary storage per item to an
otherwise constant-time length read. The existing `RewriteSizeOfArrayStruct`
does not introduce this work because it excludes roots produced by generators.
Please preserve the original size expression when the relevant container
remains required whole or the connector does not narrow it, and add this V2
no-pruning control. Checking only whether *some* scan field changed would miss
the same unnecessary work when an unrelated field is pruned. This is a
source-established allocation/complexity regression at `11a1fbbd`, not a
measured elapsed-time slowdown.
--
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]