在不去优化的情况下编写高性能 Javascript 代码

Writing high-performance Javascript code without getting deoptimised

在 Javascript 中编写对大型数值数组进行操作的性能敏感代码时(想想一个线性代数包,对整数或浮点数进行操作),人们总是希望 JIT 帮忙,因为尽可能多。大致意思是:

  1. 我们总是希望我们的数组是压缩 SMI(小整数)或压缩双精度数,这取决于我们是在进行整数还是浮点计算。
  2. 我们总是希望将相同类型的东西传递给函数,这样它们就不会被标记为 "megamorphic" 和取消优化。例如,我们总是希望调用 vec.add(x, y)xy 都是打包的 SMI 数组,或者都是打包的 Double 数组。
  3. 我们希望尽可能内联函数。

当偏离这些情况时,性能会突然急剧下降。这可能由于各种无关紧要的原因而发生:

  1. 您可以通过看似无害的操作将压缩 SMI 数组转换为压缩双精度数组,例如 myArray.map(x => -x)。这实际上是 "best" 糟糕的情况,因为打包的 Double 数组仍然非常快。
  2. 您可以将压缩数组转换为通用盒装数组,例如通过将数组映射到(意外地)返回 nullundefined 的函数。这种坏情况很容易避免。
  3. 您可能会通过传入太多类型的东西并将其变成超形态来取消优化整个函数,例如 vec.add()。如果您想执行 "generic programming",则可能会发生这种情况,其中 vec.add() 在您不注意类型的情况下(因此它会看到很多类型进来)和在您的情况下使用想要获得最大性能(例如,它应该只接收盒装双打)。

我的问题更像是一个软问题,关于如何根据上述考虑编写高性能 Javascript 代码,同时仍然保持代码的美观和可读性。一些具体的子问题,以便您知道我想要什么样的答案:

这里是 V8 开发人员。鉴于对这个问题的兴趣,以及缺乏其他答案,我可以试一试;恐怕这不会是你所希望的答案。

Is there a set of guidelines somewhere on how to program while staying in the world of packed SMI arrays (for instance)?

简短回答:就在这里:const guidelines = ["keep your integers small enough"]

更长的答案:由于各种原因,很难给出一套全面的指南。总的来说,我们的观点是 JavaScript 开发人员应该编写对他们和他们的用例有意义的代码,而 JavaScript 引擎开发人员应该弄清楚如何 运行 在他们的引擎上快速编写代码.另一方面,这种理想显然有一些局限性,从某种意义上说,无论引擎实现选择和优化工作如何,某些编码模式总是比其他模式具有更高的性能成本。

当我们谈论性能建议时,我们尽量牢记这一点,并仔细估计哪些建议很可能在许多引擎和许多年内保持有效,并且也是合理的 idiomatic/non-intrusive。

回到手头的例子:在内部使用 Smis 应该是用户代码不需要知道的实现细节。它会使某些情况更有效率,并且在其他情况下不应该受到伤害。并非所有引擎都使用 Smis(例如,AFAIK Firefox/Spidermonkey 历史上没有;我听说在某些情况下他们现在确实使用 Smis;但我不知道任何细节,也无法与任何人交谈此事的权威)。在 V8 中,Smis 的大小是一个内部细节,实际上随着时间和版本的变化而变化。在曾经是大多数用例的 32 位平台上,Smis 一直是 31 位有符号整数;在 64 位平台上,它们曾经是 32 位有符号整数,最近这似乎是最常见的情况,直到 Chrome 80 年我们发布了 "pointer compression" 用于 64 位架构,这需要降低 Smi 大小到 32 位平台已知的 31 位。如果您碰巧基于 Smis 通常是 32 位的假设来实现,您会遇到像 this.

这样不幸的情况

值得庆幸的是,正如您所指出的,双数组仍然非常快。对于大量使用数字的代码,assume/target 双数组可能有意义。鉴于 JavaScript 中双精度数的普遍存在,可以合理地假设所有引擎都对双精度数和双精度数组有良好的支持。

Is possible to do generic high-performance programming in Javascript without using something like a macro system to inline things like vec.add() into callsites?

"generic" 通常与 "high-performance" 不一致。这与 JavaScript 或特定引擎实现无关。

"Generic" 代码意味着必须在 运行 时间做出决定。每次执行函数时,代码都必须 运行 确定,比如说 "is x an integer? If so, take that code path. Is x a string? Then jump over here. Is it an object? Does it have .valueOf? No? Then maybe .toString()? Maybe on its prototype chain? Call that, and restart from the beginning with its result"。 "High-performance" 优化代码本质上是建立在放弃所有这些动态检查的想法之上的;这只有在 engine/compiler 有某种方法可以提前推断类型时才有可能:如果它可以证明(或以足够高的概率假设)x 总是一个整数,那么它只需要为该案例生成代码(如果涉及未经证实的假设,则由类型检查保护)。

