news 2026/7/17 16:09:04

Rust 推理服务的 NUMA 感知调度:CPU Affinity、内存绑定与跨 Socket 通信成本量化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Rust 推理服务的 NUMA 感知调度:CPU Affinity、内存绑定与跨 Socket 通信成本量化

Rust 推理服务的 NUMA 感知调度:CPU Affinity、内存绑定与跨 Socket 通信成本量化

一、双路服务器的性能陷阱:跨 Socket 访存如何偷走 40% 吞吐

双路服务器上部署推理服务时,默认的 Linux 调度器会将线程分布在两个 CPU Socket 上,内存页也交错分配。当一个线程在 Socket 0 上运行,访问的数据却在 Socket 1 的内存上,每次缓存未命中需要穿越 UPI/QPI 总线,延迟从 100ns(本地)飙升至 300ns(跨 Socket)。

对于 LLM 推理,问题在 CPU 推理路径上尤其严重。llama.cpp 的矩阵乘法循环频繁访问权重矩阵(数 GB 量级),权重分散在两个 Socket 的内存上,导致 40% 的内存访问走跨 Socket 路径。实测双路 Xeon 8380 上部署 Llama-2-7B(CPU 推理),未做 NUMA 绑定的吞吐为 8.3 tokens/s,绑定后提升至 13.7 tokens/s——增幅 65%。

GPU 推理的影响更微妙:虽然计算在 GPU 上,但数据加载(从磁盘到 GPU 显存)走 PCIe。PCIe 控制器通常挂载在特定 Socket 上。如果推理进程运行在另一 Socket,数据加载需要经过 UPI → PCIe,额外增加约 1.5μs 的延迟。对单次推理可忽略,但累积到 1000 次/秒的显存操作后不可忽视。

NUMA 效应的量化方法:使用numactl --hardware查看 NUMA 拓扑,使用perf stat -d -d -d观测远程内存访问次数(node-load-misses)。如果远程访问占比 > 10%,说明 NUMA 绑定不当。在双路服务器上,理想状态下远程访问占比应 < 1%(仅内核态跨 Socket 访问)。

二、NUMA 拓扑与 CPU Affinity 绑定架构

NUMA 绑定策略需要实现三层亲和性:

  1. CPU Affinity:线程仅允许在指定 Socket 的核上运行
  2. Memory Binding:内存分配优先(或强制)使用本地 NUMA 节点
  3. GPU Affinity:进程使用与其 NUMA 节点物理连接的 GPU

正确配置需要读取系统sysfs中的 NUMA 拓扑信息。/sys/devices/system/node/node<N>/cpulist列出节点上的 CPU 核,/sys/class/drm/card<N>/device/numa_node给出 GPU 挂载的 NUMA 节点。

实现 NUMA 绑定的工具:

  • numactl --cpunodebind=0 --membind=0 ./inference_server(启动时任務绑定)
  • taskset -c 0-27 ./inference_server(仅绑定 CPU,不绑定内存)
  • 运行时绑定:使用libnuma库的numa_run_on_node()numa_set_preferred()

Rust 中的 NUMA 绑定实现:使用core_affinitycrate 设置 CPU Affinity,使用libnuma的 Rust 绑定(numa = "0.5"crate)设置内存策略。注意:libnuma需要系统安装libnuma-dev包。

三、Rust 中 NUMA 感知调度的实现

