Hotdry.
compiler-design

超扁平AST:把语法树压成单层数组,实现零指针序列化与缓存友好遍历

用单层数组+偏移编码替代传统指针树,彻底消除序列化开销并提升遍历缓存命中率,给出可直接落地的对齐、子节点上限与重建阈值参数。

传统编译器里,AST 是一棵深度嵌套、到处挂指针的树。它很直观,却带来三大现实痛点:

  1. 指针爆炸:64 位地址把数据量直接翻倍,跨进程 / 网络还要再序列化一次。
  2. 缓存不友好:父子节点在堆上离散分布,前序遍历一路跳地址,CPU 预取失效。
  3. 快照成本高:想落地到磁盘或塞进共享内存,得先走一遍 “指针→偏移” 的转换,加载时再 “偏移→指针” 改回去。

“超扁平” 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 成本

二、构建算法:一次预分配,零重建

  1. 语法分析完成后,先走一趟 “计数” 小遍历,统计节点总量与各类 payload 长度,得到总字节数。
  2. 一次性 malloc(totalBytes),把 Header、Node 区、字符串侧表全部留出来。
  3. 第二趟真正填数据:每遇到一个 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 最高的微创新。


参考资料

  1. Facebook Superpack: Pushing the limits of compression with compiler analysis, Meta Engineering Blog, 2021.
  2. “Sea of Nodes” IR concept, Dark Side of the Moon blog, 2015.
查看归档