【问题标题】:Throughput of trivially concurrent code does not increase with the number of threads普通并发代码的吞吐量不会随着线程数的增加而增加
【发布时间】:2020-09-17 07:48:31
【问题描述】:

我正在尝试使用 OpenMP 对我实现的数据结构的速度进行基准测试。但是,我似乎犯了一个根本性的错误:无论我尝试对什么操作进行基准测试,吞吐量都会随着线程数的增加而减少而不是增加。 在下面您可以看到尝试对 for 循环的速度进行基准测试的代码,因此我希望它可以(在某种程度上)随线程数线性扩展,但它不会(在有和没有的双核笔记本电脑上编译 - g++ 上的 O3 标志与 c++11)。

#include <omp.h>
#include <atomic>
#include <chrono>
#include <iostream>

thread_local const int OPS = 10000;
thread_local const int TIMES = 200;

double get_tp(int THREADS)
{
    double threadtime[THREADS] = {0};

    //Repeat the test many times
    for(int iteration = 0; iteration < TIMES; iteration++)
    {
        #pragma  omp  parallel num_threads(THREADS)
        {
            double start, stop;
            int loc_ops = OPS/float(THREADS);
            int t = omp_get_thread_num();

            //Force all threads to start at the same time
            #pragma  omp  barrier
            start = omp_get_wtime();


            //Do a certain kind of operations loc_ops times
            for(int i = 0; i < loc_ops; i++)
            {
                //Here I would put the operations to benchmark
                //in this case a boring for loop
                int x = 0;
                for(int j = 0; j < 1000; j++)
                    x++;
            }

        stop = omp_get_wtime();
        threadtime[t] += stop-start;
        }
    }

    double total_time = 0;
    std::cout << "\nThread times: ";
    for(int i = 0; i < THREADS; i++)
    {
        total_time += threadtime[i];
        std::cout << threadtime[i] << ", ";
    }
    std::cout << "\nTotal time: " << total_time << "\n";
    double mopss = float(OPS)*TIMES/total_time;
    return mopss;
}

int main()
{
    std::cout << "\n1  " << get_tp(1) << "ops/s\n";
    std::cout << "\n2  " << get_tp(2) << "ops/s\n";
    std::cout << "\n4  " << get_tp(4) << "ops/s\n";
    std::cout << "\n8  " << get_tp(8) << "ops/s\n";
}

在双核上使用 -O3 的输出,因此我们预计 2 个线程后吞吐量不会增加,但当从 1 个线程变为 2 个线程时它甚至不会增加,它会减少 50%:

1 Thread 
Thread times: 7.411e-06, 
Total time: 7.411e-06
2.69869e+11 ops/s

2 Threads 
Thread times: 7.36701e-06, 7.38301e-06, 
Total time: 1.475e-05
1.35593e+11ops/s

4 Threads 
Thread times: 7.44301e-06, 8.31901e-06, 8.34001e-06, 7.498e-06, 
Total time: 3.16e-05
6.32911e+10ops/s

8 Threads 
Thread times: 7.885e-06, 8.18899e-06, 9.001e-06, 7.838e-06, 7.75799e-06, 7.783e-06, 8.349e-06, 8.855e-06, 
Total time: 6.5658e-05
3.04609e+10ops/s

为确保编译器不会删除循环,我还尝试在测量时间后输出“x”,据我所知问题仍然存在。我还在具有更多内核的机器上尝试了代码,它的行为非常相似。如果没有 -O3,吞吐量也不会扩展。所以我的基准测试方式显然有问题。我希望你能帮助我。

【问题讨论】:

  • "为了确保编译器不会去掉循环,我也试过在测量时间后输出"x"" -- 那并不能保证编译器不删除循环。编译器可以简单地评估x 的最终值,并用该计算替换整个循环。在从基准测试中得出任何结论之前,请查看代码的汇编输出以验证循环是否仍然存在。您可能正在对创建线程的开销进行基准测试,因为每个线程都没有完成任何实质性工作。
  • 我正在查看汇编代码,但我无法理解真正发生的事情,因此将某些内容放入循环中以使其不会得到优化似乎是一个非常好的主意。如果我将 std::rand() 放入其中,结果会更糟,因为 2 个线程的吞吐量约为 1 个线程的吞吐量的 2%。但是,std::rand 是 not necessarily thread 安全的,但我不确定这是否仅涉及其正确性或速度。
  • 使用 rand() 会因为不断的缓存失效而导致严重的速度损失。要在多线程应用程序中处理随机数,请使用drand48_r family 中的函数。它们都将 PRNG 状态作为附加参数。

标签: c++ multithreading concurrency openmp microbenchmark


【解决方案1】:

我不确定您为什么将性能定义为每个总 CPU 时间的操作总数,然后对线程数的递减函数感到惊讶。这种情况几乎总是普遍存在,除非缓存效果开始发挥作用。真正的性能指标是每个挂钟时间的操作数。

