Skip to content

[SPARK-59639][SQL] Tighten nullability of Divide, Remainder, IntegralDivide, and Pmod under ANSI mode - #58910

Open
david-mollitor-db wants to merge 2 commits into
apache:masterfrom
david-mollitor-db:SPARK-59639
Open

david-mollitor-db wants to merge 2 commits into
apache:masterfrom
david-mollitor-db:SPARK-59639

Conversation

@david-mollitor-db

@david-mollitor-db david-mollitor-db commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Divide, Remainder, and IntegralDivide (which share the DivModLike trait) and Pmod declared override def nullable: Boolean = true unconditionally. This PR makes nullable reflect what the operators can actually produce at runtime:

override def nullable: Boolean = left.nullable || right.nullable || !failOnError

Under ANSI mode (failOnError), divide-by-zero and integral overflow throw rather than returning null, so the result is null only when one of the inputs is null. Under LEGACY/TRY mode the behavior is unchanged (nullable stays effectively true), because divide-by-zero returns null there and, for decimals, a precision overflow can also return null independent of the divisor.

A follow-up commit also updates DivModLike.doGenCode / Pmod.doGenCode: when the expression is now non-nullable they no longer emit the boolean isNull = false; declaration (which had become a dead store, plus a dead local that widened the StackMapTable frames covering its scope) and instead report FalseLiteral — mirroring the existing MakeDecimal / CheckOverflow codegen. eval semantics are untouched.

Why are the changes needed?

Whole-stage and expression code generation represent SQL NULL with boolean isNull flags. A nullable = true child forces the framework to materialize an isNull variable and makes every parent expression emit a null-guard branch on it (CodegenContext.nullSafeExec). Because nullability propagates, a single spuriously-nullable % / / / pmod cascades dead if (!isNull) branches into the comparisons, CASE WHENs, and predicates built on top of it.

For example, (id % 2) = 0 inside a CASE WHEN (ANSI) generated:

boolean project_isNull_5 = false;          // id % 2 -- never set to true
if (!project_isNull_5) { ... }             // dead guard
...
if (!project_isNull_4 && project_value_4)  // dead: !isNull_4 is always true here

Correcting the nullability removes these dead guards at the source and feeds more accurate nullability to the optimizer; the codegen follow-up additionally removes the now-dead isNull = false stores.

Beyond cleaner code, this has a JIT dimension. HotSpot does not JIT-compile a method whose bytecode exceeds -XX:HugeMethodLimit (default 8000 bytes) — such a method runs in the interpreter. The per-operation isNull flags, null-guard branches, and dead stores inflate the bytecode of exactly the arithmetic-heavy generated methods that sit closest to this limit. On a set of representative codegen-heavy projections, the largest generated method dropped from ~9.1 KB (above the limit, interpreter-only) to ~7.7 KB (JIT-compiled) with this change.

Does this PR introduce any user-facing change?

Yes (schema metadata only). Under ANSI mode, an output column that is a /, %, div, or pmod over non-nullable inputs is now reported as non-nullable, whereas before it was reported as nullable. Computed values and error behavior are unchanged: ANSI divide/remainder/pmod-by-zero and integral overflow still throw, and LEGACY/TRY still return null.

  • Before: spark.range(1).selectExpr("id % 2").schema.head.nullable is true
  • After (ANSI): the same is false

How was this patch tested?

Existing suites, all pass with no golden-file changes:

  • ArithmeticExpressionSuite, PredicateSuite, ColumnExpressionSuite
  • SQLQueryTestSuite: operators.sql, try_arithmetic.sql, decimalArithmeticOperations.sql

.sql.out golden files are unaffected because their schema lines use df.schema.catalogString, which does not encode nullability. The codegen changes were additionally verified by inspecting generated bytecode: the dead isNull guards and stores for % / / / pmod over non-null inputs are gone under ANSI (LEGACY/TRY are unchanged).

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Isaac

This pull request and its description were written by Isaac.

david-mollitor-db and others added 2 commits September 18, 2026 16:19
…Divide, and Pmod under ANSI mode

Divide, Remainder, IntegralDivide (which share the DivModLike trait) and Pmod
declared `nullable = true` unconditionally. Under ANSI mode (the default),
divide-by-zero and integral overflow throw instead of returning null, so these
operators are null only when one of their inputs is null.

The over-broad nullability forces codegen to materialize an isNull flag and makes
every parent expression emit a null-guard branch on it; because nullability
propagates, one spuriously-nullable `%` cascades dead `if (!isNull)` guards into
the comparisons, CASE WHENs, and predicates built on top of it.

Make `nullable` reflect the runtime behavior:

  override def nullable: Boolean = left.nullable || right.nullable || !failOnError

Under ANSI (failOnError) the result is null iff a child is. LEGACY/TRY are
unchanged (still nullable): divide-by-zero returns null there, and for decimals a
precision overflow can also return null independent of the divisor.

No behavioral change: eval and code generation semantics are untouched (ANSI
divide/remainder/pmod-by-zero and integral overflow still throw). Only the
declared nullability tightens, and only under ANSI.

Co-authored-by: Isaac <no-reply@databricks.com>
…s non-nullable

Tightening `nullable` in the previous commit removed the parent null-guard
branches, but `DivModLike.doGenCode` / `Pmod.doGenCode` still emitted
`boolean isNull = false;` unconditionally -- leaving a dead store, plus a dead
local that widened the StackMapTable frames covering its scope, whenever the
result is now non-nullable.

Mirror MakeDecimal/CheckOverflow: in the non-null-input branch, when the result
is non-nullable, omit the `isNull` declaration and report `FalseLiteral`, and
guard Pmod's decimal `ev.isNull = value == null` line on `nullable`.

On codegen-heavy workflows this drops generated class bytecode a further ~3% on
top of the previous change (the largest stage class ~13% smaller), with no
behavioral change.

Co-authored-by: Isaac <no-reply@databricks.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant