V8告别Sea of Nodes:为何Turbofan转向更传统的控制流图IR

本文详细阐述了V8引擎中Turbofan优化编译器放弃使用Sea of Nodes中间表示,转向名为Turboshaft的基于控制流图的IR的原因。文章深入对比了两种IR的优劣,指出SoN在编译JavaScript/WebAssembly时的复杂性、性能限制和可维护性问题。

Land ahoy: 告别Sea of Nodes

V8的顶级优化编译器Turbofan,是少数几个大规模使用Sea of Nodes(SoN)的生产编译器之一。然而,大约三年前,我们开始逐步摆脱Sea of Nodes,回归到更传统的控制流图中间表示,我们将其命名为Turboshaft。如今,Turbofan的整个JavaScript后端都已使用Turboshaft,WebAssembly在其整个流水线中也全程使用Turboshaft。Turbofan仍有两个部分使用着一些Sea of Nodes:我们正用Turboshaft逐步替代的内置函数流水线,以及我们正用另一个基于CFG的IR——Maglev——替代的JavaScript流水线前端。这篇博客将解释我们决定离开Sea of Nodes的原因。

Turbofan和Sea of Nodes的诞生

12年前的2013年,V8只有一个优化编译器:Crankshaft。它使用的是基于控制流图的中间表示。Crankshaft的初始版本尽管支持的功能相当有限,但仍带来了显著的性能提升。在随后的几年里,团队不断改进它,以便在更多情况下生成更快的代码。然而,技术债务开始堆积,Crankshaft出现了许多问题:

  • 它包含了太多手写的汇编代码。每次向IR添加新操作符时,都必须为V8官方支持的四种架构(x64、ia32、arm、arm64)手动编写其到汇编的翻译代码。
  • 它在优化asm.js方面很吃力,而当时asm.js被视为实现高性能JavaScript的重要一步。
  • 它不允许在降级(lowering)过程中引入控制流。换句话说,控制流在图构建时创建,之后就是固定的。这是一个重大限制,因为编写编译器时的一个常见做法是从高级操作开始,然后通过引入额外的控制流将它们降级为低级操作。例如,考虑一个高级操作JSAdd(x,y),之后将其降级为类似 if (x is String and y is String) { StringAdd(x, y) } else { … } 的代码可能是有意义的。但在Crankshaft中,这无法实现。
  • 不支持try-catch,支持它们极具挑战性:多位工程师曾花费数月尝试支持,但未成功。
  • 它存在许多性能悬崖和回退(bailout)。使用特定功能或指令,或遇到功能的特定边界情况,都可能导致性能下降百倍。这使得JavaScript开发人员难以编写高效代码并预测其应用程序的性能。
  • 它包含许多去优化循环:Crankshaft会基于一些推测性假设优化函数,当这些假设不成立时,函数会被去优化,但太多情况下,Crankshaft会用相同的假设重新优化函数,导致无休止的优化-去优化循环。

单独来看,这些问题或许都可以克服。然而,当它们组合在一起时,就显得太多了。因此,我们决定用从头编写的新编译器Turbofan取代Crankshaft。并且,Turbofan没有使用传统的CFG IR,而是采用了一种被认为更强大的IR:Sea of Nodes。当时,这种IR已经在Java HotSpot虚拟机的JIT编译器C2中使用了十多年。

到底什么是Sea of Nodes?

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

基本块内的指令是隐式排序的:第一条指令必须在第二条之前执行,第二条必须在第三条之前执行,依此类推。在上面的小例子中,这感觉很自然:无论如何,v1 == 0 的计算不能在 x % 2 计算之前进行。然而,考虑以下情况:

在这里,CFG看似强制要求 a * 2b * 2 之前计算,尽管我们完全可以按相反顺序计算它们。

这就是Sea of Nodes的用武之地:Sea of Nodes不表示基本块,而是只表示指令之间真正的依赖关系。Sea of Nodes中的节点是单个指令(而不是基本块),边表示值的用途(即:从 a 到 b 的边表示 a 使用了 b)。因此,上一个例子在Sea of Nodes中的表示如下:

最终,编译器需要生成汇编,从而必须顺序调度这两个乘法操作,但在此之前,它们之间不再有依赖关系。

现在让我们把控制流混入其中。控制节点(例如分支、跳转、返回)通常彼此之间没有强制特定调度顺序的值依赖关系,尽管它们确实必须以特定顺序调度。因此,为了表示控制流,我们需要一种新的边——控制边——它对没有值依赖的节点施加一些顺序:

在这个例子中,如果没有控制边,没有什么能阻止返回语句在分支之前执行,这显然是错误的。

