Ilya created SPARK-59204:
----------------------------

             Summary: [SQL][AQE][CACHE] Severe planning-time regression in 
iterative cached DataFrame workload after Spark 3.4.3 -> 4.1.3
                 Key: SPARK-59204
                 URL: https://issues.apache.org/jira/browse/SPARK-59204
             Project: Spark
          Issue Type: Bug
          Components: SQL
    Affects Versions: 4.1.3
         Environment: Regression workload:
 * Apache Spark: 4.1.3

 * Scala: 2.13

 * Java: 17

 * CatBoost Spark: {{ai.catboost:catboost-spark_4.1_2.13:1.2.10}}

Regression baseline:
 * Apache Spark: 3.4.3

 * Scala: 2.13

The standalone reproducer itself uses only Spark DataFrame APIs and synthetic 
data.
h2.  
            Reporter: Ilya
         Attachments: spark_aqe_cache_plan_growth_reproducer.py

We observe a severe performance regression after migrating an iterative PySpark 
DataFrame workload from Apache Spark 3.4.3 to Spark 4.1.3.

The workload repeatedly:
 # derives features from the current cached state;
 # derives a prediction from those features;
 # appends the prediction back to the previous state;
 # repartitions the new state;
 # caches and materializes it with count().

The previous cached state is therefore referenced both directly by the next 
state and indirectly through the feature/prediction branch.

Spark 3.4.3 shows gradual degradation as this recursive dependency becomes more 
complex.

Spark 4.1.3 with AQE enabled during cache materialization shows dramatically 
stronger nonlinear degradation.

Disabling AQE only while materializing the newly cached iterative state reduces 
the degradation by more than an order of magnitude.

A standalone PySpark reproducer without CatBoost, Iceberg, S3, Airflow, or 
production data is attached.
h2. Original workload regression

The production workload was first tested to isolate the dependency upgrade from 
the Spark upgrade.

Results:
{code:java}
Spark 3.4.3 + CatBoost Spark 1.2.8
~48 minutes

Spark 3.4.3 + CatBoost Spark 1.2.10
~48 minutes
{code}
Therefore, upgrading CatBoost from 1.2.8 to 1.2.10 does not reproduce the 
regression on Spark 3.4.3.

After upgrading Spark:
{code:java}
Spark 4.1.3 + CatBoost Spark 1.2.10
AQE enabled during iterative cache materialization
{code}
the workload exhibits severe degradation and, with the default 
spark.sql.maxPlanStringLength, eventually fails with:
{code:java}
java.lang.OutOfMemoryError: Java heap space
{code}
h2. Effect of spark.sql.maxPlanStringLength

Changing only:
{code:java}
spark.sql.maxPlanStringLength=8192
{code}
prevents the driver OOM.

However, it does not fix the underlying performance degradation.

The application continues running, but late iterations become extremely 
expensive and the full workload takes approximately 3-4 hours instead of 
approximately 48 minutes on Spark 3.4.3.

This OOM behavior appears related to SPARK-50992 and is being reported there 
separately.
h2. Original workload pattern

The production workload is conceptually similar to:
{code:python}
extended = state.union(dummy_rows)

features = (
    feature_pipeline
    .transform(extended)
    .cache()
)

features.count()

prediction = model.transform(features)

state = (
    state
    .union(prediction)
    .repartition(200, "key1", "key2")
)

state = state.cache()
state.count()
{code}
The previous state is therefore referenced:
 # directly in the next outer Union;
 # indirectly again through features -> prediction.

h2. Production planning-time degradation

One timing measurement surrounds only the lazy model transform() call.

There is no Spark action inside this timing window, so this is not 
executor-side model inference time.

With Spark 4.1.3 and AQE enabled during cache materialization:
{code:java}
step 11:    0.322 s
step 12:    0.703 s
step 13:    0.989 s
step 14:    1.570 s
step 15:    2.956 s
step 16:    5.797 s
step 17:   11.336 s
step 18:   22.606 s
{code}
In another run the growth continued:
{code:java}
step 13:    0.852 s
step 14:    1.593 s
step 15:    2.982 s
step 16:    5.828 s
step 17:   11.794 s
step 18:   22.561 s
step 19:   45.476 s
step 20:   94.096 s
step 21:  179.974 s
step 22:  367.955 s
{code}
The later sequence is close to doubling on each iteration.

Execution time also starts degrading.

For example:
{code:java}
feature execution:

step 16:   38.8 s
step 18:   59.1 s
step 19:   84.0 s
step 20:  134.5 s
step 21:  240.9 s
step 22:  426.4 s
{code}
and iterative-state materialization:
{code:java}
step 16:   93.7 s
step 18:  122.6 s
step 20:  206.2 s
step 21:  296.2 s
step 22:  534.4 s
{code}
h2. AQE workaround in the original workload

The following scoped workaround dramatically improves the workload:
{code:python}
previous_aqe = spark.conf.get(
    "spark.sql.adaptive.enabled"
)

