读书笔记 · 约 13,747 字 · 预计推送 2 部分

Crafting Interpreters 选读:树遍历(jlox)与字节码 VM(clox)两种实现对照(主题精读) 未开始

目录

题记与说明


0. TL;DR

《Crafting Interpreters》用同一门语言 Lox 写了两遍完整实现:jlox(Java,把代码表示成 AST,递归 Visitor 走树求值)和 clox(C,把代码编译成线性字节码,用栈式 VM 的 switch 循环执行)。书的真正论点是:决定解释器性能的不是优化技巧,而是"代码在内存里长什么样"。AST 是散布堆上的对象图,走树 = 不断追逐指针、浪费缓存;字节码是紧凑线性数组,天然缓存友好,且编译期就能把变量解析、名字查找这些工作做完。作者的判词很直白:树遍历"简单、可移植、慢",对通用命令式语言 "it won't fly",执行模型本身就是错的——"你没法把一辆 AMC Gremlin 打磨成 SR-71 黑鸟"。基准是 fib(40):jlox 约 72 秒,等效 C 程序 0.5 秒。第三方书评称这种"故意写两遍"的做法给了读者"立体镜式的视角"——树遍历让语义可触摸,字节码 VM 揭示生产级实现的工程取舍。


1. 核心问题

这组材料共同回答的问题是:同一种语言语义,为什么"直接解释语法树"和"编译成字节码再模拟执行"会有数量级的性能差距?差距的每一分钱花在哪里?又各自付出了什么代价? 拆开是四个子问题:

  1. 谱系定位:树遍历解释器、字节码 VM、原生编译、JIT 在实现策略的光谱上各占什么位置?"解释器"和"编译器"到底是不是互斥概念?
  2. 树遍历的实现机制:jlox 如何用 Visitor 模式在 AST 上递归求值?它简单在哪里、慢在哪里(指针、装箱、虚分派、缓存)?
  3. 字节码的折中逻辑:为什么选字节码而不是直接编译到机器码?字节码到底"是"什么?
  4. 关键机制对照:变量解析、作用域、动态类型值表示、运行时错误展开,两种实现各怎么做?性能证据是什么?

2. 主要主张

2.1 谱系:树遍历是"学生的和玩具语言的",字节码是主流动态语言的路

"Some programming languages begin executing code right after parsing it to an AST (with maybe a bit of static analysis applied). To run the program, the interpreter traverses the syntax tree one branch and leaf at a time, evaluating each node as it goes. ... This implementation style is common for student projects and little languages, but is not widely used for general-purpose languages since it tends to be slow."(Ch.2 §2.2.2)
"Running bytecode in a VM is slower than translating it to native code ahead of time because every instruction must be simulated at runtime each time it executes. In return, you get simplicity and portability."(Ch.2 §2.1.7)

2.2 树遍历(jlox):最短路径,"让每个节点自己求值"

"This type cast happens at runtime when the - is evaluated. That's the core of what makes a language dynamically typed right there."(Ch.7 §7.2.3)

2.3 为什么放弃走树:三条硬伤(Ch.14 的完整论证)

Ch.14 是全书对照的枢纽,作者先承认树遍历的两个真实优点("this style of interpreter is *really simple* to implement. The runtime representation of the code directly maps to the syntax";以及可移植),再逐条拆解:

  1. 总判词:
"An even more fundamental reason that jlox isn't sufficient is that it's too damn slow. A tree-walk interpreter is fine for some kinds of high-level, declarative languages. But for a general-purpose, imperative language—even a 'scripting' language like Lox—it won't fly."(Ch.14 开篇)
  1. 微优化救不了错误的执行模型:
"We could take jlox and run it in a profiler and start tuning and tweaking hotspots, but that will only get us so far. The execution model—walking the AST—is fundamentally the wrong design. We can't micro-optimize that to the performance we want any more than you can polish an AMC Gremlin into an SR-71 Blackbird."(Ch.14 开篇)
  1. 内存与缓存的定量论证:1 + 2 这样一个表达式会展开成一串 Java 对象,每个指针"adds an extra 32 or 64 bits of overhead",对象头还要占记账空间;而"Those sub-objects could be *anywhere*. Every step the tree-walker takes where it follows a reference to a child node may step outside the bounds of the cache and force the CPU to stall"——缓存命中与走 RAM 的差距是"upwards of 100 *times* faster"(Ch.14 §14.1.1)。即使解析时对象恰好连续分配,几轮 GC 搬移之后也无从保证。
  2. 分派开销:"Our AST walker has other overhead too around interface dispatch and the Visitor pattern, but the locality issues alone are enough to justify a better code representation."(Ch.14 §14.1.1)
  3. 为什么不是原生编译:机器码快,但"compiling to native code ain't easy"——寄存器分配、流水线、指令调度,且可移植性直接归零;LLVM 可以缓解但引入重型依赖(Ch.14 §14.1.2)。
  4. 字节码 = 折中点的正式定义:
