避免使用模运算符是否更好?

71

我认为计算一个数的模是一种比较费时的操作,至少相对于简单的算术测试(例如查看一个数字是否超过了数组的长度)。如果确实如此,那么将以下代码替换为更高效的代码是否更好:

res = array[(i + 1) % len];

有以下的内容是什么?:

res = array[(i + 1 == len) ? 0 : i + 1];

第一个代码段看起来更加舒适,但我想知道第二个是否更高效。如果是这样的话,当使用编译性语言时,我是否可以期望优化编译器将第一个片段替换为第二个呢?

当然,这种“优化”(如果它确实是一种优化)并不适用于所有情况(在这种情况下,仅当 i+1 不超过 len 时才有效)。


13
可能是忽略了整体而只看到了局部的情况。 - Burhan Khalid
3
如果 len 是一个编译时常数,最近版本的 GCC 编译器(使用 -O2 选项)通常会进行聪明的优化,往往可以避免目标处理器的取模指令。 - Basile Starynkevitch
2
这确实是你应该忘记的优化方式。优化编译器会比你做得更好。更重要的是你代码的可读性。 - Basile Starynkevitch
或者你可以让数组长度增加1,将第一个元素复制到新的最后一个元素中,这样你就可以正常访问它。在不同情况下,这三个选项中的任何一个都可能是最快的。 - harold
2
这通常用于循环队列。 - Mohamed ElNakeep
9个回答

51
我的一般建议如下。使用你认为更容易阅读的版本,然后对整个系统进行分析。只优化那些性能瓶颈被分析器标记出来的代码。我敢打赌取模运算符不会是其中之一。
至于具体示例,只有基准测试才能告诉你在你特定的架构和编译器下哪个更快。你可能会用branching替换模运算符,但很难确定哪个更快。

3
在最新的计算机上,整数运算几乎是免费的;更重要的是缓存未命中... 这会导致更高的成本。L1缓存未命中会使处理器停滞数百个周期,在此期间处理器可以执行数十个除法或取模操作;因此,取模的最终成本可以忽略不计。 - Basile Starynkevitch
3
缓存行为在这两个代码片段中是相同的,版本#2是否使用分支以及分支预测器的效果将是重要的。 - NPE
@Basile Starynkevitch,我在笔记本电脑上看到了模数与访问大表之间约为300的因素。(添加一个测试以避免数组访问的17平方可除性仍然是有益的。) - starblue
@NPE 可能值得在这个答案中补充一下,C语言本身并没有速度;这是实现的质量(例如“你特定的架构”)的品质。除了依赖于硬件外,“模运算符的速度”还取决于为硬件构建代码的编译器的质量;一个差劲的编译器可能会使用等效的汇编 int foo = 54321; int bar = foo / 10000; foo -= bar * 10000; 来获得模数,而一个优秀的编译器甚至可以优化该代码。 - autistic

30

一些简单的测量:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    int test = atoi(argv[1]);
    int divisor = atoi(argv[2]);
    int iterations = atoi(argv[3]);

    int a = 0;

    if (test == 0) {
        for (int i = 0; i < iterations; i++)
            a = (a + 1) % divisor;
    } else if (test == 1) {
        for (int i = 0; i < iterations; i++)
            a = a + 1 == divisor ? 0 : a + 1;
    }

    printf("%d\n", a);
}

使用gcc或clang编译时加上-O3选项,然后运行time ./a.out 0 42 1000000000(模数版本)或者time ./a.out 1 42 1000000000(比较版本),结果如下:

  • 模数版本用户运行时间为6.25秒
  • 比较版本用户运行时间为1.03秒

(使用gcc 5.2.1或clang 3.6.2;Intel Core i5-4690K @ 3.50GHz;64-bit Linux)

这意味着最好使用比较版本。


5
如果使用更加真实的数据(例如如果数字是随机的),那么差异就不会那么大了。 - user1209304
36
比较版本之所以更快,是因为if语句的结果每次都相同,因此分支预测器每次都会得出正确的结果。如果随机输入,比较版本甚至可能不如取模版本。 - Bigminimus
1
@Bigminimus 嗯,但是 if 语句的结果在两个测试中始终相同吗? - Baris Demiray
1
他正在引用 (?) 运算符,你的代码根据除数的大小猜错的概率只有 1/100、1/400 等。 - Garret Gang

