NUMA

相关阅读: 多处理器

CPU 术语

大多数CPU都是在二维平面上构建的.CPU还必须添加集成内存控制器.对于每个CPU核心,有四个内存总线(上,下,左,右)的简单解决方案允许完全可用的带宽,但仅此而已.CPU在很长一段时间内都停滞在4核状态.当芯片变成3D时,在上面和下面添加痕迹允许直接总线穿过对角线相反的CPU.在卡上放置一个四核CPU,然后连接到总线,这是合乎逻辑的下一步.

如今每个处理器都包含许多核心,这些核心都有一个共享的片上缓存和片外内存,并且在服务器内不同内存部分的内存访问成本是可变的. 提高数据访问效率是当前CPU设计的主要目标之一, 因此每个CPU核都被赋予了一个较小的一级缓存(32 KB)和一个较大的二级缓存(256 KB).各个核心随后共享几个MB的3级缓存,其大小随着时间的推移而大幅增长.

为了避免缓存丢失(请求不在缓存中的数据),需要花费大量的研究时间来寻找合适的CPU缓存数量,缓存结构和相应的算法. 详见 缓存一致性

一个 CPU 有如下的一些术语: socket core ucore threads

20240510153146

因此, 一个CPU Socket里可以由多个CPU Core和一个Uncore部分组成.每个CPU Core内部又可以由两个CPU Thread组成. 每个CPU thread都是一个操作系统可见的逻辑CPU.对大多数操作系统来说,一个八核HT(Hyper-Threading)打开的CPU会被识别为16个CPU

如下图所示

20240512092458

QPI(QuickPath Interconnect)是英特尔(Intel)处理器架构中使用的一种高速互联技术.它用于处理器与其他组件(如内存,I/O设备和其他处理器)之间的通信.

LLC(Last-Level Cache):LLC 是处理器架构中的最后一级缓存.在多级缓存结构中,处理器通常具有多个级别的缓存,而最后一级缓存(通常是共享的)被称为 LLC.LLC 位于处理器核心和主存之间,用于存储频繁访问的数据,以加快处理器对数据的访问速度.

NUMA系统

NUMA体系结构中多了Node的概念,这个概念其实是用来解决core的分组的问题.每个node有自己的内部CPU,总线和内存,同时还可以访问其他node内的内存,NUMA的最大的优势就是可以方便的增加CPU的数量.NUMA系统中,内存的划分是根据物理内存模块和内存控制器的布局来确定的

image

在Intel x86平台上:

与本地内存一样,所谓本地IO资源,就是CPU可以经过Uncore部件里的PCIe Root Complex直接访问到的IO资源. 如果是非本地IO资源,则需要经过QPI链路到该IO资源所属的CPU,再通过该CPU PCIe Root Complex访问. 如果同一个NUMA Node内的CPU和内存和另外一个NUMA Node的IO资源发生互操作,因为要跨越QPI链路, 会存在额外的访问延迟问题

曾经在Intel IvyBridge的NUMA平台上做的内存访问性能测试显示,远程内存访问的延时时本地内存的一倍.

一个NUMA Node内部是由一个物理CPU和它所有的本地内存, 本地IO资源组成的. 通常一个 Socket 有一个 Node,也有可能一个 Socket 有多个 Node.

root@kamilu:~$ sudo apt install numactl

root@kamilu:~$ numactl -H
available: 3 nodes (0-2)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 24 25 26 27 28 29 30 31 32 33 34 35
node 0 size: 31819 MB
node 0 free: 10283 MB
node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23 36 37 38 39 40 41 42 43 44 45 46 47
node 1 size: 32193 MB
node 1 free: 26965 MB
node 2 cpus:
node 2 size: 16384 MB
node 2 free: 16375 MB
node distances:
node   0   1   2
  0:  10  21  24
  1:  21  10  14
  2:  24  14  10

可以使用 numactl 来控制运行该程序的 CPU 和内存节点, 例如指定程序的内存只能使用 NUMA 节点 1 的内存,而 CPU 则绑定在 NUMA 节点 0 上可以使用

numactl --membind=1 --cpunodebind=0 ./a

--membind 参数严格地限制内存分配在指定的节点上不同, 当使用 --preferred 参数时,操作系统将优先尝试在指定的 NUMA 节点上分配内存,但如果该节点的内存不足,它会在其他节点上分配内存.

