在现代大规模服务器环境中,处理器的核心数量不断增加,多核、多插槽(Multi-socket)的架构已成为主流。然而,这种设计也引入了一个复杂的性能挑战:非统一内存访问(NUMA, Non-Uniform Memory Access)。当一个CPU访问其本地内存控制器所连接的内存时,速度非常快;但当它需要访问另一个CPU插槽上的内存时,延迟会显著增加,带宽也会受限。这种跨插槽(cross-socket)的内存访问是导致大规模服务器应用程序性能瓶颈的常见原因。
本文将深入探讨 NUMA 架构的原理,解释其对应用程序性能的影响,并详细介绍如何利用 C++ 和相关的操作系统 API 实现 NUMA 亲和性调度。通过精确控制线程的 CPU 亲和性(CPU affinity)和内存的 NUMA 节点亲和性(memory affinity),我们可以显著减少跨 Socket 内存访问,从而提升应用程序在高性能计算、大数据处理、数据库等领域的表现。
1. NUMA 架构的基石
理解 NUMA 亲和性调度的前提是深入理解 NUMA 架构本身。在 NUMA 系统中,物理内存被划分为多个独立的“NUMA 节点”。每个 NUMA 节点通常包含:
- 一组 CPU 核心: 这些核心可以直接访问该节点内的内存。
- 一个或多个内存控制器: 连接到该节点上的物理内存。
- 本地内存: 由该内存控制器管理的 RAM 模块。
不同 NUMA 节点之间的通信通过高速互连总线(如 Intel 的 QPI/UPI,AMD 的 Infinity Fabric)进行。
1.1. 内存访问的距离与成本
NUMA 架构的关键特征是内存访问的“距离”或“成本”。
- 本地访问(Local Access): 当一个 CPU 核心访问其所在 NUMA 节点内的内存时,这被称为本地访问。这种访问速度最快,延迟最低,带宽最高。
- 远程访问(Remote Access): 当一个 CPU 核心需要访问其他 NUMA 节点上的内存时,这被称为远程访问。这种访问需要通过互连总线,导致更高的延迟和更低的有效带宽。
NUMA 系统中的每个节点都有一个唯一的 ID(通常从 0 开始)。操作系统和硬件会维护一个 NUMA 距离表,表示从一个节点访问另一个节点的内存所需的相对成本。例如:
| 源节点 | 目标节点 | 距离(相对值) |
|---|---|---|
| 0 | 0 | 10 |
| 0 | 1 | 20 |
| 1 | 0 | 20 |
| 1 | 1 | 10 |
上表中,“距离”值越小表示访问成本越低。从节点 0 访问节点 0 的内存成本为 10,而访问节点 1 的内存成本为 20,这清楚地展示了远程访问的开销。
1.2. 操作系统与 NUMA
现代操作系统(如 Linux)对 NUMA 架构是感知(NUMA-aware)的。然而,默认情况下,OS 的调度器和内存管理器可能无法为所有应用程序提供最优的 NUMA 亲和性。
- 默认调度: 操作系统调度器通常会尝试将线程调度到空闲的 CPU 核心上,但可能不会优先考虑与线程当前使用的内存位于同一 NUMA 节点的 CPU。
- 默认内存分配: 默认的内存分配策略通常是“首次接触”(first-touch)策略。这意味着当一个线程首次访问某页内存时,该页会被分配到该线程当前运行的 NUMA 节点上。如果一个线程在节点 A 上分配了一块内存,但稍后又在节点 B 上访问它,就会产生远程访问。如果多个线程在不同节点上竞争同一块内存,情况会变得更糟。
2. NUMA 亲和性为何至关重要
NUMA 亲和性优化旨在确保线程在其本地 NUMA 节点上运行,并尽可能访问该节点上的本地内存。这样做带来了多方面的性能优势:
2.1. 降低内存访问延迟
通过将线程与其所需的数据 colocated(共置)在同一个 NUMA 节点上,可以显著减少内存访问的平均延迟,因为大部分访问都变成了本地访问。
2.2. 提升内存带宽
每个 NUMA 节点都有其独立的内存控制器和内存带宽。通过将工作负载和数据分散到不同的 NUMA 节点上,并确保它们在本地访问,可以有效利用所有节点的内存带宽,避免单个节点内存控制器成为瓶颈。
2.3. 减少缓存失效和共享开销
当数据在不同 NUMA 节点之间频繁访问时,会增加缓存一致性协议的流量,导致更多的缓存失效和数据传输。NUMA 亲和性有助于将相关数据和处理它的线程保持在同一节点内,从而提高缓存命中率,减少昂贵的跨节点缓存同步。
2.4. 提高应用程序可伸缩性
对于设计不当的应用程序,随着 CPU 核心和内存的增加,性能可能无法线性提升,甚至可能出现瓶颈。NUMA 亲和性调度是解锁大规模服务器硬件潜力的关键,它允许应用程序在更多的核心和更大的内存上实现更好的扩展性。
2.5. 典型的受益场景
- 高性能计算 (HPC): 科学模拟、并行计算等。
- 大数据处理: 内存数据库、图计算、流处理系统。
- 低延迟服务: 高频交易、实时数据分析。
- 大规模数据库和缓存: Redis、Memcached、MySQL 等。
3. 识别 NUMA 瓶颈:工具与方法
在着手优化之前,首先需要确认应用程序是否存在 NUMA 相关的性能瓶颈。
3.1. 检查硬件拓扑
使用 numactl --hardware 或 lscpu 命令可以查看系统的 NUMA 拓扑结构。
# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
node 0 size: 64426 MB
node 0 free: 60920 MB
node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
node 1 size: 64491 MB
node 1 free: 61081 MB
node distances:
node 0 1
0: 10 21
1: 21 10
从输出可以看出,系统有两个 NUMA 节点(0 和 1),每个节点包含一部分 CPU 核心和本地内存。node distances 表明从节点 0 访问节点 1 的内存成本为 21,而本地访问成本为 10。
3.2. 监控 NUMA 统计
numastat 工具可以显示每个 NUMA 节点上的内存使用情况和远程访问统计。
# numastat -m
Node 0 Node 1
--------------- ---------------
MemTotal 64426.69 MB 64491.50 MB
MemFree 60920.80 MB 61081.20 MB
MemUsed 3505.89 MB 3410.30 MB
...
Numa_Hit 3490.00 MB 3400.00 MB # 本地命中
Numa_Miss 10.00 MB 10.00 MB # 远程访问
Numa_Foreign 5.00 MB 0.00 MB # 其他节点访问我的内存
Interleave_Hit 0.00 MB 0.00 MB
Local_Node 3490.00 MB 3400.00 MB
Other_Node 15.00 MB 10.00 MB # 我访问了其他节点的内存
Numa_Miss 和 Other_Node 是关键指标。高值的 Other_Node 表示当前节点上的进程频繁访问其他节点的内存,可能存在 NUMA 瓶颈。
3.3. 使用 perf 进行深度分析
perf 工具可以提供关于内存访问模式的详细信息,包括缓存行未命中、TLB 未命中等。结合 perf mem 命令,可以识别出哪些函数或数据结构导致了大量内存访问。
# perf record -e mem-loads,mem-stores -g <your_application>
# perf report
分析报告中的 dTLB-load-misses、L1-dcache-load-misses、LLC-load-misses 等事件,结合函数调用栈,有助于定位内存热点。
4. C++ 实现 NUMA 亲和性调度的核心概念
在 C++ 中实现 NUMA 亲和性调度主要涉及两个核心方面:CPU 亲和性和内存亲和性。
- CPU 亲和性(CPU Affinity): 将一个进程或线程绑定到特定的 CPU 核心或一组 CPU 核心上运行。在 NUMA 环境中,这意味着将线程绑定到属于某个特定 NUMA 节点的 CPU 上。
- 内存亲和性(Memory Affinity): 控制内存分配的策略,确保数据被分配到特定的 NUMA 节点上,最好是与处理它的 CPU 位于同一个节点。
Linux 提供了一套用户空间库 libnuma(需要安装 numactl-devel 或 libnuma-dev 包)来简化 NUMA 相关的操作。此外,也可以直接使用底层的系统调用。
5. C++ 实现 NUMA 亲和性调度策略
我们将通过 libnuma 和一些 POSIX/Linux 特定的 API 来实现 NUMA 亲和性调度。
5.1. 查询 NUMA 拓扑信息
在进行任何亲和性设置之前,应用程序需要能够查询当前的 NUMA 拓扑。
#include <iostream>
#include <vector>
#include <numa.h>
#include <numaif.h> // For MPOL_BIND etc.
#include <sched.h> // For CPU_SET etc.
// 辅助函数:将 cpu_set_t 转换为字符串
std::string cpu_set_to_string(const cpu_set_t& cpu_set) {
std::string s;
bool first = true;
for (int i = 0; i < CPU_SETSIZE; ++i) {
if (CPU_ISSET(i, &cpu_set)) {
if (!first) {
s += ",";
}
s += std::to_string(i);
first = false;
}
}
return s;
}
void query_numa_topology() {
if (numa_available() == -1) {
std::cerr << "NUMA is not available on this system." << std::endl;
return;
}
int num_nodes = numa_num_configured_nodes();
std::cout << "Detected " << num_nodes << " NUMA nodes." << std::endl;
for (int node_id = 0; node_id < num_nodes; ++node_id) {
if (!numa_node_of_cpu(node_id)) { // Check if node_id is valid
continue;
}
std::cout << "--- Node " << node_id << " ---" << std::endl;
// 获取节点上的 CPU 核心
struct bitmask *cpu_mask = numa_allocate_cpumask();
if (numa_node_to_cpus(node_id, cpu_mask) == -1) {
std::cerr << "Error getting CPUs for node " << node_id << std::endl;
numa_free_cpumask(cpu_mask);
continue;
}
cpu_set_t cpus_in_node;
CPU_ZERO(&cpus_in_node);
for (unsigned int i = 0; i < numa_max_possible_cpu(); ++i) {
if (numa_bitmask_isbitset(cpu_mask, i)) {
CPU_SET(i, &cpus_in_node);
}
}
std::cout << " CPUs: " << cpu_set_to_string(cpus_in_node) << std::endl;
numa_free_cpumask(cpu_mask);
// 获取节点上的内存大小 (libnuma 2.x 提供了 numa_node_size64)
long long free_mem = -1;
long long total_mem = numa_node_size64(node_id, &free_mem);
if (total_mem != -1) {
std::cout << " Total Memory: " << total_mem / (1024 * 1024) << " MB" << std::endl;
std::cout << " Free Memory: " << free_mem / (1024 * 1024) << " MB" << std::endl;
}
// 获取节点之间的距离
for (int other_node_id = 0; other_node_id < num_nodes; ++other_node_id) {
if (node_id == other_node_id) continue;
int distance = numa_distance(node_id, other_node_id);
if (distance != -1) {
std::cout << " Distance to Node " << other_node_id << ": " << distance << std::endl;
}
}
}
}
int main() {
query_numa_topology();
return 0;
}
编译与运行:
g++ -o numa_query numa_query.cpp -lnuma
./numa_query
这段代码展示了如何使用 libnuma 提供的函数来获取系统中的 NUMA 节点数量、每个节点包含的 CPU 核心、内存大小以及节点间的距离。这是构建 NUMA 亲和性调度的基础。
5.2. 设置进程/线程的 CPU 亲和性
将线程绑定到特定的 CPU 核心或 NUMA 节点是 NUMA 亲和性调度的第一步。
5.2.1. 使用 sched_setaffinity (POSIX/Linux)
这是最底层和最灵活的方式,允许将线程绑定到任意的 CPU 核心集合。
#include <iostream>
#include <thread>
#include <vector>
#include <numeric>
#include <sched.h> // For cpu_set_t, sched_setaffinity
#include <unistd.h> // For getpid
// 辅助函数:设置当前线程的 CPU 亲和性
bool set_cpu_affinity(const std::vector<int>& cpus) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
for (int cpu_id : cpus) {
if (cpu_id < 0 || cpu_id >= CPU_SETSIZE) {
std::cerr << "Invalid CPU ID: " << cpu_id << std::endl;
return false;
}
CPU_SET(cpu_id, &cpuset);
}
if (pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset) != 0) {
std::cerr << "Error setting CPU affinity." << std::endl;
return false;
}
return true;
}
// 线程工作函数
void worker_function(int thread_id, const std::vector<int>& cpus_to_bind) {
if (!set_cpu_affinity(cpus_to_bind)) {
std::cerr << "Thread " << thread_id << " failed to set CPU affinity." << std::endl;
return;
}
std::cout << "Thread " << thread_id << " (PID: " << getpid()
<< ", TID: " << std::this_thread::get_id() << ") "
<< "is running on CPU(s): ";
cpu_set_t current_cpuset;
CPU_ZERO(¤t_cpuset);
if (pthread_getaffinity_np(pthread_self(), sizeof(cpu_set_t), ¤t_cpuset) == 0) {
for (int i = 0; i < CPU_SETSIZE; ++i) {
if (CPU_ISSET(i, ¤t_cpuset)) {
std::cout << i << " ";
}
}
std::cout << std::endl;
} else {
std::cout << "Error getting current CPU affinity." << std::endl;
}
// 模拟工作负载
double sum = 0;
for (long long i = 0; i < 1000000000; ++i) {
sum += std::sqrt(i);
}
std::cout << "Thread " << thread_id << " finished work. Sum: " << sum << std::endl;
}
int main() {
std::vector<std::thread> threads;
// 假设我们有两个 NUMA 节点,节点 0 包含 CPU 0, 1,节点 1 包含 CPU 2, 3
// 实际应通过 numa_node_to_cpus 查询
std::vector<int> cpus_node0 = {0, 1};
std::vector<int> cpus_node1 = {2, 3};
// 启动线程 0 并绑定到节点 0 的 CPU
threads.emplace_back(worker_function, 0, cpus_node0);
// 启动线程 1 并绑定到节点 1 的 CPU
threads.emplace_back(worker_function, 1, cpus_node1);
for (auto& t : threads) {
t.join();
}
std::cout << "All threads finished." << std::endl;
return 0;
}
注意事项:
pthread_setaffinity_np用于设置特定线程的亲和性。CPU_SETSIZE是cpu_set_t可以包含的最大 CPU 数量,通常很大。pthread_getaffinity_np用于验证亲和性是否成功设置。- 需要注意
root权限或 CAP_SYS_NICE 能力才能成功设置亲和性。
5.2.2. 使用 numa_run_on_node (libnuma)
libnuma 提供了更高级的抽象,可以直接将线程运行在指定的 NUMA 节点上,而无需手动指定具体的 CPU 核心。
#include <iostream>
#include <thread>
#include <vector>
#include <numa.h>
#include <unistd.h> // For getpid
// 线程工作函数
void worker_function_numa(int thread_id, int numa_node_id) {
if (numa_available() == -1) {
std::cerr << "NUMA is not available." << std::endl;
return;
}
// 设置当前线程运行在指定的 NUMA 节点上
if (numa_run_on_node(numa_node_id) == -1) {
std::cerr << "Thread " << thread_id << " failed to set NUMA node affinity for node " << numa_node_id << std::endl;
return;
}
// 验证当前线程运行在哪个节点 (numa_node_of_cpu(sched_getcpu()) 并不总是可靠)
// 更准确的验证需要检查线程的 CPU 亲和性,并与节点 CPU 映射对比
std::cout << "Thread " << thread_id << " (PID: " << getpid()
<< ", TID: " << std::this_thread::get_id() << ") "
<< "is intended to run on NUMA Node " << numa_node_id << std::endl;
// 模拟工作负载
double sum = 0;
for (long long i = 0; i < 1000000000; ++i) {
sum += std::sqrt(i);
}
std::cout << "Thread " << thread_id << " on Node " << numa_node_id << " finished work. Sum: " << sum << std::endl;
}
int main() {
if (numa_available() == -1) {
std::cerr << "NUMA is not available on this system." << std::endl;
return 1;
}
int num_nodes = numa_num_configured_nodes();
if (num_nodes < 2) {
std::cout << "System has less than 2 NUMA nodes, affinity may not be beneficial." << std::endl;
// Still run the example to demonstrate API usage
}
std::vector<std::thread> threads;
for (int i = 0; i < std::min(num_nodes, 2); ++i) { // 创建与前两个 NUMA 节点对应的线程
threads.emplace_back(worker_function_numa, i, i);
}
for (auto& t : threads) {
t.join();
}
std::cout << "All NUMA-aware threads finished." << std::endl;
return 0;
}
numa_run_on_node() 会设置当前线程的 CPU 亲和性,使其倾向于运行在指定 NUMA 节点的 CPU 上。但它不会改变已经分配的内存的亲和性。
5.3. 设置内存亲和性
内存亲和性确保数据被分配到特定的 NUMA 节点上。
5.3.1. 使用 numa_alloc_onnode() 和 numa_free() (libnuma)
这是最直接的方式,用于在指定的 NUMA 节点上分配一块内存。
#include <iostream>
#include <vector>
#include <numa.h>
#include <cstdlib> // For free
void allocate_on_specific_node(int node_id, size_t size_mb) {
if (numa_available() == -1) {
std::cerr << "NUMA is not available." << std::endl;
return;
}
if (node_id < 0 || node_id >= numa_num_configured_nodes()) {
std::cerr << "Invalid NUMA node ID: " << node_id << std::endl;
return;
}
size_t size_bytes = size_mb * 1024 * 1024;
std::cout << "Attempting to allocate " << size_mb << " MB on NUMA Node " << node_id << std::endl;
void* mem_ptr = numa_alloc_onnode(size_bytes, node_id);
if (mem_ptr == nullptr) {
std::cerr << "Failed to allocate memory on NUMA Node " << node_id << std::endl;
return;
}
// 写入数据以确保物理页面被分配到指定节点 (首次接触原则)
// 如果没有写入,实际的物理页面可能不会立即分配到指定节点
char* data = static_cast<char*>(mem_ptr);
for (size_t i = 0; i < size_bytes; ++i) {
data[i] = static_cast<char>(i % 256);
}
std::cout << "Successfully allocated and touched " << size_mb << " MB on NUMA Node " << node_id << std::endl;
// 验证内存亲和性 (可选,需要 /proc/pid/numa_maps 或 numastat)
// 实际验证内存是否在指定节点上需要更复杂的工具,例如 `numastat -p <pid>`
numa_free(mem_ptr, size_bytes);
std::cout << "Memory freed." << std::endl;
}
int main() {
if (numa_available() == -1) {
std::cerr << "NUMA is not available on this system." << std::endl;
return 1;
}
int num_nodes = numa_num_configured_nodes();
if (num_nodes < 1) {
std::cerr << "No NUMA nodes detected." << std::endl;
return 1;
}
allocate_on_specific_node(0, 100); // 在节点 0 上分配 100MB
if (num_nodes > 1) {
allocate_on_specific_node(1, 100); // 在节点 1 上分配 100MB
}
return 0;
}
关键点: numa_alloc_onnode 返回的内存指针需要使用 numa_free 释放,而不是标准的 free。并且,为了确保物理页面真正分配到目标节点,最好在分配后立即对内存进行写入操作,触发“首次接触”策略。
5.3.2. 使用 numa_set_membind() 和 mbind() (libnuma/Linux syscall)
numa_set_membind() 设置当前线程的默认内存分配策略。之后通过 malloc 或 new 分配的内存将尝试绑定到指定的 NUMA 节点集。
mbind() 是一个更底层的系统调用,允许对特定的内存区域(页范围)设置亲和性策略。
#include <iostream>
#include <vector>
#include <numa.h>
#include <numaif.h> // For MPOL_BIND
#include <memory> // For std::unique_ptr
void demonstrate_membind(int node_id, size_t size_mb) {
if (numa_available() == -1) {
std::cerr << "NUMA is not available." << std::endl;
return;
}
if (node_id < 0 || node_id >= numa_num_configured_nodes()) {
std::cerr << "Invalid NUMA node ID: " << node_id << std::endl;
return;
}
// 设置当前线程的内存分配策略为绑定到指定节点
struct bitmask *nodes_mask = numa_allocate_nodemask();
numa_bitmask_setbit(nodes_mask, node_id);
if (numa_set_membind(nodes_mask) == -1) {
std::cerr << "Failed to set membind policy for node " << node_id << std::endl;
numa_free_nodemask(nodes_mask);
return;
}
numa_free_nodemask(nodes_mask);
std::cout << "Set memory policy to bind to NUMA Node " << node_id << std::endl;
size_t size_bytes = size_mb * 1024 * 1024;
// 使用 new 分配内存,它将遵循当前线程的 membinding 策略
std::unique_ptr<char[]> data_ptr(new char[size_bytes]);
// 写入数据以确保物理页面被分配到指定节点
for (size_t i = 0; i < size_bytes; ++i) {
data_ptr[i] = static_cast<char>(i % 256);
}
std::cout << "Allocated and touched " << size_mb << " MB using new/membind on NUMA Node " << node_id << std::endl;
// 打印当前线程的内存策略(可选)
struct bitmask *current_nodes_mask = numa_allocate_nodemask();
int policy = -1;
if (get_mempolicy(&policy, current_nodes_mask->maskp, numa_max_possible_node() + 1, 0, 0) == 0) {
std::cout << "Current memory policy: " << policy << ", Nodes: ";
for (int i = 0; i <= numa_max_possible_node(); ++i) {
if (numa_bitmask_isbitset(current_nodes_mask, i)) {
std::cout << i << " ";
}
}
std::cout << std::endl;
}
numa_free_nodemask(current_nodes_mask);
// 重置内存策略为默认 (可选,避免影响后续操作)
numa_set_localalloc();
std::cout << "Reset memory policy to local allocation." << std::endl;
}
void demonstrate_mbind(int node_id, size_t size_mb) {
if (numa_available() == -1) {
std::cerr << "NUMA is not available." << std::endl;
return;
}
if (node_id < 0 || node_id >= numa_num_configured_nodes()) {
std::cerr << "Invalid NUMA node ID: " << node_id << std::endl;
return;
}
size_t size_bytes = size_mb * 1024 * 1024;
// 使用 posix_memalign 分配页对齐的内存
void* mem_ptr = nullptr;
if (posix_memalign(&mem_ptr, sysconf(_SC_PAGESIZE), size_bytes) != 0) {
std::cerr << "Failed to allocate page-aligned memory." << std::endl;
return;
}
// 设置内存区域的绑定策略
unsigned long nodemask = (1UL << node_id); // 仅绑定到指定节点
if (mbind(mem_ptr, size_bytes, MPOL_BIND, &nodemask, numa_max_possible_node() + 1, 0) != 0) {
std::cerr << "Failed to mbind memory to node " << node_id << ". Error: " << errno << std::endl;
free(mem_ptr);
return;
}
std::cout << "Successfully mbound " << size_mb << " MB to NUMA Node " << node_id << std::endl;
// 写入数据以确保物理页面被分配到指定节点
char* data = static_cast<char*>(mem_ptr);
for (size_t i = 0; i < size_bytes; ++i) {
data[i] = static_cast<char>(i % 256);
}
std::cout << "Data touched after mbind." << std::endl;
free(mem_ptr);
std::cout << "Memory freed." << std::endl;
}
int main() {
if (numa_available() == -1) {
std::cerr << "NUMA is not available on this system." << std::endl;
return 1;
}
int num_nodes = numa_num_configured_nodes();
if (num_nodes < 1) {
std::cerr << "No NUMA nodes detected." << std::endl;
return 1;
}
demonstrate_membind(0, 50); // 绑定线程的后续 malloc/new 到节点 0
if (num_nodes > 1) {
demonstrate_membind(1, 50); // 绑定线程的后续 malloc/new 到节点 1
}
std::cout << "n--- Demonstrating mbind ---n" << std::endl;
demonstrate_mbind(0, 50); // 直接绑定一个内存区域到节点 0
if (num_nodes > 1) {
demonstrate_mbind(1, 50); // 直接绑定一个内存区域到节点 1
}
return 0;
}
numa_set_membind() 策略:
MPOL_BIND: 将内存绑定到指定的节点集。如果指定节点集上的内存不足,分配将失败。MPOL_PREFERRED: 优先在指定节点上分配内存,如果该节点内存不足,则可以在其他节点上分配。MPOL_INTERLEAVE: 将内存页轮询地分配到指定的节点集上。适用于数据被多个节点均匀访问的场景。
mbind() 注意事项:
mbind需要页对齐的内存地址和大小。mbind仅对调用者进程可见,不会影响其他进程。- 通常与
posix_memalign或mmap一起使用。
6. NUMA 感知的数据结构与算法设计
仅仅设置 CPU 和内存亲和性是不够的,还需要在应用程序层面进行设计,以充分利用 NUMA 架构。
6.1. 数据分区(Data Partitioning)
将大型共享数据结构按照 NUMA 节点进行逻辑或物理分区。
- 示例: 一个大型的哈希表可以被分成多个子哈希表,每个子哈希表及其访问线程都绑定到不同的 NUMA 节点。
6.2. 每节点(Per-Node)数据结构
为每个 NUMA 节点创建独立的数据结构实例。
- 示例: 线程池中的每个工作线程都在其本地 NUMA 节点上维护一个任务队列。当任务到达时,可以根据其目标数据或处理逻辑将其路由到正确的节点队列。
6.3. 本地访问优先
设计算法时,尽量让处理数据的线程优先访问其本地 NUMA 节点上的数据。如果必须访问远程数据,考虑将其复制到本地或使用批量传输以减少远程访问的频率。
6.4. NUMA 感知的线程池
构建一个 NUMA 感知的线程池,其中每个线程组都被绑定到特定的 NUMA 节点。当任务到来时,调度器可以根据任务的内存访问模式将其分配给最佳的线程组。
// 示例:NUMA 感知线程池的骨架
#include <iostream>
#include <vector>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <atomic>
#include <numa.h>
#include <sched.h>
// 任务结构
struct Task {
std::function<void()> func;
int target_node; // 目标 NUMA 节点
};
// NUMA 感知的工作线程
class NumaWorker {
public:
NumaWorker(int id, int node_id, const std::vector<int>& cpus_in_node)
: worker_id_(id), numa_node_id_(node_id), cpus_to_bind_(cpus_in_node), running_(true) {
worker_thread_ = std::thread(&NumaWorker::run, this);
}
~NumaWorker() {
if (worker_thread_.joinable()) {
stop();
worker_thread_.join();
}
}
void enqueue(Task task) {
{
std::lock_guard<std::mutex> lock(queue_mutex_);
tasks_.push(std::move(task));
}
cv_.notify_one();
}
void stop() {
running_ = false;
cv_.notify_all(); // 唤醒所有等待的线程以退出
}
int get_numa_node_id() const { return numa_node_id_; }
private:
void run() {
// 设置当前线程的 CPU 亲和性
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
for (int cpu_id : cpus_to_bind_) {
CPU_SET(cpu_id, &cpuset);
}
if (pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset) != 0) {
std::cerr << "Worker " << worker_id_ << " failed to set CPU affinity to node " << numa_node_id_ << std::endl;
} else {
std::cout << "Worker " << worker_id_ << " bound to NUMA Node " << numa_node_id_
<< " (CPUs: " << cpu_set_to_string(cpuset) << ")" << std::endl;
}
// 设置当前线程的内存分配策略为绑定到指定节点
if (numa_available() != -1) {
struct bitmask *nodes_mask = numa_allocate_nodemask();
numa_bitmask_setbit(nodes_mask, numa_node_id_);
if (numa_set_membind(nodes_mask) == -1) {
std::cerr << "Worker " << worker_id_ << " failed to set membind policy for node " << numa_node_id_ << std::endl;
}
numa_free_nodemask(nodes_mask);
}
while (running_) {
Task task;
{
std::unique_lock<std::mutex> lock(queue_mutex_);
cv_.wait(lock, [this]{ return !tasks_.empty() || !running_; });
if (!running_ && tasks_.empty()) {
break;
}
task = std::move(tasks_.front());
tasks_.pop();
}
task.func(); // 执行任务
}
}
int worker_id_;
int numa_node_id_;
std::vector<int> cpus_to_bind_;
std::thread worker_thread_;
std::queue<Task> tasks_;
std::mutex queue_mutex_;
std::condition_variable cv_;
std::atomic<bool> running_;
// 辅助函数:将 cpu_set_t 转换为字符串 (需要包含在 NumaWorker 类中或作为全局函数)
std::string cpu_set_to_string(const cpu_set_t& cpu_set) {
std::string s;
bool first = true;
for (int i = 0; i < CPU_SETSIZE; ++i) {
if (CPU_ISSET(i, &cpu_set)) {
if (!first) {
s += ",";
}
s += std::to_string(i);
first = false;
}
}
return s;
}
};
// NUMA 感知线程池
class NumaThreadPool {
public:
NumaThreadPool(int num_threads_per_node) {
if (numa_available() == -1) {
std::cerr << "NUMA is not available. Creating a single default worker pool." << std::endl;
// Fallback to non-NUMA if NUMA is not available
std::vector<int> all_cpus;
for(int i = 0; i < std::thread::hardware_concurrency(); ++i) all_cpus.push_back(i);
numa_workers_.emplace_back(std::make_unique<NumaWorker>(0, 0, all_cpus));
return;
}
int num_nodes = numa_num_configured_nodes();
for (int node_id = 0; node_id < num_nodes; ++node_id) {
struct bitmask *cpu_mask = numa_allocate_cpumask();
if (numa_node_to_cpus(node_id, cpu_mask) == -1) {
std::cerr << "Error getting CPUs for node " << node_id << std::endl;
numa_free_cpumask(cpu_mask);
continue;
}
std::vector<int> cpus_in_node;
for (unsigned int i = 0; i < numa_max_possible_cpu(); ++i) {
if (numa_bitmask_isbitset(cpu_mask, i)) {
cpus_in_node.push_back(i);
}
}
numa_free_cpumask(cpu_mask);
if (cpus_in_node.empty()) {
std::cerr << "No CPUs found for node " << node_id << std::endl;
continue;
}
// 为每个 NUMA 节点创建多个工作线程
for (int i = 0; i < num_threads_per_node; ++i) {
// 将线程绑定到节点内的部分 CPU,例如轮询分配
std::vector<int> worker_cpus = {cpus_in_node[i % cpus_in_node.size()]};
numa_workers_.emplace_back(std::make_unique<NumaWorker>(
numa_workers_.size(), node_id, worker_cpus));
}
}
}
void submit(std::function<void()> func, int target_node = -1) {
if (numa_workers_.empty()) {
std::cerr << "No NUMA workers available." << std::endl;
return;
}
// 简单的任务调度逻辑:如果指定了目标节点,则发送给对应节点的 worker
// 否则,轮询分配给所有 worker
if (target_node != -1 && target_node < numa_workers_.size()) { // Simplified: direct index to worker
numa_workers_[target_node]->enqueue({std::move(func), target_node});
} else {
// Round-robin dispatch
static std::atomic<size_t> next_worker_idx(0);
size_t idx = next_worker_idx.fetch_add(1) % numa_workers_.size();
numa_workers_[idx]->enqueue({std::move(func), numa_workers_[idx]->get_numa_node_id()});
}
}
private:
std::vector<std::unique_ptr<NumaWorker>> numa_workers_;
};
int main() {
NumaThreadPool pool(2); // 每个 NUMA 节点创建 2 个工作线程
// 提交任务给特定 NUMA 节点
pool.submit([]{
// 模拟一个需要访问节点 0 数据的任务
std::cout << "Task 1 (Node 0) running on thread " << std::this_thread::get_id() << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}, 0);
// 提交任务给特定 NUMA 节点
pool.submit([]{
// 模拟一个需要访问节点 1 数据的任务
std::cout << "Task 2 (Node 1) running on thread " << std::this_thread::get_id() << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}, 1);
// 提交一些不指定节点的任务,让线程池自行调度
for (int i = 0; i < 5; ++i) {
pool.submit([i]{
std::cout << "Generic task " << i << " running on thread " << std::this_thread::get_id() << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
});
}
std::this_thread::sleep_for(std::chrono::seconds(2)); // 等待任务完成
std::cout << "Main: Shutting down thread pool." << std::endl;
// pool 的析构函数会自动停止并join所有worker线程
return 0;
}
此示例展示了一个简化的 NUMA 感知线程池。每个 NumaWorker 实例都被绑定到一个特定的 NUMA 节点,并且其分配的内存也倾向于该节点。NumaThreadPool 接收任务,并可以根据任务的 target_node 属性将其路由到相应的 NUMA 节点上的工作线程。
6.5. 避免伪共享(False Sharing)
在 NUMA 系统中,伪共享问题会更加突出。当不同 CPU 核心访问同一缓存行中但不同位置的数据时,即使它们访问的是不同的变量,由于缓存一致性协议,整个缓存行会在不同核心之间来回“弹跳”,导致性能下降。通过填充(padding)或确保独立数据结构位于独立的缓存行来避免伪共享。
// 避免伪共享的示例
struct AlignedData {
alignas(64) long long counter; // 确保 counter 位于独立的缓存行
// 其他数据...
};
// 如果没有 alignas,两个线程分别修改不同 NumaData 实例的 counter,
// 但这两个 counter 恰好位于同一个缓存行,就会发生伪共享。
struct NumaData {
AlignedData node_specific_data[2]; // 假设有两个 NUMA 节点
};
7. 实践考量与最佳实践
7.1. 永远先测量,再优化
NUMA 亲和性调度可能非常复杂,如果应用场景不适合,其开销甚至可能抵消性能收益。始终使用 numastat、perf 等工具分析应用程序,确认 NUMA 瓶颈确实存在,并衡量优化前后的性能差异。
7.2. 逐步实施,验证效果
不要一次性改动所有代码。从最核心、内存访问最频繁的模块开始,逐步应用 NUMA 亲和性,并每次都进行充分的测试和性能评估。
7.3. 动态调度与静态绑定
- 静态绑定: 最简单直接,适合于任务模式和数据访问模式相对固定、可预测的应用程序(如 HPC)。
- 动态调度: 对于负载波动大、任务类型多样的应用程序,可能需要更复杂的动态调度机制。操作系统内核通常会尝试进行 NUMA 感知的负载均衡和内存迁移,但应用程序级的动态调度可以提供更细粒度的控制。这通常涉及监控每个节点的负载和内存使用情况,然后动态调整线程亲和性或数据位置,但这会显著增加实现复杂性。
7.4. 内存页大小
使用大页(Huge Pages)可以减少 TLB 未命中,但需要注意其与 NUMA 内存分配策略的交互。有时候,大页分配可能会更倾向于在某个节点上集中,可能需要手动 mbind。
7.5. 进程和线程的生命周期
当进程 fork() 子进程时,子进程会继承父进程的 NUMA 亲和性设置。如果子进程需要不同的 NUMA 行为,必须在 fork() 后重新设置。
7.6. 虚拟化环境
在虚拟机或容器环境中,NUMA 拓扑可能由 Hypervisor 虚拟化。确保 Hypervisor 和 guest OS 都正确配置为 NUMA 感知,以避免在虚拟层引入新的瓶颈。
7.7. 操作系统内核版本
Linux 内核在 NUMA 调度和内存管理方面持续改进。使用较新的内核版本通常能获得更好的 NUMA 性能。
7.8. 错误处理
NUMA 相关的 API 调用可能会失败(例如,指定的节点不存在,或权限不足)。务必检查返回值并进行适当的错误处理。
8. 展望未来与进阶议题
- 用户态页面错误处理(Userfaultfd): 允许应用程序在发生页面错误时介入,可以用于实现自定义的按需页面迁移,从而在运行时动态调整数据亲和性。
- 硬件辅助 NUMA 优化: 新的处理器架构可能会引入更多硬件级别的 NUMA 优化机制,例如更智能的内存控制器或互连总线。
- 编程语言运行时: 现代语言运行时(如 Java HotSpot JVM、Go Runtime)也在不断演进,以更好地支持 NUMA 架构。了解它们如何处理内存分配和线程调度,有助于更好地与 C++ 代码集成。
- 容器编排与 NUMA: Kubernetes 等容器编排系统正在发展 NUMA 感知的调度器和资源管理器,允许将容器部署到具有特定 NUMA 节点亲和性的物理机器上。
结语
NUMA 亲和性调度是现代大规模服务器上优化应用程序性能的强大工具。通过理解 NUMA 架构的内在挑战,并精细控制 CPU 亲和性与内存亲和性,C++ 开发者可以构建出能够充分利用底层硬件潜力的、高性能且可伸缩的应用程序。然而,这并非一劳永逸的解决方案,持续的性能分析和细致的调优是成功的关键。