This is an automated email from the ASF dual-hosted git repository.
weiting-chen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git
The following commit(s) were added to refs/heads/main by this push:
new aaec0f5161 [VL] Offload 2-arg decimal ceiling/floor to Velox native
execution (#12777)
aaec0f5161 is described below
commit aaec0f51615a0a573ad3484bb21ab1b6331eb400
Author: Minni Mittal <[email protected]>
AuthorDate: Fri Oct 30 19:43:51 2026 +0530
[VL] Offload 2-arg decimal ceiling/floor to Velox native execution (#12777)
* [VL] Offload 2-arg decimal ceiling/floor to Velox native execution
Spark's `ceiling(x, scale)` / `floor(x, scale)` on decimal inputs produce
`RoundCeil(decimal, scale)` / `RoundFloor(decimal, scale)`. These were
mapped
to the substrait `ceil` / `floor` function names but had no native execution
path, so the 2-arg decimal form fell back to vanilla Spark.
This wires them to the Velox `decimal_ceil` / `decimal_floor` special forms:
- SubstraitParser: `mapToVeloxFunction` gains a `numArgs` parameter and
remaps
2-arg `ceil` / `floor` on decimals to `decimal_ceil` / `decimal_floor`.
Unary
`ceil(decimal)` / `floor(decimal)` keep their existing name.
- DecimalCeilFloorTransformer: new transformer mirroring
DecimalRoundTransformer;
recomputes the output DecimalType from the input type and constant-folded
scale
(matching Spark's RoundBase.dataType) and emits the scale as a literal.
- ExpressionConverter: route decimal RoundCeil / RoundFloor through the new
transformer.
Addresses the RoundCeil / RoundFloor items in #10134.
* [VL] Address review: ANSI fallback + defensive scale handling for 2-arg
decimal ceil/floor
Address review feedback on the 2-arg decimal ceiling/floor offload:
- Overflow semantics: Velox's decimal_ceil / decimal_floor return NULL when
the rounded result exceeds the declared precision, whereas Spark's
RoundBase
raises under ANSI mode. DecimalCeilFloorTransformer now falls back to
Spark
when ANSI mode is enabled, so the ANSI error is preserved instead of a
silent
NULL (this holds even when spark.gluten.sql.ansiFallback.enabled is
false).
Under non-ANSI mode Spark also returns NULL, so native offload stays
correct.
- Defensive scale handling: require a foldable scale and wrap evaluation /
type
errors in GlutenNotSupportException so a non-literal or un-evaluable scale
triggers a clean Spark fallback instead of aborting transformation.
- Tests: gate the 2-arg ceiling/floor test with SparkVersionUtil.gteSpark40
(the
SQL form is invalid on Spark 3.4/3.5 and would fail during analysis), and
add
a regression test asserting the ANSI overflow case falls back and raises
rather
than returning NULL.
Generated-by: GitHub Copilot CLI (Claude Opus 4.8)
Co-authored-by: Copilot <[email protected]>
* [VL] Preserve decimal ceil/floor overflow semantics
## Summary
Preserve Spark behavior when offloading two-argument decimal ceiling and
floor expressions. Fall back for precision-overflow and unsupported
negative-scale cases while retaining native execution for safe inputs, with
focused cross-version regression coverage.
## Prompting Intent
Address all open review comments on apache/gluten#12777, validate the
changes through the six-round review gauntlet, keep unsupported cases on Spark,
and ensure the public commit contains no internal or Microsoft-only material.
## Linked Sources
- Pull request: https://github.com/apache/gluten/pull/12777
- Tracking issue: https://github.com/apache/gluten/issues/10134
- Review comments:
https://github.com/apache/gluten/pull/12777#pullrequestreview-threads
## Rationale
Velox returns NULL for decimal directional-rounding overflow while Spark
raises regardless of ANSI mode. Type-level eligibility checks avoid silent
semantic divergence without disabling native execution for safe precision and
scale combinations. Negative-scale Spark decimals fall back because Velox
decimal types cannot represent them safely.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8f9d9624-2e39-43dd-b723-f541d372a92c
* [VL] Scope decimal ceil/floor conversion to Velox
## Summary
Move the decimal RoundCeil and RoundFloor transformer selection from the
shared expression converter into the Velox backend-specific converter hook.
Bolt and ClickHouse retain their existing generic conversion and fallback
behavior.
## Prompting Intent
Address the new review finding on apache/gluten#12777 that the shared
converter exposed Velox-only decimal ceiling and floor semantics to the Bolt
backend, while keeping the contribution public and free of internal material.
## Linked Sources
- Pull request: https://github.com/apache/gluten/pull/12777
- Review thread:
https://github.com/apache/gluten/pull/12777#discussion_r4141110964
- Tracking issue: https://github.com/apache/gluten/issues/10134
## Rationale
Used the existing backend-specific expression converter hook instead of
adding backend-name checks to common code. This keeps the common module
backend-neutral and confines the specialized decimal_ceil and decimal_floor
path to the only backend that implements it.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8f9d9624-2e39-43dd-b723-f541d372a92c
* [VL] Format decimal ceil/floor tests
## Summary
Apply the repository Scala formatter to the decimal ceiling and floor
regression tests so the Scala format CI check passes without changing behavior.
## Prompting Intent
Investigate and fix the Scala formatting failure reported by CI for
apache/gluten#12777 while keeping the public contribution free of internal
material.
## Linked Sources
- Pull request: https://github.com/apache/gluten/pull/12777
- Failed Scala format run:
https://github.com/apache/gluten/actions/runs/36673625270
## Rationale
Used the repository-provided Scala formatter rather than manually
approximating its layout rules. The formatter modified only the reported test
file, and a subsequent format check completed successfully.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8f9d9624-2e39-43dd-b723-f541d372a92c
---------
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 8f9d9624-2e39-43dd-b723-f541d372a92c
---
.../backendsapi/velox/VeloxSparkPlanExecApi.scala | 23 ++++
.../functions/MathFunctionsValidateSuite.scala | 126 +++++++++++++++++++++
cpp/velox/substrait/SubstraitParser.cc | 11 +-
cpp/velox/substrait/SubstraitParser.h | 7 +-
.../expression/DecimalCeilFloorTransformer.scala | 111 ++++++++++++++++++
5 files changed, 275 insertions(+), 3 deletions(-)
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
index 4c64695507..4a7c2328d4 100644
---
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
+++
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxSparkPlanExecApi.scala
@@ -1405,6 +1405,29 @@ class VeloxSparkPlanExecApi extends SparkPlanExecApi
with Logging {
)
}
+ override def extraExpressionConverter(
+ substraitExprName: String,
+ expr: Expression,
+ attributeSeq: Seq[Attribute]): Option[ExpressionTransformer] = {
+ expr match {
+ case rc: RoundCeil if rc.child.dataType.isInstanceOf[DecimalType] =>
+ Some(
+ DecimalCeilFloorTransformer(
+ substraitExprName,
+ ExpressionConverter.replaceWithExpressionTransformer(rc.child,
attributeSeq),
+ rc,
+ rc.scale))
+ case rf: RoundFloor if rf.child.dataType.isInstanceOf[DecimalType] =>
+ Some(
+ DecimalCeilFloorTransformer(
+ substraitExprName,
+ ExpressionConverter.replaceWithExpressionTransformer(rf.child,
attributeSeq),
+ rf,
+ rf.scale))
+ case _ => None
+ }
+ }
+
override def rewriteSpillPath(path: String): String = {
val fs = VeloxConfig.get.veloxSpillFileSystem
fs match {
diff --git
a/backends-velox/src/test/scala/org/apache/gluten/functions/MathFunctionsValidateSuite.scala
b/backends-velox/src/test/scala/org/apache/gluten/functions/MathFunctionsValidateSuite.scala
index 13f3f4feea..5d64b5fd33 100644
---
a/backends-velox/src/test/scala/org/apache/gluten/functions/MathFunctionsValidateSuite.scala
+++
b/backends-velox/src/test/scala/org/apache/gluten/functions/MathFunctionsValidateSuite.scala
@@ -21,7 +21,10 @@ import
org.apache.gluten.execution.{BatchScanExecTransformer, ProjectExecTransfo
import org.apache.spark.SparkConf
import org.apache.spark.sql.Row
+import org.apache.spark.sql.catalyst.expressions.{RoundCeil, RoundFloor}
+import org.apache.spark.sql.functions.col
import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.DecimalType
class MathFunctionsValidateSuiteAnsiOn extends FunctionsValidateSuite {
@@ -122,6 +125,129 @@ class MathFunctionsValidateSuite extends
FunctionsValidateSuite {
}
}
+ test("2-arg ceiling / floor on decimals (RoundCeil / RoundFloor)") {
+ // The 2-arg forms produce Spark RoundCeil / RoundFloor and dispatch to
the Velox
+ // decimal_ceil / decimal_floor special forms. The projection is native
only when the
+ // expression offloads, so checkGlutenPlan[ProjectExecTransformer] doubles
as an offload
+ // assertion; runQueryAndCompare additionally validates results against
vanilla Spark.
+ withSQLConf(SQLConf.ANSI_ENABLED.key -> "false") {
+ runQueryAndCompare(
+ "SELECT ceiling(cast(l_quantity as decimal(12, 2)), 1) FROM lineitem
limit 10") {
+ checkGlutenPlan[ProjectExecTransformer]
+ }
+ runQueryAndCompare(
+ "SELECT floor(cast(l_quantity as decimal(12, 2)), 1) FROM lineitem
limit 10") {
+ checkGlutenPlan[ProjectExecTransformer]
+ }
+ // Negative scale rounds to the left of the decimal point.
+ runQueryAndCompare(
+ "SELECT ceiling(cast(l_extendedprice as decimal(20, 4)), -2) FROM
lineitem limit 10") {
+ checkGlutenPlan[ProjectExecTransformer]
+ }
+ runQueryAndCompare(
+ "SELECT floor(cast(l_extendedprice as decimal(20, 4)), -2) FROM
lineitem limit 10") {
+ checkGlutenPlan[ProjectExecTransformer]
+ }
+ }
+ }
+
+ test("2-arg ceiling / floor on decimals offloads under ANSI when precision
cannot overflow") {
+ withSQLConf(
+ SQLConf.ANSI_ENABLED.key -> "true",
+ GlutenConfig.GLUTEN_ANSI_FALLBACK_ENABLED.key -> "false") {
+ runQueryAndCompare(
+ "SELECT ceiling(cast(l_quantity as decimal(12, 2)), 1) FROM lineitem
limit 10") {
+ checkGlutenPlan[ProjectExecTransformer]
+ }
+ runQueryAndCompare(
+ "SELECT floor(cast(l_extendedprice as decimal(20, 4)), -2) FROM
lineitem limit 10") {
+ checkGlutenPlan[ProjectExecTransformer]
+ }
+ }
+ }
+
+ test("2-arg ceiling / floor on decimals falls back when precision can
overflow") {
+ Seq("true", "false").foreach {
+ ansiEnabled =>
+ withSQLConf(
+ SQLConf.ANSI_ENABLED.key -> ansiEnabled,
+ GlutenConfig.GLUTEN_ANSI_FALLBACK_ENABLED.key -> "false") {
+ Seq(
+ ("ceiling", "99999999999999999999999999999999999999", "decimal(38,
0)", "-1"),
+ ("floor", "-99999999999999999999999999999999999999", "decimal(38,
0)", "-1"),
+ ("ceiling", "5", "decimal(10, 2)", "-38")
+ ).foreach {
+ case (function, value, decimalType, scale) =>
+ val df =
+ spark.sql(s"SELECT $function(cast('$value' as $decimalType),
$scale)")
+ assert(
+ df.queryExecution.executedPlan
+ .collect { case project: ProjectExecTransformer => project }
+ .isEmpty,
+ s"Expected decimal $function overflow to fall back to
Spark:\n" +
+ df.queryExecution.executedPlan
+ )
+ val error = intercept[Exception] {
+ df.collect()
+ }
+ assert(
+ Iterator
+ .iterate[Throwable](error)(_.getCause)
+ .takeWhile(_ != null)
+ .exists(_.isInstanceOf[ArithmeticException]),
+ s"Expected an arithmetic overflow error but received: $error"
+ )
+ }
+
+ val df =
+ spark.sql(
+ "SELECT ceiling(cast('0' as decimal(10, 2)), cast(-2147483648 as
int))")
+ assert(
+ df.queryExecution.executedPlan
+ .collect { case project: ProjectExecTransformer => project }
+ .isEmpty,
+ s"Expected extreme-scale decimal ceiling to fall back to Spark:\n"
+
+ df.queryExecution.executedPlan
+ )
+ assert(df.collect().head.getDecimal(0).signum() == 0)
+ }
+ }
+ }
+
+ test("2-arg ceiling / floor on negative-scale decimals falls back") {
+ withSQLConf(
+ SQLConf.LEGACY_ALLOW_NEGATIVE_SCALE_OF_DECIMAL_ENABLED.key -> "true",
+ SQLConf.ANSI_ENABLED.key -> "true",
+ GlutenConfig.GLUTEN_ANSI_FALLBACK_ENABLED.key -> "false"
+ ) {
+ val input = spark
+ .range(-1L, 2L)
+ .select(col("id").cast(DecimalType(1, -256)).as("d"))
+
+ Seq("ceiling(d, -255)", "floor(d, -255)").foreach {
+ expression =>
+ val df = input.selectExpr(expression)
+ assert(
+ df.queryExecution.optimizedPlan.expressions.exists(
+ _.find {
+ case _: RoundCeil | _: RoundFloor => true
+ case _ => false
+ }.isDefined),
+ s"Expected $expression to remain in the optimized plan:\n" +
+ df.queryExecution.optimizedPlan
+ )
+ assert(
+ df.queryExecution.executedPlan
+ .collect { case project: ProjectExecTransformer => project }
+ .isEmpty,
+ s"Expected $expression on a negative-scale decimal to fall back to
Spark:\n" +
+ df.queryExecution.executedPlan
+ )
+ assert(df.collect().forall(_.getDecimal(0).signum() == 0))
+ }
+ }
+ }
+
test("cos") {
runQueryAndCompare("SELECT cos(l_orderkey) from lineitem limit 1") {
checkGlutenPlan[ProjectExecTransformer]
diff --git a/cpp/velox/substrait/SubstraitParser.cc
b/cpp/velox/substrait/SubstraitParser.cc
index 869c42b62c..3333810e01 100644
--- a/cpp/velox/substrait/SubstraitParser.cc
+++ b/cpp/velox/substrait/SubstraitParser.cc
@@ -274,10 +274,10 @@ std::string SubstraitParser::findVeloxFunction(
break;
}
}
- return mapToVeloxFunction(funcName, isDecimal);
+ return mapToVeloxFunction(funcName, isDecimal, types.size());
}
-std::string SubstraitParser::mapToVeloxFunction(const std::string&
substraitFunction, bool isDecimal) {
+std::string SubstraitParser::mapToVeloxFunction(const std::string&
substraitFunction, bool isDecimal, size_t numArgs) {
auto it = substraitVeloxFunctionMap_.find(substraitFunction);
if (isDecimal) {
if (substraitFunction == "lt" || substraitFunction == "lte" ||
substraitFunction == "gt" ||
@@ -287,6 +287,13 @@ std::string SubstraitParser::mapToVeloxFunction(const
std::string& substraitFunc
if (substraitFunction == "round") {
return "decimal_round";
}
+ // Spark RoundCeil / RoundFloor are emitted with substrait names "ceil"
+ // and "floor" but require dispatch to the 2-arg decimal special forms.
+ // The unary forms `ceil(decimal)` / `floor(decimal)` keep their original
+ // name (handled by simple-function registration).
+ if (numArgs == 2 && (substraitFunction == "ceil" || substraitFunction ==
"floor")) {
+ return "decimal_" + substraitFunction;
+ }
}
if (it != substraitVeloxFunctionMap_.end()) {
return it->second;
diff --git a/cpp/velox/substrait/SubstraitParser.h
b/cpp/velox/substrait/SubstraitParser.h
index 8587c1e5f4..8986f17232 100644
--- a/cpp/velox/substrait/SubstraitParser.h
+++ b/cpp/velox/substrait/SubstraitParser.h
@@ -86,7 +86,12 @@ class SubstraitParser {
static std::string findVeloxFunction(const std::unordered_map<uint64_t,
std::string>& functionMap, uint64_t id);
/// Map the Substrait function keyword into Velox function keyword.
- static std::string mapToVeloxFunction(const std::string& substraitFunction,
bool isDecimal);
+ /// `numArgs` is the number of substrait arguments observed in the call
+ /// signature. It is used to disambiguate overloads such as unary
+ /// `ceil(decimal)` (mapped to `ceil`) vs. 2-arg `ceil(decimal, int)`
+ /// (mapped to the special form `decimal_ceil`). Pass 0 when arity is
+ /// unknown -- in that case arity-sensitive remappings are skipped.
+ static std::string mapToVeloxFunction(const std::string& substraitFunction,
bool isDecimal, size_t numArgs = 0);
/// @brief Return whether a config is set as true in AdvancedExtension
/// optimization.
diff --git
a/gluten-substrait/src/main/scala/org/apache/gluten/expression/DecimalCeilFloorTransformer.scala
b/gluten-substrait/src/main/scala/org/apache/gluten/expression/DecimalCeilFloorTransformer.scala
new file mode 100644
index 0000000000..477a08f23a
--- /dev/null
+++
b/gluten-substrait/src/main/scala/org/apache/gluten/expression/DecimalCeilFloorTransformer.scala
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.gluten.expression
+
+import org.apache.gluten.backendsapi.BackendsApiManager
+import org.apache.gluten.exception.GlutenNotSupportException
+
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.types.{DataType, DecimalType}
+
+/**
+ * Transformer for Spark `RoundCeil(decimal, scale)` and `RoundFloor(decimal,
scale)`. These power
+ * the 2-argument forms of `ceiling(x, scale)` / `floor(x, scale)` and
dispatch to the Velox
+ * `decimal_ceil` / `decimal_floor` special forms (substrait names `ceil` /
`floor`, remapped on the
+ * C++ side based on arity + decimal arg type).
+ *
+ * The output `DataType` is recomputed from the original Spark decimal input
type and the constant
+ * folded scale, matching Spark's `RoundBase.dataType` formula. Mirrors the
structure of
+ * `DecimalRoundTransformer`.
+ */
+case class DecimalCeilFloorTransformer(
+ substraitExprName: String,
+ child: ExpressionTransformer,
+ original: Expression,
+ scaleExpr: Expression)
+ extends BinaryExpressionTransformer {
+
+ // Spark requires the scale to be a foldable integer literal, but guard
defensively so any
+ // non-foldable scale, evaluation failure, or unexpected value type triggers
a clean fallback
+ // via GlutenNotSupportException instead of aborting the whole
transformation.
+ private val toScale: Int = {
+ if (!scaleExpr.foldable) {
+ throw new GlutenNotSupportException(
+ s"Scale expression is not foldable for ${original.nodeName}. Falling
back to Spark.")
+ }
+ val evaluated =
+ try {
+ scaleExpr.eval(EmptyRow)
+ } catch {
+ case e: Exception =>
+ throw new GlutenNotSupportException(
+ s"Failed to evaluate scale expression for ${original.nodeName}:
${e.getMessage}. " +
+ "Falling back to Spark.")
+ }
+ evaluated match {
+ case null =>
+ throw new GlutenNotSupportException(
+ s"Scale expression evaluated to null for ${original.nodeName}.
Falling back to Spark.")
+ case i: Int => i
+ case other =>
+ throw new GlutenNotSupportException(
+ s"Scale expression for ${original.nodeName} is expected to be an int
but evaluated to " +
+ s"${other.getClass.getSimpleName}. Falling back to Spark.")
+ }
+ }
+
+ private val decimalType = original.children.head.dataType match {
+ case decimalType: DecimalType =>
+ decimalType
+ case other =>
+ throw new GlutenNotSupportException(
+ s"Decimal type is expected for ${original.nodeName} but received
${other.typeName}.")
+ }
+
+ if (decimalType.scale < 0) {
+ throw new GlutenNotSupportException(
+ s"${original.nodeName} does not support native execution for
${decimalType.sql} because " +
+ "Velox decimal types require a non-negative scale. Falling back to
Spark.")
+ }
+
+ private val requiredPrecision: Long = {
+ val integralLeastNumDigits = decimalType.precision.toLong -
decimalType.scale + 1L
+ if (toScale < 0) {
+ math.max(integralLeastNumDigits, -toScale.toLong + 1L)
+ } else {
+ integralLeastNumDigits + math.min(decimalType.scale, toScale)
+ }
+ }
+
+ // Velox returns NULL on decimal precision overflow, while Spark raises
regardless of ANSI mode.
+ // Spark caps the output type at MAX_PRECISION, so fall back only when
rounding can change the
+ // value and the uncapped result precision exceeds that limit.
+ if (toScale < decimalType.scale && requiredPrecision >
DecimalType.MAX_PRECISION) {
+ throw new GlutenNotSupportException(
+ s"${original.nodeName} on ${decimalType.sql} with scale $toScale may
exceed decimal " +
+ s"precision ${DecimalType.MAX_PRECISION} (required precision:
$requiredPrecision). " +
+ "Falling back to Spark.")
+ }
+
+ override val dataType: DataType =
+
BackendsApiManager.getSparkPlanExecApiInstance.genDecimalRoundExpressionOutput(
+ decimalType,
+ toScale)
+
+ override def left: ExpressionTransformer = child
+ override def right: ExpressionTransformer = LiteralTransformer(toScale)
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]