"On one end, a tree-walk interpreter is simple, portable, and slow. On the other, native code is complex and platform-specific but fast. Bytecode sits in the middle. It retains the portability of a tree-walker ... It sacrifices *some* simplicity to get a performance boost in return, though not as fast as going fully native."(Ch.14 §14.1.3)

设计观一句话:"It's an idealized fantasy instruction set that makes your life as the compiler writer easier."——为"最容易实现的架构"设计指令集,再用软件模拟(VM)执行它。

  1. 师承关系:"We'll follow in the footsteps of the main implementations of Python, Ruby, Lua, OCaml, Erlang, and others."(Ch.14 §14.1.3)

2.4 字节码的表示革命:AST 类家族 → 三个数组

"Remember that whole family of AST classes we defined in jlox? In clox, we've reduced that down to three arrays: bytes of code, constant values, and line information for debugging. ... You can think of bytecode as a sort of compact serialization of the AST, highly optimized for how the interpreter will deserialize it in the order it needs as it executes."

2.5 关键机制对照:把"查找"从运行时搬进编译期

Ch.22 给出两种实现最典型的一对差异——词法作用域的实现:

"Accessing a variable from a known stack slot is an indexed array lookup." / "Allocating space for a new local requires only incrementing the stackTop pointer, and freeing is likewise a decrement."(Ch.22)
"Any processing work we do in the compiler is work we don't have to do at runtime."(Ch.22)

2.6 第三方视角:双实现是"立体镜",但书也有边界

tinycomputers.io 书评(第三方,非作者自述):


3. 方法:两种实现的技术路线逐维对照

书的方法本身就是"控制变量":同一门语言 Lox、同一种语法(Pratt/递归下降)、同一组特性逐章对应实现两遍,差异被隔离在"代码的中间表示 + 执行循环"上。阶段图(Ch.14 图注原文):"jlox is Parser to Syntax Trees to Interpreter. clox is Compiler to Bytecode to Virtual Machine." clox 在 Pratt 解析的同时直接产出字节码、不构建 AST,正符合 Ch.2 §2.2.1 所述 single-pass compiler 的特征("produce output code directly in the parser, without ever allocating any syntax trees")。

维度jlox(树遍历)clox(字节码 VM)
宿主语言Java(内存/并发托管给 JVM)C(自己写动态数组、哈希表、mark-sweep GC)
管线Scanner → Parser → AST → Interpreter 递归求值Compiler(解析中直接出码)→ Chunk 字节码 → VM 取指执行
代码表示Java 对象树(Expr/Stmt 类族 + Visitor 接口)三个数组:code 字节、constants 常量池、lines 行号
求值模型后序递归遍历,每节点一次虚方法分派栈式 VM 的 switch 取指-执行大循环
变量解析Environment 链 + 每块一个 HashMap,运行时逐层查编译期 resolveLocal 定栈槽,运行时 vm.stack[slot] 下标访问;全局走常量池索引 + 哈希表
作用域开销进块新建 HashMapstackTop 指针加/减
值表示Object 装箱 + instanceof/cast16 字节 tagged union → Ch.30 NaN boxing 压成 8 字节
控制流/错误Java 异常展开递归调用栈字节码内 jump 指令(Go-to 式)
行号来源Token 随节点携带平行 lines 数组,错误时才查
自省工具AstPrinter(打印 AST)disassembleChunk(反汇编字节码)
性能锚点fib(40) ≈ 72 秒同基准快一个数量级以上(见 §4),优化后再 2×/10%

方法论要点:jlox 章节把精力花在语义(闭包、类、继承的语义设计),clox 章节把精力花在表示与工程(值编码、内存布局、GC、哈希表)——两部分的分工本身就是一种学习路径设计。


