编写高性能的Javascript代码而不被降级



当用Javascript编写对性能敏感的代码时,如果它在大型数字数组上运行(比如线性代数包,在整数或浮点数上运行),人们总是希望JIT尽可能地提供帮助。大致意思是:

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

当偏离这些情况时,性能会突然急剧下降。这种情况可能出于各种无害的原因:

  1. 您可以通过看似无害的操作将压缩SMI数组转换为压缩Double数组,类似于myArray.map(x => -x)。这实际上是"最好的"坏情况,因为打包的Double数组仍然非常快
  2. 您可以将压缩数组转换为通用的盒装数组,例如,通过将数组映射到(意外)返回nullundefined的函数上。这种坏情况很容易避免
  3. 您可能会通过传递太多类型的东西并将其变为超纯函数来取消对整个函数(如vec.add())的优化。如果你想进行"通用编程",就会发生这种情况,在这种情况下,vec.add()既用于你对类型不小心的情况(所以它会看到很多类型出现),也用于你想要获得最大性能的情况(例如,它只应该收到盒装的double)

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

  • 在打包SMI阵列的世界里,是否有一套关于如何编程的指导方针
  • 是否可以在Javascript中进行通用的高性能编程,而不使用宏系统之类的东西将vec.add()之类的东西内联到调用站点中
  • 如何根据超形态调用点和去优化等因素将高性能代码模块化为库?例如,如果我很高兴地高速使用线性代数包A,然后我导入了一个依赖于A的包B,但B用其他类型调用它并对它进行了去优化,那么(在不更改代码的情况下)我的代码会突然变慢
  • 是否有任何好的易于使用的测量工具来检查Javascript引擎内部对类型的处理

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大小降低到32位平台上已知的31位。如果您碰巧基于Smis通常是32位的假设来实现,那么您会遇到这样的不幸情况。

值得庆幸的是,正如您所指出的,双数组仍然非常快。对于重数字的代码,假设/target双数组可能是有意义的。考虑到doubles在JavaScript中的普遍性,可以合理地假设所有引擎都对doubles和doublearrays有很好的支持。

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

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

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

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

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

根据超形态调用点和去优化等问题,如何将高性能代码模块化为库?例如,如果我很高兴地高速使用线性代数包A,然后我导入了一个依赖于A的包B,但B用其他类型调用它并对它进行了去优化,那么(在没有更改代码的情况下)我的代码会突然变慢。

是的,这是JavaScript的一个常见问题。V8曾经在内部用JavaScript实现某些内置(比如Array.sort),而这个问题(我们称之为"类型反馈污染")是我们完全放弃该技术的主要原因之一。

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

此外,在发动机内部,除了"一种类型==快"one_answers"不止一种类型===慢"之外,还有更多的情况。如果给定的操作同时看到Smis和Double,那就完全没问题了。从两种数组中加载元素也可以。我们使用"超级形态"一词来形容负载看到了太多不同的类型,以至于它放弃了单独跟踪它们,而是使用一种更通用的机制,可以更好地扩展到大量类型——包含此类负载的函数仍然可以得到优化。"去优化"是一种非常具体的行为,即必须丢弃函数的优化代码,因为看到了以前从未见过的新类型,因此优化的代码无法处理。但即便如此:只需返回未优化的代码,收集更多的类型反馈,稍后再进行优化。如果这种情况发生几次,那就没什么好担心的;它只会在病理不好的情况下成为一个问题。

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

使用TypeScript可以提供帮助。大警告:TypeScript的类型旨在提高开发人员的生产力,而不是执行性能(事实证明,这两个视角对类型系统的要求非常不同)。也就是说,有一些重叠:例如,如果你一直将事物注释为number,那么如果你意外地将null放入一个本应只包含/运算数字的数组或函数中,TS编译器会警告你。当然,规程仍然是必需的:单个number_func(random_object as number)转义填充可能会悄悄地破坏一切,因为类型注释的正确性在任何地方都没有强制执行。

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

有什么好的、易于使用的测量工具来检查Javascript引擎内部对类型的处理吗?

不,这是故意的。如上所述,我们不希望您根据V8目前能够特别好地优化的任何模式来专门定制代码,我们也不认为您真的想这样做。这组事情可能会朝着任何一个方向变化:如果有一种你喜欢使用的模式,我们可能会在未来的版本中对此进行优化(我们之前曾考虑过将未装箱的32位整数存储为数组元素的想法……但这方面的工作尚未开始,因此没有承诺);有时,如果有一种我们过去用来优化的模式,如果它妨碍了其他更重要/更有影响力的优化,我们可能会决定放弃它。此外,众所周知,像内联启发法这样的东西很难正确,因此在正确的时间做出正确的内联决策是一个正在进行的研究领域,并对引擎/编译器行为进行相应的更改;这就造成了另一种情况,如果你花了很多时间调整代码,直到某组当前浏览器版本做出了你认为(或知道?)最好的内联决策,但半年后回来才意识到当前浏览器已经改变了它们的启发式方法,那么对每个人(你我们)来说都是不幸的。

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

最新更新