NUMA 内存策略

什么是 NUMA 内存策略?

在 Linux 内核中,“内存策略”决定了内核在 NUMA 系统或模拟 NUMA 系统中从哪个节点分配内存。Linux 自 2.4.? 版本以来一直支持非统一内存访问架构的平台。当前的内存策略支持于 2004 年 5 月左右添加到 Linux 2.6 中。本文档旨在描述 2.6 内存策略支持的概念和 API。

内存策略不应与 cpusets(Documentation/admin-guide/cgroup-v1/cpusets.rst)混淆,cpusets 是一种管理机制,用于限制一组进程可以从中分配内存的节点。内存策略是一种 NUMA 感知应用程序可以利用的编程接口。当 cpuset 和策略都应用于一个任务时,cpuset 的限制优先。有关更多详细信息,请参阅下面的内存策略和 cpusets

内存策略概念

内存策略范围

Linux 内核支持内存策略的 _范围_,此处从最通用到最具体进行描述

系统默认策略

此策略“硬编码”在内核中。它管理所有不受下面讨论的更具体策略范围控制的页分配。当系统“正常运行”时,系统默认策略将使用下面描述的“本地分配”。然而,在启动过程中,系统默认策略将被设置为在所有具有“足够”内存的节点上交错分配,以避免在启动时分配过多,从而使初始启动节点过载。

任务/进程策略

这是一种可选的、按任务的策略。当为特定任务定义时,此策略控制由任务或代表任务进行的所有不受更具体范围控制的页分配。如果任务未定义任务策略,则所有原本应受任务策略控制的页分配将“回退”到系统默认策略。

任务策略适用于任务的整个地址空间。因此,它可以在 fork() [不带 CLONE_VM 标志的 clone()] 和 exec*() 调用中继承。这允许父任务为从对内存策略一无所知的可执行映像 exec() 出来的子任务建立任务策略。有关任务可用于设置/更改其任务/进程策略的系统调用的概述,请参阅下面的内存策略 API 部分。

在多线程任务中,任务策略仅适用于安装策略的线程(Linux 内核任务)以及该线程随后创建的任何线程。在新任务策略安装时存在的任何同级线程会保留其当前策略。

任务策略仅适用于策略安装后分配的页。当任务更改其任务策略时,任何已由任务发生缺页的页仍保留在其最初根据分配时策略分配的位置。

VMA 策略

“VMA”或“虚拟内存区域”是指任务虚拟地址空间的一个范围。任务可以为其虚拟地址空间的一个范围定义特定策略。有关用于设置 VMA 策略的 mbind() 系统调用的概述,请参阅下面的内存策略 API 部分。

VMA 策略将管理支持此地址空间区域的页分配。任务地址空间中没有明确 VMA 策略的任何区域将回退到任务策略,而任务策略本身可能会回退到系统默认策略。

VMA 策略有一些复杂细节

  • VMA 策略仅适用于匿名页。这包括为匿名段(如任务栈和堆)分配的页,以及使用 MAP_ANONYMOUS 标志 mmap() 的地址空间的任何区域。如果 VMA 策略应用于文件映射,并且该映射使用了 MAP_SHARED 标志,则 VMA 策略将被忽略。如果文件映射使用了 MAP_PRIVATE 标志,VMA 策略仅在尝试写入映射时分配匿名页时(即,在写时复制时)应用。

  • VMA 策略在所有共享虚拟地址空间的任务(即线程)之间共享,无论策略何时安装;它们也通过 fork() 继承。然而,由于 VMA 策略引用任务地址空间的特定区域,并且地址空间在 exec*() 时被丢弃和重新创建,因此 VMA 策略不能通过 exec() 继承。因此,只有 NUMA 感知应用程序才能使用 VMA 策略。

  • 任务可以在先前 mmap() 的区域的子范围内安装新的 VMA 策略。当发生这种情况时,Linux 会将现有虚拟内存区域拆分为 2 或 3 个 VMA,每个 VMA 都有自己的策略。

  • 默认情况下,VMA 策略仅适用于策略安装后分配的页。任何已发生缺页到 VMA 范围内的页仍保留在其最初根据分配时策略分配的位置。然而,自 2.6.16 版本以来,Linux 通过 mbind() 系统调用支持页迁移,因此页内容可以移动以匹配新安装的策略。

