优化 JSC 中的 JavaScript 标准库函数

在 JavaScriptCore (JSC) 工作了三年之后,我最近首次有机会优化我们的一个标准库函数。我认为分享我在 JSC 中关于它们如何工作以及我们如何使其更快的心得会很有趣。

JSC 中的标准库函数是如何实现的?

JavaScript 标准库函数包括 Array、Object 等的所有原型函数,本文我们将探讨 Function.prototype.toString

在 JSC 中,标准库函数可以由原生代码 (C++) 或 JavaScript 实现。当使用 JavaScript 时,我们可以为常规 JavaScript 程序不允许的操作使用特殊语法。用 JavaScript 编写的标准库函数可以像任何其他 JavaScript 函数一样调用,并且也将在我们每个层级(包括内联)中经过相同的优化。同时,用 C++ 编写的标准库函数需要 VM 调用原生代码,这成本更高,虽然实现会由 C++ 编译器优化,但其内部对调用者 JS 函数来说是完全不透明的,因此无法进行内联。我提到内联的原因是,它允许调用者“看”到函数体内部,这解锁了许多超越函数边界的进一步优化,其中一些我们将在本例中看到。

让标准库函数更快

让我们从一个简化的测试用例开始,看看如何优化 Function.prototype.toString

function f() { /* ... some code here ... */ }

function g() {
  return f.toString();
}

当我们调用 g 时,JSC 将首先在解释器 (称为 LLInt) 中执行它,如果我们继续多次调用它,它最终将通过 JSC 中的三个编译器进行提升:非优化型 Baseline 编译器以及我们的优化型 DFG 和 FTL 编译器。您可以通过 这篇文章 阅读更多关于我们执行层级的信息,这篇文章发布于 FTL 首次引入之时,但从那时起 FTL 获得了一个新的后端

在任何层级运行任何代码之前的第一步是将其从源代码转换为字节码(同样,您可以此处阅读更多关于我们字节码的信息)。之后,我们的 DFG 和 FTL 编译器也有自己的代码表示形式,我们称之为中间表示 (Intermediate Representation) 或简称 IR。为了本文的目的,我将自由地使用一些介于我们初始字节码和 DFG IR 之间的伪代码。这样我们可以看到一些只在 DFG 中可用的细节,而无需暴露与我们示例无关的过多底层复杂性。以下是我们的假想 IR 对于 g 的样子:

function g():
    f = Lookup("f") // look for the variable "f" in the lexical scope
    toString = Get(f, "toString") // access the "toString" property of `f`,
                                  // respecting the semantics of JavaScript property
                                  // lookup, including looking up the prototype, etc.
    result = Call(toString, f) // call the toString function with `f` as `this`
    Return(result) // return our result to the caller

缓存

实现的第一个优化根本不是针对标准库函数的:对函数调用 toString 的结果永不改变,因此我们可以将其缓存起来。

我们对 Function.prototype.toString 的实现是用 C++ 编写的,必须处理一些特殊情况。其中一种情况是对用 C++ 实现的原生函数调用 toString,但对于它是常规 JavaScript 函数的常见情况,我们必须查看该函数的源代码。由于源代码在执行时不能更改,并且函数的名称也不能更改,因此此结果可以缓存。这对我们的 IR 来说是完全透明的,这意味着我们无需在编译器中更改任何内容,并且已经获得了显著的加速。

推测

当我们开始尝试优化任何 JavaScript 程序时,我们很快就会面临一些挑战,因为 JavaScript 中的几乎所有内容都可以在运行时发生变异,包括我们的标准库函数。现代 JavaScript VM 解决这些挑战的一种方法是推测程序在执行期间的某些事实不会改变,但由于我们无法确定这一点,如果我们的假设变得无效,我们需要一种回退机制。我们有一篇精彩的文章深入探讨了 JSC 中我们如何进行推测,但对于我们目前的目的,我们只需要知道我们可以编译一个函数并声明我们生成的代码仅在某些假设有效时才有效。这也不是标准库函数特有的,但稍后我们将看到所有这些优化如何相互作用,产生大于其各部分之和的效果,这很重要。

在这种情况下,我们可以推测 f 永远不会改变,在我们的示例中,假设它总是与地址 0x123456 处分配的对象一起调用。这是通过一种称为 watchpoints 的机制完成的,这超出了本文的范围,但简而言之,如果有人给 f 赋值,这段代码将立即失效,从而产生不同的结果。使用其他 watchpoints,我们还可以推测 f.toString 将始终解析为 Function.prototype.toString,这已经带来了更好的代码。

function g():
    f = 0x123456 // speculated value of Lookup("f")
    toString = Function.prototype.toString // speculated value of Get(f, "toString")
    result = Call(toString, f)
    Return(result)

固有函数 (Intrinsics)

接下来,JSC 具有固有函数 (Intrinsics) 的概念。JSC 中的固有函数是指编译器利用其正在调用已知标准库函数的知识来内联发出优化代码。这比常规函数内联更强大,因为它只会在内联中发出快速路径,并且即使标准库函数是用 C++ 编写的也有效。在这种情况下,该固有函数告诉我们正在调用 Function.prototype.toString,我们可以发出一个新的指令 FunctionToString,而不是泛型函数调用。我们新的伪指令将尝试加载我们在第一次优化中计算出的缓存值,但对于尚未有缓存值的慢速情况,它仍将调用 C++ 实现。首次计算 toString 需要一些非常复杂的代码,我们不希望每次调用 toString 时都内联发出这些代码。我们包含新指令的代码可能看起来像这样:

function g():
    f = 0x123456
    result = FunctionToString(f)
    Return(result)

更深入地探讨,我们新的 FunctionToString 指令的实现可能如下所示:

function g():
    f = 0x123456
    result = Load(f, "cachedToString") // Load a specific property of the object,
                                       // much faster since it's just a memory
                                       // access, not a JS property access like Get
    if (!result) {
        // For the slow case where we don't have a cached value we just fallback
        // to calling the C++ code
        toString = Function.prototype.toString
        result = Call(toString, f)
    }
    Return(result)

抽象解释

加快我们示例的下一步是使用我们的抽象解释器 (AI)。AI 背后的思想是它理解我们的 IR 指令在运行时将对其操作数做什么,如果其所有操作数都已知(即我们证明它们将始终具有相同的值),我们可以在编译时尝试计算指令的结果。在这种情况下,由于我们已经推测 f 将是 0x123456,当我们在编译 FunctionToString 指令时,我们可以尝试从 0x123456 处的对象加载 cachedToString 属性。如果成功,我们生成的代码将看起来更像这样:

function g():
    result = "function f() { /* … some code here .. */ }"
    Return(result)

这好多了!

总结

我们可以通过以下方法加速示例中的 f.toString() 调用:

  • 缓存结果
  • 推测 f 始终是同一个对象
  • 推测 f.toString 始终解析为 Function.prototype.toString
  • 添加一个固有函数 (Intrinsic) 和一个新指令 FunctionToString,它在可用时直接加载缓存值
  • 教会我们的抽象解释器,如果我们已经知道要字符串化的函数是哪个,并且其 toString 值已经计算过,我们可以直接将缓存值用作常量。

如果您想深入了解实际实现,可以在此处找到提交,如果您有任何问题,请随时在Twitter上联系我。