C++优化内存读取速度

Posted

技术标签:

【中文标题】C++优化内存读取速度【英文标题】:C++ Optimize Memory Read Speed 【发布时间】:2021-08-30 14:07:36 【问题描述】:

我正在创建一个具有 1024 * 1024 * 1024 个元素的 int(32 位)向量,如下所示:

std::vector<int> nums;
for (size_t i = 0; i < 1024 * 1024 * 1024; i++) 
   nums.push_back(rand() % 1024);

此时它拥有 4 GB 的随机数据。然后我只是简单地总结了向量中的所有元素,如下所示:

uint64_t total = 0;
for (auto cn = nums.begin(); cn < nums.end(); cn++) 
   total += *cn;

这大约需要 0.18 秒,这意味着数据的处理速度约为 22.2 GB/s。我在 M1 上运行它,内存带宽更高,约为 60GB/s。有没有办法让上面的代码在单核上跑得更快?

编辑: 手动 SIMD 版本:

int32x4_t simd_total = vmovq_n_s32(0); 
for (auto cn = nums.begin(); cn < nums.end()-3; cn +=4)  
    const int32_t v[4] = cn[0], cn[1], cn[2], cn[3] 
    simd_total = vaddq_s32(simd_total, vld1q_s32(v)); 
 
return vaddvq_s32(simd_total); 

SIMD 版本与非手动 SIMD 版本具有相同的性能。

编辑 2: 好的,所以我将向量元素更改为 uint32_t 并将结果类型更改为 uint32_t(如@Peter Cordes 所建议):

uint32_t sum_ints_32(const std::vector<uint32_t>& nums) 
    uint32_t total = 0;
    for (auto cn = nums.begin(); cn < nums.end(); cn++) 
        total += *cn;
    
    return total;

这运行得更快(~45 GB/s)。这是反汇编:

