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 < nums.end() - 3
以确保cn[3]
不会读到末尾。但当然,在 C++ 中评估 nums.end() - 3
是 UB,避免这种情况是一件很痛苦的事情。 (即使在实践中,您将在未映射 0 页的系统上运行代码,因此 ptr - 3
永远不会包装到高无符号地址,如果您想遵循严格的 C++ 规则,您可以执行 if( size >= 4) for()...
或其他操作像那样烦人,甚至是 do/while 以确保编译器在进入循环之前不会进行 2 次冗余检查。【参考方案3】:
考虑尽可能多地预先计算并使用内置的 STL 函数,这将在尝试 SIMD 或汇编方法之前产生尽可能多的优化代码。如果仍然太慢,请尝试 SIMD/汇编版本:
避免在未保留的std::vector
s 上调用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++优化内存读取速度的主要内容,如果未能解决你的问题,请参考以下文章