【问题标题】:Writing high-performance Javascript code without getting deoptimised编写高性能的 Javascript 代码而不会去优化
【发布时间】:2020-06-20 22:21:47
【问题描述】:

在使用 Javascript 编写对大型数值数组进行操作的性能敏感代码时(想想线性代数包,对整数或浮点数进行操作),人们总是希望 JIT 尽可能地提供帮助。大致意思是:

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

当人们偏离这些情况时,会发生突然而剧烈的性能下降。这可能由于各种无害的原因而发生:

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

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

  • 是否有一套关于如何在 SMI 阵列封装的世界中进行编程的指南(例如)?
  • 是否可以在不使用宏系统之类的东西将vec.add() 之类的东西内联到调用站点中的情况下,在 Javascript 中进行通用的高性能编程?
  • 如何根据宏态调用站点和去优化等问题将高性能代码模块化到库中?例如,如果我愉快地高速使用线性代数包A,然后我导入了一个依赖于A 的包B,但B 用其他类型调用它并对其进行去优化,突然(没有我的代码正在更改)我的代码运行速度较慢。
  • 是否有任何好的易于使用测量工具来检查 Javascript 引擎在内部对类型所做的工作?

【问题讨论】:

  • 这是一个非常有趣的话题,而且是一篇写得很好的帖子,表明你已经正确地完成了你的部分研究。但是我担心问题对于 SO 格式来说太宽泛了,而且它不可避免地会吸引更多的意见而不是事实。代码优化是一个非常复杂的事情,一个引擎的两个版本的行为可能不一样。我认为有一个负责 V8 JIT 的人有时会闲逛,所以也许他们可以为他们的引擎给出一个正确的答案,但即使对他们来说,我认为这对于单个 Q/A 来说主题太宽泛了.
  • “我的问题更像是一个软问题,关于如何编写高性能 Javascript 代码......” 顺便说一句,请注意 javascript 提供了后台进程(网络工作者)的生成,还有一些库可以利用 GPU(tensorflow.js 和 gpu.js)提供手段,而不是仅仅依靠编译来提高基于 javascript 的应用程序的计算吞吐量......
  • @JonTrent 实际上我在帖子中撒了一点谎,我不太关心经典的线性代数应用程序,但更关心整数上的计算机代数。这意味着立即排除了许多现有的数字包,因为(例如)在对矩阵进行行缩减时,它们可能会除以 2,这在我工作的世界中是“不允许的”,因为 (1/2)不是整数。我考虑过网络工作者(特别是对于一些我希望取消的长时间运行的计算),但我在这里要解决的问题是降低延迟,足以响应交互。
  • 对于 JavaScript 中的整数运算,您可能正在查看 asm.js 样式的代码,大致是“在每个操作后面加上一个 |0”。它并不漂亮,但你可以用一种没有适当整数的语言来做最好的事情。您也可以使用 BigInts,但截至目前,它们在任何常见引擎中都不是很快(主要是由于需求不足)。

标签: javascript v8 jit spidermonkey


【解决方案1】:

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

是否有一套关于如何在 SMI 阵列的世界中进行编程的指南(例如)?

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

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

当我们谈论性能建议时,我们会尽量牢记这一点,并仔细估计哪些建议很可能在许多引擎和多年内保持有效,并且具有合理的惯用性/非侵入性。

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

值得庆幸的是,正如您所指出的,双精度数组仍然非常快。对于数字繁重的代码,假设/目标双数组可能是有意义的。鉴于 JavaScript 中双精度数的普遍性,可以合理地假设所有引擎都对双精度数和双精度数组具有良好的支持。

是否可以在不使用宏系统之类的东西将 vec.add() 之类的东西内联到调用站点中的情况下在 Javascript 中进行通用的高性能编程?

“通用”通常与“高性能”不一致。这与 JavaScript 或特定引擎实现无关。

“通用”代码意味着必须在运行时做出决定。每次你执行一个函数时,代码必须运行来确定,比如,“x 是一个整数吗?如果是,取那个代码路径。x 是一个字符串吗?然后跳到这里。它是一个对象吗?它有.valueOf?没有?那么也许.toString()?也许在它的原型链上?调用它,并从头开始重新开始它的结果“。 “高性能”优化代码本质上是建立在放弃所有这些动态检查的想法之上的;这只有在引擎/编译器有某种方法可以提前推断类型时才有可能:如果它可以证明(或以足够高的概率假设)x 总是一个整数,那么它只需要为这种情况(如果涉及未经证实的假设,则由类型检查保护)。

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

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

如何根据宏态调用站点和去优化等问题将高性能代码模块化到库中?例如,如果我愉快地高速使用线性代数包 A,然后我导入一个依赖于 A 的包 B,但 B 用其他类型调用它并对其进行去优化,突然(没有我的代码更改)我的代码运行速度变慢.

是的,这是 JavaScript 的普遍问题。 V8 过去在内部使用 JavaScript 实现某些内置函数(例如 Array.sort),而这个问题(我们称之为“类型反馈污染”)是我们完全放弃该技术的主要原因之一。

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

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

总结一下就是:别担心。只需编写合理的代码,让引擎处理即可。我所说的“合理”是指:对你的用例有意义的东西是可读的、可维护的、使用高效的算法、不包含读取超出数组长度的错误。理想情况下,这就是它的全部内容,您不需要做任何其他事情。如果这样做让您感觉更好某事,和/或如果您实际上正在观察性能问题,我可以提供两个想法:

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

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

是否有任何易于使用的好测量工具来检查 Javascript 引擎在内部对类型所做的工作?

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

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

【讨论】:

  • 感谢您的出色回答,它证实了我对事物运作方式的许多怀疑,重要的是它们打算如何运作。顺便问一下,您在Array.sort() 中提到的“类型反馈”问题是否有任何博客文章等?我很想多读一点。
  • 我认为我们没有写过关于这个特定方面的博客。这本质上就是您自己在问题中所描述的:当用 JavaScript 实现内置函数时,它们“就像一个库”,因为如果不同的代码段以不同的类型调用它们,那么性能可能会受到影响——有时只是一点点,有时更多。这不是该技术的唯一问题,甚至可以说不是最大的问题。我主要是想说我对一般问题很熟悉。
猜你喜欢
  • 2021-03-24
  • 1970-01-01
  • 1970-01-01
  • 2015-06-27
  • 1970-01-01
  • 1970-01-01
  • 2010-10-28
  • 2016-12-20
  • 1970-01-01
相关资源
最近更新 更多