9

好的,以下是获取“模3”循环计数器下一个值的两种方法。

int next1(int n) {
    return (n + 1) % 3;
}

int next2(int n) {
    return n == 2 ? 0 : n + 1;
}

我已经使用gcc -O3选项(针对普通的x64架构)进行了编译,并使用-s获取了汇编代码。
第一个函数的代码执行一些难以解释的魔术(*),以避免进行除法运算,而是使用乘法代替。
addl    $1, %edi
movl    $1431655766, %edx
movl    %edi, %eax
imull   %edx
movl    %edi, %eax
sarl    $31, %eax
subl    %eax, %edx
leal    (%rdx,%rdx,2), %eax
subl    %eax, %edi
movl    %edi, %eax
ret

而第一个函数的长度(我敢打赌速度也会更慢)比第二个函数长得多:

leal    1(%rdi), %eax
cmpl    $2, %edi
movl    $0, %edx
cmove   %edx, %eax
ret

因此,“现代编译器总是比你更好”并不总是正确的。

有趣的是,使用4而不是3进行相同的实验会导致第一个函数的与掩码。

addl    $1, %edi
movl    %edi, %edx
sarl    $31, %edx
shrl    $30, %edx
leal    (%rdi,%rdx), %eax
andl    $3, %eax
subl    %edx, %eax
ret

但是它仍然相对于第二版而言,大体上来说,较为低劣。

更明确地介绍正确方法来做事。

int next3(int n) {
    return (n + 1) & 3;;
}

产生更好的结果:
leal    1(%rdi), %eax
andl    $3, %eax
ret

(*) 嗯,不是很复杂。倒数乘法。计算整数常量 K = (2^N)/3,对于足够大的 N 值。现在,当您想要 X/3 的值时,不要进行除以 3 的运算,而是计算 X*K,并将其向右移动 N 个位置。


1
第二个版本中的比较可能会使其劣于第一个版本;如果它不能定期预测正确的分支,那么这将破坏流水线。尽管如此,+1表明现代编译器并不总是能够神奇地找到最佳的机器代码。 - Ray
据我所知,条件移动已经添加到指令集中(Pentium Pro),因此根本不需要进行分支预测。“CMOVcc指令对于优化小的IF结构非常有用。它们还有助于消除IF语句的分支开销和处理器分支错误预测的可能性。”《Pentium-Pro Family Developers Manual》,第2卷,第6.14页。http://www.phatcode.net/res/231/files/24269101.pdf - Michel Billaud
Michel Billaud:看起来你是对的。我看到了cmpl,但完全忽略了缺少跳转的问题。 - Ray
2
% 4 代码更复杂,因为你正在进行 有符号 算术运算。根据 C99 的规定,模数的符号必须与被除数的符号相匹配,因此它不仅仅是一个直接的按位 AND。将类型更改为 unsigned int,您将获得与 & 3 代码相同的结果。 - pat
1
-1 是因为答案强烈暗示根据代码大小来判断,这是一个可以接受的启发式方法,但在像这个问题中进行优化时却是一个错误。并非所有指令都是相等的。即使在 RISC 架构上,某些操作可能比其他操作需要更长的时间,在流水线 CPU 上,执行错误预测分支所花费的时间(比分支本身长,但在分支之后继续执行,直到在管道深处找到分支条件的结果)可能比具有更多指令的无条件代码所花费的时间还要长。 - mtraceur

2
如果您的代码中'len'足够大,则条件语句将更快,因为分支预测器几乎总是猜对。
如果不是这样,那么我认为这与循环队列密切相关,其中长度通常是2的幂。这将使编译器能够用简单的AND替换模数运算。
代码如下:
#include <stdio.h>
#include <stdlib.h>

#define modulo

int main()
{
    int iterations = 1000000000;
    int size = 16;
    int a[size];
    unsigned long long res = 0;
    int i, j;

    for (i=0;i<size;i++)
        a[i] = i;

    for (i=0,j=0;i<iterations;i++)
    {
        j++;
        #ifdef modulo
            j %= size;
        #else
            if (j >= size)
                j = 0;
        #endif
        res += a[j];
    }

    printf("%llu\n", res);
}

