Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

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 readsDispatchedDsv2Decimal scaladoc and in the getSupportLevel scaladoc in statics.scala. Keeping the full explanation on readsDispatchedDsv2Decimal with one-line pointers elsewhere would leave one place to update. Here, three points seem enough: the rescale mirrors UnsafeRowWriter, the caller owns the null check, and the guard keeps changePrecision off the hot path. The new changePrecision assertions in CometCodegenSourceSuite repeat what the behavior tests prove, so matching the guard (.precision() == 18) there might pin the part that only a source test can.

Comment on lines +234 to 253

Copy link
Copy Markdown
Contributor

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 readsDispatchedDsv2Decimal scaladoc and in the getSupportLevel scaladoc in statics.scala. Keeping the full explanation on readsDispatchedDsv2Decimal with one-line pointers elsewhere would leave one place to update. Here, three points seem enough: the rescale mirrors UnsafeRowWriter, the caller owns the null check, and the guard keeps changePrecision off the hot path. The new changePrecision assertions in CometCodegenSourceSuite repeat what the behavior tests prove, so matching the guard (.precision() == 18) there might pin the part that only a source test can.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 i = 100000000, SELECT decfn.ns.as_money(i) IS NULL FROM t should return false: the function returns a non-null Decimal, and Spark evaluates IsNull before any decimal row write. Comet dispatches the child separately, so this new setNull makes native IS NULL return true. The base writer kept the child non-null, so this introduces a wrong result and changes filtering behavior in both ANSI modes. Could affected expression trees remain together through the Spark materialization boundary, or fall back when that cannot be preserved? Please add this parent-expression regression alongside the direct-output tests.

Evidence: A local probe compiled CometBatchKernelCodegenOutput.scala from both supplied SHAs and Janino-compiled its emitted writer. For an Invoke returning Decimal(v.toLong, 10, 0) as DECIMAL(10,2), Spark 4.1.3 UnsafeProjection(IsNull(call)) returned false at v=±100000000. The base Arrow output was non-null; the head output was null. Results matched under ANSI true/false. Spark’s direct decimal projection returned null, confirming the materialization distinction. CometIsNull.convert serializes its child separately, and native IsNullBuilder uses DataFusion’s IsNullExpr over that child’s validity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2949bda. You're right that the kernel's null reached IS NULL. An expression that takes one of these calls as an argument now runs in the same kernel as the call, so Spark's own code reads the value the function returned, and the operator falls back to Spark if the dispatcher declines it. I added your IS NULL case to CometCodegenSuite, along with a cast to a wider decimal, a comparison, a cast to string and hash. The last two differ from Spark even for values that fit, because Spark reads the function's scale 0 value, so the problem was wider than the overflow case. All of them fail without the change.

Aggregates can't run in the kernel, so count, max and sum over one of these calls now fall back to Spark. count of a value that doesn't fit was another shape the old writer got right by accident: Spark counts it, and the kernel's null didn't. Group, sort, window and join keys already matched Spark when I checked them, because Spark writes those into rows too.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 eb5bf55cd0a677f29f5c3c6a563c3caab9e7bf79 with the PR's catalog fixture.

For rows 3, NULL, 100000000, -100000000, in both ANSI modes:

  • abs(decfn.ns.as_money(i)) IS NULL returns true for both overflowing values in Comet and false in Spark.
  • decfn.ns.money_array(i)[0] IS NULL and decfn.ns.money_struct(i).m IS NULL show the same mismatch.
  • count(abs(decfn.ns.as_money(i))) returns 1 in Comet and 3 in Spark.

All three nullness/count probes pass against base 75d7c7afda78cef4894dc7a183d810c7be463aa0 using the same catalog fixture. The five existing #6425 tests pass on the head. These are local Spark 4.1.3 / JNI executions, with native Comet operators confirmed in the failing plans.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 abs, instance/static calls, array/struct access, nullness and string consumers, filtering, and count/max/sum, in both ANSI modes. It also checks dispatcher-disabled fallback and a parquet write/read boundary, where normalization really should occur. The new regression failed before the correction on both branches while the five existing decimal tests passed.

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 cargo check --all-targets passed. The tested backport is 66ec696 in #6490. Fresh CI is running; full Spark SQL CI is not yet verified.

