[SPARK-57902][SQL] Emit a ternary for 2-arg Coalesce with a non-nullable pure fallback#56971
[SPARK-57902][SQL] Emit a ternary for 2-arg Coalesce with a non-nullable pure fallback#56971gengliangwang wants to merge 3 commits into
Conversation
…ble pure fallback ### What changes were proposed in this pull request? For `coalesce(a, b)` where `b` is non-nullable and its evaluation emits no code (a literal or an already-evaluated value), emit a single ternary instead of the general do-while block with a global mutable `isNull` field. The empty-code requirement preserves lazy evaluation. ### Why are the changes needed? Part of SPARK-56908 (reduce generated Java size in whole-stage codegen). ~-2% of total generated source on a TPC-DS codegen dump, and one global mutable field removed per affected coalesce. ### Does this PR introduce any user-facing change? No. ### How was this patch tested? NullExpressionsSuite: new test for the ternary fast path; the SPARK-22705 test now uses three children to keep exercising the general path. Generated-by: Claude Code (Opus 4.8)
| assert(ctx.inlinedMutableStates.size == 1) | ||
| } | ||
|
|
||
| test("Coalesce with a non-nullable pure fallback compiles to a ternary with no global state") { |
There was a problem hiding this comment.
Noting a minor test coverage gap: the new fast-path test uses only Literal inputs and StringType. The motivating hot path is coalesce(nullable_column, 0) where the first arg is a BoundReference (a distinct codegen path via currentVars lookup, not ExprCode.forNonNullValue) and the fallback is a primitive.
Adding a BoundReference-through-a-row case (both null and non-null branches, e.g. checkEvaluation(Coalesce(Seq(BoundReference(0, IntegerType, nullable = true), Literal(0))), 42, InternalRow(42))) would pin the actual motivating path.
| assert(ctx.inlinedMutableStates.isEmpty) | ||
| checkEvaluation(Coalesce(Seq(Literal.create(null, StringType), Literal("a"))), "a") | ||
| checkEvaluation(Coalesce(Seq(Literal("x"), Literal("y"))), "x") | ||
| } |
There was a problem hiding this comment.
Also, the subtlest branch, which is the probe-reuse path (2-arg, non-nullable b, but b.code NON-empty, so Some(Seq(first, second)) is threaded into the general path), is only covered indirectly by end-to-end suites, not by a direct unit test. Adding a direct regression test would be nice here too.
uros-b
left a comment
There was a problem hiding this comment.
Thank you @gengliangwang, I left a few comments regarding unit tests coverage
cloud-fan
left a comment
There was a problem hiding this comment.
0 blocking, 0 non-blocking, 0 nits.
Clean, well-scoped codegen optimization; correct and consistent with the existing NaNvl/If ternary-with-FalseLiteral idiom.
No findings.
Verification
Traced the fast-path ternary a.isNull ? b.value : a.value (isNull=FalseLiteral) against the general do-while for every input dimension: a null / a non-null both agree; result nullability is correct since b non-nullable makes Coalesce.nullable false; the empty-b.code gate preserves lazy eval (no statements of b hoisted before a's null check) and keeps b a single pure expression, so nondeterminism/eval-count is unchanged. NullPropagation truncates a Coalesce at its first non-nullable child, so coalesce(nullable, non-nullable) is exactly the surviving shape this targets. Confirmed the test's inlinedMutableStates.isEmpty holds (a string Literal emits empty code via forNonNullValue; addReferenceObj appends to references, not inlinedMutableStates) and that checkEvaluation exercises the codegen path.
@uros-b's two test-coverage suggestions (BoundReference+primitive path, and a direct test for the probe-reuse branch) are both valid and stand on their own.
… Coalesce tests Per review feedback, cover the motivating hot path directly: coalesce(nullable BoundReference, primitive literal) exercising the ternary fast path from a row read (both null and non-null), and a two-arg coalesce whose non-nullable fallback emits code, exercising the probe-reuse branch that threads the already-generated ExprCodes into the general do-while path. Generated-by: Claude Code (Opus 4.8)
|
Thanks @uros-b! Addressed both: added a |
| import java.sql.Timestamp | ||
|
|
||
| import org.apache.spark.{SparkFunSuite, SparkRuntimeException} | ||
| import org.apache.spark.sql.catalyst.InternalRow |
There was a problem hiding this comment.
Fixed — reordered so FunctionIdentifier precedes InternalRow.
| val first = children(0).genCode(ctx) | ||
| val second = children(1).genCode(ctx) | ||
| if (second.code.isEmpty) { | ||
| val resultType = CodeGenerator.javaType(dataType) |
There was a problem hiding this comment.
CodeGenerator.javaType(dataType) is computed in both the fast-path branch and the general path(line 137).
There was a problem hiding this comment.
Good catch — hoisted val resultType = CodeGenerator.javaType(dataType) to the top of doGenCode so it's computed once and shared by both the fast path and the general path.
| // evaluation: there are no statements of `b` to hoist before the null check. | ||
| val probedEvals: Option[Seq[ExprCode]] = | ||
| if (children.length == 2 && !children(1).nullable) { | ||
| val first = children(0).genCode(ctx) |
There was a problem hiding this comment.
Done — switched to children.head.
…d, fix import order Per @LuciferYang's review: - hoist `val resultType = CodeGenerator.javaType(dataType)` to the top of doGenCode so it is computed once instead of in both the fast-path and general-path branches; - use `children.head` instead of `children(0)`; - fix the import order of InternalRow / FunctionIdentifier in NullExpressionsSuite. Generated-by: Claude Code (Opus 4.8)
…ble pure fallback
### What changes were proposed in this pull request?
The general `Coalesce` codegen evaluates its children inside a `do { ... } while (false)` loop and,
before that, promotes the result's null flag to a **class-level mutable field** via
`ctx.addMutableState`:
```scala
ev.isNull = JavaCode.isNullGlobal(ctx.addMutableState(CodeGenerator.JAVA_BOOLEAN, ev.isNull))
```
It has to be a field rather than a local `boolean` because the branches may be split out into a
separate `coalesce_N(...)` method (via `splitExpressionsWithCurrentInputs`), so the flag must be
visible across that method boundary. So every coalesce on the general path adds one
`private boolean ...;` field to the generated class, e.g. for `coalesce(a, b)`:
```java
private boolean coalesce_isNull_0; // added to the generated class
...
coalesce_isNull_0 = true;
int coalesce_value_0 = -1;
do {
if (!aIsNull) { coalesce_isNull_0 = false; coalesce_value_0 = aValue; continue; }
if (!bIsNull) { coalesce_isNull_0 = false; coalesce_value_0 = bValue; continue; }
} while (false);
```
For `coalesce(a, b)` where `b` is non-nullable and its evaluation emits **no** code (a literal or an
already-evaluated value) -- e.g. the `coalesce(sum, 0)` in every SUM/AVG update -- the result can
never be null and `b` carries a pure Java expression, so the whole thing collapses to a single
ternary with **no** field and **no** loop (`isNull` becomes the compile-time constant
`FalseLiteral`):
```java
int coalesce_value_0 = aIsNull ? 0 : aValue;
```
The empty-code requirement preserves lazy evaluation: there are no statements of `b` to hoist past
the null check. When the fast path does not apply, the general path reuses the `ExprCode` already
generated while probing (generating a child twice would duplicate codegen side effects on the
context, such as orphaned mutable state for stateful expressions and repeated reference
registrations).
Note on why this shrinks the compiled artifact and not just the source: the generated code is
compiled by Janino, which does no dead-code elimination or field pruning, so the removed
`do/while` scaffold and the `private boolean` field (which also occupies a constant-pool entry and
per-instance state) genuinely disappear from the generated class -- they are not something a
compiler would have optimized away. Spark also intentionally bounds the number of mutable states,
which is what the `SPARK-22705` test guards.
### Why are the changes needed?
This is part of the effort to reduce generated Java size in whole-stage codegen (parent
[SPARK-56908](https://issues.apache.org/jira/browse/SPARK-56908)). Smaller generated methods reduce
Janino compilation work, JIT work, and pressure against the JVM 64KB method limit. Measured on a
TPC-DS codegen dump (150 queries, 1,572 whole-stage-codegen subtrees): about **-2%** of total
generated source (109/150 queries smaller, 0 larger), and one global mutable field removed per
affected coalesce.
### Does this PR introduce _any_ user-facing change?
No. This only changes the shape of generated code; results are unchanged.
### How was this patch tested?
`NullExpressionsSuite` -- added a test asserting the two-argument non-nullable-pure-fallback form
compiles to a ternary with no global mutable state and evaluates correctly on both the null and
non-null branches. The existing `SPARK-22705` global-variable test is updated to use three children
so it continues to exercise the general do-while path (the two-argument form it previously used now
takes the new fast path).
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
Closes #56971 from gengliangwang/SPARK-57902-coalesce-ternary.
Authored-by: Gengliang Wang <gengliang@apache.org>
Signed-off-by: Gengliang Wang <gengliang@apache.org>
(cherry picked from commit c116a27)
Signed-off-by: Gengliang Wang <gengliang@apache.org>
What changes were proposed in this pull request?
The general
Coalescecodegen evaluates its children inside ado { ... } while (false)loop and,before that, promotes the result's null flag to a class-level mutable field via
ctx.addMutableState:It has to be a field rather than a local
booleanbecause the branches may be split out into aseparate
coalesce_N(...)method (viasplitExpressionsWithCurrentInputs), so the flag must bevisible across that method boundary. So every coalesce on the general path adds one
private boolean ...;field to the generated class, e.g. forcoalesce(a, b):For
coalesce(a, b)wherebis non-nullable and its evaluation emits no code (a literal or analready-evaluated value) -- e.g. the
coalesce(sum, 0)in every SUM/AVG update -- the result cannever be null and
bcarries a pure Java expression, so the whole thing collapses to a singleternary with no field and no loop (
isNullbecomes the compile-time constantFalseLiteral):The empty-code requirement preserves lazy evaluation: there are no statements of
bto hoist pastthe null check. When the fast path does not apply, the general path reuses the
ExprCodealreadygenerated while probing (generating a child twice would duplicate codegen side effects on the
context, such as orphaned mutable state for stateful expressions and repeated reference
registrations).
Note on why this shrinks the compiled artifact and not just the source: the generated code is
compiled by Janino, which does no dead-code elimination or field pruning, so the removed
do/whilescaffold and theprivate booleanfield (which also occupies a constant-pool entry andper-instance state) genuinely disappear from the generated class -- they are not something a
compiler would have optimized away. Spark also intentionally bounds the number of mutable states,
which is what the
SPARK-22705test guards.Why are the changes needed?
This is part of the effort to reduce generated Java size in whole-stage codegen (parent
SPARK-56908). Smaller generated methods reduce
Janino compilation work, JIT work, and pressure against the JVM 64KB method limit. Measured on a
TPC-DS codegen dump (150 queries, 1,572 whole-stage-codegen subtrees): about -2% of total
generated source (109/150 queries smaller, 0 larger), and one global mutable field removed per
affected coalesce.
Does this PR introduce any user-facing change?
No. This only changes the shape of generated code; results are unchanged.
How was this patch tested?
NullExpressionsSuite-- added a test asserting the two-argument non-nullable-pure-fallback formcompiles to a ternary with no global mutable state and evaluates correctly on both the null and
non-null branches. The existing
SPARK-22705global-variable test is updated to use three childrenso it continues to exercise the general do-while path (the two-argument form it previously used now
takes the new fast path).
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)