try:
    spark.conf.set(
        "spark.sql.adaptive.enabled",
        "false",
    )

    state = state.cache()
    state.count()

finally:
    spark.conf.set(
        "spark.sql.adaptive.enabled",
        previous_aqe,
    )
{code}
AQE remains enabled during the rest of the application.

With this workaround on Spark 4.1.3:
 * all 26 iterations complete;
 * feature calculation remains approximately stable;
 * state execution/materialization remains approximately 74-83 seconds across 
the iterations;
 * sum of measured iteration times is approximately 48 minutes;
 * full application runtime is approximately 60 minutes.

We also observe substantially fewer nested Union branches in cached-plan 
representations compared with Spark 4.1.3 when AQE remains enabled during 
materialization.
h2. Standalone PySpark reproducer

The attached file:
{code:java}
spark_aqe_cache_plan_growth_reproducer.py
{code}
uses only synthetic data and Spark DataFrame APIs.

It intentionally reproduces this dependency structure:
{code:java}
previous state -------------------------------+
     |                                       |
     +--> derived features --> prediction ----+--> union
                                                   |
                                              repartition
                                                   |
                                                 cache
                                                   |
                                                 count
{code}
The reproducer can be run with AQE enabled:
{code:bash}
spark-submit   spark_aqe_cache_plan_growth_reproducer.py   --mode aqe_on   
--retention isolated
{code}
or with AQE disabled only around state cache materialization:
{code:bash}
spark-submit   spark_aqe_cache_plan_growth_reproducer.py   --mode 
scoped_aqe_off   --retention isolated
{code}
retention=isolated unpersists the previous state and features only after the 
new state has been fully materialized.

This was done to reduce accumulated cache-memory usage as a confounding factor.
h2. Standalone reproduction: Spark 4.1.3

With AQE enabled:
{code:java}
state materialization:

step 1:    ~2.0 s
step 4:    ~2.9 s
step 5:    ~5.6 s
step 6:   ~21.0 s
step 7:  ~110-118 s
{code}
The result was reproduced in multiple Spark 4.1.3 runs.

For example, one run produced:
{code:java}
step 1:    1.92 s
step 4:    2.66 s
step 5:    5.62 s
step 6:   21.05 s
step 7:  115.67 s
{code}
Another produced:
{code:java}
step 1:    3.49 s
step 4:    7.72 s
step 5:    7.64 s
step 6:   25.65 s
step 7:  117.87 s
{code}
Iteration 8 becomes extremely slow.
h2. Standalone reproduction: Spark 4.1.3 with scoped AQE disable

On the same Spark 4.1.3 environment, disabling AQE only while materializing the 
newly cached state dramatically reduces the degradation.

For example:
{code:java}
step 1:   2.38 s
step 4:   2.30 s
step 7:   5.39 s
{code}
The equivalent AQE-enabled iteration 7 requires approximately:
{code:java}
109-118 s
{code}
versus:
{code:java}
5.4 s
{code}
with AQE disabled only during materialization.

This is approximately a 20x difference at iteration 7.

The workaround does not completely eliminate the inherent cost of the 
recursively growing dependency structure, but it dramatically reduces the Spark 
4.1.3-specific degradation.
h2. Standalone comparison with Spark 3.4.3

The same synthetic workload on Spark 3.4.3 with AQE enabled also becomes 
gradually more expensive, but the degradation is substantially less aggressive.

Example:
{code:java}
state materialization:

step 3:   6.62 s
step 5:  10.32 s
step 9:  20.46 s
{code}
The shape of the degradation differs significantly from Spark 4.1.3:
{code:java}
Spark 3.4.3:
gradual growth

Spark 4.1.3 + AQE:
~2 s -> ~5 s -> ~21 s -> ~115 s
{code}
Therefore, the issue is not that recursive iterative plans have zero cost on 
Spark 3.4.3.

Rather, Spark 4.1.3 with AQE enabled during cached-state materialization 
amplifies this cost dramatically.
h2. Physical-plan diagnostics

Initially, inspecting only the outer optimized logical plan appeared to show no 
growth.

At iterations 2, 4, and 6 it remained approximately:
{code:java}
optimized nodes:       5
Union:                 1
InMemoryRelation:      2
{code}
However, InMemoryRelation hides the physical cached plan.

We therefore recursively traversed:
 * AdaptiveSparkPlanExec.executedPlan
 * QueryStageExec.plan
 * InMemoryTableScanExec.relation.cachedPlan

without calling treeString() or explain().
h3. Recursive physical-plan references

On Spark 4.1.3 with AQE enabled, this exposes rapid recursive growth.
{code:java}
step 1:
all node references:              51
AdaptiveSparkPlanExec:             5
TableCacheQueryStageExec:          3
InMemoryTableScanExec:             3
ShuffleQueryStageExec:             3
Union:                             2

