高性能JavaScript中的对象池?

Jos*_*eph 42 javascript performance

我正在编写一些需要快速运行的javascript代码,并使用了许多短期对象.我最好使用对象池,还是仅仅根据需要创建对象?

我写了一个JSPerf测试,它表明使用对象池没有任何好处,但是我不确定jsperf基准测试是否运行得足够长,以便浏览器的垃圾收集器能够启动.

代码是游戏的一部分,所以我不关心传统的浏览器支持.无论如何,我的图形引擎无法在旧浏览器上运行.

Dom*_*omi 49

首先让我说:我会建议不要使用池,除非你正在开发可视化,游戏或其他计算成本高昂的代码,这些代码实际上做了很多工作.您的普通Web应用程序受I/O限制,您的CPU和RAM大部分时间都处于空闲状态.在这种情况下,通过优化I/O而不是执行速度,您可以获得更多收益; 即确保,您的文件加载速度快,您采用客户端而不是服务器端渲染+模板.但是,如果您正在玩游戏,科学计算或其他受CPU限制的Javascript代码,那么本文可能对您有意义.

简短版本:

在性能关键代码中:

  1. 首先使用通用优化 [1] [2] [3] [4](以及更多).不要马上跳进游泳池(你知道我的意思!).
  2. 注意语法糖和外部库,因为甚至Promises和许多内置函数(例如Array.concat等)都会做很多邪恶的事情,包括分配.
  3. 避免不可变(例如String),因为这些将在您对它们执行的状态更改操作期间创建新对象.
  4. 了解您的分配.使用封装来创建对象,这样您可以在分析期间轻松找到所有分配并快速更改分配策略.
  5. 如果您担心性能,请始终分析并比较不同的方法.理想情况下,你不应该随便相信intarwebz(包括我).请记住,我们对诸如"快速","长寿"等词语的定义可能会有很大差异.
  6. 如果您决定使用池:
    • 您可能必须为长寿命和短期对象使用不同的池,以避免短期池的碎片化.
    • 您想要针对不同的场景比较不同的算法和不同的池化粒度(池整个对象或仅汇集一些对象属性?).
    • 池化会增加代码复杂性,从而使优化器的工作更加困难,从而可能降低性能.

长版:

首先,考虑系统堆与大对象池基本相同.这意味着,当你创建一个新的对象(使用new,[],{},(),嵌套函数,字符串连接等),系统将使用一个(非常精密,快速,低级别的性能优化)算法来给你一些未使用的空间(即一个对象),确保它的字节被清零并返回它.这与对象池必须做的非常相似.但是,Javascript的运行时堆管理器使用GC来检索"借来的对象",其中池以几乎零成本的形式返回它的对象,但是需要开发人员自己负责跟踪所有这些对象.

现代Javascript运行时环境(例如V8)具有运行时分析器和运行时优化器,当它识别性能关键代码段时,理想情况下可以(但不一定(尚未))进行积极优化.它还可以使用该信息来确定垃圾收集的好时机.如果它意识到你运行游戏循环,它可能只是在每几个循环后运行GC(甚至可能将老一代集合减少到最小等),从而实际上不会让你感觉它正在做的工作(但是,它仍然会如果是昂贵的操作,请更快地耗尽电池).有时,优化器甚至可以将分配移动到堆栈,这种分配基本上是免费的,而且对缓存更友好.话虽这么说,这些优化技术并不完美(实际上它们不可能,因为完美的代码优化是NP难的,但这是另一个话题).

让我们以游戏为例:JS中关于快速矢量数学的讨论解释了重复的矢量分配(在大多数游戏中你需要大量的矢量数学)会减慢一些应该非常快的东西:矢量数学用Float32Array.在这种情况下,如果您以正确的方式使用正确的游泳池,您可以从游泳池中受益.

这些是我在Javascript中编写游戏时学到的经验:

  • 封装函数中所有常用对象的创建.让它首先返回一个新对象,然后将它与池版本进行比较:

代替

var x = new X(...);
Run Code Online (Sandbox Code Playgroud)

使用:

var x = X.create(...);
Run Code Online (Sandbox Code Playgroud)

甚至:

// this keeps all your allocation in the control of `Allocator`:
var x = Allocator.createX(...);      // or:
var y = Allocator.create('Y', ...);
Run Code Online (Sandbox Code Playgroud)

这样,您可以先实现X.createAllocator.createX使用return new X();,然后在以后用池替换它,以便轻松地比较速度.更好的是,它允许您快速查找代码中的所有分配,以便在时机成熟时逐个查看.不要担心额外的函数调用,因为任何体面的优化器工具都会内联,甚至可能由运行时优化器内联.

  • 尝试将对象创建保持在最低限度.如果您可以重复使用现有对象,那就这样做吧.以2D矢量数学为例:不要使矢量(或其他常用对象)不可变.即使不变性产生更漂亮和更具bug的代码,但它往往非常昂贵(因为突然每个向量操作都需要创建一个新的向量,或从池中获取一个,而不是仅添加或乘以几个数字).在其他语言中,您可以使向量不可变的原因是因为通常这些分配可以在堆栈上完成,从而将分配成本降低到几乎为零.但是在Javascript中 -