0000000100002218 <__Z11sum_ints_32RKNSt3__16vectorIjNS_9allocatorIjEEEE>:
   100002218:   a940200c    ldp x12, x8, [x0]
   10000221c:   eb08019f    cmp x12, x8
   100002220:   54000102    b.cs    100002240 <__Z11sum_ints_32RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x28>  // b.hs, b.nlast
   100002224:   aa2c03e9    mvn x9, x12
   100002228:   8b090109    add x9, x8, x9
   10000222c:   f1006d3f    cmp x9, #0x1b
   100002230:   540000c8    b.hi    100002248 <__Z11sum_ints_32RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x30>  // b.pmore
   100002234:   52800000    mov w0, #0x0                    // #0
   100002238:   aa0c03e9    mov x9, x12
   10000223c:   14000016    b   100002294 <__Z11sum_ints_32RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x7c>
   100002240:   52800000    mov w0, #0x0                    // #0
   100002244:   d65f03c0    ret
   100002248:   d342fd29    lsr x9, x9, #2
   10000224c:   9100052a    add x10, x9, #0x1
   100002250:   927ded4b    and x11, x10, #0x7ffffffffffffff8
   100002254:   8b0b0989    add x9, x12, x11, lsl #2
   100002258:   9100418c    add x12, x12, #0x10
   10000225c:   6f00e400    movi    v0.2d, #0x0
   100002260:   aa0b03ed    mov x13, x11
   100002264:   6f00e401    movi    v1.2d, #0x0
   100002268:   ad7f8d82    ldp q2, q3, [x12, #-16]
   10000226c:   4ea08440    add v0.4s, v2.4s, v0.4s
   100002270:   4ea18461    add v1.4s, v3.4s, v1.4s
   100002274:   9100818c    add x12, x12, #0x20
   100002278:   f10021ad    subs    x13, x13, #0x8
   10000227c:   54ffff61    b.ne    100002268 <__Z11sum_ints_32RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x50>  // b.any
   100002280:   4ea08420    add v0.4s, v1.4s, v0.4s
   100002284:   4eb1b800    addv    s0, v0.4s
   100002288:   1e260000    fmov    w0, s0
   10000228c:   eb0b015f    cmp x10, x11
   100002290:   540000a0    b.eq    1000022a4 <__Z11sum_ints_32RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x8c>  // b.none
   100002294:   b840452a    ldr w10, [x9], #4
   100002298:   0b000140    add w0, w10, w0
   10000229c:   eb08013f    cmp x9, x8
   1000022a0:   54ffffa3    b.cc    100002294 <__Z11sum_ints_32RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x7c>  // b.lo, b.ul, b.last
   1000022a4:   d65f03c0    ret

我还重写了 Manual-SIMD 版本:

uint32_t sum_ints_simd_2(const std::vector<uint32_t>& nums) 
    uint32x4_t  simd_total = vmovq_n_u32(0);
    for (auto cn = nums.begin(); cn < nums.end()-3; cn +=4) 
        const uint32_t v[4] =  cn[0], cn[1], cn[2], cn[3] ;
        simd_total = vaddq_u32(simd_total, vld1q_u32(v));
    
    return vaddvq_u32(simd_total);

它的运行速度仍然比非手动 SIMD 版本慢 2 倍,并导致以下反汇编:

0000000100002464 <__Z15sum_ints_simd_2RKNSt3__16vectorIjNS_9allocatorIjEEEE>:
   100002464:   a9402408    ldp x8, x9, [x0]
   100002468:   d1003129    sub x9, x9, #0xc
   10000246c:   6f00e400    movi    v0.2d, #0x0
   100002470:   eb09011f    cmp x8, x9
   100002474:   540000c2    b.cs    10000248c <__Z15sum_ints_simd_2RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x28>  // b.hs, b.nlast
   100002478:   6f00e400    movi    v0.2d, #0x0
   10000247c:   3cc10501    ldr q1, [x8], #16
   100002480:   4ea08420    add v0.4s, v1.4s, v0.4s
   100002484:   eb09011f    cmp x8, x9
   100002488:   54ffffa3    b.cc    10000247c <__Z15sum_ints_simd_2RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x18>  // b.lo, b.ul, b.last
   10000248c:   4eb1b800    addv    s0, v0.4s
   100002490:   1e260000    fmov    w0, s0
   100002494:   d65f03c0    ret

为了达到与自动矢量化版本相同的速度,我们可以在手动 SIMD 版本中使用 uint32x4x2 而不是 uint32x4:

uint32_t sum_ints_simd_3(const std::vector<uint32_t>& nums) 
    uint32x4x2_t simd_total;
    simd_total.val[0] = vmovq_n_u32(0);
    simd_total.val[1] = vmovq_n_u32(0);
    for (auto cn = nums.begin(); cn < nums.end()-7; cn +=8) 
        const uint32_t v[4] =  cn[0], cn[1], cn[2], cn[3] ;
        const uint32_t v2[4] =  cn[4], cn[5], cn[6], cn[7] ;
        simd_total.val[0] = vaddq_u32(simd_total.val[0], vld1q_u32(v));
        simd_total.val[1] = vaddq_u32(simd_total.val[1], vld1q_u32(v2));
    
    return vaddvq_u32(simd_total.val[0]) + vaddvq_u32(simd_total.val[1]);

为了获得更快的速度,我们可以利用 uint32x4x4(大约为 53 GB/s):

uint32_t sum_ints_simd_4(const std::vector<uint32_t>& nums) 
    uint32x4x4_t simd_total;
    simd_total.val[0] = vmovq_n_u32(0);
    simd_total.val[1] = vmovq_n_u32(0);
    simd_total.val[2] = vmovq_n_u32(0);
    simd_total.val[3] = vmovq_n_u32(0);
    for (auto cn = nums.begin(); cn < nums.end()-15; cn +=16) 
        const uint32_t v[4] =  cn[0], cn[1], cn[2], cn[3] ;
        const uint32_t v2[4] =  cn[4], cn[5], cn[6], cn[7] ;
        const uint32_t v3[4] =  cn[8], cn[9], cn[10], cn[11] ;
        const uint32_t v4[4] =  cn[12], cn[13], cn[14], cn[15] ;
        simd_total.val[0] = vaddq_u32(simd_total.val[0], vld1q_u32(v));
        simd_total.val[1] = vaddq_u32(simd_total.val[1], vld1q_u32(v2));
        simd_total.val[2] = vaddq_u32(simd_total.val[2], vld1q_u32(v3));
        simd_total.val[3] = vaddq_u32(simd_total.val[3], vld1q_u32(v4));
    
    return vaddvq_u32(simd_total.val[0])
        + vaddvq_u32(simd_total.val[1])
        + vaddvq_u32(simd_total.val[2])
        + vaddvq_u32(simd_total.val[3]);

这让我们得到以下反汇编:

0000000100005e34 <__Z15sum_ints_simd_4RKNSt3__16vectorIjNS_9allocatorIjEEEE>:
   100005e34:   a9402408    ldp x8, x9, [x0]
   100005e38:   d100f129    sub x9, x9, #0x3c
   100005e3c:   6f00e403    movi    v3.2d, #0x0
   100005e40:   6f00e402    movi    v2.2d, #0x0
   100005e44:   6f00e401    movi    v1.2d, #0x0
   100005e48:   6f00e400    movi    v0.2d, #0x0
   100005e4c:   eb09011f    cmp x8, x9
   100005e50:   540001c2    b.cs    100005e88 <__Z15sum_ints_simd_4RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x54>  // b.hs, b.nlast
   100005e54:   6f00e400    movi    v0.2d, #0x0
   100005e58:   6f00e401    movi    v1.2d, #0x0
   100005e5c:   6f00e402    movi    v2.2d, #0x0
   100005e60:   6f00e403    movi    v3.2d, #0x0
   100005e64:   ad401504    ldp q4, q5, [x8]
   100005e68:   ad411d06    ldp q6, q7, [x8, #32]
   100005e6c:   4ea38483    add v3.4s, v4.4s, v3.4s
   100005e70:   4ea284a2    add v2.4s, v5.4s, v2.4s
   100005e74:   4ea184c1    add v1.4s, v6.4s, v1.4s
   100005e78:   4ea084e0    add v0.4s, v7.4s, v0.4s
   100005e7c:   91010108    add x8, x8, #0x40
   100005e80:   eb09011f    cmp x8, x9
   100005e84:   54ffff03    b.cc    100005e64 <__Z15sum_ints_simd_4RKNSt3__16vectorIjNS_9allocatorIjEEEE+0x30>  // b.lo, b.ul, b.last
   100005e88:   4eb1b863    addv    s3, v3.4s
   100005e8c:   1e260068    fmov    w8, s3
   100005e90:   4eb1b842    addv    s2, v2.4s
   100005e94:   1e260049    fmov    w9, s2
   100005e98:   0b080128    add w8, w9, w8
   100005e9c:   4eb1b821    addv    s1, v1.4s
   100005ea0:   1e260029    fmov    w9, s1
   100005ea4:   0b090108    add w8, w8, w9
   100005ea8:   4eb1b800    addv    s0, v0.4s
   100005eac:   1e260009    fmov    w9, s0
   100005eb0:   0b090100    add w0, w8, w9
   100005eb4:   d65f03c0    ret

疯狂的东西

【问题讨论】:

你可能更多地受到延迟而不是带宽的限制,听起来。所以:不是标准 C++ 您必须记住,其他任务和硬件项目需要共享数据总线和地址总线。当其他设备正在使用数据和地址总线时,CPU 获取可能必须等待。 你用的是什么编译器? 您可以尝试展开 for 循环。处理器不喜欢分支语句,所以你消除的分支越多,它就会越快乐。因此,例如,在再次分支之前,您可以在循环中添加 4、16 或更多。一些编译器可能会在更高的优化级别上执行此操作。 @Peter Cordes,是的,你是对的,我想保持一致,但第一个版本已经自动矢量化,所以它不是很准确。我的意思是非手动 SIMD 版本;)顺便说一句,使用 uint32x4x4_t 手动 SIMD 版本甚至比自动矢量化版本(~53 GB/s)更快 【参考方案1】:

-march=native 有帮助吗?如果 Apple clang 尚未在第一代 AArch64 MacOS CPU 上利用任何 SIMD 功能,则 IDK,但总体而言,clang 可能只是采用基线 AArch64。

如果你使用uint32_t sums,你能走得更快吗,所以编译器在添加之前不必加宽每个元素?这意味着每条 SIMD 指令只能处理内存中相同大小的累加器的一半数据。

https://godbolt.org/z/7c19913jE 表明 Thomas Matthews 的展开建议确实让 clang11 -O3 -march=apple-a13 展开它制作的 SIMD 矢量化 asm 循环。一般来说,这种来源变化并不是一场胜利,例如很多对于 x86-64 clang -O3 -march=haswell 来说更糟,但在这里确实有帮助。


另一种可能性是单核无法使内存带宽饱和。但by Anandtech for example 发布的基准测试结果似乎排除了这一可能性:他们发现即使是单核也可以达到 59GB/s,尽管这可能是在运行优化 memcpy 函数。

(他们说 单个 Firestorm 内核几乎可以使内存控制器饱和的事实令人震惊,这是我们以前在设计中从未见过的。 这听起来有点奇怪;台式机/笔记本电脑英特尔CPU 非常接近,unlike their "server" chips。也许不像 Apple 那样接近

与现代 x86 相比,M1 具有相当低的内存延迟,因此这可能有助于单个内核能够跟踪传入的负载,以保持必要的延迟 x 带宽乘积,即使它的内存带宽很高。

【讨论】:

好的,我们把讨论移到这里,我又更新了问题。 @user2403221:您在编辑中谈到“非 SIMD 版本”。但实际上那是自动矢量化版本!查看 asm:ldp 加载两个 16 字节 q 寄存器,用于 uint32_t 循环的两个 add v0.4s ... 指令,而手动循环每次迭代只有一个。 (将副本复制到优化后的本地数组,而不是从std::vector 进行向量加载,否则会慢得多)。 是的,不清楚,我又编辑了!非常感谢,我想这就是我们今天所能达到的所有速度! 可能值得添加Optimizing AMD Opteron Memory Bandwidth 是一本好书。具有交错页面的 DRAM 的额外并行性对于北通道具有比任何库在较新的 DRAM 上产生的高频率的读取有一些显着影响。 @Noah:在此处转发您共享的链接:Apple M1 microarchitecture reverse engineering (PDF),作者为 Maynard Handley。包括一些关于为弄清楚事情是如何工作而进行的实验的细节。还有一些很好的通用计算机体系结构的东西。 (reddit thread 有人将其与 Maynard 的大部分工作联系起来,以及 Travis Downs (BeeOnRope)、Dougall J、Andrei Frumusanu 等其他人的贡献。)。【参考方案2】:

这里有一些技巧。

循环展开

uint64_t total = 0;
for (auto cn = nums.begin(); cn < nums.end(); cn += 4)

    total += cn[0];
    total += cn[1];
    total += cn[2];
    total += cn[3];

注册预取

uint64_t total = 0;
for (auto cn = nums.begin(); cn < nums.end(); cn += 4)

    const uint64 n0 = cn[0];
    const uint64 n1 = cn[1];
    const uint64 n2 = cn[2];
    const uint64 n3 = cn[3];
    total += n0;
    total += n1;
    total += n2;
    total += n3;

您应该在高优化级别打印每个这些的汇编语言并比较它们。

此外,您的处理器可能有一些您可以使用的专用指令。例如,ARM 处理器可以通过一条指令从内存中加载多个寄存器。

另外,查找 SIMD 指令或在互联网上搜索“C++ SIMD 读取内存”。

我与编译器(在嵌入式系统上)争论过,发现编译器的优化策略可能比指令专业化或其他技术更好或相等(使用测试点和示波器执行时序)。

您必须记住,您在单核机器上的任务很可能会比使用多核系统或专用(嵌入式)系统更频繁地更换。

【讨论】:

不是我的 DV,但您的“注册预取”版本应该使用现代 C++ 编译器编译为与第一个版本相同的 asm。 (与clang -O3 -mcpu=apple-a13 godbolt.org/z/7c19913jE 一样,大概类似于M1 上MacOS 上的Apple clang)。如果没有,那么无论哪个版本不是最佳的,都将错过优化。 (实际上,这是编译器已经擅长的优化类型;它们已经将您的 C++ 源代码编译为 SSA 形式,其中值是否具有 C++ 变量名并不重要。) 如果您的循环包含通过指针进行的赋值,那么提前加载可能很有用:如果您重新读取刚刚存储的内容,编译器可以不必检查别名以保持准确的 C++ 语义。但是在这里您没有使用n0..3 的地址,因此考虑到编译器内部的通常设计,它们将很容易地完全优化掉。不过,有趣的是,在自动矢量化时,clang 并没有为您展开原始源。不过,如果不是因为使用了更广泛的总和,它可能会有。 clang 喜欢展开,至少对于 x86 而言。也许不是 AArch64。 请注意,手动展开标量并不总是一件好事!使用带有 clang 的 x86 的相同代码,展开的源代码使用 SSE2 击败了自动矢量化(其中符号扩展为 64 位矢量元素是一种痛苦)。 godbolt.org/z/oo31sYYeh 显示 clang 自动矢量化(和展开)简单循环,但仅对循环使用标量(展开 4)。或者使用可用的 AVX2,godbolt.org/z/TGP6sxj6E,将 n0..3 作为一个向量的元素,并在循环内水平求和!!与简单源的更好的 asm 相比,保留 4 个向量累加器。 使用多个累加器(单独的 total0 ... total3 变量)在源中展开会很有帮助。但通常仅适用于浮点,编译器无法为您执行此操作(没有-ffast-math,或者至少没有-fassociative-math 和其他一些选项。)但这通常不是整数的一个因素,因为它是关联的,所以编译器可以如果有用的话,发明更多的向量累加器来隐藏 SIMD 整数添加延迟。 顺便说一句,您的代码存在正确性问题:您需要cn &lt; nums.end() - 3 以确保cn[3] 不会读到末尾。但当然,在 C++ 中评估 nums.end() - 3 是 UB,避免这种情况是一件很痛苦的事情。 (即使在实践中,您将在未映射 0 页的系统上运行代码,因此 ptr - 3 永远不会包装到高无符号地址,如果您想遵循严格的 C++ 规则,您可以执行 if( size &gt;= 4) for()... 或其他操作像那样烦人,甚至是 do/while 以确保编译器在进入循环之前不会进行 2 次冗余检查。【参考方案3】:

考虑尽可能多地预先计算并使用内置的 STL 函数,这将在尝试 SIMD 或汇编方法之前产生尽可能多的优化代码。如果仍然太慢,请尝试 SIMD/汇编版本:

避免在未保留的std::vectors 上调用push_back:这会导致系统在达到容量限制时分配更多空间。由于您事先知道数组的大小,因此请提前预留空间:(对于非内置类型,也可以考虑 emplace_back)。

此外,STL 函数可以将样板代码减少到两个函数调用。

另外,avoid rand().

const std::size_t GB = 1024 * 1024 * 1024;
std::vector<int> nums(4 * GB);
std::generate(std::begin(nums), std::end(nums), []() return rand() % 1024; );

//...

const auto sum = std::accumulate(std::begin(nums), std::end(nums), 0);

【讨论】:

以上是关于C++优化内存读取速度的主要内容,如果未能解决你的问题,请参考以下文章

项目新增内存表优化软件速度

gd32f450 内存读取速度

如何提高交易系统的运行速度:C和C++速度优化

Redis

mysql 表记录超过十万条后,查询速度特别慢?

如何优化VMWare虚拟机的运行速度?