面向 NUMA 优化应用

优化面向 NUMA 的应用 (PDF 225KB)

摘要

NUMA(非一致性内存访问)是一种共享内存架构,描述如何在多处理器系统中放置与处理器相关的主要内存模块。 与其他大多数处理器架构特性一样,忽略 NUMA 可能会导致应用内存性能降低。 但幸运的是,我们可以采取一些步骤缓解基于 NUMA 的性能问题,也可使用 NUMA 架构充分利用并行应用。 其中需要考虑的因素包括处理器关联、基于隐式操作系统策略的内存分配,以及使用系统 API 以通过显式指令分配和迁移内存页。

本文是“英特尔® 多线程应用开发指南”系列的一部分,该系列介绍了针对英特尔® 平台开发高效多线程应用的指导原则。

背景

如何理解 NUMA,最好的方法可能是将其与同类的 UMA(一致性内存访问)进行对比。 在 UMA 内存架构中,所有处理器均通过一条总线(或其他互连形式)访问共享内存,如下图所示:

之所以称为 UMA,是因为每颗处理器都必须使用相同的共享总线访问内存,因此所有处理器的内存访问时间都是一致的。 请注意,访问时间也不受内存中数据位置的限制。 也就是说,无论哪个共享内存模块包含待检索数据,访问时间均保持相同。

在 NUMA 共享内存架构中,每颗处理器都有其自己的本地内存模块,能够直接进行访问,因此具有非常独特的性能优势。 同时,它还可通过共享总线(或其他互连形式)访问其他处理器的内存模块,如下图所示:

之所以称为 NUMA,是因为内存访问时间因待访问数据位置的不同而会有所差异。 如果数据驻留在本地内存,访问速度会非常快。 如果数据驻留在远程内存,访问速度将会慢一些。 作为一种分层共享内存方案,NUMA 的优势在于它能够通过引进快速的本地内存,缩短平均访问时间。

现代多处理器系统将这些基本架构融合在一起,如下图所示:

在这种复杂的分层方案中,处理器按照其在一个或其他多核 CPU 封装或“节点”上的物理位置进行分组。 一个节点中的处理器按照 UMA 共享内存架构共享对内存模块的访问。 同时,它们还可使用共享互连方式访问远程节点的内存,但这种访问按照 NUMA 共享内存架构的方式进行,因此性能会有所降低。

建议

管理 NUMA 共享内存架构的性能时必须注意两个关键点:处理器关联和数据放置

处理器关联

当前,复杂操作系统主要使用调度程序将应用线程分配给处理器内核。 调度程序考虑系统状态和不同的策略目标(比如“平衡内核负载”或“整合内核上的线程或使内核保持为休眠状态”),然后匹配应用线程和相应的物理内核。 特定线程会在其分配的内核上执行一段时间,之后被交换到内核之外进行等待,因为其他线程也需要执行。 如果另一内核可用,调度程序将选择迁移该线程,以确保及时执行并实现其策略目标。

将线程从一个内核迁移到另一内核会导致 NUMA 共享内存架构出现问题,因为它会断开线程与其本地内存分配之间的关联。 也就是说,线程可能启动时在节点 1 上分配内存,因为它在节点 1 封装中的内核上运行。 但是当该线程后来迁移至节点 2 上的内核时,之前保存的数据将变成远程数据,且内存访问时间也会大幅增加。

输入处理器关联。 处理器关联指线程/进程与特定处理器资源实例相关联的持续性(无论其他实例的可用性如何)。 通过使用系统 API,或修改操作系统数据结构(比如关联掩码),特定内核或内核集可与应用线程相关联。 然后在制定有关线程寿命的决策时,调度程序会注意到这种关联。 例如,线程可能配置成仅在内核 0-3(均属于四核 CPU 封装 0)上运行。 调度程序将在内核 0-3 之间进行选择,不会考虑将线程迁移至其他封装。

执行处理器关联可确保内存分配对有需要的线程保持本地性。 不过,也应该注意其存在的缺点。 一般来说,如果本可以使用更好的资源管理方式,处理器关联将会限制调度程序的选择,并产生资源争用现象,从而对系统性能造成不利影响。 除了阻止调度程序将等待线程分配给未利用的内核外,处理器关联的局限性还会对应用本身产生不利影响,因为其他节点上的额外执行时间无法弥补速度较慢的内存访问。

编程人员必须慎重考虑处理器关联方法是否适用于特定应用和共享系统环境。 最后需注意的一点是,除显式指令外,部分系统提供的处理器关联 API 还支持向调度程序提供优先级“提示”和关联“建议”。 相比于强制执行明确的线程放置结构,使用此类建议能够确保在通用案例中实现最佳性能,并在高资源竞争期间避免限制调度选择。

基于隐式内存分配策略的数据放置

