为什么Ruby irb迭代如此缓慢?

3
我在irb中使用Ruby的基准类Benchmark并进行了测试,发现在迭代时Ruby明显变慢。我做了一个简单的测试,没有使用Benchmark或Profiler__类(我认为可能是它使速度变慢)。
def average_test
    total_time = 0
    time = 0
    TESTS.times do |count|
        time = test
        total_time = total_time + time
        yield count, time
    end
    average = total_time / TESTS
    yield 'average', average
end
def test
    x = 0
    start_time = Time.now
    for i in 1..ITERATIONS
        x = x + 1
    end
    end_time = Time.now
    time = end_time - start_time
end
ITERATIONS = 10_000_000
TESTS = 20
# create results file
results = File.new('results.txt', 'w')
# start test
average_test {|count, time| results.print "Test #{count}: #{time}"}
results.close

以下是在irb中运行后的结果。(秒为单位,抱歉)
测试0: 2.390647,测试1: 2.343761,测试2: 2.312554,测试3: 2.566792,测试4: 2.665193,测试5: 2.537908,测试6: 2.643086,测试7: 2.534492,测试8: 2.589034,测试9: 2.390633,测试10: 2.539533,测试11: 2.385508,测试12: 2.49659,测试13: 2.498958,测试14: 2.527309,测试15: 2.462983,测试16: 2.504546,测试17: 2.570159,测试18: 2.371447,测试19: 2.330072,
测试平均值: 2.48306025秒,2483毫秒
我还在JavaScript中进行了同样的测试,只是为了比较速度。
function test() {
    var start = Date.now();
    var x = 0;
    for (var i = 0; i < ITERATIONS; i++) {
        x = x + 1;
    }
    var end = Date.now();
    var dt = end - start;
    return dt;
}
function averageTest() {
    var total = 0;
    for (var i = 0; i < TESTS; i++) {
        var time = test();
        total = total + time;
        console.log('Test ' + i + ': ', time);
    }
    var avg = total / TESTS;
    console.log('Average: ', avg);
    return avg;
}
var ITERATIONS = 10000000;
var TESTS = 20;
// start test
var avgTime = averageTest(); // results

这是在Chrome浏览器中运行JavaScript代码的结果(以毫秒为单位):
测试0:41,测试1:44,测试2:41,测试3:48,测试4:46,测试5:48,测试6:49,测试7:47,测试8:46,测试9:50,测试10:41,测试11:41,测试12:47,测试13:54,测试14:55,测试15:57,测试16:35,测试17:50, 测试18:47,测试19:49,
平均值:46.8(毫秒),0.0468(秒)
Ruby的平均值为2483毫秒,而JavaScript的平均值为46.8毫秒。
为什么会有如此巨大的差异?是因为Ruby的运算符是方法调用,而方法调用很慢吗?
我感觉我做错了什么。谢谢。

@bjhaid,我只在average_test方法中使用yield,而不是在test方法中。test方法是计时一千万次迭代的地方。我甚至使用了一个没有块的for..in循环。 - user3869617
1
如果我只比较test方法,Chrome在我的机器上非常快,只需18毫秒,但Firefox需要大约1300毫秒(两者都在浏览器控制台中运行)。Ruby大约需要720毫秒。可能是Chrome的JIT编译器在这种类型的循环中表现出色。 - Bruno E.
我从Ruby得到了0.47767336060000004秒,从Chrome得到了15.45毫秒,从Firefox得到了1217.6毫秒。看起来Chrome在这种情况下做了一些魔法。你是在使用Windows吗? - ndnenkov
@ndn 是的,我正在使用Windows。我刚刚在Microsoft Edge中尝试了JavaScript测试,平均得分为2000毫秒。我想浏览器差异很大,但我从未预料到差异会这么大。 - user3869617
你看过V8生成的本地机器代码吗?我怀疑问题在于你的基准测试没有做任何事情(既没有副作用也没有值),因此被完全优化掉了。所以,你实际上并没有对V8的运行时性能进行基准测试,而是编译器性能(即它有多快地将你的代码编译成无效代码)。与Rubinius或JRuby相比,V8的优化编译器非常先进,当然YARV根本没有优化编译器。V8是由编写Self VM、Animorphic Smalltalk VM的同一批人编写的。 - Jörg W Mittag
显示剩余3条评论
1个回答

