开发内功修炼@张彦飞开发内功修炼@张彦飞

talk is cheap,
show me the code!

Linux 内核是如何感知到硬件上的 NUMA 信息的?

大家好,我是飞哥!

在 Linux 程序运行过程中,有一个对性能影响较大的特性,那就是 NUMA。在不少公司中,都通过 numactl 等命令对运行的服务进行了 NUMA 绑定,进而提高程序的运行性能。

那么我们今天来深入了解一下 NUMA 的原理。在硬件上的 NUMA 组成为什么会影响程序的运行性能,Linux 操作系统又是如何识别 NUMA 信息,来将 CPU 和内存进行分组划分 node 的。

一、NUMA 介绍

NUMA 全称是 Non-uniform memory access,是非一致性内存访问的意思。不过这段话还是由点费解,我们需要看看硬件才能更好地理解它。

现代的 CPU 都在硬件内部实现了一个内存控制器,内存条都会和这个内存控制器进行相连。

图1.webp

之前我们在 深入了解服务器 CPU 的型号、代际、片内与片间互联架构 一文中提到过,服务器 CPU 和个人 PC CPU 的一个很大的区别就是扩展性。在一台服务器的内部是支持插2/4/8等多 CPU 的。每个 CPU 都可以连接几条的内存。两个 CPU 之间如果想要访问对方上连接的内存条,中间就得跨过 UPI 总线。

图2.png

下面是一台服务器的实际内部图片。中间两个银色长方形的东东是罩着散热片的 CPU,每个 CPU 旁边都有一些内存插槽,支持插入多条内存。

图3.png

CPU 扩展性的设计极大地提升了服务器上的 CPU 核数与内存容量。但同时也带来了另外一个问题,那就是 CPU 物理核在访问不同的内存条的时候延迟是不同的。这就是非一致性内存访问的含义。

其实不仅仅是跨 CPU 访问存在延时差异。在服务器高核心 CPU 上,由于 Mesh 架构、以及存在两个内存控制器,物理核访问不同的内存控制器上的内存条也会有差异。只不过这个差异没有跨 CPU 差异大。

这种问题的出现使得 Linux 操作系统不得不关注内存访问速度不平均的问题。你在 Linux 上执行 numactl 命令可以查看你机器上的 NUMA 配置情况。拿我手头的一台虚拟机来举例。

# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3
node 0 size: 7838 MB
node 0 free: 6208 MB
node 1 cpus: 4 5 6 7
node 1 size: 7934 MB
node 1 free: 6589 MB
node distances:
node   0   1
  0:  10  20
  1:  20  10

上面的输出中展示了 Linux 把所有的 CPU 核心和内存分成了两个 node。其中 node 0 中的拥有的 CPU 核心是 0、1、2、3 这四个核,总共拥有 7838 MB 的内存。node 1 中拥有的核心是 4、5、6、7 四个核,拥有的内存是 7934 MB。

另外 node distances 这里显示了跨 node 进行内存访问时一个大概的延时差距。同 node 内部的内存访问肯定是最快的,跨 node 则相对较慢。

那么内核是如何识别到底层的 NUMA 信息的呢?

二、Linux 对 NUMA 信息的读取

2.1 Linux 内核识别如何识别内存属于哪个节点

在计算机的体系结构中,除了操作系统和硬件外,其实中间还存在着一层固件,英文名叫 firmware。它是位于主板上的使用 SPI Nor Flash 存储着的软件。起着在硬件和操作系统中间承上启下的作用。它负责着硬件自检、初始化硬件设备、加载操作系统引导程序,并提供接口将控制权转移到操作系统。

图4.png

回到我们今天的话题。那么 CPU 和内存条之间这种访问非一致性特点,Linux 就是通过固件来获得这个知识的。其中在 Linux 和固件中间的接口规范是 ACPI(Advanced Configuration and Power
Interface),高级配置和电源接口。

这是较新的 6.5 版本的文档地址: https://uefi.org/sites/default/files/resources/ACPI_Spec_6_5_Aug29.pdf。感兴趣的同学可以下载下来。

在这个接口规范中的第 17 章中描述了 NUMA 相关的内容。在 ACPI 中定义了两个表,分别是:

  • SRAT(System Resource Affinity Table)。在这个表中表示的是 CPU 核和内存的关系图。包括有几个 node,每个 node 里面有那几个 CPU 逻辑核,有哪些内存。
  • SLIT(System Locality Information Table)。在这个表中记录的是各个结点之间的距离。