@andygrove andygrove Oct 2, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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
Expand Down
77 changes: 76 additions & 1 deletion spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ package org.apache.comet.rules
import scala.collection.mutable.ListBuffer

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.expressions.{Divide, DoubleLiteral, EqualNullSafe, EqualTo, Expression, FloatLiteral, GreaterThan, GreaterThanOrEqual, KnownFloatingPointNormalized, LessThan, LessThanOrEqual, NamedExpression, Remainder}
import org.apache.spark.sql.catalyst.expressions.{Divide, DoubleLiteral, EqualNullSafe, EqualTo, Expression, ExprId, FloatLiteral, GreaterThan, GreaterThanOrEqual, KnownFloatingPointNormalized, LessThan, LessThanOrEqual, NamedExpression, Remainder}
import org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateMode, Final, Partial, PartialMerge}
import org.apache.spark.sql.catalyst.optimizer.NormalizeNaNAndZero
import org.apache.spark.sql.catalyst.rules.Rule
Expand Down Expand Up @@ -79,6 +79,10 @@ object CometExecRule {
val COMET_UNSAFE_PARTIAL: TreeNodeTag[String] =
TreeNodeTag[String]("comet.unsafePartialAgg")

/** Keep a DSv2 decimal producer and its consumers within Spark's row-materialization path. */
val COMET_UNMATERIALIZED_DECIMAL: TreeNodeTag[Unit] =
TreeNodeTag[Unit]("comet.unmaterializedDecimal")

/**
* Fully native operators.
*/
Expand Down Expand Up @@ -351,6 +355,12 @@ case class CometExecRule(session: SparkSession)
// spotless:on
private def transform(plan: SparkPlan): SparkPlan = {
def convertNode(op: SparkPlan): SparkPlan = op match {
case op if op.getTagValue(CometExecRule.COMET_UNMATERIALIZED_DECIMAL).isDefined =>
withFallbackReason(
op,
"DSv2 decimal result and its consumers must share Spark materialization")
op

// Scan marker produced by an optional, out-of-tree scan contrib (e.g. contrib/delta).
// Matched by trait (no compile-time dependency on the contrib) and present only when that
// contrib is on the classpath. The marker carries its own serde handler and typically wraps
Expand Down Expand Up @@ -704,6 +714,68 @@ case class CometExecRule(session: SparkSession)
}
}

/**
* Spark can pass a Decimal between fused operators without writing a row. An operator that
* defines an attribute from a dispatched DSv2 decimal call therefore cannot write it to Arrow
* before its consumers run: that would rescale/null the value early. The operator can be a
* retained Project, a generator (`explode` passes the array's elements on as they are), an
* Expand, or an aggregate whose result expression makes the call. Follow those attributes and
* keep both ends, including intervening operators, in Spark. Declining only the consumer would
* leave the early write.
*
* Paths are propagated conservatively through decimal outputs, including row boundaries. This
* may retain more Spark operators after a real materialization, but never introduces a new one
* before a consumer. Unrelated inputs, and trees that consume the call within one expression,
* remain eligible for native execution.
*/
private def tagUnmaterializedDecimals(plan: SparkPlan): Unit = {
type Paths = Map[ExprId, Set[SparkPlan]]
def visit(op: SparkPlan): Paths = {
val inputs = op.children.flatMap(visit).groupBy(_._1).map { case (id, paths) =>
id -> paths.flatMap(_._2).toSet
}
val consumed = op.references.toSeq.flatMap(a => inputs.getOrElse(a.exprId, Set.empty)).toSet
if (consumed.nonEmpty) {
(consumed + op).foreach(_.setTagValue(CometExecRule.COMET_UNMATERIALIZED_DECIMAL, ()))
}
val sources = op match {
case p: ProjectExec =>
p.projectList
.filter(QueryPlanSerde.producesUnmaterializedDsv2Decimal)
.map(_.exprId)
.toSet
// A result expression such as `fn(max(i))`, which HAVING can read. QueryPlanSerde already
// falls back an aggregate whose function makes the call.
case a: BaseAggregateExec =>
a.resultExpressions
.filter(QueryPlanSerde.producesUnmaterializedDsv2Decimal)
.map(_.exprId)
.toSet
// Other operators, such as a generator or an Expand, do not tie each new attribute to one
// expression, so take every decimal attribute that one defines. A Comet operator from an
// earlier pass is already decided.
case _
if !op.isInstanceOf[CometPlan] &&
op.expressions.exists(QueryPlanSerde.evaluatesDispatchedDsv2Decimal) =>
op.output
.filter(a => !op.inputSet.contains(a) && QueryPlanSerde.containsDecimal(a.dataType))
.map(_.exprId)
.toSet
case _ => Set.empty[ExprId]
}
op.output.flatMap { attr =>
val inherited = inputs.getOrElse(attr.exprId, Set.empty)
// Computed decimal outputs can retain the raw value as well (e.g. abs(d), or max(d)).
val derived =
if (QueryPlanSerde.containsDecimal(attr.dataType)) consumed else Set.empty[SparkPlan]
val path = inherited ++ derived
if (sources.contains(attr.exprId) || path.nonEmpty) Some(attr.exprId -> (path + op))
else None
}.toMap
}
val _ = visit(plan)
}

private def normalizePlan(plan: SparkPlan): SparkPlan = {
plan.transformUp {
case p: ProjectExec =>
Expand Down Expand Up @@ -797,6 +869,7 @@ case class CometExecRule(session: SparkSession)
// corresponding Final or PartialMerge cannot be converted and the intermediate buffer
// formats are incompatible. This runs before transform() so the tags are checked
// during the bottom-up conversion. Tags persist through AQE stage creation.
tagUnmaterializedDecimals(planWithJoinRewritten)
tagUnsafePartialAggregates(planWithJoinRewritten)

var newPlan = revertUnsafePartialAggregates(transform(planWithJoinRewritten))
Expand Down Expand Up @@ -1370,6 +1443,8 @@ case class CometExecRule(session: SparkSession)
if (handler.isEmpty) return false
val serde = handler.get.asInstanceOf[CometOperatorSerde[SparkPlan]]
if (!isOperatorEnabled(serde, agg.asInstanceOf[SparkPlan])) return false
// tagUnmaterializedDecimals runs first, and transform() leaves a tagged operator in Spark.
if (agg.getTagValue(CometExecRule.COMET_UNMATERIALIZED_DECIMAL).isDefined) return false

val aggregateExpressions = agg.aggregateExpressions
val groupingExpressions = agg.groupingExpressions
Expand Down
70 changes: 70 additions & 0 deletions spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 IS NULL, a cast to string or hash, writes a value that Arrow holds exactly, so the kernel can end there. As written, upper(name) = 'X' AND decfn.ns.as_money(i) IS NULL should dispatch the whole AND including upper, and sum(CASE WHEN decfn.ns.as_money(i) IS NULL THEN 1 ELSE 0 END) should fall back even though the aggregate only sees an INT. A sibling the dispatcher declines would also take the operator back to Spark. The scan also walks the full subtree at every level of the top-down conversion, which a decimal-only walk would bound. Something like isDispatchedDsv2DecimalCall(c) || (SupportLevel.containsType(c.dataType, classOf[DecimalType]) && readsDispatchedDsv2Decimal(c)) per child, shared with the aggregate check, is what I have in mind. I have not run it, so this is an expectation. A test with one of those shapes would settle it.

Comment on lines +1178 to +1179

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 IS NULL, a cast to string or hash, writes a value that Arrow holds exactly, so the kernel can end there. As written, upper(name) = 'X' AND decfn.ns.as_money(i) IS NULL should dispatch the whole AND including upper, and sum(CASE WHEN decfn.ns.as_money(i) IS NULL THEN 1 ELSE 0 END) should fall back even though the aggregate only sees an INT. A sibling the dispatcher declines would also take the operator back to Spark. The scan also walks the full subtree at every level of the top-down conversion, which a decimal-only walk would bound. Something like isDispatchedDsv2DecimalCall(c) || (SupportLevel.containsType(c.dataType, classOf[DecimalType]) && readsDispatchedDsv2Decimal(c)) per child, shared with the aggregate check, is what I have in mind. I have not run it, so this is an expectation. A test with one of those shapes would settle it.


/** 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.
Expand Down
16 changes: 11 additions & 5 deletions spark/src/main/scala/org/apache/comet/serde/statics.scala
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ object CometStaticInvoke extends CometExpressionSerde[StaticInvoke] {
private def handlerFor(expr: StaticInvoke): Option[CometExpressionSerde[StaticInvoke]] =
staticInvokeExpressions.get((expr.functionName, expr.staticObject.getName))

/** Whether [[convert]] hands `expr` to the codegen dispatcher, because no handler claims it. */
def runsInDispatcher(expr: StaticInvoke): Boolean =
handlerFor(expr).isEmpty && icebergHandlerFor(expr).isEmpty

/**
* Iceberg's system functions are keyed by class name only because both the `StaticInvoke` and
* `ApplyFunctionExpression` lowerings share the same identity class. Consulted after the
Expand All @@ -80,11 +84,13 @@ object CometStaticInvoke extends CometExpressionSerde[StaticInvoke] {
* [[CodegenDispatchFallback]]: that would also route a *handler's* `Unsupported` through the
* dispatcher, and at least one of those is not dispatchable. `CometIcebergTruncate` declines a
* decimal because Iceberg's `truncate` can return a value wider than the column's declared
* precision, which Spark nulls only when the row is materialized; the dispatcher writes into an
* Arrow `Decimal128(precision, scale)` vector just like a native kernel does, so it produces
* the out-of-range value instead of a null. The mixin's contract ("the case must be something
* `doGenCode` can compile") does not cover a limit that lives at the Arrow output boundary, so
* enrollment stays with the individual handlers.
* precision, which Spark nulls only when the row is materialized. The dispatcher nulls such a
* value at its own output, as the row writer does, and an expression or aggregate that reads a
* dispatched call either runs in the same kernel or falls back (see
* `QueryPlanSerde.readsDispatchedDsv2Decimal`), but the decline predates that and stays. The
* mixin's contract ("the case must be something `doGenCode` can compile") does not cover a
* limit that lives at the Arrow output boundary, so enrollment stays with the individual
* handlers.
*/
override def getSupportLevel(expr: StaticInvoke): SupportLevel =
handlerFor(expr)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,13 @@ object CometShuffleExchangeExec
if (QueryPlanSerde.exprToProto(expr, inputs).isEmpty) {
reasons += s"unsupported hash partitioning expression: $expr"
}
// Spark hashes the `Decimal` a DSv2 function returned, at the scale the function chose,
// while the native shuffle would hash the dispatcher's rescaled output, and so place
// rows in other partitions (#6425). The JVM columnar shuffle computes the partition with
// Spark's own projection.
if (QueryPlanSerde.producesUnmaterializedDsv2Decimal(expr)) {
reasons += s"hash partitioning expression reads a DSv2 decimal result: $expr"
}
}
for (dt <- expressions.map(_.dataType).distinct) {
if (!supportedHashPartitioningDataType(dt)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ class CometCodegenSourceSuite extends AnyFunSuite {
!result.body.contains(".toJavaBigDecimal("),
"expected no BigDecimal allocation for short-precision output; got:\n" +
CodeFormatter.format(result.code))
// Rescaled to the declared type first, as Spark's row writer does (#6425).
assert(
result.body.contains(".changePrecision(18, 2)"),
s"expected changePrecision before the write; got:\n${CodeFormatter.format(result.code)}")
}

test("DecimalVector setSafe uses BigDecimal slow path for long-precision output") {
Expand All @@ -417,6 +421,9 @@ class CometCodegenSourceSuite extends AnyFunSuite {
!result.body.contains(".toUnscaledLong()"),
"expected no unscaled-long write for long-precision output; got:\n" +
CodeFormatter.format(result.code))
assert(
result.body.contains(".changePrecision(38, 10)"),
s"expected changePrecision before the write; got:\n${CodeFormatter.format(result.code)}")
}

test("VarCharVector setSafe uses on-heap UTF8String shortcut") {
Expand Down
Loading
Loading