代替:

function add(a, b) { return new Vector(a.x + b.x, a.y + a.y); }
// ...
var z = add(x, y);
Run Code Online (Sandbox Code Playgroud)

尝试:

function add(out, a, b) { out.set(a.x + b.x, a.y + a.y); return out; }
// ...
var z = add(x, x, y);   // you can do that here, if you don't need x anymore (Note: z = x)
Run Code Online (Sandbox Code Playgroud)
  • 不要创建临时变量.那些使并行优化几乎不可能.

避免:

var tmp = new X(...);
for (var x ...) {
    tmp.set(x);
    use(tmp);       // use() will modify tmp instead of x now, and x remains unchanged.
}
Run Code Online (Sandbox Code Playgroud)
  • 就像循环前面的临时变量一样,简单的池会妨碍简单循环的并行化优化:优化器将很难证明您的池操作不需要特定的顺序,并且至少需要额外的同步这可能不是必需的new(因为运行时可以完全控制如何分配东西).在计算循环紧张的情况下,您可能希望考虑每次迭代进行多次计算,而不是仅考虑一次(也称为部分展开循环).
  • 除非你真的喜欢修补,否则不要写自己的游泳池.那里已经有很多了.例如,本文列出了一大堆.
  • 如果您发现内存流失会破坏您的一天,那么只能尝试汇集.在这种情况下,请确保正确分析您的应用程序,找出瓶颈并做出反应.一如既往:不要盲目优化.
  • 根据池查询算法的类型,您可能希望对长寿命和短期对象使用不同的池,以避免短期池的碎片.查询短期对象比查询长寿命对象更具性能要求(因为前者可能每秒发生数百次,数千次甚至数百次).

池算法

除非你编写一个非常复杂的池查询算法,否则你通常会遇到两个或三个选项.这些选项中的每一个在某些情况下都更快,在其他情况下更慢.我经常看到的是:

  1. 链接列表:仅保留列表中的空对象.每当需要一个对象时,只需很少的费用就将其从列表中删除.当不再需要该物体时,将其放回去.
  2. 数组:保留数组中的所有对象.每当需要一个对象时,迭代所有池化对象,返回第一个空闲对象,并将其inUse标志设置为true.不再需要该对象时取消设置.

玩这些选项.除非您的链表实现相当复杂,否则您可能会发现基于数组的解决方案对于短期对象(这是池性能实际上很重要)更快,给定,数组中没有长寿命对象,导致搜索自由对象变得不必要地长.如果您通常需要一次分配多个对象(例如,对于部分展开的循环),请考虑批量分配选项,该选项分配(小)对象数组,而不仅仅是一个,以减少未分配对象的查找开销.如果你真的很喜欢快速池(和/或只想尝试一些新的东西),那么看一下如何快速实现系统堆并允许不同大小的分配.

最后的话

无论您决定使用什么,继续进行剖析,研究和分享成功的方法,使我们心爱的JS代码运行得更快!


Dan*_*Tao 13

一般来说(根据我的个人经验),汇集对象不会提高速度.创建对象通常非常便宜.相反,对象池的目的是减少垃圾收集引起的jank(周期性滞后).

作为一个具体的例子(不一定是JavaScript,但作为一般说明),想想具有高级3D图形的游戏.如果一个游戏的平均帧速率为60fps,则比另一个平均帧速率为40fps的游戏更快.但是如果第二个游戏的fps 一直是40,那么图形看起来很平滑,而如果第一个游戏的fps 通常远高于60fps但偶尔会下降到10fps,那么图形看起来就会变得不稳定.

如果您创建一个运行两个游戏10分钟的基准测试并且每隔一段时间对帧速率进行采样,它就会告诉您第一个游戏具有更好的性能.但它不会在波动中发现.这就是对象池要解决的问题.

当然,这不是涵盖所有案例的一揽子声明.当您经常分配大型阵列时,池化不仅可以改善波动性而且还可以改善原始性能的一种情况是:通过简单地设置arr.length = 0和重用arr,您可以通过逃避未来的重新分析来提高性能.类似地,如果您经常创建非常大的对象,这些对象都共享一个公共模式(即,它们具有明确定义的属性集,因此您不必在将每个对象返回到池时"清理"它们),在这种情况下,您可能会看到池中的性能提升.

正如我所说,一般来讲,虽然,这不是对象池的主要目的.

  • @Domi好点.但是你过分强调我措辞不好的括号.真的,我应该说"在我的理解中,*通过个人经验进一步支持*".因为我不仅仅是基于个人经验来回答这个问题,而是基于我对什么是对象池以及它们服务的目的的研究.我已根据这项研究使用它们,我的经验与之相符. (2认同)