ChowJS:用于游戏机的 AOT JavaScript 引擎

1,143 阅读11分钟
  • ChowJS:用于游戏机的 AOT JavaScript 引擎

    Mathias Kærlev 2021 年 9 月 15 日

    您好,欢迎来到MP2 游戏技术博客!我是 Mathias,这是我们对 MP2 所做的一些工作进行一系列技术深入研究的第一个条目。

    最近,我们一直致力于让大型 JavaScript 游戏在游戏机上运行。为了实现这一点,我们使用了ChowJS,这是我们的提前 JavaScript 编译器和运行时,它可以针对游戏机。

    注意:我们将假设一些关于游戏开发、JS 和编译器的知识,以保持简短。

    注 2:ChowJS 与 Chowdren 没有直接关系,Chowdren是 Clickteam Fusion 和 Construct 3 的编译器和运行时。

    介绍

    JavaScript是一种非常流行的编程语言,这是有充分理由的。快速构建 JS 应用程序很容易,生态系统令人惊叹,并且通过浏览器或Node 等运行时在 PC 和移动等平台上对 JS 有很好的支持。

    如果您正在用 JS 构建一个严肃的多平台游戏,情况就有点不同了。尤其是让 JS 跑到游戏机上的路子,长期以来一直是个难题。这是为什么?

    带有 JIT 的 JavaScript

    要执行 JS 程序,您需要使用 JS 引擎。今天一些流行的选择包括

    • V8 (Node.js, Chrome)
  • 蜘蛛猴(火狐)

    • JavaScriptCore (Safari)

    所有主要 JS 引擎的共同点是它们使用**即时 (JIT)**编译。简而言之,这意味着代码在运行时被优化并编译为机器代码,这非常重要。

    禁用 JIT 的 V8 可能 比启用 JIT 的 V8慢****5 倍甚至 17 倍,这是一个巨大的差异。从这个角度来看,假设您的游戏在标准 V8 下花费 8毫秒来完成每一帧。假设禁用 JIT 时速度降低 5 倍,这将导致 40毫秒的帧时间,远低于任何 60 FPS 目标。

    由于游戏机通常在受限硬件上运行,因此 JIT 对实现可接受的帧时间至关重要。但是,大多数控制台不允许用户应用程序在运行时创建可执行代码,从而有效地消除了使用 JIT 的可能性。

    没有 JIT 的 JavaScript

    要在控制台上运行 JS,您将有 3 个选项:

    1. 在解释模式下使用 JS 引擎。
  1. 离线编译和优化您的代码,即**提前 (AOT)**编译。
  2. 用另一种语言(如 C++)手动重写源项目。

出于性能原因,使用解释器对我们来说是不可行的。在我们的例子中,我们还有以下约束:

  • 游戏仍在定期更改。

  • 游戏很大而且不是以统一的方式编写的,使用了许多不同的风格和范例。

    • 该游戏使用了许多动态 JavaScript 功能,例如monkeypatching 和eval.

    因此,重写整个项目是不可能的,剩下的就是 AOT 编译了。我们现在很高兴地提出我们的解决方案:一个名为ChowJS的 JavaScript 引擎。

    **关于相关工作的旁注:**据我们所知, Hermes是唯一用于 JS 的生产质量 AOT 编译器。不幸的是,它只能编译为字节码而不是机器码,并且不支持我们需要的所有功能。还为特定游戏制作了一些非公共编译器,例如 Rob 的 JavaScript → Haxe 编译器。Rob 的编译器旨在支持 CrossCode 使用的特定 JS 子集,我认为同样的方法对我们不起作用。最后,Fastly 最近宣布了 这个,但它现在似乎只在 Wasm 中实现了一个 JS 解释器。

    ChowJS

    ChowJS是一个用于 JavaScript 的快速 AOT 引擎。它具有以下特点:

    • 支持现代 JavaScript,包括ES2020
  • PC移动控制台平台上运行(与Chowdren相同的平台后端)。

    • 提供NW.js运行时环境的子集。
  • 使用AOT 编译器使用SSA IR生成快速机器代码

    • 高度可配置的优化。
  • 为快速属性查找实现内联缓存

    • 使用引用计数 GC。

    ChowJS 很大程度上基于 QuickJS,我们发现它是一个优秀的 JavaScript 引擎,可以利用它。特别是,我们借用了 QuickJS 的字节码、解释器和 ECMAScript 支持。

    它是如何工作的?

    一个重要的观察结果是,大多数 JavaScript 程序在启动阶段后表现出相当静态的行为,这也是 JIT 工作的原因。启动后,原型不太可能改变,大多数全局变量不会改变,等等。在 AOT 环境中,我们仍然可以利用这一点。

    考虑以下程序:

  function Test()
{
  };

  Test.prototype.foo = function()
  {
      console.log("hello world!");
  };

  Test.prototype.bar = function()
{
      this.foo();
};

以下是 ChowJS 如何编译这个程序的概述:

![ChowJS 编译器流水线](用于 JavaScript 开发的 Neovim 和 Tmux.assets/chowjs_pipeline.png)

详细说明,这是正在发生的事情:

  • 我们在ChowJS 解释器中运行程序,直到启动阶段完成。此时方法已被编译为字节码。
  • 对于每种方法,我们
    1. 将方法的字节码转换为我们的IR
  1. 对 IR 执行多次传递和优化。 3. 将 IR 转换为 C 并将其编译为机器代码

对于AOT编译,这是至关重要的,我们已经进入启动JS背景下,因为这使得用户对象,原型和方法,在编译时提供给我们,使优化了很多更简单。例如,在编译 时bar,我们可以显示它foo是与 相同原型的一部分bar,使this.foo()调用成为函数内联的绝佳候选者。

我们现在将简要回顾编译器的重要部分。如果您对编译器没有浓厚的兴趣,请随时跳到性能部分。

字节码

考虑以下函数:

  function printParity(x) {
  var s;
    if (x % 2 == 0)
    s = "even";
    else
    s = "odd";
    print("Your number is " + s);
  };

为该函数生成的字节码如下:

[000] get_arg <ArgRef: 'x'>
[001] push_i32 2
[002] mod
[003] push_i32 0
[004] eq
[005] if_false <Label: 15>
[007] push_atom_value <AtomRef: 'even'>
[012] put_loc <VarRef: 's'>
[013] goto <Label: 21>
[015] push_atom_value <AtomRef: 'odd'>
[020] put_loc <VarRef: 's'>
[021] get_var <AtomRef: 'print'>
[026] push_atom_value <AtomRef: 'Your number is '>
[031] get_loc <VarRef: 's'>
[032] add
[033] call 1
[034] return_undef

字节码是基于堆栈的。例如

  • get_arg <ArgRef: 'x'>x参数压入堆栈
    • push_i32 2将压入2堆栈。
  • mod将从堆栈中弹出x2值并推送 的结果x % 2

为了处理控制流,使用了return_defcallif_false 等指令。例如,if_false如果从堆栈中弹出的第一个值是,则将跳转到给定的地址标签false,而 goto将执行无条件跳转。

字节码被设计为由解释器执行,但除了简单的窥视孔优化之外,它很难操作。出于这个原因,我们将其转换为更易于分析和优化的中间表示 (IR)。

SSA IR

ChowJS 使用静态单分配 (SSA)形式的 IR和控制流图 (CFG) 来表示控制流。在 SSA 形式中,每个变量只分配一次,我们使用 ϕ ( phi) 函数来合并来自不同基本块的值。

有多种方法可以将基于堆栈的字节码转换为像我们这样的 SSA IR,但这里有一些有价值的参考资料:

printParity函数的初始和未优化 IR如下:

  BB0:
  %0 = get_arg 0
    %1 = undefined
  %2 = push_i32 2
    %3 = mod  %0, %2
    %4 = push_i32 0
    %5 = eq  %3, %4
    if_false <BlockRef: BB2> <BlockRef: BB1> %5
  
  BB1:
    %6 = push_atom_value <AtomRef: 'even'>
  goto <BlockRef: BB3>
  
BB2:
    %7 = push_atom_value <AtomRef: 'odd'>
  goto <BlockRef: BB3>
  
BB3:
    %8 = phi <BlockRef: BB2> <BlockRef: BB1> %7, %6
    %9 = get_var <AtomRef: 'print'>
    %10 = push_atom_value <AtomRef: 'Your number is '>
    %11 = add  %10, %8
    %12 = call 1 %9, %11
  return_undef

指令现在具有明确的输出和输入值,并且很容易推断它们之间的关系。另请注意,%8使用 phi指令选择%7%6取决于我们来自哪个基本块。

IR 通行证和优化

我们通过多次传递来转换 IR:

CallApplySimplification`→ `CacheValuePass`→ `ExplicitToObject`→ `InlinePass`→ `DCE`→ `CriticalEdgeSplitting`→ `Typer`→`Lowering

以下是printParity在所有通行证运行后、但在退出 SSA 形式之前的 IR for外观:

Type info:
  %0: Unknown (no_rc: true)
%2: int (no_rc: true)
  %3: float (no_rc: true)
%4: int (no_rc: true)
  %5: bool (no_rc: true)
%6: str (no_rc: true)
  %7: str (no_rc: true)
  %8: str (no_rc: true)
  %9: ObjectRef (no_rc: true)
%10: str (no_rc: true)
  %11: str (no_rc: false)
%12: Unknown (no_rc: false)

BB0:
  %0 = get_arg 0 
%2 = push_i32 2 
  %3 = mod  %0, %2
%4 = push_i32 0 
  %5 = eq  %3, %4
if_false <BlockRef: BB2> <BlockRef: BB1> %5

BB1:
  %6 = push_atom_value <AtomRef: 'even'>
goto <BlockRef: BB3>