有了这个规范,CPU 读取这两个表就可以获得 NUMA 系统的 CPU 及物理内存分布信息。操作系统在启动的时候会执行 start_kernel 这个核心函数,然后会调用到

//file:arch/x86/kernel/setup.c
void __init setup_arch(char **cmdline_p)
{
    ...
    // 保存物理内存检测结果
    e820__memory_setup();
    ...

    // membloc内存分配器初始化
    e820__memblock_setup();

    // 内存初始化(包括 NUMA 机制初始化)
    initmem_init();
}

在 setup_arch 中显示调用了 e820__memory_setup 来保存物理内存检测结果。然后调用 e820__memblock_setup 初始化内存分配器。详情参见Linux 内核“偷吃”了你的内存! 一文。在 initmem_init 完成了 NUMA 的初始化。

在 initmem_init 中,依次调用了 x86_numa_init、numa_init、x86_acpi_numa_init,最后执行到了 acpi_numa_init 函数中来读取 ACPI 中的 SRAT 表,获取到各个 node 中的 CPU 逻辑核、内存的分布信息。

//file:drivers/acpi/numa/srat.c
int __init acpi_numa_init(void)
{
    ...
    // 解析 SRAT 表中的 NUMA 信息
    // 具体包括:CPU_AFFINITY、MEMORY_AFFINITY 等
    if (!acpi_table_parse(ACPI_SIG_SRAT, acpi_parse_srat)) {
        ...
    }
    ...
}

在 SRAT 表读取并解析完成后,Linux 操作系统就知道了内存和 node 的关系了。numa 信息都最后保存在了 numa_meminfo 这个数据结构中,这是一个全局的列表,每一项都是(起始地址, 结束地址, 节点编号)的三元组,描述了内存块与 NUMA 节点的关联关系。

//file:arch/x86/mm/numa.c
static struct numa_meminfo numa_meminfo __initdata_or_meminfo;

//file:arch/x86/mm/numa_internal.h
struct numa_meminfo {
    int            nr_blks;
    struct numa_memblk    blk[NR_NODE_MEMBLKS];
};

2.2 memblock 分配器 关联 NUMA 信息

在此之后,Linux 就可以通过 numa_meminfo 数组来获取硬件 NUMA 信息了。前面在[]() 一文中我们提到了内核的 memblock 内存分配器。有了 numa_meminfo 数组,memblock 就可以根据这个信息读取到自己各个 region 分别是属于哪个 node 的了。

这件工作是在 numa_init 中开始的。

//file:arch/x86/mm/numa.c
static int __init numa_init(int (*init_func)(void))
{
    ...

    //2.1 把numa相关的信息保存在 numa_meminfo 中
    init_func();

    //2.2 memblock 添加 NUMA 信息,并为每个 node 申请对象
    numa_register_memblks(&numa_meminfo);

    ...
    // 用于将各个CPU core与NUMA节点关联
    numa_init_array();
    return 0;
}

在 numa_register_memblks 中完成了三件事情

  • 将每一个 memblock region 与 NUMA 节点号关联
  • 为每一个 node 都申请一个表示它的内核对象(pglist_data)
  • 再次打印 memblock 信息
//file:arch/x86/mm/numa.c
static int __init numa_register_memblks(struct numa_meminfo *mi)
{
    ...
    //1.将每一个 memblock region 与 NUMA 节点号关联
    for (i = 0; i < mi->nr_blks; i++) {
        struct numa_memblk *mb = &mi->blk[i];
        memblock_set_node(mb->start, mb->end - mb->start,
                  &memblock.memory, mb->nid);
    }
    ...
    //2.为所有可能存在的node申请pglist_data结构体空间 
    for_each_node_mask(nid, node_possible_map) {
        ...
        //为nid申请一个pglist_data结构体
        alloc_node_data(nid);
    }

    //3.打印MemBlock内存分配器的详细调试信息
    memblock_dump_all();
}

这个函数的详细逻辑就不展开了。我们来看下 memblock_dump_all。如果你开启了 memblock=debug 启动参数,在它执行完后,memblock 内存分配器的信息再次被打印了出来。