这里的关键在于,控制边只对具有此类传入或传出边的操作施加顺序,而不影响其他操作,比如算术操作。这是Sea of Nodes和控制流图之间的主要区别。

现在让我们加入具有副作用(效应)的操作(例如,对内存的加载和存储)。与控制节点类似,有效应操作通常没有值依赖关系,但仍然不能以随机顺序运行。例如,a[0] += 42; x = a[0]x = a[0]; a[0] += 42 并不等价。因此,我们需要一种方法对效应操作施加顺序(即调度)。我们可以为此目的重用控制链,但这比需要的限制更严格。例如,考虑下面这个小代码段:

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

通过将 a[2](读取内存)放在控制链上,我们将强制它在基于 c 的分支之前发生,尽管实际上,如果其结果仅在 then 分支体内使用,这个加载操作可以很容易地在分支之后发生。将程序中的许多节点放在控制链上会违背Sea of Nodes的目标,因为我们最终基本上会得到一个类似CFG的IR,只有纯操作在其周围浮动。

因此,为了获得更多自由并实际受益于Sea of Nodes,Turbofan有另一种边——效应边——它对具有副作用的节点施加一些顺序。让我们暂时忽略控制流,看一个小例子:

在这个例子中,arr[0] = 42let x = arr[a] 没有值依赖关系(即,前者不是后者的输入,反之亦然)。但是,因为 a 可能是 0,arr[0] = 42 应该在 x = arr[a] 之前执行,以便后者总是从数组中加载正确的值。

请注意,虽然Turbofan有一个单一的效应链(它在分支处分叉,在控制流合并时重新合并),用于所有效应操作,但也可以有多个效应链,其中没有依赖关系的操作可以位于不同的效应链上,从而放宽它们的调度方式(详见 SeaOfNodes/Simple 的第10章)。然而,正如我们稍后将解释的,维护一个单一的效应链已经很容易出错,因此我们在Turbofan中没有尝试使用多个效应链。

当然,大多数真实的程序会同时包含控制流和效应操作。

请注意,存储和加载操作需要控制输入,因为它们可能受到各种检查(例如类型检查或边界检查)的保护。

这个例子很好地展示了Sea of Nodes相对于CFG的强大之处:y = x * c 仅用于 else 分支,因此可以自由地浮动到分支之后,而不是像原始JavaScript代码中写的那样在分支之前计算。arr[0] 也是如此,它仅用于 else 分支,因此可以浮动到分支之后(尽管在实践中,Turbofan不会将 arr[0] 下移,原因我稍后会解释)。

作为对比,以下是相应的CFG表示:

至此,我们开始看到SoN的主要问题:与CFG相比,它离编译器输入(源代码)和输出(汇编)都远得多,这使得它更不直观。此外,效应和控制依赖总是显式的,这使得快速推理图和编写降级代码变得困难(因为降级代码总是必须显式地维护控制链和效应链,而这在CFG中是隐式的)。

麻烦开始了……

在处理了十多年的Sea of Nodes之后,我们认为它弊大于利,至少就JavaScript和WebAssembly而言是如此。我们将在下面详细讨论其中一些问题。

手动/视觉检查和理解Sea of Nodes图很困难

我们已经看到,在小型程序上,CFG更容易阅读,因为它更接近原始源代码,这是开发者(包括编译器工程师!)习惯编写的形式。为了让不信服的读者更好地理解这个问题,让我提供一个稍大的例子。考虑下面的JavaScript函数,它连接一个字符串数组:

1
2
3
4
5
6
7
function concat(arr) {
  let res = "";
  for (let i = 0; i < arr.length; i++) {
    res += arr[i];
  }
  return res;
}

这是对应的Sea of Nodes图,处于Turbofan编译流水线的中间(这意味着一些降级已经发生):

这张图已经开始看起来像一团杂乱的节点汤。作为编译器工程师,我工作的一大部分是查看Turbofan图,要么是为了理解错误,要么是为了寻找优化机会。当图看起来像这样时,要做到这些可不容易。毕竟,编译器的输入是源代码,它是类似CFG的(指令在给定块中都有固定位置),而编译器的输出是汇编,也是类似CFG的(指令在给定块中也有固定位置)。因此,拥有类似CFG的IR使得编译器工程师更容易将IR中的元素与源代码或生成的汇编对应起来。

作为对比,以下是相应的CFG图(因为我们已经开始用CFG替换Sea of Nodes,所以已经有此图可用):

