为什么数组中的 splat 成本如此之高?

aki*_*kim 3 ruby arrays performance

我喜欢使用 splat 构建数组和哈希:

\n
    \n
  • 它们是数组和散列文字,因此您不必进行一些计算来查看得到的值类型,语法非常清晰
  • \n
  • 它们使在单个表达式中构建相当复杂的值变得容易,而不是使用更命令式的样式(是的,您可以使用诸如 之类的东西变成单个赋值tap,但它的可读性较差)。
  • \n
\n

然而,泼溅的成本很高。

\n
require \'benchmark\'\n\n$array = (0...100).to_a\n\nn = 100_000\nBenchmark.bm do |x|\n  x.report(\'add   \') {n.times{$array + $array + $array}}\n  x.report(\'splat \') {n.times{[*$array, *$array, *$array]}}\nend\n
Run Code Online (Sandbox Code Playgroud)\n

在机器 A (MRI 3.1.3) 上我有:

\n
          user     system      total        real\nadd     0.031583   0.001421   0.033004 (  0.033006)\nsplat   0.050174   0.001397   0.051571 (  0.051584)\n
Run Code Online (Sandbox Code Playgroud)\n

在机器 B (MRI 2.7.4) 上:

\n
          user     system      total        real\nadd     0.278377   0.000000   0.278377 (  0.278316)\nsplat   0.780735   0.043730   0.824465 (  0.824377)\n
Run Code Online (Sandbox Code Playgroud)\n

为什么基于 splat 的数组构建如此慢?我预计基于 splat 的构造不会比普通加法慢(毕竟 AST 甚至可以将一个转换为另一个),而且我实际上期望它更高效(因为该语言可以看到所有内容,因此它可以避免中间过程)通过二进制加法创建的数组,它还可以预测最终数组的大小并预先预留空间等)。

\n

那么,为什么 go 抛出方法调用(因此,先验地,解释器不太可优化)的替代方案比将所有内容诚实地暴露给解释器的方法更快呢?

\n

编辑:更多选择

\n
          user     system      total        real\nadd     0.031583   0.001421   0.033004 (  0.033006)\nsplat   0.050174   0.001397   0.051571 (  0.051584)\n
Run Code Online (Sandbox Code Playgroud)\n

这是机器 A,MRI 3.1.3。

\n
Warming up --------------------------------------\n                 add   300.630k i/100ms\n              append   140.913k i/100ms\n             concat2   154.698k i/100ms\n             concat3   120.459k i/100ms\n        concat_splat   142.808k i/100ms\n             flatten    10.329k i/100ms\n          flatten(1)    52.207k i/100ms\n               splat   195.946k i/100ms\nCalculating -------------------------------------\n                 add      3.040M (\xc2\xb1 0.7%) i/s -     15.332M in   5.043760s\n              append      1.400M (\xc2\xb1 1.9%) i/s -      7.046M in   5.034067s\n             concat2      1.532M (\xc2\xb1 2.1%) i/s -      7.735M in   5.049821s\n             concat3      1.134M (\xc2\xb1 2.6%) i/s -      5.782M in   5.101784s\n        concat_splat      1.409M (\xc2\xb1 1.9%) i/s -      7.140M in   5.068373s\n             flatten    102.948k (\xc2\xb1 0.6%) i/s -    516.450k in   5.016786s\n          flatten(1)    517.582k (\xc2\xb1 5.2%) i/s -      2.610M in   5.058161s\n               splat      1.939M (\xc2\xb1 1.4%) i/s -      9.797M in   5.052514s\n\nComparison:\n                 add:  3039958.8 i/s\n               splat:  1939484.0 i/s -  1.57x  (\xc2\xb1 0.00) slower\n             concat2:  1532384.2 i/s -  1.98x  (\xc2\xb1 0.00) slower\n        concat_splat:  1409339.8 i/s -  2.16x  (\xc2\xb1 0.00) slower\n              append:  1400120.7 i/s -  2.17x  (\xc2\xb1 0.00) slower\n             concat3:  1134080.2 i/s -  2.68x  (\xc2\xb1 0.00) slower\n          flatten(1):   517582.1 i/s -  5.87x  (\xc2\xb1 0.00) slower\n             flatten:   102948.3 i/s - 29.53x  (\xc2\xb1 0.00) slower\n
Run Code Online (Sandbox Code Playgroud)\n

Kon*_*kov 8

Addition 和 splat 版本发出不同的字节码(为简洁起见,省略了一些输出):

puts RubyVM::InstructionSequence.compile(<<~ADDITION).disasm
  src = (0...100).to_a
  res = src + src + src
ADDITION

0000 putobject                              0...100                   (   1)[Li]
0002 opt_send_without_block                 <calldata!mid:to_a, argc:0, ARGS_SIMPLE>
0004 setlocal_WC_0                          src@0
0006 getlocal_WC_0                          src@0                     (   2)[Li]
0008 getlocal_WC_0                          src@0
0010 opt_plus                               <calldata!mid:+, argc:1, ARGS_SIMPLE>
0012 getlocal_WC_0                          src@0
0014 opt_plus                               <calldata!mid:+, argc:1, ARGS_SIMPLE>
0016 dup
0017 setlocal_WC_0                          res@1
0019 leave 
Run Code Online (Sandbox Code Playgroud)

puts RubyVM::InstructionSequence.compile(<<~SPLATS).disasm
  src = (0...100).to_a
  res = [*src, *src, *src]
SPLATS

0000 putobject                              0...100                   (   1)[Li]
0002 opt_send_without_block                 <calldata!mid:to_a, argc:0, ARGS_SIMPLE>
0004 setlocal_WC_0                          src@0
0006 getlocal_WC_0                          src@0                     (   2)[Li]
0008 splatarray                             true
0010 getlocal_WC_0                          src@0
0012 concatarray
0013 getlocal_WC_0                          src@0
0015 concatarray
0016 dup
0017 setlocal_WC_0                          res@1
0019 leave
Run Code Online (Sandbox Code Playgroud)

上面的两个片段看起来非常相似,区别在于 2ops_plus条指令与splatarray+ 2concatarray条指令。但在实施方面,差异变得更大。

rb_ary_plus简而言之,第一个归结为 2 :

  • 为 src + src 分配内存
  • 将 src + src 复制到新的内存位置
  • 为 (src + src) + src 分配内存
  • 复制 (src + src) + src 到新的内存位置

后者在内部似乎更复杂:splatarray归结为rb_ary_dup(因此我们首先复制 ary),concatarray在幕后也复制目标数组,然后归结为rb_ary_splice; 后者有点毛茸茸的,但我相信我们进入这个分支,我们有效地将数组容量加倍(包括复制第一个数组),然后复制第二个数组。我不能 100% 确定我是否正确地跟踪了这个执行流程,但如果我这样做了,它会给我们带来:

  • 重复的源代码
  • 再次复制 src (?)
  • 双目标容量(包括复制)
  • 将第二个数组复制到上面分配的空间
  • 重复(src + src)
  • 双 (src + src) 容量(包括将 (src + src) 元素复制到新的内存位置)
  • 将第三个数组复制到分配的空间

这些额外的重复可以解释这种差异(更不用说后者的整体复杂性意味着检查更多的条件等)。