4. 数据与证据

  1. 基准锚点(Ch.14 原文):递归 fib(40),"On my laptop, that takes jlox about 72 seconds to execute. An equivalent C program finishes in half a second." 作者设定的工程目标:"we don't need to settle for more than *two orders of magnitude* slower"(与 C 比)。
  2. 量级交叉确认:WebSearch 检索摘要将书中结论概括为 clox 比 jlox 快"约一个数量级(~10×)"(并援引 tinycomputers 书评称 clox 靠架构而非高级优化取胜)。注:该"10×"为二手概括,本次未能在原书页面直接核对到这一具体倍数;可直接核对的是上面 72s vs 0.5s(jlox vs C)与 Ch.30 的相对数字。
  3. Ch.30 profiler 实测(WebFetch 提取):优化前热点画像——OP_INVOKE 42%、OP_GET_GLOBAL 17%、OP_GET_PROPERTY 12%,tableGet() 占执行时间 "a whole 72% ... (inclusive)";VM 总时间的 70% 花在 findEntry() 里取模那一行。硬件事实:"division and modulo are about 30-50 times slower than addition and subtraction on x86."
  4. 优化收益(Ch.30):把 hash % capacity 换成位掩码 hash & (capacity-1)(容量恒为 2 的幂)后,十秒内跑完的批次数从 3,192 提升到"almost exactly twice as much work ... We made the VM twice as fast (usual caveat: on this benchmark)";tableGet() 占比从 72% 降到 35%。NaN boxing 把 Value 从 16 字节压到 8 字节后:"the new value representation seems to make everything roughly 10% faster across the board."
  5. 证据充分性评估:fib(40) 微基准 + profiler 百分比足以支撑"执行模型/表示决定量级差距"的主张(这正是作者论证所用的证据);但全部数字来自作者单机、单基准,作者自己也反复标注 "on this benchmark";第三方书评属于定性佐证。没有多基准、多机器的独立复现,这是证据面的主要缺口。

5. 结论

  1. 树遍历解释器是"语义学习器"和原型工具的正确形态:简单、直接映射语法、宿主语言兜底内存与错误处理;但它在内存布局(散布堆上的对象图)、缓存局部性(指针追逐)、分派开销(虚方法)三处结构性失血,且这些都不是微调能救的。
  2. 字节码 VM 的胜利来自表示的更换,而非优化叠加:AST 类家族压缩成三个数组;变量解析从运行时 HashMap 链前移到编译期栈槽;名字根本不进运行时。"编译器里做的每件事都是运行时不用做的"是整条路线的纲领。
  3. 这条路线是主流动态语言的实际历史:CPython/Ruby(旧版树遍历→后改字节码)/Lua/OCaml/Erlang 的主要实现都编译到字节码;"解释器"与"编译器"是可叠加的属性而非对立选项。
  4. 双实现教学法有效:先写慢而清晰的 jlox 把语义跑通,再写快而工程化的 clox 对照理解"为什么"——第三方书评的"立体镜"比喻成立。

6. 局限

作者承认/书中自带的:

第三方指出的书级局限(tinycomputers 书评):

我(精读者)补充的局限:


7. 对我的启发

  1. "表示决定速度"优先于"算法决定速度":jlox 与 clox 算的是同一棵"表达式",差距全部来自内存里的表示(对象图 vs 线性字节)与缓存行为。做任何性能敏感系统(DSL、序列化、渲染、数据处理管线)时,第一步应该审视数据布局是否对硬件友好,而不是先找热点微调。
  2. "编译器是最大的优化锤":能在编译期完成的解析、绑定、常量计算,绝不留到运行时。这个原则可以平移到提示词模板编译、正则预编译、shader 编译、配置校验前移——凡是"同一份静态描述被反复执行"的场景。
  3. 先写两遍,再谈取舍:jlox→clox 的路径是可复用的学习法——先实现"慢而正确"的参照版本锁定语义,再实现"快而工程化"的版本,用同一套端到端测试(作者先写测试套件再写书,Ch.14 Design Note)保证两者行为一致。迁移/重写架构前先固化行为测试,这条纪律比任何实现技巧都值钱。
  4. Profiler 是唯一的热点来源:直觉热点(全局变量查询)不是最大头(OP_INVOKE 42%);取模运算 30–50× 于加减这种"常识"只有被 profiler 指着才值得动手。优化前先测,优化后复测验证。
  5. 写作素材:文中"AMC Gremlin → SR-71"、" Interpreter, and it has a compiler"、"compact serialization of the AST" 都是可直接复用的讲解范式——用具体比喻把抽象权衡讲到位,这正是本书写作上最值得学的一点。

附注:本次检索与阅读方式

← 资料库今日播报 →