什么是 ‘NUMA-aware Scheduling’?如何确保进程的代码执行与其访问的内存处于同一个物理 CPU 槽位?

各位专家同仁,

欢迎来到今天的技术讲座。我们将深入探讨一个在现代高性能计算领域至关重要的话题:NUMA-aware Scheduling,即NUMA(Non-Uniform Memory Access,非一致性内存访问)感知的调度。随着多核处理器技术的飞速发展,处理器核心数量呈指数级增长,但内存访问速度的提升却相对缓慢,这使得内存墙问题日益突出。为了缓解这一瓶颈,NUMA架构应运而生,但它也带来了新的挑战。

理解并有效利用NUMA架构,对于优化大规模并发应用、提升系统性能至关重要。作为编程专家,我们不仅要了解其概念,更要掌握如何在实践中确保进程的代码执行与其访问的内存能够高效地处于同一个物理CPU槽位(即NUMA节点),从而最大限度地发挥硬件潜能。

一、 NUMA架构的兴起与核心概念

在深入NUMA感知调度之前,我们首先需要回顾一下NUMA架构产生的背景及其基本构成。

1.1 从SMP到NUMA:背景与驱动力

在早期的多处理器系统中,对称多处理器(Symmetric Multiprocessing, SMP)架构占据主导地位。在SMP系统中,所有CPU共享同一个内存控制器和内存总线,对所有内存的访问延迟是相同的。这简化了编程模型,但随着CPU核心数量的增加,共享内存总线很快成为性能瓶颈。所有核心争抢有限的内存带宽,导致内存访问延迟急剧增加,L3缓存未命中后去主内存的开销变得无法忍受。

为了突破SMP架构的瓶颈,NUMA架构应运而生。其核心思想是将系统内存划分为多个物理区域,每个区域与一组CPU核心绑定,形成一个独立的“NUMA节点”。每个NUMA节点拥有自己的本地内存和内存控制器。

1.2 NUMA架构的基本构成

一个典型的NUMA系统由多个NUMA节点组成。每个节点通常包含:

  • 一组CPU核心: 这些核心可以直接、快速地访问该节点的本地内存。
  • 本地内存: 与该节点CPU核心直接相连的内存模块。
  • 内存控制器: 管理该节点本地内存的访问。
  • 互联模块: 允许该节点上的CPU访问其他节点的内存(远程内存)。

下图简化地展示了NUMA架构:

NUMA Node 0 NUMA Node 1
CPU 0, CPU 1 CPU 2, CPU 3
L3 Cache 0 L3 Cache 1
Local Memory 0 Local Memory 1
Memory Controller 0 Memory Controller 1
互联总线 (e.g., Intel QPI/UPI, AMD Infinity Fabric)

1.3 本地与远程内存访问的代价

NUMA架构的关键特性在于“非一致性内存访问”。这意味着:

  • 本地内存访问: CPU访问其所在NUMA节点的内存,延迟最低,带宽最高。这是最佳情况。
  • 远程内存访问: CPU访问其他NUMA节点的内存,需要通过互联总线进行数据传输。这会导致显著更高的延迟和更低的有效带宽。延迟可能比本地访问高出数倍甚至一个数量级。

这种访问代价的差异是NUMA感知调度的根本驱动力。如果一个进程在Node 0上运行,但频繁访问Node 1上的数据,那么其性能将受到严重影响。

二、 未感知NUMA带来的性能挑战

在没有NUMA感知的情况下,操作系统调度器和内存分配器可能做出次优的决策,导致严重的性能问题。

2.1 典型场景:跨节点内存访问

