【问题标题】:Trial-division code runs 2x faster as 32-bit on Windows than 64-bit on Linux试除法代码在 Windows 上运行 32 位比在 Linux 上运行 64 位快 2 倍
【发布时间】:2015-07-11 02:00:18
【问题描述】:

我有一段代码在 Windows 上的运行速度比在 linux 上快 2 倍。 以下是我测量的时间:

g++ -Ofast -march=native -m64
    29.1123
g++ -Ofast -march=native
    29.0497
clang++ -Ofast -march=native
    28.9192
visual studio 2013 Debug 32b
    13.8802
visual studio 2013 Release 32b
    12.5569

好像真的差别太大了。

代码如下:

#include <iostream>
#include <map>
#include <chrono>
static std::size_t Count = 1000;

static std::size_t MaxNum = 50000000;

bool IsPrime(std::size_t num)
{
    for (std::size_t i = 2; i < num; i++)
    {
        if (num % i == 0)
            return false;
    }
    return true;
}

int main()
{
    auto start = std::chrono::steady_clock::now();
    std::map<std::size_t, bool> value;
    for (std::size_t i = 0; i < Count; i++)
    {
        value[i] = IsPrime(i);
        value[MaxNum - i] = IsPrime(MaxNum - i);
    }
    std::chrono::duration<double> serialTime = std::chrono::steady_clock::now() - start;
    std::cout << "Serial time = " << serialTime.count() << std::endl;

    system("pause");
    return 0;
}

所有这些都是在使用 windows 8 和 linux 3.19.5(gcc 4.9.2,clang 3.5.0)的同一台机器上测量的。 linux和windows都是64位的。

这可能是什么原因?一些调度程序问题?

【问题讨论】:

  • 如果你能提供一些 IsPrime 的反汇编将会很有趣。并调整一下优化设置。
  • 尝试只调用IsPrime,而不将结果存储在value 映射中。然后尝试在value 映射中存储虚假值而不调用IsPrime,看看会发生什么。
  • VS2013 不使用QueryPerformanceCounter 代替std::steady_clockstackoverflow.com/a/13266477/2502409
  • 为什么是CountMaxNum static?那些不应该是const吗?
  • @UlrichEckhardt:那段代码不是我写的 :-)

标签: c++ performance x86 benchmarking 32bit-64bit


【解决方案1】:

size_t 是 Linux 上 x86-64 System V ABI 中的 64 位无符号类型,您在其中编译 64 位二进制文​​件。但是在 32 位二进制文​​件中(就像你在 Windows 上制作的那样),它只有 32 位,因此试除循环只进行 32 位除法。 (size_t 是针对 C++ 对象的大小,而不是文件,所以它只需要指针宽度。)

在 x86-64 Linux 上,-m64 是默认设置,因为 32 位基本上被认为已过时。要制作 32 位可执行文件,请使用 g++ -m32


与大多数整数运算不同,现代 x86 CPU 上的除法吞吐量(和延迟)取决于操作数大小:64 位除法比 32 位除法慢。https://agner.org/optimize/ 用于表哪些端口的指令吞吐量/延迟/微指令)。

与乘法或特别是加法等其他操作相比,它非常慢:您的程序完全是整数除法吞吐量的瓶颈,而不是map 操作。 (在 Skylake 上使用 32 位二进制的性能计数器,arith.divider_active 计数 24.03 十亿个除法执行单元处于活动状态的周期,总共有 24.84 十亿个核心时钟周期。是的,没错,除法太慢了有一个专门用于该执行单元的性能计数器。这也是一种特殊情况,因为它没有完全流水线化,所以即使在这种情况下,你有独立的划分,它也不能像其他时钟周期一样在每个时钟周期启动一个新的FP 或整数乘法等多周期操作。)

g++ 不幸的是,由于数字是编译时常量,因此范围有限,因此未能优化。 g++ -m64 优化到 div ecx 而不是 div rcx 是合法的(并且是一个巨大的加速)。这种变化使 64 位二进制文​​件的运行速度与 32 位二进制文​​件一样快。 (它计算完全相同的东西,只是没有那么多高零位。结果是隐式零扩展以填充 64 位寄存器,而不是由除法器显式计算为零,在这种情况下要快得多。)

我在 Skylake 上验证了这一点,方法是编辑二进制文件以将 0x40 替换为 0x48 REX.W 前缀,将 div rcx 更改为 div ecx 并使用无操作的 REX 前缀。从g++ -O3 -m32 -march=native 开始,所花费的总周期在 32 位二进制文​​件的 1% 以内。 (还有时间,因为 CPU 恰好在两次运行时都以相同的时钟速度运行。)(g++7.3 asm output on the Godbolt compiler explorer.)

