便携式多核/NUMA 内存分配/初始化最佳实践

计算科学 表现 内存管理 多核
2021-12-02 22:20:31

当在共享内存环境(例如,通过 OpenMP、Pthreads 或 TBB 线程化)中执行内存带宽受限的计算时,如何确保内存在物理内存中正确分布是一个难题,这样每个线程主要访问一个“本地”内存总线。尽管接口不可移植,但大多数操作系统都有设置线程亲和性的方法(例如pthread_setaffinity_np(),在许多 POSIX 系统sched_setaffinity()上、LinuxSetThreadAffinityMask()上、Windows 上)。还有诸如hwloc之类的库用于确定内存层次结构,但不幸的是,大多数操作系统还没有提供设置 NUMA 内存策略的方法。Linux 是一个明显的例外,有libnuma允许应用程序以页面粒度操作内存策略和页面迁移(自 2004 年以来在主线中,因此广泛可用)。其他操作系统希望用户遵守隐含的“第一次接触”策略。

使用“第一次接触”策略意味着调用者应该创建和分配线程,并使用他们计划稍后在首次写入新分配的内存时使用的任何亲和性。(很少有系统被配置为malloc()实际找到页面,它只是承诺在实际出错时找到它们,可能是由不同的线程。)这意味着使用分配calloc()或在分配使用后立即初始化内存memset()是有害的,因为它往往会出错所有内存都在运行分配线程的内核的内存总线上,当从多个线程访问内存时,会导致最坏情况下的内存带宽。这同样适用于new坚持初始化许多新分配的 C++ 运算符(例如std::complex)。关于这个环境的一些观察:

  • 分配可以进行“线程集体”,但现在分配会混合到线程模型中,这对于可能必须与使用不同线程模型(可能每个都有自己的线程池)的客户端交互的库来说是不可取的。
  • RAII 被认为是惯用 C++ 的重要组成部分,但它似乎对 NUMA 环境中的内存性能有害。放置new可以与通过分配的内存malloc()或来自的例程一起使用libnuma,但这会改变分配过程(我认为这是必要的)。
  • 编辑:我之前关于运营商的说法new不正确,它可以支持多个参数,请参阅 Chetan 的回复。我相信让库或 STL 容器使用指定的亲和力仍然存在问题。可能会打包多个字段,并且可能不方便确保例如std::vector在正确上下文管理器处于活动状态的情况下进行重新分配。
  • 每个线程都可以分配和故障自己的私有内存,但是索引到相邻区域会更加复杂。(考虑一个稀疏矩阵向量积是的一个X矩阵和向量的行分区;索引无主的部分X需要更复杂的数据结构X在虚拟内存中不连续。)

NUMA 分配/初始化的任何解决方案是否被认为是惯用的?我是否遗漏了其他关键问题?

(我的 C++ 示例并不是要强调该语言,但是 C++语言编码了一些关于内存管理的决策,而 C 语言没有,因此当建议 C++ 程序员做这些时往往会有更多的阻力事情不同。)

4个回答

我倾向于解决这个问题的一种方法是在内存控制器级别有效地分解线程和(MPI)任务。即,通过为每个 CPU 插槽或内存控制器设置一个任务,然后在每个任务下执行线程,从代码中删除 NUMA 方面。如果您这样做,那么无论哪个线程实际执行分配或初始化工作,您都应该能够通过首次触摸或可用 API 之一安全地将所有内存绑定到该套接字/控制器。套接字之间的消息传递通常得到了很好的优化,至少在 MPI 中是这样。你总是可以有比这更多的 MPI 任务,但由于你提出的问题,我很少建议人们少做。

这个答案是对问题中两个与 C++ 相关的误解的回应。

  1. “这同样适用于坚持初始化新分配(包括 POD)的 C++ new 运算符”
  2. “C++ operator new 只接受一个参数”

这不是您提到的多核问题的直接答案。只是回应将 C++ 程序员归类为 C++ 狂热者的评论,以便维护声誉;)。

