较大的循环执行 2 * 10,000,000 次操作,而 2 个较小的循环中的每一个都执行 1 * 10,000,000 次操作
如果不定义我们为这些“操作”建模的机器模型和成本模型,就谈论“操作”是没有意义的。或者,简单地说:在你清楚你在数什么之前,数数是没有意义的。
在这种情况下,您计算的是添加。你是对的:在你的模型中,只有添加,两个版本都有相同数量的添加。
他们确实,但是,不有相同数量的块激活。
请记住,Integer#times 大致如下:
class Integer
def times
return enum_for(__callee__) unless block_given?
return self unless positive?
i = -1
yield i while (i += 1) < self
self
end
end
因此,对于循环的每次迭代,都有一个传递给Integer#times 的块的激活(即yield)。
如果我们将它添加为一个新的“操作”类,我们有以下内容:
-
one_loop:20,000,000 次添加和 10,000,000 次区块激活
-
two_loops:20,000,000 次添加和 20,000,000 次区块激活
因此,两种方法的加法数量相同,但two_loops 的块激活次数是其两倍。
这意味着,我们还必须考虑添加与块激活的相对成本。现在,语义,添加只是一个普通的方法调用。激活块有点类似于方法调用。
因此,我们预计添加和块激活的成本大致相似,这意味着我们的成本将是:
-
one_loop: 30,000,000 “方法调用类似操作”
-
two_loops: 40,000,000 “方法调用类似操作”
换句话说,我们预计 two_loops 会慢 33% 或 one_loop 会快 25%,这取决于您如何看待它。
但是,我们实际上发现差异要大得多,因此很明显我们的模型中遗漏了一些东西。
我们缺少的是优化。整数上的算术运算非常很常见并且非常对性能非常关键,因此所有Ruby实现都竭尽全力使它们变得快速。事实上,在所有 Ruby 实现中,简单的添加(例如您正在使用的添加)将直接映射到单个 CPU ADD 指令,并且不会产生方法调用的开销。
块激活在 Ruby 中也非常重要,因此它们也进行了大量优化,但它们基本上比添加两个机器字整数要复杂几个数量级。
事实上,块激活对机器字整数加法的相对复杂性如此之大,我们实际上可以在我们的模型中完全忽略加法:
-
one_loop:10,000,000 次区块激活
-
two_loops: 20,000,000 块激活
这给了我们 2:1 的系数,因此我们预计 two_loops 会慢 100% 或 one_loop 会快 50%。
顺便说一句,我忽略了另一个正在发生的操作:局部变量的定义和初始化。论点类似:这是一个非常快的操作,与块激活相比可以忽略不计。
实际上,到目前为止,我们只讨论了这些操作的相对成本,以及它们如何意味着我们可以忽略加法和局部变量的成本。然而,还有一个更强有力的理由忽略这些:优化。
即使是最简单的 Ruby 实现也能够完全优化掉局部变量:它们只在一个地方定义和初始化,并且再也不会被访问。它们只存在于块的范围内,在块的一次激活期间,因此即使是非常简单的优化器也可以看到它们完全没用,因此即使是最简单的优化器也会将代码优化为大致这样:
def one_loop
N.times do
1+1
2+2
end
end
def two_loops
N.times do
1+1
end
N.times do
2+2
end
end
意味着我们不仅可以忽略局部变量的成本,因为它与其他成本相比很小,而且实际上,局部变量甚至不存在。
同样,稍微聪明一点的优化器会识别出one_loop 中的第一个加法没有副作用,不返回,不存储在变量中(或至少不存储在任何地方使用的变量中),并且在general 不会以任何方式、形状或形式影响计算的结果,因此会将代码优化为:
def one_loop
N.times do
2+2
end
end
def two_loops
N.times do
1+1
end
N.times do
2+2
end
end
此外,同样的论点实际上适用于剩余的加法。它没有副作用,它所做的只是从块中返回,但Integer#times 忽略块的返回值。我没有查看生成的代码,但我强烈怀疑即使是最愚蠢的优化器也可以轻松证明您的块是无操作的,因此它将代码优化为大致如下:
def one_loop
N.times do
end
end
def two_loops
N.times do
end
N.times do
end
end
这意味着 one_loop 具有块的 N 迭代,two_loops 具有 2 * N 迭代,因此大约需要两倍的时间。
现在,我们可以在您的基准测试中看到,这些数字实际上并不是 2:1。它们是 1.75:1 或大约 7:4。
我可以在我的机器上确认这些结果,这里有 YARV 2.7.1 没有 JIT,我得到的几乎是 7:4:
user system total real
two smaller loops 0.711479 0.000099 0.711578 ( 0.711680)
one large loop 0.401808 0.000059 0.401867 ( 0.401916)
但是,当我打开 JIT 时,我得到的 2:1 几乎完全符合我们的预期:
user system total real
two smaller loops 0.587017 0.000279 0.587296 ( 0.587098)
one large loop 0.291713 0.000062 0.291775 ( 0.291779)
您还会注意到它通常更快。
使用JRuby 9.2.9.0,我们再次获得稍微更快的执行速度和几乎2:1:
user system total real
two smaller loops 0.740000 0.010000 0.750000 ( 0.517670)
one large loop 0.260000 0.000000 0.260000 ( 0.263270)
这是使用默认选项,以下是一些更激进的编译器标志的结果:
user system total real
two smaller loops 0.370000 0.000000 0.370000 ( 0.362050)
one large loop 0.390000 0.010000 0.400000 ( 0.213861)
TruffleRuby 20.1.0 再次比 JRuby 快得多:
user system total real
two smaller loops 0.009955 0.000039 0.009994 ( 0.010035)
one large loop 0.004759 0.000007 0.004766 ( 0.004742)
再一次,非常接近 2:1。此外,尽管我们只对这两种方法的相对性能感兴趣,但很高兴看到 TruffleRuby 在这个基准测试中比 YARV 快 70 到 100 倍!
实际上,令我有些惊讶的是,TruffleRuby 无法证明带有空块主体的Integer#times 是空操作。我本来希望它能够像这样优化代码:
def one_loop
end
def two_loops
end
因此两个版本之间根本没有运行时差异。
我错过了什么吗?两个较小的循环真的比单个较大的循环做更多的工作吗?还是我以某种误导或不准确的方式构建了我的基准脚本?
我会说以上所有。
主要问题是你测量几乎与你计算的完全相反。您只计算添加而忽略块激活,这并没有错,IFF您只关心添加的数量而不是其他。
而您测量只是块激活的成本而忽略了添加的成本,如果您对此感兴趣,这也完全没问题。
问题在于这两者不匹配:你没有测量你正在计算的东西,你没有计算你正在测量的东西,所以你只是无法从结果中得出任何结论你对你的假设的实验。
在one of your comments,你问:
这是否意味着除了循环内发生的任何操作之外,每个循环的每次迭代都算作它自己的操作?
我们不能告诉你。 你需要定义你感兴趣的操作,以及你想忽略的操作。如果您将“操作”定义为 only 表示“加法”,那么不,循环的每次迭代都 not 算作自己的操作,并且您的两个示例都有完全相同的操作量。
另一个问题是您的假设“加法次数相同,因此执行时间相同”是无效的,因为加法不是唯一需要时间的操作。即使您计算其他类型的操作,您的假设仍然假设每个操作都花费相同的时间,这也是不正确的。
总体而言,您的基准测试方法还存在一些问题,但这并不是您困惑的根源。以下是我发现的您的基准测试中的一些问题,但我相信还有其他问题:
- 您的基准测试的编写方式是优化掉您感兴趣的所有操作,只留下您不感兴趣的操作。
- 即使它们没有经过优化,与您不关心的操作的执行时间相比,它们的执行时间也可以忽略不计。
- 您的基准测试运行的时间不够长,而且经常足以给优化器一个机会。例如,编译方法的默认阈值在 20 到 20000 次调用之间,具体取决于 Ruby 实现、编译器标志等。这两种方法都只调用两次,一次是在排练期间,一次是在实际情况下。您需要确保它们被调用超过 20000 次,以确保 a) 它们完全被编译,并且 b) 有足够的迭代 after 它们已被编译,之前较慢的迭代它们的编译不会显着影响结果。
我始终建议想要编写基准测试的人阅读并理解以下邮件列表线程:
JMH vs Caliper: reference thread
尤其是从链接消息开始的子线程和后续讨论。
虽然此线程是关于 特定 基准测试工具,用于对 Java 代码进行基准测试,但线程中讨论的任何内容都适用于 all 基准测试所有现代高性能语言实现。
基准测试由以编写基准作为全职工作的基准工程师编写是有原因的:编写基准需要大量知识和专业知识。
至少你需要
- 对计算机组织有深入的了解。
- 深入了解您进行基准测试的所有特定硬件平台。
- 对编程语言有深入的了解。
- 深入了解您编写基准代码时使用的所有特定编程语言。
- 深入了解一般语言实现,包括但不限于提前编译器、JIT 编译器、解释器、VM、垃圾收集器、内存分配器、优化器、内联、循环展开、死代码消除、常量折叠、公共子表达式消除、尾调用消除、窥视孔优化、编译时评估、多态内联缓存等等。
- 深入了解运行代码的特定语言实现。
- 还有更多,例如操作系统、调度、NUMA、多线程、SMT、CMT……
当您拥有所有这些时,您会看到一堆数字,您需要知道如何解释,这需要深入的统计知识。
benchmark library in the Ruby stdlib 是许多“罪恶”的一个例子。 25 年前完全没问题,当时只有一个 Ruby 实现,它只是一个愚蠢的 AST-walking 解释器,没有任何优化,计算机只有一个 CPU,没有乱序执行或推测,仅此而已.但就目前而言,我们有大量积极优化 Ruby 实现(最突出的是 TruffleRuby)和复杂的 CPU,它们也执行自己的优化,它只是不再削减它了。
不幸的是,没有可与现有工具相比复杂程度的基准工具,例如在 Java 世界中,但至少有一些替代品,例如 better-benchmark(不再维护)、benchmark-ips(由 Rubinius 的创始人 Evan Phoenix)或 fruity(由 Marc-André Lafortune,ruby-core团队成员)。