其中,在CFG中,循环在哪里很清楚,循环的退出条件是什么也很清楚,并且基于我们期望它们的位置,很容易在CFG中找到一些指令:例如,arr.length 可以在循环头中找到(是 v22 = [v0 + 12]),字符串连接可以在循环的末尾附近找到(v47 StringConcat(...))。

可以说,在CFG版本中,值的使用链更难追踪,但我认为,大多数情况下,清晰地看到图的控制流结构比看到一团值节点更好。

太多节点位于效应链上和/或有控制输入

为了受益于Sea of Nodes,图中的大多数节点应该自由浮动,不受控制链或效应链约束。不幸的是,在典型的JavaScript图中情况并非如此,因为几乎所有的通用JS操作都可能具有任意副作用。在Turbofan中,它们应该很少见,因为我们有反馈可以将其降级为更具体的操作。

尽管如此,每个内存操作都需要一个效应输入(因为加载操作不应浮动到存储操作之后,反之亦然)和一个控制输入(因为操作前可能有类型检查或边界检查)。甚至一些纯操作,如除法,也需要控制输入,因为它们可能有受检查保护的特殊情况。

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

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

这是对应的Turbofan图。为了更清晰,我用红色虚线高亮了部分效应链,并用数字标注了一些节点,以便于下文讨论。

第一个观察是几乎所有节点都在效应链上。让我们逐一查看其中几个,看看它们是否真的需要在链上:

  1. CheckedTaggedToTaggedPointer:检查函数的第一个输入是指针而不是“小整数”(参见V8中的指针压缩)。就其本身而言,它并不真的需要效应输入,但在实践中,它仍然需要在效应链上,因为它保护后续节点。
  2. CheckMaps:现在我们知道第一个输入是指针,该节点加载其“映射”(参见V8中的映射(隐藏类)),并检查其是否与反馈记录的对象匹配。
  3. LoadField:现在我们知道第一个对象是具有正确映射的指针,我们可以加载其 .str 字段。
  4. 56 是对第二个输入的重复操作。
  5. CheckString:现在我们已加载 a.str,该节点检查它确实是一个字符串。
  6. 8:对第二个输入的重复。
  7. 9:检查 a.strb.str 的组合长度是否小于V8中字符串的最大大小。
  8. StringConcat:最终连接两个字符串。

这个图是JavaScript程序Turbofan图的典型代表:检查映射、加载值、检查加载值的映射,依此类推,最后对这些值进行一些计算。像这个例子一样,在许多情况下,大多数指令最终都位于效应链或控制链上,这给操作施加了严格的顺序,完全违背了Sea of Nodes的初衷。

内存操作不易浮动

考虑以下JavaScript程序:

1
2
3
4
5
6
7
let x = arr[0];
let y = arr[1];
if (c) {
  return x;
} else {
  return y;
}

考虑到 xy 各自仅用于 if-else 的某一侧,我们可能希望SoN允许它们自由地浮动到“then”和“else”分支内部。然而,在实践中,在SoN中实现这一点并不比在CFG中更容易。让我们看一下SoN图来理解原因:

当我们构建SoN图时,我们沿着构建效应链,因此第二个加载操作最终紧跟在第一个之后,之后效应链必须分叉以到达两个返回(如果你想知道为什么返回操作也在效应链上,那是因为之前可能有带副作用的操作,如存储操作,这些操作必须在函数返回之前执行)。鉴于第二个加载操作是两个返回操作的前驱,它必须在分支之前调度,因此SoN不允许两个加载操作中的任何一个自由地向下浮动。

为了将加载操作移到“then”和“else”分支,我们必须计算出它们之间没有副作用,并且第二个加载操作和返回操作之间也没有副作用,然后我们可以在开始时而不是在第二个加载操作之后分叉效应链。在SoN图或CFG上执行此分析极其相似。

既然我们已经提到许多节点最终位于效应链上,并且效应节点通常不能自由浮动很远,现在是时候认识到,在某种程度上,SoN只是纯节点可以浮动的CFG。实际上,控制节点和控制链总是镜像等效CFG的结构。并且,当分支的两个目标都有副作用时(这在JavaScript中很常见),效应链会分叉和合并,分叉和合并的位置与控制链相同(如上例所示:控制链在分支处分叉,效应链通过在加载处分叉来镜像这一点;如果程序在if-else之后继续,两条链将在大致相同的位置合并)。因此,效应节点最终通常被约束在调度在两个控制节点之间,也就是在一个基本块内。在这个基本块内,效应链将约束效应操作保持与源代码中相同的顺序。最终,只有纯操作才能真正自由浮动。

