【问题标题】:no speedup using openmp + SIMD使用 openmp + SIMD 没有加速
【发布时间】:2017-08-11 05:27:24
【问题描述】:

我是 Openmp 新手,现在尝试使用 Openmp + SIMD 内部函数来加速我的程序,但结果远非预期。

为了在不丢失太多基本信息的情况下简化案例,我写了一个更简单的玩具示例:

#include <omp.h>
#include <stdlib.h>
#include <iostream>
#include <vector>
#include <sys/time.h>

#include "immintrin.h" // for SIMD intrinsics

int main() {
    int64_t size = 160000000;
    std::vector<int> src(size);

    // generating random src data
    for (int i = 0; i < size; ++i)
        src[i] = (rand() / (float)RAND_MAX) * size;

    // to store the final results, so size is the same as src
    std::vector<int> dst(size);

    // get pointers for vector load and store
    int * src_ptr = src.data();
    int * dst_ptr = dst.data();

    __m256i vec_src;
    __m256i vec_op = _mm256_set1_epi32(2);
    __m256i vec_dst;

    omp_set_num_threads(4); // you can change thread count here

    // only measure the parallel part
    struct timeval one, two;
    double get_time;
    gettimeofday (&one, NULL);

    #pragma omp parallel for private(vec_src, vec_op, vec_dst)
    for (int64_t i = 0; i < size; i += 8) {
        // load needed data
        vec_src = _mm256_loadu_si256((__m256i const *)(src_ptr + i));

        // computation part
        vec_dst = _mm256_add_epi32(vec_src, vec_op);
        vec_dst = _mm256_mullo_epi32(vec_dst, vec_src);
        vec_dst = _mm256_slli_epi32(vec_dst, 1);
        vec_dst = _mm256_add_epi32(vec_dst, vec_src);
        vec_dst = _mm256_sub_epi32(vec_dst, vec_src);

        // store results
        _mm256_storeu_si256((__m256i *)(dst_ptr + i), vec_dst);
    }

    gettimeofday(&two, NULL);
    double oneD = one.tv_sec + (double)one.tv_usec * .000001;
    double twoD = two.tv_sec + (double)two.tv_usec * .000001;
    get_time = 1000 * (twoD - oneD);
    std::cout << "took time: " << get_time << std::endl;

    // output something in case the computation is optimized out
    int64_t i = (int)((rand() / (float)RAND_MAX) * size);
    for (int64_t i = 0; i < size; ++i)
        std::cout << i << ": " << dst[i] << std::endl;

    return 0;
}

使用icpc -g -std=c++11 -march=core-avx2 -O3 -qopenmp test.cpp -o test编译并测量并行部分的经过时间。结果如下(每次运行 5 次取中值):

1 thread: 92.519

2 threads: 89.045

4 threads: 90.361

计算似乎令人尴尬地并行,因为不同的线程可以在给定不同索引的情况下同时加载所需的数据,写入结果的情况类似,但为什么没有加速?

更多信息:

  1. 我使用icpc -g -std=c++11 -march=core-avx2 -O3 -qopenmp -S test.cpp检查了汇编代码,发现生成了向量化指令;

  2. 1234563 @。

欢迎任何建议或线索。

EDIT-1:

感谢@JerryCoffin 指出可能的原因,所以我使用 Vtune 进行了内存访问分析。结果如下:

1-thread: Memory Bound: 6.5%, L1 Bound: 0.134, L3 Latency: 0.039

2-threads: Memory Bound: 18.0%, L1 Bound: 0.115, L3 Latency: 0.015

4-threads: Memory Bound: 21.6%, L1 Bound: 0.213, L3 Latency: 0.003

这是一个 Intel 4770 处理器,最大速度为 25.6GB/s(Vtune 测量为 23GB/s)。带宽。内存限制确实增加了,但我仍然不确定这是否是原因。有什么建议吗?

EDIT-2(只是试图提供全面的信息,所以附加的东西可能很长但希望不会乏味):

感谢@PaulR 和@bazza 的建议。我尝试了3种方法进行比较。需要注意的一件事是处理器具有4 内核和8 硬件线程。结果如下:

(1) 只需提前将dst 初始化为全零:1 thread: 91.922; 2 threads: 93.170; 4 threads: 93.868 --- 似乎无效;