用简单的数学推理很容易证明。给定总工作W和每个核心的处理能力P,单个核心上的时间为T_1 = W / P。在n 核心之间平均分配工作意味着它们每个都适用于T_1,n = (W / n + H) / P,其中H 是由并行化本身引起的每个线程的开销。这些总和是T_n = n * T_1,n = W / P + n (H / P) = T_1 + n (H / P)。开销总是一个正值,即使在所谓的令人尴尬的并行这种微不足道的情况下,没有两个线程需要通信或同步。例如,启动 OpenMP 线程需要时间。你无法摆脱开销,你只能通过确保每个线程都有很多工作来在线程的生命周期内摊销它。因此,T_n > T_1 并且在这两种情况下使用固定数量的操作,n 内核的性能将始终低于单个内核。此规则的唯一例外是当大小为W 的工作的数据不适合较低级别的缓存,但大小为W / n 的工作的数据却适合。这会导致超过内核数量的巨大加速,称为超线性加速。您正在线程函数内部进行测量,因此您忽略 HT_n 的值应该或多或少等于 T_1 在计时器精度内,但是...

由于多个线程在多个 CPU 内核上运行,它们都在争夺有限的共享 CPU 资源,即最后一级缓存(如果有)、内存带宽和热包络。

当您只是增加一个标量变量时,内存带宽不是问题,但当代码开始实际将数据移入和移出 CPU 时,它就会成为瓶颈。数值计算的一个典型例子是稀疏矩阵向量乘法 (spMVM) - 一个经过适当优化的 spMVM 例程,使用double 非零值和long 索引占用大量内存带宽,以至于内存完全饱和每个 CPU 插槽只有两个线程的总线,在这种情况下,昂贵的 64 核 CPU 成为一个非常糟糕的选择。这适用于所有具有低算术强度(每单位数据量的操作)的算法。

谈到散热包络时,大多数现代 CPU 都采用动态电源管理,并且会根据有多少内核处于活动状态来超频或降频。因此,虽然n 降频内核执行的每单位时间的总工作量比单个内核要多,但就每CPU 总时间的工作量而言,单个内核的性能优于n 内核,这是您使用的指标。​​

考虑到所有这些,最后(但并非最不重要的)一件事需要考虑——定时器分辨率和测量噪声。您的运行时间以几微秒为单位。除非你的代码运行在一些专门的硬件上,除了运行你的代码之外什么都不做(即,不与守护进程、内核线程和其他进程共享时间,也没有中断处理),你需要运行几个数量级的基准测试,最好是至少几秒钟。

【讨论】:

    【解决方案2】:

    即使您在外循环之后输出x 的值,几乎可以肯定循环仍在优化中。编译器可以用一条指令轻松替换整个循环,因为循环边界在编译时是恒定的。确实,在this example

    #include <iostream>
    
    int main()
    {
        int x = 0;
        for (int i = 0; i < 10000; ++i) {
            for (int j = 0; j < 1000; ++j) {
                ++x;
            }
        }
    
        std::cout << x << '\n';
        return 0;
    }
    

    循环被替换为单个汇编指令mov esi, 10000000

    始终在进行基准测试时检查程序集输出,以确保您测量的是您认为的自己;在这种情况下,您只是在测量创建线程的开销,当然,创建的线程越多,开销就越高。

    考虑让最里面的循环做一些无法优化的事情。随机数生成是一个很好的候选,因为它应该在恒定时间内执行,并且它具有置换 PRNG 状态的副作用(使其没有资格被完全删除,除非事先知道种子 编译器能够解开 PRNG 中的所有突变)。

    For example:

    #include <iostream>
    #include <random>
    
    int main()
    {
        std::mt19937 r;
        std::uniform_real_distribution<double> dist{0, 1};
    
        for (int i = 0; i < 10000; ++i) {
            for (int j = 0; j < 1000; ++j) {
                dist(r);
            }
        }
    
        return 0;
    }
    

    循环和 PRNG 调用在这里保持不变。

    【讨论】:

    • 如果我使用这种方法(并为每个线程分离 r 和 dist 对象),1 和 2 线程的性能非常相似,所以很遗憾没有加速。所以我的基准测试方式仍然存在根本性的问题。
    • @Dystackful 对“玩具”代码进行基准测试几乎没有任何意义。对真实代码进行基准测试。
    • 我尝试对我的数据结构进行基准测试。不幸的是,即使是一个非常简单的情况(每个操作只需要一个原子负载)的基准测试也没有像我预期的那样运行,即超过 1 个线程的性能没有提高。由于我没有发现数据结构的这部分代码的工作方式有任何问题,我相信问题出在我的基准测试方式上。
    • 我尝试从 boost 中对无锁队列进行基准测试,对于具有 1 或 2 个线程的入队和出队(在空队列上),吞吐量(根据我的基准)基本相同。这看起来很奇怪。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-02-05
    • 1970-01-01
    • 2018-09-09
    • 2018-08-31
    • 2021-05-31
    • 2017-09-06
    • 1970-01-01
    相关资源
    最近更新 更多