This is an automated email from the ASF dual-hosted git repository.
marin-ma 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 ccfacb07d0 [GLUTEN-13014][VL] Make a udfLibraryPaths UDF callable by
its own name (#13016)
ccfacb07d0 is described below
commit ccfacb07d09e3fea0b2ed0badde7fca96ab72a15
Author: Pedrum Jalali <[email protected]>
AuthorDate: Thu Sep 17 01:05:57 2026 -0700
[GLUTEN-13014][VL] Make a udfLibraryPaths UDF callable by its own name
(#13016)
---
.../gluten/backendsapi/velox/VeloxRuleApi.scala | 7 +-
.../org/apache/gluten/config/VeloxConfig.scala | 22 +++++++
.../apache/spark/sql/expression/UDFResolver.scala | 50 +++++++++++++-
.../gluten/expression/UDFResolverSuite.scala | 77 ++++++++++++++++++++++
.../apache/gluten/expression/VeloxUdfSuite.scala | 28 ++++++++
cpp/velox/udf/examples/MyUDF.cc | 38 +++++++++++
docs/developers/VeloxUDF.md | 15 +++++
docs/velox-configuration.md | 1 +
8 files changed, 236 insertions(+), 2 deletions(-)
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
index 11d1944590..997d66b47e 100644
---
a/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
+++
b/backends-velox/src/main/scala/org/apache/gluten/backendsapi/velox/VeloxRuleApi.scala
@@ -17,7 +17,7 @@
package org.apache.gluten.backendsapi.velox
import org.apache.gluten.backendsapi.{BackendsApiManager, RuleApi}
-import org.apache.gluten.config.GlutenConfig
+import org.apache.gluten.config.{GlutenConfig, VeloxConfig}
import org.apache.gluten.extension._
import org.apache.gluten.extension.columnar._
import
org.apache.gluten.extension.columnar.MiscColumnarRules.{PreventBatchTypeMismatchInTableCache,
RemoveGlutenTableCacheColumnarToRow, RemoveTopmostColumnarToRow,
RewriteSubqueryBroadcast}
@@ -34,6 +34,7 @@ import org.apache.gluten.sql.shims.SparkShimLoader
import org.apache.spark.sql.execution._
import org.apache.spark.sql.execution.datasources.noop.GlutenNoopWriterRule
+import org.apache.spark.sql.expression.UDFResolver
class VeloxRuleApi extends RuleApi {
import VeloxRuleApi._
@@ -67,6 +68,10 @@ object VeloxRuleApi {
if (BackendsApiManager.getSettings.supportAppendDataExec()) {
injector.injectPlannerStrategy(SparkShimLoader.getSparkShims.getRewriteCreateTableAsSelect(_))
}
+
+ if (VeloxConfig.nativeUDFBypassRegistration) {
+ UDFResolver.getFunctionDescriptions.foreach(injector.injectFunction)
+ }
}
/**
diff --git
a/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
b/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
index 7fb3f69dd3..5d377a9368 100644
--- a/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
+++ b/backends-velox/src/main/scala/org/apache/gluten/config/VeloxConfig.scala
@@ -16,7 +16,9 @@
*/
package org.apache.gluten.config
+import org.apache.spark.SparkEnv
import org.apache.spark.network.util.ByteUnit
+import org.apache.spark.sql.internal.SparkConfigUtil._
import org.apache.spark.sql.internal.SQLConf
import java.util.Locale
@@ -132,6 +134,16 @@ object VeloxConfig extends ConfigRegistry {
new VeloxConfig(SQLConf.get)
}
+ /**
+ * Reads the flag straight off the SparkConf instead of going through
[[get]].
+ *
+ * Session extensions are applied while the SparkSession is still being
built, so `SQLConf.get`
+ * returns defaults at that point and [[get]] would report this flag as off
however the user set
+ * it.
+ */
+ def nativeUDFBypassRegistration: Boolean =
+ Option(SparkEnv.get).exists(_.conf.get(NATIVE_UDF_BYPASS_REGISTRATION))
+
// velox caching options.
val COLUMNAR_VELOX_CACHE_ENABLED =
buildStaticConf("spark.gluten.sql.columnar.backend.velox.cacheEnabled")
@@ -696,6 +708,16 @@ object VeloxConfig extends ConfigRegistry {
.booleanConf
.createWithDefault(true)
+ val NATIVE_UDF_BYPASS_REGISTRATION =
+
buildStaticConf("spark.gluten.sql.columnar.backend.velox.nativeUDF.bypassRegistration")
+ .doc(
+ "If true, a UDF from udfLibraryPaths can be called by the name it was
registered " +
+ "with, so you do not have to write a Java class for it or run CREATE
TEMPORARY " +
+ "FUNCTION. In exchange, there is no Java version to fall back to, so
any query " +
+ "Gluten cannot run natively will fail instead of running on Spark.
Off by default.")
+ .booleanConf
+ .createWithDefault(false)
+
val CAST_FROM_VARCHAR_ADD_TRIM_NODE =
buildConf("spark.gluten.velox.castFromVarcharAddTrimNode")
.doc(
diff --git
a/backends-velox/src/main/scala/org/apache/spark/sql/expression/UDFResolver.scala
b/backends-velox/src/main/scala/org/apache/spark/sql/expression/UDFResolver.scala
index 43ee7b4f7d..bad8303f21 100644
---
a/backends-velox/src/main/scala/org/apache/spark/sql/expression/UDFResolver.scala
+++
b/backends-velox/src/main/scala/org/apache/spark/sql/expression/UDFResolver.scala
@@ -19,13 +19,16 @@ package org.apache.spark.sql.expression
import org.apache.gluten.backendsapi.velox.VeloxBackendSettings
import org.apache.gluten.exception.{GlutenException, GlutenNotSupportException}
import org.apache.gluten.expression._
+import org.apache.gluten.extension.injector.FunctionDescription
import org.apache.gluten.jni.JniWorkspace
import org.apache.spark.{SparkConf, SparkFiles}
import org.apache.spark.deploy.SparkHadoopUtil
import org.apache.spark.internal.Logging
+import org.apache.spark.sql.catalyst.FunctionIdentifier
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Cast,
Expression, Unevaluable}
+import org.apache.spark.sql.catalyst.analysis.FunctionRegistry
+import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Cast,
Expression, ExpressionInfo, Unevaluable}
import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateFunction
import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext,
ExprCode}
import org.apache.spark.sql.catalyst.types.DataTypeUtils
@@ -37,6 +40,7 @@ import org.apache.spark.util.Utils
import java.io.File
import java.net.URI
import java.nio.file.{Files, FileVisitOption, Paths}
+import java.util.Locale
import scala.collection.JavaConverters.asScalaIteratorConverter
import scala.collection.mutable
@@ -338,6 +342,50 @@ object UDFResolver extends Logging {
.toBoolean
}
+ /**
+ * One Spark function per loaded UDF whose name contains no dot. A dotted
name is a Hive UDF class
+ * name, which VeloxHiveUDFTransformer already resolves, so it is skipped
here.
+ *
+ * A name is also skipped when it collides with a Spark built-in: the names
are unqualified, so
+ * injecting one would redirect that built-in to a native implementation
with possibly different
+ * semantics for every query on the session.
+ *
+ * Names differing only in case are skipped as a group. Spark lowercases a
function name when it
+ * registers it, so they would collapse to one entry and the last
registration would win, leaving
+ * a call to either name running the other one's implementation.
+ */
+ def getFunctionDescriptions: Seq[FunctionDescription] = {
+ val candidates = UDFNames.toSeq.filterNot(_.contains(".")).sorted
+ val byLowerCase = candidates.groupBy(_.toLowerCase(Locale.ROOT))
+
+ val (ambiguous, distinct) =
+ candidates.partition(name =>
byLowerCase(name.toLowerCase(Locale.ROOT)).size > 1)
+
+ val (shadowing, injectable) =
+ distinct.partition(name =>
FunctionRegistry.builtin.functionExists(FunctionIdentifier(name)))
+
+ ambiguous.foreach(
+ name =>
+ logWarning(
+ s"Not registering UDF '$name' by name: it differs only in case from
another UDF " +
+ s"loaded from the same libraries, and Spark function names are
case-insensitive. " +
+ s"Rename it in the UDF library to call it directly."))
+
+ shadowing.foreach(
+ name =>
+ logWarning(
+ s"Not registering UDF '$name' by name: it shadows a Spark built-in.
" +
+ s"Rename it in the UDF library to call it directly."))
+
+ injectable.map {
+ name =>
+ (
+ FunctionIdentifier(name),
+ new ExpressionInfo(classOf[UDFExpression].getName, name),
+ (children: Seq[Expression]) => getUdfExpression(name,
name)(children))
+ }
+ }
+
def getUdfExpression(name: String, alias: String)(children:
Seq[Expression]): UDFExpression = {
def errorMessage: String =
s"UDF $name -> ${children.map(_.dataType.simpleString).mkString(", ")}
is not registered."
diff --git
a/backends-velox/src/test/scala/org/apache/gluten/expression/UDFResolverSuite.scala
b/backends-velox/src/test/scala/org/apache/gluten/expression/UDFResolverSuite.scala
new file mode 100644
index 0000000000..6968be7da2
--- /dev/null
+++
b/backends-velox/src/test/scala/org/apache/gluten/expression/UDFResolverSuite.scala
@@ -0,0 +1,77 @@
+/*
+ * 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.config.VeloxConfig
+
+import org.apache.spark.sql.expression.UDFResolver
+
+import org.scalatest.BeforeAndAfterEach
+import org.scalatest.funsuite.AnyFunSuite
+
+class UDFResolverSuite extends AnyFunSuite with BeforeAndAfterEach {
+
+ // UDFNames is JVM-global and populated once per JVM, so it is restored
rather than cleared.
+ private var savedNames: Set[String] = Set.empty
+
+ override protected def beforeEach(): Unit = {
+ savedNames = UDFResolver.UDFNames.toSet
+ UDFResolver.UDFNames.clear()
+ }
+
+ override protected def afterEach(): Unit = {
+ UDFResolver.UDFNames.clear()
+ UDFResolver.UDFNames ++= savedNames
+ }
+
+ private def describedNames(): Seq[String] =
+ UDFResolver.getFunctionDescriptions.map(_._1.funcName)
+
+ test("registration by name is off unless it is turned on") {
+
assert(VeloxConfig.NATIVE_UDF_BYPASS_REGISTRATION.defaultValue.contains(false))
+ }
+
+ test("a name with no dot is described") {
+ UDFResolver.UDFNames += "myudf_increment"
+ assert(describedNames() == Seq("myudf_increment"))
+ }
+
+ test("a dotted name is skipped, it is a hive udf class name") {
+ UDFResolver.UDFNames +=
"org.apache.spark.sql.hive.execution.UDFStringString"
+ assert(describedNames().isEmpty)
+ }
+
+ test("a name colliding with a spark built-in is skipped") {
+ UDFResolver.UDFNames += "abs"
+ assert(describedNames().isEmpty)
+ }
+
+ test("a name colliding with a spark built-in does not skip the others") {
+ UDFResolver.UDFNames ++= Seq("abs", "myudf_increment", "upper")
+ assert(describedNames() == Seq("myudf_increment"))
+ }
+
+ test("names differing only in case are skipped as a group") {
+ UDFResolver.UDFNames ++= Seq("Foo", "foo", "myudf_increment")
+ assert(describedNames() == Seq("myudf_increment"))
+ }
+
+ test("names are described in sorted order") {
+ UDFResolver.UDFNames ++= Seq("b_udf", "a_udf")
+ assert(describedNames() == Seq("a_udf", "b_udf"))
+ }
+}
diff --git
a/backends-velox/src/test/scala/org/apache/gluten/expression/VeloxUdfSuite.scala
b/backends-velox/src/test/scala/org/apache/gluten/expression/VeloxUdfSuite.scala
index a5128f62d9..493e1c79a7 100644
---
a/backends-velox/src/test/scala/org/apache/gluten/expression/VeloxUdfSuite.scala
+++
b/backends-velox/src/test/scala/org/apache/gluten/expression/VeloxUdfSuite.scala
@@ -17,12 +17,14 @@
package org.apache.gluten.expression
import org.apache.gluten.backendsapi.velox.VeloxBackendSettings
+import org.apache.gluten.config.VeloxConfig
import org.apache.gluten.execution.ProjectExecTransformer
import org.apache.gluten.execution.WindowExecTransformer
import org.apache.gluten.tags.{SkipTest, UDFTest}
import org.apache.spark.SparkConf
import org.apache.spark.sql.{GlutenQueryTest, Row, SparkSession}
+import org.apache.spark.sql.catalyst.FunctionIdentifier
import org.apache.spark.sql.catalyst.plans.SQLHelper
import org.apache.spark.sql.execution.ProjectExec
import org.apache.spark.sql.execution.window.WindowExec
@@ -294,6 +296,30 @@ abstract class VeloxUdfSuite extends GlutenQueryTest with
SQLHelper {
}
}
}
+
+ test("native udf with a plain name is callable without a hive udf class") {
+ // No CREATE TEMPORARY FUNCTION and no Java class: the session extension
put the name in
+ // Spark's registry via SparkInjector.injectFunction, which is the only
writer for it.
+ assert(
+ spark.sessionState.functionRegistry
+ .lookupFunction(FunctionIdentifier("myudf_plus_one"))
+ .isDefined)
+
+ val df = spark.sql("SELECT myudf_plus_one(col1) FROM VALUES (1L), (2L),
(3L) AS t(col1)")
+ checkGlutenPlan[ProjectExecTransformer](df)
+ checkAnswer(df, Seq(Row(2L), Row(3L), Row(4L)))
+ }
+
+ test("native udf with a plain name fails at analysis when gluten is
disabled") {
+ withSQLConf(("spark.gluten.enabled", "false")) {
+ val e = intercept[Exception] {
+ spark.sql("SELECT myudf_plus_one(col1) FROM VALUES (1L) AS
t(col1)").collect()
+ }
+ // The injected function has no JVM implementation to fall back to, so
the call is
+ // rejected rather than silently returning a result from somewhere else.
+ assert(e.getMessage.contains("myudf_plus_one"))
+ }
+ }
}
@UDFTest
@@ -305,6 +331,8 @@ class VeloxUdfSuiteLocal extends VeloxUdfSuite {
.set("spark.files", udfLibPath)
.set(VeloxBackendSettings.GLUTEN_VELOX_UDF_LIB_PATHS, udfLibRelativePath)
.set("spark.shuffle.manager",
"org.apache.spark.shuffle.sort.ColumnarShuffleManager")
+ // Off by default, so the by-name tests below have to opt in.
+ .set(VeloxConfig.NATIVE_UDF_BYPASS_REGISTRATION.key, "true")
}
}
diff --git a/cpp/velox/udf/examples/MyUDF.cc b/cpp/velox/udf/examples/MyUDF.cc
index 260629fdf9..687cdd2d3b 100644
--- a/cpp/velox/udf/examples/MyUDF.cc
+++ b/cpp/velox/udf/examples/MyUDF.cc
@@ -71,6 +71,43 @@ class HiveStringStringRegisterer final : public
gluten::UdfRegisterer {
} // namespace hivestringstring
+namespace myudfplusone {
+
+template <typename T>
+struct MyUdfPlusOneFunction {
+ VELOX_DEFINE_FUNCTION_TYPES(T);
+
+ FOLLY_ALWAYS_INLINE void call(int64_t& result, const int64_t& a) {
+ result = a + 1;
+ }
+};
+
+// name: myudf_plus_one
+// signatures:
+// bigint -> bigint
+// type: SimpleFunction
+// A name with no dot, so it is callable directly without a Hive UDF class.
+class MyUdfPlusOneRegisterer final : public gluten::UdfRegisterer {
+ public:
+ int getNumUdf() override {
+ return 1;
+ }
+
+ void populateUdfEntries(int& index, gluten::UdfEntry* udfEntries) override {
+ udfEntries[index++] = {name_.c_str(), kBigInt, 1, arg_, false, false};
+ }
+
+ void registerSignatures() override {
+ facebook::velox::registerFunction<MyUdfPlusOneFunction, int64_t,
int64_t>({name_});
+ }
+
+ private:
+ const std::string name_ = "myudf_plus_one";
+ const char* arg_[1] = {kBigInt};
+};
+
+} // namespace myudfplusone
+
std::vector<std::shared_ptr<gluten::UdfRegisterer>>& globalRegisters() {
static std::vector<std::shared_ptr<gluten::UdfRegisterer>> registerers;
return registerers;
@@ -83,6 +120,7 @@ void setupRegisterers() {
}
auto& registerers = globalRegisters();
registerers.push_back(std::make_shared<hivestringstring::HiveStringStringRegisterer>());
+
registerers.push_back(std::make_shared<myudfplusone::MyUdfPlusOneRegisterer>());
inited = true;
}
} // namespace
diff --git a/docs/developers/VeloxUDF.md b/docs/developers/VeloxUDF.md
index a38f1a48db..8dbf276a48 100644
--- a/docs/developers/VeloxUDF.md
+++ b/docs/developers/VeloxUDF.md
@@ -192,6 +192,20 @@ VeloxColumnarToRow
+- Scan hive spark_catalog.default.tbl [col1#11], HiveTableRelation
[`spark_catalog`.`default`.`tbl`,
org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe, Data Cols: [col1#11],
Partition Cols: []]
```
+## Natively Only UDF Registration
+
+This is an alternative to the registration described above, for a UDF that is
implemented only in Velox and has no Java counterpart.
+
+This is off by default. Set
`spark.gluten.sql.columnar.backend.velox.nativeUDF.bypassRegistration=true` to
turn it on.
+
+Once enabled, a UDF whose registered name contains no dot is added to the
session's function registry under that name, provided the name is not already a
Spark built-in and no other loaded UDF differs from it only in case. It needs
no matching Hive UDF class, no jar on the classpath, and no `CREATE TEMPORARY
FUNCTION` — register it under a name with no dot, such as `my_udf`, and call it
directly:
+
+```
+spark-sql (default)> select my_udf(col1) from tbl;
+```
+
+**There is no fallback with this method.** A name registered this way has no
Java implementation behind it, so a query that Gluten cannot offload fails
instead of falling back to the JVM. Use the registration described above
whenever the fallback path is required.
+
## Configurations
| Parameters | Description
|
@@ -199,6 +213,7 @@ VeloxColumnarToRow
| spark.gluten.sql.columnar.backend.velox.udfLibraryPaths | Path to the
udf/udaf libraries.
|
| spark.gluten.sql.columnar.backend.velox.driver.udfLibraryPaths | Path to the
udf/udaf libraries on driver node. Only applicable on yarn-client mode.
|
| spark.gluten.sql.columnar.backend.velox.udfAllowTypeConversion | Whether to
inject possible `cast` to convert mismatched data types from input to one
registered signatures. |
+| spark.gluten.sql.columnar.backend.velox.nativeUDF.bypassRegistration | Call
a UDF by the name it was registered with, without writing a Java class for it.
There is then no Java version to fall back to, so a query Gluten cannot run
natively will fail. Defaults to `false`. |
# Pandas UDFs (a.k.a. Vectorized UDFs)
diff --git a/docs/velox-configuration.md b/docs/velox-configuration.md
index 2c14b37c45..3ab3220ad8 100644
--- a/docs/velox-configuration.md
+++ b/docs/velox-configuration.md
@@ -61,6 +61,7 @@ nav_order: 16
| spark.gluten.sql.columnar.backend.velox.memInitCapacity
| 🔄 Dynamic | 8MB | The initial memory capacity to reserve
for a newly created Velox query memory pool.
[...]
|
spark.gluten.sql.columnar.backend.velox.memoryPoolCapacityTransferAcrossTasks
| 🔄 Dynamic | true | Whether to allow memory capacity transfer
between memory pools from different tasks.
[...]
| spark.gluten.sql.columnar.backend.velox.memoryUseHugePages
| 🔄 Dynamic | false | Use explicit huge pages for Velox
memory allocation.
[...]
+| spark.gluten.sql.columnar.backend.velox.nativeUDF.bypassRegistration
| ⚓ Static | false | If true, a UDF from udfLibraryPaths
can be called by the name it was registered with, so you do not have to write a
Java class for it or run CREATE TEMPORARY FUNCTION. In exchange, there is no
Java version to fall back to, so any query Gluten cannot run natively will fail
instead of running on Spark. Off by default.
[...]
| spark.gluten.sql.columnar.backend.velox.numCacheFileHandles
| ⚓ Static | 10000 | Maximum number of entries in the file
handle cache. Each entry holds an open file descriptor (local FS) or connection
state (remote FS). Note that on local filesystems, high values may approach the
OS file descriptor limit (ulimit -n). On remote object stores (S3, ABFS, GCS)
entries represent network connections/sockets rather than per-file OS file
descriptors, but the [...]
| spark.gluten.sql.columnar.backend.velox.orc.scan.enabled
| 🔄 Dynamic | true | Enable velox orc scan. If disabled,
vanilla spark orc scan will be used.
[...]
| spark.gluten.sql.columnar.backend.velox.parquet.dictionaryPageSizeBytes
| 🔄 Dynamic | 2MB | The maximum size in bytes for a
Parquet dictionary page
[...]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]