考虑一个多线程应用程序,它创建了大量数据结构。

  1. 场景一:默认内存分配策略

    • 在Linux中,默认的内存分配策略通常是“First Touch”:哪个CPU核心第一次访问某个内存页面,这个页面就会被分配到该CPU核心所在的NUMA节点。
    • 假设一个主线程在Node 0上运行,它初始化了一个巨大的数组。这个数组的内存很可能被分配到Node 0。
    • 随后,应用程序启动了多个工作线程,其中一些线程被调度到Node 1上执行。
    • 当Node 1上的线程尝试访问Node 0上的数组数据时,它们将进行远程内存访问,性能大幅下降。
  2. 场景二:调度器漂移

    • 即使初始时进程及其数据都位于同一个NUMA节点,操作系统调度器也可能为了负载均衡或其他原因,将进程(或其线程)迁移到另一个NUMA节点上。
    • 一旦进程在新的节点上运行,它仍然会继续访问原来节点上的数据,从而导致远程访问。

2.2 性能影响分析

跨NUMA节点的内存访问会带来多方面的性能损失:

  • 高延迟: 数据需要通过互联总线传输,导致访问延迟增加。
  • 低带宽: 互联总线的带宽通常低于本地内存总线,限制了数据吞吐量。
  • 缓存效率降低: 远程访问会增加L3缓存的未命中率,因为数据可能不在当前节点的L3缓存中。
  • 总线竞争: 多个节点同时进行远程访问会加剧互联总线的竞争,进一步降低性能。

这些因素共同作用,可能导致应用程序的整体性能下降20%到50%甚至更高,尤其是在内存密集型和并发度高的应用中。

三、 NUMA-aware Scheduling:核心思想与目标

NUMA-aware Scheduling的核心思想是:将进程(或线程)调度到与其主要内存数据所在的NUMA节点相同的CPU核心上执行,并尽可能确保其所需的内存也在该节点上进行分配。

其主要目标包括:

  • 最小化远程内存访问: 这是最重要的目标,通过将计算和数据尽可能地“绑定”在同一个NUMA节点上实现。
  • 最大化缓存局部性: 当数据和计算在同一个节点时,数据更有可能停留在该节点的L3缓存中,提高缓存命中率。
  • 降低互联总线压力: 减少跨节点的数据传输,释放互联总线的带宽,使其可以服务于真正需要跨节点通信的场景。
  • 提升整体系统吞吐量: 通过减少延迟和提高效率,使系统能够处理更多的任务。

实现NUMA感知调度,需要操作系统、应用程序和开发者协同工作。操作系统层提供机制,应用程序层利用这些机制,而开发者则需要深入理解并恰当应用。

四、 操作系统层面的NUMA感知支持

现代操作系统,特别是Linux,已经内置了强大的NUMA感知调度和内存管理机制。

4.1 Linux内核调度器的NUMA感知

Linux的Completely Fair Scheduler (CFS) 具备一定的NUMA感知能力。

  • CPU亲和性启发式: CFS会尝试将进程调度到上次运行的CPU上,这间接有助于保持NUMA局部性。
  • NUMA平衡(NUMA Balancing): Linux内核2.6.38引入了自动NUMA平衡功能。当kernel.numa_balancing参数启用(通常默认启用)时,内核会主动监控进程的内存访问模式。如果发现一个进程频繁地远程访问内存,内核会尝试将该进程迁移到数据所在的NUMA节点,或者将远程访问的内存页面迁移到进程所在的NUMA节点。这通过页错误处理和软NUMA迁移机制实现。
    • 可以通过 /proc/sys/kernel/numa_balancing 查看或修改此参数。
    • echo 1 > /proc/sys/kernel/numa_balancing 启用。

4.2 Linux内核内存管理器的NUMA策略

Linux内存管理器提供了多种NUMA内存分配策略,可以通过API或工具进行设置。

  • First Touch (默认): 前面提到,哪个CPU第一次访问页面,页面就分配到那个NUMA节点。对于新分配的内存,如果没有明确指定策略,通常由发起分配的CPU决定。
  • Preferred (MPOL_PREFERRED): 优先从指定节点分配内存。如果指定节点内存不足,则可以从其他节点分配。
  • Bind (MPOL_BIND): 严格绑定到指定的NUMA节点列表。内存只能从这些节点中分配。如果这些节点内存不足,则分配失败。
  • Interleave (MPOL_INTERLEAVE): 将内存页面轮流地、均匀地分配到指定的NUMA节点列表上。这对于所有节点上的CPU都需要访问相同数据集的场景非常有用,可以分散内存压力。
  • Local (MPOL_LOCAL): 优先从当前线程执行的NUMA节点分配内存。这在线程迁移时可能会导致内存迁移。