运行 Linux 的 3.9GHz Skylake i7-6700k 上的 32 位代码,gcc7.3 -O3

$ cat > primes.cpp     # and paste your code, then edit to remove the silly system("pause")
$ g++ -Ofast -march=native -m32 primes.cpp -o prime32

$ taskset -c 3 perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,instructions,uops_issued.any,uops_executed.thread,arith.divider_active  ./prime32 
Serial time = 6.37695


 Performance counter stats for './prime32':
       6377.915381      task-clock (msec)         #    1.000 CPUs utilized          
                66      context-switches          #    0.010 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               111      page-faults               #    0.017 K/sec                  
    24,843,147,246      cycles                    #    3.895 GHz                    
     6,209,323,281      branches                  #  973.566 M/sec                  
    24,846,631,255      instructions              #    1.00  insn per cycle         
    49,663,976,413      uops_issued.any           # 7786.867 M/sec                  
    40,368,420,246      uops_executed.thread      # 6329.407 M/sec                  
    24,026,890,696      arith.divider_active      # 3767.201 M/sec                  

       6.378365398 seconds time elapsed

对比64 位 REX.W=0(手工编辑的二进制文件)

 Performance counter stats for './prime64.div32':

       6399.385863      task-clock (msec)         #    1.000 CPUs utilized          
                69      context-switches          #    0.011 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               146      page-faults               #    0.023 K/sec                  
    24,938,804,081      cycles                    #    3.897 GHz                    
     6,209,114,782      branches                  #  970.267 M/sec                  
    24,845,723,992      instructions              #    1.00  insn per cycle         
    49,662,777,865      uops_issued.any           # 7760.554 M/sec                  
    40,366,734,518      uops_executed.thread      # 6307.908 M/sec                  
    24,045,288,378      arith.divider_active      # 3757.437 M/sec                  

       6.399836443 seconds time elapsed

对比原始的 64 位二进制文​​件

$ g++ -Ofast -march=native primes.cpp -o prime64
$ taskset -c 3 perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,instructions,uops_issued.any,uops_executed.thread,arith.divider_active  ./prime64
Serial time = 20.1916

 Performance counter stats for './prime64':

      20193.891072      task-clock (msec)         #    1.000 CPUs utilized          
                48      context-switches          #    0.002 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
               148      page-faults               #    0.007 K/sec                  
    78,733,701,858      cycles                    #    3.899 GHz                    
     6,225,969,960      branches                  #  308.310 M/sec                  
    24,930,415,081      instructions              #    0.32  insn per cycle         
   127,285,602,089      uops_issued.any           # 6303.174 M/sec                  
   111,797,662,287      uops_executed.thread      # 5536.212 M/sec                  
    27,904,367,637      arith.divider_active      # 1381.822 M/sec                  

      20.193208642 seconds time elapsed

IDK 为什么arith.divider_active 的性能计数器没有增加更多。 div 64div r32 的微指令多得多,因此可能会损害乱序执行并减少周围代码的重叠。但我们知道,没有其他指令的背靠背div 具有类似的性能差异。

无论如何,这段代码大部分时间都在那个可怕的试除循环中(它检查每个奇数和偶数除数,即使我们在检查低位后已经可以排除所有偶数除数...... 它一直检查到num 而不是sqrt(num),所以对于非常大的素数来说它非常慢。)

根据perf record,99.98% 的 cpu 周期事件在 2nd 试分循环中触发,MaxNum - i,所以div 仍然是整个瓶颈,而且它是只是性能计数器的一个怪癖,并非所有时间都记录为arith.divider_active

  3.92 │1e8:   mov    rax,rbp
  0.02 │       xor    edx,edx
 95.99 │       div    rcx
  0.05 │       test   rdx,rdx 
       │     ↓ je     238     
  ... loop counter logic to increment rcx

来自 Agner Fog 的 Skylake 指令表:

           uops    uops      ports          latency     recip tput
           fused   unfused
DIV r32     10     10       p0 p1 p5 p6     26           6
DIV r64     36     36       p0 p1 p5 p6     35-88        21-83

(div r64 本身实际上是数据依赖于其输入的实际大小,小输入更快。真正慢的情况是商非常大,IIRC。而且可能也更慢当 RDX:RAX 中 128 位被除数的上半部分不为零时。C 编译器通常只使用 divrdx=0。)

循环计数的比率 (78733701858 / 24938804081 = ~3.15) 实际上小于最佳情况吞吐量的比率 (21/6 = 3.5)。它应该是一个纯粹的吞吐量瓶颈,而不是延迟,因为下一个循环迭代可以开始而无需等待最后一个除法结果。 (感谢分支预测 + 推测执行。)也许在那个除法循环中有一些分支未命中。