[    0.010796] MEMBLOCK configuration:
[    0.010797]  memory size = 0x00000003fff78c00 reserved size = 0x0000000003d7bd7e
[    0.010797]  memory.cnt  = 0x4
[    0.010799]  memory[0x0]    [0x0000000000001000-0x000000000009efff], 0x000000000009e000 bytes on node 0 flags: 0x0
[    0.010800]  memory[0x1]    [0x0000000000100000-0x00000000bffd9fff], 0x00000000bfeda000 bytes on node 0 flags: 0x0
[    0.010801]  memory[0x2]    [0x0000000100000000-0x000000023fffffff], 0x0000000140000000 bytes on node 0 flags: 0x0
[    0.010802]  memory[0x3]    [0x0000000240000000-0x000000043fffffff], 0x0000000200000000 bytes on node 1 flags: 0x0
[    0.010803]  reserved.cnt  = 0x7
[    0.010804]  reserved[0x0]    [0x0000000000000000-0x00000000000fffff], 0x0000000000100000 bytes on node 0 flags: 0x0
[    0.010806]  reserved[0x1]    [0x0000000001000000-0x000000000340cfff], 0x000000000240d000 bytes on node 0 flags: 0x0
[    0.010807]  reserved[0x2]    [0x0000000034f31000-0x000000003678ffff], 0x000000000185f000 bytes on node 0 flags: 0x0
[    0.010808]  reserved[0x3]    [0x00000000bffe0000-0x00000000bffe3d7d], 0x0000000000003d7e bytes on node 0 flags: 0x0
[    0.010809]  reserved[0x4]    [0x000000023fffb000-0x000000023fffffff], 0x0000000000005000 bytes flags: 0x0
[    0.010810]  reserved[0x5]    [0x000000043fff9000-0x000000043fffdfff], 0x0000000000005000 bytes flags: 0x0
[    0.010811]  reserved[0x6]    [0x000000043fffe000-0x000000043fffffff], 0x0000000000002000 bytes on node 1 flags: 0x0

不过这次不同的是,每一段内存地址范围后面都跟上了 node 的信息,例如 on node 0、on node 1 等。

三、操作系统内存识别过程总结

在刚开始操作系统启动的时候,操作系统通过 e820 读取到了内存的布局,并将它打印到了日志中。

