C++ 中哪个更快:mod (%) 或另一个计数器?

What is faster in C++: mod (%) or another counter?

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

我正在用 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;
}

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

(我假设你打算写 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 CPUs 上被微编码为大约 10 微指令,所以即使它是一条指令,它仍然占用前端多个周期,占用吞吐量资源,使周围的代码进入乱序的后端)。

(有一个 constant divisor, it's still multiply, right shift, sub to get the quotient, then multiply and subtract to get the remainder from that. So still several single-uop instructions. Although there are some tricks for divisibility testing by small constants see @Cassio Neri's answer on Fast divisibility tests (by 2,3,4,5,.., 16)? 引用了他的期刊文章;最近的 GCC 可能已经开始使用这些。)


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

不过,让这项工作更便宜可以帮助编译器更快地发现和处理分支预测错误。但是具有快速恢复功能的现代 CPUs 可以在恢复时继续处理分支之前的旧指令。 ( / )

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

或者,如果您关心您的代码 运行ning 按顺序 CPUs(对于 ARM 和其他针对低功耗实现的非 x86 ISA 很常见),真正的工作是等待分支条件逻辑。 (只有硬件预取或缓存未命中加载和类似的东西可以做有用的工作,同时 运行 宁额外代码来测试分支条件。)


使用递减计数器

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

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

递减计数器在 asm 中更有效,在 C 中同样可读(与递增计数器相比),所以如果你要进行微优化,你应该这样写。相关:. Maybe also a code review 3 和 5 的倍数的 asm 总和,但它对不匹配没有任何作用,因此优化它是不同的。

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

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

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

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

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

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 大小的块;而不是试图优化操作。

编译器可能生成更好的代码真是一件幸事。