获得更多浮动节点的一种方法是使用多个效应链,如前所述,但这需要付出代价:首先,管理一个效应链已经很困难;管理多个会困难得多。其次,在像JavaScript这样的动态语言中,我们最终会有许多可能别名(alias)的内存访问,这意味着多个效应链将不得不非常频繁地合并,从而抵消了拥有多个效应链的部分优势。

手动管理效应链和控制链很困难

正如上一节提到的,虽然效应链和控制链在某种程度上是不同的,但实际上,效应链通常具有与控制链相同的“形状”:如果分支的目标包含效应操作(通常情况如此),那么效应链将在分支处分叉,并在控制流合并时重新合并。

因为我们处理的是JavaScript,许多节点都有副作用,并且有很多分支(通常基于某些对象的类型进行分支),这导致我们必须并行跟踪效应链和控制链,而在CFG中,我们只需要跟踪控制链。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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中更好的辅助工具本可以简化效应链和控制链的管理,但在CFG中这根本不会成为问题。

调度器过于复杂

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

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

你会注意到,虽然源代码JavaScript程序中有两个相同的除法操作,但Sea of Nodes图中只有一个。实际上,Sea of Nodes一开始会有两个除法,但既然这是一个纯操作(假设输入是double),冗余消除会很容易地将它们去重为一个。

然后,当进入调度阶段时,我们必须找到一个地方来调度这个除法。显然,它不能放在 case 1 或 case 2 之后,因为它也被另一个使用。相反,它必须被调度在 switch 之前。缺点是,现在 a / b 将在 c 等于 3 时也被计算,尽管它并不需要。这是一个真正的问题,可能导致许多去重后的指令浮动到其用户的共同支配者那里,从而减慢了许多不需要它们的路径。

不过有一个修复方法:Turbofan的调度器会尝试识别这些情况并复制指令,使得它们只在需要的路径上被计算。缺点是这使得调度器更加复杂,需要额外的逻辑来确定哪些节点可以并且应该被复制,以及如何复制它们。

所以,基本上,我们从2个除法开始,然后“优化”为1个除法,然后又进一步“优化”为2个除法。这不仅发生在除法上:许多其他操作也会经历类似的循环。

难以找到访问图的好顺序

编译器的所有阶段都需要访问图,无论是为了降级节点、应用局部优化,还是对整个图进行分析。在CFG中,访问节点的顺序通常很简单:从第一个块开始(假设是单入口函数),遍历块中的每个节点,然后移动到后继块,依此类推。在窥孔优化阶段(例如强度折减),按此顺序处理图的一个优点是,在处理节点之前,其输入总是被优化过的,因此恰好访问每个节点一次就足以应用大多数窥孔优化。例如,考虑以下归约序列:

总共用了三个步骤来优化整个序列,每个步骤都做了有用的工作。之后,死代码消除会移除 v1v2,结果比初始序列少了一条指令。

在Sea of Nodes中,不可能从头到尾处理纯指令,因为它们不在任何控制链或效应链上,因此没有指向纯根节点之类的指针。相反,为窥孔优化处理Sea of Nodes图的常用方法是从末尾(例如返回指令)开始,沿着值输入、效应输入和控制输入向上访问。这样做的优点是,我们不会访问任何未使用的指令,但优点也就到此为止,因为对于窥孔优化来说,这差不多是最差的访问顺序。在上面的例子中,我们会采取以下步骤:

  1. 访问 v3,但此时无法降级它,然后移动到它的输入。
  2. 访问 v1,将其降级为 a << 3,然后移动到它的用途,以防 v1 的降级使它们能被优化。
  3. 再次访问 v3,但仍无法降级(这次我们不会再次访问其输入)。
  4. 访问 v2,将其降级为 b << 3,然后移动到它的用途,以防此降级使它们能被优化。
  5. 再次访问 v3,将其降级为 (a & b) << 3

因此,总共访问了 v3 3次,但只降级了1次。

我们之前测量了典型JavaScript程序的这种影响,发现平均每个节点每被访问20次才改变一次!

图访问顺序困难的另一个后果是,状态跟踪既困难又昂贵。许多优化需要沿着图跟踪某些状态,例如加载消除或逃逸分析。然而,这在Sea of Nodes中很难做到,因为在某个给定点,很难知道是否需要保持给定的状态存活,因为很难判断未处理的节点是否需要此状态来处理。

因此,Turbofan的加载消除阶段在大图上会触发回退(bailout),以避免花费太长时间完成和消耗太多内存。相比之下,我们为新的CFG编译器编写了一个新的加载消除阶段,经基准测试,其速度提升了高达190倍(它具有更好的最坏情况复杂度,因此在大图上很容易达到这种加速),同时使用了更少的内存。