在简单情况下,许多操作系统以透明的方式支持 NUMA 友好型数据放置。 当单线程应用分配内存时,处理器会将内存页分配给与请求线程的节点(CPU 封装)相关的物理内存,从而确保其对线程的本地性并达到最佳的访问性能。

另外,部分操作系统会等待第一次内存访问完成,然后才执行内存页分配。 为了解此处所具备的优势,可以考虑带有启动顺序的多线程应用,即包含按照主控制线程的内存,然后是创建不同 worker 线程,然后是长时间应用处理或服务。 尽管将内存页放在分配线程的本地比较合理,但事实上,将其放在将访问数据的 worker 线程本地更加高效。 这样操作系统会注意到第一个访问请求,并根据请求程序的节点位置分配内存页。

这两种策略(首次访问本地性和首个请求本地性)共同证明,应用编程人员部署程序时必须了解 NUMA 环境。 如果内存页放置策略以首次访问为基础,编程人员可通过在启动时纳入精心设计的数据访问顺序,向操作系统生成有关最佳内存放置的“提示”,从而充分发挥这一策略的作用。 如果内存页放置策略以请求程序位置为基础,编程人员应确保由按照顺序访问数据的线程执行内存分配,而不是由设计为供应代理的初始化或控制线程执行。

访问相同数据的多个线程最好放在相同节点上,以便放置在节点本地的内存分配能够有利于所有线程。 例如,这样可用于预取方案,旨在通过在实际需要之前生成数据请求,以提升应用性能。 此类线程必须生成位于 NUMA 架构实际消费者线程本地的数据放置,以提升其特征性能。

需要注意的是,当操作系统充分使用某一节点的物理内存资源时,同一节点上的线程发来的内存请求将通常由远程节点上不具备最佳性能的分配来执行。 对于需要消耗大量内存的应用来说,这样能够正确地按照大小分类特定线程的内存需求,并确保与访问线程相关的本地放置。

在大量线程通过所有节点随机共享相同数据池的情况下,建议跨所有节点将数据划分成均匀的条带。 这样能够分散内存访问负载,避免系统中单个节点出现瓶颈访问模式。

基于显式内存分配指令的数据放置

在基于 NUMA 的系统中,进行数据放置的另外一种方法是使用系统 API 明确配置内存页分配的位置。 其中一种 API 是面向 Linux 的 libnuma 库。

使用 API,编程人员能够建立虚拟内存地址范围与特定节点的关联,还可在轻松指示内存分配系统调用中的所需节点。 借助这种功能,应用编程人员可确保特定数据集的放置,无论由哪个线程分配或哪个线程首先访问。 例如,这样有利于复杂应用使用代表 worker 线程的内存管理线程的方案。 还证明了它适用于创建多个短期线程(均包含可预测数据要求)的应用。 预取方案也可大大受益于这种控制。

当然,这种方法也存在缺点,即应用编程人员在处理内存分配和数据放置时需完成大量管理任务。 错误放置的数据对性能的影响要甚于默认系统行为。 显式内存管理还假定整个应用使用期间处理器关联均处于精细控制之下。

基于 NUMA 的内存管理 API 为应用编程人员提供的另一项功能是内存页迁移。 通常来说,在节点之间迁移内存页是一项非常昂贵的操作,有时需要避免。 说到这点,倘若应用为长期、内存密集型应用,迁移内存页以重新建立 NUMA 友好型配置将物有所值。3 例如,可以试想一下长期应用,它包含多个已经终止的线程以及已经创建但驻留在另一节点上的新线程。 现在,对于需要它的线程来说,数据不再具有本地性,而占据主导地位的是性能较低的访问请求。 编程人员可以运用有关线程寿命和数据需求的特定于应用的知识,确定显式迁移是否井然有序。

使用指南

确定是否能够实现 NUMA 架构的性能优势,关键问题在于数据放置。 将数据有效放置在处理器(需要访问数据)本地内存的频率越高,该架构将越有利于缩短整体访问时间。 通过为每个节点提供其自己的本地内存,内存访问将能够避免与共享内存总线相关的吞吐限制和争用问题。事实上,从理论上来说内存受限的系统能够以完全并行化的方式访问内存,进而根据系统上的节点数据显著提升性能。

相反,不处于节点(将访问数据)本地的数据越多,该架构对内存性能造成的影响越大。 在 NUMA 模式中,通过 NUMA 模式中相邻节点检索数据所需的时间比访问本地内存所需的时间长得多。 总体来说,处理器距离越远,访问内存的成本越高。

更多资源

现代代码社区

Drepper,Ulrich。 “编程人员应了解的内存信息”。 2007 年 11 月。

英特尔® 64 和 32 位英特尔架构优化参考手册。请参阅第 8.8 节“关联性与管理共享平台资源”。 2009 年 3 月。

Lameter,Christoph。 “本地和远程内存: Linux/NUMA 系统中的内存”。 2006 年 6 月。

有关编译器优化的更完整信息,请参阅优化通知