NebulaGraph 向量化架构实践

:technologist:关于作者

黄斌(https://github.com/codesigner), NebulaGraph 数据库内核技术负责人、NebulaGraph Analytics 主要架构设计者,专注于数据库架构、查询优化、分布式系统与大规模图计算。

:bulb:导读

向量化在 NebulaGraph 中并不只是一次局部性能优化,而是逐渐演变为贯穿数据库内核与图计算体系的基础执行模型。本文将从实际工程演进出发,介绍这套向量模型如何影响图数据表示、查询执行、表达式求值、数据传输与 NebulaGraph Analytics,并总结其中的关键设计取舍。

写在前面

在 NebulaGraph 的实践中,一个早期设计问题对整个系统产生了持续影响:是否应该让列式 batch 成为贯穿整个系统的共同工作单元?

这个选择塑造了 NebulaGraph 的内存格式、表达式框架、图算子、中间结果表示、NebulaGraph Analytics 的图计算执行内核,以及数据传输层。

本文将回顾我们如何走到这一设计,分析型数据库系统给了我们哪些启发,图工作负载又带来了哪些不同的设计要求,以及这套方案的适用边界在哪里。这不是一篇跑分对比,而是一次系统工程复盘:我们关心的是哪些接口和取舍为后续优化创造了条件。

下文将沿着设计依赖逐层展开:先建立向量模型,再围绕它构建数据库执行体系,随后把这一模型延伸到进程边界和 Analytics,最后总结贯穿整个过程的工程取舍。

第一部分:确立向量模型

从逐行求值到向量化基线

NebulaGraph Database 3.x 的表达式求值以通用 Value为中心。每个表达式都提供一个虚函数 eval(ExpressionContext&)。以算术表达式为例,它会递归访问子表达式,取得两个Value对象,根据表达式类型进行分派,再调用 Value上的动态操作。函数调用也会对每一行重复类似的过程:依次求值各个参数,组织参数引用,再调用动态选出的函数实现。

这种设计有它的优点:逻辑直接、容易扩展,也很适合解释执行。但它的主要问题是大量工作会按行重复。查询处理前序阶段已经确定的信息,以及本可以由一个 batch 分摊的框架开销,仍然留在逐行处理的热点循环里:

•每一行都要重新遍历表达式;

•每一行都会触发虚函数调用和算子分派;

•即使验证阶段已经确定了类型,值仍然携带动态类型信息;

•临时对象和通用访问接口遮蔽了底层连续内存,编译器很难进一步优化;

•单行能够完成的工作太少,难以摊薄框架开销。

可以用下面这张图概括两种模型的差别

表达式的语义没有改变,改变的是求值的工作单元:前一种模型让通用值逐行经过分派,后一种模型则把分派提升到 batch 粒度。

从第一种模型转向第二种模型,并不只是换一种循环写法,而是把决策移出逐行循环。解析和验证阶段确定表达式及其值类型,初始化阶段绑定到具体的函数实现,执行阶段再根据 batch 中向量的编码选择执行路径。这样,最内层循环就可以直接处理带类型的视图或原始 buffer,不必为每一行重新发现相同的信息。

我们开始设计 5.x 时,向量化和查询编译都是可行方向。我们并不把它们视为相互排斥的两套理念。Kersten 等人在比较编译式与向量化查询引擎时也得出了类似结论:两种模型都可以做到高效,但各有所长。他们的实验表明,向量化善于掩盖缓存未命中的延迟,而以数据为中心的编译在数据位于缓存中时能够减少指令数(Kersten et al., 2018)。对我们而言,关键不只是选择如何执行某条查询,而是这个选择会如何改变整个执行引擎的组织方式。

我们最终选择向量化作为基线,主要基于三个现实因素。

第一,它可以渐进落地。我们能够先定义向量表示,把算子边界切换到batch,再逐步特化表达式路径,而不必在项目初期就承担完整查询编译基础设施的建设成本。

第二,执行循环逻辑仍然由普通 C++ 代码实现,可以继续使用熟悉的调试器、sanitizer、profiler 和编译器诊断工具。
第三,它提供了一组能够贯穿执行体系的机制:batch 级分派、列式数据访问、显式行选择,以及编码感知的执行路径。这些机制可以减少重复分派、改善局部性、避免不必要的数据复制和物化,也为后续特化提供统一基础。

JIT 编译仍然是值得继续探索的方向。如果利用运行时信息进行特化所带来的收益足以抵消编译成本,我们可能会在局部引入 JIT,例如将结构确定的表达式树编译成特化的求值路径,或者针对某个查询的 schema 生成特化代码。但未来的 JIT 会建立在向量模型之上,而不是取代它。目前的表达式框架仍然以普通 C++ 提前编译,稠密循环由 C++ 编译器优化,而不是交给运行时 JIT。

向量模型是起点

本文所说的向量模型由三个部分组成:类型化 batch、物理编码和显式行选择。它们分别规定数据如何成批组织、一个逻辑列如何表示,以及当前操作作用于哪些行。

在设计内存模型时,Apache Arrow 是一个自然的起点。它围绕连续的类型化 buffer、有效性位图和嵌套列式数据建立了一套实用标准(Apache Arrow 概览)。对于 primitive 类型的 flat vector,NebulaGraph 的值布局和有效性布局与 Arrow 兼容:值连续存放,空值信息单独保存。

整个向量系统将逻辑类型与物理编码分开。Flat 和 Constant 编码覆盖最基本的场景;Slice、Concat、Dictionary 等编码则让一个逻辑列可以表示区间、重复值、间接引用或多个拼接片段,而不必先把所有数据复制到新的 flat buffer 中。

算子之间传递的基本单元 是vector batch,也就是一组具有相同逻辑行数的向量。选择向量用于描述当前操作实际参与计算的是 batch 中的哪些行。相比某一种具体编码,这种分离更为重要:值仍然留在各自的列中;过滤和控制流可以更新活跃行集合,决定后续表达式处理哪些行,而不必复制底层数据。

primitive 类型布局的兼容性只是起点。我们还需要把图数据类型、GQL schema、因式分解中间结果,以及 buffer 的生命周期和传输要求统一纳入执行层的数据表示,因此选择构建 NebulaGraph 自己的向量层。

让向量理解图

在关系型执行引擎中,一个向量通常表示一个具有单一逻辑类型的列。GQL 同样包含标量、列表、map 和 record 等常见值,但属性图还带来了额外的复杂性。

一个点有自己的标识,属于某个图,并可以带有多个 label 和属性。边则包含标识、方向、起点和终点、label 或 type,以及相应属性。一条路径由类型可能各不相同的点和边依次组成。GQL 还允许同一结果列的不同行携带不同的点类型或边类型。

如果把所有图数据都封装在通用 Value对象中,虽然可以保留足够的通用性,但向量层只能把它们当作不透明值处理,无法直接看到向量化最希望暴露出来的内部结构。因此,我们为图对象增加了专用的向量类型。

同一个 NodeVector 的不同行可以属于不同的点类型。它保存紧凑的点 header,并为这些类型的 schema 属性并集中的每个属性建立一个子向量;图标识、点类型标识到属性列下标的映射,则记录每种类型具体拥有哪些列。EdgeVector 以相同的方式组织边类型及其属性。这样,属性仍然是列,而不是每行一个由通用值组成的 map。

PathVector 采用分解式表示:带类型的点向量和边向量负责保存元素,邻接元数据则把路径中的前后元素连接起来。内部元素类型各异的路径可以共享元素存储,同时保留重建路径值所需的顺序。reader 向上层提供图语义下的路径视图,底层物理表示仍然保持分解状态。

这些类型也是我们构建自有向量层的直接原因。它们把 GQL 和属性图语义编码进了向量体系。我们当然也可以把通用 Value对象存入普通列,但执行引擎最终仍然需要面向图的 reader、writer、schema 解析和序列化。由我们自己实现向量格式,能够把这些需求直接呈现出来,而不是把它们藏在通用值的边界之后。

第二部分:围绕向量构建执行体系

图原生执行:Expand与因式分解

Expand:面向图场景特化的 Join

从逻辑上说,图遍历并没有摆脱 Join. 由一个点扩展到相邻的边和目标点,本质上仍然是在通过 key 关联记录,也会产生那些让 Join 变得棘手的基数效应:扇出、重复、过滤以及可能非常庞大的中间结果。

但在物理执行层面,Expand 可以直接利用图存储的邻接结构,并围绕源点标识、方向、边类型和所需属性组织访问;过滤也可以在扩展前沿仍然较小时尽早完成。

因此,NebulaGraph 为 Expand 提供了特化实现,而不是把每一跳都展开为一次扫描再加一个通用 Join. 逻辑上,Expand 仍然可以理解为基于邻接关系的 Join;特化算子则让这种频繁出现的 Join 形态与图存储的组织方式相匹配。

多跳结果的因式分解

到了多跳查询,这个问题会更加明显。考虑下面的查询:

MATCH (v)-[e1]->(m)-[e2]->(t)
RETURN v, e1, m, e2, t

如果 v有很多出边,flat table 会为每个 e1重复保存 v. 如果每个 m又有很多出边,第二跳还会为每个 e2再次重复v、e1和 m. 这样的关系结果完全合法,但如果把这些重复列过早地全部物化出来,作为中间表示并不高效。

NebulaGraph 的 FTree 借鉴了因式分解数据库的研究,尤其是 Olteanu 和 Závodný 的论文Size Bounds for Factorised Representations of Query Results. 展开后的表格结果可能多次重复同一段路径前缀,而这段前缀实际上只需存储一次。因式分解表示不会立即物化完全展开的结果,而是显式保留这些依赖关系。

在NebulaGraph 中,FTree 的每一层保存当前扩展步骤引入的值,并记录每个父项对应的子项区间,使重复的路径前缀只需存储一次。当下游执行需要标准 vector batch 接口时,因式分解结果通过向量编码表达切片和重复,无需逐行复制每一个值。

同一组路径的两种表示:扁平化会不断重复前缀,因式分解表示则只保留一份父项,并记录它对应的子项区间。

向量化让按列处理变得规整,因式分解则减少中间结果中的重复,两者相互补充。我们会在查询形态和下游消费者能够受益时使用因式分解;FTree 并不意味着每一个MATCH的结果都会在整个执行计划中始终保持因式分解形式。

以向量为核心的 Pipeline 执行器

有了这些表示,我们明确了图算子之间可以交换什么数据。接下来的问题是:如何驱动这些算子,同时避免把调度策略塞进每个算子的实现里?

NebulaGraph 会把物理计划划分为线性的 pipeline,pipeline 内的算子通过 vector batch 交换数据。算子只需要实现与自身逻辑相关的局部状态机:需要输入时,通过 consume()接收一个batch;输出就绪时,通过 produce()输出一个 batch. source、transform 和 sink 算子都遵循同一种交互模型;遇到 Join、Exchange 或其他需要协调多路输入的边界,则由 bridge 连接不同 pipeline.

驱动循环由 pipeline 负责。它判断算子是否需要数据,从上游拉取输出并向下游传递,同时处理输入结束。阻塞、让出执行、取消和恢复都在算子具体逻辑之外统一协调。并发来自对多个 pipeline 实例的调度,而不是让单个算子自行管理线程。

我们刻意让 consume/produce 的组合比单向回调链更有表达力。Projection 可以收到输入后立即产生输出;Aggregation 可能要消费多个 batch 后才有结果;Join 可能需要等待另一条 pipeline 构建状态;Limit 则可能在上游耗尽之前就提前结束。算子通过一套精简接口报告状态,由 pipeline 决定何时拉取、推送、停止或重新调度。因此,反压由 pipeline 根据各算子的状态统一协调,而不是散落在算子内部的临时逻辑中。

把调度策略留在算子之外,也为调整调度方式留下了空间,而无需重写算子算法。pipeline 可以运行在不同的调度后端上,使用不同的并发度,也可以在等待异步存储请求时挂起。这种分离对图算子尤其有价值:Expand 可以专注于邻接访问、过滤和结果构造,存储访问前后的暂停与恢复则由 pipeline 统一负责。在这里,vector batch 既是一种数据布局,也是保持物理算子可组合性的接口。

DuckDB 的向量化执行器,以及 Velox 组织算子与 pipeline 的方式,都帮助我们理解了这部分设计空间(DuckDB: an Embeddable Analytical Database,Velox tasks and drivers)。我们借鉴了这些设计思路,并围绕NebulaGraph 的图算子、异步边界和执行生命周期实现了自己的执行器。

向量化表达式引擎

当向量成为物理算子之间的数据接口后,我们必须回到最初推动这项工作的那个问题:表达式求值。如果表达式仍然逐行执行,就会在一条已经向量化的pipeline 中重新引入逐行解释开销。

对每个函数,框架都从一份精简的强类型标量实现出发。模板traits 会萃取函数签名及其支持的调用形式,包括 nullable、null-free 和 ASCII 特化版本。同一组元数据还会记录默认空值行为、结果是否可能为空、函数是否具有确定性、是否存在副作用,以及 ASCII 输入能否保证 ASCII 输出。框架根据这些信息生成执行适配层,函数作者不必在每个函数中重复编写向量解码和 batch 循环逻辑。

因此,框架会综合两类信息:traits 决定哪些执行路径在语义上合法;输入编码、有效性、ASCII 状态和当前选择向量则决定这个 batch 最适合走哪条合法路径。

对于采用默认空值行为的函数,只要任一参数为空,结果就为空,无需进入标量函数实现。如果选中的输入均不含空值,内层循环可以完全去掉这项检查。在nullable 的稠密 batch 上,有效性位图会按机器字宽度合并处理:一个机器字范围内全部有效时,直接复用 null-free 的稠密循环;有效性混合时,则只计算有效行并保留空值语义。显式接收 nullable 参数的函数会继续走自己的 nullable 路径,不会套用这一默认行为。

字符串函数还多一个维度。如果函数提供ASCII 特化实现,并且所有选中的字符串参数都已知为 ASCII,适配层会为整个 batch 一次性选择该实现。长度、子串、trim 和模式匹配等操作便可以跳过通用 Unicode 处理。前提不成立时,同一表达式会回退到通用字符串路径。这个 fast path 由函数声明的 trait 和向量层的数据属性共同决定,而不是逐行猜测。

编码同样会影响循环形态。Flat 和 Constant 的 primitive 输入可以在进入循环前解码为特化 reader。对于稠密 batch,框架可以暴露连续的 buffer 和结构规整的循环,使 GCC 或 Clang 能够进行自动向量化。对每次函数的 batch 求值,框架会在进入内层循环前选择函数和编码路径,避免在逐行处理中重复相同的分派。

其他路径同样保持向量化,只是不一定表现为稠密的基础类型循环。稀疏选择只访问被选中的行下标;点、边和map 等图值或容器类型则通过类型化的向量访问接口按 batch 处理。语义要求更严格的表达式会保留必要的检查和执行顺序,同时仍保持面向 batch 的执行。

所以,并不存在唯一一种“向量化循环”。框架会从一组有限路径中作出选择:稠密或稀疏、null-free 或 nullable、ASCII 或通用 Unicode、primitive 或复杂类型、flat 或间接编码。单个函数的实现仍然精简,编译器看到的热点路径也依然规整。

Velox 的 simple-function 框架是这种设计方式的重要参考:通过编译期 trait 萃取函数特征,再在执行期选择向量路径(Velox scalar functions)。我们采用了相同的整体思路,但接入的是 NebulaGraph 自己的类型系统、图值、空值语义和表达式生命周期。

向量模型之上的多层优化

向量模型在不同层次创造了多种优化机会:

这些机制会相互增强。batch、列式布局、选择向量和编码感知的 fast path 让执行过程更加规整;在稠密的基础类型循环上,这种规整性也让编译器更容易生成 SIMD 指令。这些指令能否改善端到端性能,仍然取决于转换成本、代码体积,以及工作负载的实际耗时分布。

因此,我们通常先让数据和循环变得规整,再交给编译器优化,随后检查生成的机器码,并测量完整工作负载。只有当一个稳定而重要的 kernel 仍然存在可测量的编译器优化缺口,并且目标硬件上的端到端数据足以证明维护成本和可移植性代价值得承担时,我们才会考虑手写 SIMD.

第三部分:把向量模型延伸到整个系统

贯穿数据链路的向量化

如果每经过一个进程边界都把 batch 还原成行,格式转换和数据复制就可能抵消部分列式执行收益。因此,无论在数据库内部还是面向客户端的边界上,我们都继续以 vector table 作为通信单元;一个 vector table 可以汇集一个或多个 vector batch. 两条路径共享同一种列式模型,但在兼容性和演进方式上有不同要求。

在 storaged 与 graphd 之间,NebulaGraph 使用自研 RPC 框架 NRPC. 它的向量传输可以接管执行层产生的列式 buffer 所有权,在执行 buffer 进入 RPC 路径的这一边界实现零拷贝交接。

从 graphd 到各类客户端,接口采用面向向量的列式传输协议。schema 信息和列 buffer 以 table 的形式传递,而不是组织成通用值构成的行数组;SDK 也会直接反序列化这份列式表示。其动机与Arrow ADBC类似:让数据库结果跨越客户端边界时继续保持列式,避免不必要的“列转行再转列”。

同一种列式模型跨越两类边界:内部传输在执行层与NRPC 的交接处避免完整复制 payload,客户端协议则继续保留结果的列式结构。

两类边界都保留了结果的列式结构,区别在于它们的演进约束:内部路径可以采用适合Join、Expand 或因式分解的编码,而公开客户端协议必须维护一组稳定的表示及图 schema 元数据。端到端向量化指的是始终保留列式工作单元,而不是要求每一层共享同一块内存或同一种 wire format.

NebulaGraph Analytics 中的向量化控制流

同一套执行模型也被应用到了数据库查询引擎之外。在NebulaGraph Analytics 中,负责扫描图数据并进行计算的执行内核,不仅使用选择向量来实现表达式向量化,也用它来实现过程式控制流的向量化,包括IF/ELSEIF/ELSE分支、WHILE循环,以及按行生效的BREAK和CONTINUE。

标量解释器关心的是:对一个元素,下一步应该执行哪条指令。向量化引擎关心的则是:当前batch 中,哪些行应该参与下一条指令。选择向量承载的就是这个答案。

一组IF/ELSEIF/ELSE会把当前选择向量划分成各分支对应的选择向量;未匹配的行继续流向下一个条件,最后进入ELSE分支。WHILE会不断更新仍需留在循环中的行集合。CONTINUE把选中的行带到下一轮迭代,BREAK则把它们移入循环结束后的选择向量。各分支内部的表达式接收的仍然是向量,并以batch 为单位求值。

这张图表达的是核心思路:同一个batch 在不断变化的行选择中向前流动,而不是让每一行分别解释和执行同一套控制流。

这样,过程式控制流与表达式求值就能够使用同一种行选择机制:控制流语句更新参与执行的行集合,表达式框架继续接收向量输入,并对选中的行按batch 求值。执行内核仍需维护作用域和语言语义,但不必为每一行分别解释整套控制流。

向量模型如何串起整个系统

把这些部分放在一起看,它们并不是向量化在不同位置的孤立应用,而是建立在同一套模型之上:类型化batch、显式行选择,以及在下游真正需要之前避免数据被过早展开或转换的编码方式。这套模型贯穿图数据的表示、物理算子、表达式求值、数据传输和 Analytics 执行内核。

共享的向量模型连接了图数据表示、数据库执行、表达式求值、列式传输和Analytics 内核,各层围绕同一基础进行特化。

从向量模型出发,逐步延伸到执行、传输和图计算的过程,也带来了几条更具普遍性的工程经验。

第四部分:工程启示与仍待探索的设计空间

1.先确定工作单元,再优化工作本身

当算子、表达式和传输层共享同一种类型化 batch 模型时,各层优化更容易相互叠加;逐行处理的边界则会引入转换成本,打断这种传递。

2.数据表示本身就是查询执行的一部分

数据表示决定了下游算子需要完成多少工作。图向量保留了类型化结构,因式分解则避免过早生成冗余行。

3.特化物理执行,但不改变逻辑基数

Expand 和 FTree 可以在物理执行中利用邻接关系并共享路径前缀,但不能合并逻辑上不同的匹配结果。下游结果仍需保持 GQL 计划要求的重复、空值行为、路径规则、错误和基数。

4.传输边界也是执行设计的一部分

内部 RPC 和公开客户端协议面对不同的兼容性约束,但二者都会保留列式数据。内部交接可以复用已有 buffer 的所有权,公开协议则需要稳定的跨语言表示。

5.向量元数据并非没有成本

我们在生产环境中遇到过另一种内存成本结构:在超宽表上进行点查时,返回的行数很少,列数却很多,内存中的向量元数据可能接近实际数据 payload 的大小。为降低这部分开销,我们共享不可变的类型描述,并避免在向量和 batch 之间重复保存 schema.

6.保持基线的可移植性,让优化渐进发生

我们以可移植、便于调试和剖析、由编译器优化的 C++ 实现作为基线。更广泛地使用因式分解结果、有针对性地引入 JIT,以及选择性使用 intrinsic,都是建立在向量模型上的渐进扩展,只有工作负载足以证明额外复杂度值得承担时才会采用。

结语

在 NebulaGraph 中,我们把向量化作为一项系统级设计,而不只是表达式求值的局部优化。以向量模型为基础,我们重新设计了图数据及其类型信息的内存表示、算子的驱动方式、图扩展对重复数据的处理方式、过程式控制流在 batch 上的执行方式,以及结果跨越进程和客户端边界的方式。

我们的核心体会是,共享的向量模型让数据流和控制流在不同子系统之间保持规整,也让 CPU、编译器及周边系统能够围绕同一套结构协同优化。

参考资料

1.Timo Kersten et al.,Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask, PVLDB 11(13), 2018.

2.Apache Arrow,Arrow Columnar Format Overview.

3.Apache Arrow,Arrow Database Connectivity (ADBC).

4.Dan Olteanu and Jakub Závodný,Size Bounds for Factorised Representations of Query Results, ACM TODS 40(1), 2015.

5.Pedro Pedreira et al.,Velox: Meta’s Unified Execution Engine, PVLDB 15(12), 2022.

6.Velox documentation,Scalar Functions andWhat’s in the Task?.

7.Mark Raasveldt and Hannes Mühleisen,DuckDB: an Embeddable Analytical Database, SIGMOD 2019.

感谢你看到这里~如果对 NebulaGraph 和 NebulaGraph Analystic 感兴趣,可通过下方问卷,获得 1v1 企业版支持。也欢迎通过下方二维码加群,与我们的用户进行深度交流~
联系我们 - 腾讯问卷