共享策略

从概念上讲,共享策略适用于映射到一个或多个任务不同地址空间的“共享内存对象”。应用程序安装共享策略的方式与 VMA 策略相同——使用 mbind() 系统调用指定映射共享对象的虚拟地址范围。然而,与 VMA 策略(可被视为任务地址空间某个范围的属性)不同,共享策略直接应用于共享对象。因此,所有连接到该对象的任务都共享该策略,并且任何任务为该共享对象分配的所有页都将遵守该策略。

截至 2.6.22 版本,只有通过 shmget() 或 mmap(MAP_ANONYMOUS|MAP_SHARED) 创建的共享内存段支持共享策略。当 Linux 添加共享策略支持时,相关数据结构被添加到了 hugetlbfs 共享内存段中。当时,hugetlbfs 不支持在缺页时进行分配(即延迟分配),因此 hugetlbfs 共享内存段从未“连接”到共享策略支持。尽管 hugetlbfs 段现在支持延迟分配,但其对共享策略的支持尚未完成。

如上文VMA 策略部分所述,使用 MAP_SHARED 标志 mmap() 的常规文件的页缓存页分配会忽略安装在由共享文件映射支持的虚拟地址范围上的任何 VMA 策略。相反,共享页缓存页(包括尚未由任务写入的私有映射所支持的页)遵循任务策略(如果有),否则遵循系统默认策略。

共享策略基础设施支持对共享对象的子集范围应用不同策略。然而,Linux 仍然会为每个不同策略的范围拆分安装策略的任务的 VMA。因此,连接到共享内存段的不同任务可以具有映射该共享对象的不同 VMA 配置。当一个任务在一个或多个区域范围内安装了共享策略时,可以通过检查共享内存区域的任务的 /proc/<pid>/numa_maps 来看到这一点。

内存策略组成部分

NUMA 内存策略由“模式”、可选模式标志和可选的节点集组成。模式决定策略的行为,可选模式标志决定模式的行为,可选的节点集可以被视为策略行为的参数。

在内部,内存策略由一个引用计数结构 struct mempolicy 实现。此结构的详细信息将在下文根据解释行为的需要进行讨论。

NUMA 内存策略支持以下行为模式

默认模式--MPOL_DEFAULT

此模式仅在内存策略 API 中使用。在内部,MPOL_DEFAULT 在所有策略范围中都转换为 NULL 内存策略。当指定 MPOL_DEFAULT 时,任何现有的非默认策略都将被简单地移除。因此,MPOL_DEFAULT 意味着“回退到下一个最具体的策略范围。”

例如,NULL 或默认任务策略将回退到系统默认策略。NULL 或默认 VMA 策略将回退到任务策略。

当在内存策略 API 中指定时,默认模式不使用可选的节点集。

为该策略指定的节点集非空是错误的。

MPOL_BIND (绑定模式)

此模式指定内存必须来自策略指定的节点集。内存将从该集中具有足够可用内存且最接近分配发生节点的节点进行分配。

MPOL_PREFERRED (首选模式)

此模式指定应尝试从策略中指定的单个节点进行分配。如果该分配失败,内核将根据平台固件提供的信息,按照与首选节点距离递增的顺序搜索其他节点。

在内部,首选策略使用单个节点——struct mempolicy 的 preferred_node 成员。当内部模式标志 MPOL_F_LOCAL 设置时,preferred_node 被忽略,并且策略被解释为本地分配。“本地”分配策略可以看作是一种从包含发生分配的 CPU 所在节点开始的首选策略。

用户可以通过传递一个空节点掩码来指定始终首选本地分配。如果传递空节点掩码,则该策略不能使用下面描述的 MPOL_F_STATIC_NODES 或 MPOL_F_RELATIVE_NODES 标志。

MPOL_INTERLEAVED (交错模式)

此模式指定页分配以页粒度在策略中指定的节点间交错进行。此模式在不同使用上下文中行为也略有不同