大小=15:

  • 模数:4.868秒
  • 条件:1.291秒

大小=16:

  • 模数:1.067秒
  • 条件:1.599秒

使用gcc 7.3.0编译,采用-O3优化。 计算机配置为i7 920。


我不知道为什么在这两种情况下,cond 版本的时间不同。 - Doug Currie
我认为,因为res不是volatile类型,gcc可以进行许多优化,但随着大小的增加,这些优化效果会变得不那么有效。当我将“volatile”添加到res时,条件语句的时间始终在2秒左右。对于模数,当幂为2且不稳定时(超过4秒,并随着大小增加而增加),时间大约为2秒。 - J. Doe
我也注意到,在非易失性保留情况下,对于大小为1024的情况,条件运行速度更快,大约为1秒钟,因此我猜这与优化(或分支预测器)的“好”和“坏”大小有关。 - J. Doe

2

这里有一些额外的基准测试。请注意,我还添加了一个无分支版本:

#include <iostream>
#include <array>
#include <algorithm>
#include <random>
#include <chrono>
using namespace std::chrono;

constexpr size_t iter = 1e8;

int main() {
  std::minstd_rand rnd_engine{1234};
  std::uniform_int_distribution<int> dist {-1000, 1000};
  auto gen = [&]() { return dist(rnd_engine); };

  std::array<int, 10> a;
  std::generate( a.begin(), a.end(), gen);

  for (size_t size = 2; size < 10; size++) {
    std::cout << "Modulus size = " << size << '\n';
  
    {
      std::cout << "operator%  ";
      long sum = 0;
      size_t x = 0;
      auto start = high_resolution_clock::now();
      for (size_t i = 0; i < iter; ++i) {
        sum += a[x];
        x = (x + 1) % size;
      }
      auto stop = high_resolution_clock::now();
      std::cout << duration_cast<microseconds>(stop - start).count()*0.001
                << "ms\t(sum = " << sum << ")\n";
    }
  
    {
      std::cout << "ternary    ";
      long sum = 0;
      size_t x = 0;
      auto start = high_resolution_clock::now();
      for (size_t i = 0; i < iter; ++i) {
        sum += a[x];
        x = ((x + 1) == size) ? 0 : x + 1;
      }
      auto stop = high_resolution_clock::now();
      std::cout << duration_cast<microseconds>(stop - start).count()*0.001
                << "ms\t(sum = " << sum << ")\n";
    }
    
    {
      std::cout << "branchless ";
      long sum = 0;
      size_t x = 1;
      auto start = high_resolution_clock::now();
      for (size_t i = 0; i < iter; ++i) {
        sum += a[x-1];
        x = ( x != size ) * x + 1;
      }
      auto stop = high_resolution_clock::now();
      std::cout << duration_cast<microseconds>(stop - start).count()*0.001
                << "ms\t(sum = " << sum << ")\n";
    }

  }
  return 0;
}

这是在我的i7-4870HQ上的输出结果

$ g++ -Ofast test.cpp && ./a.out
Modulus size = 2
operator%  904.249ms    (sum = -4200000000)
ternary    137.04ms     (sum = -4200000000)
branchless 169.182ms    (sum = -4200000000)
Modulus size = 3
operator%  914.911ms    (sum = -31533333963)
ternary    113.384ms    (sum = -31533333963)
branchless 167.614ms    (sum = -31533333963)
Modulus size = 4
operator%  877.3ms      (sum = -36250000000)
ternary    97.265ms     (sum = -36250000000)
branchless 167.215ms    (sum = -36250000000)
Modulus size = 5
operator%  891.295ms    (sum = -30700000000)
ternary    88.562ms     (sum = -30700000000)
branchless 167.087ms    (sum = -30700000000)
Modulus size = 6
operator%  903.644ms    (sum = -39683333196)
ternary    83.433ms     (sum = -39683333196)
branchless 167.778ms    (sum = -39683333196)
Modulus size = 7
operator%  908.096ms    (sum = -34585713678)
ternary    79.703ms     (sum = -34585713678)
branchless 166.849ms    (sum = -34585713678)
Modulus size = 8
operator%  869ms        (sum = -39212500000)
ternary    76.972ms     (sum = -39212500000)
branchless 167.29ms     (sum = -39212500000)
Modulus size = 9
operator%  875.003ms    (sum = -36500000580)
ternary    75.011ms     (sum = -36500000580)
branchless 172.356ms    (sum = -36500000580)