缓存不友好

Turbofan中几乎所有的阶段都会就地修改图。考虑到节点在内存中相当大(主要是因为每个节点都有指向其输入和用途的指针),我们尽可能重用节点。然而,不可避免地,当我们将节点降级为多个节点的序列时,必须引入新节点,这些新节点必然不会在内存中靠近原始节点分配。因此,我们在Turbofan流水线中走得越深,运行的阶段越多,图的缓存友好性就越差。以下是这种现象的图示:

很难精确估计这种缓存不友好对内存的影响。不过,现在我们有了新的CFG编译器,我们可以比较两者之间的缓存未命中次数:平均而言,Sea of Nodes的L1数据缓存未命中次数大约是新的CFG IR的3倍,在某些阶段甚至高达7倍。我们估计这会导致高达5%的编译时间开销,尽管这个数字有点粗略。请记住,在JIT编译器中,快速编译至关重要。

控制流依赖的类型化有限

考虑以下JavaScript函数:

1
2
3
4
5
6
function foo(x) {
  if (x < 42) {
    return x + 1;
  }
  return x;
}

如果到目前为止,我们只见过 xx+1 的结果是“小整数”(“小整数”是31位整数,参见V8中的值标记),那么我们将推测这种情况会继续保持。如果我们看到 x 大于31位整数,我们将去优化。类似地,如果 x+1 产生的结果大于31位,我们也会去优化。这意味着我们需要检查 x+1 是否小于或大于适合31位的最大值。让我们看看相应的CFG和SoN图:

(假设有一个CheckedAdd操作,它将其输入相加,并在结果溢出31位时去优化)

在CFG中,很容易认识到当执行 CheckedAdd(v1, 1) 时,v1 保证小于42,因此没有必要检查31位溢出。这样我们就可以轻松地将 CheckedAdd 替换为常规的 Add,后者执行速度更快,并且不需要去优化状态(否则需要该状态以了解在去优化后如何恢复执行)。

然而,在SoN图中,CheckedAdd 作为一个纯操作,将在图中自由浮动,因此无法移除检查,直到我们计算出调度并决定在分支之后计算它(而到那时,我们又回到了CFG,所以这不是SoN的优化了)。

由于这个31位小整数优化,此类受检查的操作在V8中很常见,并且能够用未检查操作替换受检查的操作,对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在这方面略有改进,因为控制链上节点的降级可以引入新的控制流,但这仍然有限。

难以确定循环内有什么。因为许多节点浮动在控制链之外,所以很难确定每个循环内有什么。因此,像循环剥离和循环展开这样的基本优化很难实现。

编译速度慢。这是我已提到的多个问题的直接后果:难以找到访问节点的好顺序,导致许多无用的重访问;状态跟踪昂贵;内存使用不佳;缓存局部性差……对于提前(AOT)编译器来说,这可能不是什么大问题,但在JIT编译器中,编译慢意味着我们一直执行缓慢的未优化代码,直到优化代码准备好,同时占用了其他任务(例如,其他编译作业或垃圾收集器)的资源。一个后果是,我们被迫非常仔细地考虑新优化的编译时间-加速比权衡,常常倾向于少优化以保持优化速度。

Sea of Nodes 从构造上破坏了任何先前的调度。JavaScript源代码通常不会针对CPU微架构进行手动优化。然而,WebAssembly代码可以,要么在源代码级别(例如C++),要么通过提前编译工具链(如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代码),因此相比于传统CFG没有提供太多好处。此外,由于难以在降级中引入新的控制流,甚至基本的优化也变得难以实现。
  3. 编译太慢。由于难以找到访问图的好顺序,状态跟踪昂贵。缓存局部性差。在归约阶段达到不动点需要太长时间。

因此,在与Turbofan打交道十年并与Sea of Nodes斗争之后,我们最终决定摆脱它,转而回归到更传统的CFG IR。到目前为止,我们对新IR的经验非常积极,我们对回归CFG感到非常高兴:与SoN相比,编译时间缩短了一半,编译器的代码更简单、更短,调查错误通常也更容易,等等。

不过,这篇文章已经相当长了,我就此打住。请继续关注即将发布的博客文章,其中将解释我们新的CFG IR——Turboshaft的设计。

发布者:Darius Mercadier。

品牌 条款 隐私 推特 在GitHub上编辑此页面

除非另有说明,V8项目的任何代码示例均根据V8的BSD风格许可证获得许可。本页上的其他内容根据知识共享署名3.0许可证获得许可。有关详细信息,请参阅我们的网站政策。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计