numactl --preferred=1 ./my_program

NUMA互联

在Intel x86上,NUMA Node之间的互联是通过 QPI(QuickPath Interconnect) Link的. CPU的Uncore部分有QPI的控制器来控制CPU到QPI的数据访问, 下图就是一个利用 QPI Switch 互联的 8 NUMA Node 的 x86 系统

numa-imc-iio-qpi-switch-3

NUMA亲和性

NUMA Affinity(亲和性)是和NUMA Hierarchy(层级结构)直接相关的.对系统软件来说, 以下两个概念至关重要

中断处理例程(Interrupt Service Routine, ISR)用于响应硬件中断事件,尽快地处理中断并进行必要的操作. 当一个设备或外部事件触发了一个硬件中断,CPU会中断当前执行的任务,并跳转到相应的ISR来处理中断

半部机制(SoftIRQ)是一种延迟处理机制,用于处理与中断相关的一些非关键,耗时较长的任务. SoftIRQ不是由硬件中断触发,而是在上下文切换,网络数据包处理,定时器等事件发生时,由内核调度执行的

Firmware接口

由于NUMA的亲和性对应用的性能非常重要,那么硬件平台就需要给操作系统提供接口机制来感知硬件的NUMA层级结构. 在x86平台,ACPI规范提供了以下接口来让操作系统来检测系统的NUMA层级结构.

ACPI(Advanced Configuration and Power Interface)是一种开放标准,旨在为操作系统和硬件之间提供统一的接口,以实现高级配置和电源管理功能, 包括ACPI表, 系统电源管理, 系统配置和资源管理, 事件处理, 系统配置表和命名空间. ACPI规范的广泛应用使得不同的操作系统和硬件厂商能够以一致的方式进行交互,提高了系统的兼容性和可移植性

ACPI 5.0a规范的第17章是有关NUMA的章节.ACPI规范里,NUMA Node被第9章定义的Module Device所描述. ACPI规范里用Proximity Domain(接近性域)对NUMA Node做了抽象,两者的概念大多时候等同.

node 初始化

下面我们结合 linux 代码来看一下上面提到的 numa node 初始化的过程, 从几条启动日志开始说起

20230705155010

通过第一条日志顺藤摸瓜可以找到如下的函数

/**
 * dummy_numa_init - Fallback dummy NUMA init
 *
 * Used if there's no underlying NUMA architecture, NUMA initialization
 * fails, or NUMA is disabled on the command line.
 *
 * Must online at least one node and add memory blocks that cover all
 * allowed memory.  This function must not fail.
 */
// arch/x86/mm/numa.c
static int __init dummy_numa_init(void)
{
    printk(KERN_INFO "%s\n",
           numa_off ? "NUMA turned off" : "No NUMA configuration found");
    /* max_pfn是e820探测到的最大物理内存页,其初始化是max_pfn = e820__end_of_ram_pfn() */
    printk(KERN_INFO "Faking a node at [mem %#018Lx-%#018Lx]\n",
           0LLU, PFN_PHYS(max_pfn) - 1);
    /* 一个nodemask_t是 位图, 最多支持MAX_NUMNODES个node
     * 这里将node 0置位
     */
    node_set(0, numa_nodes_parsed);
    /* 将node 0的起始和结束地址记录起来 */
    numa_add_memblk(0, 0, PFN_PHYS(max_pfn));

    return 0;
}

int __init numa_add_memblk(int nid, u64 start, u64 end)
{
    return numa_add_memblk_to(nid, start, end, &numa_meminfo);
}

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

struct numa_memblk {
    u64         start;
    u64         end;
    int         nid;
};

代码和注释写的很清晰, 当没有 NUMA 架构或者 NUMA 架构被禁止的时候, Linux为了适配两者,将 UMA "假装"成一种NUMA架构,也就只有一个node 0节点,该节点包括所有物理内存. numa_nodes_parsed 为 NUMA 节点的位图, 每一个bit代表一个node,node_set是将一个node设置为"在线".

numa_add_memblk 就是将一个 node 加入到 numa_meminfo, 并设置内存地址的范围. numa_meminfo, numa_memblk 的结构体也比较清晰

PFN_PHYS(max_pfn) 宏用于获取探测到的最大物理内存页的范围

