跳到主要内容

陆地在望:离开节点海洋

· 阅读需 29 分钟
Darius Mercadier

V8的最终优化编译器Turbofan以使用节点海洋 (SoN)而闻名,这是少数几个在生产环境中使用的大规模编译器之一。然而,从大约三年前开始,我们逐步放弃节点海洋,采用一种更传统的控制流图 (CFG) 中间表示 (IR),我们将其命名为Turboshaft。目前,Turbofan的整个JavaScript后端已经改用Turboshaft,而WebAssembly的整个管道也采用了Turboshaft。Turbofan的两个部分仍然使用一些节点海洋:一个是内置管道,我们正在慢慢被Turboshaft替换;另一个是JavaScript管道的前端,我们正在用另一个基于控制流图的IR(Maglev)代替。本文将阐述我们为何离开节点海洋的原因。

12年前,也就是2013年,V8只有一个优化编译器:Crankshaft。它采用了基于控制流图的中间表示。Crankshaft的初始版本提供了显著的性能提升,尽管在支持的功能上仍然相当有限。在接下来的几年中,团队持续改进它,以便在越来越多的场景中生成更快的代码。然而,技术债务开始积累,Crankshaft出现了一些问题:

  1. 它包含了太多手写的汇编代码。每次向IR添加新的操作符,都必须手动为V8官方支持的四种架构(x64、ia32、arm、arm64)编写对应的汇编翻译。

  2. 它难以优化asm.js,而asm.js当时被认为是迈向高性能JavaScript的重要一步。

  3. 它不允许在低级转换中引入控制流。换句话说,控制流是在图构建时创建的,并且是最终版。这是一个主要限制,因为编写编译器时通常会从高级操作开始,然后通过引入额外的控制流逐步降低到低级操作。例如,高级操作JSAdd(x,y)可能需要被转换为类似于if (x 是 String 且 y 是 String) { StringAdd(x, y } else { … }的结构。但在Crankshaft中,这并不可能。

  4. 它不支持try-catch,并且支持它非常困难:多名工程师花费了数个月时间尝试支持它,但未能成功。

  5. 它存在许多性能瓶颈与回退。使用某个特定功能或指令,或者在某个功能的特定边缘案例中运行,可能导致性能下降100倍。这使得JavaScript开发者难以编写高效代码并预测应用的性能。

  6. 它包含许多反优化循环: Crankshaft会基于某些推测假设优化某个函数,然后当这些假设不成立时函数会被反优化,但太频繁地,Crankshaft会基于相同假设重新优化函数,导致无休止的优化-反优化循环。

单独来看,每个问题都有可能被解决。然而,结合在一起,这些问题显得过于繁重。因此,我们决定用一个全新的编译器替换Crankshaft:Turbofan。并且,与其使用传统的CFG IR,Turbofan使用了一种据称更强大的IR:节点海洋。当时,这种IR已在Java HotSpot虚拟机的JIT编译器C2中被使用了超过10年。

那么什么是节点海洋呢?

首先,简单回顾一下控制流图(CFG):CFG是程序的图表示形式,其中图中的节点代表程序中的基本块(即没有分支或跳转的指令序列),而边则表示程序的控制流。以下是一个简单示例:

简单的CFG图

基本块中的指令是隐式有序的:第一条指令应在第二条指令之前执行,而第二条指令应在第三条指令之前执行,依此类推。在上面的小例子中,这种顺序很自然:v1 == 0 无法在 x % 2 被计算之前计算出来。然而,请考虑以下情况。

带有可重新排序的算术操作的CFG图

在这里,控制流图(CFG)似乎规定必须先计算 a * 2,然后再计算 b * 2,尽管实际上我们可以反过来计算它们。 这就是Sea of Nodes的用武之地:Sea of Nodes不表示基本块,而是仅表示指令之间的真实依赖关系。Sea of Nodes中的节点是单个指令(而不是基本块),边表示值的使用(即:从 ab 的边表示 a 使用了 b)。因此,以下是这个最后的例子如何用Sea of Nodes表示:

带有算术操作的简单Sea of Nodes图

最终,编译器需要生成汇编代码,因此必须按顺序安排这两个乘法,但在此之前,它们之间不再有任何依赖关系。

现在让我们在这里加入控制流。控制节点(例如 branchgotoreturn)之间通常没有强制特定执行顺序的值依赖关系,尽管它们绝对需要按特定顺序进行安排。因此,为了表示控制流,我们需要一种新的边类型,控制边,它对没有值依赖的节点施加某种顺序:

带有控制流的Sea of Nodes图

在这个例子中,如果没有控制边,return 可以在 branch 之前执行,这显然是错误的。 这里的关键是控制边只对具有这些输入或输出边的操作强制排序,而对其他操作(例如算术操作)则不会强制排序。这是Sea of Nodes和控制流图之间的主要区别。

现在让我们在这里加入有副作用的操作(例如从内存加载和存储)。类似于控制节点,有副作用的操作通常没有值依赖,但仍不能以随机顺序运行。例如,a[0] += 42; x = a[0]x = a[0]; a[0] += 42 是不等价的。因此,我们需要一种方法对有副作用的操作施加顺序(= 一种调度)。我们可以为此重用控制链,但这会过于严格。例如,请考虑以下小代码片段:

let v = a[2];
if (c) {
return v;
}

通过将 a[2](它读取内存)放到控制链,我们将强制它发生在 c 的分支之前,尽管实际上,如果其结果仅在then分支体内使用,这个加载很容易在分支之后发生。将程序中的大量节点放入控制链会使Sea of Nodes的目标失效,因为我们最终将基本陷入一个只漂浮纯操作的类似控制流图的中间表示。

因此,为了获得更多自由并真正受益于Sea of Nodes,Turbofan有另一种边类型,效果边,它对带有副作用的节点施加某些顺序。现在让我们暂时忽略控制流,看看一个小例子:

带有副作用操作的Sea of Nodes图

在这个例子中,arr[0] = 42let x = arr[a] 没有值依赖关系(即,前者不是后者的输入,反之亦然)。然而,因为 a 可能是 0arr[0] = 42 应在 x = arr[a] 之前执行,以确保后者总是从数组中加载正确的值。 注意,虽然Turbofan只有一条效果链(在分支时分裂,在控制流合并时合并),它用于所有有副作用的操作,但可以有多个效果链,其中没有依赖关系的操作可以位于不同的效果链上,从而放松它们的调度方式(详见 SeaOfNodes/Simple的第10章)。然而,正如我们稍后会解释的那样,维护单一效果链已经非常容易出错,因此我们在Turbofan中没有尝试使用多个效果链。

当然,大多数实际程序都会包含控制流和有副作用的操作。

带有控制流和副作用操作的Sea of Nodes图

注意,storeload 需要控制输入,因为它们可能会受到各种检查(例如类型检查或边界检查)保护。 这个例子很好地展示了与控制流图(CFG)相比,节点海洋(Sea of Nodes)的强大功能:y = x * c 仅在 else 分支中使用,因此可以自由移动到 branch 之后,而不是像原JavaScript代码中那样提前计算。同样地,对于 arr[0],它仅在 else 分支中使用,因此也可以branch 之后移动(尽管在实际操作中,Turbofan不会下移 arr[0],原因我稍后会解释)。 以下是对应CFG的样子以供比较:

带有控制流和有副作用操作的CFG图

我们已经开始看到使用SoN的主要问题:它比起CFG离编译器的输入(源代码)和输出(汇编代码)要远得多,这使得它不直观。此外,总是显式表达副作用和控制依赖使得快速理解图和编写低降操作变得困难(因为低降操作必须始终显式维护控制和副作用链,而在CFG中这些是隐式的)。

问题随之而来…

经过十多年的节点海洋的使用体验,我们认为它的缺点多于优点,至少在JavaScript和WebAssembly方面是这样。我们将在下面讨论一些具体问题。

手动/视觉检查和理解节点海洋图非常困难

我们已经看到在小型程序中,CFG更容易阅读,因为它更接近原始源代码,这是开发者(包括编译器工程师!)习惯编写的格式。对于仍然未被说服的读者,我将提供一个稍大的例子,以便你更好地理解问题。考虑以下用于拼接字符串数组的JavaScript函数:

function concat(arr) {
let res = "";
for (let i = 0; i < arr.length; i++) {
res += arr[i];
}
return res;
}

这是Turbofan编译管道中间的对应节点海洋图(这意味着某些低降操作已经完成):

一个简单数组拼接函数的节点海洋图

看上去已经像是一团乱糟糟的节点了。作为一个编译器工程师,我的工作很大一部分是观察Turbofan图,以理解错误或寻找优化机会。然而,当图看起来像这样时,做这些事情并不容易。毕竟,编译器的输入是类似于CFG的源代码(指令在一个块中都有固定的位置),编译器的输出是类似于CFG的汇编代码(指令同样在一个块中都有固定的位置)。因此,具有类似CFG的中间表示(IR)可以让编译器工程师更容易将IR的元素匹配到源代码或生成的汇编代码。

作为比较,这里是对应的CFG图(我们可以提供这个是因为已经开始用CFG替代节点海洋的过程):

同一个简单数组拼接函数的CFG图

在其他事情之外,使用CFG可以很清楚地看出循环在哪里,循环的退出条件是什么,并且可以很容易地基于预期找到CFG中的一些指令的位置:例如 arr.length 可以在循环的头部找到(它是 v22 = [v0 + 12]),字符串拼接可以在循环的末尾找到(v47 StringConcat(...))。 可以说,值的使用链在CFG版本中更加难以追踪,但我认为,在大多数情况下,清楚地看到图的控制流结构要比一堆值节点的混乱更有意义。

太多的节点在副作用链上和/或有控制输入

为了从节点海洋中获益,大多数图中的节点应能自由浮动,不受控制或副作用链的束缚。不幸的是,这在典型的JavaScript图中并不普遍,因为几乎所有通用JS操作都可能有任意的副作用。不过,它们在Turbofan中应该较少出现,因为我们有反馈,这应该可以将其降低到更具体的操作。

然而,每个内存操作都需要一个副作用输入(因为Load不应越过Store,反之亦然)和一个控制输入(因为操作之前可能有类型检查或边界检查)。甚至一些纯操作如除法也需要控制输入,因为它们可能有特殊情况保护检查。

让我们看一个具体的例子,从以下JavaScript函数开始:

function foo(a, b) {
// 假设 `a.str` 和 `b.str` 是字符串
return a.str + b.str;
}

以下是对应的Turbofan图。为了更清楚,我用虚线红色线条标出了一部分副作用链,并用数字注释了几个节点,以便后续讨论。

一个简单字符串拼接函数的节点海洋图

第一个观察是几乎所有节点都在副作用链上。让我们看看其中的几个节点,并判断它们是否真的需要这样做:

  • 1 (CheckedTaggedToTaggedPointer): 这检查了函数的第一个输入是指针,而不是“小整数”(参见V8中的指针压缩)。单独使用时,它实际上不需要一个效果输入,但在实际中,它仍然需要在效果链上,因为它对后续节点进行保护。
  • 2 (CheckMaps): 现在我们知道第一个输入是指针,这个节点加载其“地图”(参见V8中的地图(隐藏类)),并检查它是否与反馈记录的此对象相匹配。
  • 3 (LoadField): 现在我们知道第一个对象是具有正确地图的指针,我们可以加载它的 .str 字段。
  • 456 对第二个输入重复上述过程。
  • 7 (CheckString): 在我们加载 a.str 后,这个节点检查它是否确实是字符串。
  • 8: 对第二个输入重复。
  • 9: 检查 a.strb.str 的总长度是否小于V8中字符串的最大大小。
  • 10 (StringConcat): 最后将两个字符串连接起来。

这个图表是Turbofan处理JavaScript程序的图表的典型例子:检查地图,加载值,检查加载值的地图,等等,最终对这些值进行一些计算。就像这个例子一样,在很多情况下,大多数指令最终都会在效果或控制链上,这对操作顺序施加了严格的限制,并完全违背了节点海洋的目的。

内存操作不易浮动

让我们考虑以下JavaScript程序:

let x = arr[0];
let y = arr[1];
if (c) {
return x;
} else {
return y;
}

鉴于 xy 分别仅用于 if-else的单个分支,我们可能希望节点海洋允许它们自由浮动到“then”和“else”分支内。然而,在实际中,使这种情况发生在节点海洋中并不比在CFG中更容易。让我们看一下节点海洋图表以理解原因:

节点海洋图,其中效果链反映了控制链,导致效果操作不像预期那样自由浮动

当我们构建节点海洋图时,我们在创建过程中建立效果链,因此第二个 Load 最终紧接在第一个之后,然后效果链不得不分裂以到达两个 return(如果你想知道为什么 return 会在效果链上,那是因为在返回函数之前可能存在带有副作用的操作,比如 Store,需要在返回之前执行)。鉴于第二个 Load 是两个 return 的前置节点,它必须在 branch 之前被调度,因此节点海洋不允许任何一个 Load 自由向下浮动。 为了将 Load 向下移动到“then”和“else”分支,我们需要计算它们之间没有副作用,并且第二个 Loadreturn之间没有副作用,然后我们可以在开始处而不是在第二个 Load之后分裂效果链。在节点海洋图或CFG上进行这种分析非常相似。

既然我们提到很多节点最终会出现在效果链上,并且带有副作用的节点通常不会自由浮动太远,现在是时候意识到,从某种意义上说,节点海洋只是CFG,其中纯节点是浮动的。实际上,控制节点和控制链总是反映等效CFG的结构。而当一个分支的两个目标都有副作用时(在JavaScript中很常见),效果链正好在控制链分裂和合并的地方分裂和合并(就像上面例子中的情况:控制链在 branch 分裂,效果链通过在 Load 分裂来反映这一点;如果程序会在 if-else之后继续运行,两个链会在同一个地方左右合并)。因此,带有副作用的节点通常会被约束至需要在两个控制节点之间调度,也就是基本块中。在这个基本块中,效果链会约束带有副作用的节点保持与源代码中的顺序一致。最终,只有纯节点能够真正自由浮动。

一种获得更多浮动节点的方法是使用多个效果链,如前所述,但这样做需要付出代价:首先,管理单个效果链已经很困难;管理多个链会更加困难。其次,在JavaScript这样的动态语言中,我们会遇到很多可能别名的内存访问,这意味着多个效果链必须经常合并,从而抵消使用多个效果链的一些优势。

手动管理效果和控制链很难

如上一节所述,尽管效果链和控制链在某种程度上是不同的,但在实际中,效果链通常与控制链具有相同的“形状”:如果一个分支的目标包含带有副作用的操作(这种情况很常见),那么效果链将在分支处分裂,并在控制流合并时合并回来。 因为我们使用的是JavaScript,所以许多节点都有副作用,并且有许多分支(通常根据某些对象的类型进行分支),这导致我们不得不同时跟踪效应链和控制链,而在控制流图(CFG)中,我们只需跟踪控制链。

历史证明手动管理效应链和控制链容易出错,难以阅读且难以维护。以下是JSNativeContextSpecialization阶段的代码示例:

JSNativeContextSpecialization::ReduceNamedAccess(...) {
Effect effect{...};
[...]
Node* receiverissmi_effect = effect;
[...]
Effect this_effect = effect;
[...]
this_effect = graph()->NewNode(common()->EffectPhi(2), this_effect,
receiverissmi_effect, this_control);
receiverissmi_effect = receiverissmi_control = nullptr;
[...]
effect = graph()->NewNode(common()->EffectPhi(control_count), ...);
[...]
}

由于这里有各种分支和情况需要处理,我们最终管理了3个不同的效应链。很容易搞错并使用了错误的效应链。这种错误很容易发生,以至于我们最初确实搞错了,并且几个月后才意识到我们的错误

对于这个问题,我将归咎于Turbofan和Sea of Nodes,而不仅仅是后者。在Turbofan中更好的工具可以简化效应链和控制链的管理,但在控制流图中这根本不会成为问题。

调度器过于复杂

最终,所有指令都必须被调度以生成汇编代码。调度指令的理论足够简单:每条指令应在其值、控制和效应输入之后调度(忽略循环)。

让我们来看一个有趣的例子:

简单switch-case的Sea of Nodes图

你会注意到,虽然源JavaScript程序有两个相同的除法,Sea of Nodes图中只有一个。实际上,Sea of Nodes一开始会有两个除法,但由于这是一个纯操作(假设输入为双精度数),冗余消除会轻松地将它们合并为一个。 然后,在进入调度阶段时,我们必须为这个除法找到一个调度位置。显然,它不能安排在case 1case 2之后,因为它在其他分支中被使用。因此,它必须在switch之前调度。缺点是,现在即使c3时,也会计算a / b,而实际上并不需要计算。这是一个实际问题,会导致许多被去重的指令漂浮到其使用者的公共支配节点上,从而拖慢许多不需要它们的路径。 不过,有一个修复方法:Turbofan的调度器会尝试识别这些情况并复制指令,以便它们仅在需要的路径上被计算。缺点是,这使得调度器更加复杂,需要额外的逻辑来确定哪些节点可以且应该复制,以及如何复制它们。 基本上,我们一开始有2个除法,然后“优化”为一个除法,随后又进一步优化回2个除法。这种情况不仅发生在除法上,许多其他操作也会经历类似的循环。

查找图的良好访问顺序很困难

编译器的所有阶段都需要访问图,无论是降低节点、应用局部优化,还是对整个图运行分析。在控制流图(CFG)中,访问节点的顺序通常很直接:从第一个块开始(假设是单入口函数),迭代块中的每个节点,然后依次移动到后继块,依此类推。在窥孔优化阶段(例如强度削减),以这种顺序处理图的一个很好的特性是,输入总是在处理某个节点之前被优化,因此只需一次访问每个节点即可应用大多数窥孔优化。比如,考虑以下缩减序列:

总共只需三步即可优化整个序列,而且每一步都做了有用的工作。在此之后,死代码消除会移除v1v2,使初始序列减少了一条指令。

使用节点海洋方法时,不可能从头到尾处理纯指令,因为它们不在任何控制或效果链上,因此没有指向纯根或类似内容的指针。相反,处理节点海洋图进行窥眼优化的常见方法是从尽头开始(例如,return指令),然后向上处理值、效果和控制输入。这有一个好的特点,即我们不会访问任何未使用的指令,但优点到此为止,因为对于窥眼优化,这是你可能遇到的最糟糕的访问顺序。在上面的例子中,我们将执行以下步骤:

  • 首先访问v3,但此时无法降低它,然后转向访问它的输入
    • 访问v1,将其降低到a << 3,然后转向访问它的用途,以防v1的降低使它们可以被优化。
      • 再次访问v3,但此时仍然无法降低它(这次我们不会再次访问它的输入)
    • 访问v2,将其降低到b << 3,然后转向访问它的用途,以防此降低使它们可以被优化。
      • 再次访问v3,将其降低到(a & b) << 3

所以,总的来说,v3被访问了3次但只降低了一次。

我们之前在典型的JavaScript程序上测量了这一效果,并发现平均来说,节点每访问20次才会改变一次!

图形的访问顺序难以确定的另一个后果是**状态跟踪既困难又昂贵。**许多优化需要沿着图形跟踪某些状态,比如负载消除或逃逸分析。然而,用节点海洋这样做很难,因为在给定点上,很难知道某个状态是否需要继续保留,因为很难确定未处理的节点是否需要此状态进行处理。 因此,Turbofan的负载消除阶段在处理大型图形时会中途退出,以避免耗时过长并消耗过多内存。相比之下,我们为新的控制流图编译器写了一个新的负载消除阶段,经过基准测试速度提高了最多190倍(它有更好的最坏情况下复杂度,因此在大型图形上容易实现这种速度提升),同时使用了更少的内存。

缓存不友好

Turbofan中的几乎所有阶段都会原地修改图。由于节点在内存中相当大(主要因为每个节点都有指向其输入和用途的指针),我们尽量重复使用节点。然而不可避免地,当我们将节点降低为多个节点的序列时,我们不得不引入新的节点,这些新的节点肯定不会在内存中与原始节点紧密分配。结果是,我们在Turbofan的管道中越深入,运行的阶段越多,图形的缓存友好性越差。以下是这一现象的一个示例:

很难估计这种缓存不友好对内存的确切影响。不过,现在我们有了新的控制流图编译器,可以比较两者之间的缓存未命中次数:节点海洋平均遭受了约3倍于我们新的控制流图IR的L1数据缓存未命中,在某些阶段甚至高达7倍。我们估计这对编译时间最多造成5%的影响,不过这只是粗略估算。尽管如此,请记住,在即时编译器中,快速编译是至关重要的。

与控制流相关的类型处理有限

让我们考虑以下JavaScript函数:

function foo(x) {
if (x < 42) {
return x + 1;
}
return x;
}

如果到目前为止我们只看到xx+1的结果为小整数(“小整数”是31位整数,参见V8中的值标记),那么我们会推测这种情况将保持不变。如果我们发现x大于31位整数,那么我们会去优化。同样,如果x+1生成的结果大于31位,那么我们也会去优化。这个意味着我们需要检查x+1是否小于或大于31位的最大值。让我们看看对应的控制流图和节点海洋图:

(假设有一个CheckedAdd操作,它对其输入进行相加并在结果溢出31位时去优化) 使用控制流图时,很容易发现当执行CheckedAdd(v1, 1)时,v1肯定小于42,因此无需检查是否溢出31位。因此,我们很容易将CheckedAdd替换为普通的Add,这将执行得更快,并且不需要去优化状态(否则它需要此状态来知道去优化后如何恢复执行)。 然而,使用节点海洋图时,CheckedAdd作为一个纯操作,将自由在图中流动,因此无法移除检查,直到我们计算出了调度并决定将在分支之后计算它(并且此时,我们回到了控制流图,这就不再是节点海洋的优化了)。

在 V8 中,由于这种 31 位小整数优化,受检操作非常频繁,而能够将受检操作替换为非受检操作对 Turbofan 生成代码的质量产生重大影响。因此,Turbofan 的 SoN CheckedAdd 添加了控制输入,这可以启用此优化,但也意味着在纯节点上引入调度约束,也就是回到 CFG。

以及许多其他问题……

传播死节点很难。 经常在某些降级过程中,我们意识到当前节点实际上是不可到达的。在 CFG 中,我们可以切断当前基本块,接下来的块由于没有前驱会自动变得显然不可达。在 Sea of Nodes 中,这更困难,因为需要同时修补控制链和效果链。因此,当效果链中的一个节点已死亡时,我们需要沿着效果链向前走到下一个合并点,沿途杀死所有节点,并仔细处理控制链上的节点。

引入新的控制流很难。 由于控制流节点必须在控制链上,无法在常规降级过程中引入新的控制流。所以,如果图中有一个纯节点,例如 Int32Max,返回两个整数的最大值,最终我们可能希望降级为 if (x > y) { x } else { y },在 Sea of Nodes 中这并不容易做到,因为我们需要找到如何在控制链上插入该子图的位置。一种方法是从一开始就将 Int32Max 放在控制链上,但这显得浪费:节点是纯的,应该允许自由移动。因此,解决此问题的 Sea of Nodes 规范方法——在 Turbofan 和 Sea of Nodes 的发明者 Cliff Click 的 Coffee Compiler Club 聊天中提到的方法——是推迟这种降级直到我们有了一个调度(即一个 CFG)。因此,在管道中间有一个阶段会计算调度并降级图,其中许多随机优化打包在一起,因为它们都需要调度。相比之下,使用 CFG,我们可以更早或更晚在管道中进行这些优化。 此外,回想一下引言中提到的 Crankshaft(Turbofan 的前身)的一个问题是,在构建图后几乎不可能引入控制流。Turbofan 在这一点上略有改善,因为控制链上的节点的降级可以引入新的控制流,但这仍然受到限制。

确定循环内部内容很难。 因为许多节点浮动在控制链之外,很难确定每个循环内的内容。因此,诸如循环剥离和循环展开等基本优化很难实现。

编译速度慢。 这是我之前提到的多个问题的直接后果:找到节点的良好访问顺序很困难,这导致许多无用的重复访问,状态跟踪成本昂贵,内存使用不好,缓存局部性差……这可能对提前编译器没什么大问题,但在 JIT 编译器中,编译速度慢意味着我们在优化代码准备好之前持续执行缓慢的非优化代码,同时占用其他任务的资源(例如,其他编译工作或垃圾回收器)。其结果之一是我们不得不非常仔细地权衡编译时间 - 新优化的加速权衡,通常倾向于减少优化以保持编译速度。

Sea of Nodes 根据其设计破坏了任何之前的调度。 JavaScript 源代码通常不会手动针对 CPU 微架构进行优化。然而,WebAssembly 代码可以在源级别(例如 C++)或通过一个 提前编译(AOT) 编译工具链(如 Binaryen/Emscripten)进行优化。因此,WebAssembly 代码可以以一种在大多数架构上都应该表现不错的方式进行调度(例如,减少 溢出 的需求,假设有 16 个寄存器)。然而,SoN 总是丢弃初始调度,并且只能依赖其自己的调度程序,这由于 JIT 编译的时间限制,可能很容易比 AOT 编译器(或一个仔细考虑代码调度的 C++ 开发者)做得更差。我们曾见过 WebAssembly 因此而受到影响。不幸的是,在 Turbofan 中为 WebAssembly 使用 CFG 编译器,而为 JavaScript 使用 SoN 编译器也不是一个选择,因为为两者使用同一个编译器可以跨语言进行内联。

Sea of Nodes: 优雅但对于 JavaScript 不实用

所以,总结一下,我们在 Sea of Nodes 和 Turbofan 中遇到的主要问题如下:

  1. 太复杂了。效果和控制链条难以理解,导致许多微妙的错误。图形难以阅读和分析,从而使得新优化难以实现和改进。

  2. 太有限了。由于我们正在编译JavaScript代码,效果和控制链条上的节点太多,因此与传统的控制流图相比并没有提供太多优势。此外,由于在降低阶段中很难引入新的控制流,甚至基本的优化都变得难以实现。

  3. 编译太慢了。状态跟踪很昂贵,因为很难找到一个良好的顺序来访问图形。缓存局部性很差。而在减少阶段中达到固定点所需的时间太长。

因此,在经过十年与Turbofan和Sea of Nodes的斗争后,我们终于决定放弃它,而回到更传统的控制流图中间表示(CFG IR)。到目前为止,我们对新IR的体验非常积极,我们很高兴回到了CFG:相比Sea of Nodes,编译时间缩短了一半,编译器的代码大大简化和缩短,调试错误通常也更容易等等。 不过,这篇文章已经相当长了,所以我就写到这里。敬请期待即将发布的一篇博客文章,其中将解释我们新CFG IR——Turboshaft的设计。