`if` 语句在模运算和赋值操作之前是多余的吗?

Is the `if` statement redundant before modulo and before assign operations?

考虑下一个代码:

unsigned idx;
//.. some work with idx
if( idx >= idx_max )
    idx %= idx_max;

可以简化为只有第二行:

idx %= idx_max;

并会达到同样的效果。


几次遇到下一个代码:

unsigned x;
//... some work with x
if( x!=0 )
  x=0;

可以简化为

x=0;

题目:

对我来说,在那里使用 if 似乎是个坏主意。

你是对的。无论是否idx >= idx_max,都会在idx %= idx_max之后的idx_max之下。 ifidx < idx_max无论是否跟if都不变

虽然您可能认为围绕 modulo 进行分支可能会节省时间,但真正的罪魁祸首,我想说的是,当遵循分支时,流水线操作 modern CPU'我们必须重置他们的管道,这会花费相对大量的时间。最好不要遵循一个分支,而不是整数 modulo,它花费的时间与整数除法大致相同。

编辑:事实证明,modulus 相对于分支非常慢,正如其他人在这里所建议的那样。这是一个人在研究这个完全相同的问题:CppCon 2015: Chandler Carruth "Tuning C++: Benchmarks, and CPUs, and Compilers! Oh My!"(在另一个 SO 问题中建议 linked 到这个问题的另一个答案)。

这家伙写编译器,认为没有分支会更快;但他的基准证明他错了。即使分支仅占 20% 的时间,它的测试速度也更快。

没有 if 的另一个原因:少了一行代码来维护,并且其他人也可以弄清楚它的含义。上面 link 中的家伙实际上创建了一个 "faster modulus" 宏。恕我直言,这个或内联函数是性能关键型应用程序的方式,因为如果没有分支,您的代码将更容易理解,但执行速度也会一样快。

最后,上面视频中的那个人正计划让编译器作者知道这个优化。因此,如果不在代码中,可能会为您添加 if 。因此,当这种情况发生时,仅 mod 就可以了。

如果您想了解编译器在做什么,您只需要调出一些程序集。我推荐这个网站(我已经输入了问题的代码):https://godbolt.org/g/FwZZOb.

第一个例子比较有趣。

int div(unsigned int num, unsigned int num2) {
    if( num >= num2 ) return num % num2;
    return num;
}

int div2(unsigned int num, unsigned int num2) {
    return num % num2;
}

生成:

div(unsigned int, unsigned int):          # @div(unsigned int, unsigned int)
        mov     eax, edi
        cmp     eax, esi
        jb      .LBB0_2
        xor     edx, edx
        div     esi
        mov     eax, edx
.LBB0_2:
        ret

div2(unsigned int, unsigned int):         # @div2(unsigned int, unsigned int)
        xor     edx, edx
        mov     eax, edi
        div     esi
        mov     eax, edx
        ret

基本上,出于非常具体和合乎逻辑的原因,编译器 不会 优化分支。如果整数除法与比较的成本大致相同,那么分支将毫无意义。但是整数除法(通常与模数一起执行)实际上非常昂贵:http://www.agner.org/optimize/instruction_tables.pdf。这些数字因体系结构和整数大小而有很大差异,但通常可能是 15 到接近 100 个周期的延迟。

通过在执行模数之前进行分支,您实际上可以节省很多工作。但请注意:编译器也不会将没有分支的代码转换为汇编级别的分支。那是因为分支也有一个缺点:如果模数最终是必要的,你只是浪费了一点时间。

如果不知道 idx < idx_max 为真的相对频率,就无法合理确定正确的优化。所以编译器(gcc 和 clang 做同样的事情)选择以相对透明的方式映射代码,将选择权留给开发人员。

所以那个分支可能是一个非常合理的选择。

第二个分支应该完全没有意义,因为比较和赋值的可比成本。也就是说,您可以在 link 中看到,如果编译器具有对变量的引用,它们仍然不会执行此优化。如果该值是局部变量(如您演示的代码中所示),则编译器将优化分支。

总而言之,第一段代码可能是一个合理的优化,第二段代码可能只是一个疲倦的程序员。

问候第一段代码:这是基于 Chandler Carruth 对 Clang 的建议的微优化(有关详细信息,请参阅 ),但它不一定认为它是有效的微优化- 这种形式的优化(使用 if 而不是三元)或在任何给定的编译器上。

Modulo 是一个相当昂贵的操作,如果代码经常被执行,并且有一个强大的统计倾斜到条件的一侧或另一侧,CPU 的分支预测(给定一个现代的CPU) 将显着降低分支指令的成本。

在很多情况下,用一个已经保存的值写入一个变量可能比读取它慢,发现已经保存了所需的值,然后跳过写入。一些系统有一个处理器高速缓存,它会立即将所有写请求发送到内存。虽然这样的设计在今天并不常见,但它们曾经很常见,因为它们可以提供完全 read/write 缓存可以提供的性能提升的很大一部分,但成本却很小。

像上面这样的代码在一些多 CPU 情况下也可能是相关的。最常见的情况是代码 运行 同时在两个或多个 CPU 内核上重复命中变量。在具有强内存模型的多核缓存系统中,想要写入变量的核必须首先与其他核协商以获得包含它的缓存行的独占所有权,然后必须再次协商以放弃这种控制任何其他核心想要读取或写入它。这样的操作往往非常昂贵,即使每次写入只是简单地存储存储已经保存的值,也必须承担成本。但是,如果该位置变为零并且再也不会被写入,那么两个内核都可以同时保留缓存行以进行非独占只读访问,而不必为此进一步协商。

在几乎所有可能有多个 CPU 命中一个变量的情况下,至少应声明该变量 volatile。一个可能适用于此的例外情况是,在 main() 开始后发生的对变量的所有写入都将存储相同的值,并且代码将正确运行,无论是否按一个 CPU在另一个可见。如果多次执行某些操作会造成浪费但无害,并且变量的目的是说明是否需要执行此操作,那么许多实现在没有 volatile 限定符的情况下可能会生成比使用 volatile 限定符更好的代码,前提是他们不试图通过无条件写入来提高效率。

顺便说一句,如果通过指针访问对象,就会有另一个 上述代码的可能原因:如果一个函数被设计为接受 某个字段为零的 const 对象,或一个非 const 对象 应该将该字段设置为零,可能需要像上面这样的代码 确保在两种情况下都有明确的行为。