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


17

当在共享内存环境(例如,通过OpenMP,Pthreads或TBB进行线程化)中执行内存带宽受限的计算时,将存在一个难题,即如何确保内存在物理内存之间正确分配,以使每个线程大部分访问内存中的内存。 “本地”内存总线。尽管接口不是可移植的,但是大多数操作系统都有设置线程相似性的方法(例如,pthread_setaffinity_np()在许多POSIX系统,sched_setaffinity()Linux,SetThreadAffinityMask()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在虚拟内存中不连续,则需要更复杂的数据结构。)ÿ一种XXX

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

(我并不是说我的C ++示例暗示要强调该语言,但是C ++ 语言编码了一些有关内存管理的决定,而C之类的语言则没有这样做,因此在建议C ++程序员这样做时往往会有更大的阻力情况有所不同。)

Answers:


7

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


1
这是一个实用的解决方案,但是即使我们正在迅速获得更多核心,每个NUMA节点的核心数量也停滞在4个左右。那么在假设的1000个核心节点上,我们是否将运行250个MPI进程?(这很好,但我对此表示怀疑。)
Jed Brown

我不同意每个NUMA的内核数量停滞不前。Sandy Bridge E5有8个。MagnyCours有12个。我有一个Westmere-EX节点有10个。Interlagos(ORNL Titan)有20个。Knights Corner将有50个以上。我想每个NUMA的核心数量都在保持或多或少地遵循摩尔定律。
比尔·巴特

Magny Cours和Interlagos在不同的NUMA区域有两个芯片,因此每个NUMA区域有6和8个核心。倒退到2006年,四核Clovertown的两个插槽将共享相同的内存接口(Blackford芯片组),在我看来,每个NUMA地区的内核数量增长如此之快。Blue Gene / Q将内存的平面视图扩展得更远,也许Knight's Corner将迈出另一步(尽管它是另一种设备,所以也许我们应该与GPU进行比较,因为我们拥有15个(Fermi)或现在有8个(Fermi)开普勒)SM查看平面内存
杰德·布朗

AMD芯片的好消息。我已经忘记了。不过,我认为您将在一段时间内看到该领域的持续增长。
比尔·巴特

6

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

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

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

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

要点2。C++允许使用多个参数重载“ new”。下面的第二个代码显示了这种分配单个对象的情况。它应该给出一个想法,也许对您遇到的情况有用。运算符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';
}

英特尔的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";
}

感谢您的更正。看来,C ++确实相至C不存在额外的并发症,除了非POD阵列诸如std::complex明确地初始化。
杰德·布朗

1
@JedBrown:原因6避免使用std::complex
Jack Poulson

1

在Deal.II中,我们拥有使用线程构建块将每个单元上的程序集并行化为多个内核的软件基础结构(实质上,每个单元有一个任务,需要将这些任务调度到可用处理器上,这不是这样实施,但这是总体思路)。问题在于,对于本地集成,您需要多个临时(临时)对象,并且您至少需要提供尽可能多的可以并行运行的任务。我们看到较差的速度,大概是因为将任务放在处理器上时,它会抓取一个临时对象,该临时对象通常位于其他内核的缓存中。我们有两个问题:

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

(ii)如何确定我在哪里,每个临时对象在哪里以及需要使用哪个临时对象来访问当前内核高速缓存中最热的那个对象?

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

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


您应该将线程绑定到套接字,这样就不必怀疑处理器是否固定。Linux喜欢四处移动。
比尔·巴特

另外,对getcpu()或sched_getcpu()进行采样(取决于您的libc和内核以及诸如此类)应该可以确定线程在Linux上的运行位置。
比尔·巴特

是的,我认为我们用于将工作调度到线程上的线程构建模块将线程连接到处理器。这就是为什么我们尝试使用线程本地存储的原因。但是,我仍然很难为我的问题(i)提出解决方案。
Wolfgang Bangerth'5

1

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

我建议使用LIKWID作为这样一种工具,因为它避免了基于代码的方法,例如,您可以将进程固定到核心。这种解决机器特定内存配置问题的工具方法将有助于确保代码在集群中的可移植性。

您可以从ISC'13“ LIKWID-轻量级性能工具 ”中找到概述它的简短演示,作者已经发表了关于Arxiv的论文“ 现代多核处理器上HPM辅助性能工程的最佳实践 ”。本文介绍了一种从硬件计数器解释数据的方法,以开发特定于您的计算机体系结构和内存拓扑的高性能代码。


LIKWID是有用的,但问题更多是关于如何编写数值/内存敏感的库,该库可以在各种执行环境,线程方案,MPI资源管理和亲和力设置以及与其他图书馆等
杰德·布朗
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.