内联与所有这些都是正交的。 "generic" 函数仍然可以内联。在某些情况下,编译器可能能够将类型信息传播到内联函数中以减少那里的多态性。

(相比之下:C++ 作为一种静态编译语言,具有解决相关问题的模板。简而言之,它们让程序员明确指示编译器创建函数的专门副本(或整个 类 ), 在给定类型上参数化。在某些情况下这是一个很好的解决方案,但并非没有其自身的一系列缺点,例如编译时间长和二进制文件大。JavaScript,当然,没有模板之类的东西。你可以使用 eval 来构建一个有点相似的系统,但是你会 运行 陷入类似的缺点:你必须在 运行 时做相当于 C++ 编译器的工作,而且您将不得不担心生成的代码量之大。)

How does one modularise high-performance code into libaries in light of things like megamorphic call sites and deoptimisations? For instance, if I am happily using Linear Algebra package A at high speed, and then I import a package B that depends on A, but B calls it with other types and deoptimises it, suddenly (without my code changing) my code runs slower.

是的,这是 JavaScript 的一个普遍问题。 V8 曾经在内部实现 JavaScript 中的某些内置函数(如 Array.sort),而这个问题(我们称之为 "type feedback pollution")是我们完全摆脱它的主要原因之一技术。

就是说,对于数字代码,类型并不多(只有 Smis 和 double),正如您所指出的,它们在实践中应该具有相似的性能,因此虽然类型反馈污染确实是一个理论上的问题,并且在某些情况下可能会产生重大影响,在线性代数场景中您也很可能看不到可测量的差异。

此外,引擎内部的情况比"one type == fast"和"more than one type == slow"多得多。如果给定的操作已经看到 Smis 和双打,那完全没问题。从两种数组加载元素也很好。我们使用术语 "megamorphic" 来表示负载已经看到如此多的不同类型以至于它放弃单独跟踪它们的情况,而是使用更通用的机制来更好地扩展到大量类型——一个包含这样的函数负载仍然可以得到优化。 "deoptimization" 是一个非常具体的行为,它必须放弃函数的优化代码,因为看到了以前从未见过的新类型,因此优化代码无法处理。但即使这样也没关系:只需返回未优化的代码以收集更多的类型反馈,然后再进行优化。如果这种情况发生几次,则无需担心;它只会在病态的坏情况下成为问题。

所以总结起来就是:不用担心。写合理的代码,让引擎去处理就好了。 "reasonable",我的意思是:什么对您的用例有意义,可读,可维护,使用高效算法,不包含读取超出数组长度的错误。理想情况下,仅此而已,您无需执行任何其他操作。如果做 某事 让你感觉更好,and/or 如果你确实在观察性能问题,我可以提供两个想法:

使用 TypeScript 可以 提供帮助。大警告:TypeScript 的类型旨在提高开发人员的生产力,而不是执行性能(事实证明,这两种观点对类型系统的要求非常不同)。也就是说,有一些重叠:例如如果你始终将事物注释为 number,那么如果你不小心将 null 放入一个数组或函数中,而该数组或函数应该只对数字进行 contain/operate,则 TS 编译器会警告你。当然,纪律仍然是必需的:一个 number_func(random_object as number) 逃生口可以悄无声息地破坏一切,因为类型注释的正确性并没有在任何地方强制执行。

使用 TypedArrays 也有帮助。与常规 JavaScript 数组相比,它们每个数组的开销(内存消耗和分配速度)要多一些(因此,如果您需要许多小数组,那么常规数组可能更有效),而且它们不太灵活,因为它们分配后不能增长或收缩,但它们确实提供了所有元素都只有一种类型的保证。

Are there any good easy to use measurement tools for checking what the Javascript engine is doing internally with types?

不,那是故意的。如上所述,我们不希望您专门针对 V8 今天可以特别优化的任何模式定制您的代码,我们也不相信您真的想这样做。这组东西可以朝任一方向改变:如果有你喜欢使用的模式,我们可能会在未来的版本中对此进行优化(我们之前曾考虑过将未装箱的 32 位整数存储为数组元素的想法...... . 但相关工作尚未开始,所以没有承诺);有时,如果我们过去曾优化过某个模式,如果它妨碍了其他更多 important/impactful 优化,我们可能会决定放弃它。此外,众所周知,内联启发式之类的东西很难正确处理,因此在正确的时间做出正确的内联决策是一个正在进行的研究领域,也是对 engine/compiler 行为的相应改变;如果您花费大量时间调整代码直到某些当前浏览器版本大致执行您的内联决策,这将成为另一种情况,这对每个人(您 我们)来说都是不幸的认为(或知道?)是最好的,半年后才回来意识到当时的浏览器已经改变了他们的启发式。

当然,您始终可以从整体上衡量应用程序的性能——这才是最重要的,而不是引擎内部做出的具体选择。当心微基准测试,因为它们具有误导性:如果您只提取两行代码并对它们进行基准测试,那么场景很可能会完全不同(例如,不同类型的反馈),引擎将做出非常不同的决定。