【问题标题】:What is faster in C++: mod (%) or another counter?C++ 中哪个更快:mod (%) 或其他计数器?
【发布时间】:2021-04-05 10:13:35
【问题描述】:

冒着重复的风险,也许我现在找不到类似的帖子:

我正在用 C++ 编写(具体来说是 C++20)。我有一个带有计数器的循环,每回合都会计数。我们称之为counter。如果这个counter 达到页面限制(我们称之为page_limit),程序应该在下一页继续。所以它看起来像这样:

const size_t page_limit = 4942;
size_t counter = 0;
while (counter < foo) {
    if (counter % page_limit == 0) {
        // start new page
    }
    
    // some other code
    counter += 1;
}

现在我想知道,因为计数器变得非常高:如果我不让程序每次都计算模 counter % page_limit,而是创建另一个计数器,程序会运行得更快吗?它可能看起来像这样:

const size_t page_limit = 4942;
size_t counter = 0;
size_t page_counter = 4942;
while (counter < foo) {
    if (page_counter == page_limit) {
        // start new page
        page_counter = 0;
    }

    // some other code
    counter += 1;
    page_counter += 1;
}

【问题讨论】:

  • 使用像googlebenchmark这样的基准工具并找出答案。
  • 这将是一个微优化 - 现代编译器通过使用一些我从未听说过的疯狂 CPU 指令来优化整数模运算 - 所以我认为你问这个问题是在浪费你的时间.在发布任何编译器优化问题之前,您还应该查看 GodBolt.org。
  • 优化代码时的一般经验法则:您是否调用了超过十亿次?如果你故意让它变慢,例如 if (x % y || x % y || x % y ...) 重复 20 次,它是否会导致 可衡量的 性能拖累?如果没有,请继续,这不是问题。
  • 你应该把正确性放在过早的优化之前。 if (counter % page_limit) 可能不是你想要的。你的两个 sn-ps 做不同的事情,因此比较它们的性能不是很有意义。
  • @Jere:您实际上希望手持编译器使用递减计数器,而不是向上计数。 if(--pgcount == 0) { /*new page*/; pgcount=page_limit; }。这在 asm 中效率更高,在 C 中同样具有可读性,因此,如果您要进行微优化,则应该这样编写。相关:using that technique in hand-written asm FizzBuzz。也可能是 3 和 5 的倍数的 asm 和的 code review,但它对不匹配没有任何作用。

标签: c++ performance assembly micro-optimization branch-prediction


【解决方案1】:

如果除数是常数,大多数优化编译器会将除法或模运算转换为乘以预先生成的逆常数和移位指令。如果在循环中重复使用相同的除数值,也可能出现这种情况。
模乘以逆得到一个,然后将乘以除数得到一个,然后原始数字减去产品将是模数。
乘法和移位是相当新的 X86 处理器上的快速指令,但分支预测也可以减少条件分支所需的时间,因此可能需要一个基准来确定哪个是最好的。

