Velox 向量化执行引擎深度实战:从 ExpressionEval 编译求值、Memory Arbitration 到 Spilling 的工程全解

每个分析引擎都在重写同一层代码。Velox 是 Meta 给出的答案:把执行层从数据库里拆出来,做成一个可复用的 C++ 库。

一、问题的起点:被重复实现 N 次的执行层

2020 年前后,数据基础设施领域存在一个安静但昂贵的重复劳动。

打开 Presto、Spark、ClickHouse、若干云数仓、若干流处理引擎的源码,你会发现同一套机器被反复实现了一遍:列式内存格式、向量化表达式求值器、Hash Join、会 spill 到磁盘的 Hash Aggregation、几百个标量与聚合函数、Parquet/ORC Reader、S3/HDFS 适配器。

每个系统都写了自己的版本,用自己的语言,带着自己的 bug、自己的性能悬崖,以及自己对 NULL + 1 和 SUM 溢出该怎样处理的微妙不同的语义。

这种重复是双重昂贵的:

  • 构建昂贵:每个团队从零手写向量化 Join。
  • 改进昂贵:一个 SIMD 解码技巧或更聪明的 spill 策略,必须在每个引擎里被独立重新发现一次。

Meta 内部同时跑着 Presto、Spark、自研流处理引擎、TorchArrow 特征工程流水线——每一套都有自己的执行层。Velox 就是把这个层拎出来,变成单一共享库:一处修 bug,一处优化,跨产品语义一致。

这不是一个理论命题。今天 Velox 已经跑在 Presto C++(Prestissimo)之下,通过 Apache Gluten 加速 Spark,并被 NVIDIA cuDF 用作 GPU 执行路径的宿主。Intel、IBM、Microsoft、ByteDance、Voltron Data 都在贡献代码。4.2k stars,488 贡献者,13.5k+ commits。

二、Velox 是什么,以及它不是什么

这是最容易踩的认知坑,必须先讲清楚。

Velox 是一个库,不是一个数据库。 它没有 SQL Parser,没有查询优化器,没有 DataFrame API,没有客户端协议。它接收一个已经优化好的物理计划(core::PlanNode 树),然后把它跑完。

SQL 文本 ──► [Parser] ──► [Optimizer] ──► PlanNode 树 ──► Velox ──► 结果
              ↑ 不在 Velox            ↑ 不在 Velox      ↑ 只有这一段是 Velox

所以你不能"运行 Velox",你只能集成 Velox。这直接决定了它的成本结构:它不是给终端用户的工具,是给引擎开发者的地基。

它提供的是一组可插拔组件:

组件职责
Type泛型类型系统,支持 scalar / array / map / row 等嵌套类型
VectorArrow 兼容的列式内存布局,Flat/Dictionary/Constant/Sequence 编码
Expression Eval全向量化表达式求值
Functions遵循 Presto 与 Spark 语义的标量/聚合/窗口函数
OperatorsTableScan、Projection、Filter、HashJoin、Aggregation、OrderBy、Unnest、Exchange
Connector数据源/格式扩展点(ORC/DWRF/Parquet/Nimble + S3/HDFS/GCS/ABFS)
Network Serializer线协议(PrestoPage、Spark UnsafeRow)
Resource MgmtMemoryPool、Arbitrator、Spilling、AsyncCache

八个扩展点:自定义类型、简单函数、向量化函数、聚合函数、窗口函数、Operator、文件格式、存储适配器。

三、Vector 层:为什么编码(Encoding)才是性能核心

多数人对"列式格式"的理解止步于"按列存"。Velox 的关键设计是:Vector 不只是内存布局,还是一套计算可复用的编码体系。

四种基础编码:

  • Flat:最朴素,值数组 + 可选 null bitmap。
  • Constant:整列一个值,长度 N 但只存 1 个值。
  • Dictionary:值数组 + 长度 N 的 indices 数组。Join 探测侧、低基数列的典型形态。
  • Sequence / RLE:[value, run-length] 对,Parquet 字典页解码后的天然形态。

这个设计的威力在于计算可以下推到编码层,而不是先物化再算。

考虑 WHERE country = 'CN' AND amount > 100:

DictionaryVector<string> (1000 行, 字典只有 5 个值)
  ──► 对字典求值,不是对 1000 行求值
  ──► 得到 5 个 bool 的 base result + 1000 个 indices
  ──► 结果本身仍是 Dictionary(bool)

这一步叫 Peeling(剥离编码)。Velox 的表达式求值器会检测输入是否带 dictionary 包裹,如果有就"剥掉"indices,只对 base vector(5 个值)跑函数,再把结果的编码原样贴回去。1000 次函数调用变成 5 次。

这带来一个反直觉的性能特征:字典编码越重,查询可能越快。一个在 Flat 数据上 3 秒的查询,在同一份数据的字典编码形态上可能是 200 毫秒。这就是为什么同样的逻辑,Gluten 加速后的 Spark 有时比原生 Spark 快一个数量级——不是因为 C++ 比 Java 快(那通常只有 2-3 倍),而是因为编码感知的求值路径。

还有两个实战中要留意的机制:

Lazy Vector:TableScan 产出的列默认不解码。只有当 Filter/Projection 真的触碰它时才触发 I/O 与解码。这意味着 SELECT count(*) FROM t WHERE a > 5 里,b、c、d 三列可能一次都不会被读取。列裁剪是在执行期动态完成的,不只是优化期。

StringView:Velox 用 16 字节的 StringView 表示字符串——4 字节长度 + 4 字节前缀 + 8 字节指针/内联数据。短字符串(≤12 字节)直接内联,零间接寻址。这个布局使得字符串比较可以先比 4 字节前缀,绝大多数不等的情况根本不需要解引用指针。

四、ExpressionEval:special form 与求值优化

Velox 的表达式是一棵 core::ITypedExpr 树,求值前会被编译成 ExecExpr(由 Expr 节点组成的求值树)。

4.1 为什么 NULLIF 必须是 special form

这是社区里一个很能说明设计哲学的例子。NULLIF(a, b) 看起来等价于 IF(a = b, NULL, a),你很想把它实现成一个普通函数。但不能。

原因是求值语义:普通标量函数的所有参数都会被无脑求值,而 special form 拥有"不评估某些参数"的特权。

// 作为普通函数,这行会崩:
SELECT nullif(x, 0) FROM t WHERE x != 0;
// 如果 x 可能为 0,普通函数签名下两个参数都已求值,
// 语义上 a=b 的短路消失,且 NULL 处理与 CAST 边界行为全部错位

Velox 把这些带控制流语义的东西实现为 special form:AND/OR(短路)、IF/CASE(条件求值)、COALESCE、NULLIF、TRY(异常捕获)、CAST、IN、Subscript。它们在 Expr 层有专门的实现,能决定"哪些子树现在才求值"。

工程含义:如果你发现自己写的函数需要"有时候不评估第二个参数",你需要的不是函数,是一个 special form。

4.2 共享子表达式消除(CSE)

WHERE upper(a) LIKE '%X%' OR upper(a) LIKE '%Y%',upper(a) 会被求值两次。Velox 的 ExprSet 会通过 ExprSetSimplifier / CSE 通道识别同构子树(基于 ITypedExpr 的结构相等 + 类型相等),把它折叠成一个 Expr 节点,结果被多个父节点引用。

这在宽表 + 大量派生列的场景下收益极大,因为它同时减少了 CPU 和中间 Vector 的内存分配。

4.3 结果复用与 null 传播

向量化函数通过 SelectivityVector 描述"哪些行需要计算"。一个成熟的 Velox 函数实现要做三件事:

  1. 用 rows 参数只算活跃行,而不是整批 1024 行;
  2. 检查输入的 null bitmap,把 null 行从 rows 中剔除(null 传播);
  3. 若输入是 Constant 或全 null,走快速路径直接返回 Constant/全 null 结果。

漏掉任何一条,函数都能跑对,但会慢 5-10 倍。

五、动手:写一个向量化标量函数

理论讲够了,看代码。下面定义一个把 URL 主机名抽出来的 url_host,写成向量化函数(不是 Simple Function),因为它需要访问 StringView 并做零拷贝切片。

#include "velox/expression/VectorFunction.h"
#include "velox/type/StringView.h"