3
我用了几种不同的Ruby实现来测试你的基准测试,结果差异很大。这似乎证实了我的怀疑,即你的基准测试并没有按照你想象中的那样进行衡量。正如我在上面的评论中提到的那样:编写基准测试时,您应该始终阅读生成的本机机器代码,并验证它实际上是否测量了您认为它测量的内容。
例如,在YARV基准测试套件中有一个基准测试旨在衡量消息分派性能,但是在Rubinius上,消息分派完全被优化掉了,因此实际执行的唯一操作就是递增基准测试循环的计数器变量。基本上,它只告诉您CPU的频率,仅此而已。
以下是Ruby 2.3.0dev(2015-08-08 trunk 51510)[x86_64-darwin14]的当前快照:
这里是当前的YARV快照: 测试0:0.720945 测试1:0.733733 测试2:0.722778 测试3:0.734074 测试4:0.774355 测试5:0.773379 测试6:0.751547 测试7:0.708566 测试8:0.724959 测试9:0.730899 测试10:0.725978 测试11:0.712902 测试12:0.747069 测试13:0.737792 测试14:0.736885 测试15:0.751422 测试16:0.718943 测试17:0.760094 测试18:0.746343 测试19:0.764731 平均值:0.738870
如您所见,性能在多个运行中非常一致,并且似乎与评论中发布的其他结果相符。
以下是Rubinius 2.5.8(2.1.0 bef51ae3 2015-08-09 3.5.1 JI)[x86_64-darwin14.4.0]的当前版本:
这里是Rubinius的当前版本:
测试 0: 1.159465
测试 1: 1.063721
测试 2: 0.516513
测试 3: 0.515016
测试 4: 0.553987
测试 5: 0.544286
测试 6: 0.567737
测试 7: 0.563350
测试 8: 0.517581
测试 9: 0.501865
测试 10: 0.503399
测试 11: 0.512046
测试 12: 0.487296
测试 13: 0.533193
测试 14: 0.533217
测试 15: 0.511648
测试 16: 0.535847
测试 17: 0.490049
测试 18: 0.539681
测试 19: 0.551324
平均值: 0.585061

可以看到,在第二次运行期间编译器启动,此后速度变为两倍,比YARV快得多,而在前两次运行期间,它明显比YARV慢。

jruby 9.0.0.0-SNAPSHOT (2.2.2) 2015-07-23 89c1348 Java HotSpot(TM) 64-Bit Server VM 25.5-b02 on 1.8.0_05-b13 +jit [darwin-x86_64]

这是在略微陈旧的HotSpot发行版(几个月)上运行的JRuby的当前快照:

测试 0: 1.169000
测试 1: 0.805000
测试 2: 0.772000
测试 3: 0.755000
测试 4: 0.777000
测试 5: 0.749000
测试 6: 0.751000
测试 7: 0.694000
测试 8: 0.696000
测试 9: 0.708000
测试 10: 0.691000
测试 11: 0.745000
测试 12: 0.752000
测试 13: 0.755000
测试 14: 0.707000
测试 15: 0.744000
测试 16: 0.674000
测试 17: 0.710000
测试 18: 0.733000
测试 19: 0.706000
平均值: 0.754650

同样的,在第一次和第二次运行之间编译器似乎启动了,之后它表现与YARV相当。

jruby 9.0.1.0-SNAPSHOT (2.2.2) 2015-08-09 2939c73 OpenJDK 64-Bit Server VM 25.40-b25-internal-graal-0.7 on 1.8.0-internal-b128 +jit [darwin-x86_64]

这是JRuby的较新版本,运行在未来版本的HotSpot上:

测试  0: 0.815000
测试  1: 0.693000
测试  2: 0.634000
测试  3: 0.615000
测试  4: 0.599000
测试  5: 0.616000
测试  6: 0.623000
测试  7: 0.611000
测试  8: 0.604000
测试  9: 0.598000
测试 10: 0.628000
测试 11: 0.627000
测试 12: 0.601000
测试 13: 0.646000
测试 14: 0.675000
测试 15: 0.611000
测试 16: 0.684000
测试 17: 0.689000
测试 18: 0.626000
测试 19: 0.639000
平均值: 0.641700

同样地,在前两次运行时速度会变快,之后会趋于稳定,在YARV和其他JRuby之间略微快一些,比Rubinius稍微慢一些。

jruby 9.0.1.0-SNAPSHOT (2.2.2) 2015-08-09 2939c73 OpenJDK 64-Bit Server VM 25.40-b25-internal-graal-0.7 on 1.8.0-internal-b128 +jit [darwin-x86_64]

这是我的最爱:启用Truffle运行在Graal-enabled JVM上的JRuby+Truffle:

测试0: 6.226秒
测试1: 5.696秒
测试2: 1.836秒
测试3: 0.057秒
测试4: 0.111秒
测试5: 0.103秒
测试6: 0.082秒
测试7: 0.146秒
测试8: 0.089秒
测试9: 0.077秒
测试10: 0.076秒
测试11: 0.082秒
测试12: 0.072秒
测试13: 0.104秒
测试14: 0.124秒
测试15: 0.084秒
测试16: 0.080秒
测试17: 0.118秒
测试18: 0.087秒
测试19: 0.070秒
平均值: 0.766秒

