如何使这个并行求和函数使用向量指令?

时间:2015-12-12 21:20:53

标签: c++ multithreading parallel-processing prefetch

作为一个侧面项目,我正在研究一个多线程的算法算法,当处理足够大的数组时,它会胜过std::accumulate。首先,我将描述我的思维过程,但如果您想直接跳到问题,请随时向下滚动到该部分。

我在网上找到了许多并行的算法,其中大多数采用以下方法:

template <typename T, typename IT>
T parallel_sum(IT _begin, IT _end, T _init) {
    const auto size = distance(_begin, _end);
    static const auto n = thread::hardware_concurrency();
    if (size < 10000 || n == 1) return accumulate(_begin, _end, _init);
    vector<future<T>> partials;
    partials.reserve(n);
    auto chunkSize = size / n;
    for (unsigned i{ 0 }; i < n; i++) {
        partials.push_back(async(launch::async, [](IT _b, IT _e){
            return accumulate(_b, _e, T{0});
        }, next(_begin, i*chunkSize), (i==n-1)?_end:next(_begin, (i+1)*chunkSize)));
    }
    for (auto& f : partials) _init += f.get();
    return _init;
}

假设有2个线程可用(由thread::hardware_concurrency()报告),此函数将以下列方式访问内存中的元素:

memory access diagram for parallel_sum

作为一个简单的例子,我们在这里看8个元素。两个线程用红色和蓝色表示。箭头显示线程希望加载数据的位置。一旦单元格变为红色或蓝色,它们就会被相应的线程加载。

这种方法(至少在我看来)并不是最好的,因为线程同时从内存的不同部分加载数据。如果你有许多处理线程,比如8核超线程CPU上的16,或者甚至更多,CPU的预取器将很难跟上来自完全不同内存部分的所有这些读取(假设数组太大而不适合缓存)。这就是为什么我认为第二个例子应该更快:

template <typename T, typename IT>
T parallel_sum2(IT _begin, IT _end, T _init) {
    const auto size = distance(_begin, _end);
    static const auto n = thread::hardware_concurrency();
    if (size < 10000 || n == 1) return accumulate(_begin, _end, _init);
    vector<future<T>> partials;
    partials.reserve(n);
    for (unsigned i{ 0 }; i < n; i++) {
        partials.push_back(async(launch::async, [](IT _b, IT _e, unsigned _s){
            T _ret{ 0 };
            for (; _b < _e; advance(_b, _s)) _ret += *_b;
            return _ret;
        }, next(_begin, i), _end, n));
    }
    for (auto& f : partials) _init += f.get();
    return _init;
}

此函数按顺序方式访问内存,如下所示:

memory access diagram for parallel_sum2

这样预取器始终能够保持领先,因为所有线程都访问内存的相同部分,因此应该减少缓存未命中次数,加快所有时间,至少我认为是这样。

问题是理论上虽然这一切都很好,但实际的编译版本显示了不同的结果。第二个是慢一点。我深入研究了这个问题,发现为实际添加而生成的汇编代码非常不同。这些是&#34;热循环&#34;在每个执行添加的内容中(请记住第一个内部使用std::accumulate,因此您基本上都在查看):

assembly for std::accumulate inside parallel_sum assembly for the for loop inside parallel_sum2

请忽略百分比和颜色,我的探查器有时会出错。

我注意到编译时std::accumulate使用AVX2向量指令vpaddq。这可以一次添加四个64位整数。我认为第二个版本无法向量化的原因是每个线程一次只访问一个元素,然后跳过一些元素。向量加法将加载几个连续元素,然后将它们加在一起。显然,这是不可能的,因为线程不会连续加载元素。我尝试在第二个版本中手动展开for循环,并且该向量指令确实出现在程序集中,但由于某种原因,整个过程变得非常缓慢。

上面的结果和汇编代码来自gcc编译的版本,但是Visual Studio 2015也可以观察到同样的行为,尽管我还没有看到它产生的程序集。

那么有没有办法在保留这种顺序存储器访问模型的同时采用向量指令的优势?或者这个内存访问方法与函数的第一个版本相比有什么用呢?

我写了一个小benchmark program,它已经准备好编译和运行了,以防你想亲自看看性能。

PS。:我的主要目标硬件是现代x86_64(如haswell等)。

1 个答案:

答案 0 :(得分:3)

每个核心都有自己的缓存和预取。

您应该将每个线程视为独立执行程序。在这种情况下,第二种方法的缺点将很明显:您不能在单线程中访问后续数据。有些孔不应该被处理,因此线程不能使用向量指令。

另一个问题:CPU以块的形式预取数据。由于缓存级别的工作方式不同,更改缓存过时的块标记内的某些数据,如果其他核心尝试对同一块数据执行某些操作,则需要等待第一个核心写入更改并再次检索该块。基本上在你的第二个示例缓存中总是陈旧的,你会看到原始内存访问性能。

处理并发处理的最佳方法是以大的后续块处理数据。