C++ 中什么更快:mod (%) 或其他计数器? 使用递减计数器

问题描述

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

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

解决方法

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

,

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

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

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

如果您的 page_limit 不是编译时常量,这更有可能有帮助。 dec/jz 每次只执行一次many decrements 比 div/test edx,edx/jz 便宜很多,包括前端吞吐量。 (div 在 Intel CPU 上被微编码为大约 10 uop,因此即使它是一条指令,它仍会占用前端多个周期,从而从获取周围代码中占用吞吐量资源——订单后台)。

(使用 constant divisor,it's still multiply,right shift,sub to get the quotient,然后乘法和减法得到余数。所以仍然是几个单 uop 指令。虽然有一些通过小常数进行可分性测试的技巧,请参阅 @Cassio Neri 在 {{ 上的回答3}} 引用了他的期刊文章;最近的 GCC 可能已经开始使用这些。)


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

不过,使这项工作更便宜可以帮助编译器更快地看到和处理分支预测错误。但是具有快速恢复功能的现代 CPU 可以在恢复时继续处理分支之前的旧指令。 ( Fast divisibility tests (by 2,3,4,5,..,16)? / What exactly happens when a skylake CPU mispredicts a branch? )

当然还有一些循环确实可以完全保持 CPU 的吞吐量资源繁忙,而不是缓存未命中或延迟链的瓶颈。每次迭代执行更少的 uops 对另一个超线程(或一般的 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 中同样可读(与递增计数器相比),因此如果您要进行微优化,则应该这样编写。相关:Avoid stalling pipeline by calculating conditional early。也许也是 3 和 5 的倍数的 asm 和的 using that technique in hand-written asm FizzBuzz,但它对不匹配没有任何作用,因此优化它是不同的。

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

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

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

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

,

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

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

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

相关问答

Selenium Web驱动程序和Java。元素在(x,y)点处不可单击。其...
Python-如何使用点“。” 访问字典成员?
Java 字符串是不可变的。到底是什么意思?
Java中的“ final”关键字如何工作?(我仍然可以修改对象。...
“loop:”在Java代码中。这是什么,为什么要编译?
java.lang.ClassNotFoundException:sun.jdbc.odbc.JdbcOdbc...