Apache Arrow Gandiva 外部函数开发指南:从 C 函数到 LLVM IR 函数的注册与集成
【免费下载链接】arrowApache Arrow is the universal columnar format and multi-language toolbox for fast data interchange and in-memory analytics项目地址: https://gitcode.com/GitHub_Trending/arrow3/arrow
本指南基于 Apache Arrow 仓库中的 Gandiva 模块官方文档(docs/source/cpp/gandiva/external_func.rst)整理而成。Gandiva 是一个基于 LLVM 的表达式编译框架,通过"外部函数"机制,开发者可以用 C、C++、Rust 甚至 Zig 等语言编写自定义函数并注册进 Gandiva 表达式系统。读完本文,你将掌握两类外部函数(C 函数与 IR 函数)的选型原则、NativeFunction元数据注册方法、C 函数签名映射规则(含变长字符串类型的特殊处理),以及通过FunctionRegistry完成注册的完整 API 用法。
Gandiva 外部 C 函数与 IR 函数集成架构图
如上图所示,外部函数从"多语言编写 → 编译为 LLVM IR / 直接暴露为 C 函数 → 注册进 FunctionRegistry → 由 LLVMGenerator 生成 IR → LLVM JIT 引擎编译为机器码"形成了一条完整链路,本文即围绕这条链路的每一步展开。
外部函数的两大类型:C Functions 与 IR Functions
Gandiva 支持两种主要的外部函数类型:
- C 函数(C Functions):符合 C 调用约定的函数。开发者可以用多种语言(如 C++、Rust、C、Zig)实现逻辑,再以 C 函数的形式暴露给 Gandiva。其核心优势是语言灵活、适用范围广,几乎适用于所有使用场景,集成也相对容易。
- IR 函数(IR Functions):以 LLVM IR(LLVM 中间表示)形式实现的函数。同样可以用多种语言编写,然后编译成 LLVM IR 后注册进 Gandiva。
从源码结构看,Gandiva 对这两类函数的注册、查询和调用路径是分离但统一的:gandiva::FunctionRegistry(见 function_registry.h)内部同时维护了pc_registry_(预编译函数签名表)、c_functions_(C 函数指针列表)与bitcode_memory_buffers_(bitcode 缓冲区列表)三类数据,说明两类函数最终都汇聚到同一个注册表体系中,只是实现来源不同。
如何选择:C 函数还是 IR 函数
| 考量维度 | C 函数 | IR 函数 |
|---|---|---|
| 语言灵活性 | 高,任意语言实现后暴露 C ABI | 需编译为 LLVM IR,依赖 LLVM 工具链 |
| 适用场景 | 绝大多数通用场景 | 逻辑简单的操作,如单步算术/比较 |
| 内联能力 | 不可内联,存在调用开销 | 可内联,对调用开销敏感的热点操作更优 |
| 第三方库依赖 | 直接链接即可 | 依赖库也必须一并编译进 LLVM IR,复杂库会拖累性能 |
| 高级特性 | 可使用线程局部变量等 | 不支持线程局部变量(受 Gandiva 内部 JIT 引擎限制) |
文档中特别指出:IR 函数的可内联优势在"简单操作且调用开销占比显著"的场景下收益最大;而像使用 thread-local 变量这类高级特性,由于当前 Gandiva 内部 JIT 引擎的限制,IR 函数无法支持,此时应选用 C 函数。
外部函数注册总览:元数据 + 实现
要让一个函数对 Gandiva 可用,必须完成两件事:注册函数元数据(签名与属性)和提供函数实现(C 函数指针或 LLVM IR 函数)。
元数据注册使用gandiva::NativeFunction类,实现则通过gandiva::FunctionRegistry的三种Register重载接入。下面分别展开。
用 NativeFunction 类注册函数元数据
gandiva::NativeFunction类负责捕获外部函数的签名与元数据,其定义位于 native_function.h(构造函数见第 56-68 行):
NativeFunction(const std::string& base_name, const std::vector<std::string>& aliases, const DataTypeVector& param_types, const DataTypePtr& ret_type, const ResultNullableType& result_nullable_type, std::string pc_name, int32_t flags = 0);各构造参数含义如下:
base_name:函数在表达式中使用的名字,例如"add"、"upper"。aliases:函数的别名列表。例如仓库中upper函数注册时就带有别名"ucase"(见 function_registry_string.cc 第 90 行)。从构造函数实现看(native_function.h),base_name与每个别名都会各生成一个FunctionSignature存入signatures_,即别名与主名在签名查找层面完全等价。param_types:std::vector<std::shared_ptr<arrow::DataType>>,函数接受的参数类型列表。ret_type:std::shared_ptr<arrow::DataType>,函数返回类型。result_nullable_type:结果可空性规则,由输入的 nullability 推导,取值为:取值 含义 ResultNullableType::kResultNullIfNull结果有效性是各子节点有效性的交集(任一输入为 null 则结果为 null) ResultNullableType::kResultNullNever结果永远有效(不产生 null) ResultNullableType::kResultNullInternal结果有效性取决于函数内部逻辑 该枚举定义在 native_function.h,与文档完全一致。
pc_name:对应预编译函数(precompiled function)的名字。通常遵循{base_name}_{param1_type}_{param2_type}...{paramN_type}命名约定,例如base_name为add、两个int32参数并返回int32的函数,其预编译函数名为add_int32_int32。该约定并非强制,只要保证唯一即可,因为注册后引擎会以pc_name作为符号进行全局映射查找(见下文AddGlobalMappingForFunc)。flags:可选函数属性标志,默认 0。可组合使用以下位标志(定义见 native_function.h):NativeFunction::kNeedsContext:函数需要一个int64_t context参数(返回字符串类型时必需,见下文);NativeFunction::kNeedsFunctionHolder:函数需要一个函数持有者(function holder)参数;NativeFunction::kCanReturnErrors:函数可以通过 context 返回错误信息。
源码中的真实注册示例
仓库内置函数的注册代码就是最好的范本。例如 function_registry_string.cc 第 77 行注册base64函数:
NativeFunction("base64", {}, DataTypeVector{binary()}, utf8(), kResultNullIfNull, "base64_binary", NativeFunction::kNeedsContext)可以看到:base64接受binary参数、返回utf8,可空性为kResultNullIfNull,预编译函数名为base64_binary,并且由于返回类型是字符串,必须带上kNeedsContext标志——这正是文档中"返回 utf8 必须设置 kNeedsContext"规则的直接体现。类似地,upper(别名ucase,function_registry_string.cc)也是典型的字符串返回函数。
外部 C 函数开发
C 函数签名映射表
并非所有 Arrow 数据类型都被 Gandiva 外部函数支持,且 Gandiva 类型与 C 函数签名类型存在固定映射。文档给出的完整映射表如下:
| Gandiva 类型(Arrow 数据类型) | C 函数类型 |
|---|---|
| int8 | int8_t |
| int16 | int16_t |
| int32 | int32_t |
| int64 | int64_t |
| uint8 | uint8_t |
| uint16 | uint16_t |
| uint32 | uint32_t |
| uint64 | uint64_t |
| float32 | float |
| float64 | double |
| boolean | bool |
| date32 | int32_t |
| date64 | int64_t |
| timestamp | int64_t |
| time32 | int32_t |
| time64 | int64_t |
| interval_month | int32_t |
| interval_day_time | int64_t |
| utf8(作为参数) | const char*,uint32_t(见变长类型小节) |
| utf8(作为返回) | int64_t context,const char*,uint32_t*(见变长类型小节) |
| binary(作为参数) | const char*,uint32_t(见变长类型小节) |
| binary(作为返回) | int64_t context,const char*,uint32_t*(见变长类型小节) |
这一映射在源码的 LLVM 签名生成逻辑中得到了印证:external_c_functions.cc 的GetNumArgs会为kNeedsContext增加 1 个参数、为字符串类型参数额外增加 1 个长度参数、为字符串返回类型额外增加 1 个输出长度指针参数;MapToLLVMSignature 则据此把字符串参数映射为i64(context)、i8*/指针加i32(长度)、i32*(输出长度指针),与上表逐项对应。
变长类型(utf8 / binary)的特殊处理
arrow::StringType(即utf8类型)与arrow::BinaryType都是变长类型,在外部函数中的处理方式相同。由于utf8更常用,文档以它为例说明变长类型的处理规则:
作为参数时,对应的 C 函数需要接受两个参数:
const char*:指向字符串数据的指针;uint32_t:字符串数据的长度。
作为返回类型时,需要满足以下四点:
- 元数据标志:
NativeFunction元数据必须包含NativeFunction::kNeedsContext标志,这是保证函数上下文正确管理的关键(见上文base64注册示例)。 - 函数参数:C 函数开头要增加
int64_t context参数(负责上下文管理);末尾要增加uint32_t*输出参数,用于写回返回字符串的长度。 - 返回值:函数返回
const char*指针,指向字符串数据本身。 - 实现要点:函数体内应使用
gdv_fn_context_arena_malloc进行内存分配、使用gdv_fn_context_set_error_msg设置错误信息。这两个辅助函数都以int64_t context作为第一个参数,声明位于 gdv_function_stubs.h:
void gdv_fn_context_set_error_msg(int64_t context_ptr, const char* err_msg); uint8_t* gdv_fn_context_arena_malloc(int64_t context_ptr, int32_t data_len);gdv_fn_context_arena_malloc从 context 关联的 arena(内存池,对应SimpleArena机制)中分配临时内存,避免了每次调用都向系统申请堆内存的开销,也保证了 JIT 生成代码中字符串返回值的生命周期安全。仓库内置的字符串/二进制处理函数如gdv_fn_base64_encode_binary、gdv_fn_base64_decode_utf8、gdv_fn_castVARBINARY_int32_int64(见 gdv_function_stubs.h)都是"int64_t context+const char*输入 +int32_t*输出长度"这一范式的现成范例,可以直接参考其实现来编写自己的变长返回函数。
C 函数注册 API
使用gandiva::FunctionRegistry的注册 API 即可将外部 C 函数接入(声明见 function_registry.h):
/// \brief register a C function into the function registry /// @param func the registered function's metadata /// @param c_function_ptr the function pointer to the /// registered function's implementation /// @param function_holder_maker this will be used as the function holder if the /// function requires a function holder arrow::Status Register( NativeFunction func, void* c_function_ptr, std::optional<FunctionHolderMaker> function_holder_maker = std::nullopt);参数说明:
NativeFunction func:外部 C 函数的元数据;void* c_function_ptr:指向外部 C 函数实现的函数指针;- 可选的
function_holder_maker:当外部 C 函数需要函数持有者时,用于创建该持有者的工厂函数。FunctionHolderMaker类型定义为std::function<arrow::Result<std::shared_ptr<FunctionHolder>>(const FunctionNode&)>,可参考gandiva::FunctionHolder基类及其若干子类(如InHolder、IntervalHolder、RandomGeneratorHolder、RegexFunctionsHolder、ToDateHolder等,均位于 cpp/src/gandiva 目录下)。
从 function_registry.cc 的实现可以看到,注册时如果提供了function_holder_maker,它会以第一个签名的base_name为键注册到holder_maker_registry_;随后函数指针被存入c_functions_列表,并调用Add把NativeFunction的每个签名加入pc_registry_map_签名查找表。运行时,external_c_functions.cc 的ExternalCFunctions::AddMappings会遍历c_functions_,为每个签名计算 LLVM 签名并调用engine->AddGlobalMappingForFunc把pc_name映射到 C 函数指针,从而让 JIT 生成的机器码能够直接调用你的 C 实现。
外部 IR 函数开发
实现与编译
IR 函数允许你用多种语言实现,然后编译为 Gandiva 可识别的 LLVM 中间表示:
- 使用 C++ 或 C:实现代码编译成 LLVM bitcode(Gandiva 理解的中间表示)。C++ 实现可用 clang 的
-emit-llvm选项直接编译出 LLVM bitcode,例如:clang++ -emit-llvm -c my_funcs.cpp -o my_funcs.bc - 集成 CMake:在配合 CMake 的 C++ 项目中,可以使用 Arrow 仓库提供的
GandivaAddBitcode.cmake模块,将自定义 bitcode 平滑地接入 Gandiva 构建流程。
类型一致性要求:IR 函数的参数与返回类型必须严格遵循前文 C 函数一节建立的规则,以保证与 Gandiva 类型系统的兼容(包括字符串类型的双参数/输出长度指针约定)。
在 Gandiva 中注册外部 IR 函数
实现并编译出 LLVM bitcode 之后,通过gandiva::FunctionRegistry提供的两个 API 完成注册(声明见 function_registry.h):
从 bitcode 文件注册:
// Registers a set of functions from a specified bitcode file arrow::Status Register(const std::vector<NativeFunction>& funcs, const std::string& bitcode_path);从 bitcode 缓冲区注册:
// Registers a set of functions from a bitcode buffer arrow::Status Register(const std::vector<NativeFunction>& funcs, std::shared_ptr<arrow::Buffer> bitcode_buffer);关键点:
- 这两个 API 用于一次性注册一组外部 IR 函数,来源可以是 bitcode 文件路径,也可以是已加载进内存的 bitcode 缓冲区(
arrow::Buffer); - 必须确保 bitcode 文件/缓冲区中包含正确编译的 IR 函数实现;
- 每个
NativeFunction实例用于定义被注册 IR 函数的元数据(签名、可空性、pc_name 等)。
从 function_registry.cc 的实现看,文件版本会先把 bitcode 读入内存并包装为LLVMMemoryArrowBuffer,再统一走缓冲区版本:bitcode 缓冲区被存入bitcode_memory_buffers_,随后逐个Add到签名表。JIT 编译时,LLVMGenerator会从GetBitcodeBuffers()取出这些缓冲区,把其中的 IR 函数与签名表中注册的pc_name对应起来。此外,MakeDefaultFunctionRegistry()(function_registry.cc)会把算术、日期时间、哈希、数学运算、字符串、日期时间算术六大内置注册表合并为默认注册表——你的外部函数注册到自定义的FunctionRegistry后,同样可以随该注册表一起参与表达式编译。
总结
在 Gandiva 中扩展自定义函数的核心流程可以归纳为四步:
- 选型:根据逻辑复杂度、内联需求、第三方库依赖和 LLVM 工具链集成情况,在 C 函数与 IR 函数之间做出选择;
- 实现:按签名映射表编写 C 函数(注意变长字符串类型的
const char* + uint32_t参数约定与返回时的kNeedsContext标志、uint32_t*输出长度参数),或将实现编译为 LLVM bitcode; - 注册元数据:用
gandiva::NativeFunction描述 base_name、别名、参数/返回类型、结果可空性与 pc_name; - 注册实现:调用
gandiva::FunctionRegistry::Register的三种重载之一(C 函数指针、bitcode 文件或 bitcode 缓冲区),把实现与元数据关联起来。
本文涉及的NativeFunction、FunctionRegistry、FunctionHolder等类定义与内置注册实例,均可在 cpp/src/gandiva 目录下的头文件与各function_registry_*.cc文件中找到完整实现,需要处理更复杂场景(如函数持有者、错误返回、十进制/哈希等特殊类型)时,直接阅读这些源码与 cpp/src/gandiva/tests 下的测试用例是最快的上手路径。
【免费下载链接】arrowApache Arrow is the universal columnar format and multi-language toolbox for fast data interchange and in-memory analytics项目地址: https://gitcode.com/GitHub_Trending/arrow3/arrow
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考