演讲:自动改造JIT编译器
自动为现有语言实现加装 JIT 编译器
观看演示 时长:42:43
[LOADING...]
摘要
Laurence Tratt 讨论了 yk,一个开源的元跟踪 JIT 编译器框架。他分享了如何以最小且非侵入式的代码改动,自动加速基于 C 的语言解释器(如 Lua 和 MicroPython)。他讲解了跟踪循环的内部工作原理、如何利用开发者的提示来优化编译出的跟踪,以及如何管理复杂的逆优化并退回解释器。
个人简介
Laurence Tratt 是伦敦国王学院信息学系的 Shopify / 英国皇家工程院语言工程研究教席(Research Chair in Language Engineering)。他的研究重点在于提升我们开发和运用软件的能力,尤其关注性能:如何让更多软件以用户需要和期望的速度运行。
关于会议
软件正在改变世界。QCon London 通过在开发者社区中传播知识与创新来推动软件开发发展。QCon 是一场由实践者驱动的会议,专为技术团队负责人、架构师、工程总监以及项目经理等影响团队创新的角色而设计。
InfoQ 活动
-
[LOADING...] 2026 年 8 月 6 日,美国东部时间下午 1:00
为高风险事件响应构建 AI Agent 评估
演讲者:Brianne Bujnowski——Datadog AI 高级产品营销经理;Benjamin Barton——Datadog 高级软件工程师
-
[LOADING...] 2026 年 8 月 27 日,美国东部时间下午 1:00
框架之下:为什么 Agent 上下文是一个基础设施问题
演讲实录
Laurence Tratt:
如果我们能让更多的编程语言运行得更快,那该多好?尤其是那些“难搞的家伙”——也就是我们所说的动态类型语言或脚本语言,比如 Lua、Ruby、Python。此外,还有相当多其他语言实现,它们的运行速度可能也不如你所愿。CPU 公司有 CPU 模拟器,等等。我要向大家展示的是,我们可以对现有的语言实现进行改造,自动为它们加上即时编译器。我要介绍我们开发的一项新技术,名为 yk。好消息是,它能做一些很有意思的事情;问题在于,展示性能提升是相当有挑战性的。我先从一个简单的演示开始。
我们来看这个 Lua 程序,之前已经提到过。这是一个非常标准的 Mandelbrot 程序。我把质量参数调高了一些,让它运行足够长时间,这样我们才能真正进行基准测试。Lua 是一种动态类型语言,用于游戏、Neovim 等各种地方。我们使用从 lua.org 下载的标准 Lua 虚拟机来运行它,看看需要多长时间。这是一个 Mandelbrot 程序,用了 3.2 秒。非常好。现在,我们使用同一个 Lua 虚拟机的一个分支,通过 yk 技术来运行。它显示快了很多,只用了 0.8 秒,大约快了 4 倍。当然,你可以打包票,我是特地挑了一个占便宜的例子。我选择了一个我们特别擅长的场景。在更广泛的 Lua 基准测试中,几何平均值大概在 2 倍左右。重要的是,我们不仅仅是在这里加速 Lua。我们还刚刚开始做了一点工作,目前还很早期、不那么成熟:把它用到 MicroPython——一个轻量级 Python 虚拟机——上。我们来看 MicroPython 在著名的 Fannkuch 基准测试上的表现。它正在做事情。我们已经做了一点工作。你可以看到它的性能大致是线性的。这是普通的 MicroPython 实现一直在“吭哧吭哧”地运行。需要多长时间?十五秒。你猜到了,我们有自己的分支,叫 ykmicropython。请注意,在某个时刻(大约就是现在),它突然开始明显变热,并快速推进。这个版本大约快了 2 倍。这并不是 ykmicropython 的代表性结果,因为我认为到目前为止我们在这个项目上投入了不到 10 人日。
接下来在本次演讲中,我想说明我们为什么做这件事、它是如何工作的、涉及的一些技术挑战,以及下一步计划。我们很幸运,这项工作由 Shopify 和英国皇家工程院资助。他们都不为我说的话负责。这项工作是与 Ed Barrett、Lukas Diekmann 和 Pavel Durov 合作完成的。我只想强调一点:我们的目标是不做太多改动。你看到 yklua 的那些变化,这里是它与标准 Lua VM 的差异对比。我们增加了 400 行代码,可能本来还可以少加一些;修改了约 50 行。我们不是在讨论大规模侵入性的改动。这不到代码库的 5%。
术语
我想先定义一些术语。我用到了“虚拟机”这个词,但还没有解释它。我们这里说的是编程语言虚拟机。它们是包含一种或多种语言实现的系统。比如,Java 虚拟机里有多个 JIT 编译器、解释器等等。当我说“解释器”时——我知道这个词特别容易引起争议——我指的是一个简单的语言实现,大致是那种概念上本科生就能写出来的东西。如果你不喜欢这个定义,有一篇博客文章里我尝试做了更严谨的定义。最后是“即时编译器”(JIT compiler)。它是指某种语言实现:运行程序、观察程序、找出最常执行的程序点,对它们进行优化,编译成机器码,然后使用这些动态编译出来的机器码版本。
为什么?我们为什么要做这件事?
除了因为它很有趣之外。我想,每个人都经历过软件运行太慢的情况。有时你可以提前很久预见到会发生这种情况。你可能在运行一个大数据系统,而且知道还会有更多数据进来,所以你能预测到九个月后你的程序会慢到无法适应当前情况。根据我的经验,人们常常在不到 24 小时内,从不关心性能变成觉得这是生死攸关的危机。然后他们惊慌失措地折腾;如果他们还算理智和聪明,就会对自己的代码做性能剖析。也许他们会尝试使用更好的算法或数据结构;也许他们会尝试用更快的语言重写。我承认,有了大语言模型(LLM)之后,这件事变得容易了一些。他们往往会错过我最喜欢的技术之一——考虑到我很懒——那就是直接换用更快的语言实现。也许你升级一下 C 编译器,就能免费获得百分之几的额外性能。很多语言都有更快、可替代的、带 JIT 编译的虚拟机。如果你运行的是从 python.org 下载的普通 Python 版本,这个解释器并不是很快。你可以换成 PyPy——一个带有 JIT 编译器的 Python 虚拟机。你可能会免费获得 3 倍、4 倍甚至 5 倍的性能提升。你要做的就这么多。你可以把性能问题推迟很久。
有人认识这些名字吗?那是一个 .NET 的。我们还有一些 Python 实现。实际上,这些都是 Python 的 JIT 编译虚拟机,全都是针对 Python 的 JIT 编译器。再往下还有一些,还有,还有,还有。这些是我所知道的公开可用的 Python 即时编译器;还有一些是不公开的。这些几乎都已经死亡或废弃,或者和死亡没什么区别。当然,也有一部分仍然活着并在使用中。一个显而易见的问题是:为什么会有这么多?不可能只是为了好玩。我觉得根本原因是,构建带 JIT 编译的虚拟机真的很难。有大量活动部件需要做好。哪怕犯最小的错误,事情就会“啪”地崩溃,然后你得调试一个星期。因为难,所以贵。大多数编程语言都相当庞大。像 Python、Ruby 这样的语言,按特性数量和交互复杂度来说,真的是很大的语言,需要实现的东西非常多。比如 Java 主虚拟机 HotSpot,已经投入了数千人年的努力。Chrome 里的 JavaScript 虚拟机 V8 则有 50 人的团队。这些都是必需的巨大资源。
还有其他问题。如果你做一个新的实现——我甚至可以说“也许”——你几乎肯定会在某些方面(即使是小方面)与现有的主流实现不兼容。人们试用你的东西,发现它不能立刻运行自己的程序,于是放弃,转向别的。你永远不会再被试用。你也可能与现有的模块和扩展生态系统不兼容。同样,如果人们不能在新实现上运行他们的东西,他们就会放弃并离开。另一个大问题是——我认为这也是为什么你在上一页看到那么多 Python JIT 编译器的主要原因之一——它们真的很难演进。你为自己最喜欢的语言实现了一个新的即时编译器,让它能适应当前语言版本,然后负责语言规范的“烦人”家伙们去修改了规范。他们扩展了语言,可能破坏某些特性,而你现在不得不应对这些变化。你可能已经在其中嵌入了对现有语言的深层假设。于是你被卡住了,你往往会越来越落后。你可以在许多知名(有时曾是知名)的 JIT 编译器上看到这一点。例如,LuaJIT 就停留在相对较旧版本的 Lua 上。
出于所有这些原因,长期以来人们一直想看看能否自动化地创建即时编译器。有两种基本方法,恰好体现在现存仅有的两项技术上。RPython 实际上就是 PyPy 的另一个项目名称;你只要听说过其中一个,就相当于听说过另一个。它是一个元跟踪(meta-tracing)系统。Truffle 是一个部分求值(partial evaluation)系统。我们需要简要定义一些术语,因为这两个系统都要求你用宿主语言编写一个新的解释器。要么是 Python 的一个子集(RPython),要么在 Truffle 中用 Java 写。你必须为被实现的语言(guest language)编写一个新的解释器。被实现的语言可以是 Lua、Ruby、Python、CPU 模拟器等等,具体是什么并不重要。如果你这样做,往往能获得非常好的性能。我对这些系统的钦佩不亚于任何人。它们绝对令人惊叹,性能提升可能非常可观。但恐怕在座没有多少人用过它们或它们产出的成果。不幸的是,我认为这又回到了前面几张幻灯片提到的原因。它们往往不完全兼容,容易停留在稍微旧一点的语言版本上,很难跟上更新。这就是我们给自己设定的挑战:我们能否获得更快的语言实现所带来的好处,同时又不被旧版本困住?还有一个细节,我稍后会谈到。
如何实现?
现在来谈谈我今天介绍的具体技术。有一件事我或许早就该意识到,却花了好几年才明白。我们说的这些“难搞的家伙”中的大多数语言,都有事实上(de facto)的标准实现。通常,Ruby 和 Python 对应的是 CPython、MRI Ruby(有时也叫 CRuby);Lua 就直接叫 Lua VM。这些标准实现几乎总是 C 解释器,它们就是权威来源(source of truth)。这些语言有语言规范,写下了大量语义。但语义并不完整。库等很多东西并不在规范中,而人们却会依赖它们。基本上,人们在自己的标准实现上运行程序,如果程序表现符合预期,那这个程序就“没问题”,无论是否存在其他合法执行方式。他们开始依赖现有 C 解释器所做的一切。我们意识到,必须把这一点作为我们的权威来源。从根本上说,这就是我们所做的。你之前看到的演示应该能让你有所体会。如果你做出这个决定——而且这是一个相当大的决定——至少以我的浅薄之见,你只能使用一种技术:从 C 解释器生成一个元跟踪即时编译器。我不认为其他技术可行,或者至少我不知道怎么让它们可行。每当有人使用 meta 这个前缀,我就非常害怕,因为现在我必须理解两样东西:那个词本身,以及“元”(meta)这个词。
如果你要构建一个带 JIT 编译的虚拟机,典型做法就像 JVM。它们基本上是方法编译器:找出被频繁调用的函数,然后对这些函数以及可以内联的部分执行类似 GCC 或 Clang 的编译流程。从这个意义上说,它们相当传统。跟踪式即时编译器则完全不同。它寻找的是循环——for 循环、while 循环——那些执行很多次、超过某个计数器阈值而变热(hot)的循环。真正的区别在这里:当一个循环变热后,编译器会记录该循环在一次具体执行过程中所采取的动作。这与提前编译(AOT)编译器非常不同。然后,编译器会获取这些跟踪记录(也就是某一次迭代的执行记录),对其进行优化,并编译成机器码。
元跟踪(meta-tracing)——这是最奇怪的部分,我说出来很容易,但真正理解却很难——就是记录解释器在执行被实现语言中某个特定循环时所做的操作。这实在太奇怪了,所以我打算从几个不同的角度来看它,希望其中某个角度能让你产生共鸣。
我们把“运行一个程序”和“语言实现”的生命周期一分为二。第一部分我称之为“提前”(ahead-of-time),也就是我们通常所说的编译期。因为 JIT 是在运行时编译,这很容易混淆。我有一个用 C 写的解释器。我要用我们的 LLVM 分支(ykllvm)来编译它。你看,我们起名字很有想象力。这会给我一个相当标准的可执行文件。里面还有别的东西,我稍后会讲到。大体上,没有什么太奇怪的事。当我想运行某个具体的被实现程序时,我会拿到编译好的可执行文件,然后在其中运行我的程序。基本上,这就像正常运行一样,没有什么特别高明之处——直到某个循环超过阈值,变成热点。在那一刻,我们会记录解释器在该循环下一次迭代时做了些什么。我们跟踪它。一旦记录完该循环的那一次迭代,会发生两件事。第一,我们立即退回解释器,让它继续执行;因为在另一个线程中,我们会开始编译这条跟踪并优化它,希望最终能得到编译后的跟踪版本。与此同时,解释器可能一直在继续执行那个循环,并不断询问:有机器码版本了吗?有机器码版本了吗?有机器码版本了吗?最终会有的。于是它不再解释执行你的程序,而是交给机器码版本,也就是 JIT 编译出的代码。这段 JIT 编译出的代码只对一部分程序行为有效。到某个时刻,它会遇到自己无法处理的情况,然后逆优化回到解释器。
不过,在我的头脑里,这还不能完全解释它如何工作。让我们换个角度看。解释器基本上就是一个巨大的 while 循环。程序在内存中的某个地方,你可以称之为字节码序列或操作码(opcode)。程序计数器从开头开始。每次进入 while 循环,你会查看程序计数器指向的当前指令,看它是什么,然后根据指令类型做相应处理。如果是一条变量查找指令,我就查找变量,把结果压入栈,然后程序计数器加一,去取下一条指令。如果是一条加法指令,我就从栈里弹出两个值,相加,把结果压回去,程序计数器加一,继续。解释器就是这样一遍又一遍地重复这个模式,非常没有想象力。今天我们需要考虑的另一种指令是某种条件或 if 语句。这里我用“小于等于则跳转”(jump less than or equal)作为例子。你可以把它看作伪装的 if 语句。在这种情况下,我们会从栈中弹出一个值;如果它小于等于零,就跳转到某个标签。这个跳转可能向前或向后跳得很远。如果大于零,程序计数器就加一,继续执行下一条指令。我稍后会解释为什么把这条指令单独拿出来讲。只要记住它和其他指令不一样。
正因为这种模式,你总能在解释器中找到一个循环,也就是这个核心循环。这是我们正在处理的东西。如果我有一个被实现程序,谁在乎它顶层是 Python 语法还是 Lua 语法,或者其他什么。假设那个小片段位于一个执行频率足够高、已经变热的循环中。我们现在要跟踪它。假设我写了一个跟踪式 JIT 编译器——先不是元跟踪,只是跟踪。我会手动编写一个函数,记录该循环在这一迭代中执行过的所有操作码。在这个例子里,我走了那条 if 语句的真分支,执行了 y = y + 3。你可以看到,我记录的是一个具体执行过程;我没有把假分支包含在跟踪里。在元跟踪系统中——希望这种语法能让你想起上一页——我要记录的是 C 解释器采取的动作。希望你能看出,这就是上一页看到的代码,只有一个细微差别:那条“相等跳转”变成了一个守卫(guard)。理解方式是(稍后我会更详细地讲):因为我们记录的是具体执行过程,所以只有当守卫处的东西为真时,你才能继续执行这条跟踪。在这个例子中,我们走了 if 语句的真分支,所以下面的跟踪只对真分支有效。如果你想走假分支,就需要做一些巧妙的逆优化处理,我们稍后再谈。
我们究竟如何从一个 C 程序得到这个能完成所有这些事情的运行系统?我部分提到了,我们使用的 LLVM 分支 ykllvm 是这个方案的一部分。从 Makefile 的角度来看,它基本上很容易接入。你不用直接调用编译器,而是使用我们提供的一个小包装脚本。你告诉它你是要发布版本还是调试版本。我们会向 Clang 传递一些标志——无论出于什么原因,Clang 通常是你使用的中间前端,我稍后会提到这些原因。从这个角度来看,它非常容易。它做了几件事。其中一件最明显的事,你在源代码层是看不到的。我现在要展示的是二进制里存在、但你在源代码层永远不会看到的东西。它会在你的解释器内部插入记录函数。当你开始记录一条跟踪时,这些记录函数会告诉你执行路径经过了解释器的哪些地方。你最终可能得到诸如 0102 这样的序列。这告诉你:你先执行了一条查找指令,然后执行了一条加法指令。非常简单。
它做的另一件事是获取 LLVM 的 IR,也就是其中间表示。如果你把它理解为编译器为你的程序构建的内部数据结构(类似于 AST),我们就会把它转换成另一种略微简化的表示,序列化之后放进二进制文件中。这里是一小段 yklua 的 LLVM IR,细节很多。我们基本上把这一段转换成我们稍作简化的 IR。这里唯一的实际区别——我们稍后还会回到这一点——是这些安全点(safepoint):它们在之前的 IR 中并不存在。我们以某种方式添加了一点额外信息。这是文本表示形式。它的二进制表示随后会被转储到可执行文件中。当你在运行时加载解释器时,你会得到一份编译成机器码的普通 C 代码副本,外加作为第二部分的某种 IR 表示。然后在运行时,我们取出为跟踪记录下的那些 ID(比如 0102),取得已经序列化的 IR 片段,将它们拼接成一条我们可以优化并编译的跟踪。这样,我们就闭合了那个循环。
我们如何进行优化?因为这些跟踪可能相当长,可能有几百条指令,但依然值得处理。我相信你们中有些人会想:我敢打赌这全靠内联(inlining)。这确实是一个重点。内联的意思是,如果我调用函数 f,编译器基本上会把函数 f 的内容复制到调用它的函数里。这有两方面作用。显而易见的是,它优化掉了运行时调用函数的一些开销:我不需要在栈上建立新帧,也不必处理你的疯狂 ABI 要求我做的寄存器移动。跟踪天然就是内联的,它甚至不需要考虑这一点,就自动做到了。这非常好。内联带来的主要作用是,当你在更广阔的上下文中观察一个函数时,它能暴露出更多优化机会。然后你可以运行一些标准的编译器优化,比如常量折叠、强度削减,我们还有死加载(dead load)、存储分析、值集合(value sets)等等。实际上,我们的优化数量没有典型编译器那么多,因为我们还没到那个阶段。都是相当标准的东西。
真正重要的是,解释器可以把它所知道的关于语言和/或典型程序的信息暴露给 yk,然后 yk 用这些信息做进一步优化。经典例子是:你可能知道在你的语言中,某些东西是不可变的,或者极少变化,以至于系统可以假设它们是不可变的。只要系统能退出并逆优化到更一般的版本,那么在此基础上做优化通常是值得的。例如,你可能知道,一旦某个函数被编译成操作码序列,它就不会改变,或者改变少到不值得考虑。让我具体展示一下。这里是标准 Lua VM。让我展示一个我们添加的小函数,就是 R4,这是 yklua 中的。我在这里不太专业地高亮了这个四行小函数,它允许 yklua 告诉 yk:某个 Lua 函数的操作码不会改变。这里有一个小属性、这个注解,写着 yk_idempotent。我现在把它注释掉,重新编译,再运行一次,看看会发生什么。我们重新编译。链接需要一点时间。然后我们再次运行 Mandelbrot 程序。注意,这个版本不再是之前的 yklua。之前是 0.8 秒,现在变成了 2.93 秒。它还没有普通 Lua 实现那么慢,但仅仅去掉这个提示,我们就慢了 3.5 倍。这让你感受到,当你把这些关于程序及其运行方式的提示表达给系统时,它有多么强大。不过,刚才展示的这个提示有个问题:是的,它非常有效。它太有效了,如果真的去仔细分析,我们会被各种细节淹没。我要展示一个更简单的例子,让我们作为人类能真正理解正在发生什么。我们来看 Lua 中的 ADDI 指令。它把一个立即数加到一个变量上。如果看到类似 x + 1 的语句,只要这个整数是一个小值,它就会变成 ADDI 语句。我记得它是一个有符号 8 位值。我们马上会讲到这一点。我们做了一些工作,我接下来会尝试演示,然后简单解释它的运作原理。这里有一个非常重要的程序,一个意义非凡的程序。它是一个循环,会计数并打印一个值。请注意,在 sum + 1 中,那个整数可以是 8 位有符号值。有谁想选一个 50 到 127 之间的数吗?64。请记住 64,因为我们稍后需要看到它。我现在要运行这个程序,并打开一些调试输出,这样我们就能看到发生了什么。我们运行它。我并不确定这个答案是否正确,但看起来合理。当我观察这里时,我们看到的既有 trace IR(这是优化后的),也有最终生成的 x86 代码。第一件事,这里有常量 64。请相信我,我没有在这件事上撒谎,它确实是那个值。我没有在旁边藏 127 个程序。不知怎么地,我们已经把它烧进了 trace 里。更有趣的是,你可以看到这里有一串指令模式:我们从内存中加载 64 位值,给它加上 64,然后把新值存回同一个堆地址。如你所愿,我们成功把它优化成了一条 x86 指令。那个 0x40——所以,选了个 2 的幂的人,想得很周到,这在十六进制里是一个很好看的数字。我们真的已经能够把那个 Lua 值烧进 x86 机器码中。另外,正如我们这些程序员所知道的,如果看到 ADDI 指令,它其实是一个常量整数;我们希望在机器码里它也变成常量整数。这并不需要太多工作。OP_ADDI 的工作方式,是委托给一个叫做 op_arithI 的宏。我们来看一下。我要做的是非常简短地删掉这一段。你在这里看到的这一小段代码,是普通的 Lua 虚拟机。我们所做的,只是添加了一个对 yk_promote 函数的简单调用。yk_promote 的意思是:给我一个实际上在 8 到 64 字节之间的值;现在我会说,这个值在这个 trace 的上下文里是常量。同时我会留下一个 guard,以确保如果这个假设错了,JIT 优化也能处理这种情况。这就是我要做的全部事情:把 Lua 源代码里写的 64,变成 x86 机器码里的一个 64 位立即数。也就是说,我把我知道的语言层面的常量性,暴露给了 yk 这个系统。JIT 编译器很喜欢做这种事,而追踪式 JIT 编译器能做大量这类事情。
我一直在提到 guard。我所说的是,只要下次执行该 trace 时我们遇到的值仍然成立,这个 trace 就有效。如果 guard 求值为真,就继续执行。如果它变成假会怎样?那我们就必须去优化。我们在这里似乎真是搬起石头砸自己的脚,因为我们必须去优化回所谓正常的 C 代码——显然它已经被编译成机器码了。这个过程大致如下。程序启动时,我有一组栈帧,所以我会像平常一样,先为 main 函数在栈上创建一个栈帧。它很可能会调用一个类似 interpreter 的函数。你可能会想象,当我为某个特定循环执行 JIT 编译后的代码时,可能会在栈上创建一个新栈帧,这是最直观的方式。实际上,这两个栈帧会混在一起。当你进入 JIT 编译后的代码时,它会接管 C 解释器的栈;它会在一些相同的地方读写。当它需要时,它确实会额外扩展栈,因为它显然在做更多工作。那么,当我们遇到一个为假的 guard 时,就必须让系统从那种状态恢复到另一种状态,这样我们才能跳回 Clang 编译的普通 AOT 代码。这要怎么做呢?这就是我们之前看到的 yk IR,它是被序列化到解释器里的东西。我提到了这些 safepoint 行。本质上,只要有条件分支或函数调用,就会出现一个 safepoint。在这些点上,控制流可能发生分叉,你可能必须去优化。你可以看到,从第一个 safepoint 起,就有一串变量列表,它们是程序中该点上的活跃变量。我们必须把所有活跃变量恢复到 Clang 编译 AOT 二进制时的同一状态。每个 safepoint 都关联着 LLVM 里的一个概念:栈映射(stack map)。它会告诉我们类似下面的信息:变量 %0_0 将位于栈偏移 0x40 处以及 RAX 寄存器中;我们必须把值放到这些位置。它可能会说,变量 %0_1 是一个常量。太好了,这很完美,我们什么都不用做。它可能还会说,%0_2 在两个寄存器中,等等。去优化机制会把系统恢复到所有这些位置上,跳转到正确的地方,然后继续执行。
技术挑战
这里显然存在一两个问题。其中之一是,如果我在 JIT 编译代码中执行,而某些 C 代码取了栈上变量的地址,它可能指向下面这里。当我回到解释器时,那个地址已经无效了。我该如何处理?无论好坏,我们必须在每个线程上维护第二个影子栈(shadow stack)。我们把普通栈称为 C 栈。当你取一个变量的地址时,它会被放到影子栈上。正常程序中的每个函数都有一个栈帧,这些地址是稳定的。即使发生去优化,你拿到的地址也是来自右侧(影子栈),而不是来自左侧(C 栈)。系统根本不知道它曾进入 JIT 编译模式又回到了 C 解释器,它完全是透明的。不过,这也是有代价的。影子栈会让性能变慢一些。栈映射(stack map)在 LLVM 中并不是什么被遗忘的特性,但它是一个不太受重视的特性,没有得到太多打磨。有些优化 pass 不尊重它;有些代码生成器不幸地把指令放到了不该放的位置。为了让这些东西可靠工作,我们实际上不得不关闭一些优化。事实上,我们确实为此付出了代价,还要加上你之前看到的那些 record 函数。粗略来说(具体会有差异),不开启 JIT 的解释器会比正常慢 3 到 4 倍。而你之前看到的性能优势,是 JIT 正在赢回这部分开销并获取更多收益。这就是我们必须克服的。
我想让你感受另一个不同的挑战,因为我相信有些人会记得,很久以前 Firefox 的第一个 JavaScript VM 里有一个追踪式 JIT 编译器,叫做 TraceMonkey。TraceMonkey 很快就被替换掉了,因为它性能不是很好,不是一个出色的 VM。在我看来,人们由此过度概括,认为所有追踪式编译器都不好。我希望我在这里已经向你们展示,这很可能不是事实。可能确实存在某些上下文里它是个好主意;不是所有地方都适用,但有些地方是可以的。让我来谈谈为什么人们会认为它有一些非常糟糕的问题。我要展示一个追踪的坏例子。这是一个 Lua 循环。我想问大家一个问题:这是一个循环吗?你可以从我那可怕的语气里猜到,我问的是一个诱导性很强的问题。因为在 AOT 视角下,这显然是一个循环。你有点慌了,你知道接下来会发生一些不对头的事情。通常情况下,它显然是一个循环;它不会变成一只会飞的青蛙或什么的。但从追踪和迭代的角度来看,答案会变得奇怪得多、困难得多,最终归结为“也许吧”?下面这些操作码,是我可能从 Lua 动态编译操作码的机制中得到的;我稍微改了一下,但它能给你一个感觉。这里有开始和结束标签,查找变量,小于或等于跳转指令,等等。假设这个循环已经变热,我开始追踪下一次迭代。有两种可能发生的情况。我会从顶部开始,如果 i 大于零,那个小于或等于跳转就会直接落入下一条指令;我们继续向下走到循环末尾,再跳回开头。现在我们闭环了,我们追踪了一次迭代。像 yk 这样的追踪优化器处理这种东西简直是轻而易举;它非常开心,会做得非常出色,你会获得非常好的性能。但你也可能运气不好。如果 i 是 0 减 1 或类似的值,那个小于或等于跳转会立即跳过 while 循环体,直接跳到末尾。我们仍在记录,但我们不会闭环。事实上,如果你不走运,有时你会跑得很远很远,经过某种曲折的路径后才回到开头,最后得到长到荒谬的 trace。从根本上说,这没有完全绕开的办法。你可以改善这个问题,比如设置一个未完成的分支,本质上就是:如果你从那里出发却到了这里,那很可能是不好的,应该放弃。你永远无法完全修复它,因为你开始记录时并不知道接下来会执行什么。当你得到任何类型的坏 trace 时,性能都会差很多。我认为这是有时人们对追踪感到毛骨悚然的原因之一。正如我所说,我们可以改善,但你永远无法完全消除这个问题。这是一个真正的挑战。
回顾
让我们回顾一下我展示的内容。把一个 C 解释器经过非常少的代码改动、几乎不费什么成本就变成一个 JIT 编译器,这是可行的。虽然我稍微挑了一些好看的数据,但你能感觉到它可以加速执行。我在这里展示的是 alpha 质量的软件;它还没有达到生产就绪,但它远超一般的研究原型。我希望我们能继续把它推进得更远。
下一步
接下来呢?我们还没有支持完整的 LLVM。有些东西——比如说,如果你使用向量指令或向量类型,我们就只能表示“有待完成”。修复这个问题并非什么高深科学,但它背后有大量工程工作。我们想做更多优化。逃逸分析就是一个非常好的优化例子:如果有一个循环,你在其中分配了一些内存,对堆进行读写,在同一个迭代里释放内存,并且那个内存指针没有逃逸,那么你通常可以完全不做分配。这在追踪式编译器中非常有效。我们想做的正是这类事情,它们会带来更多加速。我觉得目前 yklua 的几何平均加速比大概能达到 3 倍,这是相当合理的。我们有一个仍在开发中的 MicroPython 解释器。我想接下来我们可能会研究 Pico Python。希望到那时我们能升级到“大人物”,也就是 CPython、CRuby 这类。从某个意义上说,它们在思想上并没有什么不同,但它们一定会用到许多我们还没有机会实现的功能。这就是接下来的计划。你可以自己下载并试用,它是开源的,所有部分都可以保留。
问答
参与者1: 我有一些千禧年之前的 Java 开发者背景。这个感觉非常像现代 HotSpot 的去优化,即退回解释模式,这非常酷。你需要为每种语言、每个解释器做机器指令层面的设置吗?
Laurence Tratt: HotSpot 是如今标准的 JVM 实现。你和我差不多是同一个时代的人,可能还记得 HotSpot 出现之前的日子。HotSpot 可能是史上最好的 JIT 编译器,最好的 VM——它现在实际上包含了三个 JIT 编译器,可能是有史以来实现得最好的一个。它高效得惊人。你可能还记得我在很多幻灯片中提到过 Jython,也就是运行在 JVM 上的 Python;它真的很慢。这很有意思,说明 HotSpot 并不是运行某些语言的绝佳载体,所以才有这类项目的空间。yk 会为你处理所有与机器相关的部分。你不需要关心自己是否在 x86 上;我们目前只在 x86 上运行。想象一下,如果我们写了 ARM 后端,那么你的解释器就会在 ARM 或其他平台上进行 JIT 编译。这一切对解释器作者来说完全透明。你确实需要为了让 yk 而对自己的解释器做一些调整,因为你希望暴露这种常量性,并且需要告诉它循环大致在哪里。你确实需要做一些调整,但这些调整并不是你可能担心的那种平台相关的东西。
参与者2: 我只是想确认一下。看起来你修改 Lua 代码时,修改的是 Lua 编译器吗?
Laurence Tratt: 那是 Lua 虚拟机,是的,我修改的是 Lua 解释器。
参与者2: 那是不是意味着,如果他们发布了 Lua 的新版本,你必须把你所有的改动重新应用到新版本上?
Laurence Tratt: 是的。总共 400 行代码。我觉得一个下午就能完成 rebase。
参与者2: 这就是为什么你仍然可以说,你可以跟上更新并保持差异。你可以非常轻松地创建新版本的 JIT。
Laurence Tratt: 完全正确。是的。我认为这正是这里真正不同的地方。有几行代码我们是侵入式修改的,但实际上,这些通常都是系统中稳定的部分。我们不会怎么去改库之类的。我们希望,随着时间的推移,保持更新是一件相对容易的工作。相对而言。
参与者3: 我也是 JIT 新手。我想问一下底层 C 代码中你添加 guard 和检查点的问题。我猜想,这些开销会不会因为分支预测器而趋于零?因为你总是走在安全路径上,直到你不走为止。
Laurence Tratt: 分支预测器确实很好,但并不完美。它们需要一段时间来学习你的代码。实际上,传统实现的解释器本身在分支预测友好性上出奇地差。有一个概念叫线程化分发(threaded dispatch)。通常的做法是——你记得我有一个很大的 switch 语句——你会回到 switch 语句的开头,然后进行分发。而在线程化分发中,你会在每个操作码结束时查好下一步要去哪里,然后直接跳到那里。这会让它更适应分支预测。不过,所有这些的开销并不会变成零,尽管它们可以非常高效。我还没有完全向你们展示的是:当我们编译一个 trace 时,许多 guard 会完全消失。我们能把它们优化掉,是因为可以从常量性中证明它们不需要。你看到那个 3.5 倍变慢的版本,很可能比更快版本多出五倍的 guard。即使每个 guard 只有一点点开销,如果我们能去掉它们,这也是性能最终叠加起来的一部分原因。
参与者3: 如果你的 guard 更少,但确实走错了路径,那么去优化时需要重建的状态就会糟糕得多。
Laurence Tratt: 是的,确实如此。那时我们就必须去优化,把系统恢复到原状,因为普通解释器可以处理任何程序行为;那时我们必须回到它,把它作为通用的真值来源。
参与者3: 那你几乎可以把这一点推到没有 guard 的极限。如果你真的非常自信,几乎可以做到每个程序只有一个 guard。
Laurence Tratt: 我还从来没这么想过。我不认为可以这样,因为那需要你向前看;你必须知道在未来的某个状态下,你会违背已经记录的 trace。我认为你还是需要一边往前走一边看到这些 guard。
查看更多 配有文字记录的演讲
录制于:[LOADING...] 2026年8月5日