图片无法显示,请右键点击新窗口打开图片

在 Linux 和其他操作系统中,UMA(Uniform Memory Access)NUMA(Non-Uniform Memory Access) 都是与内存访问模型和多处理器系统设计相关的术语。这两个术语描述的是处理器如何访问系统中的内存区域以及相关的性能特性, 上图大致描述了 UMA 和 NUMA 之间的差异,总结如下:

  • UMA(Uniform Memory Access): UMA 是一种内存架构,其中所有处理器访问内存所需的时间是均匀的,无论内存是哪一部分. 处理器共享同一内存资源,并且每次访问的延迟都是相同的. 缺点是当多个处理器尝试同时访问内存时,可能会导致内容竞争,从而限制系统的可扩展性.
  • NUMA(Non-Uniform Memory Access): NUMA 是一种内存架构,针对多处理器系统设计,其中处理器访问内存的时间取决于内存所在的位置. 在 NUMA 架构中,系统内存被划分为多个节点,每个节点与一个或多个处理器相关联. 当处理器访问其本地节点的内存时,访问速度最快。但是访问其他节点(非本地或远程节点)的内存时,访问速度会变慢. 这种架构的主要优势在于,它能更好地扩展到具有大量处理器的系统,并尽量减少内存访问的竞争状况.

在 NUMA 架构里,正常情况下 CPU 优先分配本地(LOCAL)节点上的内存,但在内存紧缺或者特殊需求场景下,CPU 也可以分配远端(REMOTE)节点的内存,这样做的好处是解决内存压力,但降低内存访问的性能.

图片无法显示,请右键点击新窗口打开图片

在 Linux 中,NUMA Balancing 是一个内核特性,用于自动改善多处理器系统上的应用程序性能,特别是在 NUMA 架构上。这一特性旨在确保内存分配和进程调度与 NUMA 架构保持一致,以便获得最佳的性能. NUMA Balancing 机制一般会做以下几个操作:

  • 巡检: 在进程创建的时候,NUMA Balancing 机制会为每个进程启动一个定时任务, 该任务会扫描进程里跨 NUMA 的物理页
  • 标记: 巡检过程中,NUMA Balancing 机制会对跨 NUMA 物理页进行标记,将其标记为 PROTNONE,这里不是 PROT_NONE, 被标记的内存没有读写权限.
  • 替换: 当进程访问被标记的内存会触发缺页,缺页异常处理函数识别出被标记的页之后,将其迁移(Migrate)到本地(LOCAL)节点上,并恢复正常的页表属性.

图片无法显示,请右键点击新窗口打开图片

NUMA Balancing 机制一个重要任务就是将跨 NUMA Page 对应的页表修改为 PROTNONE,这里的 PROTNONE 是一种特殊的页表,对应的匿名内存即不可读也不可写,与 PROT_NONE 不同的是,PROTNONE 是将原先的 PTE Entry 分作两部分,高于 BIT12 的字段存储物理页帧的反码,低于 BIT12 的字段则将 BIT8 置位,其余全部清零,因此缺页异常处理函数首先判断 _PAGE_PRESENT 清零,然后判断 BIT8(_PAGE_PROTNONE) 置位,那么知道该 Entry 对应着 NUMA Balancing 的页表,因此接下来的逻辑就按 NUMA Balancing Page Fault 进行处理.

图片无法显示,请右键点击新窗口打开图片

上图为 NUMA Balancing 机制的运作逻辑,其为每个进程维护一个 NUMA Balancing 的内核线程,该内核线程通过多个 sysctl 变量进行控制,当 NUMA Balancing 内核线程被唤醒,其将执行 task_numa_work 函数,该函数会检查进程地址空间中那些跨 NUMA 的区域,然后获得对应的 PTE Entry,并调用 pte_modify 函数将页表修改为 PROTNONE. 在不同版本 Linux 里,四个 sysctl 有的导到 sysctl 系统工具里,但有的内核没有导出,开发者可以修改下面几个变量控制任务的执行:

  • numa_balancing_scan_period_min_ms: 以毫秒为单位扫描任务虚拟内存的最小时间, 它有效地控制了每个任务的最大扫描速率.
  • numa_balancing_scan_delay_ms: 任务初始分叉时使用的起始“扫描延迟
  • numa_balancing_scan_period_max_ms: 以毫秒为单位扫描任务虚拟内存的最大时间, 它有效地控制了每个任务的最小扫描速率.
  • numa_balancing_scan_size_mb: 为给定的扫描扫描的页面价值多少兆字节.