[    0.000000] BIOS-provided physical RAM map:
[    0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable
[    0.000000] BIOS-e820: [mem 0x000000000009fc00-0x000000000009ffff] reserved
[    0.000000] BIOS-e820: [mem 0x00000000000f0000-0x00000000000fffff] reserved
[    0.000000] BIOS-e820: [mem 0x0000000000100000-0x00000000bffd9fff] usable
[    0.000000] BIOS-e820: [mem 0x00000000bffda000-0x00000000bfffffff] reserved
[    0.000000] BIOS-e820: [mem 0x00000000feff4000-0x00000000feffffff] reserved
[    0.000000] BIOS-e820: [mem 0x00000000fffc0000-0x00000000ffffffff] reserved
[    0.000000] BIOS-e820: [mem 0x0000000100000000-0x000000043fffffff] usable

接着内核创建了 memblock 内存分配器来进行系统启动时的内存管理。如果开启了 memblock=debug 启动参数,同样能把它打印出来。

[    0.010238] MEMBLOCK configuration:
[    0.010239]  memory size = 0x00000003fff78c00 reserved size = 0x0000000003c6d144
[    0.010240]  memory.cnt  = 0x3
[    0.010241]  memory[0x0]    [0x0000000000001000-0x000000000009efff], 0x000000000009e000 bytes flags: 0x0
[    0.010243]  memory[0x1]    [0x0000000000100000-0x00000000bffd9fff], 0x00000000bfeda000 bytes flags: 0x0
[    0.010244]  memory[0x2]    [0x0000000100000000-0x000000043fffffff], 0x0000000340000000 bytes flags: 0x0
[    0.010245]  reserved.cnt  = 0x4
[    0.010246]  reserved[0x0]    [0x0000000000000000-0x0000000000000fff], 0x0000000000001000 bytes flags: 0x0
[    0.010247]  reserved[0x1]    [0x00000000000f5a40-0x00000000000f5b83], 0x0000000000000144 bytes flags: 0x0
[    0.010248]  reserved[0x2]    [0x0000000001000000-0x000000000340cfff], 0x000000000240d000 bytes flags: 0x0
[    0.010249]  reserved[0x3]    [0x0000000034f31000-0x000000003678ffff], 0x000000000185f000 bytes flags: 0x0

不过到这里,Linux 操作系统还不知道内存的 NUMA 信息。它通过 ACPI 接口读取固件中的 SRAT 表,将 NUMA 信息保存到 numa_meminfo 数组中。从此,Linux 就知道了硬件上的 NUMA 信息,并对 memblock 内存分配器也设置了 node 信息。并再次将其打印了出来。这次 memblock 的每一个 region 中就都携带了 node 信息。

[    0.010796] MEMBLOCK configuration:
[    0.010797]  memory size = 0x00000003fff78c00 reserved size = 0x0000000003d7bd7e
[    0.010797]  memory.cnt  = 0x4
[    0.010799]  memory[0x0]    [0x0000000000001000-0x000000000009efff], 0x000000000009e000 bytes on node 0 flags: 0x0
[    0.010800]  memory[0x1]    [0x0000000000100000-0x00000000bffd9fff], 0x00000000bfeda000 bytes on node 0 flags: 0x0
[    0.010801]  memory[0x2]    [0x0000000100000000-0x000000023fffffff], 0x0000000140000000 bytes on node 0 flags: 0x0
[    0.010802]  memory[0x3]    [0x0000000240000000-0x000000043fffffff], 0x0000000200000000 bytes on node 1 flags: 0x0
[    0.010803]  reserved.cnt  = 0x7
[    0.010804]  reserved[0x0]    [0x0000000000000000-0x00000000000fffff], 0x0000000000100000 bytes on node 0 flags: 0x0
[    0.010806]  reserved[0x1]    [0x0000000001000000-0x000000000340cfff], 0x000000000240d000 bytes on node 0 flags: 0x0
[    0.010807]  reserved[0x2]    [0x0000000034f31000-0x000000003678ffff], 0x000000000185f000 bytes on node 0 flags: 0x0
[    0.010808]  reserved[0x3]    [0x00000000bffe0000-0x00000000bffe3d7d], 0x0000000000003d7e bytes on node 0 flags: 0x0
[    0.010809]  reserved[0x4]    [0x000000023fffb000-0x000000023fffffff], 0x0000000000005000 bytes flags: 0x0
[    0.010810]  reserved[0x5]    [0x000000043fff9000-0x000000043fffdfff], 0x0000000000005000 bytes flags: 0x0
[    0.010811]  reserved[0x6]    [0x000000043fffe000-0x000000043fffffff], 0x0000000000002000 bytes on node 1 flags: 0x0

以上就是 Linux 内存中 NUMA 机制的初始化大概过程。

总结

在现代服务器的非统一内存访问(NUMA)是一种用于多处理器硬件架构下,识别和保存每个 CPU 核和内存条之间的连接拓扑非常的重要。因为 CPU 只是和它直连的内存访问速度最快,访问和其它 CPU 连接的内存速度将会大大下降。

Linux 通过固件读取 ACPI 规范中的 SRAT 和 SLIT 表识别 NUMA 信息,在系统启动过程中,经一系列函数调用完成 NUMA 初始化,将信息保存到numa_meminfo,并使 memblock 分配器关联 NUMA 信息。最后通过 e820 读取内存布局,再结合 ACPI 获取的 NUMA 信息完成内存识别及相关设置。

当内核有了硬件 NUMA 信息的拓扑图后,我们在应用侧就可以通过 numactl 等命令来优化程序的性能了!

不过最后要补充说一点,关于 NUMA 绑定并不是有益无害。在业界也有不同的声音。比如 Oracal 的技术大咖们认为绑定 NUMA 可能在全局内存并未用尽的情况下出现内存分配错误,导致系统出现剧烈抖动。

目前我的知识星球内容更新完七大部分的视频讲解了,分别是CPU内存硬件原理、内核内存管理、内核进程管理、内核网络管理、内核文件系统管理、内核容器原理、性能观测。总共104节视频,时长累计 2272 分钟。接下来更新和大家手头工作最为密切的性能优化相关内容,敬请期待。

本原创文章未经允许不得转载 | 当前页面:开发内功修炼@张彦飞 » Linux 内核是如何感知到硬件上的 NUMA 信息的?