[ 
https://issues.apache.org/jira/browse/SPARK-59252?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Peter Toth updated SPARK-59252:
-------------------------------
    Description: 
h2. Problem

Three physical nodes recompute {{outputPartitioning}} on every call, and the 
planner asks for it many times per node. All three do real work in the body, 
and all three read only values that are fixed for the instance.

Counted by instrumenting the bodies and running {{KeyGroupedPartitioningSuite}} 
(133 tests), each measured with the previous one already memoized:

|| node || body executions as {{def}} || as {{lazy val}} ||
| {{PartitioningPreservingUnaryExecNode.outputPartitioning}} | 23,512 | 3,663 |
| {{DataSourceV2ScanExecBase.outputPartitioning}} | 33,051 | 1,262 |
| {{GroupPartitionsExec.outputPartitioning}} | 8,821 | 1,326 |

They are one chain, which is why they belong together: 19,105 of the scan's 
33,051 reads arrive through {{PartitioningPreservingUnaryExecNode}}, mostly as 
{{Project -> Filter -> scan}}. Memoizing only the scan leaves the projection 
node rebuilding its answer 23,512 times; memoizing the projection node takes 
the scan's reads down to 15,888 on its own.

h2. What each body costs

{{DataSourceV2ScanExecBase}} 
({{sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2ScanExecBase.scala}})
 sorts every partition key and hands them to {{KeyedPartitioning.apply}}, which 
wraps each one and runs a {{distinct}} over them. In a microbenchmark over a 
two-column key that measured 35 us at 100 partitions, 124 us at 1,000 and 1,515 
us at 10,000.

{{PartitioningPreservingUnaryExecNode}} 
({{sql/core/src/main/scala/org/apache/spark/sql/execution/AliasAwareOutputExpression.scala}})
 flattens and partitions the child's partitioning, allocates an 
{{AttributeSet}} and an {{ExpressionSet}} per key position, cross-products the 
per-position alternatives through {{LazyList}}s and calls 
{{kps.head.project(positions)}}, which walks all N keys building a 
{{mutable.HashMap}} whenever a position is dropped. More allocation per call 
than the scan's sort, at 18x the frequency.

{{GroupPartitionsExec}} 
({{sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala}})
 rebuilds every {{KeyedPartitioning}} in the child's partitioning through 
{{p.transform}}.

h2. Why memoizing is sound

The planner already treats {{outputPartitioning}} as a property of the node 
rather than a question to re-ask. {{ValidateRequirements}} reads one child's 
partitioning twice and compares specs built from the two reads. 
{{EnsureRequirements}} reads it at five points and threads the results between 
them, and at {{EnsureRequirements.scala:335}} it is the pruning predicate of a 
{{multiTransformDownWithPruning}}, re-evaluated per generated alternative, 
which is only sound if the answer is fixed.

{{FileSourceScanExec}}, the V1 twin of the scan node, is already {{override 
lazy val (outputPartitioning, outputOrdering)}} over a conf-derived 
{{bucketedScan}} {{lazy val}}. {{BroadcastHashJoinExec}} and 
{{AQEShuffleReadExec}} do the same for {{outputPartitioning}}.

h2. The one behaviour change to state

The scan node's body reads {{conf.v2BucketingEnabled}}, and {{conf}} is 
{{session.sessionState.conf}}, so memoizing reads it once per node instead of 
once per call. Where that is visible is a cached plan: {{CacheManager}} holds 
one plan across conf changes and sessions and {{InMemoryTableScanExec}} reads 
{{cachedPlan.outputPartitioning}}, so a cached V2 scan keeps the bucketing 
setting it was first materialised under. {{FileSourceScanExec}} already behaves 
that way.

{{PartitioningPreservingUnaryExecNode}} adds no new freezing of its own: its 
{{aliasCandidateLimit}} is already a {{val}} read at construction.

h2. Not in scope

{{DataSourceV2ScanExecBase.outputOrdering}} stays a {{def}}. Making it a {{lazy 
val}} fails {{KeyGroupedPartitioningSuite}}'s "SPARK-56241: scan with 
KeyedPartitioning reports key-derived outputOrdering", which flips 
{{spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled}} and re-reads it 
off a plan it already built.

{{supportsColumnar}} on the same trait ran 5,908 times, each walking 
{{inputPartitions}} up to three times, but it memoizes a connector call 
({{scan.columnarSupportMode()}}), which is a different question from these 
three.

h2. Context

Found while measuring 
[SPARK-59249|https://issues.apache.org/jira/browse/SPARK-59249].


  was:
h2. Problem

{{DataSourceV2ScanExecBase.outputPartitioning}} 
({{sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2ScanExecBase.scala}})
 is a {{def}}, and on the storage-partitioned join path it does real work on 
every call:

{code:scala}
override def outputPartitioning: physical.Partitioning = {
  keyGroupedPartitioning match {
    case Some(exprs) if conf.v2BucketingEnabled && 
KeyedPartitioning.supportsExpressions(exprs) &&
        inputPartitions.nonEmpty && 
inputPartitions.forall(_.isInstanceOf[HasPartitionKey]) =>
      val rowOrdering = 
KeyedPartitioning.groupedKeyRowOrdering(exprs.map(_.dataType))
      val partitionKeys =
        
inputPartitions.map(_.asInstanceOf[HasPartitionKey].partitionKey()).sorted(rowOrdering)
      KeyedPartitioning(exprs, partitionKeys)
    case _ =>
      super.outputPartitioning
  }
}
{code}

That is a sort of every partition key plus a {{KeyedPartitioning.apply}}, which 
wraps every key and runs a {{distinct}} to decide {{isGrouped}}.

h2. How often

Instrumented run of {{KeyGroupedPartitioningSuite}}: *31,922 calls across 1,167 
scan instances, a mean of 27 per scan and a maximum of 63.* {{outputOrdering}} 
on the same trait calls it as well.

Measured per call for a two-column key: 9.8 us sort + 25.1 us apply at 100 
partitions, 45.7 + 78.0 at 1,000, and 712 + 803 at 10,000.

h2. Proposal

Make it a {{lazy val}}. Measured with that change, the call count drops from 
*31,922 to 1,167*, exactly one per scan, and all 133 
{{KeyGroupedPartitioningSuite}} tests pass.

The value is stable per instance: {{keyGroupedPartitioning}} is a constructor 
field, and every implementor of {{inputPartitions}} is already a {{lazy val}} 
({{BatchScanExec}}, {{MicroBatchScanExec}}, {{ContinuousScanExec}}, 
{{RealTimeStreamScanExec}}).

{{BroadcastHashJoinExec}} and {{AQEShuffleReadExec}} already override 
{{outputPartitioning}} as a {{lazy val}}, so the shape is established.

h2. The one thing to settle first

{{conf}} is {{session.sessionState.conf}}, so a {{lazy val}} freezes 
{{v2BucketingEnabled}} at the first read rather than re-reading it per call.

That cuts both ways and is worth stating rather than assuming. A {{def}} that 
reads a mutable conf can also return *different* partitionings across calls 
within one planning run, and the planner treats {{outputPartitioning}} as a 
property of the node: {{EnsureRequirements}} and {{ValidateRequirements}} ask 
for it repeatedly and compare the answers. So memoizing may remove an 
inconsistency as well as a cost. Any test that flips the conf between calls on 
one plan instance needs checking.

h2. Context

Found while measuring 
[SPARK-59249|https://issues.apache.org/jira/browse/SPARK-59249], which removed 
one ~100 us term from this method. At 10,000 partitions that term is about 6% 
of the call, and the other 94% still runs 27 more times, so this is the larger 
remaining win at the site.


        Summary: Memoize outputPartitioning on the plan nodes that recompute it 
per call  (was: Memoize DataSourceV2ScanExecBase.outputPartitioning)

> Memoize outputPartitioning on the plan nodes that recompute it per call
> -----------------------------------------------------------------------
>
>                 Key: SPARK-59252
>                 URL: https://issues.apache.org/jira/browse/SPARK-59252
>             Project: Spark
>          Issue Type: Improvement
>          Components: SQL
>    Affects Versions: 5.0.0
>            Reporter: Peter Toth
>            Priority: Major
>
> h2. Problem
> Three physical nodes recompute {{outputPartitioning}} on every call, and the 
> planner asks for it many times per node. All three do real work in the body, 
> and all three read only values that are fixed for the instance.
> Counted by instrumenting the bodies and running 
> {{KeyGroupedPartitioningSuite}} (133 tests), each measured with the previous 
> one already memoized:
> || node || body executions as {{def}} || as {{lazy val}} ||
> | {{PartitioningPreservingUnaryExecNode.outputPartitioning}} | 23,512 | 3,663 
> |
> | {{DataSourceV2ScanExecBase.outputPartitioning}} | 33,051 | 1,262 |
> | {{GroupPartitionsExec.outputPartitioning}} | 8,821 | 1,326 |
> They are one chain, which is why they belong together: 19,105 of the scan's 
> 33,051 reads arrive through {{PartitioningPreservingUnaryExecNode}}, mostly 
> as {{Project -> Filter -> scan}}. Memoizing only the scan leaves the 
> projection node rebuilding its answer 23,512 times; memoizing the projection 
> node takes the scan's reads down to 15,888 on its own.
> h2. What each body costs
> {{DataSourceV2ScanExecBase}} 
> ({{sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2ScanExecBase.scala}})
>  sorts every partition key and hands them to {{KeyedPartitioning.apply}}, 
> which wraps each one and runs a {{distinct}} over them. In a microbenchmark 
> over a two-column key that measured 35 us at 100 partitions, 124 us at 1,000 
> and 1,515 us at 10,000.
> {{PartitioningPreservingUnaryExecNode}} 
> ({{sql/core/src/main/scala/org/apache/spark/sql/execution/AliasAwareOutputExpression.scala}})
>  flattens and partitions the child's partitioning, allocates an 
> {{AttributeSet}} and an {{ExpressionSet}} per key position, cross-products 
> the per-position alternatives through {{LazyList}}s and calls 
> {{kps.head.project(positions)}}, which walks all N keys building a 
> {{mutable.HashMap}} whenever a position is dropped. More allocation per call 
> than the scan's sort, at 18x the frequency.
> {{GroupPartitionsExec}} 
> ({{sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala}})
>  rebuilds every {{KeyedPartitioning}} in the child's partitioning through 
> {{p.transform}}.
> h2. Why memoizing is sound
> The planner already treats {{outputPartitioning}} as a property of the node 
> rather than a question to re-ask. {{ValidateRequirements}} reads one child's 
> partitioning twice and compares specs built from the two reads. 
> {{EnsureRequirements}} reads it at five points and threads the results 
> between them, and at {{EnsureRequirements.scala:335}} it is the pruning 
> predicate of a {{multiTransformDownWithPruning}}, re-evaluated per generated 
> alternative, which is only sound if the answer is fixed.
> {{FileSourceScanExec}}, the V1 twin of the scan node, is already {{override 
> lazy val (outputPartitioning, outputOrdering)}} over a conf-derived 
> {{bucketedScan}} {{lazy val}}. {{BroadcastHashJoinExec}} and 
> {{AQEShuffleReadExec}} do the same for {{outputPartitioning}}.
> h2. The one behaviour change to state
> The scan node's body reads {{conf.v2BucketingEnabled}}, and {{conf}} is 
> {{session.sessionState.conf}}, so memoizing reads it once per node instead of 
> once per call. Where that is visible is a cached plan: {{CacheManager}} holds 
> one plan across conf changes and sessions and {{InMemoryTableScanExec}} reads 
> {{cachedPlan.outputPartitioning}}, so a cached V2 scan keeps the bucketing 
> setting it was first materialised under. {{FileSourceScanExec}} already 
> behaves that way.
> {{PartitioningPreservingUnaryExecNode}} adds no new freezing of its own: its 
> {{aliasCandidateLimit}} is already a {{val}} read at construction.
> h2. Not in scope
> {{DataSourceV2ScanExecBase.outputOrdering}} stays a {{def}}. Making it a 
> {{lazy val}} fails {{KeyGroupedPartitioningSuite}}'s "SPARK-56241: scan with 
> KeyedPartitioning reports key-derived outputOrdering", which flips 
> {{spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled}} and re-reads 
> it off a plan it already built.
> {{supportsColumnar}} on the same trait ran 5,908 times, each walking 
> {{inputPartitions}} up to three times, but it memoizes a connector call 
> ({{scan.columnarSupportMode()}}), which is a different question from these 
> three.
> h2. Context
> Found while measuring 
> [SPARK-59249|https://issues.apache.org/jira/browse/SPARK-59249].



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to