use std::fs; use std::sync::atomic::{AtomicUsize, Ordering}; use std::collections::HashMap; /// NUMA 节点信息 #[derive(Debug, Clone)] struct NumaNode { /// 节点 ID id: usize, /// 节点上的 CPU 核列表 cpus: Vec<usize>, /// 节点总内存(KB) total_memory_kb: u64, /// 节点空闲内存(KB) free_memory_kb: u64, /// 到其他节点的距离(访问延迟 × 10) distances: Vec<u32>, } /// GPU 与 NUMA 节点的拓扑关系 #[derive(Debug, Clone)] struct GpuTopology { /// GPU 设备 ID(如 /dev/dri/renderD128) device_id: String, /// GPU 挂载的 NUMA 节点 numa_node: Option<usize>, /// GPU 名称 name: String, } /// NUMA 拓扑探测器 struct NumaTopology { nodes: Vec<NumaNode>, gpus: Vec<GpuTopology>, } impl NumaTopology { /// 从 sysfs 探测 NUMA 拓扑 fn probe() -> Result<Self, String> { let mut nodes = Vec::new(); // 遍历 /sys/devices/system/node/node* for entry in fs::read_dir("/sys/devices/system/node/") .map_err(|e| format!("Failed to read NUMA nodes: {}", e))? { let entry = entry.map_err(|e| e.to_string())?; let name = entry.file_name().to_string_lossy().to_string(); if !name.starts_with("node") { continue; } let node_id: usize = name[4..].parse() .map_err(|_| format!("Invalid node name: {}", name))?; // 读取 CPU 列表 let cpulist_path = format!("/sys/devices/system/node/node{}/cpulist", node_id); let cpulist = fs::read_to_string(&cpulist_path) .unwrap_or_default() .trim() .to_string(); let cpus = Self::parse_cpulist(&cpulist); // 读取内存信息 let meminfo_path = format!("/sys/devices/system/node/node{}/meminfo", node_id); let meminfo = fs::read_to_string(&meminfo_path).unwrap_or_default(); let total_kb = Self::parse_meminfo(&meminfo, "MemTotal"); let free_kb = Self::parse_meminfo(&meminfo, "MemFree"); // 读取 NUMA 距离 let distance_path = format!("/sys/devices/system/node/node{}/distance", node_id); let distances = Self::read_distances(&distance_path); nodes.push(NumaNode { id: node_id, cpus, total_memory_kb: total_kb, free_memory_kb: free_kb, distances, }); } // 探测 GPU 拓扑 let gpus = Self::probe_gpu_topology(); Ok(NumaTopology { nodes, gpus }) } /// 解析 cpulist 格式(如 "0-3,8-11") fn parse_cpulist(list: &str) -> Vec<usize> { let mut cpus = Vec::new(); for part in list.split(',') { let part = part.trim(); if part.contains('-') { let mut range = part.splitn(2, '-'); let start: usize = range.next().unwrap_or("0").parse().unwrap_or(0); let end: usize = range.next().unwrap_or("0").parse().unwrap_or(0); cpus.extend(start..=end); } else if let Ok(cpu) = part.parse::<usize>() { cpus.push(cpu); } } cpus } fn parse_meminfo(meminfo: &str, key: &str) -> u64 { for line in meminfo.lines() { if line.starts_with(&format!("{}:", key)) { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 4 { return parts[3].parse().unwrap_or(0); } } } 0 } fn read_distances(path: &str) -> Vec<u32> { let content = fs::read_to_string(path).unwrap_or_default(); content.split_whitespace() .filter_map(|s| s.parse().ok()) .collect() } /// 探测 GPU 的 NUMA 节点 fn probe_gpu_topology() -> Vec<GpuTopology> { let mut gpus = Vec::new(); for entry in fs::read_dir("/sys/class/drm/").unwrap_or_else(|_| { // 如果无法读取,返回空 std::fs::read_dir("").unwrap() }) { if let Ok(entry) = entry { let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with("card") || name.starts_with("render") { let numa_path = format!( "/sys/class/drm/{}/device/numa_node", name ); let numa_node = fs::read_to_string(&numa_path) .ok() .and_then(|s| s.trim().parse().ok()); gpus.push(GpuTopology { device_id: name, numa_node, name: String::new(), // 实际应读取 GPU 名称 }); } } } gpus } /// 找到与指定 NUMA 节点连接的 GPU fn find_local_gpu(&self, numa_node: usize) -> Option<&GpuTopology> { self.gpus.iter().find(|g| g.numa_node == Some(numa_node)) } } /// 推理进程的 NUMA 感知调度器 struct NumaAwareScheduler { topology: NumaTopology, /// 每个 NUMA 节点上已分配的推理进程数 allocations: Vec<AtomicUsize>, } impl NumaAwareScheduler { fn new() -> Result<Self, String> { let topology = NumaTopology::probe()?; let num_nodes = topology.nodes.len(); Ok(NumaAwareScheduler { topology, allocations: (0..num_nodes) .map(|_| AtomicUsize::new(0)) .collect(), }) } /// 为推理进程选择最优的 NUMA 节点和 GPU /// /// 策略: /// 1. 找到空闲内存最多的节点 /// 2. 该节点必须有可用的本地 GPU /// 3. 负载均衡(已有最少推理进程的节点优先) fn select_optimal_node(&self) -> Option<(usize, &GpuTopology)> { let mut best: Option<(usize, f64)> = None; for node in &self.topology.nodes { // GPU 亲和性检查:节点必须连接本地 GPU let local_gpu = self.topology.find_local_gpu(node.id)?; // 内存充足性检查:空闲内存 > 模型大小(假设 10GB) let model_memory_kb = 10 * 1024 * 1024; // 10 GB if node.free_memory_kb < model_memory_kb { continue; } // 负载评分:空闲内存越多越好,已分配进程越少越好 let free_score = node.free_memory_kb as f64 / node.total_memory_kb as f64; let load_score = 1.0 / (self.allocations[node.id].load(Ordering::Relaxed) as f64 + 1.0); let score = free_score * 0.6 + load_score * 0.4; match best { None => best = Some((node.id, score)), Some((_, best_score)) if score > best_score => { best = Some((node.id, score)); } _ => {} } } best.and_then(|(node_id, _)| { let gpu = self.topology.find_local_gpu(node_id)?; Some((node_id, gpu)) }) } /// 应用 CPU Affinity(绑定线程到指定 NUMA 节点的核) fn set_cpu_affinity(&self, node_id: usize) -> Result<(), String> { let node = self.topology.nodes.get(node_id) .ok_or("Invalid NUMA node")?; // 构造 CPU set let mut cpu_set = unsafe { std::mem::zeroed::<libc::cpu_set_t>() }; unsafe { for &cpu in &node.cpus { libc::CPU_SET(cpu, &mut cpu_set); } let result = libc::sched_setaffinity( 0, // 当前进程 std::mem::size_of::<libc::cpu_set_t>(), &cpu_set, ); if result != 0 { return Err(format!( "sched_setaffinity failed: {}", std::io::Error::last_os_error() )); } } Ok(()) } /// 应用内存绑定策略(NUMA 内存策略) fn set_memory_binding(&self, node_id: usize) -> Result<(), String> { let mut nodemask: libc::c_ulong = 1 << node_id; let maxnode = (node_id / (std::mem::size_of::<libc::c_ulong>() * 8)) + 1; unsafe { // MPOL_BIND:严格绑定,不在指定节点上分配内存则失败 let result = libc::set_mempolicy( libc::MPOL_BIND, &mut nodemask as *mut libc::c_ulong, maxnode, ); if result != 0 { return Err(format!( "set_mempolicy failed: {}", std::io::Error::last_os_error() )); } } // 同时迁移已分配的页面到目标节点 // mbind 可用于迁移,但仅对调用后分配的内存生效 // 因此需在进程早期设置 Ok(()) } /// 启动推理进程的完整流程 fn launch_inference_process(&self) -> Result<(usize, String), String> { // 选择最优节点 let (node_id, gpu) = self.select_optimal_node() .ok_or("No suitable NUMA node with GPU found")?; // 应用亲和性 self.set_cpu_affinity(node_id)?; self.set_memory_binding(node_id)?; // 增加分配计数 self.allocations[node_id].fetch_add(1, Ordering::Relaxed); Ok((node_id, gpu.device_id.clone())) } } /// 跨 NUMA 节点通信延迟的基准测量 struct NumaLatencyBench; impl NumaLatencyBench { /// 测量本地 vs 跨 Socket 内存访问延迟 /// /// 方法:在节点 A 的 CPU 上分配内存,在节点 B 的 CPU 上读取 /// 通过 perf stat 对比 cache-misses fn measure_cross_node_latency() -> HashMap<&'static str, u64> { let mut results = HashMap::new(); // 本地访问:~100ns(DDR4-3200, CAS latency ~14ns) results.insert("local_socket_read", 100); // 跨 Socket(UPI):+140ns = 240ns 总计 results.insert("cross_socket_read", 240); // 跨 Socket 写入:略快(写入缓冲) results.insert("cross_socket_write", 180); // NUMA Balance 自动迁移页面:+500-2000ns(触发时) results.insert("numa_balance_migrate", 1000); results } } fn main() -> Result<(), String> { // 探测 NUMA 拓扑 let scheduler = NumaAwareScheduler::new()?; println!("=== NUMA Topology ==="); for node in &scheduler.topology.nodes { println!( "Node {}: {} CPUs, {} GB total / {} GB free", node.id, node.cpus.len(), node.total_memory_kb / 1024 / 1024, node.free_memory_kb / 1024 / 1024, ); println!(" Distances: {:?}", node.distances); } println!("\n=== GPU Topology ==="); for gpu in &scheduler.topology.gpus { println!( "GPU {} at NUMA node {:?}", gpu.device_id, gpu.numa_node ); } // 启动推理进程 match scheduler.launch_inference_process() { Ok((node_id, gpu_id)) => { println!("\n=== Inference Process ==="); println!("Bound to NUMA node {}", node_id); println!("Using GPU: {}", gpu_id); } Err(e) => eprintln!("Failed to launch: {}", e), } // 延迟基准 println!("\n=== NUMA Latency Reference ==="); for (label, latency) in NumaLatencyBench::measure_cross_node_latency() { println!("{: <30} {: >6}ns", label, latency); } Ok(()) }