对于匿名页和共享内存页的分配,交错模式使用缺页地址在包含该地址的段 [VMA] 中的页偏移量,对策略指定的节点集进行索引,再对策略指定的节点数取模。然后,它尝试从选定节点开始分配一个页,就好像该节点已由首选策略指定或已由本地分配选中一样。也就是说,分配将遵循每个节点的 zonelist。

对于页缓存页的分配,交错模式使用每个任务维护的节点计数器来索引策略指定的节点集。此计数器在达到最高指定节点后会回绕到最低指定节点。这倾向于根据页的分配顺序而不是基于地址范围或文件的任何页偏移量,将页分散到策略指定的节点上。在系统启动期间,临时交错系统默认策略在此模式下工作。

MPOL_PREFERRED_MANY (多首选模式)

此模式指定分配应优先从策略中指定的节点掩码中满足。如果节点掩码中的所有节点都存在内存压力,则分配可以回退到所有现有 NUMA 节点。这实际上是 MPOL_PREFERRED 允许用于掩码而不是单个节点的情况。

MPOL_WEIGHTED_INTERLEAVE (加权交错模式)

此模式的操作与 MPOL_INTERLEAVE 相同,不同之处在于交错行为是根据 /sys/kernel/mm/mempolicy/weighted_interleave/ 中设置的权重执行的。

加权交错根据权重在节点上分配页。例如,如果节点 [0,1] 的权重分别为 [5,2],则每当 node1 上分配 2 个页时,node0 上将分配 5 个页。

NUMA 内存策略支持以下可选模式标志

MPOL_F_STATIC_NODES

此标志指定,如果任务或 VMA 的允许节点集在内存策略定义后发生更改,则用户传递的节点掩码不应重新映射。

没有此标志,任何时候内存策略因允许节点集的变化而重新绑定时,首选节点掩码 (Preferred Many)、首选节点 (Preferred) 或节点掩码 (Bind, Interleave) 都会被重新映射到新的允许节点集。这可能导致使用先前不希望使用的节点。

使用此标志,如果用户指定的节点与任务 cpuset 允许的节点重叠,则内存策略将应用于它们的交集。如果两组节点不重叠,则使用默认策略。

例如,考虑一个任务,它附加到一个包含内存节点 1-3 的 cpuset,并在此同一节点集上设置了交错策略。如果 cpuset 的内存节点更改为 3-5,则交错现在将在节点 3、4 和 5 上发生。然而,使用此标志,由于用户节点掩码中只允许节点 3,“交错”仅在该节点上发生。如果用户节点掩码中的任何节点现在都不允许使用,则使用默认行为。

MPOL_F_STATIC_NODES 不能与 MPOL_F_RELATIVE_NODES 标志结合使用。它也不能用于使用空节点掩码(本地分配)创建的 MPOL_PREFERRED 策略。

MPOL_F_RELATIVE_NODES

此标志指定用户传递的节点掩码将相对于任务或 VMA 的允许节点集进行映射。内核存储用户传递的节点掩码,如果允许节点发生变化,则原始节点掩码将相对于新的允许节点集重新映射。

没有此标志(且没有 MPOL_F_STATIC_NODES),任何时候内存策略因允许节点集的变化而重新绑定时,节点 (Preferred) 或节点掩码 (Bind, Interleave) 都会被重新映射到新的允许节点集。该重新映射可能无法在连续重新绑定时保留用户传递的节点掩码与其允许节点集的相对性质:如果允许节点集恢复到其原始状态,节点掩码 1,3,5 可能会被重新映射到 7-9,然后再映射到 1-3。

使用此标志,重新映射完成后,用户传递的节点掩码中的节点编号将相对于允许的节点集。换句话说,如果用户节点掩码中设置了节点 0、2 和 4,则策略将作用于允许节点集中的第一个(在 Bind 或 Interleave 情况下,是第三个和第五个)节点。用户传递的节点掩码表示相对于任务或 VMA 允许节点集的节点。

如果用户节点掩码包含超出新允许节点集范围的节点(例如,当允许节点集仅为 0-3 时,用户节点掩码中设置了节点 5),则重新映射将回绕到节点掩码的开头,如果尚未设置,则在内存策略节点掩码中设置该节点。

