找回密码
 骑士注册

QQ登录

微博登录

搜索
❏ 站外平台:

探索传统 JavaScript 基准测试

2017-09-29 15:40    收藏: 1    

不是那么显眼的 Kraken 案例

Kraken 基准是 Mozilla 于 2010 年 9 月 发布的,据说它包含了现实场景应用的片段/内核,并且与 SunSpider 相比少了一个微基准。我不想在 Kraken 上花太多口舌,因为我认为它不像 SunSpider 和 Octane 一样对 JavaScript 性能有着深远的影响,所以我将强调一个特别的案例——audio-oscillator.js 测试。

audio-oscillator.js

正如你所见,测试调用了 calcOsc 函数 500 次。calcOsc 首先在全局的 sine Oscillator 上调用 generate,然后创建一个新的 Oscillator,调用它的 generate 方法并将其添加到全局的 sine Oscillator 里。没有详细说明测试为什么是这样做的,让我们看看 Oscillator 原型上的 generate 方法。

audio-oscillator-data.js

让我们看看代码,你也许会觉得这里主要是循环中的数组访问或者乘法或者 Math.round 调用,但令人惊讶的是 offset % this.waveTableLength 表达式完全支配了 Oscillator.prototype.generate 的运行。在任何的英特尔机器上的分析器中运行此基准测试显示,超过 20% 的时间占用都属于我们为模数生成的 idiv 指令。然而一个有趣的发现是,Oscillator 实例的 waveTableLength 字段总是包含相同的值——2048,因为它在 Oscillator 构造器中只分配一次。

audio-oscillator-data.js

如果我们知道整数模数运算的右边是 2 的幂,我们显然可以生成更好的代码,完全避免了英特尔上的 idiv 指令。所以我们需要获取一种信息使 this.waveTableLengthOscillator 构造器到 Oscillator.prototype.generate 中的模运算都是 2048。一个显而易见的方法是尝试依赖于将所有内容内嵌到 calcOsc 函数,并让 load/store 消除为我们进行的常量传播,但这对于在 calcOsc 函数之外分配的 sine oscillator 无效。

因此,我们所做的就是添加支持跟踪某些常数值作为模运算符的右侧反馈。这在 V8 中是有意义的,因为我们为诸如 +*% 的二进制操作跟踪类型反馈,这意味着操作者跟踪输入的类型和产生的输出类型(参见最近的圆桌讨论中关于动态语言的快速运算的幻灯片)。当然,用 fullcodegenCrankshaft 挂接起来也是相当容易的,MODBinaryOpIC 也可以跟踪右边已知的 2 的冥。

$ ~/Projects/v8/out/Release/d8 --trace-ic audio-oscillator.js
[...SNIP...]
[BinaryOpIC(MOD:None*None->None) => (MOD:Smi*2048->Smi) @ ~Oscillator.generate+598 at audio-oscillator.js:697]
[...SNIP...]
$

事实上,以默认配置运行的 V8 (带有 Crankshaft 和 fullcodegen)表明 BinaryOpIC 正在为模数的右侧拾取适当的恒定反馈,并正确跟踪左侧始终是一个小整数(以 V8 的话叫做 Smi),我们也总是产生一个小整数结果。 使用 --print-opt-code -code-comments 查看生成的代码,很快就显示出,Crankshaft 利用反馈在 Oscillator.prototype.generate 中为整数模数生成一个有效的代码序列:

[...SNIP...]
                  ;;; <@80,#84> load-named-field
0x133a0bdacc4a   330  8b4343         movl rax,[rbx+0x43]
                  ;;; <@83,#86> compare-numeric-and-branch
0x133a0bdacc4d   333  3d00080000     cmp rax,0x800
0x133a0bdacc52   338  0f85ff000000   jnz 599  (0x133a0bdacd57)
[...SNIP...]
                  ;;; <@90,#94> mod-by-power-of-2-i
0x133a0bdacc5b   347  4585db         testl r11,r11
0x133a0bdacc5e   350  790f           jns 367  (0x133a0bdacc6f)
0x133a0bdacc60   352  41f7db         negl r11
0x133a0bdacc63   355  4181e3ff070000 andl r11,0x7ff
0x133a0bdacc6a   362  41f7db         negl r11
0x133a0bdacc6d   365  eb07           jmp 374  (0x133a0bdacc76)
0x133a0bdacc6f   367  4181e3ff070000 andl r11,0x7ff
[...SNIP...]
                  ;;; <@127,#88> deoptimize
0x133a0bdacd57   599  e81273cdff     call 0x133a0ba8406e
[...SNIP...]

所以你看到我们加载 this.waveTableLengthrbx 持有 this 的引用)的值,检查它仍然是 2048(十六进制的 0x800),如果是这样,就只用适当的掩码 0x7ff(r11 包含循环感应变量 i 的值)执行一个位操作 AND ,而不是使用 idiv 指令(注意保留左侧的符号)。

过度特定的问题