这些策略可以通过 set_mempolicy() 系统调用应用于整个进程,或通过 mbind() 系统调用应用于特定的内存区域。

4.3 numad守护进程

numad是一个用户空间守护进程,它可以在后台持续监控系统上的NUMA拓扑、内存使用和CPU负载,并尝试动态地调整进程的CPU亲和性和内存分配策略,以优化NUMA性能。它通常用于服务器环境,以实现更细粒度的NUMA优化。

五、 确保代码执行与内存共置的实践方法

作为编程专家,我们有多种工具和技术来手动或半自动地确保进程的代码执行与内存访问处于同一个NUMA节点。

5.1 CPU亲和性设置 (Process/Thread Affinity)

将进程或线程绑定到特定的CPU核心集合(通常是同一个NUMA节点内的核心),是实现NUMA感知调度的第一步。

A. 使用 taskset 命令

taskset 是一个命令行工具,用于设置或检索进程的CPU亲和性。

  • 查看进程亲和性:

    taskset -p <PID>

    示例输出:pid 1234's current affinity list: 0-3 (表示可以在CPU 0到3上运行)

  • 启动新程序并设置亲和性:

    # 在CPU 0-15 上运行 my_application
    taskset -c 0-15 ./my_application
  • 修改正在运行进程的亲和性:

    # 将PID 1234的进程亲和性设置为CPU 0-7
    taskset -p -c 0-7 1234

B. 使用 sched_setaffinity 系统调用 (C/C++)

在C/C++程序中,可以通过 sched_setaffinity 系统调用来编程设置线程或进程的CPU亲和性。

#define _GNU_SOURCE // Required for CPU_SET, CPU_ZERO, etc.
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

void set_cpu_affinity(int cpu_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_id, &cpuset);

    if (sched_setaffinity(0, sizeof(cpu_set_t), &cpuset) == -1) {
        perror("sched_setaffinity failed");
        exit(EXIT_FAILURE);
    }
    printf("Process/thread affinity set to CPU %dn", cpu_id);
}

int main() {
    // 获取当前进程的PID
    pid_t pid = getpid();
    printf("Current PID: %dn", pid);

    // 假设我们想将当前进程/线程绑定到CPU 0
    // 在NUMA系统中,CPU 0通常属于Node 0
    set_cpu_affinity(0);

    // 验证亲和性是否设置成功
    cpu_set_t current_cpuset;
    CPU_ZERO(&current_cpuset);
    if (sched_getaffinity(0, sizeof(cpu_set_t), &current_cpuset) == -1) {
        perror("sched_getaffinity failed");
        exit(EXIT_FAILURE);
    }

    printf("Current affinity list for PID %d: ", pid);
    for (int i = 0; i < CPU_SETSIZE; i++) {
        if (CPU_ISSET(i, &current_cpuset)) {
            printf("%d ", i);
        }
    }
    printf("n");

    // 模拟一些工作
    for (long i = 0; i < 1000000000; i++) {
        // Do some computation
    }
    printf("Work finished.n");

    return 0;
}

编译与运行:
gcc -o affinity_test affinity_test.c
./affinity_test

