免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

编译器IR分层与内容寻址缓存:从AST到TIR的设计哲学

编译器IR分层与内容寻址缓存:从AST到TIR的设计哲学 如果你自己动手写过编译器或者只是好奇地打开过 Rust、Swift 这类现代编译器的源码工程你一定会被它们内部一层又一层的 IR 搞晕。Parser 先吐出一棵 AST然后编译器内部又悄悄把它变成 HIR再往下还有 TIRRust 里叫 MIR最后才落到 LLVM IR再折腾成机器码。新手的第一反应基本都是这不多此一举吗AST 不已经代表整个程序了吗直接把 AST 翻译成机器码不就行了另一个让不少搞构建系统的同学很头疼的问题是CI 里明明配了编译缓存可为什么换台机器、换个工作目录、甚至只是 checkout 时间变了缓存就疯狂失效这背后牵扯的其实是“缓存用什么当 Key”的设计问题——是按文件路径和修改时间还是按内容本身寻址。这两个问题一个在编译器前端一个在构建系统表面看毫不相干但本质是同一个母题一个语义多且杂的系统如何用清晰的中间表示和可靠的寻址方式组织起来才能既保证正确性又保证效率和可维护性。这篇博文就把这两件事完全讲透IR 为什么非要分 AST、HIR、TIR 三层以及缓存为什么一定要按内容寻址。适合正在写编译器、研究编译器源码或者维护大型构建系统的同学参考我会尽量用大白话把设计动机拆开同时给出可以直接落地的实践细节。1. 三层 IR 到底分什么语法、语义、实现三件事不能混1.1 AST 只负责“源码长什么样”不负责“源码在干什么”ASTAbstract Syntax Tree抽象语法树是 parser 的产物。它的目标是忠实还原源码的语法结构每个节点对应语言文法里的一条产生式。一个if是一个 IfExpr一个for是一个 ForLoop一个加法运算是一个 BinaryOp括号、分号这类纯书写成分会被丢掉但语法意义上的结构分毫不少。问题也出在这AST 太贴着源码了。它表达的是“代码看起来是什么样子”而不是“代码实际上在做什么”。举个很简单的例子for i in 0..10 { a[i] 2; }在 AST 里这就是一棵“循环树”嵌套了条件判断、索引表达式、赋值操作。如果我想直接从这棵树生成机器码我就要为for单独写一套完整的指令选择规则为..区间语法写一套生成规则再为写一套复合赋值规则。这还只是三四个语法结构如果把闭包、模式匹配、async/await、泛型、解构赋值这种复杂语法全算上前端要向指令选择层暴露的映射规则会膨胀到根本无法维护。更麻烦的是语义信息的缺失。AST 上通常没有“这个变量的类型是什么”“这行代码属于哪个作用域”“这个表达式是左值还是右值”“这个值移动了几次”这类关键语义。你想做类型检查、所有权检查、生命周期分析AST 给不了足够的支撑。它天生是给语法层面的工具用的——格式化、高亮、补全、重构、宏展开这些场景需要的是“结构守恒”而不是“语义简化”。1.2 HIR 脱糖又带类型让“代码在做什么”显形HIRHigh-level Intermediate Representation高级中间表示要解决的问题就是补上 AST 缺的语义把源码从“语法视角”翻译成“语义视角”。这一步的核心动作是脱糖desugaring。什么叫脱糖就是把语言里那些为了方便程序员书写而引入的高级语法拆解成一组更小、更基础的语言原语。常见的脱糖包括for i in 0..10变成基于loop、break、continue和迭代器协议的结构match/switch模式匹配变成一层层条件判断和绑定a b变成a a b闭包变成“结构体 捕获字段 统一的调用方法”async/await变成显式的状态机结构?操作符变成展开的match加提前返回。脱糖之后HIR 已经看不出原始语言的“糖”了但它的好处是语义种类大幅减少后续所有分析只需要面对几十种基础节点而不是面对几百种语法结构。同一时间HIR 会做类型化处理给每个表达式和变量关联上类型信息建立作用域链、符号表甚至记录所有权和生命周期约束。为什么这些工作在 HIR 做而不是 AST 做因为 AST 的节点里塞满了语法糖如果你在 AST 上直接做类型检查你不但要理解for、match、闭包各自的语义规则还要处理它们之间的组合情况而脱糖后的 HIR类型系统只需要处理几十种核心表达式规则简单组合爆炸的问题也缓解了。Rust 编译器就是在这里完成借用检查borrow check的因为 HIR 已经能清晰表达变量的借用区间和数据流关系。1.3 TIR 把控制流和数据流彻底摆到台面上TIRlow-level / target intermediate representation面向目标的低级中间表示是离机器最近的一层。它和 HIR 最大的区别在于表达方式HIR 还是树形结构保留了if、loop这种结构化控制流而 TIR 一定要显式地把控制流摊开成“基本块 跳转”的图结构CFGControl Flow Graph把数据流摊开成“虚拟寄存器/临时变量”的显式读写。为什么要这样因为机器指令没有嵌套结构只有顺序执行和跳转。你脑子里的“循环”在 CPU 看来就是一条条件跳转指令。让优化器理解“一个循环”和让优化器理解“一张控制流图里的环形结构”是两种不同难度的事。TIR 把一切结构化控制语句全部降级为 label、branch、jump后续的数据流分析、活跃变量分析、寄存器分配全部变成图算法后端实现就干净了。TIR 还要做一件很实际的事情把高层的类型系统映射到具体的内存布局。比如一个VecT在 HIR 里是一个高级容器类型到了 TIR它必须显式表达成“数据指针 长度 容量”三个字段以及堆分配、释放的调用。一个闭包在 TIR 里必须是一个具体的结构体字段就是捕获的变量。一个结构体字段访问必须算好偏移量。这些都是实现层面才有的知识和语言语义无关放在这层正合适。1.4 三层分工速查表为了方便对照我把三层各自的核心问题、输入输出和典型客户端整理成一张表IR 层级核心要回答的问题主要输入典型使用方AST源码的语法结构是什么源码文本Parser、IDE、格式化器、宏系统HIR程序语义是什么、类型是什么、何时合法AST类型检查、借用检查、高级优化TIR如何组织控制流和数据流、内存怎么布局HIR优化器、指令选择、寄存器分配、代码生成这张表值得贴在你编译器项目的 README 第一页。每次你犹豫“这个优化应该在哪个层做”先问自己一个问题它依赖的是语法结构、语义信息还是实现细节答案会直接把你导向正确的层级。1.5 为什么不能一步到位两个绕不开的现实原因第一个原因是映射爆炸。AST 中每一种语法糖都可能需要多种指令级规则来处理语法糖组合起来之后规则数量更是乘法爆炸。分层之后每一层的“翻译”只针对一组有限的原语复杂度线性可控。第二个原因是分析困难。编译器优化在 AST 上做会发现到处都是“噪音”——括号、分号、无关的嵌套、为了语法舒服而引入的空壳节点。数据流分析、常量传播、死代码消除这些优化在树形 AST 上描述起来非常别扭但在 TIR 的 CFG 上就是标准的图遍历。就好比你写一篇文章先有提纲AST然后改成一套规范的段落结构HIR最后变成详细到每一句话的稿件TIR每一步都只处理自己这层该关心的东西整个流水线才转得动。2. 主流编译器都是怎么分层的直接抄一份参考架构2.1 rustc从 AST 到 HIR、THIR、MIR 再到 LLVM IRRust 编译器 rustc 是分层 IR 的典型教科书。它的完整链路是源码 - AST - HIR - THIR - MIR - LLVM IR - 机器码AST 是 parser 的产物宏展开也发生在这个阶段。HIR 负责脱糖和类型检查cargo check阶段很多工作就在这里完成。THIRTyped HIR是带完整类型的 HIR主要用于模式匹配的降级和借用检查。真正做大量优化的是 MIR它是 Rust 自己的“TIR”基于 CFG做了借用检查的部分工作、常量传播、常量求值const eval、内联、以及生成 LLVM IR 前的各种变换。rustc 选择在进入 LLVM 之前保留自己的中间层一个重要原因是Rust 的所有权模型、借用检查、模式匹配语义太特殊直接往 LLVM IR 上映射会丢失太多语义信息后续分析无从下手。MIR 让编译器既能完成精细的语言级检查又不用过早绑死 LLVM IR 的细节。想亲眼看看各层长什么样用这几条命令就行# 输出 HIR rustc -Z unprettyhir your_file.rs # 输出 MIR rustc -Z dump-mirall your_file.rs # 输出 LLVM IR rustc --emitllvm-ir your_file.rs实测下来第一次看到 HIR 打印结果会非常震撼for全部消失了整个程序变成一堆loop、break、match和控制流大杂烩。那种“原来语言还能这样被拆解”的感觉是理解 IR 分层的绝佳起点。2.2 SwiftAST → SIL → LLVM IR中间层承担安全大任Swift 编译器也有自己的中间表示叫 SILSwift Intermediate Language。它的定位介于 HIR 和 TIR 之间分为原始 SIL 和规范 SIL 两个阶段。原始 SIL 从 AST 生成保留了大量高层语义规范 SIL 经过一系列优化和语义调整后再交给 LLVM。SIL 比较有意思的设计是引入“所有权”ownership和“排他性”exclusivity检查以及大量的内存安全验证。Swift 团队没有把这类语言级语义检查扛在 AST 上而是设计了一层专门服务于这些检查的 IR。这进一步说明了一个趋势语言越复杂越需要舍得加中间层。你可以用下面的命令查看 SILswiftc -emit-silgen your_file.swift # 原始 SIL swiftc -emit-sil your_file.swift # 规范 SIL如果你正在设计的新语言有比较强的安全语义比如生命周期、借用、并发约束强烈建议参考 Swift 的做法在 HIR 和真正的 TIR 之间留出一个专门做语言级分析的位置。2.3 GCC、JVM、Go不同取舍下的 IR 分层思路GCC 的分层也很典型AST 之后经过 GIMPLE一种类似 HIR 的三地址码表示再经过 RTLRegister Transfer Language低层表示然后才到汇编。GIMPLE 让 GCC 在语言无关层做了大量优化RTL 则负责指令选择、寄存器分配、指令调度这些机器相关的事。JVM/CLR 这边Java 是 AST 到字节码再在 JIT 里做优化C# 也是先编译成 IL再在运行时 JIT 成机器码。它们的“字节码/IL”其实就是一个兼具平台无关语义和 VM 可执行性的中间层分层思想一样只是把编译时和运行时的工作分配方式不同。Go 编译器则直接从 AST 生成 SSA 形式的中间表示再一路优化到机器码。因为 Go 的语法相对克制、语义相对简单它的中间层数量可以少一些。这告诉我们一个道理层数不是越多越好而是复杂度到哪儿层就在哪儿。语言特性少可以精简特性一多撑不住就得拆层。2.4 给自己的语言定层级三个判断标准如果你在做一个新语言不知道怎么定分层可以参考这三个标准语言里有没有高级控制流或匹配能力有match、模式匹配、async/await、闭包这类特性最好加一层 HIR 来做脱糖与语义检查。目标平台是不是多样需要生成 x86、ARM、Wasm 等多种后端TIR 之后接一个通用后端如 LLVM由 TIR 层统一暴露给后端。有没有需要跨函数或跨模块分析的语义有所有权、借用、生命周期之类就应该在 HIR 与 TIR 之间安排一个“中间层”专门做语言级分析别把这类逻辑散落在代码生成阶段。3. 实操推演从 AST 往下走一遍 Lower你就明白为什么需要三层3.1 从一份“带 for 循环的小语言”开始空谈不如动手。假设我在写一个极简单的语言语法类似 C支持变量、赋值、for循环和数组。它的 AST 节点定义大概长这样用 Rust 伪代码enum Expr { Int(i64), Var(String), Assign(BoxExpr, BoxExpr), BinOp(BinOp, BoxExpr, BoxExpr), Index(BoxExpr, BoxExpr), } enum Stmt { Let(String, BoxExpr), For(String, BoxExpr, BoxExpr, BoxExpr, VecStmt), Assign(BoxExpr, BoxExpr), }这份 AST 忠实反映了语法For节点里有变量名、起始表达式、结束表达式、步长表达式、循环体语句列表。如果我直接在 AST 上做代码生成我需要为For编写“循环展开”逻辑、为BinOp编写指令选择、为Assign和Index组合编写地址计算。看起来也没多少对吧因为这是玩具语言。真实语言的 AST 节点数量级在 100 到 200 个加语法糖更是破 300。每个节点两两组合的翻译规则能让你的代码生成器变成一团永远理不清的乱麻。这时候分层的价值就体现出来了。3.2 从 AST 到 HIR脱糖和类型化把高级结构“踩扁”HIR 的节点数量通常只有 AST 的三分之一到二分之一。在 HIR 阶段我的小语言会把For脱糖成一组基础节点For(i, start, end, step, body) - { let i start; loop { if !(i end) { break; } body; i i step; } }在真正的编译器里HIR 还会顺带做几件事给每个表达式和变量关联TypeId建立类型表建立作用域链和符号表解析每个名字引用的是哪个声明如果这个语言有所有权语义这里还要记录每个变量的移动和借用信息。脱糖让 HIR 节点种类大幅下降类型化则让后续分析不需要再回到 AST 去猜类型。你再看 HIR 打印结果时会感到程序虽然“难看”了但结构反而清晰了所有的高级构造都不见了剩下的都是可以系统处理的原语。3.3 从 HIR 到 TIR基本块、虚拟寄存器与显式内存布局TIR 阶段我要把 HIR 的结构化控制流彻底转成 CFG。还是用刚才的for循环它最终会变成一组基本块entry: i start; goto check; check: if i end goto body else goto exit; body: // 循环体语句 i i step; goto check; exit: // 循环后面的代码注意这里已经没有loop、没有if嵌套只有 label 和跳转。变量变成了虚拟寄存器数组索引访问变成了显式的“取地址 偏移量计算 内存读写”。同时类型系统层面的信息被降到“选择指令”需要的最低程度。如果目标机器是 64 位整数可能是 64 位寄存器如果存在 32 位就换 32 位指令。TIR 的价值在于它先把语言与机器之间剩下的“桥”搭好后续指令选择只需要做局部匹配不需要了解语言特性。3.4 实际调试 IR 的“三板斧”我平时写编译器调试 IR 层基本靠三招第一招dump 每一层。前端有问题看 AST dump脱糖有问题看 HIR dump代码生成有问题看 TIR/MIR dump优化有问题看 LLVM IR dump。别凭脑子在大脑里模拟运行打印出来的输出比任何日志都好使。rustc 的-Z unprettyhir、-Z dump-mirallGCC 的-fdump-tree-allLLVM 的-print-after-all都是干这个用的。第二招做“极小化复现”。有时候某段代码在某一层表现异常直接去翻那一层的 dump找出最小的出错片段单独写个小测试文件验证该层行为。这比在完整项目里打断点调试高效得多。第三招给每层 IR 写一个校验器。比如 HIR 校验器检查每个TypeId是否存在于类型表TIR 校验器检查每个基本块的跳转目标是否真实存在、每个虚拟寄存器在某条路径上是否有定义。校验器跑一次就能暴露降级过程里被弄坏的语义省下大量手工排查时间。我见过不少成熟编译器项目IR 验证器代码量比优化器还大原因就在这里——IR 层直接决定了后面所有分析的可信度。4. 缓存为什么按内容寻址不靠路径和时间靠“我是什么”4.1 传统缓存为什么总是失效时间戳和路径都不靠谱先从构建系统的通用做法说起。旧式构建工具或者很多想偷懒自己做缓存的脚本习惯用“文件路径 修改时间”作为缓存的 Key。代码长这样key path(file) : mtime(file)这套逻辑在单机、串行构建、手动触发的情况下勉强能用但一上大规模 CI 就问题百出CI 每次从版本库 checkout 代码所有文件的mtime都是 checkout 那一刻源文件内容明明没变缓存却全部失效。同一份源码在不同分支、不同目录、不同机器上路径不一样共享远程缓存时 Path 不同Key 就不同命中率趋近于零。并行构建里多个进程同时读一个文件的mtime文件系统的粒度不够可能出现内容变了但时间戳没变的竞态。你只想改一个注释就触发全部缓存失效浪费多少编译时间做过的人都懂。根本问题是路径和时间戳都不是“这份输入内容”的可靠标识。它们描述的是“这份文件放在哪里、何时被碰过”而不是“这份文件到底是什么”。同内容不同位置在路径寻址里就是两个完全不同的 Key。4.2 内容寻址的核心思路Key 由输入内容唯一决定内容寻址content-addressed的做法完全不同。它先收集编译这个目标的全部输入源文件内容、依赖的库产物、编译器版本、编译参数等把整个输入集算成一个哈希值作为缓存 Key。内容不变哈希不变内容变了哪怕一个字节哈希就变了。最简单的实现思路如下1. 获取所有输入文件的字节内容 2. 分别计算每个文件的 SHA-256 哈希 3. 把所有哈希和编译参数、编译器版本串成一个字符串 4. 计算整体 SHA-256得到最终缓存 Key 5. 用该 Key 去缓存中查产物命中直接返回未命中则编译、写回缓存。这里有个细节值得注意为什么不直接对整个输入目录做一次哈希因为增量编译场景里你不想每次编译都把整个目录重新读一遍。正确的做法是维护一份“输入清单”对每个文件单独哈希只有当文件被修改时才重新计算它的哈希然后用组合哈希决定缓存是否命中。Cargo 的做法大家可能更熟悉它维护一个 fingerprint指纹里面包含 rustc 版本、编译参数、所有依赖产物的 fingerprint、源代码的哈希等。这个 fingerprint 本质上就是一个内容寻址的 Key。依赖链上的任何一层变化都会通过哈希传导到最终产物所以 Rust 的增量编译在 CI 里表现非常稳定。4.3 链式哈希为什么源文件哈希还不够如果只对源文件算哈希那么“源文件没变但依赖库变了”的情况就会假命中。比如一个 crate 依赖了另一个 crate依赖 crate 内部改了实现但对外接口没变源文件一字未改若缓存 Key 只有本 crate 的源文件哈希那下游 crate 会错误地复用旧产物——因为它的行为实际上可能受依赖实现的优化影响尤其在内联场景里。解决办法就是链式哈希构建目标时把所有“输入”的哈希递归地组合起来。一个 crate 的 fingerprint 必须包括所有直接源文件的哈希所有依赖项的 fingerprint而不是依赖项的路径或版本号编译器版本与编译参数哈希目标平台标识OS、Arch、ABI相关环境变量如CARGO_CFG_*、RUSTFLAGS的哈希。这样依赖图任何一环发生变化都会传导到最终 Key。这也是 Bazel、Nix、BuildKit 这些现代构建系统的核心思想整个构建图是一张 DAG每个节点的缓存 Key 是它所有输入的哈希之和。4.4 内容寻址与时间戳/路径寻址的对比寻址方式Key 的形式优点缺点典型系统路径 时间戳file path mtime实现简单直观路径变更、mtime 粒度、Checkout 抖动都会导致失效早期 Make、手动脚本内容哈希 参数SHA-256(源文件 依赖指纹 参数)与位置无关、与时间无关可远程共享需要维护输入清单实现稍复杂Cargo、Bazel、sccache、Nix纯内容哈希SHA-256(文件内容)简单去重效果好缺少参数和依赖信息不易直接用于编译缓存对象存储、Git 对象库从表中能看出内容寻址并不是玄学它的本质是把“缓存命中”建立在一个可验证的数学关系上相同输入得到相同输出输入变了输出就会变。接下来所有问题都变成了输入集到底怎么界定。5. 内容寻址缓存实践关键细节、坑与排查实录5.1 输入全集Key 里究竟要放哪些东西内容寻址缓存最大的坑就是“Key 里漏了输入”。最常见的场景编译 A 文件时实际读了一个头文件 B但头文件 B 没有进入 Key改了编译参数、优化级别或目标架构但 Key 没变编译器版本升级了但 Key 还是旧版本算出来的代码生成器、构建脚本、环境变量变了却没有影响 Key。排查方法说来也简单先记录编译进程真正打开过的所有文件。Linux 上可以用strace -f -e traceopenat抓取macOS 可以用fs_usage再对比“实际读到的输入集”和“缓存 Key 里的输入集”很容易就能找出漏网之鱼。以 sccache 为例它在编译前会通过编译器的“依赖列表”机制如-MD收集所有被 include 的头文件再加上编译参数、编译器二进制本身的哈希、目标平台等信息组合成缓存 Key。这就保证了每次编译的输入都被完整覆盖。对于自研构建系统一个稳妥做法是把“环境指纹”也纳入 Key。所谓环境指纹就是编译器二进制路径的哈希、构建工具的版本、相关工具链组件的版本、甚至关键环境变量的哈希。虽然相比只看源代码会牺牲一点命中率但能显著减少假命中带来的诡异行为。5.2 缓存粒度与存储细粒度更灵活粗粒度更省事缓存粒度的选择直接影响命中率。按“整个可执行文件”缓存粒度最粗命中率最低——改一个源文件整个可执行文件的 Key 全变。按“编译单元每个 .c/.rs 文件”缓存命中率大幅提升因为大部分文件没变它们的单元产物可以直接复用。Cargo 走的就是这条路线以 crate 和增量单元为粒度。更大规模的系统可以做到按函数或按模块缓存。LLVM 的 ThinLTO 缓存就支持按函数粒度缓存优化结果因为内联和跨模块优化可能让某些函数多次被处理。粒度越细复用率越高但缓存的元数据管理也越复杂。我的经验是先按编译单元等命中率不够了再细分成更小粒度不要一上来就上最细的方案。存储方面内容寻址天然适合使用 Content Addressable StorageCAS。CAS 把内容哈希作为文件名同一个文件无论被谁引用都只存一份天然去重。本地用普通磁盘缓存目录远程可以用对象存储加索引服务。Bazel 的远程执行协议里缓存的元数据执行结果、日志、退出码和实际产物文件就分开存储产物统一放进 CAS元数据通过哈希引用它们。5.3 常见故障与排查速查表现象可能原因排查方法缓存命中但产物不对Key 里遗漏了实际输入头文件、依赖、环境变量用 strace 抓打开文件列表对比 Key关闭缓存复现并对比差异一直命中不了Key 不稳定包含绝对路径、时间戳、构建序号打印两次 Keydiff 找出变化的字段把绝对路径替换为相对路径或纯内容哈希远程缓存大量失效远程缓存存储损坏、权限问题、网络超时检查远程存储服务日志测试单个条目的拉取给条目加校验和验证缓存膨胀严重缺少引用计数或 GC 策略记录每个条目被命中的时间定期清理最久未使用的条目或基于引用计数删除并行构建互相覆盖缓存写回没有原子性保护写临时文件再 rename 成最终内容哈希路径同 Key 写同一路径用原子操作5.4 提升命中率的几个实用技巧第一保证 Key 是“可打印的”。编译时最好加一个参数可以输出最终生成的缓存 Key比如--print-cache-key。调试时对比两次构建的 Key一眼就能看出哪个字段变了比瞎猜快得多。第二把临时路径和绝对路径排除在输入之外。有些编译器会在产物里记录绝对路径比如调试信息如果你连调试信息一起做缓存路径变了 Key 一定会变。解决办法是在二进制产物里用-fdebug-prefix-map之类的参数抹掉绝对路径或者单独缓存不带调试信息的产物。第三环境变量只白名单不要全量入 Key。整个env全量哈希会让缓存命中率骤降因为环境变量经常包含动态值PID、随机数、时间戳。只把真正影响编译的变量如PATH、CFLAGS、RUSTFLAGS、目标平台相关配置选出来入 Key既安全又高效。第四写入缓存时做一次端到端验证。编译完成后把产物试运行一遍确认退出码、stderr、关键输出文件存在再写回缓存。宁可漏写入也不要写入错误产物否则后面几万人都在复用你的 Bug。6. 我在实际项目里的体会分层 IR 这套体系我一开始也觉得是“过度设计”直到自己给一个有闭包、模式匹配和些许所有权语义的小语言写后端才发现不做 HIR 脱糖类型检查的规则会膨胀到自己都认不出来不做 TIR 的 CFG 化优化逻辑根本没法用算法描述。三层之间的界限不是一开始拍脑袋定的而是在写掉几千行代码、经历过几次“这层改一下那层也跟着崩”之后才慢慢清晰起来。缓存这条线给我的启发更大。凡是能用内容定义的就不要用位置和时间来定义。这个原则不只是编译缓存任何追求可复现性的系统——CI 缓存、镜像构建、依赖锁定、远程分布式任务——都适用。内容寻址不一定能让一切变快但它能让“缓存到底有没有生效”这个问题变得可以验证、可以推理而不是靠玄学。最后再分享一个小技巧不管你是写编译器还是搭构建系统给自己的 IR 层和缓存 Key 都加上“导出调试信息”的能力。这两样东西在出问题的时候比任何监控面板都管用。把 IR dump 和缓存 Key 打印出来和身边的人一起对着看很多理不清的 Bug 就在这种“对着真实中间状态看”的过程里被找出来了。踩过几次坑之后你就会明白编译器开发里最贵的从来不是 CPU 时间而是“看不见中间态”时反复试错的时间。
返回列表