Developer Guide for Implementing Spark SQL Functions in Bolt
This guide provides instructions for implementing and registering Spark SQL’s built-in functions within the Bolt native engine for use with Gluten. Understanding Bolt’s function architecture and its interaction with Gluten is key to extending its capabilities.
High-Level Support and Naming Conventions
Bolt maintains two primary namespaces for functions, located in bolt/functions/:
prestosql: Contains a rich library of functions originally implemented with Presto-compatible semantics. These are registered by default.sparksql: Contains functions specifically implemented or overridden to match Spark SQL’s semantics.
When Gluten initializes the Bolt backend, it first registers all functions from the prestosql namespace. Afterward, it registers functions from the sparksql namespace. This layering mechanism allows sparksql functions with the same name and signature to transparently override their prestosql counterparts. This is crucial for ensuring semantic compatibility with Spark.
If a required function is missing from both namespaces, or if a prestosql function’s behavior deviates from Spark’s (even with a matching signature), a new implementation must be added to the sparksql folder.
Gluten to Bolt Function Mapping: Gluten translates Spark’s Expression nodes into a Substrait plan. Bolt then consumes this plan, mapping Substrait function calls to its registered native functions. The function name used in Spark SQL is converted to a lowercase string (e.g., BitwiseAnd becomes bitwise_and) which is used to look up the corresponding native implementation in Bolt’s function registry.
Concrete Examples
- Scalar Function: The Spark SQL function
hash(col1, col2, ...)is mapped to thehashfunction in Bolt, which has a corresponding C++ implementation inbolt/functions/sparksql/Hash.cpp. - Aggregate Function: The
SUM()function in Spark is mapped to thesumaggregate function in Bolt. Its implementation,SumAggregate, can be found inbolt/functions/sparksql/aggregates/SumAggregate.cpp. This implementation handles various numeric types and demonstrates how partial and final aggregations are managed.
Anatomy of a Bolt Function
At its core, a Bolt function is a C++ struct that implements a specific set of methods and follows certain conventions.
Scalar Functions (Simple Functions)
Simple functions in Bolt operate on one row at a time. They are defined within a struct, typically templated to handle different data types.
Key components:
BOLT_DEFINE_FUNCTION_TYPES(T): A macro required inside the function struct to define necessary type aliases likeout_type<Varchar>andarg_type<Varchar>.call()Method: The main execution logic for non-nullable inputs. The first argument is always a non-const reference to the output (result), followed by const references to the inputs. Avoidreturn type signifies that the function never produces a null output for non-null inputs.callNullable()Method: If a function needs to handleNULLinputs explicitly (i.e., a null input doesn’t automatically mean a null output), this method should be implemented. Input arguments are passed as pointers, which will benullptrif the input is null. Aboolreturn fromcall()orcallNullable()controls the nullability of the result (truefor not-null,falsefor null).is_deterministicFlag: A staticconstexpr boolthat can be set tofalseif the function is non-deterministic (e.g.,rand()). By default, functions are considered deterministic.
Example: A simple plus function
// From bolt/examples/SimpleFunctions.cpp
template <typename TExecParams>
struct MyPlusFunction {
FOLLY_ALWAYS_INLINE void
call(int64_t& out, const int64_t& a, const int64_t& b) {
out = a + b;
}
};
Aggregate Functions
Aggregate functions are more complex and typically inherit from bolt::exec::Aggregate. They require methods to manage the accumulation state across rows.
- Accumulator (
char*): A raw memory buffer to hold the intermediate aggregation state. Its layout is defined by the function. initializeNewGroups(): Initializes the accumulators for a new set of groups.addRawInput()/addIntermediateResults()/addSingleGroupRawInput(): These methods define the core logic for accumulating data from raw input rows or partial aggregation results.extractValues()/extractRawValues(): Extracts the final or intermediate result from the accumulators and writes it to the output vector.destroy(): Cleans up any resources held by the accumulators (e.g., complex objects).
Window Functions
Window functions are defined by inheriting from bolt::exec::WindowFunction and implementing the following methods:
resetPartition(): Called when the engine starts processing a new partition.apply(): Called for a batch of rows within a partition to compute the function’s output. It receives frame and peer boundaries.
Function Registration
Scalar Function Registration
Scalar functions are registered using registerFunction or helper templates from RegistrationHelpers.h. Registration specifies the function’s name, its C++ implementation struct, and its signature (return type followed by argument types).
// From bolt/examples/SimpleFunctions.cpp
// Register a specific signature
registerFunction<MyPlusFunction, int64_t, int64_t, int64_t>({"my_plus"});
// Use helpers for common patterns (e.g., all numeric types)
functions::registerBinaryNumeric<MyPlusTemplatedFunction>({"my_other_plus"});
All Spark SQL functions should be registered in bolt/functions/sparksql/registration/Register.cpp by adding a call to a category-specific registration function (e.g., registerStringFunctions(prefix)).
Aggregate Function Registration
Aggregate functions are registered via registerAggregateFunction in bolt/functions/sparksql/aggregates/Register.cpp. This associates a function name with its implementation class and a set of supported signatures.
// From bolt/functions/sparksql/aggregates/SumAggregate.cpp
exec::registerAggregateFunction(
name,
std::move(signatures),
[name](
core::AggregationNode::Step step,
const std::vector<TypePtr>& argTypes,
const TypePtr& resultType,
const core::QueryConfig& config) -> std::unique_ptr<exec::Aggregate> {
// Factory lambda that creates an instance of the aggregate function
// based on input/output types and aggregation step (partial, final, etc.).
...
return std::make_unique<SumAggregate<...>>(...);
},
...
);
Window Function Registration
Window functions are registered using registerWindowFunction in bolt/functions/sparksql/window/WindowFunctionsRegistration.cpp.
// From bolt/functions/sparksql/window/WindowFunctionsRegistration.cpp
void registerWindowFunctions(const std::string& prefix) {
functions::window::registerNthValueInteger(prefix + "nth_value");
functions::window::registerRowNumberInteger(prefix + "row_number");
// ... and so on
}
Semantics and Considerations
Null Handling
- Default Behavior: If any input to a simple function is
NULL, the framework automatically produces aNULLoutput without calling the function. - Custom Behavior: To override this, implement
callNullable(result, arg1, arg2, ...). Arguments are passed as pointers, which will benullptrforNULLvalues. This is useful for functions likecoalesceoris_null.
ANSI SQL Mode
Gluten does not currently support Spark’s ANSI mode. When spark.sql.ansi.enabled is true, Gluten will typically fall back to vanilla Spark for execution. Therefore, function implementations in Bolt only need to consider the behavior of ANSI mode being off. This is particularly relevant for functions like cast, where error handling for invalid conversions (e.g., string to int) should not throw an exception but return NULL instead.
Determinism
By default, all functions are assumed to be deterministic. If your function can produce different results for the same input (e.g., rand(), now()), you must declare it as non-deterministic:
template <typename T>
struct MyNonDeterministicFunction {
static constexpr bool is_deterministic = false;
// ... implementation ...
};
Error Handling
For recoverable errors (e.g., invalid input format for a function), Bolt functions should use BOLT_USER_FAIL or BOLT_USER_CHECK. This mechanism allows Bolt to propagate a well-defined error back to Gluten and Spark, rather than crashing the process. Unrecoverable logic errors should still use BOLT_CHECK or BOLT_FAIL.
BOLT_USER_CHECK(
isValid(input), "Invalid input format for my_function: {}", input);
Performance and Memory
- Vectorization: While simple functions operate row-by-row conceptually, the framework ensures they are executed efficiently over columnar data. Avoid branching or complex logic inside the
callmethod. - Memory Pool: Functions have access to a
memory::MemoryPoolfor allocations. For string results, theStringWriter(out_type<Varchar>) handles buffer management automatically. - String Optimizations:
reuse_strings_from_arg: For functions that return a substring of an input (e.g.,substr,split), settingstatic constexpr int32_t reuse_strings_from_arg = <arg_index>;allows the output to be a zero-copy view over the input’s buffer, significantly reducing memory traffic.- ASCII Fast Path: If a string function can be implemented more efficiently for pure-ASCII inputs, provide a
callAscii()method. The engine will automatically detect ASCII-only input vectors and dispatch to this optimized path.
Testing
New functions must be accompanied by tests.
- Location: Unit tests for a new function, say
my_function, should be placed in a corresponding new file, e.g.,bolt/functions/sparksql/tests/MyFunctionTest.cpp. - Framework: Tests typically inherit from
SparkFunctionBaseTestand useevaluateOnceorevaluateto invoke the function with test data and verify the results. - Cross-Validation: It is critical to ensure the Bolt implementation matches Spark’s behavior. The most effective way to do this is to add integrated tests within the Gluten repository that run the same Spark SQL query against both vanilla Spark and the Gluten-Bolt backend, then compare the results.
Putting It All Together: A Complete Example
Here is a minimal example of adding a new scalar function my_scalar_add(a, b) that adds two integers.
-
Implement the function in
bolt/functions/sparksql/MyScalarAdd.h(new file):#pragma once #include "bolt/functions/Udf.h" namespace bytedance::bolt::functions::sparksql { template <typename T> struct MyScalarAddFunction { BOLT_DEFINE_FUNCTION_TYPES(T); FOLLY_ALWAYS_INLINE void call( int64_t& result, const int64_t& a, const int64_t& b) { result = a + b; } }; } // namespace bytedance::bolt::functions::sparksql -
Register the function in
bolt/functions/sparksql/registration/RegisterMisc.cpp(or a new registration file):#include "bolt/functions/sparksql/MyScalarAdd.h" #include "bolt/functions/lib/RegistrationHelpers.h" namespace bytedance::bolt::functions::sparksql { void registerMyFunctions(const std::string& prefix) { registerFunction<MyScalarAddFunction, int64_t, int64_t, int64_t>( {prefix + "my_scalar_add"}); } // In registerMiscFunctions (or a new top-level registration function) void registerMiscFunctions(const std::string& prefix) { // ... other registrations registerMyFunctions(prefix); } } -
Add a test in
bolt/functions/sparksql/tests/MyScalarAddTest.cpp:#include "bolt/functions/sparksql/tests/SparkFunctionBaseTest.h" using namespace bytedance::bolt; using namespace bytedance::bolt::test; class MyScalarAddTest : public functions::test::SparkFunctionBaseTest { protected: void testAdd(int64_t a, int64_t b, int64_t expected) { auto result = evaluateOnce<int64_t>( "my_scalar_add(c0, c1)", makeRowVector({ makeConstant((int64_t)a, 1), makeConstant((int64_t)b, 1), })); ASSERT_EQ(result.value(), expected); } }; TEST_F(MyScalarAddTest, basic) { testAdd(10, 20, 30); testAdd(-5, 5, 0); }This structured approach ensures that new functions are correctly implemented, registered, and tested, maintaining compatibility with Spark SQL and high performance within the Bolt engine.