【问题标题】:Why are these two smaller loops so much slower than a single loop containing the same instructions?为什么这两个较小的循环比包含相同指令的单个循环慢得多?
【发布时间】:2020-07-22 01:27:40
【问题描述】:
Ruby v2.3.3p222
MacOS Catalina v10.15.3
Processor: 2.6GHz 6-Core Intel Core i7

我有以下性能基准脚本,旨在测试一个较大的循环操作与两个较小的循环之间的差异:

require 'benchmark'

N = 10_000_000

def one_loop
  N.times do
    foo = 1+1
    bar = 2+2
  end
end

def two_loops
  N.times do
    foo = 1+1
  end

  N.times do
    bar = 2+2
  end
end


Benchmark.bmbm do |performance|
  performance.report("two smaller loops") { two_loops }
  performance.report("one large loop") { one_loop }
end

我的假设是这两种方法将在大约相同的时间内执行,因为(我认为)它们都执行相同数量的指令:较大的循环执行 2 * 10,000,000 次操作,而 2较小的循环执行 1 * 10,000,000 次操作。

但是,这似乎不是我观察到的。当我运行脚本时,我得到以下输出:

Rehearsal -----------------------------------------------------
two smaller loops   0.840000   0.000000   0.840000 (  0.838101)
one large loop      0.500000   0.010000   0.510000 (  0.506283)
-------------------------------------------- total: 1.350000sec

                        user     system      total        real
two smaller loops   0.850000   0.000000   0.850000 (  0.863052)
one large loop      0.500000   0.000000   0.500000 (  0.494525)

这真的很令人失望,因为我希望通过将 1 个大代码循环拆分为几个更简洁的循环,每个循环只做一件事并且做得很好,从而让我的团队相信我们不会看到任何性能下降。

我认为这可能是由于生成报告的顺序,但是当我将两次调用的顺序颠倒到 performance.report 时,我得到了同样令人失望的结果:

Rehearsal -----------------------------------------------------
one large loop      0.500000   0.010000   0.510000 (  0.508246)
two smaller loops   0.850000   0.000000   0.850000 (  0.852467)
-------------------------------------------- total: 1.360000sec

                        user     system      total        real
one large loop      0.490000   0.000000   0.490000 (  0.496130)
two smaller loops   0.830000   0.000000   0.830000 (  0.831476)

我错过了什么吗?两个较小的循环真的比单个较大的循环做更多的工作吗?还是我以某种误导或不准确的方式构建了我的基准脚本?

【问题讨论】:

  • 在大 O 中,这些都被认为是 N 次,因为我们删除了常数(“大”循环是 2N,较小的是 N)。我会说它很可能仍然值得拆分 - 为了性能而将代码编写为巨大的表达式听起来有点悲惨。你必须问问自己,1000 万次迭代中的 0.4 秒是否真的很重要(特别是考虑到循环中实际发生的任何事情都可能比 2 + 2 更耗时)

标签: ruby benchmarking


【解决方案1】:

较大的循环执行 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团队成员)。

【讨论】:

  • TL:DR: 微基准测试是hard,尤其是任何涉及优化编译器而不是简单解释器的东西,或者小到足以超出-底层 CPU 的订单执行变得相关。 (吞吐量和延迟是不同的)。但是,是的,动态 CPU 频率等热身效应,以及第一次接触新分配的内存时的页面错误,也可能是主要因素。 Idiomatic way of performance evaluation? 和其他 JIT 编译语言的热身效果,如大多数 JVM 和一些 Ruby。
【解决方案2】:

这是 1000 万次迭代,在每次迭代中进行两次计算,总共有 3000 万次我们称之为操作:

  N.times do
    foo = 1+1
    bar = 2+2
  end

这是 2000 万次迭代,在每次迭代中完成一个计算,总共 4000 万次我们称之为操作:

  N.times do
    foo = 1+1
  end

  N.times do
    bar = 2+2
  end

30

【讨论】:

  • @Richie Thomas 循环包括一个递减计数器并测试它是否 > 0,所以我会说它至少计数 2。您的问题是,在您的示例中,维护循环的开销更大比里面正在进行的工作。如果您将具有 100 个操作的单个循环分解为具有 50 个操作的 2 个循环,则开销可以忽略不计
  • This is 10 million iterations, and in each iteration two calculations are done, for a total of 30 million... 那么这是否意味着除了循环内发生的任何操作之外,每个循环的每次迭代都算作它自己的操作?我的印象是,情况并非如此,至少在将代码编译为汇编指令时。我确信这也取决于我的架构,这就是我包含硬件信息和 Ruby 版本的原因。编辑 - 删除了上面曾经出现在@JimCastro 评论之前的评论。
  • @JimCastro 在这种情况下,我的示例代码与我的真实示例不匹配。我将用两个循环内的更多内容重新进行基准测试。谢谢!
猜你喜欢
  • 1970-01-01
  • 2017-01-08
  • 1970-01-01
  • 1970-01-01
  • 2013-08-05
  • 1970-01-01
  • 2019-09-29
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多