(2)没有(1),把并行部分放在一个外循环100次迭代,测量100次迭代的时间:1 thread: 9109.49; 2 threads: 4951.20; 4 threads: 2511.01; 8 threads: 2861.75——除了8个线程,相当有效;

(3) 在(2) 的基础上,在100 次迭代之前再增加1 次迭代,并测量100 次迭代的时间:1 thread: 9078.02; 2 threads: 4956.66; 4 threads: 2516.93; 8 threads: 2088.88 --- 与(2) 类似,但对8 个线程更有效。

似乎更多的迭代可以暴露openmp + SIMD的优势,但是计算/内存访问比率无论循环次数都没有变化,而且局部性似乎也不是原因,因为srcdst太大了留在任何缓存中,因此连续迭代之间不存在任何关系。

有什么建议吗?

编辑 3:

为了防止误导,需要澄清一件事:在(2)和(3)中,openmp指令在添加的外循环之外

#pragma omp parallel for private(vec_src, vec_op, vec_dst)
for (int k = 0; k < 100; ++k) {
    for (int64_t i = 0; i < size; i += 8) {
        ......
    }
}

即外部循环使用多线程并行化,内部循环仍然是串行处理的。因此,(2) 和 (3) 中的有效加速可能通过增强线程间的局部性来实现。

我做了另一个实验,将 openmp 指令放在外循环中:

for (int k = 0; k < 100; ++k) {
    #pragma omp parallel for private(vec_src, vec_op, vec_dst)
    for (int64_t i = 0; i < size; i += 8) {
        ......
    }
}

而且加速还是不好:1 thread: 9074.18; 2 threads: 8809.36; 4 threads: 8936.89.93; 8 threads: 9098.83.

