Repository navigation
fix: rescale decimal results from dispatched functions to the declared type #6455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ee26712
4cf7c1c
04492d8
e989715
2949bda
eb5bf55
d6f88f8
5d07da3
c655d63
e1972d1
ee8b3e5
238c935
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -231,15 +231,43 @@ private[codegen] object CometBatchKernelCodegenOutput extends CometTypeShim { | |
| val set = if (nested) "setSafe" else "set" | ||
| OutputEmit("", s"$targetVec.$set($idx, $source);") | ||
| case dt: DecimalType => | ||
| // Rescale to the declared type, and write null when the value does not fit, as Spark's | ||
| // `UnsafeRowWriter` and `UnsafeArrayWriter` do in `write(ordinal, Decimal, precision, | ||
| // scale)`. A Spark expression already produces its declared precision and scale, but a | ||
| // DSv2 function called through `Invoke` / `StaticInvoke` can return a `Decimal` of any | ||
| // scale (#6425). Like Spark's writers, this rescales the value in place, and leaves it | ||
| // untouched when it does not fit. Unlike them, it does not test `source` for null: the | ||
| // callers write null values themselves, and skip that test only for a type that is not | ||
| // nullable. | ||
| // | ||
| // Only the kernel's own output sees the null. Spark rescales such a value when it writes a | ||
| // row, and an expression around the call reads the value the function returned, so | ||
| // `QueryPlanSerde` dispatches that expression with the call, and falls an aggregate back. | ||
| // | ||
| // The precision and scale test repeats `changePrecision`'s own fast path. It keeps the call | ||
| // off the common path, so the JIT can still scalar-replace the `Decimal` that an input | ||
| // getter allocates. With the bare call, passing a `DECIMAL(18, 2)` column through took | ||
| // about half as long again per row. | ||
| // | ||
| // DecimalOutputShortFastPath: precision <= 18 fits in a signed long, so pass the unscaled | ||
| // value to `setSafe(int, long)` and skip the BigDecimal allocation. | ||
|
Comment on lines
+234
to
253
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rule that Spark rescales a DSv2 result only when it writes a row, so a consumer runs in the kernel with the call and an aggregate falls back, is now explained here, at the aggregate check, in the |
||
| val dec = ctx.freshName("dec") | ||
| val (precision, scale) = (dt.precision, dt.scale) | ||
| val write = | ||
| if (dt.precision <= Decimal.MAX_LONG_DIGITS) { | ||
| s"$targetVec.setSafe($idx, $source.toUnscaledLong());" | ||
| if (precision <= Decimal.MAX_LONG_DIGITS) { | ||
| s"$targetVec.setSafe($idx, $dec.toUnscaledLong());" | ||
| } else { | ||
| s"$targetVec.setSafe($idx, $source.toJavaBigDecimal());" | ||
| s"$targetVec.setSafe($idx, $dec.toJavaBigDecimal());" | ||
| } | ||
| OutputEmit("", write) | ||
| OutputEmit( | ||
| "", | ||
| s"""org.apache.spark.sql.types.Decimal $dec = $source; | ||
| |if (($dec.precision() == $precision && $dec.scale() == $scale) || | ||
| | $dec.changePrecision($precision, $scale)) { | ||
| | $write | ||
| |} else { | ||
| | $targetVec.setNull($idx); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Preserve Spark’s overflow timing when a native parent consumes this output. Using the new catalog fixture with Evidence: A local probe compiled
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 2949bda. You're right that the kernel's null reached Aggregates can't run in the kernel, so
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The direct-parent cases now pass, but I reproduced @parthchandra's transitive-consumer concern on For rows
All three nullness/count probes pass against base The intermediate expression is dispatched and writes a rescaled/null decimal, but its native parent or aggregate does not match the immediate-child check. Could the guard preserve the affected value through the full expression chain, or fall these chains back, and add these nested cases to the regression coverage?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sunchao @parthchandra Fixed the transitive case in 5d07da3. Both checks now search the full argument trees: scalar consumers run with the DSv2 decimal call in one kernel, and aggregates containing such a call fall back to Spark. This keeps the intermediate decimal from being normalized before its consumer reads it. The regression covers nested Local validation: source Spark 4.1 codegen suites 170/170; branch-1.1 Spark 4.1 169/169; branch-1.1 Spark 3.5 targeted decimal/writer tests 66/66. Native builds and release
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sunchao The retained-projection case is fixed in c655d63, with the branch-1.1 cherry-pick in 76a0e1c (#6490). I reproduced both reported queries on the previous heads: the fused Spark projections preserve non-null overflowing values until the outer consumer runs, whereas the inner Comet projection had already normalized them. Both ANSI modes showed the nullness/count mismatch. The planner now follows decimal-bearing DSv2 projected attributes and keeps the affected producer, consumer, and intervening operators in Spark. Falling back only at the outer consumer would be too late. This is deliberately conservative: provenance can continue through downstream decimal outputs even beyond a real row boundary, so those affected chains may lose some native execution. Native input scans, unrelated branches, and calls consumed within one dispatched expression tree remain eligible for Comet. The regression covers retained instance/static aliases, intermediate projections, filters, array/struct access, scale-sensitive string casts, and count/max, with ANSI and AQE on/off. It also checks whole-stage codegen disabled, where Spark materializes each projection. Existing direct-output and parquet write/read boundary tests still pass. Additional full-SQL probes matched final Spark results and AQE plans, including count-only consumers and exchange/UNION boundaries. Local dispatcher, generated-source, and planner suites passed: source Spark 4.1 257 tests; branch-1.1 Spark 4.1 253 tests; branch-1.1 Spark 3.5 251 tests, with two expected version-guard cancellations. Fresh configured CI, including the full Spark 4.1 SQL matrix, is now being monitored; its verdict is still pending. CI follow-up: the strict Spark 3.5 compiler caught an implicitly discarded return value in the traversal. The one-line explicit discard is now in e1972d1 and release facb668. Local strict Spark 3.5 test compilation passes, and the source257/release253 Spark 4.1 tests passed again. This does not change the fallback behavior; replacement CI runs are pending. |
||
| |}""".stripMargin) | ||
| case _: StringType => | ||
| // Utf8OutputOnHeapShortcut: when the UTF8String is on-heap (Spark's string functions | ||
| // allocate results on-heap), pass its backing byte[] directly to `setSafe`, skipping the | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ import org.apache.spark.sql.catalyst.expressions.aggregate._ | |
| import org.apache.spark.sql.catalyst.expressions.objects.{Invoke, StaticInvoke} | ||
| import org.apache.spark.sql.catalyst.expressions.xml.{XPathBoolean, XPathDouble, XPathFloat, XPathInt, XPathList, XPathLong, XPathShort, XPathString} | ||
| import org.apache.spark.sql.comet.DecimalPrecision | ||
| import org.apache.spark.sql.connector.catalog.functions.ScalarFunction | ||
| import org.apache.spark.sql.execution.{ScalarSubquery, SparkPlan} | ||
| import org.apache.spark.sql.execution.datasources.parquet.ParquetUtils | ||
| import org.apache.spark.sql.internal.SQLConf | ||
|
|
@@ -836,6 +837,17 @@ object QueryPlanSerde extends Logging with CometExprShim with CometTypeShim { | |
| } | ||
|
|
||
| val fn = aggExpr.aggregateFunction | ||
| // An aggregate reads the `Decimal` a DSv2 function returned, whether or not it fits the | ||
| // declared type, and the kernel has nulled such a value at its own output: Spark counts it | ||
| // and takes it as the maximum, and the row writer nulls the result afterwards. An aggregate | ||
| // cannot run in the kernel, so it falls back. See [[readsDispatchedDsv2Decimal]]. | ||
| if (fn.children.exists(_.exists(isDispatchedDsv2DecimalCall))) { | ||
| withFallbackReason( | ||
| aggExpr, | ||
| s"${fn.prettyName} aggregates the decimal result of a DSv2 function, which Spark " + | ||
| "rescales to the declared type only when it writes a row") | ||
| return None | ||
| } | ||
| val cometExpr = aggrSerdeMap.get(fn.getClass) | ||
| val protoAggExprOpt = cometExpr match { | ||
| case Some(handler) => | ||
|
|
@@ -1032,6 +1044,12 @@ object QueryPlanSerde extends Logging with CometExprShim with CometTypeShim { | |
| s"${CometConf.getExprEnabledConfigKey(exprConfName)}=true to enable it.") | ||
| return None | ||
| } | ||
| if (readsDispatchedDsv2Decimal(expr)) { | ||
| // Returns None, tagged with the reason, when the dispatcher declines, which falls the | ||
| // operator back to Spark. Converting `expr` natively is not an alternative: it would read | ||
| // the call's output rather than the value the function returned. | ||
| return CometScalaUDF.emitJvmCodegenDispatch(expr, inputs, binding) | ||
| } | ||
| handler.getSupportLevel(expr) match { | ||
| case Unsupported(notes) => | ||
| // `CodegenDispatchFallback` serdes have no native path for these cases either, but the | ||
|
|
@@ -1141,6 +1159,58 @@ object QueryPlanSerde extends Logging with CometExprShim with CometTypeShim { | |
| case _ => false | ||
| } | ||
|
|
||
| /** | ||
| * Whether `expr` consumes a decimal result of a dispatched DSv2 scalar function anywhere in its | ||
| * argument trees, including through intermediate expressions and container access. | ||
| * | ||
| * Spark does not rescale such a result to the type the function declares, or write null when it | ||
| * does not fit, until it writes a row. An expression around the call reads the `Decimal` the | ||
| * function returned. The dispatcher has to write an Arrow vector of the declared type, so it | ||
| * rescales and nulls at its own output, and a native expression over that output would read | ||
| * something else. `IS NULL` of a value that does not fit is false in Spark, a cast to string | ||
| * keeps the function's scale, and `hash` reads the unscaled value at that scale (#6425). So | ||
| * such an expression runs in the same kernel as the call, where Spark's own code reads the | ||
| * value the function returned. Checking only immediate children would let an intermediate | ||
| * expression, such as `abs(call)` or `call[0]`, normalize the decimal before its parent reads | ||
| * it. `Alias` is skipped because it computes nothing. An operator's output is not necessarily a | ||
| * row-materialization boundary: CometExecRule keeps an operator that defines a decimal | ||
| * attribute from such a call, such as a Project, a generator or an aggregate's result, in Spark | ||
| * with that attribute's consumers until they have read the original value. | ||
| */ | ||
| private def readsDispatchedDsv2Decimal(expr: Expression): Boolean = | ||
| !isStructuralExpr(expr) && expr.children.exists(_.exists(isDispatchedDsv2DecimalCall)) | ||
|
Comment on lines
+1178
to
+1179
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be enough to follow only decimal-typed children here? A node whose type has no decimal, such as
Comment on lines
+1178
to
+1179
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be enough to follow only decimal-typed children here? A node whose type has no decimal, such as |
||
|
|
||
| /** Whether `expr` produces a decimal that holds the value a dispatched DSv2 call returned. */ | ||
| def producesUnmaterializedDsv2Decimal(expr: Expression): Boolean = | ||
| containsDecimal(expr.dataType) && evaluatesDispatchedDsv2Decimal(expr) | ||
|
|
||
| /** Whether `expr` calls a dispatched DSv2 scalar function that returns a decimal. */ | ||
| private[comet] def evaluatesDispatchedDsv2Decimal(expr: Expression): Boolean = | ||
| expr.exists(isDispatchedDsv2DecimalCall) | ||
|
|
||
| private def isDispatchedDsv2DecimalCall(expr: Expression): Boolean = { | ||
| val dispatchedDsv2Call = expr match { | ||
| case i: Invoke => | ||
| i.targetObject match { | ||
| case Literal(_: ScalarFunction[_], _) => true | ||
| case _ => false | ||
| } | ||
| case s: StaticInvoke => | ||
| classOf[ScalarFunction[_]].isAssignableFrom(s.staticObject) && | ||
| CometStaticInvoke.runsInDispatcher(s) | ||
| case _ => false | ||
| } | ||
| dispatchedDsv2Call && containsDecimal(expr.dataType) | ||
| } | ||
|
|
||
| private[comet] def containsDecimal(dataType: DataType): Boolean = dataType match { | ||
| case _: DecimalType => true | ||
| case ArrayType(elementType, _) => containsDecimal(elementType) | ||
| case MapType(keyType, valueType, _) => containsDecimal(keyType) || containsDecimal(valueType) | ||
| case StructType(fields) => fields.exists(f => containsDecimal(f.dataType)) | ||
| case _ => false | ||
| } | ||
|
|
||
| /** | ||
| * Creates a UnaryExpr by calling exprToProtoInternal for the provided child expression and then | ||
| * invokes the supplied function to wrap this UnaryExpr in a top-level Expr. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The rule that Spark rescales a DSv2 result only when it writes a row, so a consumer runs in the kernel with the call and an aggregate falls back, is now explained here, at the aggregate check, in the
readsDispatchedDsv2Decimalscaladoc and in thegetSupportLevelscaladoc instatics.scala. Keeping the full explanation onreadsDispatchedDsv2Decimalwith one-line pointers elsewhere would leave one place to update. Here, three points seem enough: the rescale mirrorsUnsafeRowWriter, the caller owns the null check, and the guard keepschangePrecisionoff the hot path. The newchangePrecisionassertions inCometCodegenSourceSuiterepeat what the behavior tests prove, so matching the guard (.precision() == 18) there might pin the part that only a source test can.