Truffle似乎需要显著的启动时间,前三次运行非常慢,但之后速度显著加快,使其他技术相形见绌,速度提高了5-10倍。

注意:这并不是100%公平的,因为JRuby+Truffle尚未支持完整的Ruby语言。

还要注意:这表明简单地对所有运行进行平均是非常误导性的,因为JRuby + Truffle的稳态性能实际上比YARV和JRuby快7倍。最慢的运行(JRuby + Truffle的第1个运行)和最快的运行(JRuby + Truffle的第20个运行)之间的差异高达100倍。

注意3:注意到JRuby的所有数字都以000结尾吗?那是因为JRuby不能通过JVM轻松访问底层操作系统的微秒计时器,因此必须满足于毫秒。在这个特定的基准测试中并不太重要,但对于更快的基准测试来说,它可能会显著地扭曲结果。这只是在设计基准测试时必须考虑的另一件事。

为什么会有这么大的差异?是因为Ruby的运算符是方法调用,而方法调用很慢之类的原因吗?

我认为不是。在YARV上,Fixnum#+甚至不是一个方法调用,它被优化为静态内置运算符。它本质上在CPU中执行寄存器中的原始整数加法操作。速度非常快。

只有当您猴子补丁Fixnum时,YARV才回退成将其视为方法调用。

Rubinius可能可以优化掉方法调用,尽管我没有检查过。

我感觉我做错了什么。

也许你的基准测试并没有测量你所认为的东西。特别是,在使用拥有复杂优化编译器的实现中,迭代基准测试的迭代部分可能会被优化掉。
实际上,我注意到你的JavaScript和Ruby基准测试之间存在显著差异:在JavaScript中,你正在使用原始的`for`循环,而在Ruby中,你正在使用`Range#each` (`for … in`只需转换为`each`)。如果我将Ruby和JavaScript基准测试都切换到相同的`while`循环,我得到的结果是:对于Ruby版本,YARV为223ms,Rubinius为56ms,JRuby为28ms,JRuby+Truffle为33ms。对于JS版本,Squirrelfish Extreme / Nitro(Safari)为30ms,V8/Crankshaft(Chrome)为16ms。
换句话说,如果你测量相同的东西,它们最终会变得同样快;-)(好吧,除了YARV外,它总体来说就比较慢。)
因此,事实证明Ruby和JavaScript之间的区别在于,在JS中,你并没有迭代任何东西,你只是增加了一个数字,而在Ruby中,你实际上正在迭代一个数据结构(即一个`Range`)。删除Ruby中的迭代,它就像JavaScript一样快了。
我创建了两个基准测试脚本,现在希望它们大致测量相同的事情:
#!/usr/bin/env ruby

ITERATIONS = 10_000_000
TESTS = 20
WARMUP = 3
TOTALRUNS = TESTS + WARMUP
RESULTS = []

run = -1

while (run += 1) < TOTALRUNS
  i = -1
  starttime = Time.now

  while (i += 1) < ITERATIONS do end

  endtime = Time.now
  RESULTS[run] = (endtime - starttime) * 1000
end

puts RESULTS.drop(WARMUP).reduce(:+) / TESTS

"use strict";

const ITERATIONS = 10000000;
const TESTS = 20;
const WARMUP = 3;
const TOTALRUNS = TESTS + WARMUP;
const RESULTS = [];

let run = -1;

while (++run < TOTALRUNS) {
    let i = -1;
    const STARTTIME = Date.now();

    while (++i < ITERATIONS);

    const ENDTIME = Date.now();
    RESULTS[run] = ENDTIME - STARTTIME;
}

alert(RESULTS.slice(WARMUP).reduce((acc, el) => acc + el) / TESTS);

您会注意到我增加了迭代次数,将测试运行次数翻倍,并引入了一些不计入结果计算的热身运行。我还尽量使这两个代码片段尽可能相似。(注意:您可能需要删除一些ES6语法才能使其在浏览器上运行。例如,我的版本的Safari不喜欢箭头函数字面量。)
结果如下:
Ruby - YARV: 223.2498ms - JRuby: 358.45ms - Rubinius: 477.49485ms - JRuby+Truffle+Graal: 26.4ms
JavaScript - Nitro: 3827.3ms - V8: 6839ms
说实话,我有点困惑。现在Nitro领先于V8,所有Ruby实现都比JavaScript快10倍,JRuby+Truffle+Graal再次比其他Ruby要快10倍,从而使其比JavaScript快100倍。
我想这个真正告诉我们的是,这个基准测试毫无意义 :-D

太有启发性了!这是我在其他地方无法获得或发现的信息。谢谢! - user3869617

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接