BB2:
  %7 = push_atom_value <AtomRef: 'odd'>
  goto <BlockRef: BB3>

BB3:
  %8 = phi <BlockRef: BB2> <BlockRef: BB1> %7, %6
  %9 = push_val <ObjectRef: 'print'> 
  %10 = push_atom_value <AtomRef: 'Your number is '> 
  %11 = add  %10, %8
%12 = call 1 %9, %11
  kill  %11, %12
return_undef  

上面有几点需要注意:

  • 在这个阶段,我们明确地处理值的生命周期。如果一个值需要被引用计数,我们在创建它时增加它的引用计数,并使用kill指令减少它的计数。
  • Typer推断的类型几乎所有的价值观,也想通了什么值需要引用计数。
  • push_val是一条在编译时产生已知值的指令。在最终的机器代码中,这不需要属性查找并且操作非常快。在这种情况下,我们能够优化print全局对象所需的属性查找。
  • %1 = undefined 被删除为死代码。

让我们仔细看看一些重要的传球。

CacheValuePass

CacheValuePass起着重要作用,因为它将许多结构转换为 push_val指令。也就是说,它尝试在编译时评估指令,我们将此过程称为缓存。这有助于后续的优化,因为它们可以使用编译时已知的值。此通行证功能的一些示例:

  • 缓存了一些全局对象查找。例如,缓存console或 的查找通常是安全的String

  • 某些缓存值的属性查找也被缓存。例如,缓存logon的查找通常是安全的console

  • this.foo如果foo是与正在编译的方法在同一原型中的方法,则缓存,并且不会在超级原型中覆盖。

    • … 和更多。

    这个pass大量使用了之前获得的“启动”JS上下文,因为它需要从用户程序中探索原型和对象。

    InlinePass

    InlinePass执行函数内联。内联非常重要,因为 JS 调用有与之相关的开销,并且能够内联短方法(尤其是 getter/setter)是一个巨大的胜利。内联也有助于后续的优化,因为它们有更多的上下文可以使用。

    此传递受限于CacheValuePass缓存值的能力,否则,传递无法对正在调用的方法做出任何假设。

    Typer

    Typer执行过程内类型推断,主要是为了使 Lowering有关指令是如何降低通做出决定,还要确定值是否需要引用计数。这通过消除某些类型检查来提高性能,还有助于降低引用计数的成本。

    可配置的优化

    请注意,其中一些优化(尤其是CacheValuePass通过)过于乐观,并且不会在所有场景中都有效。考虑到这一点,ChowJS 中的优化是高度可配置的。这在 AOT 上下文中至关重要,因为在不破坏语义的情况下无法做出某些假设。

    为了更直接地配置优化,可以编译 ChowJS 并启用检查以确定是否违反了任何假设。

    回退

    虽然 IR 用于编译绝大多数代码,但eval在使用诸如此类的功能时有时需要回退。这意味着有几个执行层:

    1. 字节码:通过解释器运行的字节码。
  1. 未优化的机器码:直接从字节码生成的机器码。这是通过内联每个字节码指令的操作码处理程序来完成的,消除操作码调度开销。我们还在这里进行了一些有限的窥视孔优化。这类似于 V8 的Sparkplug 的工作原理。
  2. 优化的机器码:字节码 → IR → 机器码,如前所述。

未优化的机器代码的运行速度比解释器快约 1.6 倍。

表现

以下是游戏中密集场景的一些场景更新时间,是在手持控制台上测量的。更新时间平均超过 50 帧,我们使用 V88.7.220.31进行测试:

  • ChowJS(仅限解释器)43.40ms每帧。

  • V8(仅限解释器)35.81ms每帧。

    • ChowJS11.10ms每帧。

    对于这个场景,这意味着:

    • ChowJS3.23x速度V8解释
    • ChowJS 比 ChowJS 字节码解释器快 3.91 倍。
  • 与 V8 解释器相比,ChowJS 解释器仅慢 1.21 倍。

特别是,这使我们从V8 的低于 30 FPSChowJS 的 60 FPS任务完成!

作为一个额外的胜利,将 JS 方法编译为机器代码使得使用常规 C/C++ 工具分析 JS 应用程序变得更加轻松。这是我们最喜欢的分析器之一Superluminal正在分析的 ChowJS 可执行文件 :

![超光速中的 ChowJS](用于 JavaScript 开发的 Neovim 和 Tmux.assets/chowjs_profile.png)

未来

  • 实现一种“配置文件引导的优化”将使 ChowJS 受益匪浅,并且可以让我们对在编译时无法推断的类型和对象形状做出更多假设。
    • ChowJS 使用引用计数,这是从 QuickJS 继承的。虽然我们可以通过优化移除许多 RC 操作,但与高吞吐量 GC 或更复杂的 RC 实现相比,仍然存在一些与 RC 相关的开销。
  • 有很多额外的编译器优化机会,但就目前而言,我们正在达到我们的性能目标。我们当然希望在未来用 ChowJS 做更多!