例如,考虑一个任务,它附加到一个包含内存节点 2-5 的 cpuset,并在此同一节点集上使用 MPOL_F_RELATIVE_NODES 设置了交错策略。如果 cpuset 的内存节点更改为 3-7,则交错现在将在节点 3、5-7 上发生。如果 cpuset 的内存节点随后更改为 0、2-3、5,则交错将在节点 0、2-3、5 上发生。

由于一致的重新映射,使用此标志准备节点掩码以指定内存策略的应用程序应忽略其当前实际由 cpuset 施加的内存位置,并假定它们始终位于内存节点 0 到 N-1 上来准备节点掩码,其中 N 是策略旨在管理的内存节点数。然后让内核重新映射到任务 cpuset 允许的内存节点集,因为这可能会随时间而变化。

MPOL_F_RELATIVE_NODES 不能与 MPOL_F_STATIC_NODES 标志结合使用。它也不能用于使用空节点掩码(本地分配)创建的 MPOL_PREFERRED 策略。

内存策略引用计数

为解决使用/释放竞争,struct mempolicy 包含一个原子引用计数字段。内部接口 mpol_get()/mpol_put() 分别递增和递减此引用计数。mpol_put() 仅当引用计数归零时才将结构释放回 mempolicy kmem 缓存。

当分配新的内存策略时,其引用计数被初始化为“1”,表示安装新策略的任务所持有的引用。当内存策略结构的指针存储在另一个结构中时,会添加另一个引用,因为任务的引用将在策略安装完成后被丢弃。

在策略的运行时“使用”期间,我们尝试最小化对引用计数的原子操作,因为这可能导致缓存行在 CPU 和 NUMA 节点之间跳动。这里的“使用”意味着以下之一:

  1. 查询策略,无论是通过任务本身 [使用下面讨论的 get_mempolicy() API] 还是通过另一个任务使用 /proc/<pid>/numa_maps 接口。

  2. 检查策略以确定页分配的策略模式以及任何关联的节点或节点列表。这被认为是“热路径”。请注意,对于 MPOL_BIND,“使用”范围扩展到整个分配过程,该过程可能在页回收期间休眠,因为 BIND 策略节点掩码通过引用用于过滤不合格的节点。

我们可以按如下方式避免在上述使用期间额外增加引用:

  1. 我们永远不需要获取/释放系统默认策略,因为系统一旦启动并运行,它就不会被更改或释放。

  2. 为了查询策略,我们不需要对目标任务的任务策略或 VMA 策略进行额外引用,因为我们在查询期间总是以读模式获取任务 mm 的 mmap_lock。set_mempolicy() 和 mbind() API(见下文)在安装或替换任务或 VMA 策略时总是以写模式获取 mmap_lock。因此,不可能出现一个任务或线程在另一个任务或线程查询策略时释放策略的情况。

  3. 任务或 VMA 策略的页分配使用发生在缺页路径中,我们在该路径中以读模式持有 mmap_lock。同样,因为替换任务或 VMA 策略需要以写模式持有 mmap_lock,所以当我们在进行页分配时,策略不会在我们使用它时被释放。

  4. 共享策略需要特殊考虑。一个任务可以替换共享内存策略,而另一个任务(具有独立的 mmap_lock)正在根据该策略查询或分配页。为了解决这种潜在的竞争,共享策略基础设施在查找期间,在持有共享策略管理结构的自旋锁的同时,向共享策略添加了一个额外引用。这要求我们在“使用”完策略后丢弃此额外引用。我们必须在与非共享策略相同的查询/分配路径中丢弃共享策略上的额外引用。因此,共享策略被如此标记,并且额外引用是“有条件地”丢弃的——即,仅针对共享策略。

    由于这种额外的引用计数,以及我们必须在自旋锁下以树形结构查找共享策略,因此共享策略在页分配路径中使用成本更高。对于由在不同 NUMA 节点上运行的任务共享的共享内存区域上的共享策略尤其如此。可以通过始终回退到共享内存区域的任务或系统默认策略,或者通过将整个共享内存区域预先调入内存并锁定来避免这种额外开销。然而,这可能不适用于所有应用程序。

