文章目录
- Explain
- 一、简介
- 二、一个跟踪运行的示例
- 三、跟踪树
- 3.1 Traces
- 类型特化(Type specialization)
- 3.2 Trace Trees
- 3.3 黑名单(Blacklisting)
- 四、嵌套跟踪树
- 4.1 Nesting Algorithm
- 4.2 Blacklisting with Nesting
- 五、跟踪树优化
- 5.1 Optimizations
- 5.2 Register Allocation
- 六、Implementation
- 6.1 Calling Compiled Traces
- 6.2 Trace Stitching
- 6.3 Trace Recording
- 6.4 Preemption
- 6.5 Calling External Functions
- 6.6 Correctness(正确性)
Explain
本片文章是对发表于 “ACM SIGPLAN NOTICES” 期刊的论文《Trace-based Just-in-Time Type Specialization for Dynamic Languages》的翻译。
论文在 JavaScript 的解释器中已经实现,作者将实现后的JIT编译器叫做 TraceMonkey。LuaJIT 的实现与 TraceMonkey 有很多相似之处,所以这篇文章对于理解 LuaJIT很有帮助。
一、简介
动态语⾔JavaScript、Python 和 Ruby 等语⾔⾮常流⾏,因为它们表达能⼒强、⾮专业⼈⼠也能轻松使⽤,并且部署起来就像分发源⽂件⼀样简单。它们既可⽤于⼩型脚本,也可⽤于复杂的应⽤程序。例如,JavaScript 是客户端 Web 编程的事实标准并⽤于基于浏览器的⽣产⼒应⽤程序(例如 Google Mail、Google Docs 和 Zimbra Collaboration Suite)的应⽤程序逻辑。在这个领域,为了提供流畅的⽤户体验以及⽀持新⼀代应⽤程序,虚拟机必须提供较低的启动时间和较⾼的性能。
静态类型语⾔的编译器依靠类型信息来⽣成⾼效的机器代码。在 JavaScript 等动态类型编程语⾔中,表达式的类型可能会在运⾏时发⽣变化。这意味着编译器⽆法再轻松地将操作转换为针对某⼀特定类型的机器指令。如果没有确切的类型信息,编译器必须⽣成速度较慢的通⽤机器代码,以处理所有潜在的类型组合。虽然编译时静态类型推断可能能够收集类型信息以⽣成优化的机器代码,但传统的静态分析⾮常昂贵,因此不太适合 Web 浏览器的⾼度交互环境。
我们提出了⼀种基于跟踪(trace-based)的动态语⾔编译技术,该技术可以兼顾编译速度和⽣成的机器代码的出⾊性能。我们的系统使⽤混合模式执⾏⽅法:系统在快速启动的字节码(bytecode)解释器中开始运⾏ JavaScript。在程序运⾏时,系统识别热的(hot,即执⾏频繁)的字节码序列,记录它们,并将它们编译为快速本机代码。我们将这样的指令序列称为跟踪(trace)。
与基于方法(method-based)的动态编译器不同,我们的动态编译器以单个循环的粒度运行。这种设计选择基于这样的预期:程序大部分时间都花在热循环上。即使在动态类型语言中,我们也期望热循环大多是类型稳定的,这意味着值的类型是不变的。例如,我们期望以整数开始的循环计数器在所有迭代中都保持整数。当这两个期望都成立时,基于跟踪(trace-based)的编译器可以用少量类型专门的、高效编译的跟踪(trace)来覆盖程序执行。
每个编译的跟踪(trace)都涵盖了程序中的一条路径,并将值映射到类型。当 VM 执行编译跟踪时,它无法保证将遵循相同的路径,也⽆法保证在后续循环迭代中会出现相同的类型。因此,记录(record)和编译跟踪会推测,在循环的后续迭代中,路径和类型将与记录期间完全相同。
每个编译的跟踪都包含所有守卫(guards,用于检查推测是否符合预期)来验证推测。如果其中⼀个守卫失败(即控制流不同,或者⽣成了不同类型的值),则跟踪退出。如果退出变得热(hot),VM 就会记录(record)⼀个分支跟踪(branch trace)从出⼝处开始覆盖新路径。这样,VM 记录了⼀个追踪树(trace tree)覆盖循环中的所有热路径。
嵌套循环(nested loops)可能难以针对跟踪 VM 进行优化。在一个简单的实现中,内循环(inner loop)将首先变得热,并且 VM 将从那里开始跟踪。当内循环退出时,VM 将检测到已采用不同的分支。VM 将尝试记录分支跟踪,并发现跟踪到达的不是内循环头,而是外循环(outer loop)头。此时,VM 可以继续跟踪,直到再次到达内循环头,从而在内循环的跟踪树内跟踪外循环。但这需要为内循环中的每个侧出口(side exit)和类型组合( type combination)跟踪外循环的副本。本质上,这是一种意外尾部重复的形式,很容易溢出代码缓存( code cache)。或者,VM 可以简单地停止跟踪,并放弃跟踪外循环。
我们通过记录嵌套跟踪树(nested trace trees)来解决嵌套循环问题。我们的系统对内循环的跟踪与原始版本完全相同。当到达外循环时,系统停⽌扩展内树(inner tree),但随后在外循环头开始新的跟踪。当外循环到达内循环头时,系统会尝试调用内循环的跟踪树。如果调用成功,VM 会将对内树的调用记录为外跟踪的一部分,并正常完成外跟踪。这样,我们的系统可以跟踪嵌套到任意深度和数量的循环,而不会导致过多的尾部重复。
这些技术允许 VM 动态地将程序转换为嵌套的、类型专⻔化的跟踪树。由于跟踪可以跨越函数调⽤边界,我们的技术也可以实现内联(inline)的效果。由于跟踪没有内部控制流连接,所以跟踪生成的中间代码是线性的,我们可以让编译器在线性时间内进行一些简单的优化。因此,我们的跟踪 VM 可以有效地执⾏在静态优化设置中需要过程间分析的同类优化。这使得跟踪成为⼀种有吸引⼒且有效的⼯具,可以对复杂的函数调⽤丰富的代码进⾏类型专⻔化。
我们为现有的 JavaScript 解释器 SpiderMonkey 实现了这些技术。我们将基于跟踪的VM称为TraceMonkey。TraceMonkey ⽀持 SpiderMonkey 的所有 JavaScript 特性,可使可追踪程序的运⾏速度提⾼ 2 到 20 倍。
二、一个跟踪运行的示例
本节通过描述 TraceMonkey 如何执行示例程序来概述我们的系统。示例程序如下所示,使用嵌套循环计算前 100 个素数。
for (var i = 2; i < 100; ++i) {if (!primes[i])continue;for (var k = i + i; i < 100; k += i)primes[k] = false;
}
下图的状态机描述了 Trace Monkey 的主要活动以及导致转换到新活动的条件,本节的叙述应与图中的内容一起阅读。在深色框中,TM 将 JS 作为编译的跟踪(complied trace)执行。在浅灰色框中,TM 在标准解释器(interpret)中执行 JS。白框是开销。因此,为了最大限度地提高性能,我们需要最大限度地增加在最暗的框中花费的时间,并最大限度地减少在白框中花费的时间。最好的情况是 loop edge 的类型与 entry 的类型相同——然后 TM 可以停留在本机代码中,直到循环完成。
TraceMonkey 总是在字节码(bytecode)解释器中开始执行程序。每个循环后沿边( loop back edge)都是一个潜在的跟踪点( trace point)。当解释器越过循环边(loop edge)时,TraceMonkey 会调用跟踪监视器( trace monitor),它可能决定记录或执行 native 跟踪。在执行开始时,还没有编译跟踪,因此跟踪监视器会计算每个循环后沿边执行的次数,直到循环变热,目前在 2 次执行之后变热。请注意,我们的循环编译方式是,循环边(loop edge)在进入循环之前被执行,因此第二次执行发生在第一次迭代之后。
以下是按外循环迭代分解的事件序列:
-
i=2。这是外循环的第一次迭代。第 4-5 行的循环在第二次迭代时变得很热,因此 TraceMonkey 在第 4 行进入记录模式。在记录模式下,TraceMonkey 会将跟踪的代码记录在我们称为 LIR 的低级编译器中间表示中。LIR 跟踪会对所有执行的操作和所有操作数的类型进行编码。LIR 跟踪还会对守卫(guards)进行编码,这些守卫是用于检查验证控制流和类型是否与跟踪记录期间观察到的控制流和类型相同。因此,在以后的执行中,当且仅当所有守卫指令都通过时,跟踪才具有所需的程序语义。
当执⾏返回到循环头或退出循环时,TraceMonkey 停⽌记录。在本例中,执⾏返回到第 4 ⾏的循环头。
记录完成后,TraceMonkey 使用记录的类型信息将跟踪(trace)编译为 native 代码以进行优化。结果是一个本机代码片段,如果解释器 PC 和值的类型与跟踪记录开始时观察到的相匹配,则可以输入该片段。我们示例中的第一个跟踪 T 45 T_{45} T45 覆盖了第 4 行和第 5 行。如果 PC 在第 4 行,i 和 k 是整数,并且 primes 是一个对象,则可以输入此跟踪。编译 T 45 T_{45} T45 后,TraceMonkey 返回到解释器并循环回到第 1 行。 -
i=3。现在第 1 行的循环头已变为热循环,因此 Trace Monkey 开始记录。当记录到达第 4 行时,Trace Monkey 观察到它到达已被编译为跟踪的内循环头,因此 TraceMonkey 尝试将内循环嵌套在当前跟踪(外循环的trace)中。首先是将内跟踪作为子程序调用,这将执行第 4 行上的循环直至完成,然后返回到记录器。TraceMonkey 验证调用是否成功,然后将对内跟踪的调用记录为当前跟踪的一部分。记录持续到执行到第 1 行,此时 TraceMonkey 完成并为外循环 T 16 T_{16} T16 编译跟踪。
-
i=4。在此迭代中,TraceMonkey 调用 T 16 T_{16} T16。由于 i=4,因此采用第 2 行上的 if 语句,而此分支未在原始跟踪中采用,因此这导致 T 16 T_{16} T16 未通过守卫并从侧面退出(side exit)。出口尚未热,因此 TraceMonkey 返回到解释器,解释器执行 continue 语句。
-
i=5。TraceMonkey 调用 T 16 T_{16} T16,后者又调用嵌套跟踪 T 45 T_{45} T45。 T 16 T_{16} T16 循环回到其自己的标题,开始下一次迭代,而无需返回监视器(monitor)。
-
i=6。在此迭代中,再次采用第 2 行的侧出口(side exit)。这次,侧出口变得很热,因此记录了一条覆盖第 3 行并返回到循环头的跟踪 T 23 , 1 T_{23,1} T23,1。因此, T 23 , 1 T_{23,1} T23,1的末尾直接跳转到 T 16 T_{16} T16 的开头。侧出口已修补,因此在未来的迭代中,它直接跳转到 T 23 , 1 T_{23,1} T23,1。此时,TraceMonkey 已编译足够的跟踪来覆盖整个嵌套循环结构,因此程序的其余部分完全作为本机代码运行。
三、跟踪树
在本节中,我们将描述跟踪、跟踪树以及它们在运行时的形成方式。虽然我们的技术适用于任何动态语言解释器,但为了使说明简单,我们将假设使用字节码(bytecode)解释器来描述它们。
3.1 Traces
跟踪只是一条程序路径,它可能跨越函数调用边界。TraceMonkey 专注于循环跟踪(loop traces),它起源于循环边(loop edge)并表示通过相关循环的单次迭代。
与扩展基本块(extended basic block,编译器设计中有此概念)类似,跟踪仅在顶部进入,但可能有许多出口。与扩展基本块相比,跟踪可以包含连接节点。但是,由于跟踪始终只遵循原始程序中的一条路径,因此连接节点在跟踪中无法识别,并且像常规节点一样具有单个前趋节点。
类型化跟踪(typed trace)是使用类型注释跟踪上的每个变量(包括临时变量)的跟踪。类型化跟踪还具有一个类型映射(type map)的条目(entry),在定义跟踪中使用的变量之前,提供所需的类型。例如,跟踪可以具有类型映射(x:int,b:boolean),这意味着只有当变量 x 的值是 int 类型并且 b 的值是 boolean 类型时,才可以进入跟踪。类型映射的条目非常类似于函数的签名。
在本文中,我们仅讨论类型化循环跟踪,我们将其简称为“跟踪”。类型化循环跟踪的关键属性是,可以使用与类型化语言相同的技术将它们编译为高效的机器代码。
在 TraceMonkey 中,记录跟踪使用的是 trace-flavored(跟踪风格)的 SSA LIR(低级中间表示)。在 trace-flavored 的 SSA(或 TSSA)中,phi 节点仅出现在入口点,入口点可通过入口和循环边( loop edge)到达。重要的 LIR 原语(primitive)是常量值、内存加载和存储(按地址和偏移量)、整数运算符、浮点运算符、函数调用和条件退出。类型转换(例如整数到双精度)由函数调用表示。这使得 TraceMonkey 使用的 LIR 独立于源语言的具体类型系统和类型转换规则。LIR 操作足够通用,后端编译器与语言无关。下列是一个示例跟踪的LIR 。
v0 := ld state[748] // load primes from the trace activation recordst sp[0], v0 // store primes to interpreter stack
v1 := ld state[764] // load k from the trace activation record
v2 := i2f(v1) // convert k from int to doublest sp[8], v1 // store k to interpreter stackst sp[16], 0 // store false to interpreter stack
v3 := ld v0[4] // load class word for primes
v4 := and v3, -4 // mask out object class tag for primes
v5 := eq v4, Array // test whether primes is an arrayxf v5 // side exit if v5 is false
v6 := js_Array_set(v0, v2, false) // call function to set array element
v7 := eq v6, 0 // test return value from callxt v7 // side exit if js_Array_set returns false.
字节码解释器通常以盒装格式(boxed format,即带有附加类型标记位(attached type tag bits),可以理解为集合)表示各种复杂数据结构(例如哈希表)中的值。由于跟踪旨在表示消除所有复杂性的高效代码,因此我们的跟踪尽可能多地对简单变量和数组中的未装箱(unboxed)值进行操作。跟踪将其所有中间值记录在一个小的活动记录(activation record)区域中。为了使跟踪中的变量访问速度更快,跟踪还通过取消装箱并将它们复制到其活动记录来导入本地和全局变量。因此,跟踪可以使用来自 native 活动记录的简单加载和存储来读取和写入这些变量,而不受解释器使用的装箱机制的影响。当跟踪退出时,VM 会将值从此 native 存储位置装箱并将它们复制回解释器结构。
对于源程序中的每个控制流分支,记录器都会生成条件退出 LIR 指令。如果所需的控制流与跟踪记录时的控制流不同,则这些指令将从跟踪中退出,从而确保跟踪指令仅在应该运行时运行。我们将这些指令称为守卫(guard)指令。
我们的大多数跟踪都表示循环并以特殊的循环 LIR 指令结束,这种LIR 指令只是一个无条件分支到跟踪的顶部,因此跟踪仅通过守卫指令返回或结束循环。
现在,我们描述记录跟踪生成LIR时所执行的部分关键优化。所有这些优化都通过专门针对当前跟踪,将复杂的动态语言构造简化为简单的类型构造。每个优化都需要守卫指令来验证它们对状态的假设并在必要时退出跟踪。
类型特化(Type specialization)
所有 LIR 原语(primitives)都适用于特定类型的操作数。因此,LIR 跟踪必然是类型特化的,编译器可以轻松生成不需要类型分派(type dispatch)的转换。典型的字节码解释器会随每个值携带标记位(tag bits),并且在执行任何操作前,必须先检查标记位、动态分派(dynamically dispatch),再屏蔽标记位以恢复未标记的值,使用未标记的值执行操作,然后对计算的结果重新应用标记。
下图是 SpiderMonkey JavaScript 解释器中的标记值。测试标记、解箱(unboxing,提取未标记的值)和装箱(boxing,创建标记值)是显著的开销,避免这些开销是跟踪编译的一个主要优点。
LIR 忽略操作本身以外的所有内容。一个潜在的问题是,某些操作可能会产生不可预测类型的值。例如,从对象读取的值可能会产生任何类型,而不一定是记录期间观察到的类型。记录器生成的守卫指令,如果操作产生的值与记录期间看到的值的类型不同,则有条件退出。这些守卫指令保证只要执行在跟踪中,值的类型就与跟踪的类型相匹配。当 VM 沿着此类类型守卫观察到 side exit 时,会记录源自 side exit 位置的新类型跟踪,从而捕获所讨论操作的新类型。
对象特化。在 JavaScript 中,名称查找语义很复杂,并且可能很昂贵,因为它们包括对象继承和 eval 等特性。要评估像 o.x 这样的对象属性读取表达式,解释器必须搜索 o 及其所有原型和父级的属性映射。属性映射可以用不同的数据结构(例如,每个对象的哈希表或共享哈希表)实现,因此搜索过程还必须根据搜索过程中找到的每个对象的表示进行分派。TraceMonkey 可以简单地观察搜索过程的结果并记录最简单的 LIR 以访问属性值。例如,搜索可能会在 o 的原型中找到 o.x 的值,它使用共享哈希表表示将 x 放在属性 vector 的slot 2 中。然后,记录可以生成仅用两到三个加载即可读取 o.x 的 LIR:一个用于获取原型,可能一个用于获取属性值 vector ,还有一个用于从 vector 中获取slot 2。与原始解释器代码相比,这是一个巨大的简化和加速。继承关系和对象表示在执行过程中可能会发生变化,因此简化的代码需要守卫指令来确保对象表示相同。在 TraceMonkey 中,对象的表示被分配了一个称为对象形状(object shape)的整数键。因此,守卫是对对象形状的简单相等性检查。
数字特化。JavaScript 没有整数类型,只有 Number 类型,即一组 64 位 IEEE-754 浮点数(“双精度数”)。但许多 JavaScript 运算符(尤其是数组访问和按位运算符)实际上都是对整数进行操作,因此它们首先将数字转换为整数,然后将任何整数结果转换回双精度数。显然,想要快速运行的 JavaScript VM 必须找到一种直接对整数进行操作的方法,并避免这些转换。在 TraceMonkey 中,我们支持两种数字表示:整数和双精度数。解释器尽可能多地使用整数表示,无法用整数表示则切换为双精度数的表示。启动跟踪时,可能会导入某些值并将其表示为整数。对整数的某些操作需要守卫,例如,将两个整数相加可能会产生一个对于整数表示来说太大的值(加法溢出)。
函数内联。LIR 跟踪可以跨越任一方向的函数边界,从而实现函数内联。需要记录函数入口和出口用来复制参数和返回值的 mov 指令,然后,编译器使用复制传播优化这些 mov 语句。为了能够返回到解释器,跟踪还必须生成 LIR 来记录已进入和退出调用栈帧(call frame)。frame 入口和出口 LIR 保存的信息足以允许稍后恢复解释器调用堆栈(call stack),并且比解释器的标准调用代码简单得多。如果输入的函数不是常量(在 JavaScript 中包括任何按函数名调用),记录器还必须发出 LIR 来确保该函数是相同的。
guard 和 side exit。上面描述的每个优化都需要一个或多个守卫来验证在进行优化时所做的假设,守卫只是一组执行测试和条件退出的 LIR 指令。exit 分支到一个侧出口(side exit),这是 LIR 的一小段跟踪外(off-trace)片段,它返回一个指向一个结构的指针,该结构描述退出的原因以及出口点处的解释器 PC 和恢复解释器状态结构所需的任何其他数据。
中止(abort)。有些构造很难在 LIR 跟踪中记录。例如,eval 或对外部函数的调用可能会以不可预测的方式改变程序状态,使得跟踪器难以知道当前类型映射以继续跟踪。跟踪实现还可以有许多其他限制,例如,小内存设备可能会限制跟踪的长度。当发生任何阻止实现继续跟踪记录的情况时,实现将中止跟踪记录并返回到跟踪监视器。
3.2 Trace Trees
特别简单的循环,即控制流、值类型、值表示和内联函数都是不变的循环,可以用单个跟踪来表示。但大多数循环至少有一些变化,因此程序将从主跟踪中获取侧出口( side exit)。当侧出口变得热时,TraceMonkey 会从该点开始新的分支跟踪(branch trace),并修补侧出口以直接跳转到该跟踪。这样,单个跟踪可以根据需要扩展为单入口、多出口的跟踪树。
本节介绍如何在执行过程中形成跟踪树。目标是在执行过程中形成覆盖程序所有热路径的跟踪树。
开始一棵树。树总是从循环头开始,因为它们是寻找热路径的天然位置。在 TraceMonkey 中,循环头(后向分支的目标)的字节码很容易被检测出来。当给定的循环头执行了一定次数(当前实现中为 2 次)时,TraceMonkey 会开始一棵树。开始一棵树只是意味着开始记录当前点和类型映射的跟踪,并将该跟踪标记为树的根(root)。每棵树都与一个循环头和类型映射相关联,因此给定的循环头可能会有多棵树。
关闭循环。跟踪记录可以以多种方式结束。
- 理想情况下,跟踪到达循环头时,其类型映射与入口(entry)相同,这称为类型稳定(type-stable)的循环迭代。在这种情况下,跟踪的结尾可以直接跳转到开头,因为所有值表示都与进入跟踪所需的完全相同。跳转甚至可以跳过通用的代码,这些代码会将当前跟踪结束时的状态复制到跟踪的活动记录中。
- 在某些情况下,跟踪可能会到达具有不同类型映射的循环头,这种情况有时会在循环的第一次迭代中观察到。循环中的某些变量最初可能未定义,然后在第一次循环迭代期间将它们设置为具体类型。在记录这样的迭代时,记录器无法将跟踪链接回其自己的循环头,因为它是类型不稳定的。相反,迭代以侧出口终止,该出口始终会失败并返回到解释器。同时,使用新的类型映射记录新的跟踪。每次将额外的类型不稳定跟踪添加到区域时,都会将其退出类型映射与所有现有跟踪的入口映射进行比较,以防它们相互补充。通过这种方法,我们能够覆盖类型不稳定的循环迭代,只要它们最终形成稳定的平衡。
- 最后,跟踪可能会在到达循环头之前退出循环,例如因为执行到达 break 或 return 语句。在这种情况下,VM 只需退出跟踪监视器即可结束跟踪。
如前所述,我们可能会推测性地选择在跟踪上将某些 Number(双精度)类型的值表示为整数。当我们观察到 Number 类型的变量在跟踪入口处包含整数值时,我们就会这样做。如果在跟踪记录期间意外地为变量分配了一个非整数值,我们必须将变量的类型扩展为 Number,则记录的跟踪将会变得类型不稳定,因为它以整数值开始,但以双精度值结束。这代表了错误的推测,因为在跟踪入口处,我们将 Number 类型的值特化为整数,假设在循环边(loop edge)我们会再次在变量中找到一个整数值,从而允许我们关闭循环。为了避免将来涉及此变量的推测失败,并获得类型稳定的跟踪,我们注意到,所讨论的变量有时在我们称为“oracle”的建议数据结构中保存非整数值。
在编译循环时,我们在将值专门化为整数之前咨询 oracle。只有当 oracle 不知道有关该特定变量的不利信息时,才会对整数进行推测。每当我们意外地编译出一个由于对 Number 类型变量的错误推测而导致类型不稳定的循环时,我们就会立即触发新跟踪的记录,该跟踪将基于现在更新的 oracle 信息以双精度值开始,从而变为类型稳定。
扩展树。侧出⼝会通向循环中具有不同类型或表⽰的路径,因此,为了完全覆盖循环,VM 必须记录从所有侧出口开始的跟踪。这些跟踪的记录方式与根跟踪(root trace)非常相似:每个侧出口都有一个计数器,当计数器达到热度阈值时,开始记录。记录停止的方式与根跟踪完全相同,使用根跟踪的循环头作为要达到的目标。
我们的实现不会在所有侧出口处扩展。只有当侧出口用于控制流分支并且侧出口不离开当前循环时,它才会扩展。特别是,我们不想沿着通向外循环的路径扩展跟踪树,因为我们希望通过树嵌套在外跟踪树中覆盖此类路径。
下图是一颗有两个跟踪(trace)的跟踪树(trace tree),一个主干跟踪(trunk trace)和一个分支跟踪(branch trace)。主干分支包含包含一个守卫(guard),分支跟踪附着于该守卫上。分支跟踪包含一个守卫,守卫失败后会触发 side exit。主干跟踪和分支跟踪都返回(loop back)到树锚点(tree anchor),即踪迹树的起点。
3.3 黑名单(Blacklisting)
有时,程序会遵循或生成无法编译成跟踪的路径,这通常是由于实现中的限制。TraceMonkey 目前不支持记录任意异常的抛出和捕获,之所以选择这种设计权衡,是因为 JavaScript 中异常通常很少见。如果程序选择大量使用异常,若我们反复尝试记录此路径的跟踪并反复失败,这将突然产生惩罚性(punishing)的运行时开销,因为每次观察到抛出异常时我们都会中止跟踪。
因此,如果热循环包含始终失败的跟踪,则 VM 的运行速度可能会比基本解释器还要慢得多:VM 反复花时间尝试记录跟踪,但永远无法运行任何跟踪。为了避免这个问题,每当 VM 即将开始跟踪时,它都必须尝试预测它是否会完成跟踪。
我们的预测算法基于将尝试过但失败的跟踪列入黑名单。当 VM 无法完成从给定点开始的跟踪时,VM 会记录产生的失败。VM 还设置了一个计数器,这样它就不会尝试记录从该点开始的跟踪,直到再经过几次(在我们的实现中为 32 次)。此回退(backoff)计数器提供临时条件,以防止跟踪结束。例如,循环在启动期间的行为可能与在稳定状态执行期间的行为不同,在给定的失败次数(在我们的实现中是2次)之后,VM将片段标记为黑名单,这意味着VM将永远不会在该点再次开始记录。
在实施这一基本策略后,我们观察到,对于被列入黑名单的小循环,系统可能花费大量时间来查找循环片段并确定它已被列入黑名单。我们现在通过修补字节码来避免该问题。我们定义了一个额外的无操作字节码来指示循环头。每次解释器执行无操作的循环头时,VM 都会调用跟踪监视器。要将片段列入黑名单,我们只需将循环头无操作替换为常规无操作,这样解释器将永远不会再调用跟踪监视器。
有一个相关问题我们尚未解决,当循环满足以下所有条件时,就会发生这种情况:
- VM 可以为循环形成至少一个根跟踪。
- 至少有一个热 side exit,VM 无法完成跟踪。
- 循环体很短。
在这种情况下,VM 将反复传递循环头、搜索跟踪、找到它、执行它,然后返回到解释器。对于较短的循环体,查找和调用跟踪的开销很高,并且导致性能甚至比基本解释器更慢。到目前为止,在这种情况下,我们已经改进了实现,以便 VM 可以完成分支跟踪,但很难保证这种情况永远不会发生。作为未来的工作,可以通过检测和列入黑名单的循环来避免这种情况,对于这些循环,平均跟踪调用在返回解释器之前执行少量字节码。
四、嵌套跟踪树
下图a是嵌套循环的控制流图,内层循环内有一个 if 语句;图b是应用于嵌套循环的基本跟踪树编译,内树(t2)一旦沿其循环条件守卫退出,就会返回到外树(t1)上。解释器执行时,内循环(循环头位于 i2)首先变热,并且跟踪树在该位置生成根结点。例如,第一个记录的跟踪可能是通过内循环的循环,{ i 2 、 i 3 、 i 5 、 α i_2、i_3、i_5、\alpha i2、i3、i5、α}, α \alpha α符号用于表示跟踪循环回到树锚点(tree anchor)。
当执行离开内循环时,基本设计有两个选择。首先,系统可以停止跟踪并放弃编译外循环,这显然是一个不理想的解决方案。另一个选择是继续跟踪,在内循环的跟踪树内编译外循环的跟踪。
例如,程序可能在 i 5 i_5 i5 处退出并记录包含外循环的分支跟踪:{ i 5 、 i 7 、 i 1 、 i 6 、 i 7 、 i 1 、 α {i_5、i_7、i_1、i_6、i_7、i_1、\alpha} i5、i7、i1、i6、i7、i1、α}。沿着该路径执行一段时间后,程序可能会在 i 2 i_2 i2 处采用另一个分支,然后退出,此时又要记录另一个包含外循环的分支跟踪:{ i 2 、 i 4 、 i 5 、 i 7 、 i 1 、 i 6 、 i 7 、 i 1 、 α i_2、i_4、i_5、i_7、i_1、i_6、i_7、i_1、\alpha i2、i4、i5、i7、i1、i6、i7、i1、α}。因此,外循环被记录和编译两次,并且两个副本都必须保留在跟踪缓存(trace cache)中。一般来说,如果循环嵌套深度为 k,并且每个循环都有 n 条路径(几何平均值),则这种简单的策略会产生 O ( n k ) O(n^k) O(nk) 条跟踪,这很容易填满跟踪缓存。
为了有效地执行带有嵌套循环的程序,跟踪系统需要一种用 native 代码覆盖嵌套循环的技术,而不会出现指数级的跟踪重复。
4.1 Nesting Algorithm
关键见解是,如果每个循环都由它自己的跟踪树来表示,那么每个循环的代码只能包含在它自己的树中,并且外循环路径不会被重复。另一个关键的事实是,我们不是在跟踪可能具有不可归约(irreduceable,这里指的是循环有两个或者两个以上的进入点,例如使用 goto 进去 for 或 while)控制流图的任意字节码,而是由编译器为具有结构化(structured,可以对分支循环等进行归约,golang、lua都是结构化的)控制流的语言生成的字节码。因此,给定两个循环边,系统可以容易地确定它们是否嵌套以及哪个是内循环。利用这些知识,系统可以分别编译内外循环,并使外循环的跟踪调用内循环的跟踪树。
构建嵌套跟踪树的算法如下。我们完全按照基本跟踪系统的方式从循环头开始跟踪,当我们退出循环时(通过将解释器 PC 与循环边给出的范围进行比较来检测),停止跟踪。算法的关键步骤发生在我们记录循环 L R L_R LR 的跟踪(R是正在记录的循环)并到达另一个循环 L O L_O LO 的头部(O是R的内循环,有可能已经编译跟踪,也有可能没有)时。请注意, L O L_O LO 必须是 L R L_R LR 的内循环,因为我们在退出循环时会停止跟踪。
- 如果 L O L_O LO 具有类型匹配的编译跟踪树,我们将 L O L_O LO 称为嵌套跟踪树。如果调用成功,则我们将调用记录在 L R L_R LR 的跟踪中。在将来的执行中, L R L_R LR 的跟踪将直接调用内跟踪。
- 如果 L O L_O LO 还没有类型匹配的编译跟踪树,我们必须先获得它,然后才能继续。为了做到这一点,我们只需中止(abort)记录第一个跟踪,跟踪监视器将看到内循环头,并立即开始记录内循环。我们原则上可以只暂停外循环的记录,而不是去中止,但这需要实现能够同时记录多个跟踪,从而使实现变得复杂化,同时只能节省解释器中的几次迭代,所以没有必要暂停。
如果嵌套中的所有循环都是类型稳定的,则循环嵌套不会产生重复。否则,如果循环嵌套深度为 k,并且每个循环都以 m 个不同的类型映射(几何平均值)进入,那么我们将 O ( m k ) O(m^k) O(mk) 个编译最内层循环的副本。只要 m 接近 1,生成的跟踪树将是可处理的。
一个重要的细节是,对内跟踪树的调用必须像函数调用点(call-site)一样:它必须每次都返回到同一点。嵌套的目标是使内循环和外循环独立;因此,当调用内树时,它必须每次都以相同的类型映射退出到外树中的同一点。因为我们无法真正保证这个属性,所以我们必须在调用后对其进行守卫,如果属性不成立,则从侧面退出(side exit)。内树不返回同一点的一个常见原因是,如果内树采取了一个新的 side exit,而它从未编译过该跟踪。此时,解释器 PC 位于内树中,因此我们无法继续记录或执行外树。如果在记录期间发生这种情况,我们将中止外跟踪,以便让内树有机会完成增长(growing,内树采取了新的 side exit,要将其编译成跟踪,所以内树会生长出新的子结点)。然后,外树的未来执行将能够正确完成并记录对内树的调用。如果在执行外树的编译跟踪期间发生内树侧退出,我们只需退出外跟踪并开始在内树中记录新分支。
下图展示了一个包含两个嵌套循环的循环的控制流图(左)及其对应的嵌套跟踪树(右)。外跟踪树调用两个内嵌套跟踪树,并在它们的侧出口位置放置守卫。
我们通过允许由于类型不匹配而无法自循环的跟踪来处理类型不稳定的循环。随着这种跟踪的积累,我们尝试连接它们的循环边,以形成可以执行而无需因类型问题侧退出到解释器的跟踪树组。这对于嵌套跟踪树尤其重要,其中外部树尝试调用内部树(或在这种情况下,是内部树的森林),因为内部循环通常在初始迭代中具有未定义的值,这些值在第一次迭代后会变为具体值。
4.2 Blacklisting with Nesting
黑名单算法需要修改才能很好地与嵌套配合使用。问题是外循环跟踪经常在启动期间中止(abort,因为内树不可用或从侧面退出),这将导致它们很快被基本算法列入黑名单。
观察到一个关键点,当外跟踪由于内树尚未准备好而中止时,这种情况可能只是暂时的。因此,只要我们能够为内树建立更多跟踪,我们就不应该将此类中止计入黑名单。
在我们的实现中,当外层树在内层树上中止时,我们会像往常一样增加外层树的黑名单计数器,并停止编译。当内层树完成跟踪时,我们会减少外层循环上的黑名单计数器,“原谅(forgiving)”外层循环先前的中止。我们还撤消了回退(backoff),以便外层树可以在我们下次到达时立即开始尝试编译。
五、跟踪树优化
5.1 Optimizations
由于跟踪的 LIR 是 SSA 形式,并且没有连接点和 ϕ \phi ϕ 节点(LuaJIT 的 IR 有PHI),因此某些优化易于实现。为了获得良好的启动性能,优化必须运行迅速,因此我们选择了一小部分优化。我们将优化实现为流水线(pipelined)过滤器,以便它们可以独立开启和关闭,并且所有优化仅在跟踪上进行两次循环 pass:一次前向和一次后向。
每当跟踪记录器发出一条 LIR 指令时,该指令立即传递到前向流水线中的第一个过滤器。因此,前向过滤器优化在记录跟踪时执行。每个过滤器可以将每条指令不变地传递到下一个过滤器,写入不同的指令到下一个过滤器,或者根本不写入指令。例如,常量折叠过滤器可以将类似v13 := mul 3, 1000
的乘法指令替换为常量指令v13 = 3000
。
我们目前应用四个前向过滤器:
- 对于没有浮点指令的ISA(指令集架构),软浮点过滤器将浮点 LIR 指令转换为整数指令序列。
- 常量子表达式消除(CSE)。
- 表达式简化,包括常量折叠和一些代数等式(例如,a - a = 0)。
- 源语言语义特定的表达式简化,主要是允许将 DOUBLE 替换为 INT 的代数等式。例如,LIR 将 INT 转换为 DOUBLE 然后再转换回来的指令将被此过滤器删除。
当跟踪记录完成后,nanojit运行后向优化过滤器。这些过滤器用于需要后向程序分析的优化。当运行后向过滤器时,nanojit一次读取一条 LIR 指令,并将读取到的指令通过流水线传递。
我们目前应用三个后向过滤器:
- 无用数据栈存储消除。LIR跟踪编码了许多存储到解释器栈位置的操作。但在退出跟踪之前(由解释器或其他跟踪)这些值从未被读取。因此,在下次退出之前被覆盖的栈存储是无用的。在未来退出时位于解释器栈顶部之外的位置的存储也是无用的。
- 无用调用栈存储消除。这与上述优化相同,但应用于用于函数调用内联的解释器调用栈。
- 无用代码消除。这会消除存储到从未使用的值的任何操作。
当一条LIR指令成功从后向过滤器管道中读取(pulled)后,nanojit的代码生成器会为其生成本机机器指令。
5.2 Register Allocation
我们使用一个简单的贪婪(greedy,不需要考虑整体,局部最优即可)寄存器分配器,它对跟踪 LIR 进行单次反向遍历(这个过程与机器指令生成器集成)。当分配器处理类似 v 3 = a d d v 1 , v 2 v_3 = add v_1, v_2 v3=addv1,v2 的指令时,它已经为 v 3 v_3 v3 分配了一个寄存器。如果 v 1 v_1 v1 和 v 2 v_2 v2还没有被分配寄存器,分配器会为每个未分配的值分配一个空闲寄存器。如果没有空闲寄存器,则会选择一个值进行溢出(spilling)。我们使用一种选择“最旧的”寄存器中保存的值的启发式(heuristic)方法来决定哪个值进行溢出。
启发式方法考虑在当前指令之后立即将寄存器中的值 v v v 的集合 R 进行溢出。设 值 v m v_m vm 为每个值 v v v 所引用的当前指令之前的最后一条指令。然后,启发式方法选择 v m v_m vm 最小的 v v v。这种做法的动机是,通过单次溢出释放一个寄存器,并尽可能长时间地保持该寄存器空闲。
如果在这个点需要溢出值 v s v_s vs,我们会在当前指令之后生成恢复代码。相应的溢出代码在最后一次使用 v s v_s vs 的位置之后生成。分配给 v s v_s vs 的寄存器在之前的代码中被标记为空闲,因为这个寄存器现在可以自由使用,而不会影响后续代码。
六、Implementation
为了证明我们方法的有效性,我们为 SpiderMonkey JavaScript 虚拟机实现了一个基于跟踪的动态编译器。SpiderMonkey 是嵌入在 Mozilla 的 Firefox 开源 Web 浏览器中的 JavaScript VM,全球有超过 2 亿用户使用它。SpiderMonkey 的核心是用 C++ 实现的字节码解释器。
在 SpiderMonkey 中,所有 JavaScript 值都由 jsval 类型表示。jsval 是一个机器字长(machine word),其中最多 3 个最低有效位是类型标记( type tag),其余位是数据。jsvals 中包含的所有指针都指向按 8 字节边界对齐的 GC 控制块。
JavaScript 对象的值是名称(name,是个字符串)到任意值的映射。它们在 SpiderMonkey 中以两种方式的其中一种表示。
- 大多数对象由一个共享的结构描述表示,称为对象形状(object shape),它使用哈希表将属性名称映射到数组索引。我对描述的这个结构的理解是:array 存放对象,对象在 array 中的 index 所谓 hash 的 value,对象的 name 作为 hash 的key,这样便可以实现对对象的快速访问。
- 对象保存了指向其形状(shape)的指针,以及保存自身属性值的数组。具有大量独特属性名称集的对象会直接将它们的属性存储在哈希表中。
垃圾收集器是一种精确、non-generational、stop-theworld的标记清除(mark-and-sweep)收集器。
在本节的其余部分,我们将讨论 TraceMonkey 实现的关键领域。
6.1 Calling Compiled Traces
编译后的跟踪信息存储在一个跟踪缓存(trace cache)中,通过解释器的 PC 和类型映射进行索引。跟踪信息被编译后,可以使用标准的 native 调用约定(例如x86上的FASTCALL)作为函数调用。
解释器必须命中循环边(loop edge)并进入监视器才能首次调用 native 跟踪。监视器计算当前类型映射,检查跟踪缓存中是否存在与当前PC和类型映射对应的跟踪信息,如果找到,就执行该跟踪信息。
为了执行跟踪,监视器必须构建一个跟踪活动记录( trace activation record),包含导入的局部变量和全局变量、临时堆栈空间以及 native 调用的参数空间。局部和全局变量值随后从解释器状态复制到跟踪活动记录中。然后,跟踪信息像正常的C函数指针一样被调用。
当跟踪调用返回时,监视器恢复解释器状态。首先,监视器检查跟踪退出的原因,并在需要时应用黑名单。然后,根据需要弹出或合成解释器的 JavaScript 调用堆栈帧。最后,将导入的变量从跟踪活动记录复制回解释器状态。
至少在当前的实现中,这些步骤有一定的运行时间成本,因此尽量减少解释器与跟踪之间的转换是提升性能的关键。我们的实验(见下图)表明,对于可以很好地跟踪的程序,这种转换发生的频率较低,因此对总运行时间的影响不大。在一些程序中,由于中止操作阻止系统记录热点侧退出的分支跟踪,这种成本可能会上升到总执行时间的10%。
6.2 Trace Stitching
从跟踪切换到分支跟踪(侧出口处产生)时,可以避免通过监视器调用跟踪的成本,这种特性被称为跟踪拼接(trace stitching)。在侧出口(side exit)处,退出的跟踪只需要将活跃( live,对照活跃变量来理解)的寄存器携带值写回其跟踪活动记录。在我们的实现中,具有相同类型映射的跟踪活动记录布局是相同的,因此跟踪活动记录可以立即被分支跟踪重用。
在具有分支跟踪树且跟踪较小的程序中,跟踪拼接有明显的成本。虽然写入内存然后很快读取回来的操作预计会有很高的L1缓存命中率,但对于小型跟踪来说,增加的指令数量有明显的成本。此外,如果写入和读取在动态指令流中非常接近,我们发现当前的x86处理器通常会产生6个周期或更多的延迟(例如,如果指令使用不同的基寄存器且其值相同,处理器可能无法立即检测到地址相同)。
替代方案是重新编译整个跟踪树,从而实现跨跟踪的寄存器分配。缺点是树的重新编译时间随着跟踪数量的增加呈二次方增长。我们认为,每次添加分支时重新编译跟踪树的成本将是不可承受的。这个问题可以通过仅在特定点或仅对非常热(hot)和稳定的树进行重新编译来缓解。
未来,多核硬件预计将会普及,使后端(background)对树重新编译变得具有吸引力。在一个密切相关的项目中,后端重新编译在具有许多分支跟踪的基准测试中实现了高达1.25倍的加速。我们计划将这一技术应用于TraceMonkey作为未来的工作。
6.3 Trace Recording
跟踪记录器(trace recorder)的工作是发射(emit)与当前运行的解释器字节码(bytecode)跟踪具有相同语义的 LIR。一个好的实现应该对非跟踪解释器性能影响较小,并为实现者提供维护语义等价的便捷方式。
在我们的实现中,对解释器的唯一直接修改是在循环边(loop edge)调用跟踪监视器(trace monitor),即在循环每次都会经过的地方加个热点计数器(counter)。在我们的基准测试结果中(见 6.1 的图),在监视器中花费的总时间(包括所有活动)通常少于5%,因此我们认为解释器的影响要求得到了满足。递增循环命中计数器是昂贵的,因为这需要我们在跟踪缓存(trace cache)中查找循环,但我们已经调整了我们的循环,使其在第二次迭代时迅速变热并进行跟踪。命中计数器的实现可以改进,这可能会给我们带来小幅的整体性能提升,并且在调整热度阈值方面提供更多的灵活性。一旦一个循环被列入黑名单,我们将永远不会为该循环调用跟踪监视器(见 3.3 节)。
记录通过指针交换活动(activated),该指针设置解释器的调度表( dispatch table)以调用每个字节码的单个“中断(interrupt)”例程。中断例程首先调用一个特定字节码(bytecode-specific)的记录例程(recording routine)。然后,在必要时关闭记录(如,跟踪结束)。最后,它跳转到标准的解释器字节码实现。一些字节码对类型映射有影响,这些影响在执行字节码之前无法预测(例如,调用String.charCodeAt,如果索引参数超出范围,则返回整数或NaN)。对于这些情况,我们安排解释器在执行字节码后再次调用记录器。由于这种钩子(hook)相对较少,我们将它们直接嵌入解释器,并添加一个运行时检查以查看记录器当前是否处于活动状态。
虽然将解释器与记录器分离减少了单个代码的复杂性,但也需要仔细的实现和广泛的测试以实现语义等价。在某些情况下,实现这种等价性是困难的,因为 SpiderMonkey 采用了 fat-bytecode(我理解是在字节码的编码中嵌入更多的信息)设计,这被发现对纯解释器性能有益。
在 fat-bytecode 设计中,单个字节码可以实现复杂的处理(例如,getprop字节码,它实现了完整的 JavaScript 属性值访问,包括缓存和密集数组访问的特殊情况)。 fat-bytecode 有两个优点:较少的字节码意味着较低的调度成本,较大的字节码实现为编译器提供了更多优化解释器的机会。
fat-bytecode 对TraceMonkey来说是个问题,因为它们需要记录器以相同的方式重新实现相同的特殊情况逻辑。此外,这些优点被削弱了,因为:
- 在编译的跟踪中调度成本完全消除;
- 跟踪仅包含一个特殊情况,而不是解释器的大量代码;
- TraceMonkey在运行解释器上花费的时间更少。
我们解决这些问题的一种方法是将某些复杂字节码在记录器中实现为简单字节码序列。以这种方式表达原始语义并不太困难,记录简单字节码要容易得多。这使我们能够保留 fat-bytecode 的优点,同时避免它们在跟踪记录中的一些问题。这对于回归解释器的 fat-bytecode 特别有效,例如,通过调用对象上的已知方法将对象转换为原始值,因为它允许我们内联此函数调用。
需要注意的是,我们仅在记录期间将较大(fat)的操作码分割为较薄(thinner)的操作码。在纯粹解释执行时(即,已被列入黑名单的代码),解释器直接且有效地执行较大(fat)的操作码。
6.4 Preemption
与许多虚拟机一样,SpiderMonkey需要定期抢占(Preemption)用户程序。主要原因是防止无限循环的脚本锁定主机系统,还有就是要安排GC。
在解释器中,这通过设置一个“立即抢占(preempt now)”标志来实现,该标志在每次向后跳转时都会被检查。这一策略被延续到 TraceMonkey:虚拟机在每个循环边处插入一个抢占标志的守卫。我们测量了大多数基准测试,这个额外的守卫导致的运行时间增加不到1%。在实际操作中,这个成本仅在具有非常短循环的程序中是可检测的。
我们测试并拒绝了一个解决方案,该方案通过将循环边编译为无条件跳转并在需要抢占时将跳转目标修补为退出例程来避免守卫。这种解决方案可能使正常情况下略微更快,但抢占变得非常慢。实现起来也非常复杂,尤其是在抢占后尝试重新启动执行时。
6.5 Calling External Functions
像大多数解释器一样,SpiderMonkey拥有一个外部函数接口(FFI,foreign function interface),允许它调用C内建函数和主机系统函数(例如,网页浏览器控制和DOM访问)。FFI对于 JS 可调用函数有一个标准的签名,其关键参数是一个装箱值(boxed values)数组。使用FFI调用的外部函数通过解释器API与程序状态交互(例如,从参数读取属性)。还有一些不使用FFI但以相同方式与程序状态交互的解释器内建函数,例如用于迭代器对象的 CallIteratorNext 函数。TraceMonkey必须支持这个FFI,以加速在热点循环中与主机系统交互的代码。
从 TraceMonkey 调用外部函数可能会很困难,因为跟踪不会在退出前更新解释器状态。特别是,外部函数可能需要调用堆栈或全局变量,而它们可能是过时的。
对于过时的调用堆栈问题,我们重构了一些解释器API实现函数,以按需重新生成解释器调用堆栈。我们开发了一个C++静态分析,并注释了一些解释器函数,以验证在需要使用调用堆栈时调用堆栈已被刷新。为了访问调用堆栈,函数必须被注释为 FORCESSTACK 或 REQUIRESSTACK。这些注释也需要用于调用 REQUIRESSTACK 函数,这些函数被认为是递归访问调用堆栈。FORCESSTACK 是一个可信注释,仅应用于5个函数,表示该函数刷新调用堆栈。REQUIRESSTACK 是一个不可信注释,表示该函数只能在调用堆栈已被刷新时调用。
同样,我们检测主机函数何时尝试直接读取或写入全局变量,并强制当前运行的跟踪侧退出(side exit)。这是必要的,因为我们在跟踪执行期间将全局变量缓存并解箱(unbox)到活动记录中。由于主机函数很少访问调用堆栈和全局变量,这些安全机制不会显著影响性能。
另一个问题是外部函数可以通过调用脚本重新进入解释器,而这些脚本可能再次需要访问调用堆栈或全局变量。为了解决这个问题,我们让虚拟机在编译的跟踪运行时每当解释器重新进入时设置一个标志。每次调用外部函数时都会检查此标志,如果已设置,则在返回外部函数调用后立即退出跟踪。有许多外部函数很少或从不重新进入,它们可以毫无问题地被调用,只有在必要时才会导致跟踪退出。
FFI的装箱值(boxed values)数组要求有性能成本,因此我们定义了一个新的FFI,允许C函数用其参数类型进行注释,这样跟踪器可以直接调用它们,无需不必要的参数转换。目前,我们不支持直接从跟踪调用本地属性获取和设置重写函数或DOM函数。这些功能的支持计划在未来工作中实现。
6.6 Correctness(正确性)
在开发过程中,我们能够使用现有的 JavaScript 测试套件,但大多数套件并未针对跟踪虚拟机设计,且包含的循环很少。
一个对我们帮助很大的工具是Mozilla的 JavaScript 模糊测试器 JSFUNFUZZ,它通过嵌套随机语言元素生成随机 JavaScript 程序。我们修改了 JSFUNFUZZ,使其生成循环,并更加深入地测试我们怀疑会暴露实现缺陷的某些结构。例如,我们怀疑TraceMonkey在处理类型不稳定的循环和大量分支代码时存在错误,经过专门修改的模糊测试器确实揭示了几个回归问题,我们随后进行了修正。