性能损失并行

时间:2018-09-04 11:44:24

标签: c++ vectorization c++17

我有一个程序,或多或少重复执行某些矢量运算。当我尝试使用parallel_for并行执行相同的任务时,我发现每个任务的时间明显增加。每个任务都从相同的数据读取,并且没有正在进行的同步。这是示例代码(它需要Taskflow库(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或类似工具能否获得更好的见解?

2 个答案:

答案 0 :(得分:4)

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

使用1个线程或12个线程,您实际上不会遇到此争用。如果使用24个线程,则只有将每个线程调度到其自己的物理核心时才可以避免此问题(这种情况可能不会发生(因此,您开始看到更糟糕的时间安排))。拥有48个内核,您肯定会遇到上述问题。

正如 harold 所述,您可能也受到存储的限制(但另一种资源可能导致超线程对竞争)。

答案 1 :(得分:0)

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

  

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

使用VTune之类的工具进行性能分析是确定瓶颈所在的唯一方法。 VTune的专长是它可以在CPU硬件级别分析性能,并且作为Intel工具,它可以访问性能计数器和其他工具可能无法提供的见解,因此可以在CPU看到瓶颈时发现瓶颈。对于AMD CPU,等效工具为CodeXL。可能使用的其他工具包括Performance Counter Monitor(来自https://stackoverflow.com/a/4015983)和(如果运行Windows,则Visual Studio's CPU profiler(来自https://stackoverflow.com/a/3489965)。

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