结合起来,扫描延迟扫描大小确定了扫描速率。当扫描延迟减小时,扫描速率增加。扫描延迟和因此每个任务的扫描速率都是自适应的,并取决于历史行为。如果页面被正确放置,那么扫描延迟增加,否则扫描延迟减小。 扫描大小不是自适应的,但是扫描大小越大,扫描速率就越高。更高的扫描速率会导致更高的系统开销,因为必须捕获页面错误,并且可能需要迁移数据。但是扫描速率越高,如果工作负载模式发生变化,任务内存就会更快地迁移到本地节点,从而减小由于远程内存访问而引起的性能影响。这些 sysctl 参数控制了扫描延迟和扫描页面数量的阈值.

NUMA Balancing 与 PageFault

NUMA Balancing 机制会将正常的页表修改为 PROTNONE,这个属性的虚拟区域既不能写也不能读,是一个特殊的区域。当进程访问 PROTNONE 的虚拟内存时,MMU 检查到物理内存不存在而触发缺页异常,在缺页异常处理函数里是如何辨识 NUMA Balancing 引起的缺页? 其实很简单,只需通过页表就可以判断,如果页表是 PROTNONE 的,那么缺页异常处理函数就按 NUMA Balancing 缺页处理.

图片无法显示,请右键点击新窗口打开图片

PROTNONE 分别有 PTE Entry 和 PMD Entry 两种版本,其都是将低 12 位全部清零,然后将 G(BIT8) 置位,然后将原先物理页的页帧号的反码存储在 MSB 部分. 当缺页异常函数使用 pte_protnone 和 pmd_protnone 就可以识别.

图片无法显示,请右键点击新窗口打开图片

对于 NUMA Balancing 引起的缺页,缺页异常处理函数的处理逻辑是将 REMOTE NODE 上的物理页迁移到 LOCAL NODE 上,并将页表更新到 LOCAL NODE 上的物理页。Linux 上支持 NUMA Balancing 的内存只有匿名内存和匿名透明大页内存,其处理细节可能存在差异,那么接下来通过实际的案例进行了解:

图片无法显示,请右键点击新窗口打开图片


匿名内存 NUMA Balancing 缺页场景

图片无法显示,请右键点击新窗口打开图片

NUMA Balancing 机制一个重要任务就是将跨 NUMA Page 对应的页表修改为 PROTNONE,这里的 PROTNONE 是一种特殊的页表,对应的匿名内存即不可读也不可写,与 PROT_NONE 不同的是,PROTNONE 是将原先的 PTE Entry 分作两部分,高于 BIT12 的字段存储物理页帧的反码,低于 BIT12 的字段则将 BIT8 置位,其余全部清零,因此缺页异常处理函数首先判断 _PAGE_PRESENT 清零,然后判断 BIT8(_PAGE_PROTNONE) 置位,那么知道该 Entry 对应着 NUMA Balancing 的页表,因此接下来的逻辑就按 NUMA Balancing Page Fault 进行处理.

图片无法显示,请右键点击新窗口打开图片