numa_add_memblk_to 逻辑也比较简单, 就是先做一些配置上的判断, 然后结构体对应元素赋值

static int __init numa_add_memblk_to(int nid, u64 start, u64 end,
                     struct numa_meminfo *mi)
{
    /* ignore zero length blks */
    if (start == end)
        return 0;

    /* whine about and ignore invalid blks */
    if (start > end || nid < 0 || nid >= MAX_NUMNODES) {
        pr_warn("Warning: invalid memblk node %d [mem %#010Lx-%#010Lx]\n",
            nid, start, end - 1);
        return 0;
    }

    if (mi->nr_blks >= NR_NODE_MEMBLKS) {
        pr_err("too many memblk ranges\n");
        return -EINVAL;
    }

    mi->blk[mi->nr_blks].start = start;
    mi->blk[mi->nr_blks].end = end;
    mi->blk[mi->nr_blks].nid = nid;
    mi->nr_blks++;
    return 0;
}

整个系统的函数调用栈如下, numa_init 中也可以看出, 无论是否有 NUMA, 区别只是对所传递的函数指针的调用的差别而已. 当没有 numa 时进入 dummy_numa_init 完成初始化

// arch/x86/kernel/setup.c | setup_arch
// arch/x86/mm/numa_64.c   | initmem_init
// arch/x86/mm/numa.c      | x86_numa_init
// arch/x86/mm/numa.c      | numa_init

void __init x86_numa_init(void)
{
    if (!numa_off) {
#ifdef CONFIG_ACPI_NUMA
        if (!numa_init(x86_acpi_numa_init))
            return;
#endif
#ifdef CONFIG_AMD_NUMA
        if (!numa_init(amd_numa_init))
            return;
#endif
    }
    numa_init(dummy_numa_init);
}

NUMA 启动

如果以前文 调试内核 中的参数启动那么就是一个非常简单的系统, 如果以一个比较复杂的参数启动 qemu

taskset -c 0-15 $(QEMU) -name guest=vm0 \
    -machine pc \
    -m 64G \
    -overcommit mem-lock=off \
    -smp 16 \
    -object memory-backend-ram,size=16G,host-nodes=0,policy=bind,prealloc=no,id=m0 \
    -object memory-backend-ram,size=16G,host-nodes=1,policy=bind,prealloc=no,id=m1 \
    -object memory-backend-ram,size=16G,host-nodes=2,policy=bind,prealloc=no,id=m2 \
    -object memory-backend-ram,size=16G,host-nodes=3,policy=bind,prealloc=no,id=m3 \
    -numa node,nodeid=0,memdev=m0,cpus=0-7 \
    -numa node,nodeid=1,memdev=m1,cpus=8-15 \
    -numa node,nodeid=2,memdev=m2 \
    -numa node,nodeid=3,memdev=m3 \
    -numa dist,src=0,dst=0,val=10 \
    -numa dist,src=0,dst=1,val=21 \
    -numa dist,src=0,dst=2,val=24 \
    -numa dist,src=0,dst=3,val=24 \
    -numa dist,src=1,dst=0,val=21 \
    -numa dist,src=1,dst=1,val=10 \
    -numa dist,src=1,dst=2,val=14 \
    -numa dist,src=1,dst=3,val=14 \
    -numa dist,src=2,dst=0,val=24 \
    -numa dist,src=2,dst=1,val=14 \
    -numa dist,src=2,dst=2,val=10 \
    -numa dist,src=2,dst=3,val=16 \
    -numa dist,src=3,dst=0,val=24 \
    -numa dist,src=3,dst=1,val=14 \
    -numa dist,src=3,dst=2,val=16 \
    -numa dist,src=3,dst=3,val=10 \
    -uuid 9bc02bdb-58b3-4bb0-b00e-313bdae0ac81 \
    -device ich9-usb-ehci1,id=usb,bus=pci.0,addr=0x5.0x7 \
    -device virtio-serial-pci,id=virtio-serial0,bus=pci.0,addr=0x6 \
    -drive file=$(DISK),format=raw,id=drive-ide0-0-0,if=none \
    -device ide-hd,bus=ide.0,unit=0,drive=drive-ide0-0-0,id=ide0-0-0,bootindex=1 \
    -drive if=none,id=drive-ide0-0-1,readonly=on \
    -device ide-cd,bus=ide.0,unit=1,drive=drive-ide0-0-1,id=ide0-0-1 \
    -device virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x7 \
    -netdev user,id=ndev.0,hostfwd=tcp::5555-:22 \
    -device e1000,netdev=ndev.0 \
    -nographic \
    -kernel $(BZIMAGE) \
    -append "root=/dev/sda2 console=ttyS0 quiet nokaslr"

