[SPARK-56913][SQL] Simplify BinaryArithmetic byte/short codegen under ANSI mode#55938
[SPARK-56913][SQL] Simplify BinaryArithmetic byte/short codegen under ANSI mode#55938gengliangwang wants to merge 4 commits into
Conversation
Stack overview (SPARK-56908 umbrella)This PR is part of a stack of 8 PRs against SPARK-56908. Order:
PRs 1-4 are linearly stacked on each other (each branch is based on the previous one). PR 5 (decimal arithmetic) is stacked on top of PR 3 (cast decimal) since it uses |
… ANSI mode ### What changes were proposed in this pull request? Introduce `ArithmeticUtils.java` with six static helpers (`byteAddExact`, `byteSubtractExact`, `byteMultiplyExact`, `shortAddExact`, `shortSubtractExact`, `shortMultiplyExact`) and use them from `BinaryArithmetic.doGenCode` and from `Add` / `Subtract` / `Multiply.nullSafeEval`. The `Byte`/`Short` ANSI overflow-check branch of `BinaryArithmetic.doGenCode` previously emitted ~7 lines per call site (int tmpResult + overflow check + cast back). After this PR it emits a single `ArithmeticUtils.<type><Op>Exact(...)` call. The eval-path counterparts for Add/Subtract/Multiply also delegate to the helpers under ANSI mode, replacing the previous fall-through to `numeric.plus`/`minus`/`times` (which threw a generic `ArithmeticException`) -- the eval path now produces the same SQL-formatted `BINARY_ARITHMETIC_OVERFLOW` error as the codegen path. Primitive `int`/`long`/`float`/`double` branches are intentionally left inline (single bytecode op; routing through a static method would be a runtime regression). ### Why are the changes needed? Part of SPARK-56908 (umbrella). The Byte/Short ANSI branch is the largest single inline body in `BinaryArithmetic.doGenCode`. ### Does this PR introduce _any_ user-facing change? No. Compiled behavior is identical; the eval path now produces a SQL-formatted overflow error matching the codegen path (the previous generic `ArithmeticException` was an inconsistency). ### How was this patch tested? ``` build/sbt "catalyst/testOnly *ArithmeticExpressionSuite" ``` 35/35 pass. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Cursor 1.x
7344920 to
b38e73a
Compare
viirya
left a comment
There was a problem hiding this comment.
A focused refactor that pulls the ANSI overflow-check for Byte/Short arithmetic out of an inline codegen template and into a small Java helper. Behaviorally a no-op — but the PR description's justification for the eval-side changes (claiming the eval path used to throw a "generic ArithmeticException") is factually wrong: the eval path already produced BINARY_ARITHMETIC_OVERFLOW via ByteExactNumeric/ShortExactNumeric. The code itself is fine; the description needs correction and the eval-side additions are arguably redundant.
|
PR description:
This is wrong. Before this PR, the eval path for ANSI Byte goes:
That is the same call as the new helper, with the same error class BINARY_ARITHMETIC_OVERFLOW and identical message parameters. There was no generic ArithmeticException. Same for Short. So the new case _: ByteType if failOnError => ArithmeticUtils.byteAddExact(...) branches added to Add/Subtract/Multiply are redundant: they replace one route to the same error with a different route to the same error. Behaviorally identical. |
| * single-bytecode operations through a static method would be a runtime | ||
| * regression. | ||
| */ | ||
| public final class ArithmeticUtils { |
There was a problem hiding this comment.
Duplication with ByteExactNumeric / ShortExactNumeric. numerics.scala already has plus/minus/times that do exactly the same int-promoted check and throw the same error. Now there are two implementations of the same logic — one in numerics.scala (still used) and one in ArithmeticUtils.java (used by the new eval branches and by codegen).
There was a problem hiding this comment.
Good catch. The new commit adds a class-javadoc note explicitly calling out that these helpers mirror ByteExactNumeric / ShortExactNumeric and exist only because Scala object methods can't be called from generated code without going through references[]. I also dropped the redundant eval-path branches (see other replies), so numerics.scala is now the sole eval-path implementation.
| case _ => | ||
| throw QueryExecutionErrors.notOverrideExpectedMethodsError(this.getClass.getName, | ||
| s"genCode for Byte/Short with symbol '$symbol'", "genCode") |
There was a problem hiding this comment.
In practice, only Add/Subtract/Multiply reach BinaryArithmetic.doGenCode with byte/short (Divide/Remainder/Pmod/IntegralDivide override doGenCode via DivModLike). Reasonable defensive coding, but the notOverrideExpectedMethodsError message ("genCode") is a slightly awkward fit — it's normally used for missing method overrides, not for a runtime symbol mismatch. A SparkException.internalError(s"Unexpected symbol $symbol for Byte/Short BinaryArithmetic") would read more accurately.
There was a problem hiding this comment.
Done — switched to SparkException.internalError(s"Unexpected symbol '$symbol' for Byte/Short BinaryArithmetic").
| case _: ByteType if failOnError => | ||
| ArithmeticUtils.byteAddExact(input1.asInstanceOf[Byte], input2.asInstanceOf[Byte]) | ||
| case _: ShortType if failOnError => | ||
| ArithmeticUtils.shortAddExact(input1.asInstanceOf[Short], input2.asInstanceOf[Short]) |
There was a problem hiding this comment.
Why route Add/Subtract/Multiply.nullSafeEval through the helper rather than just leaving them as numeric.plus(...)? The pre-PR case _ => numeric.plus(...) was concise and already correct. The new ANSI-specific branches add 8 lines × 3 expressions = 24 lines of arguably unnecessary code.
Consider dropping the eval-side branches (Add/Subtract/Multiply.nullSafeEval byte/short cases). They're redundant with the existing numeric.plus-via-ByteExactNumeric route.
There was a problem hiding this comment.
Agreed — reverted. Add.nullSafeEval now falls through to numeric.plus for byte/short (which already routes through ByteExactNumeric/ShortExactNumeric and throws the same SQL-formatted overflow error). The pre-PR statement in the description about a generic ArithmeticException was wrong; sorry for the noise.
| case _: ByteType if failOnError => | ||
| ArithmeticUtils.byteSubtractExact(input1.asInstanceOf[Byte], input2.asInstanceOf[Byte]) | ||
| case _: ShortType if failOnError => | ||
| ArithmeticUtils.shortSubtractExact(input1.asInstanceOf[Short], input2.asInstanceOf[Short]) |
There was a problem hiding this comment.
Reverted — Subtract.nullSafeEval now falls through to numeric.minus for byte/short.
| case _: ByteType if failOnError => | ||
| ArithmeticUtils.byteMultiplyExact(input1.asInstanceOf[Byte], input2.asInstanceOf[Byte]) | ||
| case _: ShortType if failOnError => | ||
| ArithmeticUtils.shortMultiplyExact(input1.asInstanceOf[Short], input2.asInstanceOf[Short]) |
There was a problem hiding this comment.
Reverted — Multiply.nullSafeEval now falls through to numeric.times for byte/short.
cloud-fan
left a comment
There was a problem hiding this comment.
Summary
The Byte/Short ANSI overflow branch of BinaryArithmetic.doGenCode previously emitted ~7 inline lines per call site (int temp + range check + cast back). This PR extracts those lines into 6 helpers in a new ArithmeticUtils.java and reduces the codegen to a single call. Non-ANSI byte/short is also collapsed to a one-line inline cast. Int/long branches are intentionally left inline.
After @viirya's review, the duplicate eval-path branches for Add/Subtract/Multiply have been reverted, so the eval path still routes through numeric.plus/minus/times → ByteExactNumeric/ShortExactNumeric, producing the same BINARY_ARITHMETIC_OVERFLOW error as the new codegen path. Existing *ArithmeticExpressionSuite tests (including checkConsistencyBetweenInterpretedAndCodegenAllowingException for ByteType/ShortType under both ANSI modes) cover the path, so no new tests are required.
Key design decision worth revisiting
The Javadoc and PR description justify the new Java helper by claiming that "Scala object methods can't be called from generated code without going through the references[] array." This isn't quite right — see inline comment.
| * through the {@code references[]} array. Primitive {@code int} / {@code long} / | ||
| * {@code float} / {@code double} arithmetic stays inline -- routing those | ||
| * single-bytecode operations through a static method would be a runtime | ||
| * regression. | ||
| */ |
There was a problem hiding this comment.
The rationale here is contradicted by existing code. MathUtils and IntervalMathUtils are both top-level Scala objects and are called directly from BinaryArithmetic.doGenCode via getCanonicalName.stripSuffix("$") (arithmetic.scala:301, 324) — no references[] needed. The trick works because the Scala compiler emits public static forwarders on top-level objects' companion class.
ByteExactNumeric and ShortExactNumeric are also top-level objects, and javap on the compiled classes confirms the forwarders exist:
$ javap .../org/apache/spark/sql/types/ByteExactNumeric.class
public final class org.apache.spark.sql.types.ByteExactNumeric {
public static byte plus(byte, byte);
public static byte minus(byte, byte);
public static byte times(byte, byte);
...
(private[sql] is enforced at Scala compile time only; at the bytecode level the class is public final and the methods are public static.) So the codegen could call them directly, e.g.:
case ByteType | ShortType if failOnError =>
val methodName = symbol match {
case "+" => "plus"
case "-" => "minus"
case "*" => "times"
case _ => throw SparkException.internalError(
s"Unexpected symbol '$symbol' for Byte/Short BinaryArithmetic")
}
val numericObj = (if (dataType == ByteType) ByteExactNumeric else ShortExactNumeric)
.getClass.getCanonicalName.stripSuffix("$")
defineCodeGen(ctx, ev, (eval1, eval2) => s"$numericObj.$methodName($eval1, $eval2)")This would eliminate the new file entirely and resolve the duplication @viirya raised, rather than documenting it. Did you try this path and hit a concrete problem? If so it'd be worth capturing in the Javadoc — as written, the justification is misleading.
There was a problem hiding this comment.
You're right, sorry — private[sql] is enforced only by scalac, the bytecode is public final with public static forwarders, and Janino can call them just fine. I verified locally:
$ javap .../org/apache/spark/sql/types/ByteExactNumeric.class
public final class org.apache.spark.sql.types.ByteExactNumeric {
public static byte plus(byte, byte);
public static byte minus(byte, byte);
public static byte times(byte, byte);
...
Pushed 94014a0 which deletes ArithmeticUtils.java entirely and rewrites the codegen to call ByteExactNumeric / ShortExactNumeric directly via getCanonicalName.stripSuffix("$") — same pattern as the MathUtils / IntervalMathUtils calls on the adjacent lines. No duplication left, and the PR is purely a codegen tightening now.
I hadn't tried it before writing the helper; that was a mistake. Thanks for the verification with javap.
…actNumeric directly
What changes were proposed in this pull request?
In
BinaryArithmetic.doGenCode, theByte/ShortANSI overflow-check branch previously emitted ~7 lines per call site (int tmpResult + overflow check + cast back). After this PR it emits a single static call into the existingByteExactNumeric/ShortExactNumeric(innumerics.scala), which already implements the same overflow check +BINARY_ARITHMETIC_OVERFLOWerror that the eval path uses.The codegen rewrite uses the same
getClass.getCanonicalName.stripSuffix("$")pattern as the adjacentMathUtils/IntervalMathUtilscalls. The Scala compiler emitspublic staticforwarders on the companion class of top-level objects, so the generated Java code calls e.g.org.apache.spark.sql.types.ByteExactNumeric.plus(a, b)directly.Primitive
int/long/float/doublebranches are intentionally left inline (single bytecode op; routing those through a static method would be a runtime regression).Why are the changes needed?
Part of SPARK-56908 (umbrella). The Byte/Short ANSI branch is the largest single inline body in
BinaryArithmetic.doGenCode.Does this PR introduce any user-facing change?
No.
How was this patch tested?
35/35 pass.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Cursor 1.x