上图为 NUMA Balancing 机制的运作逻辑,其为每个进程维护一个 NUMA Balancing 的内核线程,该内核线程通过多个 sysctl 变量进行控制,当 NUMA Balancing 内核线程被唤醒,其将执行 task_numa_work 函数,该函数会检查进程地址空间中那些跨 NUMA 的区域,然后获得对应的 PTE Entry,并调用 pte_modify 函数将页表修改为 PROTNONE. 在不同版本 Linux 里,四个 sysctl 有的导到 sysctl 系统工具里,但有的内核没有导出,开发者可以修改下面几个变量控制任务的执行:

  • numa_balancing_scan_period_min_ms: 以毫秒为单位扫描任务虚拟内存的最小时间, 它有效地控制了每个任务的最大扫描速率.
  • numa_balancing_scan_delay_ms: 任务初始分叉时使用的起始“扫描延迟
  • numa_balancing_scan_period_max_ms: 以毫秒为单位扫描任务虚拟内存的最大时间, 它有效地控制了每个任务的最小扫描速率.
  • numa_balancing_scan_size_mb: 为给定的扫描扫描的页面价值多少兆字节.

结合起来,扫描延迟扫描大小确定了扫描速率。当扫描延迟减小时,扫描速率增加。扫描延迟和因此每个任务的扫描速率都是自适应的,并取决于历史行为。如果页面被正确放置,那么扫描延迟增加,否则扫描延迟减小。 扫描大小不是自适应的,但是扫描大小越大,扫描速率就越高。更高的扫描速率会导致更高的系统开销,因为必须捕获页面错误,并且可能需要迁移数据。但是扫描速率越高,如果工作负载模式发生变化,任务内存就会更快地迁移到本地节点,从而减小由于远程内存访问而引起的性能影响。这些 sysctl 参数控制了扫描延迟和扫描页面数量的阈值。经过对 NUMA Balancing 机制的研究,理论上已经知道其如何工作了,接下来通过一个实践案例进一步了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下(实践之前需要打开内核 CONFIG_NUM-A_BALANCING 宏):

cd BiscuitOS
make menuconfig
  
  [*] DIY BiscuitOS/Broiler Hardware  --->
      (numa_balancing=disable) CMDLINE on Kernel
      [*] Support NUMA Topology
  [*] Package  --->
      [*] Paging Mechanism  --->
          [*] Page Fault with Anonymous on NUMA --->

# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-NUMA-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build

BiscuitOS-PAGING-PF-ANON-NUMA-default Source Code on Gitee

图片无法显示,请右键点击新窗口打开图片

实践案例由一个应用程序构成,其分作三部分,首先是 30 行调用 mmap 函数结合 MAP_PRIVATE 和 MAP_ANONYMOUS 分配一段可读可写的匿名内存,并在 40 行对匿名内存执行写操作. 第二部分是利用 libnuma 提供的库函数,从 43-54 行构造一个跨 NUMA 的匿名页. 最后是第三部分,程序在 57 行再次对匿名内存执行写操作,此时会再次触发缺页并被识别为 NUMA Balancing Page Fault. 以上便是一个最基础的实践案例,可以知道 40 行读操作和 57 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 57 行前后加上 BS_DEBUG 开关:

图片无法显示,请右键点击新窗口打开图片 图片无法显示,请右键点击新窗口打开图片 图片无法显示,请右键点击新窗口打开图片

接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_numa_page 函数的 4698 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,另外为了加速调试进度,需要修改 “kernel/sched/fair.c” 文件中 sysctl 变量的值,这里全部设置为 0,接下来执行如下命令进行实践:

# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-NUMA-default/
# 编译内核
make kernel
# 安装 libnuma
make prepare
# 编程程序
make build

图片无法显示,请右键点击新窗口打开图片

当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “NUMA Balancing AnonMEM on do_numa_page 0x6000000000”, 说明缺页异常处理函数执行过 NUMA Balancing PROTNONE 缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.

图片无法显示,请右键点击新窗口打开图片

对于 NUMA Balancing 引起的缺页,缺页异常处理函数根据 PROTNONE 进行区分,一旦确认是 NUMA Balancing Page Fault, 那么进入 “NUMA-PAGE” 分支进行处理.

图片无法显示,请右键点击新窗口打开图片