namespace facebook::velox::functions {

class UrlHostFunction final : public exec::VectorFunction {
 public:
  // 1) 无状态时返回 false,让引擎复用同一实例
  bool isDefaultNullBehavior() const override { return true; }

  void apply(
      const SelectivityVector& rows,          // 需要计算的行
      std::vector<VectorPtr>& args,
      const TypePtr& /*outputType*/,
      exec::EvalCtx& context,
      VectorPtr& result) const override {
    const auto* input = args[0]->as<SimpleVector<StringView>>();
    VELOX_CHECK_NOT_NULL(input, "url_host expects VARCHAR");

    // 2) 复用结果 buffer:addNulls=false 表示我们自己处理 null
    BaseVector::ensureWritable(rows, VARCHAR(), context.pool(), result);
    auto* flat = result->asFlatVector<StringView>();

    rows.applyToSelected([&](vector_size_t i) {
      if (input->isNullAt(i)) {            // 显式 null 传播
        flat->setNull(i, true);
        return;
      }
      const StringView url = input->valueAt(i);
      // 3) 零拷贝:StringView 直接切片,不分配新字符串
      const auto schemeEnd = url.find("://");
      auto start = schemeEnd == std::string_view::npos
          ? 0 : schemeEnd + 3;
      const auto pathPos = url.find('/', start);
      const auto end = pathPos == std::string_view::npos
          ? url.size() : pathPos;
      flat->set(i, StringView(url.data() + start, end - start));
    });
  }

  // 4) 声明:输入全 Constant 时输出也 Constant,让引擎走快速路径
  static std::vector<std::shared_ptr<exec::FunctionSignature>> signatures() {
    return {exec::FunctionSignatureBuilder()
                .returnType("varchar")
                .argumentType("varchar")
                .build()};
  }
};

// 注册
VELOX_DECLARE_VECTOR_FUNCTION(
    udf_url_host,
    UrlHostFunction::signatures(),
    std::make_unique<UrlHostFunction>());

} // namespace

有几个点值得单独拎出来讲,它们正是"能跑"和"跑得快"的分界线:

  • ensureWritable 而不是 new FlatVector。Velox 的求值上下文会缓存 result vector 并在多个 batch 之间复用底层 buffer。每次新建意味着每批一次 malloc,在 1000 万行的扫描里那就是几万次系统调用。
  • rows.applyToSelected 而不是 for (i = 0; i < size; i++)。被 Filter 淘汰的行不该付账。
  • StringView 切片而非 std::string 构造。上面这个函数是零分配的——输出 StringView 指向输入 buffer,只有 16 字节头被写。这是 Velox 字符串处理性能的根本来源。
  • isDefaultNullBehavior() == true 让引擎在函数体之前就把 null 行从 rows 中剔除;上面的显式 isNullAt 检查是双保险,在函数被嵌套调用时仍然必要。

如果不需要这些控制,用 Simple Function 的宏封装会更省事:

template <typename T>
struct UrlHostSimple {
  VELOX_DEFINE_FUNCTION_TYPES(T);
  FOLLY_ALWAYS_INLINE bool call(
      out_type<Varchar>& out,
      const arg_type<Varchar>& url) {
    const auto pos = url.find("://");
    out.copy_from(StringView(url.data() + (pos == std::string_view::npos ? 0 : pos + 3), 0));
    return true;   // 返回 false 即产出 NULL
  }
};

SimpleFunctionAdapter 会在编译期通过模板把 T 的单值语义展开成向量化循环,并自动绑定 null 传播、ASCII/UTF8 双路径、以及 try 异常捕获。适合 90% 的场景。

六、执行模型:Driver、Task 与 Operator 流水线

Velox 的执行不是 Volcano 风格的 next() 递归调用,而是推送式流水线 + 状态机。

Task
 └─ DriverFactory × N  (并行度)
     └─ Driver
         └─ Operator 链: TableScan → Filter → Project → HashAggregation → ...