step 2:
all node references:             147
AdaptiveSparkPlanExec:            15
TableCacheQueryStageExec:         13
InMemoryTableScanExec:            13
ShuffleQueryStageExec:             7
Union:                             6

step 4:
all node references:             723
AdaptiveSparkPlanExec:            75
TableCacheQueryStageExec:         73
InMemoryTableScanExec:            73
ShuffleQueryStageExec:            31
Union:                            30

step 5:
all node references:            1491
AdaptiveSparkPlanExec:           155
TableCacheQueryStageExec:        153
InMemoryTableScanExec:           153
ShuffleQueryStageExec:            63
Union:                            62

step 6:
all node references:            3027
AdaptiveSparkPlanExec:           315
TableCacheQueryStageExec:        313
InMemoryTableScanExec:           313
ShuffleQueryStageExec:           127
Union:                           126
{code}
For several node types this is very close to a recurrence of:
{code:java}
N(step + 1) ~= 2 * N(step) + constant
{code}
For example:
{code:java}
TableCacheQueryStageExec:

73
153
313
{code}
and:
{code:java}
Union:

30
62
126
{code}
The traversal time itself also grows:
{code:java}
step 1:  0.065 s
step 2:  0.089 s
step 4:  0.357 s
step 5:  0.626 s
step 6:  1.247 s
{code}
These counts represent recursive physical-plan references reached during 
traversal.

They should not be interpreted as proof that every reference corresponds to a 
distinct JVM object.

However, they demonstrate that the small outer InMemoryRelation plan hides a 
rapidly expanding recursive physical-plan structure involving 
AdaptiveSparkPlanExec, TableCacheQueryStageExec, and InMemoryTableScanExec.

The rapid growth correlates strongly with the cache-materialization slowdown.
h2. Other experiments
h3. spark.sql.optimizer.canChangeCachedPlanOutputPartitioning

We tested:
{code:java}
spark.sql.optimizer.canChangeCachedPlanOutputPartitioning=false
{code}
while leaving AQE enabled during state materialization.

This does not resolve the issue.

Planning/execution time still grows rapidly and the production workload takes 
more than two hours.
h3. Different JVM GC

We tested ParallelGC using:
{code:java}
-XX:+UseParallelGC
{code}
for driver and executors.

This did not resolve the regression.
h3. localCheckpoint

Periodically calling localCheckpoint() truncates the recursive dependency 
structure and resets the growth after the checkpoint boundary.

However, checkpoint materialization adds significant runtime overhead in the 
production workload compared with disabling AQE only around cache 
materialization.
h2. Expected behavior

We expect Spark 4.1.3 not to exhibit an orders-of-magnitude stronger 
cache-materialization degradation than Spark 3.4.3 for the same iterative 
cached-DataFrame structure.

In particular, enabling AQE during cache materialization should not change an 
iteration from approximately:
{code:java}
5 s
{code}
to:
{code:java}
110+ s
{code}
when disabling AQE only around the same cache() + count() restores the lower 
runtime.
h2. Actual behavior

With Spark 4.1.3 and AQE enabled during iterative cache materialization:
 * cache materialization becomes rapidly and nonlinearly slower;
 * recursive physical-plan traversal exposes rapidly growing references to 
AdaptiveSparkPlanExec, TableCacheQueryStageExec, InMemoryTableScanExec, and 
nested Union nodes;
 * the same workload is dramatically faster when AQE is disabled only during 
cached-state materialization;
 * the degradation is substantially more aggressive than on Spark 3.4.3;
 * in the original workload, lazy transformation planning time also grows from 
sub-second timings to hundreds of seconds;
 * with the default spark.sql.maxPlanStringLength, the original workload 
eventually fails with driver OOM.

h2. Suspected area

The results suggest that the regression is related to the interaction between 
AQE and recursively referenced cached InMemoryRelation plans.

In particular, TableCacheQueryStageExec is heavily represented in the 
recursively expanded cached physical plans.

We are not claiming that TableCacheQueryStageExec itself is proven to be the 
root cause.

However, disabling AQE only while materializing the cached state removes most 
of the regression, making the AQE cached-plan path a strong candidate for 
investigation.
h2. Related issues
 * SPARK-42101 - Wrap InMemoryTableScanExec with QueryStage
 * SPARK-45443 - Revisit TableCacheQueryStage to avoid replicated 
InMemoryRelation materialization
 * SPARK-46995 - Allow AQE coalesce final stage in SQL cached plan
 * SPARK-50992 - OOMs and performance issues with AQE in large plans
 * apache/spark#55094 - AQE does not apply optimizations for queries involving 
TableCacheQueryStageExec

h2. Attachments
 * spark_aqe_cache_plan_growth_reproducer.py

Additional sanitized diagnostics can be provided if useful:
 * per-iteration timings;
 * driver OOM stack trace;
 * physical-plan node/reference statistics;
 * AQE-on vs scoped-AQE-off comparisons.



--
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