我正在尝试使用 Rust 中的内在函数进行实验,其中我制作了一个大的浮点数向量,然后记录将它们全部乘以一个常数所需的时间。接下来,我尝试使用 SSE 内在函数进行同样的操作。在一台具有 5000 万个浮点的相对较新的笔记本电脑上,无论哪种方式,它的时间约为 23 毫秒。当我用 AVX 做类似的事情时,它甚至更慢,比如 67ms。在启动计时器之前,我分配了输入和输出所需的所有内存。
这是我如何用简单的
f32
来做到这一点:
let mut rng = rand::thread_rng();
let x: Vec<f32> = (0..N).map(|_| rng.gen::<i16>() as f32).collect();
let mut z_native: Vec<f32> = (0..N).map(|_| 0.0).collect();
let t0 = Instant::now();
for i in 0..N {
*z_native.get_unchecked_mut(i) = *x.get_unchecked(i) * std::f32::consts::PI;
}
println!("Native {:?}", t0.elapsed());
这是我使用 SSE 的方法:
let mut x_sse: Vec<__m128> = vec![];
let y_sse: __m128 = x86_64::_mm_set_ps1(std::f32::consts::PI);
let mut z_sse: Vec<__m128> = vec![];
for i in 0..(N/4) {
x_sse.push(x86_64::_mm_set_ps(
x[(i*4)+3], x[(i*4)+2],
x[(i*4)+1], x[(i*4)+0],
));
z_sse.push(x86_64::_mm_setzero_ps());
}
let t0 = Instant::now();
for i in 0..(N/4) {
*z_sse.get_unchecked_mut(i) = x86_64::_mm_mul_ps(*x_sse.get_unchecked(i), y_sse);
}
println!("SSE {:?}", t0.elapsed());
关于它应该如何工作,我是否缺少一些东西?我知道内存对齐如何产生影响,但我读到
Vec
总是根据它包含的内容的大小对齐。我还在最后打印出从结果中随机选择的值,以确保它们不会以某种方式得到优化。谢谢!
该示例很难重现,因为我们不知道实际的编译标志。 这是我在计算机上观察到的情况;这与问题报告的内容非常不同。
for
上的z_native
循环未运行(objdump
显示两个std::sys::unix::time::Timespec::now()
调用之间几乎没有代码),因为存储的值随后不会被使用
也许
for
上的z_sse
循环被认为是更多指令(我们显式地一一写出sse指令),那么编译器不会优化它。
如果我们在 std::hint::black_box()
上使用
z_native
,一旦计算完毕,我们就会强制编译器认为该数组有用,然后保留计算(objdump
显示了两个 std::sys::unix::time::Timespec::now()
调用之间的循环代码).
在这种情况下,当没有特定于 cpu 的选项进行编译时(这似乎相当于
-C target-feature=+sse
),我得到了这个 native 循环:
80f0: 41 0f 10 44 86 d0 movups -0x30(%r14,%rax,4),%xmm0
80f6: 41 0f 10 4c 86 e0 movups -0x20(%r14,%rax,4),%xmm1
80fc: 0f 59 c2 mulps %xmm2,%xmm0
80ff: 0f 59 ca mulps %xmm2,%xmm1
8102: 41 0f 11 44 87 d0 movups %xmm0,-0x30(%r15,%rax,4)
8108: 41 0f 11 4c 87 e0 movups %xmm1,-0x20(%r15,%rax,4)
810e: 41 0f 10 44 86 f0 movups -0x10(%r14,%rax,4),%xmm0
8114: 41 0f 10 0c 86 movups (%r14,%rax,4),%xmm1
8119: 0f 59 c2 mulps %xmm2,%xmm0
811c: 0f 59 ca mulps %xmm2,%xmm1
811f: 41 0f 11 44 87 f0 movups %xmm0,-0x10(%r15,%rax,4)
8125: 41 0f 11 0c 87 movups %xmm1,(%r15,%rax,4)
812a: 48 83 c0 10 add $0x10,%rax
812e: 48 3d 8c f0 fa 02 cmp $0x2faf08c,%rax
8134: 75 ba jne 80f0 <_ZN10my_project4main17h3b5b4aee6bb4180fE+0x120>
另一方面,sse循环看起来像这样:
82d0: 41 0f 28 44 07 f0 movaps -0x10(%r15,%rax,1),%xmm0
82d6: 0f 59 c1 mulps %xmm1,%xmm0
82d9: 41 0f 29 44 04 f0 movaps %xmm0,-0x10(%r12,%rax,1)
82df: 41 0f 28 04 07 movaps (%r15,%rax,1),%xmm0
82e4: 0f 59 c1 mulps %xmm1,%xmm0
82e7: 41 0f 29 04 04 movaps %xmm0,(%r12,%rax,1)
82ec: 48 83 c0 20 add $0x20,%rax
82f0: 48 3d 10 c2 eb 0b cmp $0xbebc210,%rax
82f6: 75 d8 jne 82d0 <_ZN10my_project4main17h3b5b4aee6bb4180fE+0x300>
循环展开似乎不一样,但我无法解释为什么,并且 native 循环的持续时间大约是 sse 循环持续时间的两倍。
用
-C target-feature=+avx
编译时,情况非常相似。
native循环看起来像这样:
8170: c4 c1 7c 59 4c 86 a0 vmulps -0x60(%r14,%rax,4),%ymm0,%ymm1
8177: c4 c1 7c 59 54 86 c0 vmulps -0x40(%r14,%rax,4),%ymm0,%ymm2
817e: c4 c1 7c 59 5c 86 e0 vmulps -0x20(%r14,%rax,4),%ymm0,%ymm3
8185: c4 c1 7c 59 24 86 vmulps (%r14,%rax,4),%ymm0,%ymm4
818b: c4 c1 7c 11 4c 87 a0 vmovups %ymm1,-0x60(%r15,%rax,4)
8192: c4 c1 7c 11 54 87 c0 vmovups %ymm2,-0x40(%r15,%rax,4)
8199: c4 c1 7c 11 5c 87 e0 vmovups %ymm3,-0x20(%r15,%rax,4)
81a0: c4 c1 7c 11 24 87 vmovups %ymm4,(%r15,%rax,4)
81a6: 48 83 c0 20 add $0x20,%rax
81aa: 48 3d 98 f0 fa 02 cmp $0x2faf098,%rax
81b0: 75 be jne 8170 <_ZN10my_project4main17h3b5b4aee6bb4180fE+0x120>
而 sse 循环看起来像这样:
8360: c4 c1 78 59 4c 07 f0 vmulps -0x10(%r15,%rax,1),%xmm0,%xmm1
8367: c4 c1 78 29 4c 04 f0 vmovaps %xmm1,-0x10(%r12,%rax,1)
836e: c4 c1 78 59 0c 07 vmulps (%r15,%rax,1),%xmm0,%xmm1
8374: c4 c1 78 29 0c 04 vmovaps %xmm1,(%r12,%rax,1)
837a: 48 83 c0 20 add $0x20,%rax
837e: 48 3d 10 c2 eb 0b cmp $0xbebc210,%rax
8384: 75 da jne 8360 <_ZN10my_project4main17h3b5b4aee6bb4180fE+0x310>
循环展开策略似乎又有所不同,但在 native 循环中使用 avx 寄存器似乎没有什么好处,因为它的持续时间大约是 sse 循环持续时间的两倍。
总而言之,显式 sse 循环总是比 native 循环更好。 然而,正如 @PeterCordes 在其评论中建议的那样,编译器应该发现 native 循环对于自动向量化来说是微不足道的,但很难理解为什么这种自动向量化(假设的微不足道)不如显式版本那么有效。