【讨论】:

    【解决方案2】:

    (我假设你的意思是写if(x%y==0) 而不是if(x%y),相当于计数器。)

    我不认为编译器会为你做这种优化,所以它可能是值得的。即使您无法测量速度差异,它的代码量也会更小。 x % y == 0 方式仍然分支(因此在少数情况下仍然会出现分支错误预测)。它唯一的优点是它不需要单独的计数器变量,只需要在循环中的某个点使用一些临时寄存器。但它确实需要每次迭代的除数。

    总体而言,这对于代码大小来说应该更好,并且如果您习惯了这种习惯用法,它的可读性也不会降低。 (特别是如果您使用if(--page_count == 0) { page_count=page_limit; ...,那么所有逻辑部分都在相邻的两行中。)

    如果您的page_limit不是编译时常量,这更有可能提供帮助。dec/jz 每多次递减只使用一次是比div/test edx,edx/jz便宜很多,包括前端吞吐量。 (div 在 Intel CPU 上被微编码为大约 10 uop,所以即使它是一条指令,它仍然会占用前端多个周期,从而将吞吐量资源从将周围代码放入无序返回-结束)。

    (使用constant divisor, it's still multiply, right shift, sub to get the quotient,然后乘法和减法得到余数。所以仍然有几个单微指令。虽然有一些小常数可分性测试的技巧,请参阅@Cassio Neri 在Fast divisibility tests (by 2,3,4,5,.., 16)? 上的回答引用他的期刊文章;最近的 GCC 可能已经开始使用这些。)


    但如果你的循环体在前端指令/uop 吞吐量(在 x86 上)或除法器执行单元上没有瓶颈,那么乱序 exec 可能会隐藏大部分的偶数成本div 指令。它不在关键路径上,因此如果它的延迟与其他计算并行发生,它可能大部分是免费的,并且有空闲的吞吐量资源。 (分支预测 + 推测执行允许继续执行而无需等待分支条件已知,并且由于这项工作独立于其他工作,它可以“提前运行”,因为编译器可以看到未来的迭代。)

    不过,让这项工作变得更便宜可以帮助编译器更快地发现和处理分支错误预测。但是具有快速恢复功能的现代 CPU 可以在恢复时继续处理分支之前的旧指令。 (What exactly happens when a skylake CPU mispredicts a branch?/Avoid stalling pipeline by calculating conditional early)

    当然,一些循环确实完全保持 CPU 的吞吐量资源繁忙,而不是缓存未命中或延迟链的瓶颈。而且每次迭代执行的微指令越少,对其他超线程(或一般的 SMT)更友好。

    或者,如果您关心在有序 CPU 上运行的代码(常见于 ARM 和其他针对低功耗实现的非 x86 ISA),那么真正的工作必须等待分支条件逻辑。 (在运行额外代码来测试分支条件时,只有硬件预取或缓存未命中加载和类似的事情才能做有用的工作。)


    使用递减计数器

    您实际上不想向上计数,而是希望让编译器使用可以编译为dec reg / jz .new_page 或类似的递减计数器;所有普通的 ISA 都可以很便宜地做到这一点,因为它与您在普通循环底部找到的东西相同。 (dec/jnz 在非零时保持循环)

        if(--page_counter == 0) {
            /*new page*/;
            page_counter = page_limit;
        }
    

    向下计数器在 asm 中更有效,在 C 中同样可读(与向上计数器相比),所以如果你在进行微优化,你应该这样写。相关:using that technique in hand-written asm FizzBuzz。也可能是 3 和 5 的倍数的 asm 和的 code review,但它对不匹配没有任何作用,因此优化它是不同的。

    请注意 page_limit 只能在 if 主体内访问,因此如果编译器的寄存器数量不足,它很容易溢出,只在需要时读取它,而不是占用一个寄存器或乘数常数。

    或者如果它是一个已知常量,则只是一个立即移动指令。 (大多数 ISA 也有比较立即数,但不是全部。例如 MIPS 和 RISC-V 只有比较和分支指令,它们将指令字中的空间用于目标地址,而不是立即数。)许多 RISC ISA 有特别支持有效地将寄存器设置为比大多数采用立即数的指令更宽的常数(例如带有 16 位立即数的 ARM movw,因此 4092 可以在一条指令中完成更多 mov 但不是 cmp:它没有t 适合 12 位)。

    与除法(或乘法逆)相比,大多数 RISC ISA 没有乘立即数,并且乘法逆通常比一个立即数可以容纳的更宽。 (x86 确实有乘法立即数,但不适合给你高半数的形式。)除法立即数甚至更罕见,甚至 x86 都没有,但没有编译器会使用它,除非优化空间而不是速度如果它确实存在。

    像 x86 这样的 CISC ISA 通常可以与内存源操作数进行乘法或除法,因此如果寄存器不足,编译器可以将除数保留在内存中(尤其是当它是运行时变量时)。每次迭代加载一次(命中缓存)并不昂贵。但是,如果循环足够短并且没有足够的寄存器,则溢出和重新加载循环内部发生变化的实际变量(如page_count)可能会引入存储/重新加载延迟瓶颈。 (虽然这可能不太合理:如果你的循环体足够大,需要所有寄存器,它可能有足够的延迟来隐藏存储/重新加载。)

    【讨论】:

      【解决方案3】:

      如果有人把它放在我面前,我宁愿它是:

      const size_t page_limit = 4942;
      size_t npages = 0, nitems = 0;
      size_t pagelim = foo / page_limit;
      size_t resid = foo % page_limit;
      
      while (npages < pagelim || nitems < resid) {
          if (++nitems == page_limit) {
                /* start new page */
                nitems = 0;
                npages++;
          }
      }
      

      因为程序现在正在表达处理的意图——page_limit 大小的块中的一堆东西;而不是试图优化操作。

      编译器可能会生成更好的代码只是一种祝福。

      【讨论】:

      • 这不会高效地编译(循环中的两个条件),但在人类可读性与效率之间进行了很好的权衡。 (假设您不需要整体项目编号,例如为列表项目编号或其他内容。)
      猜你喜欢
      • 2013-01-25
      • 1970-01-01
      • 1970-01-01
      • 2023-03-11
      • 1970-01-01
      • 1970-01-01
      • 2013-01-08
      • 2011-04-21
      • 2010-11-10
      相关资源
      最近更新 更多