对于 “NUMA-PAGE” 分支核心处理函数是 do_numa_page 函数,由于该函数长度太长,这里只做部分解析,函数首先获得 PTE Entry 的内容,由于此时还是 PROTNONE 格式,因此函数在 4712 行调用 pte_modify() 函数将 PROTNONE 内容转换成一个正常的 PTE Entry,该 PTE Entry 页表属性字段来自 VMA 提供的 vm_page_prot, 页帧字段则来自 PROTNONE 高 BIT12 字段的反码. 有了 PFN 信息之后,函数在 4714 行调用 vm_normal_page 函数获得对应的匿名页.

图片无法显示,请右键点击新窗口打开图片

do_numa_page 函数在获得匿名页之后,此时在 4741 行调用 page_to_nid 函数获得匿名页所在 NUMA NODE 信息,然后调用 numa_migrate_prep 函数获得进程 LOCAL NUMA NODE 信息,此时 page_nid 和 target_nid 不相等,那么接下来 4751 行调用 migrate_misplaced_page 函数将匿名页迁移到 LOCAL NUMA NODE 上,迁移过程就是在目标 NUMA NODE 上分配一个新的物理页,然后将内容拷贝到新物理页上.

图片无法显示,请右键点击新窗口打开图片

do_numa_page 函数迁移完毕之后,在 4774-4779 行调用相关的函数更新页表,使页表映射到新的物理页上,此时新的物理页变成匿名页. 至此 NUMA Balancing Page Fault 主要处理逻辑已经完成,待缺页异常处理函数返回之后,进程可以继续访问 LOCAL NUMA NODE 的匿名页,且不会再触发缺页.

图片无法显示,请右键点击新窗口打开图片


匿名透明大页内存 NUMA Balancing 缺页场景

图片无法显示,请右键点击新窗口打开图片

NUMA Balancing 机制一个重要任务就是将跨 NUMA Page 对应的页表修改为 PROTNONE,这里的 PROTNONE 是一种特殊的页表,对应的匿名透明大页内存即不可读也不可写,与 PROT_NONE 不同的是,PROTNONE 是将原先的 PMD Entry 分作两部分,高于 BIT12 的字段存储物理页帧的反码,低于 BIT12 的字段则将 BIT8 置位,其余全部清零,因此缺页异常处理函数首先判断 _PAGE_PRESENT 清零,然后判断 BIT8(_PAGE_PROTNONE) 置位,那么知道该 Entry 对应着 NUMA Balancing 的页表,因此接下来的逻辑就按 NUMA Balancing HUGE Page Fault 进行处理.

图片无法显示,请右键点击新窗口打开图片

上图为 NUMA Balancing 机制的运作逻辑,其为每个进程维护一个 NUMA Balancing 的内核线程,该内核线程通过多个 sysctl 变量进行控制,当 NUMA Balancing 内核线程被唤醒,其将执行 task_numa_work 函数,该函数会检查进程地址空间中那些跨 NUMA 的区域,然后获得对应的 PTE Entry,并调用 pte_modify 函数将页表修改为 PROTNONE. 在不同版本 Linux 里,四个 sysctl 有的导到 sysctl 系统工具里,但有的内核没有导出,开发者可以修改下面几个变量控制任务的执行:

  • numa_balancing_scan_period_min_ms: 以毫秒为单位扫描任务虚拟内存的最小时间, 它有效地控制了每个任务的最大扫描速率.
  • numa_balancing_scan_delay_ms: 任务初始分叉时使用的起始“扫描延迟
  • numa_balancing_scan_period_max_ms: 以毫秒为单位扫描任务虚拟内存的最大时间, 它有效地控制了每个任务的最小扫描速率.
  • numa_balancing_scan_size_mb: 为给定的扫描扫描的页面价值多少兆字节.

