并行的性能损失

Posted

技术标签:

【中文标题】并行的性能损失【英文标题】:Performance loss parallel for 【发布时间】:2019-02-09 10:40:25 【问题描述】:

我有一个程序或多或少地重复执行一些向量操作。当我尝试使用parallel_for 并行执行相同的任务时,我观察到每个任务的时间显着增加。每个任务都从相同的数据中读取,并且没有同步进行。这是示例代码(它需要任务流库(https://github.com/cpp-taskflow/cpp-taskflow):

#include <array>
#include <numeric>
#include <x86intrin.h>
#include "taskflow.hpp"

//#define USE_AVX_512 1
constexpr size_t Size = 5000;
struct alignas(64) Vec : public std::array<double, Size> ;

struct SimulationData

    Vec a_;
    Vec b_;
    Vec c_;

    SimulationData()
    
        std::iota(a_.begin(), a_.end(), 10);
        std::iota(b_.begin(), b_.end(), 5);
        std::iota(c_.begin(), c_.end(), 0);
    
;
struct SimulationTask

    const SimulationData& data_;
    double res_;
    double time_;
    explicit SimulationTask(const SimulationData& data)
    : data_(data), res_(0.0), time_(0.0)
    

    constexpr static int blockSize = 20000;
    void sample()
    
        auto tbeg = std::chrono::steady_clock::now();
        Vec result;
        for(auto i=0; i < blockSize; ++i)
        
            add(result.data(), data_.a_.data(), data_.b_.data(), Size);
            mul(result.data(), result.data(), data_.c_.data(), Size);
            res_ += *std::max_element(result.begin(), result.end());
        
        auto tend = std::chrono::steady_clock::now();
        time_ = std::chrono::duration_cast<std::chrono::milliseconds>(tend-tbeg).count();
    
    inline double getResults() const
    
        return res_;
    
    inline double getTime() const
    
        return time_;
    
    static void add( double* result, const double* a, const double* b, size_t size)
    
        size_t i = 0;
        // AVX-512 loop
        #ifdef USE_AVX_512
        for( ; i < (size & ~0x7); i += 8)
        
            const __m512d kA8   = _mm512_load_pd( &a[i] );
            const __m512d kB8   = _mm512_load_pd( &b[i] );

            const __m512d kRes = _mm512_add_pd( kA8, kB8 );
            _mm512_stream_pd( &result[i], kRes );
        
        #endif
        // AVX loop
        for ( ; i < (size & ~0x3); i += 4 )
        
            const __m256d kA4   = _mm256_load_pd( &a[i] );
            const __m256d kB4   = _mm256_load_pd( &b[i] );

            const __m256d kRes = _mm256_add_pd( kA4, kB4 );
            _mm256_stream_pd( &result[i], kRes );
        

        // SSE2 loop
        for ( ; i < (size & ~0x1); i += 2 )
        
            const __m128d kA2   = _mm_load_pd( &a[i] );
            const __m128d kB2   = _mm_load_pd( &b[i] );

            const __m128d kRes = _mm_add_pd( kA2, kB2 );
            _mm_stream_pd( &result[i], kRes );
        

        // Serial loop
        for( ; i < size; i++ )
        
            result[i] = a[i] + b[i];
        
    
    static void mul( double* result, const double* a, const double* b, size_t size)
    
        size_t i = 0;
        // AVX-512 loop
        #ifdef USE_AVX_512
        for( ; i < (size & ~0x7); i += 8)
        
            const __m512d kA8   = _mm512_load_pd( &a[i] );
            const __m512d kB8   = _mm512_load_pd( &b[i] );

            const __m512d kRes = _mm512_mul_pd( kA8, kB8 );
            _mm512_stream_pd( &result[i], kRes );
        
        #endif
        // AVX loop
        for ( ; i < (size & ~0x3); i += 4 )
        
            const __m256d kA4   = _mm256_load_pd( &a[i] );
            const __m256d kB4   = _mm256_load_pd( &b[i] );

            const __m256d kRes = _mm256_mul_pd( kA4, kB4 );
            _mm256_stream_pd( &result[i], kRes );
        

        // SSE2 loop
        for ( ; i < (size & ~0x1); i += 2 )
        
            const __m128d kA2   = _mm_load_pd( &a[i] );
            const __m128d kB2   = _mm_load_pd( &b[i] );

            const __m128d kRes = _mm_mul_pd( kA2, kB2 );
            _mm_stream_pd( &result[i], kRes );
        

        // Serial loop
        for( ; i < size; i++ )
        
            result[i] = a[i] * b[i];
        
    
;

int main(int argc, const char* argv[])

    int numOfThreads = 1;
    if ( argc > 1 )
        numOfThreads = atoi( argv[1] );

    try
    
        SimulationData data;
        std::vector<SimulationTask> tasks;
        for (int i = 0; i < numOfThreads; ++i)
            tasks.emplace_back(data);

        tf::Taskflow tf;
        tf.parallel_for(tasks, [](auto &task)  task.sample(); );
        tf.wait_for_all();
        for (const auto &task : tasks)
        
            std::cout << "Result: " << task.getResults() << ", Time: " << task.getTime() << std::endl;
        
    
    catch (const std::exception& ex)
    
        std::cerr << ex.what() << std::endl;
    
    return 0;

我在双 E5-2697 v2 上使用 g++-8.2 -std=c++17 -mavx -o timing -O3 timing.cpp -lpthread 编译了这段代码(每个 CPU 有 12 个物理内核和超线程,因此有 48 个硬件线程可用)。当我增加并行任务的数量时,每个任务的时间都会增加很多:

# ./timing 1
Result: 1.0011e+12, Time: 618

使用 12 个任务:

# ./timing 12
Result: 1.0011e+12, Time: 788
Result: 1.0011e+12, Time: 609
Result: 1.0011e+12, Time: 812
Result: 1.0011e+12, Time: 605
Result: 1.0011e+12, Time: 808
Result: 1.0011e+12, Time: 1050
Result: 1.0011e+12, Time: 817
Result: 1.0011e+12, Time: 830
Result: 1.0011e+12, Time: 597
Result: 1.0011e+12, Time: 573
Result: 1.0011e+12, Time: 586
Result: 1.0011e+12, Time: 583

使用 24 个任务:

# ./timing 24
Result: 1.0011e+12, Time: 762
Result: 1.0011e+12, Time: 1033
Result: 1.0011e+12, Time: 735
Result: 1.0011e+12, Time: 1051
Result: 1.0011e+12, Time: 1060
Result: 1.0011e+12, Time: 757
Result: 1.0011e+12, Time: 1075
Result: 1.0011e+12, Time: 758
Result: 1.0011e+12, Time: 745
Result: 1.0011e+12, Time: 1165
Result: 1.0011e+12, Time: 1032
Result: 1.0011e+12, Time: 1160
Result: 1.0011e+12, Time: 757
Result: 1.0011e+12, Time: 743
Result: 1.0011e+12, Time: 736
Result: 1.0011e+12, Time: 1028
Result: 1.0011e+12, Time: 1109
Result: 1.0011e+12, Time: 1018
Result: 1.0011e+12, Time: 1338
Result: 1.0011e+12, Time: 743
Result: 1.0011e+12, Time: 1061
Result: 1.0011e+12, Time: 1046
Result: 1.0011e+12, Time: 1341
Result: 1.0011e+12, Time: 761

使用 48 个任务:

# ./timing 48
Result: 1.0011e+12, Time: 1591
Result: 1.0011e+12, Time: 1776
Result: 1.0011e+12, Time: 1923
Result: 1.0011e+12, Time: 1876
Result: 1.0011e+12, Time: 2002
Result: 1.0011e+12, Time: 1649
Result: 1.0011e+12, Time: 1955
Result: 1.0011e+12, Time: 1728
Result: 1.0011e+12, Time: 1632
Result: 1.0011e+12, Time: 1418
Result: 1.0011e+12, Time: 1904
Result: 1.0011e+12, Time: 1847
Result: 1.0011e+12, Time: 1595
Result: 1.0011e+12, Time: 1910
Result: 1.0011e+12, Time: 1530
Result: 1.0011e+12, Time: 1824
Result: 1.0011e+12, Time: 1588
Result: 1.0011e+12, Time: 1656
Result: 1.0011e+12, Time: 1876
Result: 1.0011e+12, Time: 1683
Result: 1.0011e+12, Time: 1403
Result: 1.0011e+12, Time: 1730
Result: 1.0011e+12, Time: 1476
Result: 1.0011e+12, Time: 1938
Result: 1.0011e+12, Time: 1429
Result: 1.0011e+12, Time: 1888
Result: 1.0011e+12, Time: 1530
Result: 1.0011e+12, Time: 1754
Result: 1.0011e+12, Time: 1794
Result: 1.0011e+12, Time: 1935
Result: 1.0011e+12, Time: 1757
Result: 1.0011e+12, Time: 1572
Result: 1.0011e+12, Time: 1474
Result: 1.0011e+12, Time: 1609
Result: 1.0011e+12, Time: 1394
Result: 1.0011e+12, Time: 1655
Result: 1.0011e+12, Time: 1480
Result: 1.0011e+12, Time: 2061
Result: 1.0011e+12, Time: 2056
Result: 1.0011e+12, Time: 1598
Result: 1.0011e+12, Time: 1630
Result: 1.0011e+12, Time: 1623
Result: 1.0011e+12, Time: 2073
Result: 1.0011e+12, Time: 1395
Result: 1.0011e+12, Time: 1487
Result: 1.0011e+12, Time: 1854
Result: 1.0011e+12, Time: 1569
Result: 1.0011e+12, Time: 1530

这段代码有问题吗?向量化是 parallel_for 的问题吗?我可以使用 perf 或类似工具获得更好的洞察力吗?

【问题讨论】:

24 个线程的数量是多少?可能只是intel的HT性能不佳。 顺便说一下,您可能应该合并 add/mul/max 步骤并一次完成所有步骤,节省 2/3 的负载和几乎所有的存储 - 至少,如果这是实际的任务,而不仅仅是用于测试的合成负载。 您是否打算让编译器丢弃除其中一个向量化循环之外的所有循环?如果您查看the produced assembly(搜索dummy 分配以了解哪些代码行去哪里),您可以看到除了最上面的向量化循环之外的所有循环都被消除了 - 编译器知道所有版本的结果都是相同的,所以它只保持最快的。 嗯,这是一个简化的例子。在实际任务中会生成随机数(每个任务都有自己的生成器),因此每个循环都会产生不同的结果。但是每个向量都有一些加法和乘法等,我可以用这个简单的例子重现时间差异。 @Max 同样,您知道编译器会同时抛出Serial loop 代码和SSE2 loop 代码,对吧?它认识到这些变体的效率低于AVX loop(并且结果与AVX loop 相同。 【参考方案1】:

之所以存在超线程,是因为线程(在现实世界场景中)经常需要等待来自内存的数据,从而在数据传输过程中使物理内核基本上处于空闲状态。您的示例(以及 CPU,例如通过预取)正在努力避免这种内存限制,因此通过使线程数量饱和,同一内核上的任何两个超线程都在竞争其 execution ports。请注意,您的 CPU 上每个核心周期只有 3 个整数向量 ALU 可用 - 调度程序可能会让它们都忙于一个线程的操作。

使用 1 个线程或 12 个线程,您不会真正遇到这种争用。对于 24 个线程,只有将每个线程安排到自己的物理核心才能避免此问题,这可能不会发生(因此您开始看到更糟糕的时序)。使用 48 个内核,您肯定会遇到上述问题。

正如 harold 提到的,您可能还受到存储绑定(超线程对竞争的另一种资源)。

【讨论】:

【参考方案2】:

您可能需要Intel VTune 来证明这一点,但我猜是因为工作线程在加载和存储之间没有做大量的计算工作,而是受到 CPU 速度的限制从 RAM 加载数据。因此,您拥有的线程越多,它们就越会竞争并相互饿死有限的内存带宽。正如来自英特尔的文件Detecting Memory Bandwidth Saturation in Threaded Applications 所述:

随着越来越多的线程或进程共享有限的缓存容量和内存带宽资源,线程应用程序的可扩展性可能会受到限制。随着更多线程的引入,内存密集型线程应用程序可能会遭受内存带宽饱和的影响。在这种情况下,线程应用程序将无法按预期扩展,并且可能会降低性能。 … 任何并行应用程序带宽饱和的明显症状是非扩展行为。

使用 VTune 之类的工具进行分析是确定瓶颈所在的唯一方法。 VTune 的专长在于它可以分析 CPU 硬件级别的性能,并且作为一种英特尔工具,它可以访问性能计数器和其他工具可能无法获得的洞察力,因此可以在 CPU 看到瓶颈时揭示瓶颈。对于 AMD CPU,等效工具是 CodeXL。其他可能有用的工具包括Performance Counter Monitor(来自https://***.com/a/4015983),如果运行Windows,Visual Studio's CPU profiler(来自https://***.com/a/3489965)。

要在指令级别分析性能瓶颈,Intel Architecture Code Analyzer 可能有用。它是一种静态分析器,可对给定英特尔架构的吞吐量、延迟和数据依赖性进行理论分析。但是,估计值不包括内存、缓存等的影响。如需更多信息,请参阅What is IACA and how do I use it?。

【讨论】:

我会怀疑数据加载是瓶颈。这是您可以想象的对缓存和预取最友好的任务,并且在加载方面基本上没有任何争用。我认为关于商店有一些争论,但你说得对,详细的分析是唯一确定的方法。 你可能是对的——正如我所说,我只是在猜测。但是有问题的处理器有大约 60GB/s 的带宽 (ark.intel.com/products/75283/…),如果我们在 codearcana.com/posts/2013/05/18/… 的测试范围内,每个 CPU 可以有 6-7 个线程饱和。也有类似的有限算术题:***.com/q/25179738/478380,***.com/a/18159503/478380。分析是唯一确定的方法。

以上是关于并行的性能损失的主要内容,如果未能解决你的问题,请参考以下文章

并行编程双笙子佯谬 - 高性能并行编程与优化 - 视频教程目录

Spark性能调优之合理设置并行度

Spark性能调优之合理设置并行度

R语言高性能编程

NUMA 机器上并行 MATLAB 的性能问题

并行编程 - 性能改进