通过实验将OpenMP线程排序到NUMA节点中的问题

Problem of sorting OpenMP threads into NUMA nodes by experiment

我正在尝试为每个 NUMA 节点创建一个包含一组的 std::vector<std::set<int>>,其中包含使用 omp_get_thread_num() 获得的线程 ID。

拓扑:

想法:

  1. 创建大于三级缓存的数据,
  2. 使用线程 0 设置第一次触摸,
  3. 进行多次实验以确定每个线程的最小访问时间,
  4. 根据排序的访问时间和有关拓扑的信息将线程提取到节点中。

代码:(英特尔编译器,OpenMP)

    // create data which will be shared by multiple threads
    const auto part_size = std::size_t{50 * 1024 * 1024 / sizeof(double)}; // 50 MB
    const auto size = 2 * part_size;
    auto container = std::unique_ptr<double>(new double[size]);
    
    // open a parallel section
    auto thread_count = 0;
    auto thread_id_min_duration = std::multimap<double, int>{};
    #ifdef DECIDE_THREAD_COUNT
    #pragma omp parallel num_threads(std::thread::hardware_concurrency())
    #else
    #pragma omp parallel
    #endif
    {
        // perform first touch using thread 0
        const auto thread_id = omp_get_thread_num();
        if (thread_id == 0)
        {
            thread_count = omp_get_num_threads();
            for (auto index = std::size_t{}; index < size; ++index)
            {
                container.get()[index] = static_cast<double>(std::rand() % 10 + 1);
            }
        }
        #pragma omp barrier
        
        // access the data using all threads individually
        #pragma omp for schedule(static, 1)
        for (auto thread_counter = std::size_t{}; thread_counter < thread_count; ++thread_counter)
        {
            // calculate the minimum access time of this thread
            auto this_thread_min_duration = std::numeric_limits<double>::max();
            for (auto experiment_counter = std::size_t{}; experiment_counter < 250; ++experiment_counter)
            {
                const auto* data = experiment_counter % 2 == 0 ? container.get() : container.get() + part_size;
                const auto start_timestamp = omp_get_wtime();
                for (auto index = std::size_t{}; index < part_size; ++index)
                {
                    static volatile auto exceedingly_interesting_value_wink_wink = data[index];
                }
                const auto end_timestamp = omp_get_wtime();
                const auto duration = end_timestamp - start_timestamp;
                if (duration < this_thread_min_duration)
                {
                    this_thread_min_duration = duration;
                }
            }
            #pragma omp critical
            {
                thread_id_min_duration.insert(std::make_pair(this_thread_min_duration, thread_id));
            }
        }
    } // #pragma omp parallel

此处未显示的是输出排序到多映射中的最小访问时间的代码。

环境。和输出

  1. OMP_PLACESOMP_PROC_BIND 是如何工作的?

我试图通过 export OMP_PLACES=cores OMP_PROC_BIND=spread OMP_NUM_THREADS=24 不使用 SMT。但是,我得到了这个输出:

令我困惑的是我在所有线程上的访问时间都相同。因为我试图将它们分布在 2 个 NUMA 节点上,所以我希望能清楚地看到 12 个具有访问时间的线程,比如 x 和另外 12 个具有访问时间的线程 ~2x.

  1. 为什么会出现上述情况?

附加信息

更令人费解的是以下环境及其输出:

  1. export OMP_PLACES=cores OMP_PROC_BIND=spread OMP_NUM_THREADS=26

  2. export OMP_PLACES=cores OMP_PROC_BIND=spread OMP_NUM_THREADS=48

如果能帮助理解这种现象,我们将不胜感激。

简而言之,基准测试存在缺陷

perform multiple experiments to determine the minimum access time of each thread

此处术语“最短访问时间”不明确。我假设你的意思是“延迟”。问题是您的基准测试不会衡量延迟。 volatile 告诉编译器从内存层次结构中读取存储数据。处理器可以自由地将值存储在其缓存中,而 x86-64 处理器实际上会这样做(就像几乎所有现代处理器一样)。