C. OpenMP/MPI 中的亲和性设置

  • OpenMP: OpenMP 提供了 OMP_PROC_BIND 环境变量来控制线程的亲和性。

    • export OMP_PROC_BIND=trueexport OMP_PROC_BIND=spreadexport OMP_PROC_BIND=close
    • OMP_PLACES 环境变量可以指定线程可以绑定的物理单元(例如,coressockets)。
    • 例如:export OMP_PLACES=cores OMP_PROC_BIND=close ./my_openmp_app
  • MPI: 大多数MPI实现(如OpenMPI、MPICH)都提供了参数来控制MPI进程的放置和亲和性。

    • mpirun --map-by core --bind-to core ./my_mpi_app
    • mpirun --map-by socket --bind-to socket ./my_mpi_app
    • mpirun --map-by numa --bind-to numa ./my_mpi_app (OpenMPI)

5.2 内存分配策略设置 (Memory Allocation Policies)

仅仅设置CPU亲和性是不够的,还需要确保进程访问的内存也位于同一个NUMA节点。

A. 使用 numactl 命令

numactl 是一个强大的命令行工具,用于查询NUMA拓扑,并使用特定的NUMA策略运行程序。

  • 查询NUMA拓扑:

    numactl --hardware

    示例输出:

    available: 2 nodes (0-1)
    node 0 cpus: 0 1 2 3
    node 0 size: 16298 MB
    node 0 free: 15300 MB
    node 1 cpus: 4 5 6 7
    node 1 size: 16383 MB
    node 1 free: 15400 MB
    node distances:
    node   0   1
      0:  10  21
      1:  21  10

    这里的 node distances 表示了从一个节点访问另一个节点的相对延迟,10是本地访问,21是远程访问。

  • 启动新程序并设置CPU亲和性与内存策略:

    # 在Node 0上运行 my_application,并将其内存绑定到Node 0
    numactl --cpunodebind=0 --membind=0 ./my_application
    
    # 在Node 1上运行 my_application,内存分配到Node 1
    numactl --cpunodebind=1 --membind=1 ./my_application
    
    # 将内存交错分配到Node 0和Node 1,CPU在Node 0运行
    numactl --cpunodebind=0 --interleave=0,1 ./my_application

B. 使用 set_mempolicymbind 系统调用 (C/C++)

在C/C++程序中,可以编程控制内存分配策略。

  • set_mempolicy() 设置当前进程(或线程)的默认内存分配策略。此策略将影响后续的 mallocnewmmap 分配。
#define _GNU_SOURCE
#include <numa.h> // For NUMA_NO_NODE, NUMA_NUM_NODES
#include <numaif.h> // For set_mempolicy, mbind, MPOL_*
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>

// Helper to print memory policy
void print_mem_policy() {
    int mode;
    unsigned long nodemask[1];
    unsigned int max_node = 0; // The highest node ID to consider

    // Get number of online nodes
    if (numa_available() != -1) {
        max_node = numa_max_node();
    }

    if (get_mempolicy(&mode, nodemask, max_node + 1, 0, 0) == 0) {
        printf("Current memory policy: ");
        switch (mode) {
            case MPOL_DEFAULT: printf("MPOL_DEFAULT"); break;
            case MPOL_PREFERRED: printf("MPOL_PREFERRED"); break;
            case MPOL_BIND: printf("MPOL_BIND"); break;
            case MPOL_INTERLEAVE: printf("MPOL_INTERLEAVE"); break;
            case MPOL_LOCAL: printf("MPOL_LOCAL"); break;
            default: printf("UNKNOWN (%d)", mode); break;
        }
        printf(", nodemask: %lun", nodemask[0]);
    } else {
        perror("get_mempolicy failed");
    }
}