如果您只发现 2 倍的性能比,那么您有不同的 CPU。可能是 Haswell,其中 32 位 div 吞吐量为 9-11 个周期,而 64 位 div 吞吐量为 21-74。

可能不是 AMD:即使对于 div r64,最佳情况下的吞吐量仍然很小。例如Steamroller 有 div r32 吞吐量 = 1 每 13-39 个周期,div r64 = 13-70。我猜想使用相同的实际数字,即使您将它们提供给更宽寄存器中的除法器,您也可能会获得相同的性能,这与英特尔不同。 (最坏的情况会上升,因为输入和结果的可能大小会更大。)AMD 整数除法只有 2 微秒,不像英特尔在 Skylake 上微编码为 10 或 36 微秒。 (在 57 微秒时签名 idiv r64 甚至更多。)这可能与 AMD 对宽寄存器中的小数字有效。

顺便说一句,FP 除法始终是单微指令,因为它在普通代码中对性能更为关键。 (提示:如果他们关心性能根本,没有人在现实生活中使用完全幼稚的试验除法来检查多个素数。筛子或其他东西。)


有序map 的键是size_t,并且在 64 位代码中指针更大,使得每个红黑树节点显着变大,但这不是瓶颈

顺便说一句,map&lt;&gt; 与两个bool prime_low[Count], prime_high[Count] 数组相比,这里的选择糟糕:一个用于低Count 元素,一个用于高Count。您有 2 个连续范围,键可以按位置隐含。或者至少使用std::unordered_map 哈希表。我觉得有序版本应该被称为ordered_mapmap = unordered_map,因为你经常看到代码使用map而没有利用排序。

您甚至可以使用 std::vector&lt;bool&gt; 获取位图,使用 1/8 的缓存占用空间。

有一个“x32”ABI(长模式下的 32 位指针),对于不需要超过 4G 虚拟地址空间的进程来说,它具有两全其美的优势:用于更高数据密度/更小缓存的小指针占用大量指针的数据结构,但现代调用约定、更多寄存器、基线 SSE2 和 64 位整数寄存器的优势,当您确实需要 64 位数学时。但不幸的是,它不是很受欢迎。它只是快一点,所以大多数人不希望每个库都有第三个版本。

在这种情况下,您可以修复源以使用 unsigned int(如果您想移植到 int 只有 16 位的系统,则可以使用 uint32_t)。或uint_least32_t 以避免需要固定宽度的类型。您只能对 IsPrime 的 arg 或数据结构执行此操作。 (但如果您正在优化,键是按数组中的位置隐含的,而不是显式的。)

您甚至可以制作一个带有 64 位循环和 32 位循环的 IsPrime 版本,根据输入的大小进行选择。

【讨论】:

【解决方案2】:

从编辑后的问题中提取答案:

这是由于在 windows 上构建 32b 二进制文件而不是在 linux 上构建 64b 二进制文件造成的,这里是 windows 的 64b 数字:

Visual studio 2013 Debug 64b
    29.1985
Visual studio 2013 Release 64b
    29.7469

【讨论】:

    【解决方案3】:

    你没有说 windows/linux 操作系统是 32 位还是 64 位。

    在 64 位 linux 机器上,如果将 size_t 更改为 int,您会发现 linux 上的执行时间下降到与 windows 类似的值。

    size_t 在 win32 上是 int32,在 win64 上是 int64。

    编辑:刚刚看到你的 windows 反汇编。

    您的 Windows 操作系统是 32 位版本(或者至少您已经编译为 32 位)。

    【讨论】:

    • 你是对的。我没有意识到我在 linux 上默认构建 64b 二进制文件(这就是我尝试将 -m64 传递给 gcc 的原因)。
    • @hynner 虽然我必须说 64 位代码的速度是原来的一半,这让我感到惊讶!
    • 许多算法实际上都受限于内存带宽。如果您使用双倍大小的值类型,它们在读取和写入时的使用时间会增加一倍。
    • @RichardHodges:我也是,我一直认为它只在内存消耗方面有所不同,并且 64b 算法应该与 64b 处理器上的 32b 算法一样快。
    • @hynner 他们是。但是,您的问题是 CPU 和 FPU 都不是此代码的瓶颈。给定无限量的内存,它仍然永远不会达到 100% 的 CPU 使用率,因为与您在内存总线上施加的压力相比,计算的实际工作量可以忽略不计。因此,双倍的瓶颈压力意味着双倍的执行时间是有道理的。
    猜你喜欢
    • 2015-06-20
    • 1970-01-01
    • 2012-03-12
    • 1970-01-01
    • 2011-05-13
    • 1970-01-01
    • 2017-06-14
    • 2010-10-18
    • 2014-05-16
    相关资源
    最近更新 更多