为什么将任务领域中的任务隔离到内存局部性的 NUMA 节点会减慢我令人尴尬的并行 TBB 应用程序?

Why does isolating tasks in task arenas to NUMA nodes for memory locality slow down my embarassingly parallel TBB application?

我有一个 TBB 应用程序的独立示例,我 运行 在 2-NUMA 节点 CPU 上对动态数组重复执行简单的向量加法。它用一个更复杂的例子重现了我遇到的问题。我试图通过与通过 TBB 的 NUMA API 链接到单独的 NUMA 节点的 2 task_arenas 并行初始化数据,在可用的 NUMA 节点之间干净地划分计算。然后应进行后续并行执行,以便对计算其任务的 cpu 的本地数据执行内存访问。一个控制示例使用一个简单的 parallel_for 和一个 static_partitioner 来执行计算,而我的预期示例调用每个 task_arena 一个任务,该任务调用一个 parallel_for 来计算向量加法指定区域,即之前在相应 NUMA 节点中初始化的动态区域的一半。与控制示例相比,此示例执行矢量加法的时间始终是其两倍。为调用 parallel_for 算法的 task_arena 创建任务不会产生开销,因为性能下降仅在应用 tbb::task_arena::constraints 时发生。谁能向我解释发生了什么以及为什么这种性能损失如此严重。资源的方向也会有所帮助,因为我正在为一个大学项目做这件事。

#include <iostream>
#include <iomanip>
#include <tbb/tbb.h>
#include <vector>



int main(){
    
    std::vector<int> numa_indexes = tbb::info::numa_nodes();
    std::vector<tbb::task_arena> arenas(numa_indexes.size());
    std::size_t numa_nodes = numa_indexes.size();
    for(unsigned j = 0; j < numa_indexes.size(); j++){
        arenas[j].initialize( tbb::task_arena::constraints(numa_indexes[j]));
    }

    std::size_t size = 10000000;
    std::size_t part_size = std::ceil((float)size/numa_nodes);
    double * A = (double *) malloc(sizeof(double)*size);
    double * B = (double *) malloc(sizeof(double)*size);
    double * C = (double *) malloc(sizeof(double)*size);
    double * D = (double *) malloc(sizeof(double)*size);


    //DATA INITIALIZATION
    for(unsigned k = 0; k < numa_indexes.size(); k++)
        arenas[k].execute(
        [&](){
            std::size_t local_start = k*part_size;
            std::size_t local_end = std::min(local_start + part_size, size);
            tbb::parallel_for(static_cast<std::size_t>(local_start), local_end,
                [&](std::size_t i)
            { 
                C[i] = D[i] = 0;
                A[i] = B[i] = 1;
            }, tbb::static_partitioner());
        });

    //PARALLEL ALGORITHM
    tbb::tick_count t0 = tbb::tick_count::now();
    for(int i = 0; i<100; i++)
        tbb::parallel_for(static_cast<std::size_t>(0), size,
            [&](std::size_t i)
            { 
                C[i] += A[i] + B[i];
            }, tbb::static_partitioner());
    tbb::tick_count t1 = tbb::tick_count::now();
    std::cout << "Time 1: " << (t1-t0).seconds() << std::endl;
        

    //TASK ARENA & PARALLEL ALGORITHM
    t0 = tbb::tick_count::now();
    for(int i = 0; i<100; i++){
        for(unsigned k = 0; k < numa_indexes.size(); k++){
        arenas[k].execute(
        [&](){
            for(unsigned i=0; i<numa_indexes.size(); i++)
                task_groups[i].wait();
            task_groups[k].run([&](){
                std::size_t local_start = k*part_size;
                std::size_t local_end = std::min(local_start + part_size, size);
                tbb::parallel_for(static_cast<std::size_t>(local_start), local_end,
                    [&](std::size_t i)
                    { 
                        D[i] += A[i] + B[i];
                    });
            });

        });
    }

    t1 = tbb::tick_count::now();
    std::cout << "Time 2: " << (t1-t0).seconds() << std::endl;

    double sum1 = 0;
    double sum2 = 0;
    for(int i = 0; i<size; i++){
        sum1 += C[i];
        sum2 += D[i];
    }

    std::cout << sum1 << std::endl;
    std::cout << sum2 << std::endl;

    
    return 0;
}

性能:

for(unsigned j = 0; j < numa_indexes.size(); j++){
        arenas[j].initialize( tbb::task_arena::constraints(numa_indexes[j]));
    }
$ taskset -c 0,1,8,9 ./RUNME
Time 1: 0.896496
Time 2: 1.60392
2e+07
2e+07

性能不受限制:

$ taskset -c 0,1,8,9 ./RUNME
Time 1: 0.652501
Time 2: 0.638362
2e+07
2e+07

编辑:我实现了在@AlekseiFedotov 的建议资源中找到的 task_group 的使用,但问题仍然存在。

提供的使用 arenas 的部分示例与 one-to-one 不匹配 the example from the docs,“设置首选 NUMA 节点”部分。

进一步查看 the specification of the task_arena::execute() method,我们可以发现 task_arena::execute() 是一个阻塞 API,即它不会 return 直到传递的 lambda 完成。

另一方面,the specification of the task_group::run() method 表明它的方法是异步的,即 returns 立即执行,而不是等待传递的仿函数完成。

我想这就是问题所在。代码在 arenas 中一个一个地执行两个并行循环,可以说是以串行方式。仔细考虑遵循文档中的示例。

顺便说一句,oneTBB 项目是 TBB 的改进版本,可以找到 here

编辑已编辑问题的答案:

  1. 查看问题的评论。
  2. 等待应该发生在提交作业之后,而不是提交之前。另外,不需要去另一个arena的任务组做循环内的等待,只需通过arena[i].execute( [i, &] { task_group[i].run( [i, &] { /*...*/ } ); } )提交NUMA循环中的工作,然后,在另一个循环中,等待对应的task_arena.
  3. 中的每个task_group

请注意我是如何通过复制捕获 NUMA 循环迭代的。否则,代码可能会引用 lambda 体内的错误数据。