int main() {
    if (numa_available() == -1) {
        fprintf(stderr, "NUMA not available on this system.n");
        return EXIT_FAILURE;
    }

    printf("NUMA nodes available: %dn", numa_max_node() + 1);

    // 假设我们要将进程的内存分配策略设置为绑定到Node 0
    int node_id = 0;
    struct bitmask *nodemask = numa_bitmask_alloc(numa_max_node() + 1);
    if (!nodemask) {
        perror("numa_bitmask_alloc failed");
        return EXIT_FAILURE;
    }
    numa_bitmask_setbit(nodemask, node_id);

    // 设置内存策略为MPOL_BIND到Node 0
    if (set_mempolicy(MPOL_BIND, nodemask->maskp, nodemask->size) == -1) {
        perror("set_mempolicy MPOL_BIND failed");
        numa_bitmask_free(nodemask);
        return EXIT_FAILURE;
    }
    printf("Process memory policy set to MPOL_BIND to Node %d.n", node_id);
    print_mem_policy();

    numa_bitmask_free(nodemask);

    // 分配一些内存,这些内存现在应该会尝试从Node 0分配
    size_t mem_size = 1024 * 1024 * 16; // 16 MB
    void *data = malloc(mem_size);
    if (!data) {
        perror("malloc failed");
        return EXIT_FAILURE;
    }
    printf("Allocated 16MB memory via malloc. It should be on Node %d.n", node_id);

    // 第一次访问这块内存,触发页面分配
    memset(data, 0, mem_size);

    // 验证内存分配节点(需要其他工具或 /proc/<pid>/numa_maps)
    // 例如,使用numastat -p <pid> 可以在程序运行后查看内存分布

    // 恢复默认策略
    if (set_mempolicy(MPOL_DEFAULT, NULL, 0) == -1) {
        perror("set_mempolicy MPOL_DEFAULT failed");
        return EXIT_FAILURE;
    }
    printf("Memory policy restored to MPOL_DEFAULT.n");
    print_mem_policy();

    free(data);
    return 0;
}

编译与运行:
gcc -o mempolicy_test mempolicy_test.c -lnuma
./mempolicy_test
numactl --membind=0 ./mempolicy_test (可以对比效果)

  • mbind() 精确控制特定内存区域的NUMA策略。这比 set_mempolicy 更灵活,因为它只影响指定的内存范围,而不是整个进程。
#define _GNU_SOURCE
#include <numaif.h>
#include <numa.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/mman.h> // For mmap

int main() {
    if (numa_available() == -1) {
        fprintf(stderr, "NUMA not available on this system.n");
        return EXIT_FAILURE;
    }

    // 分配一个页面对齐的内存区域
    size_t mem_size = 1024 * 1024; // 1 MB
    void *addr = mmap(NULL, mem_size, PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (addr == MAP_FAILED) {
        perror("mmap failed");
        return EXIT_FAILURE;
    }
    printf("Allocated 1MB memory at address %pn", addr);

    int target_node = 1; // 假设我们想将这块内存绑定到Node 1
    struct bitmask *nodemask = numa_bitmask_alloc(numa_max_node() + 1);
    if (!nodemask) {
        perror("numa_bitmask_alloc failed");
        munmap(addr, mem_size);
        return EXIT_FAILURE;
    }
    numa_bitmask_setbit(nodemask, target_node);

    // 将这块内存绑定到Node 1
    if (mbind(addr, mem_size, MPOL_BIND, nodemask->maskp, nodemask->size, 0) == -1) {
        perror("mbind failed");
        numa_bitmask_free(nodemask);
        munmap(addr, mem_size);
        return EXIT_FAILURE;
    }
    printf("Memory range %p - %p bound to Node %d.n", addr, (char*)addr + mem_size, target_node);

    numa_bitmask_free(nodemask);

    // 第一次访问这块内存,触发页面分配并绑定
    memset(addr, 0, mem_size);

    // 模拟一些工作
    for (long i = 0; i < 100000000; i++) {
        // Access some data in 'addr'
        ((char*)addr)[i % mem_size] = (char)i;
    }
    printf("Work finished. Data accessed on bound memory.n");

    munmap(addr, mem_size);
    return 0;
}

编译与运行:
gcc -o mbind_test mbind_test.c -lnuma
./mbind_test

C. libnuma

libnuma 库提供了一系列方便的函数,用于NUMA相关的操作,是 set_mempolicymbind 等系统调用的高级封装。

#include <numa.h> // libnuma functions
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    if (numa_available() == -1) {
        fprintf(stderr, "NUMA not available on this system.n");
        return EXIT_FAILURE;
    }

    printf("NUMA nodes available: %dn", numa_max_node() + 1);

    // 直接在Node 0上分配内存
    int target_node = 0;
    size_t mem_size = 1024 * 1024 * 10; // 10 MB
    void *data = numa_alloc_onnode(mem_size, target_node);
    if (!data) {
        perror("numa_alloc_onnode failed");
        return EXIT_FAILURE;
    }
    printf("Allocated 10MB memory on Node %d at address %pn", target_node, data);

    // 第一次访问这块内存,触发页面分配
    memset(data, 0, mem_size);

    // 模拟一些工作
    for (long i = 0; i < 100000000; i++) {
        ((char*)data)[i % mem_size] = (char)i;
    }
    printf("Work finished. Data accessed on Node %d memory.n", target_node);

    // 释放内存
    numa_free(data, mem_size);
    printf("Memory freed.n");

    // 也可以使用 numa_alloc_interleaved 来交错分配
    void *interleaved_data = numa_alloc_interleaved(mem_size);
    if (!interleaved_data) {
        perror("numa_alloc_interleaved failed");
        return EXIT_FAILURE;
    }
    printf("Allocated 10MB memory interleaved at address %pn", interleaved_data);
    memset(interleaved_data, 0, mem_size);
    numa_free(interleaved_data, mem_size);
    printf("Interleaved memory freed.n");

    return 0;
}