How do OMP_PLACES and OMP_PROC_BIND work?

您可以找到 here and there. Put it shortly, I strongly advise you to set OMP_PROC_BIND=TRUE and OMP_PLACES="{0},{1},{2},..." based on the values retrieved from hw-loc. More specifically, you can get this from hwloc-calc 的文档,这是一个非常棒的工具(考虑使用 --li --poPU,而不是 CORE,因为这就是 OpenMP运行时期望)。例如,您可以查询给定 NUMA 节点的 PU 标识符。请注意,某些机器的 non-linear OS PU 编号非常奇怪,而且 OpenMP 运行时有时无法正确映射线程。 IOMP(ICC 的 OpenMP 运行时)应该在内部使用 hw-loc,但我在过去发现了一些与此相关的错误。要检查映射是否正确,我建议您使用 hwloc-ps。请注意,OMP_PLACES=cores 不能保证线程不会从一个内核迁移到另一个内核(即使一个内核在不同的 NUMA 节点上),除非设置了 OMP_PROC_BIND=TRUE(或类似设置)。请注意,您还可以使用 numactl 控制进程的 NUMA 策略 。例如,您可以告诉 OS 不要使用给定的 NUMA 节点或交错分配。第一次接触策略不是唯一的,也可能不是所有平台上的默认策略(在某些 Linux 平台上,OS 可以在 NUMA 节点之间移动页面以改善局部性)。

Why is the above happening?

代码在每个线程中读取 50 MiB 内存需要 4.38 毫秒。这意味着假设应用了第一个触摸策略,从节点 0 读取 1200 MiB。因此,吞吐量应该约为 267 GiB/s。虽然乍一看这似乎很好,但对于这样的处理器来说这是一个相当大的吞吐量,特别是假设只使用 1 个 NUMA 节点。这当然是因为部分提取是从 L3 缓存而不是 RAM 完成的。事实上,缓存可以部分保存数组的一部分,并且由于 缓存关联性 和良好的 缓存策略 ,确实会导致更快的提取。尤其如此,因为缓存行不会失效,因为数组仅被读取。我建议你使用一个大得多的数组来防止这种复杂的效果发生。

由于远程 NUMA 内存访问,您当然希望一个 NUMA 节点具有较小的吞吐量。这在实践中并不总是正确的。事实上,这在现代 2 插槽系统上通常是错误的,因为插槽互连通常不是限制因素(这是 NUMA 系统上吞吐量下降的主要原因)。

由于不平衡的 NUMA 内存节点饱和和 non-uniform 延迟,现代平台上出现了 NUMA 效应。前者在您的应用程序中不是问题,因为所有 PU 使用相同的 NUMA 内存节点。由于线性内存访问模式、CPU缓存和硬件预取器,后者也不是问题:延迟应该完全隐藏.

Even more puzzling are the following environments and their outputs

在 24 核机器上使用 26 个线程意味着 4 个线程必须在两个内核上执行 。问题是 hyper-threading 在这种情况下应该没有多大帮助。结果,共享同一核心的多个线程将变慢。因为 IOMP 肯定会将线程固定到核心和 不平衡的工作负载 ,4 个线程将慢两倍左右。

拥有 48 个线程会导致所有线程变慢,因为工作量增加了一倍。

让我谈谈你的第一句话。 C++ std::vector 不同于 C malloc。 Malloc'ed space 不是“实例化的”:只有当您触摸内存时才会建立 physical-to-logical 地址映射。这被称为“第一次接触”。这就是为什么在 C-OpenMP 中并行初始化一个数组,以便接触数组部分的套接字获取该部分的页面。在 C++ 中,向量中的“数组”是由单个线程创建的,因此页面在该线程的套接字上结束。

这是一个解决方案:

template<typename T>
struct uninitialized {
  uninitialized() {};
  T val;
  constexpr operator T() const {return val;};
  double operator=( const T&& v ) { val = v; return val; };
};