在这种情况下,三元运算符看起来更优秀,并且当分支预测器加速时,它甚至更加如此。但请注意,这是一个非常特殊的情况:如果我们不使用非常量值递增索引,则使用更通用的operator%将是简单明了的,而另外两种方法可能会变得非常复杂。
我想强调这个非常被低估的评论:

如果len是编译时常量,最近的GCC编译器(使用-02)通常会做一些聪明的事情,经常避免目标处理器的模数机器指令。 - Basile Starynkevitch

例如,通过删除对size变量的循环并将其声明为const size_t size = 4;,我得到:
g++ -Ofast test.cpp && ./a.out
Modulus size = 4
operator%  62.103ms     (sum = -36250000000)
ternary    93.674ms     (sum = -36250000000)
branchless 166.774ms    (sum = -36250000000)

结论

无分支版本的执行时间在不同场景下非常稳定。三元操作符相对于无分支版本在考虑的情况下始终更好,特别是当分支预测器发挥作用时。最后,operator%虽然更加通用但明显较慢,它有可能在右侧特定的常量值的情况下得到优化并成为最快的。

当然,这完全取决于平台,谁知道在Arduino上会是什么情况 :)


0

取模运算符虽然昂贵,但除法也很昂贵。因此,将代码从使用取模运算符转换为除法不会优化您的代码。

  (i + 1) % len

为了优化上述代码

if ((i+1)==len){
   return 0
} else {
   return i+1
}

2
我认为你把三目运算符误认为是除法。作者自己的优化与你建议的相同。 - whY

0

我读了一篇关于制作快速哈希表的文章。瓶颈可能是模数运算符来查找哈希桶。他们建议将您的桶数量设为2的幂次方。显然,通过2的幂次方进行模数运算就像只查看最后n位一样。


0
是的,在循环中调用取模运算符%会明显变慢。如果你只调用几次,我不会担心这个问题。
即使在使用Visual Studio 2022的发布模式下,我的性能分析器也显示if (value % alignment != 0)语句的CPU使用率高达10%,这是相当大的开销。但由于我的对齐方式总是2的幂,我能够通过使用简单的位运算符&进行优化。
if ((value & (alignment - 1)) != 0)

优化编译器无法确定我的对齐方式是否始终为2的幂,因此编译器不允许隐式进行优化。这是编译器无法自动帮助你的例子之一。
现在,我的性能分析器只显示了那个“if”语句的CPU使用率为0.3%,比直接使用“%”时效率要高得多。

-4

在大多数体系结构上(例如x86上的DIV),模运算可以使用单个处理器指令完成。但是,这可能是对您所需的过早优化。


32
仅仅因为一个操作只有一条指令,不代表它在一个时钟周期内完成。 - Chris Desjardins
2
@ChrisDesjardins 同意,但是如果第二个运算符是2的幂,则可以表示为位掩码。 - Alex
12
抱歉不得不投反对票。我曾经与许多种架构一起工作过(但不包括x86),但我还没有遇到过实现一次指令完成mod/div的架构。我也见过一些应用程序,其中mod是消耗CPU最多的十个函数调用之一,因为涉及到所有的循环缓冲区 - 每个“样本”复制后都要进行%缓冲区大小的运算。在我的情况下,如果可以的话,我会尽量避免使用mod-通常是通过断言输入缓冲区大小可被2整除,这样编译器就可以优化掉mod运算。 - c.fogelklou
@c.fogelklou 好观点。分支预测在迭代的环形缓冲区上运作良好。有些人可能认为分支比取模更昂贵,可能错过了使用它的机会。 - M.kazem Akhgary
2
div 是目前最慢的整数 ALU 操作。例如,在 Skylake 上,div r64 的延迟时间为 35 至 90 个周期,而 imul r64, r64 的延迟时间仅为 3 个周期。相关链接:C++ code for testing the Collatz conjecture faster than hand-written assembly - why? 展示了 div 有多么慢,特别是与移位操作相比,后者可以用于计算2的幂。 - Peter Cordes
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接