要点 1. C++“新”或堆栈分配不坚持初始化新对象,无论是否 POD。由用户定义的类的默认构造函数具有该责任。下面的第一个代码显示了无论该类是否为 POD 打印的垃圾。

第 2 点。C++ 允许使用多个参数重载“new”。下面的第二个代码显示了分配单个对象的这种情况。它应该提供一个想法,并且可能对您的情况有用。operator new[] 也可以适当修改。

// 第 1 点的代码。

#include <iostream>

struct A
{
    // int/double/char/etc not inited with 0
    // with or without this constructor
    // If present, the class is not POD, else it is.
    A() { }

    int i;
    double d;
    char c[20];
};

int main()
{
    A* a = new A;
    std::cout << a->i << ' ' << a->d << '\n';
    for(int i = 0; i < 20; ++i)
        std::cout << (int) a->c[i] << '\n';
}

Intel 的 11.1 编译器显示了这个输出(当然是“a”指向的未初始化内存)。

993001483 6.50751e+029
105
108
... // skipped
97
108

// 第 2 点的代码。

#include <cstddef>
#include <iostream>
#include <new>

// Just to use two different classes.
class arena { };
class policy { };

struct A
{
    void* operator new(std::size_t, arena& arena_obj, policy& policy_obj)
    {
        std::cout << "special operator new\n";
        return (void*)0x1234; //Just to test
    }
};

void* operator new(std::size_t, arena& arena_obj, policy& policy_obj)
{
    std::cout << "special operator new (global)\n";
    return (void*)0x5678; //Just to test
}

int main ()
{
    arena arena_obj;
    policy policy_obj;
    A* ptr = new(arena_obj, policy_obj) A;
    int* iptr = new(arena_obj, policy_obj) int;
    std::cout << ptr << "\n";
    std::cout << iptr << "\n";
}

在 deal.II 中,我们拥有软件基础架构,可以使用线程构建块将每个单元上的组装并行化到多个内核上(本质上,每个单元有一个任务,并且需要将这些任务安排到可用的处理器上——这不是它的方式实施,但这是一般的想法)。问题是,对于本地集成,您需要许多临时(临时)对象,并且您需要提供至少与可以并行运行的任务一样多的任务。我们看到加速很差,大概是因为当一个任务被放到处理器上时,它会抓取一个临时对象,这些对象通常会在其他内核的缓存中。我们有两个问题:

(i) 这真的是原因吗?当我们在 cachegrind 下运行程序时,我发现我使用的指令数量与在单线程上运行程序时基本相同,但所有线程累积的总运行时间远大于单线程运行时间。真的是因为我不断地故障缓存吗?

(ii) 我怎样才能知道我在哪里,每个暂存对象在哪里,以及我需要使用哪个暂存对象来访问当前核心缓存中的热对象?

最终,我们都没有找到这些解决方案的答案,并且经过几项工作后,我们决定我们缺乏调查和解决这些问题的工具。我确实知道如何至少在原则上解决问题(ii)(即,使用线程本地对象,假设线程仍然固定在处理器内核上——另一个不容易测试的猜想),但我没有工具来测试问题(一世)。

因此,从我们的角度来看,处理 NUMA 仍然是一个未解决的问题。

除了 hwloc 之外,还有一些工具可以报告 HPC 集群的内存环境,并可用于设置各种 NUMA 配置。

我会推荐 LIKWID 作为这样的工具之一,因为它避免了基于代码的方法,例如允许您将进程固定到核心。这种解决机器特定内存配置的工具方法将有助于确保您的代码跨集群的可移植性。

您可以在 ISC'13“ LIKWID - 轻量级性能工具”中找到一个简短的演示文稿,作者在 Arxiv 上发表了一篇论文“现代多核处理器上 HPM 辅助性能工程的最佳实践”。本文描述了一种解释来自硬件计数器的数据以开发特定于您的机器架构和内存拓扑的高性能代码的方法。