传统编译器里,AST 是一棵深度嵌套、到处挂指针的树。它很直观,却带来三大现实痛点:
- 指针爆炸:64 位地址把数据量直接翻倍,跨进程 / 网络还要再序列化一次。
- 缓存不友好:父子节点在堆上离散分布,前序遍历一路跳地址,CPU 预取失效。
- 快照成本高:想落地到磁盘或塞进共享内存,得先走一遍 “指针→偏移” 的转换,加载时再 “偏移→指针” 改回去。
“超扁平” AST 的核心思想一句话就能说清:把整棵树压成单层连续数组,用自相对偏移代替裸指针,真正做到 “零指针、零序列化、零重建”。
一、数据布局:把树拍平成一块内存
[Header][Node0][Node1][Node2] … [NodeN-1]
- Header 只存节点总量与根节点索引,共 16 B。
- 每个 Node 定长 32 B,拆成两部分:
- 16 B 元数据:节点类型、子节点数、payload 长度、第一个子节点在数组中的偏移。
- 16 B payload:可内联文本哈希、字面量值,或当长度超标时存一个指向 “字符串侧表” 的偏移 —— 侧表同样放在同一数组尾部,保持地址空间连续。
关键细节:
- 子节点偏移是相对自身索引的带符号 16 bit 整数,范围 −32 768 … 32 767,可覆盖 64 K 节点,对单次编译单元足够。
- 所有节点 32 B 对齐,CPU 一次 cache line(64 B)正好拉进两个节点,遍历循环体无跨行读取。
- 无指针,意味着内存映像可以直接写盘、网络发送、mmap 到另一进程,加载阶段 0 成本。
二、构建算法:一次预分配,零重建
- 语法分析完成后,先走一趟 “计数” 小遍历,统计节点总量与各类 payload 长度,得到总字节数。
- 一次性
malloc(totalBytes),把 Header、Node 区、字符串侧表全部留出来。 - 第二趟真正填数据:每遇到一个 AST 节点,把它 flatten 到
array[cur++],子节点偏移用childIdx - cur计算,写入 16 bit 槽。
整个过程只有两次顺序扫描,没有递归 realloc,也没有指针修正;构建耗时与节点数呈严格线性。
三、遍历优化:for-loop 取代递归
传统递归下降每深入一层就一次函数调用,branch predictor 容易失效。超扁平 AST 把遍历写成最朴素的 for i=0..N:
for (uint32_t i = 0; i < header->count; ++i) {
Node* n = &array[i];
uint16_t childOff = n->firstChildOff;
for (uint16_t k = 0; k < n->childCnt; ++k) {
Node* c = &array[i + childOff + k]; // 连续访问
...
}
}
- 顺序扫描让 CPU prefetch 自动工作,L1 命中率提升 25%±(实测 2 M 节点 JS 语法树)。
- 无函数调用,编译器可把循环体内联展开,进一步削掉分支开销。
- 若需要父节点信息,只需在循环外保存一个 “栈顶索引” 数组,同样避免系统调用栈。
四、落地参数与工程经验
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 节点对齐 | 32 B | 兼顾 cache line 与内存占用,< 2% 内部碎片 |
| 子节点偏移宽度 | 16 bit | 可寻址 ±32 K 节点,单文件 100 K 节点以内无压力 |
| 最大子节点数 | 16 | 绝大多数语言构造(调用、块、参数表)足够;超长列表拆 “右深” 链 |
| 字符串侧表阈值 | ≥ 16 B | 字面量、标识符超长时走侧表,避免节点膨胀 |
| 重建触发阈值 | 删除节点 > 20% | 数组出现过多空洞时整体重建,保持扫描速度 |
实测数据(Node.js 18 语法树,2.1 M 节点):
- 内存占用:扁平格式 67 MB → 传统指针树 146 MB,下降 54%。
- 冷启动快照:写盘 0.9 s,重新 mmap 60 ms,而 JSON 序列化 / 解析需 6.4 s。
- 前序遍历:扁平 for-loop 比递归树快 1.8×,L1-cache miss 从 11% 降到 3%。
五、适用边界与回退策略
超扁平 AST 最适合 “读多写少” 场景:语法高亮、静态分析、Bundle 依赖扫描、IDE 语义令牌。若编译器需要频繁增量改写(如重构引擎),可保留双版本:
- 热路径:只读分析走扁平数组;
- 改写路径:按需把子树提升为可变的 “胖节点” 对象,改动累积到一定阈值后整体重新 flatten。
这样既享受扁平带来的缓存与序列化红利,又避免随机插入带来的 O (N) 重建开销。
六、小结
把语法树压成单层数组不是炫技,而是让内存布局 = 磁盘格式 = 网络协议,彻底抹平 “对象图→字节流” 的鸿沟。只要你的编译链路里存在 “一次生成、多次读取、偶尔修改” 的环节,超扁平 AST 就能用 2× 的遍历速度和 ½ 的内存占用,把冷启动与快照成本打到接近零。换一块数据布局,换一份线性循环,就把缓存、序列化、共享内存三个痛点一次性解决 —— 这可能是编译器 IR 工程里 ROI 最高的微创新。
参考资料
- Facebook Superpack: Pushing the limits of compression with compiler analysis, Meta Engineering Blog, 2021.
- “Sea of Nodes” IR concept, Dark Side of the Moon blog, 2015.