[!NOTE] NOTE
上述参数表示创建了 4 个 numa node, 每个 node 16 GB 内存, 2 个 node 有 cpu.

分配了 16 个物理机的 CPU 核心, 分配个 qemu, 2 个有 cpu 的 node 每个分到 8 个核心

手动定义了节点之间的 numa distance

我们可以得到如下所示的 numa 架构

20241013170335

当有 numa 架构时(CONFIG_ACPI_NUMA)则会进入 x86_acpi_numa_init 处理

void __init x86_numa_init(void)
{
    if (!numa_off) {
#ifdef CONFIG_ACPI_NUMA
        if (!numa_init(x86_acpi_numa_init))
            return;
#endif
#ifdef CONFIG_AMD_NUMA
        if (!numa_init(amd_numa_init))
            return;
#endif
    }
    numa_init(dummy_numa_init);
}

numa_init 中首先为每一个 numa node 分配一个编号, 然后将一个记录全局 numa distance 信息的数组清空, 调用函数指针初始化

static int numa_distance_cnt;
static u8 *numa_distance;

static int __init numa_init(int (*init_func)(void))
{
    int i;
    int ret;
    // 为每一个 node 分配一个编号 0 1 ...
    for (i = 0; i < MAX_LOCAL_APIC; i++)
        set_apicid_to_node(i, NUMA_NO_NODE);
    // ...
    // 设置 numa_distance[] -> 0
    numa_reset_distance();

    ret = init_func();
    if (ret < 0)
        return ret;
    // ...
}

我们重点关注一下这些节点是如何识别 id 并且如何计算距离的, 其中确定距离的函数 numa_set_distance 调用如下

[!NOTE] NOTE
上文我们介绍了 SRAT SLIT 的初始化, 这里就是 SLIT 对于 numa node 距离的初始化

// x86_acpi_numa_init [arch/x86/mm/srat.c]
// └─acpi_numa_init [drivers/acpi/numa/srat.c]
//   └─acpi_table_parse [drivers/acpi/tables.c]
//     └─acpi_parse_slit [drivers/acpi/numa/srat.c]
//       └─acpi_numa_slit_init [drivers/acpi/numa/srat.c]
void __init acpi_numa_slit_init(struct acpi_table_slit *slit)
{
    int i, j;

    for (i = 0; i < slit->locality_count; i++) {
        const int from_node = pxm_to_node(i);

        if (from_node == NUMA_NO_NODE)
            continue;

        for (j = 0; j < slit->locality_count; j++) {
            const int to_node = pxm_to_node(j);

            if (to_node == NUMA_NO_NODE)
                continue;

            numa_set_distance(from_node, to_node,
                slit->entry[slit->locality_count * i + j]);
        }
    }
}

其中这里的 slit->entry 数组就是我们通过 qemu 传入的参数, 这个参数会在内核启动过程中获取到

20241013150009

如果没有提供 distance 参数就会调用 numa_alloc_distance 默认初始化

void __init numa_set_distance(int from, int to, int distance)
{
    if (!numa_distance && numa_alloc_distance() < 0)
        return;
    numa_distance[from * numa_distance_cnt + to] = distance;
}

numa_alloc_distance 中只是简单的初始化了这个数组(一维数组模拟二维数组), 将同节点之间的距离设置为 10, 不同节点距离为 20

10/20 是 ACPI 标准规定

#define LOCAL_DISTANCE  10
#define REMOTE_DISTANCE 20

static int __init numa_alloc_distance(void)
{
    numa_distance = __va(phys);
    numa_distance_cnt = cnt;

    /* fill with the default distances */
    for (i = 0; i < cnt; i++)
        for (j = 0; j < cnt; j++)
            numa_distance[i * cnt + j] = i == j ?
                LOCAL_DISTANCE : REMOTE_DISTANCE;
    printk(KERN_DEBUG "NUMA: Initialized distance table, cnt=%d\n", cnt);

    return 0;
}

20241013173554

参考

zood