结合起来,扫描延迟扫描大小确定了扫描速率。当扫描延迟减小时,扫描速率增加。扫描延迟和因此每个任务的扫描速率都是自适应的,并取决于历史行为。如果页面被正确放置,那么扫描延迟增加,否则扫描延迟减小。 扫描大小不是自适应的,但是扫描大小越大,扫描速率就越高。更高的扫描速率会导致更高的系统开销,因为必须捕获页面错误,并且可能需要迁移数据。但是扫描速率越高,如果工作负载模式发生变化,任务内存就会更快地迁移到本地节点,从而减小由于远程内存访问而引起的性能影响。这些 sysctl 参数控制了扫描延迟和扫描页面数量的阈值。经过对 NUMA Balancing 机制的研究,理论上已经知道其如何工作了,接下来通过一个实践案例进一步了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下(实践之前需要打开内核 CONFIG_NUM-A_BALANCING 宏):

cd BiscuitOS
make menuconfig
  
  [*] DIY BiscuitOS/Broiler Hardware  --->
      (numa_balancing=disable) CMDLINE on Kernel
      [*] Support NUMA Topology
  [*] Package  --->
      [*] Paging Mechanism  --->
          [*] Huge PageFault with NUMA Balancing Anonymous THP --->

# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-HUGE-PF-NUMA-ANON-THP-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build

BiscuitOS-PAGING-HUGE-PF-NUAM-ANON-THP-default Source Code on Gitee

图片无法显示,请右键点击新窗口打开图片

实践案例由一个应用程序构成,其分作三部分,首先是 30 行调用 mmap 函数结合 MAP_PRIVATE 和 MAP_ANONYMOUS 分配一段可读可写的匿名透明大页内存,并在 40 行对匿名内存执行写操作. 第二部分是利用 libnuma 提供的库函数,从 43-54 行构造一个跨 NUMA 的匿名透明大页. 最后是第三部分,程序在 57 行再次对匿名透明大页内存执行写操作,此时会再次触发缺页并被识别为 NUMA Balancing Huge Page Fault. 以上便是一个最基础的实践案例,可以知道 40 行读操作和 57 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 57 行前后加上 BS_DEBUG 开关:

图片无法显示,请右键点击新窗口打开图片 图片无法显示,请右键点击新窗口打开图片 图片无法显示,请右键点击新窗口打开图片

接着在匿名透明大页内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_huge_pmd_numa_page 函数的 1489 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,另外为了加速调试进度,需要修改 “kernel/sched/fair.c” 文件中 sysctl 变量的值,这里全部设置为 0,接下来执行如下命令进行实践:

# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-HUGE-PF-NUMA-ANON-THP-default/
# 编译内核
make kernel
# 安装 libnuma
make prepare
# 编程程序
make build

图片无法显示,请右键点击新窗口打开图片

当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “NUMA Balancing ANON-THP on do_huge_pmd_numa_page 0x0x6000000000”, 说明缺页异常处理函数执行过 NUMA Balancing PROTNONE 缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.

图片无法显示,请右键点击新窗口打开图片

对于 HUGE Page 场景下 NUMA Balancing 引起的缺页,缺页异常出来函数在 __handle_mm_fault 函数里判断发生缺页的内存是匿名透明大页,然后检查到页表是 PROT_NONE 并且 VMA 可以访问,那么这些条件符合 NUMA Balancing Huge Fault 的场景,于是调用 do_huge_pmd_numa_page 函数进行处理,该函数的主要目的是将匿名透明大页迁移(Migration) 到指定的 NUMA NODE 上,并更新页表指向新的匿名透明大页.

图片无法显示,请右键点击新窗口打开图片

do_huge_pmd_numa_page 函数是 NUMA Balancing HUGE Fault 的核心处理函数,其主要任务是将匿名透明大页迁移到指定的 NUMA NODE. 函数在迁移大页之前在 1495 行调用 pmd_modify 函数修改页表,然后在 1496 调用 vm_normal_page_pmd 函数获得匿名透明大页,1504-1512 检查目的 NUMA NODE 是否合理,不合理则跳转到 out_map 处。接着函数在 1516 行调用 migrate_misplaced_page 函数进行透明大页迁移,最后返回 0.

图片无法显示,请右键点击新窗口打开图片