[
https://issues.apache.org/jira/browse/SPARK-56908?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Gengliang Wang updated SPARK-56908:
-----------------------------------
Description:
Whole-stage codegen generates a fresh Java class per stage. Across many
operators the generated source contains (a) boilerplate that is
type-independent across stages and can be deduplicated into static Java
helpers, and (b) branches or variables that are statically dead at codegen time
but emitted anyway.
These patterns cost us in three places:
- JVM 64KB method-size and constant-pool limits, which force interpreted
fallback on deep query plans.
- Janino compile time per stage.
- JIT compile work (each stage class has its own bodies).
This umbrella tracks small, behavior-preserving cleanups across the generated
Java to address these issues. Each subtask is independently PR-able; behavior
is preserved end-to-end and verified by the relevant operator's existing test
suite with {{spark.sql.codegen.wholeStage}} forced both on and off.
h3. Scope guidance (when is a dead-branch / simplification subtask worth it?)
Not every statically-dead branch is worth eliminating. We measured the payoff
on real generated code (TPC-DS whole-stage codegen): Janino constant-folds {{if
(true)}} / {{if (false)}}, so skipping such a branch produces *no bytecode
change* and no JIT/runtime benefit -- only smaller source. Janino compile time
is ~linear in source (~0.36 ms/KB), and even the most frequent source-only
patterns we measured ({{if (true)}} x445, a dead null-write branch x358 --
together only ~0.4% of generated source) saved compile time below the
run-to-run noise floor (~0.7%).
Therefore a subtask that adds branching / complexity to the codegen logic to
skip a dead branch is only justified when:
- *(b) it removes more than source text -- this is the real bar.* For example
SPARK-57198: skipping the divide-by-zero guard for a non-zero literal also
stops registering the unreachable {{errCtx}} entry in the {{references[]}} /
constant pool, which Janino cannot fold away (unlike the {{if (false)}} text
itself). Likewise SPARK-57199 moved the {{AGGREGATE_OUT_OF_MEMORY}} string +
map constructor out of all 142 generated aggregate classes' constant pools into
one compiled method. Keeping a large method under the 64KB / huge-method limit
also qualifies.
- *(a) raw frequency rarely suffices on its own.* As measured above, even
patterns occurring 358-445 times stayed below the compile-time noise floor with
no bytecode change. Frequency only matters when the dead code is a large
fraction of a _single_ method (approaching the 64KB limit), not merely numerous
across the corpus.
Trivial, infrequent (and even frequent-but-source-only) dead-branch removals
add codegen-logic complexity for negligible benefit and should be dropped
rather than merged.
h3. Measured cumulative impact (as of master, 2026-07)
We now measure generated codegen size across all TPC-DS queries with a
dedicated, planning-only benchmark added under this umbrella (SPARK-57915):
{{org.apache.spark.sql.execution.benchmark.WholeStageCodegenSizeBenchmark}}. It
creates the TPC-DS tables empty (SF=100 stats injected so plan shapes match
production), plans every query to its {{executedPlan}} with no data or
execution, walks the {{WholeStageCodegenExec}} subtrees via
{{debug.codegenStringSeq}}, and reports grand totals: generated source size,
summed/max compiled method bytecode, generated inner-class count, constant-pool
entries, cold Janino compile time, and codegen fallbacks. Run it with
{{build/sbt "sql/Test/runMain
org.apache.spark.sql.execution.benchmark.WholeStageCodegenSizeBenchmark"}}.
Comparing {{branch-4.2}} (the 4.2 cut on 2026-05-01, before the bulk of this
umbrella's subtasks) against current {{master}} (which carries ~40 SPARK-56908
subtasks merged since) over all 135 measured TPC-DS queries:
|| metric || branch-4.2 || master || delta ||
| source code size (chars) | 24,190,570 | 21,845,997 | -9.7% |
| max method bytecode, summed over stages | 951,309 | 841,185 | -11.6% |
| max method bytecode, single largest method | 6,527 | 4,962 | -24.0% |
| generated inner classes | 786 | 258 | -67.2% |
| constant pool, summed over stages | 454,006 | 432,875 | -4.7% |
| Janino compile time (ms) | 10,784 | 10,362 | -3.9% |
| codegen fallbacks | 0 | 0 | unchanged |
The largest wins are in the metrics that actually gate execution and cost: the
single largest generated method shrank ~24% (the metric behind the HotSpot 8KB
JIT-compilation threshold and the 64KB method limit), and generated inner
classes dropped ~67% (fewer classes for Janino to compile, load, and verify,
and less metaspace per query per executor). No query regressed on any metric
and codegen fallbacks stayed at zero, confirming the changes are
behavior-preserving.
This is the cumulative effect of the whole umbrella's work over that window
(dead-check elimination such as SPARK-57198/SPARK-57201, static-helper
extraction across many expressions, and the recent fast-hash-map /
UnsafeRowWriter / ColumnarToRow / Coalesce reductions), not of any single
subtask; each subtask's own before/after numbers are in its linked PR.
was:
Whole-stage codegen generates a fresh Java class per stage. Across many
operators the generated source contains (a) boilerplate that is
type-independent across stages and can be deduplicated into static Java
helpers, and (b) branches or variables that are statically dead at codegen time
but emitted anyway.
These patterns cost us in three places:
- JVM 64KB method-size and constant-pool limits, which force interpreted
fallback on deep query plans.
- Janino compile time per stage.
- JIT compile work (each stage class has its own bodies).
This umbrella tracks small, behavior-preserving cleanups across the generated
Java to address these issues. Each subtask is independently PR-able; behavior
is preserved end-to-end and verified by the relevant operator's existing test
suite with {{spark.sql.codegen.wholeStage}} forced both on and off.
h3. Scope guidance (when is a dead-branch / simplification subtask worth it?)
Not every statically-dead branch is worth eliminating. We measured the payoff
on real generated code (TPC-DS whole-stage codegen): Janino constant-folds {{if
(true)}} / {{if (false)}}, so skipping such a branch produces *no bytecode
change* and no JIT/runtime benefit -- only smaller source. Janino compile time
is ~linear in source (~0.36 ms/KB), and even the most frequent source-only
patterns we measured ({{if (true)}} x445, a dead null-write branch x358 --
together only ~0.4% of generated source) saved compile time below the
run-to-run noise floor (~0.7%).
Therefore a subtask that adds branching / complexity to the codegen logic to
skip a dead branch is only justified when:
- *(b) it removes more than source text -- this is the real bar.* For example
SPARK-57198: skipping the divide-by-zero guard for a non-zero literal also
stops registering the unreachable {{errCtx}} entry in the {{references[]}} /
constant pool, which Janino cannot fold away (unlike the {{if (false)}} text
itself). Likewise SPARK-57199 moved the {{AGGREGATE_OUT_OF_MEMORY}} string +
map constructor out of all 142 generated aggregate classes' constant pools into
one compiled method. Keeping a large method under the 64KB / huge-method limit
also qualifies.
- *(a) raw frequency rarely suffices on its own.* As measured above, even
patterns occurring 358-445 times stayed below the compile-time noise floor with
no bytecode change. Frequency only matters when the dead code is a large
fraction of a _single_ method (approaching the 64KB limit), not merely numerous
across the corpus.
Trivial, infrequent (and even frequent-but-source-only) dead-branch removals
add codegen-logic complexity for negligible benefit and should be dropped
rather than merged.
> Reduce generated Java size in whole-stage codegen
> -------------------------------------------------
>
> Key: SPARK-56908
> URL: https://issues.apache.org/jira/browse/SPARK-56908
> Project: Spark
> Issue Type: Umbrella
> Components: SQL
> Affects Versions: 4.3.0
> Reporter: Gengliang Wang
> Priority: Major
> Labels: pull-request-available
>
> Whole-stage codegen generates a fresh Java class per stage. Across many
> operators the generated source contains (a) boilerplate that is
> type-independent across stages and can be deduplicated into static Java
> helpers, and (b) branches or variables that are statically dead at codegen
> time but emitted anyway.
> These patterns cost us in three places:
> - JVM 64KB method-size and constant-pool limits, which force interpreted
> fallback on deep query plans.
> - Janino compile time per stage.
> - JIT compile work (each stage class has its own bodies).
> This umbrella tracks small, behavior-preserving cleanups across the generated
> Java to address these issues. Each subtask is independently PR-able; behavior
> is preserved end-to-end and verified by the relevant operator's existing test
> suite with {{spark.sql.codegen.wholeStage}} forced both on and off.
> h3. Scope guidance (when is a dead-branch / simplification subtask worth it?)
> Not every statically-dead branch is worth eliminating. We measured the payoff
> on real generated code (TPC-DS whole-stage codegen): Janino constant-folds
> {{if (true)}} / {{if (false)}}, so skipping such a branch produces *no
> bytecode change* and no JIT/runtime benefit -- only smaller source. Janino
> compile time is ~linear in source (~0.36 ms/KB), and even the most frequent
> source-only patterns we measured ({{if (true)}} x445, a dead null-write
> branch x358 -- together only ~0.4% of generated source) saved compile time
> below the run-to-run noise floor (~0.7%).
> Therefore a subtask that adds branching / complexity to the codegen logic to
> skip a dead branch is only justified when:
> - *(b) it removes more than source text -- this is the real bar.* For example
> SPARK-57198: skipping the divide-by-zero guard for a non-zero literal also
> stops registering the unreachable {{errCtx}} entry in the {{references[]}} /
> constant pool, which Janino cannot fold away (unlike the {{if (false)}} text
> itself). Likewise SPARK-57199 moved the {{AGGREGATE_OUT_OF_MEMORY}} string +
> map constructor out of all 142 generated aggregate classes' constant pools
> into one compiled method. Keeping a large method under the 64KB / huge-method
> limit also qualifies.
> - *(a) raw frequency rarely suffices on its own.* As measured above, even
> patterns occurring 358-445 times stayed below the compile-time noise floor
> with no bytecode change. Frequency only matters when the dead code is a large
> fraction of a _single_ method (approaching the 64KB limit), not merely
> numerous across the corpus.
> Trivial, infrequent (and even frequent-but-source-only) dead-branch removals
> add codegen-logic complexity for negligible benefit and should be dropped
> rather than merged.
> h3. Measured cumulative impact (as of master, 2026-07)
> We now measure generated codegen size across all TPC-DS queries with a
> dedicated, planning-only benchmark added under this umbrella (SPARK-57915):
> {{org.apache.spark.sql.execution.benchmark.WholeStageCodegenSizeBenchmark}}.
> It creates the TPC-DS tables empty (SF=100 stats injected so plan shapes
> match production), plans every query to its {{executedPlan}} with no data or
> execution, walks the {{WholeStageCodegenExec}} subtrees via
> {{debug.codegenStringSeq}}, and reports grand totals: generated source size,
> summed/max compiled method bytecode, generated inner-class count,
> constant-pool entries, cold Janino compile time, and codegen fallbacks. Run
> it with {{build/sbt "sql/Test/runMain
> org.apache.spark.sql.execution.benchmark.WholeStageCodegenSizeBenchmark"}}.
> Comparing {{branch-4.2}} (the 4.2 cut on 2026-05-01, before the bulk of this
> umbrella's subtasks) against current {{master}} (which carries ~40
> SPARK-56908 subtasks merged since) over all 135 measured TPC-DS queries:
> || metric || branch-4.2 || master || delta ||
> | source code size (chars) | 24,190,570 | 21,845,997 | -9.7% |
> | max method bytecode, summed over stages | 951,309 | 841,185 | -11.6% |
> | max method bytecode, single largest method | 6,527 | 4,962 | -24.0% |
> | generated inner classes | 786 | 258 | -67.2% |
> | constant pool, summed over stages | 454,006 | 432,875 | -4.7% |
> | Janino compile time (ms) | 10,784 | 10,362 | -3.9% |
> | codegen fallbacks | 0 | 0 | unchanged |
> The largest wins are in the metrics that actually gate execution and cost:
> the single largest generated method shrank ~24% (the metric behind the
> HotSpot 8KB JIT-compilation threshold and the 64KB method limit), and
> generated inner classes dropped ~67% (fewer classes for Janino to compile,
> load, and verify, and less metaspace per query per executor). No query
> regressed on any metric and codegen fallbacks stayed at zero, confirming the
> changes are behavior-preserving.
> This is the cumulative effect of the whole umbrella's work over that window
> (dead-check elimination such as SPARK-57198/SPARK-57201, static-helper
> extraction across many expressions, and the recent fast-hash-map /
> UnsafeRowWriter / ColumnarToRow / Coalesce reductions), not of any single
> subtask; each subtask's own before/after numbers are in its linked PR.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]