问题依然存在。 :(

EDIT-4:

如果我用这样的标量操作替换矢量化部分(相同的计算,但以标量方式):

#pragma omp parallel for
for (int64_t i = 0; i < size; i++) { // not i += 8
    int query = src[i];
    int res = src[i] + 2;
    res = res * query;
    res = res << 1;
    res = res + query;
    res = res - query;
    dst[i] = res;
}

加速是1 thread: 92.065; 2 threads: 89.432; 4 threads: 88.864。我可以得出这样的结论,看似尴尬的并行实际上是内存绑定的(瓶颈是加载/存储操作)?如果是这样,为什么不能很好地并行加载/存储操作?

【问题讨论】:

  • 我将首先查看您用于主内存的带宽。至少乍一看,这看起来很有可能是内存绑定的,即使只有一个内核——在这种情况下,添加更多内核并不会带来太多好处。
  • @JerryCoffin 感谢您提供可能的线索。你的意思是大部分时间都花在加载/存储而不是计算上?最初我虽然内存访问局部性很好,因为它们是每个线程的连续读/写。我将使用 vtune 分析内存访问并稍后更新。如果这是问题所在,您对解决它有什么建议吗?
  • 您的时间可能会因dst 上的页面错误而出现偏差(由于延迟分配)- 要么显式初始化 dst,和/或在外部循环中运行你的基准测试超过 2 次或更多次迭代和忽略第一次迭代的时间。
  • 您的整个数据集,src 和 dst 组合为 1.2GByte。您对 src 中的每个项目进行的处理是非常轻量级的,因此感觉应该是受内存限制的。但是,将整个 1.2GBytes 读/写到 SDRAM 应该不超过 51 毫秒,因此这里还有一些其他开销。非常值得尝试@PaulR 的建议
  • 另一件要注意的事情:您的循环目前只需要大约 90 毫秒 - 因此任何类型的线程/OMP/任何并行处理的粒度可能太小而无法产生任何显着的好处 - 尝试增加size 一个数量级(或添加外循环)?

标签: c++ multithreading performance openmp simd


【解决方案1】:

我可以得出这样的结论,看似尴尬的并行实际上是内存绑定(瓶颈是加载/存储操作)?如果是这样,为什么不能很好地并行加载/存储操作?

是的,这个问题是令人尴尬的并行,因为由于缺乏依赖关系,很容易并行化。这并不意味着它会完美地扩展。您的初始化开销与工作比或共享资源仍然会限制您的加速。

在您的情况下,您确实受到内存带宽的限制。首先实际考虑:当使用 icpc(16.0.3 或 17.0.1)编译时,"scalar" 版本在将size 设为constexpr 时会产生更好的代码。这不是因为它优化掉了这两条冗余行:

res = res + query;
res = res - query;

确实如此,但这没有什么区别。主要是编译器使用与内在函数完全相同的指令,除了存储。在商店之前,它使用vmovntdq 而不是vmovdqu,利用了关于程序、内存和架构的复杂知识。 vmovntdq 不仅需要对齐的内存,因此效率更高。它为 CPU 提供非临时提示,防止在写入内存期间缓存此数据。这提高了性能,因为将其写入缓存需要从内存中加载缓存行的剩余部分。因此,虽然您的初始 SIMD 版本确实需要三个内存操作:读取源、读取目标缓存行、写入目标,但具有非临时存储的编译器版本只需要两个。事实上,在我的 i7-4770 系统上,编译器生成的版本将 2 个线程的运行时间从约 85.8 毫秒减少到 58.0 毫秒,并且几乎完美地加速了 1.5 倍。这里的教训是相信你的编译器,除非你非常了解架构和指令集。

考虑到这里的峰值性能,传输 2*160000000*4 字节的 58 ms 对应于 22.07 GB/s(总结读取和写入),这与您的 VTune 结果大致相同。 (有趣的是,考虑到 85.8 毫秒与两次读取和一次写入的带宽大致相同)。没有更多直接的改进空间。

为了进一步提高性能,您必须对代码的操作/字节比率进行一些处理。请记住,您的处理器可以执行 217.6 GFLOP/s(我猜intops 可以执行相同或两次),但只能读写 3.2 G int/s。这让您了解需要执行多少操作才能不受内存限制。因此,如果可以,请以块为单位处理数据,以便可以重用缓存中的数据。

我无法重现您对 (2) 和 (3) 的结果。当我围绕内部循环循环时,缩放行为相同。结果看起来很可疑,尤其是考虑到结果与峰值性能非常一致。一般来说,我建议在平行区域内进行测量,并像这样利用omp_get_wtime

  double one, two;
#pragma omp parallel 
  {
    __m256i vec_src;
    __m256i vec_op = _mm256_set1_epi32(2);   
    __m256i vec_dst;

#pragma omp master
    one = omp_get_wtime();
#pragma omp barrier
    for (int kk = 0; kk < 100; kk++)
#pragma omp for
    for (int64_t i = 0; i < size; i += 8) {
        ...
    }
#pragma omp master
    {
      two = omp_get_wtime();
      std::cout << "took time: " << (two-one) * 1000 << std::endl;
    }
  }

最后一点:台式机处理器和服务器处理器在内存性能方面具有非常不同的特性。在现代服务器处理器上,您需要更多的活动线程来使内存带宽饱和,而在台式机处理器上,一个内核通常几乎可以使内存带宽饱和。

编辑:关于 VTune 没有将其归类为内存绑定的另一个想法。这可能是由较短的计算时间与初始化造成的。尝试查看 VTune 对循环中的代码的说明。

【讨论】:

  • 1.如果理解正确,您在 EDIT-4 中提到的标量实现是编译器生成的版本吗?我也是用icpc-O3编译的,但是加速不明显,可能是因为你用17.0.1而我用16.0.3
  • 请注意,由于 Ivy Bridge 用于内存复制和设置操作,您可以比非临时存储做得更好using "enchanced rep movsb"
  • GCC 或 Clang 是否生成非临时存储?上次我调查这个他们没有。这是你必须手工完成的事情。 OTOH,根据 Agner Fog 的经验法则是,当内存大小大于最后一级缓存 (TLC) 的一半时,使用非临时存储。在 OP 的代码中显然就是这种情况,但是编译器通常如何对此进行优化?如果内存大小远小于 TLC 非临时存储的一半,则性能会更差。看看 ICC 如何解决这个问题会很有趣。
  • @Zboson 服务器处理器通常具有更多内存通道、更多内核,但每个内核的频率较低。例如,i7-4770 支持双通道 DDR3 1600 (25.6 GB/s),而 E5-2690 v3 支持四通道 DDR4 2133 (68 GB/s)。但频率是 3.4 GHz 和 2.6 GHz。
  • @Zboson 我的 clang 3.8 不是 OpenMP 的朋友,但也不会在串行版本上生成非临时存储。
猜你喜欢
  • 2021-02-28
  • 2018-06-17
  • 1970-01-01
  • 2021-04-09
  • 1970-01-01
  • 1970-01-01
  • 2019-07-06
  • 2012-11-13
相关资源
最近更新 更多