统一并优化标量执行器基础设施 – #24564
对话
@kryonix
kryonix
评论于
24 分钟前
成员
我遇到了 DuckDB 标量执行器基础设施中的一个分裂:一元和二元执行有精心特化的平坦(flat)和常量(constant)路径,而三元及更高元(higher-arity)的执行则通过通用的统一向量循环进行。这些实现独立地解决了相同的表示、有效性(validity)和选择(selection)问题。这使得热路径难以持续优化,也意味着一个廉价的三元操作,当每个输入都是平坦或常量时,仍然要付出选择向量和每输入有效性检查的开销。
被违反的不变式存在于向量执行器层。元数(arity)应该决定参数如何展开以及操作如何被调用;它不应该决定哪些表示和有效性优化是可用的。我将那些公共工作移到了一个内部的 ScalarExecutor 中,并保留了 UnaryExecutor、BinaryExecutor、TernaryExecutor 和 VariadicExecutor 作为兼容性外观(facade)。它们现有的入口点和操作约定保持不变。
共享引擎将每个输入分类为常量、平坦或通用(generic)。它对全常量输入求值一次,通过元数三特化平坦/常量掩码(mask),一次处理一个机器字(a machine word at a time)地组合有效性,并对其他表示回退到 UnifiedVectorFormat。小型适配器保留了 lambda、可选返回、一元通用/状态、一元字符串、二元单类型、二元完全类型化和可变静态操作约定。一元外观保留了字典域求值和其 FunctionErrors::CANNOT_ERROR 保护,而二元外观保留了比较-补集折叠(comparison-complement folding)。
对于完全统一的外观,有两个特意留出的例外。首先,初始的共享二元仅真值(true-only)选择循环在一个测量到的热路径上出现了性能下降,因此我为该输出模式保留了一个狭窄的二元特定平坦循环。其次,当所有显式输入都有效时,一元执行保留了一个预填充的结果有效性掩码。Arrow 扩展转换依赖于这个现有契约:它在转换一个全部有效的临时存储向量之前,会将 Arrow NULL 掩码放置在结果上。可以添加 NULL 的操作会在修改该掩码之前复制它。三元和可变参数执行不会保留旧的结果掩码,因此被重用的结果仍然会清除那里的过时 NULL。全常量路径也会显式清除有效/NULL/有效重用状态。
我限制了特化,因为实例化每种掩码和每种选择-输出的组合,其代价很快就超过了运行时的收益。执行通过元数三特化算术输入。可变参数选择仅特化全平坦掩码,并在运行时调度输出存在性(output presence);添加所有三元选择掩码会使文本增加超过 1 MiB,却没有相应的可测量收益。DUCKDB_SMALLER_BINARY 可以通过现有的 vector_specialization 组中新的 variadic_executor_select_flat 功能来移除这种特化。
后续的审查还暴露了 duckdb/duckdb#24391 中小的二元执行器变更。我已经从平坦/常量特化中移除了选择查找(selection lookup),但全有效(all-valid)的通用回退路径仍然在每行上为两个输入调用 SelectionVector::get_index()。我在共享引擎边界集成了同样的思路:二元外观为每个向量分派四种输入选择掩码一次,然后每个循环使用一个恒等索引或原始选择数据。这目前有意仅限于二元,并由 vector_specialization 的 smaller-binary 组中一个独立的 binary_executor_generic_no_null 功能控制。
我针对基于 daa81697e31a3dc97a93f11220037cd2213af6cd 提交的版本,在 arm64 macOS 26.5 上使用 Apple Clang 21、发布优化,并在禁用编译器启动器(launcher)的情况下,进行了隔离的基线/候选构建测试。直接的执行器基准测试使用一个标准大小的向量,并对非恒定配置文件进行一百万次进程内迭代。配对的进程级测量显示:一元平坦(923.3 毫秒 对比 919.0 毫秒)、一元字典(3.116 秒 对比 3.128 秒)、二元平坦/平坦(1.248 秒 对比 1.262 秒)以及二元平坦/常量和常量/平坦持平或略快。保留的二元仅真值选择路径在八个进程内样本中,比固定(pinned)实现快了约 1%。
我为通用的无 NULL 路径添加了直接的字典/平坦、平坦/字典和字典/字典二元案例。在这台 arm64 机器上,它们分别测得 3.484 秒 对比 3.541 秒、3.502 秒 对比 3.529 秒 和 4.264 秒 对比 4.263 秒,都在 2% 的调查阈值内。保留该特化的原因是针对特定目标(如激发了 #24391 的 x86-64-v3 配置)的生成代码质量:Clang 为未选择的一侧发出顺序加载(sequential load),为每个被选择的一侧发出恰好一次原始选择加载,且在行循环中没有运行时选择存在性(selection-presence)分支。我无法在这台 arm64 主机上验证 x86/AVX 的结果。
廉价的(Cheap)三元执行是这次整合带来收益的地方。六种混合平坦/常量配置的性能提升了 21% 到 69%,而平坦/平坦/平坦提升了约 2%。全平坦三元选择从 7.060 秒提升到 4.894 秒,即 1.44 倍。一次处理一个字(word-at-a-time)的有效性处理也显著改善了高 NULL 比例的三元案例:50% NULL 的案例从 1.957 秒降至 0.076 秒,99% NULL 的案例从 1.833 秒降至 0.076 秒,有效性字大小簇(validity-word-sized clusters)从 2.838 秒降至 0.355 秒。采样和火焰图(flamegraph)发现了一个最初未内联的一元有效性辅助函数;修复该问题后,50% NULL 的一元案例达到了持平(3.152 秒 对比 3.132 秒)。
一个可见的微基准测试权衡是全常量二元案例。它大约慢了 3.3%,因为新路径在产生有效结果之前会清除过时的常量有效性,而固定实现省略了这一步。这是每向量一次操作,而非每行循环操作,我选择了确定性的有效/NULL/有效行为,而不是保留那微小的常量调度差异。
对于常规发布构建,剥离了本地符号的 libduckdb 从 45,252,216 字节减少到 45,024,160 字节(-0.50%),并且 __text 段减少了 0.90%。smaller-binary 的结果则不太有利:剥离后的库从 37,010,320 字节增长到 37,408,584 字节(+1.08%),__text 增长了 1.33%。通用的共享引擎即使修剪了所有 30 个特化功能后,仍然有固定成本;我没有试图通过更多的预处理器复制来隐藏这个成本。与保留这四个掩码相比,将新的二元通用特化保留在 vector_specialization 组中,大约从完全 smaller 剥离库中节省了 133 KiB。
@kryonix
统一标量执行器基础设施
caffec8
网硕互联帮助中心


评论前必须登录!
注册