数据导向设计的解析器-Yuku 博客阅读
I read a blog Data Oriented Design This is about make parser faster by using data oriented design.
Yuku,一个用 Zig 编写的 JavaScript/TypeScript 解析器和编译工具链。作者强调,它的语法分析算法并不特殊,真正的性能来源是 AST、Token、字符串和跨语言数据的内存表示。
数据导向设计
- 首先需要靠考虑数据是如何被访问的
- 再思考如何组织数据,使得访问数据更加高效
- 最后让算法适应这种数据设计,并让算法更加高效
假设解析:
const x = a + b * c;
解析器最终会产生一棵 AST:
VariableDeclaration└── BinaryExpression(+) ├── Identifier(a) └── BinaryExpression(*) ├── Identifier(b) └── Identifier(c)教科书式实现,通常会写成类似:
BinaryExpression { left: *Node, right: *Node, op: Operator}每创建一个节点,就在堆上申请一块内存:
heap allocation 1 → Identifier(a)heap allocation 2 → Identifier(b)heap allocation 3 → Identifier(c)heap allocation 4 → BinaryExpression(*)heap allocation 5 → BinaryExpression(+)问题不在于 + 和 * 怎么解析,而在于这些节点可能散落在内存各处:
地址 0x1000: Identifier(a)地址 0x9280: BinaryExpression(+)地址 0x3700: Identifier(b)地址 0xE910: BinaryExpression(*)所以对AST进行遍历的时候,需要不断的取出指针然后跳转过去,这个过程浪费了大量时间。
原文翻译:
缓存未命中而访问主存的加载操作耗时约 100 纳秒,而算术运算仅需不到一纳秒。一个在散落堆对象间追踪指针的程序,其大部分时间都停滞在加载操作上。
此外还需要进行大量的堆分配,通用分配器的调用耗时数十至数百纳秒,并将相关对象分散到地址空间中。一个为每个节点进行内存分配的解析器,处理每个文件时需执行数万次此类调用。
核心改进:用数组下标代替指针
Yuku 并没有让 AST 节点直接保存指针,而是改成保存一个 u32 下标。这样一来,节点之间不再通过内存地址互相引用,而是通过数组中的位置来建立关系。
pub const NodeIndex = enum(u32) { null = std.math.maxInt(u32), _,};所有节点都会被顺序放进一个连续数组里:
nodes[0] = Identifier(a)nodes[1] = Identifier(b)nodes[2] = Identifier(c)nodes[3] = BinaryExpression(*, left=1, right=2)nodes[4] = BinaryExpression(+, left=0, right=3)此时整棵 AST 的根节点就是:
root = 4如果我们再看节点 4,它的孩子也不再是某个分散的内存地址,而是数组下标:
left = 0right = 3也就是说,AST 在逻辑上仍然是一棵树,但在物理结构上已经变成了一段连续的数组。它的表达方式变了,结构却没有变;变的是存储方式,不变的是语义关系。
逻辑结构:Tree物理结构:Array<Node>这种做法的好处非常直接。首先,节点连续存放在内存中,遍历时更容易命中 CPU cache,减少频繁的指针跳转。其次,节点之间只保存一个整数下标,结构更紧凑,也更方便批量管理。对于解析器这种“创建很多节点、再反复遍历节点”的场景来说,这种布局通常会比传统的指针树更高效。
用数组下标代替指针的优势
- 更容易命中 CPU cache, 因为是连续的数据
- 在64位机器上,指针通常是8字节,而u32只有4字节
- 指针指向的是绝对地址
- 所有节点由一个Arena进行管理,可以一起释放
Struct Of Arrays
Yuku 的一个 AST 节点大致包含:
pub const Node = struct { data: NodeData, span: Span,};其中:data 是节点类型和实际内容,span 是节点在源代码中的起止位置。
如果用最常见的“对象数组”方式来存,它看起来大概是这样:
[data0, span0][data1, span1][data2, span2][data3, span3]但 Yuku 不是把每个节点的字段混在一起存,而是使用一种叫 Struct of Arrays,也就是 SoA 的布局,把同一种字段拆成多个数组:
data = [data0, data1, data2, data3]span = [span0, span1, span2, span3]这样做的好处很明显。如果某一次分析只关心节点类型,那它只需要读取 data 数组,不必顺手把 span 也一起加载进来。反过来,如果只想看位置信息,也可以只访问 span。
和前面的“节点下标代替指针”一样,这里优化的重点不是语法层面,而是数据的排列方式。数据越集中、越规整,CPU 访问起来就越省力。
另外,这种布局还有一个很实用的优点:追加新节点时,只需要在对应数组末尾增加一项,并更新长度计数。不需要为每个节点单独分配一块对象,也不需要在内存里到处找位置。
可变长度子节点
前面讲的二元表达式很简单,因为它固定只有两个孩子,直接把左右孩子的下标放进节点里就行了。但并不是所有语法结构都这么规整。比如代码块 block、参数列表、数组元素列表,这些东西的长度往往是不固定的,可能是 0 个,也可能是很多个。
如果给每一种节点都预留“最多可能出现的孩子数量”,那会很浪费空间。大多数节点根本用不到那么多位置,但这些空位还是要一起存进去。Yuku 的做法是:把这类可变长度的数据单独放到一块共享数组里,然后节点自己只保存一个“范围描述”。
这个范围描述长这样:
IndexRange = { start, len }start表示这串数据在共享数组里的起点len表示这一段一共有多少个元素
也就是说,节点只存自己的children在数组里的位置。真正的children列表,统一放在 tree.extras 里:
/// Returns the extra node indices for the given range.pub inline fn extra(self: *const Tree, range: IndexRange) []const NodeIndex { return self.extras.items[range.start..][0..range.len];}这其实就是一个很常见的技巧:用“起点 + 长度”来表示一段连续数据。很多语言里的字符串切片、数组切片,本质上都是类似的思路。
比如一个 block 里有 3 个语句,那么它在 extras 里的存法可能像这样:
extras[0] = statement Aextras[1] = statement Bextras[2] = statement C而这个 block 节点自己只需要记住:
start = 0len = 3这样它就能准确找到自己的那 3 个子节点,而不用把它们一个个塞进节点结构里。
解析器为什么不能直接边读边开可变列表
这里还有一个很关键的问题:解析器在读到左大括号 { 的时候,并不知道这个 block 最后会有多少条语句。只有等它读到右大括号 },它才知道列表到底有多长。
如果这时候给每个 block 单独创建一个会自动扩容的列表,就会产生很多额外的分配和回收开销。Yuku 选择的是更轻量的办法:先借用一个解析器自己的临时缓冲区,把语句先暂存在里面,等 block 结束后,再一次性拷贝到最终数组。
这个临时缓冲区可以理解成“栈式工作区”:进入一个 block 时记下当前长度,离开时恢复到这个长度。这样就能自然地支持嵌套 block。
伪代码可以这样理解:
ScratchBuffer: items = empty list
begin(): return current length of items
reset(checkpoint): shrink items back to checkpoint大致的流程可以理解成这样:
parseBody(terminator): checkpoint = remember current position in scratch buffer when function ends, restore scratch buffer to checkpoint
while not reach end of body: statement = parse one statement append statement to scratch buffer
copy all statements in scratch buffer into tree.extras at once return the range of that batch in tree.extras- 先记住当前缓冲区的位置
- 继续解析 block 里的语句,并临时放进缓冲区
- 直到遇到结束符
}才停下来 - 把这一段语句一次性复制到最终的共享数组里
- 然后把临时缓冲区恢复到原来的状态,留给外层结构继续使用
这样做的好处是,临时缓冲区可以在递归解析时反复复用。比如一个 block 里面还套着另一个 block,内层会临时占用外层上方的那一段空间,解析完成后再回退,不会互相干扰。于是整个解析过程中,真正的扩容次数很少,而每次追加子节点只是一次边界检查加一次写入。
作者提到,Yuku 为了应对不同场景,一共保留了 5 个这样的缓冲区,分别给不同类型的列表使用,比如语句、覆盖语法、装饰器,以及两个通用缓冲区。这样既避免了频繁分配,也避免了多个地方同时抢同一个临时数组。
同一个思路还能复用到别的地方
这种“起点 + 长度”的表示法不只用于子节点列表。作者还把它用在了注释和节点的关联上:每个节点对应一个前缀和数组,通过相邻两个偏移量就能知道这个节点有哪些注释。
你可以把它理解为:
- 第
i个节点的注释,不是单独挂一份链表 - 而是存在一个大数组里
- 节点只记录自己对应的那一段范围
这样,整个 AST 里很多“数量不固定”的信息,最后都被统一成了一个很朴素的形式:连续数组 + 范围描述。
字符串不复制,只保存源代码偏移量
到这里,思路其实已经很清楚了:Yuku 一直在做的事,就是尽量不要把“原本就已经存在的数据”再复制一遍。字符串也是一样。
解析器在处理代码时,会不断遇到各种字符串相关的信息,比如变量名、函数名、属性名、字符串字面量,甚至模块路径。普通做法通常是:读到一个标识符之后,给它单独分配一块内存,再把字符拷贝进去。这样虽然直观,但很多时候其实有点多余,因为这些字符串本来就已经写在源代码里了。
比如下面这段代码里,foobar 早就存在于源文本中:
const foobar = 123; ^^^^^^既然源代码里已经有这几个字节了,为什么还要再复制一份呢?Yuku 的做法更简单:它不把字符串内容本身存起来,而是只记录它在源代码中的位置。也就是说,字符串只需要两个偏移量:开始位置和结束位置。
String = { start, end }比如:
source = "const foobar = 123;"start = 6end = 12当需要真正取出名字时,直接从源代码里切一段出来就行了:
source[6..12] = "foobar"这就是所谓的 zero-copy string,也就是零复制字符串。它的意思不是“完全没有字符串”,而是“只保存引用,不重复存内容”。对大多数普通标识符来说,这个办法几乎是免费的,因为它根本不需要额外分配,也不需要把字符复制进另一块内存。
只有少数特殊情况才需要额外处理
当然,这种方法不是对所有字符串都适用。有些字符串虽然在源码里长得像字符序列,但真正的值并不是源文本本身,而是经过了解码之后的结果。
比如:
const \u0061 = 1;源码里看到的是 \u0061,但真正的标识符名字其实是 a。再比如:
"hello\nworld"源码中的 \n 是两个字符,可是解码之后,它表示的是一个换行符。也就是说,这些字符串如果只是简单地引用源码,就拿不到正确的最终值。
Yuku 的处理方式是:普通字符串直接引用源代码,只有这类带转义的少数情况,才真正做解码,并把结果放进一个额外的字符串池里。如果这种解码后的字符串后面还会重复出现,系统还可以顺便做一次去重,也就是 intern,让同样的内容只保存一份。
这样一来,最常见的情况几乎零成本,只有罕见的异常情况才需要付出解码和额外存储的代价。这个设计很符合数据导向设计的思路:先照顾最频繁发生的路径,把普通情况做得尽可能轻;再把少数特殊情况单独拎出来处理。
什么时候才真正去解码
还有一个很重要的点是,Yuku 并不会一开始就把所有字符串都解码好。它甚至不会在词法分析阶段就急着处理这些内容,而只是先记下一个跨度,并在看到反斜杠时打一个标记。
也就是说,lexer 的工作很克制:它不提前做多余的事情,只负责记录“这段字符串在哪儿”“它是不是可能需要解码”。真正需要字符串名字的时候,才去决定是直接取源代码切片,还是把它解码后放进池子里。
可以把这个过程理解成下面这样:
if token is escaped: decode it and store in extra poolelse: return the source slice directly这个设计的核心不是“技巧多复杂”,而是“把最常见的路径做得最便宜”。大部分标识符都能直接引用源代码,只有少数带转义的字符串才需要额外成本。对于解析器来说,这种差别会非常实在,因为它每天都在和大量字符串打交道,省下来的每一次复制,最后都会积累成明显的性能收益。
Unicode 标识符
JavaScript 的标识符不仅仅只有ASCII字符,还有Unicode字符。 比如一些特殊的字符,π等
Unicode 标准给每个字符定义了属性:
- ID_Start:哪些字符可以作为标识符的第一个字符(比如字母、汉字、下划线)
- ID_Continue:哪些字符可以作为标识符的后续字符(比 ID_Start 更宽松,比如数字也可以)
要判断一个 Unicode 字符能否作为标识符开头,最直接的方法是: 做一个巨大的位图(bitset),每个代码点占 1 位,代码点空间有 0x10FFFF + 1 ≈ 111 万 个位置, 两个属性(ID_Start + ID_Continue)大约需要 512 KB 左右的空间
如此大的表格无法常驻缓存,而词法分析器需要频繁查询它。
解决方案就是使用两层表结构
把 Unicode 空间按 512 个 code point 分块:
chunk 0: 0~511chunk 1: 512~1023chunk 2: 1024~1535...inline fn queryBitTable(cp: u32, comptime root: []const u8, comptime leaf: []const u64) bool { const chunk_idx = cp / 512; // which 512-codepoint chunk const leaf_base = @as(u32, root[chunk_idx]) * 16; // where its pattern lives const offset_in_chunk = cp % 512; const word = leaf[leaf_base + offset_in_chunk / 32]; const bit: u5 = @truncate(offset_in_chunk % 32); return (word >> bit) & 1 == 1;}手动执行一次 π (U+03C0,码点 960) 摘自原文伪代码:
cp = 960chunk_idx = 960 / 512 = 1 codepoints 512..1023root[1] = 1 this chunk uses leaf pattern #1leaf_base = 1 * 16 = 16 pattern #1 starts at word 16offset = 960 % 512 = 448word = leaf[16 + 448/32] = leaf[30]bit = 448 % 32 = 0(leaf[30] >> 0) & 1 = 1 π may start an identifier文章称,两个 Unicode 属性合计可从约 512 KB 压缩到约 29 KB。生成程序在构建阶段读取 Unicode 数据库并完成分块、去重,运行时只面对固定数组。
大多数代码文件主要是 ASCII,所以扫描标识符时先使用 256 项小表:
while (pos < src.len and ascii_table[src[pos]]) { pos += 1;}只有看到高位为 1 的字节: byte >= 0x80
才进入 UTF-8 解码和 Unicode 表查询。
Tree本身就可以被JS使用
Yuku 的原生解析器用 Zig 编写,但是消费端却是JavaScript,原生解析器运行后,Node 需要将 AST 转换为普通对象。 传统的解析器采用的方法为:
Zig/Rust AST ↓ JSON.stringify / 原生序列化JSON 字符串 ↓ 跨 N-API 边界JavaScript ↓ JSON.parseJS Object AST原生解析可能很快,但生成 JSON、复制字符串和 JSON.parse 可能比真正解析代码花费的时间还多。
另一种方式是不经过JSON,直接从native code种调用Node的N-API。 伪代码类似:
createObject()setProperty(node, "type", createString("BinaryExpression"))setProperty(node, "operator", createString("+"))setProperty(node, "left", createObject())setProperty(node, "right", createObject())...虽然看起来省略掉了JSON,但是这种设计往往更加糟糕。因为每一个AST节点就需要调用JS引擎的API,分配一个V8对象,设置属性,还有处理引用和垃圾回收问题,这个在native和V8之间的边界反复切换,会带来很多额外的开销。
Yuku之所以可以做到不使用传统的序列化方法,是因为Yuku本身使用了一个扁平化的表示方式。 子节点不是指针,而是一个u32下标,只要nodes整体数组的布局没有变化,复制到任何位置之后,都可以正常工作。
列表是extras数组中的范围,仍然没有指针,只是指向了extras数组中的一个片段,也就是只有offset和length。
字符串只是源码的一个范围,也就是源代码的一个slice,使用start和end就可以获得字符串的内容。所以不用再重新创建一份字符串,也不需要进行反序列化之类的操作。
因此树中的东西都是与内存位置无关的。
假设 AST 当前位于 Zig 内存地址:
0x10000000
复制到 JS 后位于:
0x90000000
如果 AST 内部存的是绝对指针,复制后就坏了:
left = 0x10001230
但是如果保存的是:
left_node_index = 42,
复制到哪里都可以,42永远都指向数组中的低42项
这意味着序列化并非转换过程,而是一次复制操作。
Yuku 将所有内容打包到一个缓冲区中,并以 ArrayBuffer 的形式返回给 JavaScript: ┌─────────────┬─────────────────┬──────────────┬─────────────┬──────────┬─────────────┐ │ Header │ Nodes │ Extras │ String Pool │ Comments │ Diagnostics │ │ 元数据 │ 固定 48B/节点 │ u32 数组 │ 原始字节 │ 注释数据 │ 错误/警告数据│ └─────────────┴─────────────────┴──────────────┴─────────────┴──────────┴─────────────┘
Token Bits 设计
一个Token包括span,tag以及flags。
span是token在源代码中的位置(包括start和end),tag是token的类型,比如是关键字,数字,标识符等等;flags是一个额外标志位,使用u8类型。
解析器对每个词元都会提出相同的问题:它的优先级是什么,是否为二元运算符,是否为关键字。答案并非通过分支或查找表给出,而是在声明时直接编码在标签的整数值中:
pub const Mask = struct { pub const IsBinaryOp: u32 = 1 << 14; pub const IsUnaryOp: u32 = 1 << 16; pub const IsIdentifierLike: u32 = 1 << 18; pub const IsKeyword: u32 = 1 << 21; pub const PrecShift: u32 = 8; // bits 8..12 hold precedence};例子:TokenTag 的定义
pub const TokenTag = enum(u32) { // low 8 bits: ordinal. the rest: precomputed answers. plus = 15 | (11 << Mask.PrecShift) | Mask.IsBinaryOp | Mask.IsUnaryOp, star = 17 | (12 << Mask.PrecShift) | Mask.IsBinaryOp, in = 119 | (9 << Mask.PrecShift) | Mask.IsBinaryOp | Mask.IsKeyword | Mask.IsIdentifierLike | Mask.IsUnconditionallyReserved, // ...};这三行分别定义了 +、*、in 这几个 token 的整数值。
一个一个来看:
plus = 15 | (11 << Mask.PrecShift) | Mask.IsBinaryOp | Mask.IsUnaryOp,
表示token编号为15,优先级是11,是二元运算符也是一元运算符
star = 17 | (12 << Mask.PrecShift) | Mask.IsBinaryOp,
表示token编号为17,优先级是12,是二元运算符
in = 119 | (9 << Mask.PrecShift) | Mask.IsBinaryOp | Mask.IsKeyword| Mask.IsIdentifierLike | Mask.IsUnconditionallyReserved,
表示token编号为119,优先级是9,是二元运算符,是关键字,是标识符,而且是保留字
由于parser在解析表达式的时候,需要反复的查询这个token的优先级,是不是关键字,是不是标识符,是不是二元运算符等等这些。如果不使用这种编码方式,就需要使用其他方法:
- switch/if 的分支判断方法,每一个都要进行判断,编译器可能不能将其优化为较快的形式,分支预测也有可能失败
- 查表,使用一个数组或者map来存储信息,但是需要使用额外的内存,内存访问可能慢,尤其如果没命中 cache
使用将信息编码进Token对bit位里面的方法,实现了每次查询仅对已在寄存器中的值进行一次移位和掩码操作。无需查表、无需分支、无需加载。 位运算对于cpu来说是一个很cheap的操作