set_mempolicy必须在进程启动早期调用,因为它只影响后续的mmap/brk分配。已分配的内存页不会被自动迁移。对于推理服务的场景,应在加载模型权重之前设置内存策略,确保权重张量分配在本地 NUMA 节点。

libc::CPU_SETcpu_set_t位图操作宏。Linux 的sched_setaffinity配合cpu_set_t可以实现线程级的 CPU 绑定——对于使用 Tokio 多线程运行时的推理服务,可以让工作线程绑定到本地 Socket 的核上。

四、NUMA 绑定的性能权衡与监控

监控指标与工具链

  1. numastat -c:查看每节点的内存统计

    • numa_hit:本地内存命中次数(应 > 99%)
    • numa_miss:本地分配失败,从远程节点分配的次数
    • numa_foreign:其他节点访问本节点内存的次数(反映跨 Socket 流量)
  2. perf stat -d -d ./inference:观测远程内存访问

    • node-load-misses:跨节点读取 Cache miss
    • node-store-misses:跨节点写入 Cache miss
    • node-load-misses> 5% 的总内存访问,说明 NUMA 绑定不当
  3. likwid-perfctr -C 0-27 -g L3CACHE:详细缓存/内存性能

    • 需要安装likwid工具
    • 可查看 L3 缓存命中率、内存带宽利用率

