OpenMP 矢量化代码运行速度比 O3 优化代码慢
OpenMP vectorised code runs way slower than O3 optimized code
我有一个最小可重现的样本如下 -
#include <iostream>
#include <chrono>
#include <immintrin.h>
#include <vector>
#include <numeric>
template<typename type>
void AddMatrixOpenMP(type* matA, type* matB, type* result, size_t size){
for(size_t i=0; i < size * size; i++){
result[i] = matA[i] + matB[i];
}
}
int main(){
size_t size = 8192;
//std::cout<<sizeof(double) * 8<<std::endl;
auto matA = (float*) aligned_alloc(sizeof(float), size * size * sizeof(float));
auto matB = (float*) aligned_alloc(sizeof(float), size * size * sizeof(float));
auto result = (float*) aligned_alloc(sizeof(float), size * size * sizeof(float));
for(int i = 0; i < size * size; i++){
*(matA + i) = i;
*(matB + i) = i;
}
auto start = std::chrono::high_resolution_clock::now();
for(int j=0; j<500; j++){
AddMatrixOpenMP<float>(matA, matB, result, size);
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
std::cout<<"Average Time is = "<<duration/500<<std::endl;
std::cout<<*(result + 100)<<" "<<*(result + 1343)<<std::endl;
}
我试验如下 - 我用 #pragma omp for simd
指令为 AddMatrixOpenMP
函数中的循环计时代码,然后在没有指令的情况下计时。我编译代码如下 -
g++ -O3 -fopenmp example.cpp
在检查程序集时,两种变体都生成矢量指令,但是当明确指定 OpenMP pragma 时,代码 运行s 慢了 3 倍。
我无法理解为什么会这样。
编辑 - 我正在 运行宁 GCC 9.3 和 OpenMP 4.5。这是在 Ubuntu 20.04 上的 i7 9750h 6C/12T 上 运行ning。我确保没有主要进程在后台运行 运行。 CPU 频率在两个版本的 运行 期间或多或少保持不变(从 4.0 到 4.1 的微小变化)
TIA
非 OpenMP 向量化器正在通过循环反转打败您的基准。
使您的函数 __attribute__((noinline, noclone))
阻止 GCC 将其内联到重复循环中。对于像这样的函数足够大的情况,call/ret 开销很小,并且持续传播并不重要,这是确保编译器不会将工作提升到循环之外的一个很好的方法。
并且在未来,检查 asm,and/or 确保基准时间与迭代次数呈线性关系。例如将 500 增加到 1000 应该会在正常工作的基准测试中给出相同的平均时间,但它不会与 -O3
一起使用。 (虽然这里出奇地接近,所以气味测试并不能明确地检测到问题!)
在代码中添加缺少的 #pragma omp simd
之后,是的,我可以重现它。在带有 GCC 10.2 -O3(没有 -march=native
或 -fopenmp
)的 i7-6700k Skylake(3.9GHz,带 DDR4-2666)上,我得到 18266,但在 -O3 -fopenmp
的情况下,我得到平均时间 39772。
对于 OpenMP 矢量化版本,如果我在运行时查看 top
,内存使用量 (RSS) 稳定在 771 MiB。 (正如预期的那样:两个输入中的初始化代码错误,并且定时区域的第一次迭代写入 result
,也为其触发页面错误。)
但是使用“普通”矢量化器(不是 OpenMP),我看到内存使用量从 ~500 MiB 攀升,直到达到最大 770MiB 时退出。
所以看起来 gcc -O3
在内联后执行了某种循环反转 并且击败了基准循环的内存带宽密集型方面,只接触每个数组元素一次。
asm 显示了证据: GCC 9.3 -O3
on Godbolt 没有 向量化,它留下一个空的内部循环而不是重复工作。
.L4: # outer loop
movss xmm0, DWORD PTR [rbx+rdx*4]
addss xmm0, DWORD PTR [r13+0+rdx*4] # one scalar operation
mov eax, 500
.L3: # do {
sub eax, 1 # empty inner loop after inversion
jne .L3 # }while(--i);
add rdx, 1
movss DWORD PTR [rcx], xmm0
add rcx, 4
cmp rdx, 67108864
jne .L4
这只比完全完成工作快 2 或 3 倍。可能是因为它没有矢量化,它实际上是 运行 一个延迟循环,而不是完全优化掉空的内部循环。并且因为现代台式机具有非常好的单线程内存带宽。
将重复计数从 500 增加到 1000 只会将计算出的“平均值”从每迭代 18266 提高到 17821 us。空循环仍需要每个时钟进行 1 次迭代。通常与重复计数线性缩放是对损坏的基准测试的一个很好的试金石,但这足够接近可信。
在定时区域内还有页面错误的开销,但整个过程会运行几秒钟,所以这是次要的。
OpenMP 矢量化版本确实 尊重您的基准重复循环。 (或者换句话说,没能在这段代码中找到可能的巨大优化。)
在基准测试为 运行:
时查看内存带宽
运行 intel_gpu_top -l
而正确的基准是 运行 显示(openMP,或 __attribute__((noinline, noclone))
)。 IMC
是 CPU 裸片上的集成内存控制器,由 IA 内核和 GPU 通过环形总线共享。这就是 GPU 监控程序在这里很有用的原因。
$ intel_gpu_top -l
Freq MHz IRQ RC6 Power IMC MiB/s RCS/0 BCS/0 VCS/0 VECS/0
req act /s % W rd wr % se wa % se wa % se wa % se wa
0 0 0 97 0.00 20421 7482 0.00 0 0 0.00 0 0 0.00 0 0 0.00 0 0
3 4 14 99 0.02 19627 6505 0.47 0 0 0.00 0 0 0.00 0 0 0.00 0 0
7 7 20 98 0.02 19625 6516 0.67 0 0 0.00 0 0 0.00 0 0 0.00 0 0
11 10 22 98 0.03 19632 6516 0.65 0 0 0.00 0 0 0.00 0 0 0.00 0 0
3 4 13 99 0.02 19609 6505 0.46 0 0 0.00 0 0 0.00 0 0 0.00 0 0
注意~19.6GB/s 读取/6.5GB/s 写入。读取 ~= 3x 写入,因为它没有将 NT 存储用于输出流。
但是随着 -O3
击败基准,1000
重复计数,我们只看到主内存带宽接近空闲水平。
Freq MHz IRQ RC6 Power IMC MiB/s RCS/0 BCS/0 VCS/0 VECS/0
req act /s % W rd wr % se wa % se wa % se wa % se wa
...
8 8 17 99 0.03 365 85 0.62 0 0 0.00 0 0 0.00 0 0 0.00 0 0
9 9 17 99 0.02 349 90 0.62 0 0 0.00 0 0 0.00 0 0 0.00 0 0
4 4 5 100 0.01 303 63 0.25 0 0 0.00 0 0 0.00 0 0 0.00 0 0
7 7 15 100 0.02 345 69 0.43 0 0 0.00 0 0 0.00 0 0 0.00 0 0
10 10 21 99 0.03 350 74 0.64 0 0 0.00 0 0 0.00 0 0 0.00 0 0
对比当基准根本不是 运行 时,基线为 150 到 180 MB/s 读取,35 到 50MB/s 写入。 (我有一些程序 运行 即使我没有触摸鼠标/键盘也不会完全休眠。)
我有一个最小可重现的样本如下 -
#include <iostream>
#include <chrono>
#include <immintrin.h>
#include <vector>
#include <numeric>
template<typename type>
void AddMatrixOpenMP(type* matA, type* matB, type* result, size_t size){
for(size_t i=0; i < size * size; i++){
result[i] = matA[i] + matB[i];
}
}
int main(){
size_t size = 8192;
//std::cout<<sizeof(double) * 8<<std::endl;
auto matA = (float*) aligned_alloc(sizeof(float), size * size * sizeof(float));
auto matB = (float*) aligned_alloc(sizeof(float), size * size * sizeof(float));
auto result = (float*) aligned_alloc(sizeof(float), size * size * sizeof(float));
for(int i = 0; i < size * size; i++){
*(matA + i) = i;
*(matB + i) = i;
}
auto start = std::chrono::high_resolution_clock::now();
for(int j=0; j<500; j++){
AddMatrixOpenMP<float>(matA, matB, result, size);
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
std::cout<<"Average Time is = "<<duration/500<<std::endl;
std::cout<<*(result + 100)<<" "<<*(result + 1343)<<std::endl;
}
我试验如下 - 我用 #pragma omp for simd
指令为 AddMatrixOpenMP
函数中的循环计时代码,然后在没有指令的情况下计时。我编译代码如下 -
g++ -O3 -fopenmp example.cpp
在检查程序集时,两种变体都生成矢量指令,但是当明确指定 OpenMP pragma 时,代码 运行s 慢了 3 倍。
我无法理解为什么会这样。
编辑 - 我正在 运行宁 GCC 9.3 和 OpenMP 4.5。这是在 Ubuntu 20.04 上的 i7 9750h 6C/12T 上 运行ning。我确保没有主要进程在后台运行 运行。 CPU 频率在两个版本的 运行 期间或多或少保持不变(从 4.0 到 4.1 的微小变化)
TIA
非 OpenMP 向量化器正在通过循环反转打败您的基准。
使您的函数 __attribute__((noinline, noclone))
阻止 GCC 将其内联到重复循环中。对于像这样的函数足够大的情况,call/ret 开销很小,并且持续传播并不重要,这是确保编译器不会将工作提升到循环之外的一个很好的方法。
并且在未来,检查 asm,and/or 确保基准时间与迭代次数呈线性关系。例如将 500 增加到 1000 应该会在正常工作的基准测试中给出相同的平均时间,但它不会与 -O3
一起使用。 (虽然这里出奇地接近,所以气味测试并不能明确地检测到问题!)
在代码中添加缺少的 #pragma omp simd
之后,是的,我可以重现它。在带有 GCC 10.2 -O3(没有 -march=native
或 -fopenmp
)的 i7-6700k Skylake(3.9GHz,带 DDR4-2666)上,我得到 18266,但在 -O3 -fopenmp
的情况下,我得到平均时间 39772。
对于 OpenMP 矢量化版本,如果我在运行时查看 top
,内存使用量 (RSS) 稳定在 771 MiB。 (正如预期的那样:两个输入中的初始化代码错误,并且定时区域的第一次迭代写入 result
,也为其触发页面错误。)
但是使用“普通”矢量化器(不是 OpenMP),我看到内存使用量从 ~500 MiB 攀升,直到达到最大 770MiB 时退出。
所以看起来 gcc -O3
在内联后执行了某种循环反转 并且击败了基准循环的内存带宽密集型方面,只接触每个数组元素一次。
asm 显示了证据: GCC 9.3 -O3
on Godbolt 没有 向量化,它留下一个空的内部循环而不是重复工作。
.L4: # outer loop
movss xmm0, DWORD PTR [rbx+rdx*4]
addss xmm0, DWORD PTR [r13+0+rdx*4] # one scalar operation
mov eax, 500
.L3: # do {
sub eax, 1 # empty inner loop after inversion
jne .L3 # }while(--i);
add rdx, 1
movss DWORD PTR [rcx], xmm0
add rcx, 4
cmp rdx, 67108864
jne .L4
这只比完全完成工作快 2 或 3 倍。可能是因为它没有矢量化,它实际上是 运行 一个延迟循环,而不是完全优化掉空的内部循环。并且因为现代台式机具有非常好的单线程内存带宽。
将重复计数从 500 增加到 1000 只会将计算出的“平均值”从每迭代 18266 提高到 17821 us。空循环仍需要每个时钟进行 1 次迭代。通常与重复计数线性缩放是对损坏的基准测试的一个很好的试金石,但这足够接近可信。
在定时区域内还有页面错误的开销,但整个过程会运行几秒钟,所以这是次要的。
OpenMP 矢量化版本确实 尊重您的基准重复循环。 (或者换句话说,没能在这段代码中找到可能的巨大优化。)
在基准测试为 运行:
时查看内存带宽运行 intel_gpu_top -l
而正确的基准是 运行 显示(openMP,或 __attribute__((noinline, noclone))
)。 IMC
是 CPU 裸片上的集成内存控制器,由 IA 内核和 GPU 通过环形总线共享。这就是 GPU 监控程序在这里很有用的原因。
$ intel_gpu_top -l
Freq MHz IRQ RC6 Power IMC MiB/s RCS/0 BCS/0 VCS/0 VECS/0
req act /s % W rd wr % se wa % se wa % se wa % se wa
0 0 0 97 0.00 20421 7482 0.00 0 0 0.00 0 0 0.00 0 0 0.00 0 0
3 4 14 99 0.02 19627 6505 0.47 0 0 0.00 0 0 0.00 0 0 0.00 0 0
7 7 20 98 0.02 19625 6516 0.67 0 0 0.00 0 0 0.00 0 0 0.00 0 0
11 10 22 98 0.03 19632 6516 0.65 0 0 0.00 0 0 0.00 0 0 0.00 0 0
3 4 13 99 0.02 19609 6505 0.46 0 0 0.00 0 0 0.00 0 0 0.00 0 0
注意~19.6GB/s 读取/6.5GB/s 写入。读取 ~= 3x 写入,因为它没有将 NT 存储用于输出流。
但是随着 -O3
击败基准,1000
重复计数,我们只看到主内存带宽接近空闲水平。
Freq MHz IRQ RC6 Power IMC MiB/s RCS/0 BCS/0 VCS/0 VECS/0
req act /s % W rd wr % se wa % se wa % se wa % se wa
...
8 8 17 99 0.03 365 85 0.62 0 0 0.00 0 0 0.00 0 0 0.00 0 0
9 9 17 99 0.02 349 90 0.62 0 0 0.00 0 0 0.00 0 0 0.00 0 0
4 4 5 100 0.01 303 63 0.25 0 0 0.00 0 0 0.00 0 0 0.00 0 0
7 7 15 100 0.02 345 69 0.43 0 0 0.00 0 0 0.00 0 0 0.00 0 0
10 10 21 99 0.03 350 74 0.64 0 0 0.00 0 0 0.00 0 0 0.00 0 0
对比当基准根本不是 运行 时,基线为 150 到 180 MB/s 读取,35 到 50MB/s 写入。 (我有一些程序 运行 即使我没有触摸鼠标/键盘也不会完全休眠。)