内存策略 API

Linux 支持 4 个系统调用来控制内存策略。这些 API 始终只影响调用任务、调用任务的地址空间或映射到调用任务地址空间中的某些共享对象。

注意

定义这些 API 和用户空间应用程序参数数据类型的头文件位于一个不属于 Linux 内核的软件包中。带有“sys_”前缀的内核系统调用接口定义在 <linux/syscalls.h> 中;模式和标志定义在 <linux/mempolicy.h> 中。

设置 [任务] 内存策略

long set_mempolicy(int mode, const unsigned long *nmask,
                                unsigned long maxnode);

将调用任务的“任务/进程内存策略”设置为由“mode”参数指定的模式和由“nmask”定义的节点集。“nmask”指向一个包含至少“maxnode”个节点 ID 的位掩码。可以通过将“mode”参数与标志组合来传递可选模式标志(例如:MPOL_INTERLEAVE | MPOL_F_STATIC_NODES)。

有关更多详细信息,请参阅 set_mempolicy(2) 手册页

获取 [任务] 内存策略或相关信息

long get_mempolicy(int *mode,
                   const unsigned long *nmask, unsigned long maxnode,
                   void *addr, int flags);

根据“flags”参数,查询调用任务的“任务/进程内存策略”,或指定虚拟地址的策略或位置。

有关更多详细信息,请参阅 get_mempolicy(2) 手册页

为任务地址空间范围安装 VMA/共享策略

long mbind(void *start, unsigned long len, int mode,
           const unsigned long *nmask, unsigned long maxnode,
           unsigned flags);

mbind() 将由 (mode, nmask, maxnodes) 指定的策略作为 VMA 策略安装到由“start”和“len”参数指定的调用任务地址空间范围内。可以通过“flags”参数请求附加操作。

有关更多详细信息,请参阅 mbind(2) 手册页。

为任务地址空间范围设置主节点

long sys_set_mempolicy_home_node(unsigned long start, unsigned long len,
                                 unsigned long home_node,
                                 unsigned long flags);

sys_set_mempolicy_home_node 为任务地址范围内存在的 VMA 策略设置主节点。该系统调用仅更新现有内存策略范围的主节点。其他地址范围将被忽略。主节点是页分配将从中获取的最近的 NUMA 节点。指定主节点会覆盖默认分配策略,使内存分配接近执行 CPU 的本地节点。

内存策略命令行接口

虽然不严格属于 Linux 内存策略实现的一部分,但存在一个命令行工具 numactl(8),它允许用户

  • 通过 set_mempolicy(2)、fork(2) 和 exec(2) 为指定程序设置任务策略

  • 通过 mbind(2) 为共享内存段设置共享策略

numactl(8) 工具与包含内存策略系统调用封装的库的运行时版本一起打包。一些发行版将头文件和编译时库打包在单独的开发包中。

内存策略和 cpusets

内存策略在 cpusets 中工作,如上所述。对于需要一个或一组节点的内存策略,节点被限制在 cpuset 约束允许其内存的节点集中。如果为策略指定的节点掩码包含 cpuset 不允许的节点且未使用 MPOL_F_RELATIVE_NODES,则使用为策略指定的节点集与具有内存的节点集的交集。如果结果为空集,则该策略被视为无效,无法安装。如果使用 MPOL_F_RELATIVE_NODES,策略的节点将映射并折叠到任务允许的节点集中,如前所述。

当两个 cpusets 中的任务共享访问一个内存区域(例如通过 shmget() 或使用 MAP_ANONYMOUS 和 MAP_SHARED 标志的 mmap() 创建的共享内存段),并且任何任务在该区域上安装共享策略时,内存策略和 cpusets 的交互可能会出现问题。在这种情况下,只有在两个 cpusets 中都允许其内存的节点才能用于策略。获取此信息需要“跳出”内存策略 API 来使用 cpuset 信息,并且需要知道其他任务可能连接到共享区域的 cpuset。此外,如果 cpusets 允许的内存集是分离的,“本地”分配是唯一有效的策略。