
几十年来,编程语言一直面临一个根本性的两难困境:是为了最大化执行速度而进行预先编译(AOT),但代价是启动缓慢;还是为了即时启动而动态解释代码,但整体性能迟缓。这种在启动性能和稳态吞吐量之间的冲突,对于像网络服务器这类既要立即响应又要高效运行的应用来说,是一个严峻的挑战。既要启动快又要运行快的双重需求,推动了一种复杂解决方案的发展——它并非偏袒任何一方,而是将两者智能地融合在一起。
本文探讨了分层编译背后优雅的哲学思想,这是驱动大多数现代高性能语言运行时的自适应引擎。它提供了一个随着程序运行而学习和演化的系统,通过动态权衡来优化性能。您将了解到该系统如何通过将代码移上一个“性能阶梯”来实现其卓越成果。第一章“原理与机制”将解析此过程的核心组件,从最初剖析代码的解释器,到使用推测性优化、去优化和栈上替换来生成极快代码的强大即时(JIT)编译器。随后,“应用与跨学科联系”一章将揭示这些机制如何与操作系统、内存管理和硬件交互,以及它们如何应用于实时图形和系统安全等专业领域。
想象一下,你有一位杰出的厨师,能做出任何你想要的菜肴。你有两种与这位厨师合作的方式。第一种方式,我们称之为预先编译(AOT)方法,即你给厨师一份未来一年可能想吃的所有餐点的清单。厨师花一周时间把自己锁在厨房里,准备和预制所有东西。好处是?当你点菜时,几乎可以立即上桌。坏处是?最初的等待时间非常漫长,你在可能永远不会吃的餐点上耗费了大量资源,而且如果你突然想吃点菜单上没有的东西,那就没辙了。
第二种方式是纯解释器方法。你走进厨房,告诉厨师你想要什么,他们就从头开始——切菜、找香料等等。没有初始等待,但每道菜都需要完整的准备时间。这对于快速吃点零食来说很棒,但对于你计划每晚都吃的七道菜盛宴来说则糟透了。
几十年来,编程语言面临着同样的困境。你是选择为了最大化性能而预先编译所有东西,付出沉重的启动成本,并失去适应程序在运行时实际行为的能力?还是选择动态解释所有东西,即时启动但运行缓慢?这就是启动性能与稳态吞吐量之间的根本冲突。一个网络服务器需要即时处理传入的请求( 必须低),但它也需要高效地处理长时间运行的复杂任务( 必须低)。我们如何才能鱼与熊掌兼得呢?
事实证明,答案不是二选一,而是构建一个能在两者之间平滑过渡的系统。这就是分层编译背后美妙的思想。
与其看作单一选择,不如将分层编译想象成一个性能阶梯。你的某段代码,比如一个函数或一个循环,从最底层开始,只有在证明自己值得时才能向上攀升。
当一个函数第一次被调用时,它在解释器中运行。解释器就像我们那位现场烹饪的厨师;它逐条读取代码指令(即“字节码”)并直接执行。它相对较慢,但是最快的启动方式。
但解释器不仅仅是一个执行者;它还是一个间谍。在运行你的代码时,它在收集情报。这个过程称为剖析(profiling)。它观察、计数并做记录。最简单的形式是热度计数器。想象在循环的顶部放一个小小的计数器。每次循环运行时,计数器就“咔哒”一下。这告诉系统你的程序的哪些部分被大量使用。
当然,在现实的工程世界中,即使是像计数器这样简单的东西也有其精妙之处。这些计数器存储在有限的内存中,比如一个 12 位整数,它最多只能数到 。当一个超热的循环运行数百万次时会发生什么?计数器会“卡”在它的最大值,这种现象称为饱和。如果我们决定进入下一个、最优化的层次需要一个例如 的计数值,一个饱和的计数器意味着我们永远也达不到那个目标!系统对于代码到底有多热变得“视而不见”。
工程师们为此设计了巧妙的解决方案。一种是增加一个额外的比特位,一个“饱和”标志。一旦计数器达到最大值,该标志就翻转,表示“这段代码极其热门,相信我”。另一种更复杂的方法是在饱和后切换到概率性计数。一旦计数器满了,它在每次后续事件中仅以一个很小的概率(比如 )增加一个辅助计数器。然后,我们可以使用这个抽样计数来形成对真实、大得多的计数值的无偏估计。这是一种极其聪明的方法,用一点点精度换取了大大扩展的动态范围,同时只使用了极少的内存。
当一个函数的热度计数器超过某个阈值时,系统决定将其提升。它向上攀登阶梯。
1 层:基线 JIT。 第一次提升通常不会到最优化的级别。那样会耗时太长,而且可能小题大做。取而代之的是,代码被发送到一个基线即时(JIT)编译器。这个编译器会快速地将字节码翻译成原生机器码。它只执行最基本的优化。结果是代码比解释器快得多,而且编译暂停时间短到几乎无法察觉。
2 层(及以上):优化 JIT。 如果剖析器看到现在运行在 1 层的代码持续变得越来越热,那么终于到了动用重武器的时候了:优化 JIT 编译器。这个编译器是一项技术杰作。它花费更多的时间和 CPU,但它产生的代码可以快得惊人。至关重要的是,它利用低层级收集的丰富剖析数据来对代码的行为进行“猜测”。
攀登这最后一级阶梯的决定是一个谨慎的经济决策。系统会进行盈亏平衡分析:运行更快代码所节省的时间,在其预期的未来生命周期内,必须超过编译它的一次性成本。你不会为了一辆只开到街角商店一次的车,就花大价钱造一个 F1 赛车引擎。
这种监控和提升的过程引出了一个控制理论中的经典问题。如果一个函数的热度恰好在阈值附近波动怎么办?系统可能会陷入一个循环,无休止地提升然后又降级代码——这种现象称为颠簸(thrashing)。这就像一个设置为 70°F 的恒温器,在 69.9°F 时启动熔炉,在 70.1°F 时关闭,导致其不断地频繁启停。
解决方案是迟滞(hysteresis)。我们不使用一个阈值,而是使用两个。当代码热度超过一个高阈值 时我们提升它,但只有当它下降到一个不同的、更低的阈值 以下时我们才降级它。这在两个阈值之间创建了一个稳定的“无人区”,防止了振荡。
这个间隙应该多宽?其推导是一段美妙的第一性原理推理。这个间隙 必须足够宽,以克服两个混淆源:在一个测量间隔内可能发生的最大真实变化(我们称之为 ),加上我们测量“噪声”的全部不确定性范围(即 )。这给了我们一个优雅而深刻直观的规则:最小迟滞宽度必须是 。这是一个由简单微积分铸就的保证,确保系统不会被自己的影子所迷惑。
现代分层编译器的真正天才之处在于优化 JIT 如何使用剖析数据。它不仅仅优化它确切知道的东西;它还进行有根据的猜测。这被称为推测性优化。
想象一段代码对变量 x 进行计算。剖析器注意到,在这段代码的一百万次执行中,x 一直是整数。优化 JIT 于是可以做出一个大胆的推测:“我打赌 x 永远是整数。”它编译一个为整数数学运算高度特化的代码版本,这比必须处理任何可能数据类型的代码要快得多。
但如果这个赌注错了呢?如果在第一百万零一次执行时,x 突然变成了一个字符串怎么办?特化的代码现在是无效的,并且有潜在危险。这时,安全网就派上用场了。JIT 在其特化代码的入口处插入一个微小而快速的检查,称为守卫(guard)。守卫的唯一工作就是检查假设是否仍然成立(例如,“x 是整数吗?”)。如果成立,执行就飞速进行。如果不成立,守卫失败,并触发去优化。
去优化是紧急弹出按钮。系统立即丢弃无效的优化代码,并将执行安全地转移回一个较慢、更通用的层级,比如基线 JIT 或解释器,它们知道如何处理意外情况。这个机制是性能和正确性的基石。没有它,可能会出现微妙的错误。例如,一个 JIT 优化可能会移除垃圾回收器需要的某个检查。如果后来的层级变化违反了 JIT 的原始假设(例如,通过在不同的内存区域分配对象),这个缺失的检查可能导致垃圾回收器过早地释放活动内存,从而引发崩溃。去优化确保当假设改变时,系统会恢复到一个已知的正确状态。
这就引出了一个有趣的问题。如果一个循环已经在慢速解释器中运行了十亿次,而我们终于准备好了一个超级优化的版本,我们必须等待循环结束才能使用它吗?答案是响亮的“不”,这要归功于一个令人难以置信的机制,叫做栈上替换(On-Stack Replacement, OSR)。
OSR 允许运行时在函数执行的中途从慢速版本切换到快速版本。为此,它必须进行一种移植。它暂停在解释器中的执行,为函数的确切状态拍下一张“快照”——每个活动变量的值、当前的程序计数器——然后小心地将这个状态映射到新优化代码的世界中,再从那里恢复执行。这就像在汽车高速行驶时完美地更换其引擎一样。这些特性的组合——分层编译、OSR、推测、去优化——赋予了现代语言运行时令人难以置信的性能。如果你从外部观察这样一个系统,你会看到一些蛛丝马迹:一个程序开始很慢,然后突然加速;一个随着新层级被编译而增长的“代码缓存”;去优化发生时的短暂抖动,随后又恢复过来。
因此,分层编译不是单一的机制,而是一种哲学。它认识到程序执行是一个动态过程,优化它的最佳方式是观察并适应它。它创造了一个选择的光谱,从解释器的即时启动、灵活的世界到 AOT 代码的预编译、僵硬的世界。通过在不同层级间移动代码,系统不断地在这个绑定时间光谱上滑动,试图为程序的每一个部分找到灵活性和原始性能之间的最佳平衡点。
而这个由相互作用的部件组成的复杂而美妙的交响曲,完全是为了一个非常人性化的目标。例如,一个调优良好的系统知道,当你在打字时,响应性是王道。它会主动抑制在用户输入爆发期间进行昂贵的 JIT 编译,因为即使是微小的编译暂停也会被用户感知为界面的卡顿。它足够聪明,知道有时候,最快的事情就是等待。这是一个不仅让我们的代码运行得快,而且尊重我们对时间感知的系统,提供了既好又可衡量的性能。
在了解了分层编译的原理之后,人们可能会留下这样一种印象:这是一个优雅、自成一体的机制。但它真正的美,如同任何深刻的科学思想一样,不在于其孤立性,而在于其联系。分层编译不仅仅是编译器教科书中的一个章节;它是现代高性能计算中心一个充满活力的跳动心脏,其动脉延伸至操作系统、计算机体系结构、安全甚至实时图形领域。这是一个关于权衡、适应以及计算机系统不同部分之间美妙而复杂共舞的故事。
从本质上讲,分层编译是运行时系统对一个根本性两难困境的回答:一个程序如何能够快速启动,又能达到惊人的峰值性能?你无法同时兼得。F1赛车速度很快,但你不会开它去杂货店买东西;它需要漫长的预热。同样,对于只运行一次的代码,激进且耗时的优化纯属浪费。
分层系统通过适应性来解决这个问题。它们开始时通过解释或使用一个快速但简单的“基线”编译器来让程序立即运行起来。这就是“去商店的短途旅行”。然后,随着程序的运行,系统会进行观察。它收集数据。代码的哪些部分是“日常通勤”——那些执行了数千次的热点循环和函数?对于这些,也只有对于这些,它才会支付高昂的前期成本,启动其强大的优化编译器。
但在这个过渡期间会发生什么?如果你交互式应用的主线程,比如说一个正在渲染复杂页面的网络浏览器,为了等待优化编译器而简单地停顿 40 毫秒,用户会看到一个刺眼的冻结。这种停顿,或称“卡顿”,是流畅用户体验的大敌。现代系统采用巧妙的策略来隐藏这一成本。优化编译可以被卸载到一个后台线程,让应用程序继续使用稍慢的基线代码运行。一旦优化版本准备就绪,一个名为栈上替换(OSR)的非凡运行时手术可以无缝地将新的、更快的代码换入正在运行的循环中,将停顿时间最小化到几乎无法察觉的瞬间。这种在吞吐量和延迟之间的精妙平衡是即时(JIT)编译器设计的基本工艺。
这个自适应过程不是一条盲目的单行道,而是一段智能的、数据驱动的旅程。较低的层级充当侦察兵。解释器(0 层)可能会观察到一个理论上可以调用几十种不同方法的函数调用,在实践中总是调用同一个。它可以安装一个快速检查,即内联缓存,来加速这个过程。随着代码变热并被提升到基线 JIT(1 层),编译器可以加强这种优化,同时继续收集更详细的统计数据,比如所有见过的对象类型的频率图。最后,当代码被认为值得顶级优化编译器(2 层)处理时,这个丰富的剖析数据宝库就会被传递过去。优化器随后可以做出大胆的、推测性的假设——例如,为前两三种最常见的对象类型生成高度特化的代码,并为罕见情况创建一个通往较慢通用路径的出口。系统甚至会从错误中学习。如果一个曾经占主导地位的代码路径由于程序行为的改变而变得罕见,系统可以“去优化”,丢弃现在效率低下的特化代码,并恢复到一个更通用的版本,也许是为了稍后为新的模式重新进行特化。这是一个活的系统,不断适应程序变化的环境。
分层编译器并非生活在真空中。它是一个更大生态系统的公民,其行为与操作系统和内存管理器的行为深度交织。
考虑与垃圾回收(GC)的关系。现代并发垃圾回收器在后台工作以清理内存,它们依赖于“屏障”——在每次指针写入或读取时运行的小段代码,以帮助 GC 跟踪对象关系。这些屏障虽然对正确性至关重要,但增加了开销。优化编译器可以使用静态分析来证明许多这些屏障在特定上下文中是冗余的,并消除它们。然而,编译器的证明可能基于一些可能被 GC 本身推翻的假设——例如,一个对象从“年轻代”提升到“老年代”。为了保持正确性,JIT 编译的代码中点缀着守卫。如果 GC 在指定的“安全点”以一种违反编译器假设的方式改变了世界,优化代码会立即去优化,回到一个包含所有屏障的安全、较慢的版本。这是一个协作工程的美妙例子,编译器为性能进行推测性优化,同时尊重内存管理器确保安全的绝对权威。
与操作系统的互动可能更加令人惊讶。想象一个带有 JIT 编译器的程序,它使用 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 系统调用创建一个子进程,这是服务器应用程序中的常见模式。[fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 被设计为快速的,使用“写时复制”(COW)机制,即子进程最初共享父进程的内存。只有当一个进程写入内存页时,操作系统才会制作一个私有副本。但 JIT 编译的代码本身呢?由于 JIT 经常需要修补或添加新代码,它必须写入其代码缓存。这个写操作会触发一个 COW 故障,导致父子进程现在拥有独立、分叉的编译代码副本。如果两个进程继续运行相同的程序,它们都将浪费地重新编译相同的函数。解决方案是一个巧妙的操作系统级工程:JIT 代码被放置在一个共享内存区域中,该区域在每个进程中被映射两次——一次是可写的(供 JIT 编译使用),一次是可执行的(供 CPU 运行)。这满足了禁止内存同时可写和可执行的安全策略,同时允许编译后的代码在进程之间无缝共享。
JIT 编译向操作系统靠拢的趋势在其被内核本身采用时达到顶峰。像 eBPF 这样的技术允许沙盒化的、JIT 编译的程序在内核中运行,以实现高性能网络和追踪。在这里,风险无限高;一个 bug 可能会使整个系统崩溃。因此,JIT 之前有一个严格的静态验证器,它充当守门人。它必须在数学上证明程序是安全的——它不会访问无效内存或陷入无限循环——然后才允许 JIT 接触它。这种验证器保安全、JIT 提性能的组合,开辟了一个安全、可编程的内核扩展新世界。
分层编译的原理如此强大,以至于它们在高度专业化的领域中也找到了用武之地。在实时计算机图形学中,每一毫秒都至关重要。为了实现流畅的每秒 60 帧,每一帧必须在 16.67 毫秒内渲染完成。任何一帧错过了这个预算都会导致可见的“抖动”。图形着色器可能极其复杂,其性能特征取决于动态场景参数,如活动光源的数量。自适应着色器编译器可以像 JIT 一样工作,为当前的光源数量动态编译着色器的特化版本。如果一个场景持续使用八个光源,编译器可以生成一个为恰好八个光源完美展开和优化的着色器。挑战一如既往,是编译成本。同步编译本身可能会导致抖动。从异步后台编译到为常见场景预编译变体(“预热”),人们采用了不同的策略来获得特化的好处,而不付出卡顿的代价。
然而,这种动态优化和重构代码的能力是一把双刃剑。在计算机安全的世界里,可预测性通常是一种美德。侧信道攻击,例如那些测量缓存计时的攻击,依赖于秘密操作与可测量的微架构效应之间的稳定关系。分层 JIT 的不确定性——它能够重排指令、改变代码布局以及根据微妙的时间变化进行去优化——可能会破坏这种稳定性,使攻击者更难获得可靠的信号。然而,这种相同的适应性也可能无意中创造出新的、更强大的泄漏路径。理解和控制这种行为是安全研究的一个关键前沿。为了分析程序是否存在潜在泄漏,安全专家可能会完全禁用自适应 JIT,强制代码在固定的、可复现的预先编译(AOT)模式下运行,甚至在缓慢但可预测的解释器中运行,以性能换取分析的清晰度。
最终,JIT 编译器做出的每一个决定都是一个经济决策。每一种潜在的优化都是一种权衡。“我应该内联这个函数吗?我应该展开这个循环吗?我应该把这个分支转换成一个谓词指令吗?”答案总是:“视情况而定。”
编译器就像一个预算有限的精明投资者。潜在的投资回报是代码剩余生命周期内节省的总运行时间。成本是编译所花费的时间。JIT 使用来自较低层级的剖析数据来估计这些值。它会问:这个函数还会被调用多少次?这个分支被采纳的概率是多少? 基于这些估计,它解决了一个类似“背包问题”的问题:在给定的编译预算下,选择能产生最大预期净收益的优化集合。这不是魔法;这是一个经过计算的、定量的过程。
从用户的角度来看,一个程序在你使用它时只是变得越来越快。但在幕后,分层编译器正进行着一个不懈而迷人的过程:观察、预测、推测和适应。它是一个智能的缩影,一个学习和演化的系统,不断努力在准备和性能这一普适的权衡中找到最佳点。它是使现代软件成为可能的、美妙而相互关联的复杂性的证明。