所以这个技巧酷毙了,但正如许多基准关注的技巧都有一个主要的缺点:太过于特定了!一旦右侧发生变化,所有优化过的代码就失去了优化(假设右手始终是不再处理的 2 的冥),任何进一步的优化尝试都必须再次使用 idiv,因为 BinaryOpIC 很可能以 Smi * Smi -> Smi 的形式报告反馈。例如,假设我们实例化另一个 Oscillator,在其上设置不同的 waveTableLength,并为 Oscillator 调用 generate,那么即使我们实际上感兴趣的 Oscillator 不受影响,我们也会损失 20% 的性能(例如,引擎在这里实行非局部惩罚)。

--- audio-oscillator.js.ORIG    2016-12-15 22:01:43.897033156 +0100
+++ audio-oscillator.js 2016-12-15 22:02:26.397326067 +0100
@@ -1931,6 +1931,10 @@
 var frequency = 344.53;
 var sine = new Oscillator(Oscillator.Sine, frequency, 1, bufferSize, sampleRate);

+var unused = new Oscillator(Oscillator.Sine, frequency, 1, bufferSize, sampleRate);
+unused.waveTableLength = 1024;
+unused.generate();
+
 var calcOsc = function() {
   sine.generate();

将原始的 audio-oscillator.js 执行时间与包含额外未使用的 Oscillator 实例与修改的 waveTableLength 的版本进行比较,显示的是预期的结果:

$ ~/Projects/v8/out/Release/d8 audio-oscillator.js.ORIG
Time (audio-oscillator-once): 64 ms.
$ ~/Projects/v8/out/Release/d8 audio-oscillator.js
Time (audio-oscillator-once): 81 ms.
$

这是一个非常可怕的性能悬崖的例子:假设开发人员编写代码库,并使用某些样本输入值进行仔细的调整和优化,性能是体面的。现在,用户读过了性能说明开始使用该库,但不知何故从性能悬崖下降,因为她/他正在以一种稍微不同的方式使用库,即特定的 BinaryOpIC 的某种污染方式的类型反馈,并且遭受 20% 的减速(与该库作者的测量相比),该库的作者和用户都无法解释,这似乎是随机的。

现在这种情况在 JavaScript 领域并不少见,不幸的是,这些悬崖中有几个是不可避免的,因为它们是由于 JavaScript 的性能是基于乐观的假设和猜测。我们已经花了 大量 时间和精力来试图找到避免这些性能悬崖的方法,而仍提供了(几乎)相同的性能。事实证明,尽可能避免 idiv 是很有意义的,即使你不一定知道右边总是一个 2 的幂(通过动态反馈),所以为什么 TurboFan 的做法有异于 Crankshaft 的做法,因为它总是在运行时检查输入是否是 2 的幂,所以一般情况下,对于有符整数模数,优化右手侧的(未知的) 2 的冥看起来像这样(伪代码):

if 0 < rhs then
  msk = rhs - 1
  if rhs & msk != 0 then
    lhs % rhs
  else
    if lhs < 0 then
      -(-lhs & msk)
    else
      lhs & msk
else
  if rhs < -1 then
    lhs % rhs
  else
    zero

这产生更加一致和可预测的性能(使用 TurboFan):

$ ~/Projects/v8/out/Release/d8 --turbo audio-oscillator.js.ORIG
Time (audio-oscillator-once): 69 ms.
$ ~/Projects/v8/out/Release/d8 --turbo audio-oscillator.js
Time (audio-oscillator-once): 69 ms.
$

基准和过度特定化的问题在于基准可以给你提示可以看看哪里以及该怎么做,但它不告诉你应该做到什么程度,不能保护合理优化。例如,所有 JavaScript 引擎都使用基准来防止性能回退,但是运行 Kraken 不能保护我们在 TurboFan 中使用的常规方法,即我们可以将 TurboFan 中的模优化降级到过度特定的版本的 Crankshaft,而基准不会告诉我们性能回退的事实,因为从基准的角度来看这很好!现在你可以扩展基准,也许以上面我们相同的方式,并试图用基准覆盖一切,这是引擎实现者在一定程度上做的事情,但这种方法不能任意缩放。即使基准测试方便,易于用来沟通和竞争,以常识所见你还是需要留下空间,否则过度特定化将支配一切,你会有一个真正的、非常好的可接受的性能,以及巨大的性能悬崖线。

Kraken 测试还有许多其它的问题,不过现在让我们继续讨论过去五年中最有影响力的 JavaScript 基准测试—— Octane 测试。

查看其它分页:

最新评论

我也要发表评论

LCTT 译者

Mars Wong 🌟 🌟 🌟
共计翻译: 14 篇 | 共计贡献: 137
贡献时间:2016-10-10 -> 2017-02-23
访问我的 LCTT 主页 | 在 GitHub 上关注我

收藏

返回顶部

分享到微信

打开微信,点击顶部的“╋”,
使用“扫一扫”将网页分享至微信。