编译与运行:
gcc -o libnuma_test libnuma_test.c -lnuma
./libnuma_test

5.3 应用程序设计考虑

除了操作系统和库提供的工具,应用程序自身的架构设计也对NUMA性能有着决定性影响。

  • 数据分区与局部性:
    • 将大型数据集按照NUMA节点进行分区。每个工作线程或任务处理其本地NUMA节点上的数据。
    • 例如,在矩阵乘法中,可以将矩阵划分为子块,每个NUMA节点负责计算一部分子块,并将其所需的输入数据和输出结果存储在本地。
  • 线程池与NUMA节点映射:
    • 为每个NUMA节点创建一个独立的线程池。将任务提交到对应NUMA节点的线程池中执行。
    • 确保每个线程池中的线程都绑定到其对应NUMA节点的CPU核心上。
  • 自定义内存分配器:
    • 对于高性能应用,可以实现自定义的NUMA感知内存分配器。
    • 这些分配器可以在启动时预先从每个NUMA节点分配一块大内存池,然后应用程序的各个部分从对应的节点内存池中获取内存。这避免了每次 malloc 都进行系统调用,并提供了更精细的控制。
  • 避免伪共享 (False Sharing):
    • 即使数据位于同一个NUMA节点,如果不同CPU核心上的线程频繁地访问同一缓存行中不同变量,也会导致性能下降(伪共享)。
    • 填充(Padding)技术可以用来确保关键数据结构占用独立的缓存行。

示例:一个简化的NUMA感知多线程求和

假设有一个大数组需要多线程求和,我们将它分割并分配到不同的NUMA节点上。

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <numa.h>
#include <numaif.h>
#include <sched.h>
#include <string.h>
#include <sys/time.h>

#define ARRAY_SIZE (1024 * 1024 * 256) // 256M integers
#define NUM_THREADS 2 // For simplicity, assume 2 NUMA nodes

// Global array pointers for each NUMA node
int *node_data[NUM_THREADS];
long partial_sums[NUM_THREADS];

typedef struct {
    int thread_id;
    int numa_node_id;
    int *data_ptr;
    size_t data_len;
} thread_arg_t;