每个 Driver 绑定到一个线程(来自 folly::CPUThreadPoolExecutor),Driver::runInternal() 循环调用 Operator::isBlocked() / getOutput()。关键设计:

  • Operator 不阻塞线程。当 HashJoin 需要等 build 侧,它返回 BlockingReason::kWaitForJoinBuild 并把 Driver 从线程上摘下来,线程去跑别的 Driver。这是 Velox 能在少量线程上跑出高 CPU 利用率的原因。
  • Operator::noMoreInput() 之后再循环直到 isFinished(),形成明确的终止协议。
  • 中间结果以 RowVector 为单位流动,默认批次约 1024 行——小到能进 L2 cache,大到摊销掉虚调用开销。

这套模型和 DuckDB 的推送式 Pipeline、Spark 的 Whole-Stage CodeGen 是同一代思想的不同实现:用批处理摊销解释开销,用状态机替代线程阻塞。

七、内存管理:Arbitrator 与 Spilling,生产事故的高发区

这是集成 Velox 时最容易出事的地方,值得多花笔墨。

7.1 分层内存池

MemoryManager (全局, 进程配额)
 └─ MemoryArbitrator   ← 全局仲裁者
     └─ QueryPool (per-query)
         └─ TaskPool
             └─ OperatorPool / NodePool

每个 Operator 通过自己的 memory::MemoryPool 申请内存。当总量逼近配额,MemoryArbitrator 介入。

7.2 仲裁策略:不是 OOM,而是"请别人让一点"

Arbitrator 的核心动作是 reclaim(回收)。它挑一个内存占用大的 Query,要求它释放到目标值。Operator 通过实现 Reclaimable/spill() 接口响应。

默认的 kMemoryReclaim 路径大致是:

  1. 尝试回收缓存(AsyncDataCache 的冷数据);
  2. 要求 Operator spill(HashAggregation → 排序后写盘;HashJoin → 把 build 侧分区落盘);
  3. 如果都不够,且 MemoryPool::abort() 被允许,则取消该 Query 并抛 VeloxRuntimeError。

生产上最常见的错误是:只配了查询级配额,却没给每个 Operator 设 memoryReclaimOnBlock 或 spill 路径。结果是一个大 Join 直接把整个进程拖到 OOM,而不是优雅降级。

// 一个务实的 Query 配置
core::QueryConfig cfg;
cfg.set(core::QueryConfig::kSpillEnabled, "true");
cfg.set(core::QueryConfig::kAggregationSpillEnabled, "true");
cfg.set(core::QueryConfig::kJoinSpillEnabled, "true");
cfg.set(core::QueryConfig::kSpillCompressionKind, "zstd");
cfg.set(core::QueryConfig::kMaxSpillFileSize, "1073741824");  // 1GB 分片
cfg.set(core::QueryConfig::kSpillStartPartitionBit, "29");    // 分区fan-out
// 关键:告诉 Arbitrator 可以从哪里回收
cfg.set(core::QueryConfig::kSpillableReservationGrowthPct, "10");

7.3 Spilling 的真实形态

HashAggregation 的 spill 不是"把哈希表写到磁盘"——那样读回来还要重建哈希表,没意义。真实做法是:

  1. 按 hash(keys) % numPartitions 把累积的 accumulator 行分区;
  2. 每个分区按 key 排序后写成 spill file(这样能流式归并,不需要重建哈希表);
  3. 后续用 merge 的方式回读,走 sorted aggregation 路径输出。

HashJoin 的 spill 类似:build 侧按 partition 落盘,probe 侧对应分区也落盘,然后逐分区重新执行整条 join 流水线(spill → restore 会重启下游 Operator)。

工程含义:spill 生效时,你的延迟不是线性增长,而是阶跃。一个 8 秒的查询在内存够时是 8 秒,在触发 partition spill 后可能是 90 秒。监控上要把 spillFillTimeNanos、spillSortTimeNanos、spillSerializationTimeNanos 单独打点——这几个指标一旦非零,就意味着你正在为内存不足付出 IO 代价。

7.4 一个真实的性能陷阱:RIGHT SEMI JOIN

社区 2026 年 7 月有一篇很好的博客:一个看起来等价的 join 重写,把 LEFT SEMI 改成 RIGHT SEMI,造成了 10 倍回退。