现在您可以创建一个 vector<uninitialized<double>> 并且数组内存在您显式初始化之前不会被触及:

vector<uninitialized<double>> x(N),y(N);

#pragma omp parallel for
for (int i=0; i<N; i++)
  y[i] = x[i] = 0.;
x[0] = 0; x[N-1] = 1.;

现在,我不确定如果你有一个集合向量会怎样。只是想我会指出这个问题。

经过更多调查,我注意到以下几点:

  1. work-load 集群上的管理器可以并且将会 disregard/reset OMP_PLACES/OMP_PROC_BIND,
  2. 内存页面迁移是现代 NUMA 系统上的事情。

在此之后,我开始使用 work-load 经理自己的线程 binding/pinning 系统,并调整我的基准以锁定我的数据所在的内存页面。此外,屈服于我的程序员的偏执狂,我放弃了 std::unique_ptr,因为担心它可能会在分配内存后进行自己的第一次接触。

    // create data which will be shared by multiple threads
    const auto size_per_thread = std::size_t{50 * 1024 * 1024 / sizeof(double)}; // 50 MB
    const auto total_size = thread_count * size_per_thread;
    double* data = nullptr;
    posix_memalign(reinterpret_cast<void**>(&data), sysconf(_SC_PAGESIZE), total_size * sizeof(double));
    if (data == nullptr)
    {
        throw std::runtime_error("could_not_allocate_memory_error");
    }
    
    // perform first touch using thread 0
    #pragma omp parallel num_threads(thread_count)
    {
        if (omp_get_thread_num() == 0)
        {
            #pragma omp simd safelen(8)
            for (auto d_index = std::size_t{}; d_index < total_size; ++d_index)
            {
                data[d_index] = -1.0;
            }
        }
    } // #pragma omp parallel
    mlock(data, total_size); // page migration is a real thing...
    
    // open a parallel section
    auto thread_id_avg_latency = std::multimap<double, int>{};
    auto generator = std::mt19937(); // heavy object can be created outside parallel
    #pragma omp parallel num_threads(thread_count) private(generator)
    {
        // access the data using all threads individually
        #pragma omp for schedule(static, 1)
        for (auto thread_counter = std::size_t{}; thread_counter < thread_count; ++thread_counter)
        {
            // seed each thread's generator
            generator.seed(thread_counter + 1);
            
            // calculate the minimum access latency of this thread
            auto this_thread_avg_latency = 0.0;
            const auto experiment_count = 250;
            for (auto experiment_counter = std::size_t{}; experiment_counter < experiment_count; ++experiment_counter)
            {
                const auto start_timestamp = omp_get_wtime() * 1E+6;
                for (auto counter = std::size_t{}; counter < size_per_thread / 100; ++counter)
                {
                    const auto index = std::uniform_int_distribution<std::size_t>(0, size_per_thread-1)(generator);
                    auto& datapoint = data[thread_counter * size_per_thread + index];
                    datapoint += index;
                }
                const auto end_timestamp = omp_get_wtime() * 1E+6;
                this_thread_avg_latency += end_timestamp - start_timestamp;
            }
            this_thread_avg_latency /= experiment_count;
            #pragma omp critical
            {
                thread_id_avg_latency.insert(std::make_pair(this_thread_avg_latency, omp_get_thread_num()));
            }
        }
    } // #pragma omp parallel
    std::free(data);

有了这些变化,我注意到了我预期的不同。

补充说明:

  1. 这个实验表明 non-local 访问的延迟是我正在使用的集群上本地访问的 1.09 - 1.15 倍,
  2. 没有可靠的 cross-platform 方法(需要 kernel-APIs),
  3. OpenMP 对线程的编号似乎与 hwloc/lstoponumactllscpu 对它们的编号完全相同(逻辑 ID?)

最令人惊讶的是延迟差异非常小,并且可能会发生内存页面迁移,这引出了一个问题,我们为什么要关心 first-touch 和所有其他 NUMA 问题完全没有?