// Worker function for each thread
void *worker_func(void *arg) {
    thread_arg_t *thread_arg = (thread_arg_t *)arg;
    int thread_id = thread_arg->thread_id;
    int numa_node_id = thread_arg->numa_node_id;
    int *data = thread_arg->data_ptr;
    size_t len = thread_arg->data_len;
    long sum = 0;

    // Set CPU affinity for this thread
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    // Bind to a CPU on the target NUMA node.
    // This example assumes CPU_ID = NUMA_NODE_ID for simplicity.
    // In real systems, you'd query numa_node_to_cpu or use lscpu output.
    int cpu_to_bind = numa_node_to_cpu(numa_node_id, 0); // Get first CPU on that node
    if (cpu_to_bind == -1) { // Fallback if numa_node_to_cpu fails or not available
        cpu_to_bind = numa_node_id; // Simple mapping
    }

    CPU_SET(cpu_to_bind, &cpuset);
    if (pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset) != 0) {
        perror("pthread_setaffinity_np failed");
    }
    printf("Thread %d (Node %d) bound to CPU %dn", thread_id, numa_node_id, cpu_to_bind);

    // Perform sum
    for (size_t i = 0; i < len; ++i) {
        sum += data[i];
    }
    partial_sums[thread_id] = sum;

    return NULL;
}

// Helper to get current time in microseconds
long get_time_us() {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec * 1000000 + tv.tv_usec;
}

int main() {
    if (numa_available() == -1) {
        fprintf(stderr, "NUMA not available on this system. Exiting.n");
        return EXIT_FAILURE;
    }

    int num_numa_nodes = numa_max_node() + 1;
    if (num_numa_nodes < NUM_THREADS) {
        fprintf(stderr, "Warning: System has fewer NUMA nodes (%d) than threads (%d). Performance might not be optimal.n",
                num_numa_nodes, NUM_THREADS);
    }

    pthread_t threads[NUM_THREADS];
    thread_arg_t thread_args[NUM_THREADS];
    size_t elements_per_node = ARRAY_SIZE / NUM_THREADS;

    long start_time = get_time_us();

    // Allocate data on specific NUMA nodes
    for (int i = 0; i < NUM_THREADS; ++i) {
        // Ensure data is allocated on its respective NUMA node
        node_data[i] = (int *)numa_alloc_onnode(elements_per_node * sizeof(int), i);
        if (!node_data[i]) {
            perror("numa_alloc_onnode failed");
            // Clean up previously allocated memory
            for (int j = 0; j < i; ++j) {
                numa_free(node_data[j], elements_per_node * sizeof(int));
            }
            return EXIT_FAILURE;
        }
        printf("Allocated %lu bytes on Node %d for data_ptr[%d] at %pn",
               elements_per_node * sizeof(int), i, i, node_data[i]);

        // Initialize data (first touch)
        for (size_t j = 0; j < elements_per_node; ++j) {
            node_data[i][j] = i + 1; // Simple value for sum verification
        }
    }

    printf("Data initialization complete.n");

    // Create threads and assign them to NUMA nodes
    for (int i = 0; i < NUM_THREADS; ++i) {
        thread_args[i].thread_id = i;
        thread_args[i].numa_node_id = i;
        thread_args[i].data_ptr = node_data[i];
        thread_args[i].data_len = elements_per_node;

        if (pthread_create(&threads[i], NULL, worker_func, &thread_args[i]) != 0) {
            perror("pthread_create failed");
            return EXIT_FAILURE;
        }
    }

    // Wait for threads to complete
    long total_sum = 0;
    for (int i = 0; i < NUM_THREADS; ++i) {
        pthread_join(threads[i], NULL);
        total_sum += partial_sums[i];
    }

    long end_time = get_time_us();

    printf("nAll threads finished.n");
    printf("Total sum: %ldn", total_sum);

    // Verify sum: (elements_per_node * 1) + (elements_per_node * 2) + ...
    long expected_sum = 0;
    for (int i = 0; i < NUM_THREADS; ++i) {
        expected_sum += (long)elements_per_node * (i + 1);
    }
    printf("Expected sum: %ldn", expected_sum);
    printf("Sum verification: %sn", (total_sum == expected_sum) ? "SUCCESS" : "FAILED");

    printf("Total execution time: %ld microsecondsn", end_time - start_time);

    // Free allocated memory
    for (int i = 0; i < NUM_THREADS; ++i) {
        numa_free(node_data[i], elements_per_node * sizeof(int));
    }

    return 0;
}