原因在于 probe/build 侧的选择。Semi join 的语义是"只要 build 侧匹配就输出 probe 行"。当你翻转方向,原本小的那侧变成了 build 侧,哈希表变大、cache miss 变多、且输出去重策略改变。这不是优化器 bug,是执行层物理特性。

教训很直接:逻辑等价的 plan,在向量化执行引擎里可能有数量级差异。做 plan rewrite 时必须带着执行层的成本模型,不能只看关系代数等价。

八、生态落地:Presto C++、Gluten 与 GPU

三条现实路径:

Presto C++(Prestissimo):Presto 的 Coordinator 仍是 Java,Worker 换成 C++,直接调用 Velox。收益不只是 CPU,更是内存控制——Java 堆的 GC 抖动消失,可以用 memory arbitrator 做精确配额。

Apache Gluten:把 Spark 的物理计划(Whole-Stage CodeGen 之后的计划)翻译成 Velox 的 PlanNode,在 Executor 进程里跑 native 算子,通过 JNI 交互。Columnar shuffle 用 Velox 的 PrestoPage/UnsafeRow serializer 保持列式,避免在 JVM 边界上来回序列化。2026 年 8 月新增的 "Native Delta Statistics with Velox Task Barriers" 让它能在 Velox 侧直接评估 Delta Lake 的 per-file statistics,而不是把行拉回 JVM 一行行处理。

GPU:IBM 与 NVIDIA 把 cuDF -backed 的算子接进 Velox,2026 年 6 月起有 nightly 的 GPU 加速 Presto C++ 镜像。VLDB 2026 的论文报告最高比纯 CPU Presto 便宜 6 倍。

还有一条重要的一致性主题:2026 年 8 月的 "War of the Allocators" 直接对比了 jemalloc 与 Velox 自研的 mmap-based allocator,讨论碎片化与 RSS 控制。当你把执行层共享出来之后,连分配器选择都变成了一个跨引擎的公共议题——这正是 Velox 的核心价值所在。

九、工程判断:什么时候该用 Velox

该用:

  • 你在写一个分析型引擎/数据库,已经有(或打算有)自己的 parser 和优化器;
  • 你想给现有的 Java/Scala 引擎加 native 执行路径;
  • 你需要 Presto 或 Spark 的函数语义但不想重写 800 个函数;
  • 你在做 AI/ML 数据预处理(FlatMapVector 对 map 型特征列的支持就是为此加的)。

不该用:

  • 你要的是"能跑 SQL 的东西"——用 DuckDB 或 DataFusion,它们包含 parser + optimizer + 执行;
  • 你的团队没有 C++ 系统能力。Velox 要求 C++17、gcc 11+/clang 15+,构建依赖 vcpkg,是一个真实的工程门槛;
  • 你需要稳定 API。Velox 没有 tagged release,你从 main 构建。这是它最被低估的成本——升级时要跑完整的回归,且社区明确不保证 API 稳定。

十、结论

Velox 的价值不在于某个 benchmark 数字,而在于它把"执行引擎"从每个数据库的私有实现,变成了一个共享的、可被集体优化的公共组件。

这个转变的影响是结构性的:一次 SIMD 优化(比如 2026 年 6 月那个把 Parquet DELTA 解码 CPU 时间砍掉 4.5 倍的工作)不再属于某个引擎,而是同时进入 Presto、Spark 和未来的流处理系统。一次 HashTableCache 的改进(build 一次、多 task 复用,替代 build-per-task)让所有广播 Join 同时受益。

对工程师来说,理解 Velox 的收益有两层:

  1. 如果你在集成它:把注意力放在 encoding-aware 求值和 memory arbitration 上——这两处决定了你是拿到 10 倍收益还是只拿到 2 倍。
  2. 如果你只是在用 Presto/Spark:知道 Velox 在下面,意味着你知道性能调优的新杠杆在哪里:字典编码保留、列裁剪是否生效、spill 指标是否为零、以及 plan 重写是否触发了物理层的性能悬崖。

执行层的去重,是过去十年数据基础设施里最重要的一次整合。它还没结束——GPU 路径、Nimble 格式、Axiom 这样的上层封装仍在快速演进。但方向已经清楚了:没人应该再手写一遍 Hash Join。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部