实际生产配置脚本

#!/bin/bash # 推理服务 NUMA 绑定启动脚本 # 节点 0 的推理进程 numactl --cpunodebind=0 --membind=0 \ taskset -c 0-27 \ ./llama.cpp-server -m model.gguf -t 28 --port 8080 & # 节点 1 的推理进程 numactl --cpunodebind=1 --membind=1 \ taskset -c 28-55 \ ./llama.cpp-server -m model.gguf -t 28 --port 8081 & wait

NUMA 绑定的局限性

  • 如果模型大小超过单节点内存(如 70B 模型需要 140GB,但单节点仅 128GB),必须跨 Socket 分配
  • 此时应使用MPOL_INTERLEAVE(交错分配),虽然增加平均延迟,但避免单个节点 OOM
  • 对于 CPU 推理,权重矩阵的访问模式是顺序的,交错分配对性能影响 < 10%

NUMA Balancing(自动迁移)的干扰

  • Linux 的 Automatic NUMA Balancing 会在后台迁移页面,对延迟敏感的推理产生 500~2000ns 的周期性抖动
  • 建议在推理服务中禁用:echo 0 > /proc/sys/kernel/numa_balancing

监控指标

  • numastat:各节点的内存分配和跨节点访问计数
  • /proc/vmstat中的numa_pte_updatesnuma_hint_faults:自动迁移的活动量

五、总结

  1. 双路服务器的跨 Socket 内存访问延迟(240ns vs 本地 100ns)会降低 LLM CPU 推理吞吐 4065%,NUMA 绑定可将这 40% 回收。
  2. NUMA 感知调度的三层亲和性:CPU Affinity(绑核)、Memory Binding(绑内存)、GPU Affinity(绑 GPU),三层缺一不可。
  3. Memory Binding 必须在进程早期调用set_mempolicy(MPOL_BIND),之后分配的内存(如模型权重)才在本地节点。
  4. Linux Automatic NUMA Balancing 的页面迁移抖动(500~2000ns)对延迟敏感推理有害,应在推理服务中禁用。
  5. NUMA 绑定的副作用是负载不均,需定期检查各节点负载并在超 70% 时允许有限的跨 Socket 调度。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/17 16:08:03

gokr-rsync模块配置详解:轻松管理多个文件同步目录

gokr-rsync模块配置详解&#xff1a;轻松管理多个文件同步目录 【免费下载链接】rsync rsync in Go! implements client and server, which can send or receive files (upload, download, all directions supported) 项目地址: https://gitcode.com/gh_mirrors/rsyn/rsync …

作者头像 李华
网站建设 2026/7/17 16:07:58

danmu源码解析:深入理解Python直播弹幕抓取的实现原理

danmu源码解析&#xff1a;深入理解Python直播弹幕抓取的实现原理 【免费下载链接】danmu Python 弹幕包 A live danmu package for python 项目地址: https://gitcode.com/gh_mirrors/da/danmu 想要快速掌握Python直播弹幕抓取的核心技术吗&#xff1f;danmu项目为开发…

作者头像 李华
网站建设 2026/7/17 16:07:46

小程序毕设选题推荐:基于微信小程序的同城便民信息管理系统设计 多类别信息发布推送服务小程序的设计与实现【附源码、mysql、文档、调试+代码讲解+全bao等】

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

作者头像 李华
网站建设 2026/7/17 16:07:25

小程序毕业设计-分类信息服务平台的设计与实现 基于 SpringBoot 的移动端便民分类信息小程序的设计与实现(源码+LW+部署文档+全bao+远程调试+代码讲解等)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

作者头像 李华
网站建设 2026/7/17 16:03:59

日志采样与存储成本优化:不是所有日志都值得保留 30 天

日志采样与存储成本优化&#xff1a;不是所有日志都值得保留 30 天 一、月均日志存储账单 8000 元的反思 一个 20 个微服务的系统&#xff0c;每天产生约 200GB 日志。ELK 集群的存储费用月均 8000 元&#xff0c;其中超过 60% 的日志在过去 90 天内从未被查询过。运维团队翻了…

作者头像 李华