编译与运行:
gcc -o numa_sum numa_sum.c -lpthread -lnuma
./numa_sum

这个例子展示了如何结合 numa_alloc_onnode 来在特定NUMA节点分配内存,以及 pthread_setaffinity_np 来将线程绑定到对应节点的CPU上,从而实现NUMA感知。

六、 实用工具与监控

除了编程接口,还有一些系统工具可以帮助我们理解和调试NUMA行为。

  • lscpu 显示CPU架构信息,包括NUMA节点数量、每个节点的CPU列表等。
    lscpu | grep -i numa
  • numastat 显示每个NUMA节点的内存使用统计,包括本地和远程页面的分配情况。
    numastat
    numastat -p <PID> # 查看特定进程的NUMA统计
  • /proc/<pid>/numa_maps 详细列出进程内存区域的NUMA映射,可以查看哪些内存区域在哪个NUMA节点上。
  • hwloc (Hardware Locality): 一个强大的库和工具集,用于发现和报告系统硬件拓扑结构,包括NUMA节点、CPU、缓存、PCI设备等。它提供了更高级别的抽象,可以帮助开发者构建更通用的NUMA感知应用。

七、 最佳实践与潜在陷阱

7.1 何时使用NUMA感知优化

  • 内存密集型应用: 当应用程序需要处理大量数据,并且这些数据在运行时频繁访问时。
  • 高并发应用: 拥有大量线程或进程,并且这些并发单元可能竞争内存带宽时。
  • 高性能计算 (HPC): 科学计算、数据分析、机器学习等对性能要求极高的场景。
  • 服务器应用: 数据库、Web服务器等,通过优化NUMA可以显著提升响应速度和吞吐量。

7.2 潜在陷阱与注意事项

  • 过度优化: NUMA感知优化并非万能药。对于CPU密集型但内存访问模式不规则的应用,或者数据量不大、远程访问开销不明显的应用,过度使用NUMA策略可能适得其反,增加复杂性而无性能提升。始终先进行性能测量!
  • 动态负载: 如果应用程序的负载是高度动态的,线程或数据经常在节点之间迁移,那么固定的CPU和内存绑定可能导致新的瓶颈。例如,如果一个线程被绑定到Node 0,但其主要的计算和数据突然转移到Node 1,那么这种绑定反而会限制性能。
  • 内存池管理: 使用 numa_alloc_onnode 或自定义分配器时,需要谨慎管理内存的生命周期,确保正确释放。
  • NUMA和虚拟化/容器: 在虚拟化或容器环境中,底层的NUMA拓扑可能会被抽象或隐藏。需要确保虚拟机或容器被配置为暴露和利用底层的NUMA特性。例如,VMware vNUMA 和 KVM 的 NUMA 策略。
  • CPU和内存的实际映射: 并非所有的CPU核心都均匀地分布在NUMA节点上,有时一个节点可能只有少量核心。需要通过 lscpunumactl --hardware 仔细了解实际的硬件拓扑。numa_node_to_cpunuma_preferred 可以帮助查询。

八、 展望

NUMA架构将继续是现代服务器和高性能计算系统的主流。随着异构计算(CPU+GPU)的发展,NUMA的概念甚至会扩展到更广阔的硬件资源池,例如将GPU显存视为一个“内存节点”,如何高效地在CPU内存和GPU显存之间调度数据和计算,也将成为新的NUMA感知挑战。

结语

NUMA-aware Scheduling是提升现代多核系统性能的关键技术。它要求我们不仅理解硬件架构的深层原理,更要掌握操作系统提供的工具和API,并在应用程序设计中加以体现。通过精心规划CPU亲和性、内存分配策略以及数据布局,我们可以显著减少远程内存访问,最大化缓存局部性,从而释放应用程序的全部潜能。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注