正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码拆解(二十四):NUMA 绑核与 CPU 亲和——推理进程为什么要把内存落在 GPU 旁边
前面二十余篇沿着 DeepInfer 的请求、调度、KV、执行、采样、并行与通信一路读下来,读到的几乎都是”GPU 上发生了什么”。这一篇换一个视角,把镜头降到 GPU 之外:推理进程启动的那几百毫秒里,引擎在 CPU 与内存这一侧做了哪些布置,以及这些布置为什么会影响一次前向里 H2D 拷贝、NCCL 握手与异步编译的时延。
起点:推理引擎里被忽视的那半边
DeepSeek 官方推理引擎 DeepInfer(发布时对应 DeepSeek-V4.1-Flash 的部署栈)在仓库根目录里放了一个只有约 600 行 Python 的包 deep_cluster/。它不出现在数据流图里,不参与任何一次矩阵乘,却决定了每个 worker 进程被”安置”在机器的哪个位置。
DeepInfer 的整体分层如图 1 所示:引擎进程负责调度与 CPU 侧预处理,worker 进程持有模型并在设备上执行。本文关心的 deep_cluster 与绑核逻辑,落在”引擎/Worker 启动之前”和”Worker 初始化”这两段,处在数据流图的最底层。

图 1:DeepInfer 架构总览。来源:DeepInfer 仓库 doc/images/architecture_overview.png。
如果只看 GPU 侧,一台 8 卡服务器像是八块互相平级的设备;但从操作系统看,机器是一个”内存分域”的拓扑:
- 每颗 CPU 插槽(socket)连着自己的一组内存条,这组内存控制器与挂在上面的 CPU 核构成一个 NUMA 节点(Non-Uniform Memory Access,非一致内存访问);
- 连接到某个 socket 的 PCIe Root Complex 上的 GPU,与这个 socket 的本地内存”距离”最近;
- 另一个 socket 的内存则是远端内存:CPU 核仍能访问,但要穿过片间互连(UPI/Infinity Fabric 一类),延迟更高、可用的有效带宽更低。
图 2 是 Mao(微软,arXiv:2411.01460)给出的 NUMA 节点示意图,用来说明”本地内存 vs 远端内存”的基本结构。

图 2:双内存域、UPI 互连的 NUMA 拓扑。来源:Mao,arXiv:2411.01460 图 1。
顺着图 2 往下读:左下与右上各是一组”CPU 核 + 本地内存”,两组之间由 UPI 链路相连。访问本组的 DRAM 只需经过本组的内存控制器;访问另一组的 DRAM 则要先越过 UPI,再进入对方的内存控制器。这就是”非一致”三个字的来源——不是内存本身不一样,而是路径长短不一样。在 8 卡服务器上,GPU 通过 PCIe 挂到某一个内存域,网卡同样如此;进程的内存页如果没有落在 GPU 所在的那个域里,每次 H2D 拷贝都要先付出这段跨域的代价。
对推理引擎来说,这件事并不抽象,至少有三处会直接踩到:
- H2D 拷贝的源页。每个 decode step 都要把 CPU 侧拼好的输入(token id、调度元数据、seqlens 等)拷进 GPU。若 host 缓冲区落在远端 NUMA 节点,拷贝要先把数据”拉”过片间互连再经 PCIe 发出,小包拷贝的延迟会明显变长。
- NCCL 与 HCCL 的通信线程。张量并行与专家并行每步都要做 AllReduce、dispatch/combine,通信库在 CPU 侧轮询、置旗标、发射网络/链路事务;这些线程若能贴近对应 GPU 的本地核,轮询延迟更稳定。
- 异步编译线程。DeepInfer 的结构化输出由 XGrammar 在独立线程池中异步编译语法,一旦这些线程与推理主线程抢同一批物理核,编译抖动就会变成调度抖动。
把这三件事合起来看,绑核的目标就很清楚了:让”会碰 GPU 的 CPU 线程”和”会被 GPU 读的 host 内存”都在同一个内存域里。
一个常被忽略的事实是:内核自带的自动 NUMA 平衡(AutoNUMA)并不足以保证这种落位。Mao 的论文统计了百度生产集群开启 AutoNUMA 时的本地内存访问比例,平均值只有 57.76%,远低于预期(图 3)。原因并不神秘:AutoNUMA 会根据访问热度迁移页,但在线负载的线程本身在 socket 间频繁迁移,页刚被迁到”本地”,访问它的线程又跑了,迁移反而制造了新的远端访问。

图 3:开启 AutoNUMA 时集群实例的本地访问比例分布,论文给出的平均值约 57.76%。来源:Mao,arXiv:2411.01460 图 3。
这张图解释了一个工程判断:对于延迟敏感的推理进程,与其把落位交给内核”边跑边猜”,不如在启动时用一次显式绑定把”进程—内存—设备”三者的关系钉死。deep_cluster 提供的正是这次显式绑定所需的最小工具集。
下面从 deep_cluster/ 的三个文件出发:设备发现、NUMA 查询与绑定,再到 worker 启动时如何调用它们。
为什么先要”不 import 加速器库”地发现设备
deep_cluster/hardware/device.py 的开头有一行看似无奇的文档字符串:Hardware-neutral device discovery functions——不依赖设备的设备发现。它只 import os、platform、pathlib,通过读取 /proc、/sys 与 /dev 来判断这台机器上有多少块加速器、是哪家厂商的。
1@functools.cache2def accelerator_kind(*, _root: Path = Path("/")) -> AcceleratorKind:3 if ascend.is_available(_root=_root):4 return "ascend"5 if nvidia.is_available(_root=_root):6 return "nvidia"7 return "cpu"NVIDIA 的发现路径:procfs 与 sysfs
nvidia.py 对 GPU 的枚举完全依靠文件系统:
1def _proc_entries(*, _root: Path = Path("/")) -> tuple[Path, ...]:2 gpu_dir = _path(_root, "/proc/driver/nvidia/gpus")3 if not gpu_dir.is_dir():4 return ()5 return tuple(sorted((path for path in gpu_dir.iterdir() if path.is_dir()),6 key=lambda path: path.name))/proc/driver/nvidia/gpus/ 下的每个子目录以 GPU 的 PCI Bus ID(例如 0000:8a:00.0)命名,顺序即内核枚举顺序,与 CUDA 运行时默认的设备序一致。找不到 procfs 条目时退回到 /dev/nvidia0、/dev/nvidia1 这类设备节点。
NUMA 节点则从 sysfs 读:
1bus_id = entries[index].name2candidates = (3 _path(_root, f"/sys/bus/pci/devices/{bus_id}/numa_node"),4 _path(_root, f"/sys/bus/pci/drivers/nvidia/{bus_id}/numa_node"),5)numa_node 文件由内核根据 PCI 设备所在的 Root Complex 给出,是判断”这块 GPU 离哪个内存域最近”最权威的一手信息。注意这里把 _root 做成参数并用 functools.cache 缓存:单元测试可以在临时目录里伪造一整套 /proc 与 /sys 结构,而不需要在真正的 GPU 机器上跑。
计算能力:只用 NVML,不建 CUDA 上下文
device.py 需要知道 GPU 的 SM 主版本,用来给 MFU 的分母定一个上限:
1def get_bf16_tflops_limit() -> int:2 if accelerator_kind() == "ascend":3 return 4324 if is_sm_major_10():5 return 2500 if platform.machine() in {"aarch64", "arm64"} else 22506 raise RuntimeError("unknown GPU type")这个判断发生在 CUDA Graph 捕获、编译等许多步骤之前。如果为了读 compute capability 就 torch.cuda.init(),那么在整个进程生命周期的早期就建立起 CUDA 上下文,会多占显存、也会改变 driver 的初始化时序。nvidia.compute_capability() 因此改用 NVML:
1library.nvmlDeviceGetCudaComputeCapability.argtypes = [2 ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)]nvmlInit_v2 在没有任何 CUDA 上下文时也能工作,返回 None 时上层按”不是 SM10 系”处理。这是一个典型的”引擎只服务一种模型、一种硬件,所以可以精打细算”的风格:连”读一个属性”都不愿意承担上下文的代价。
Ascend 的发现路径:ubcore 与 npu-smi
ascend.py 面对的是另一套拓扑。它从 /sys/class/ubcore/ 下名为 udmaN 或 udmaC<控制器>d<die>e<实体> 的条目出发,读取 device/ub_totalues(总 UE 数)判断是否有可用单元,并读取 device/numa 得到拓扑节点号:
1for entry in _entries(_root=_root):2 device_dir = entry / "device"3 total_ues = device_dir / "ub_totalues"4 if not total_ues.is_file() or _read_int(total_ues) == 0:5 continue6 numa_node = _read_int(numa_path) if numa_path.is_file() else -1如果 ubcore 不可用,则回退到 /dev/davinci0… 的设备节点枚举。两个后端在”NUMA 节点号”上还有一处语义差异:NVIDIA 的 sysfs numa_node 直接就是内核节点号;Ascend 的 device/numa 需要再折算一次:
1return (raw_numa // 2) * 2 + index % 2也就是说,硬件给出的粗粒度节点号会被展开成”每个 NPU 占据一个细分 NUMA 域”的映射,避免同一张卡组里两块 NPU 被绑到同一个核集上。这类细节只在双平台都要维护时才会暴露出来,deep_cluster 把两家厂商的特殊之处都收在这两个文件里,device.py 以上只剩统一的接口。
一张”可见设备”表:rank 与物理卡之间的翻译
从 device.py 中间往外看,核心是同一条规则:进程里的 local_rank 不直接等于物理卡号,中间隔着一次由 CUDA_VISIBLE_DEVICES / ASCEND_RT_VISIBLE_DEVICES 定义的映射。
1def physical_device_id(local_rank: int, *, ...) -> str:2 identifiers = visible_device_ids(...)3 if local_rank < 0 or local_rank >= len(identifiers):4 raise IndexError(f"local rank {local_rank} is outside {len(identifiers)} visible devices")5 return identifiers[local_rank].strip()visible_device_ids() 在环境变量未设置时返回全部物理卡;设为空串或 -1 时返回空元组(与 CUDA 的”对进程不可见”语义一致);否则按逗号切分。于是 physical_device_id(0) 在 CUDA_VISIBLE_DEVICES=4,5 下返回 "4"。
assign_visible_devices() 是这条映射的反向操作,用于在拉起子进程前分配设备:
1group = rank % (count // devices_per_rank)2start = offset + group * devices_per_rank3selected = tuple(str(index) for index in range(start, start + devices_per_rank))4set_visible_devices(selected)它先检查”总卡数必须能被每 rank 卡数整除”,然后按 rank 分组、从 offset 起连续切分。读这段代码时有一处容易看错:offset 是全局起点,不随 group 循环回绕;count % devices_per_rank 非零时直接报错而不是取余,说明设计假设是”整机卡数按 rank 均分”,不做跨机拼接。
这张表在 nvidia.py 的 device_numa_node(index) 里被直接消费:传入的不是物理卡号,而是 procfs 条目里的下标。所以 bind.py 查询 NUMA 前必须先走一遍 physical_device_id 翻译(见下一节),否则在多机、多卡混部时会查到另一块卡所在的节点。
NUMA 查询与绑定:which_numa 与 bind_numa
deep_cluster/topology/bind.py 是这一篇的核心。它的定位也很明确:NUMA lookup and process binding without accelerator runtime imports——不 import 加速器运行时,只通过 libnuma 与 psutil 操作操作系统。
which_numa:这块卡离谁近,网卡又离谁近
1def which_numa(i_gpu: int | None = None, i_ib: int | None = None, *, _root: Path = Path("/")) -> int:2 ...3 identifier = physical_device_id(i_gpu, _root=_root)4 if identifier.startswith("-"):5 raise IndexError(identifier)6 kind = accelerator_kind(_root=_root)7 if kind == "ascend":8 match = re.search(r"(\d+)$", identifier)9 physical_index = int(match.group(1)) if match is not None else int(identifier)10 else:11 physical_index = int(identifier)12 return device_numa_node(physical_index, _root=_root)这里有两个值得展开的点。
其一,identifier.startswith("-") 的处理。 NVIDIA 在 MIG 或部分虚拟化场景中,CUDA_VISIBLE_DEVICES 可能给出 MIG-<uuid> 或带负号的非法项;physical_device_id 不做类型判断,只做字符串翻译,which_numa 用”以负号开头即视为非法”的约定快速失败,避免把异常下标送进 sysfs 读取。
其二,InfiniBand 的查询。 which_numa(i_ib=0) 读取的是:
1numa_path = _root / f"sys/class/infiniband/mlx5_{i_ib}/device/numa_node"这条路径对 GPU 直连网卡(RDMA RoCE)意义很大:当 DeepInfer 做 P/D 分离或跨节点专家并行时,NCCL/HCCL 需要 GPU 显存与网卡之间通过 PCIe Peer-to-Peer 或 GPUDirect RDMA 直接传递数据。若 GPU 和网卡挂在不同的 CPU 根复合体上,数据就要绕一圈”上行到远端 socket 再回来”,带宽与尾延迟都受损。这里把 mlx5_{index} 硬编码为设备名前缀,是因为服务器上的 IB 网卡几乎都是 ConnectX 系列;设备索引按 sys/class/infiniband 下的排序,需要与 ibv_devices 的顺序对应核对。
bind_numa:CPU 亲和与内存策略是两件事
1MPOL_BIND = 22
3def bind_numa(node: int) -> int:4 library = _libnuma()5 cpumask = library.numa_allocate_cpumask()6 if library.numa_node_to_cpus(node, cpumask):7 _perror("get cpus in numa node failed")8 return -19 if library.numa_sched_setaffinity(os.getpid(), cpumask):10 _perror("failed to set affinity")11 return -112 nodemask = library.numa_allocate_nodemask()13 library.numa_bitmask_setbit(nodemask, node)14 if library.set_mempolicy(MPOL_BIND, nodemask.contents.maskp, nodemask.contents.size + 1):15 _perror("failed to set memory policy")16 return -117 return 0这段不到二十行的代码做了三个动作:
numa_node_to_cpus(node, cpumask)把节点号翻译成对应的逻辑 CPU 位图。这一步是”查询”:要知道该节点上具体有哪些核。numa_sched_setaffinity(getpid(), cpumask)把当前线程的 CPU 亲和集设成这个位图;此后由它创建的工作线程在创建时继承这份掩码,只能在这些核上调度。注意这里绑定的是逻辑 CPU(含超线程兄弟),不是cpu.py中按物理核去重后的列表。set_mempolicy(MPOL_BIND, …)设置内存分配策略:此后进程申请内存时,内核只在给定的节点掩码里分配。
后两点必须一起成立才有意义。只做 CPU 亲和,如果内存先于绑定由其他节点分配过,已有页不会迁移,后续访问仍然是远端;只做内存策略,CPU 线程仍可能在别的节点上跑,指令与数据都被”拉远”。MPOL_BIND 的含义是”只在指定节点分配,不满足时宁可进入慢路径回收”,与 MPOL_PREFERRED(优先但不强制)不同——推理进程通常希望确定性,宁可慢也不希望关键缓冲区偶尔落到远端。
第三个参数 nodemask.contents.size + 1 容易被读错:Linux 的 set_mempolicy 中 maxnode 表示”最大节点号 + 1”(位图的位数),而 libnuma 的 struct bitmask 里 size 字段记录的是位图位数;传 size + 1 是 libnuma 自己调用 set_mempolicy 时的惯例,多留一位以确保覆盖所有节点。这一行是 ctypes 直调 libnuma 时必须自己承担的口径细节,也是这段代码最”手写”的地方。
_libnuma() 用 ctypes.CDLL("libnuma.so.1", use_errno=True) 并设置了每个函数的 argtypes/restype。不 import 任何 Python NUMA 包,只为减少一个依赖;use_errno=True 保证失败时 ctypes.get_errno() 能拿到真实的 errno,供 _perror 打印。
set_cpu_affinity:细粒度核集的另一条入口
1def set_cpu_affinity(cpus: Iterable[int]) -> None:2 import psutil3 psutil.Process().cpu_affinity(list(cpus))与 bind_numa 针对”整个节点”不同,这条接口接受任意核集,并且延迟到函数体内才 import psutil,使 deep_cluster 在不想引入 psutil 的场景下(例如只做设备发现的工具)保持轻量。
Worker 启动时的调用顺序
deepinfer/worker/base.py 里,init_model() 是”进程落地”的时刻:
1def init_model(self) -> None:2 configure_worker_environment()3
4 _bind_numa(self.local_rank)5 self.device = torch.device(f"cuda:{self.local_rank}")6 torch.cuda.set_device(self.device)7 configure_worker_kernels()8
9 # Bind CPU.10 bind_cpu(self.gpu_id)11 ...顺序有两个细节值得讲。
先 NUMA、后 CUDA。 _bind_numa 放在 torch.cuda.set_device 之前。原因是 CUDA 上下文初始化时会做大量 host 侧分配(驱动状态、内核库、固定内存池等),这些页在 MPOL_BIND 生效之前落到哪个节点由内核的 first-touch 决定,未必是 GPU 所在的节点。先绑定,后面的 host 分配才”落在 GPU 旁边”。
_bind_numa 的实现与容错:
1def _bind_numa(local_rank: int) -> None:2 # Bind process and memory affinity using the device NUMA topology.3 try:4 from deep_cluster.topology import bind_numa, which_numa5
6 devices = os.environ["CUDA_VISIBLE_DEVICES"].split(",")7 if not 0 <= local_rank < len(devices):8 raise IndexError(f"local_rank={local_rank} out of visible devices {devices}")9 bind_numa(which_numa(local_rank))10 except Exception:11 logger.warning("NUMA 绑定失败,内存落位退化为 first-touch,可能导致 H2D 性能下降", exc_info=True)它先用 CUDA_VISIBLE_DEVICES 做边界检查,再交给 which_numa 翻译。整段被 try/except Exception 包裹,失败只告警不退出——这与 DeepInfer 其他关键路径上”宁可报错也不静默降级”的风格不同。差别在于性质:NUMA 绑定失败是部署环境问题(比如容器里没有 libnuma.so.1,或者 sysfs 被裁剪),引擎仍能正确运行,只是延迟特征劣化;若在这里抛异常导致服务起不来,代价反而更大。告警文案写清楚了退化后的行为(first-touch)与可能的后果(H2D 变慢),便于在不改变引擎功能的情况下定位性能问题。
bind_cpu(self.gpu_id) 又是一次绑定。 gpu_id 不是 local_rank:
1@property2def gpu_id(self) -> int:3 return int(os.environ["CUDA_VISIBLE_DEVICES"].split(",")[self.local_rank])即”物理卡号”。而 bind_cpu 用这个物理卡号去切分 CPU 核:
1def bind_cpu(local_rank: int) -> None:2 phy_cpus = psutil.cpu_count(logical=False)3 ngpus = get_actual_num_gpus()4 num_cpus_per_gpu = phy_cpus // ngpus5 bind_cpus = list(range(local_rank * num_cpus_per_gpu, (local_rank + 1) * num_cpus_per_gpu))6 logger.info(f"bind_cpu gpu_id: {local_rank}, cpus: {bind_cpus}.")7 import deep_cluster.topology8 deep_cluster.topology.set_cpu_affinity(bind_cpus)两句话解释了这段代码的全部逻辑:物理核数除以实际卡数,得到”每卡配额”;按物理卡号把 CPU 连续区间切成不同段,实现”一张卡配一段核”。物理卡号在这里是对的——同一进程组里不同 rank 若都按 local_rank 切分,在 CUDA_VISIBLE_DEVICES=4,5 这类映射下会把 0 号 rank 切到机器最前面的核、5 号 rank 反而切到靠前的区间,导致某些核上多个 rank 叠加,另一些核空置。
三个设计取舍:
- 用物理核计数(logical=False):超线程兄弟共享执行单元,计算侧并发收益有限;用于划分”配额”时以物理核为单位更符合直觉。
- 按物理卡号而非 NUMA 节点切分:
bind_numa已经限定到了卡所在的 NUMA 节点,bind_cpu在此基础上把该节点内的核再在卡间均分。前提是多卡插槽的 CPU 核数与卡数大致成比例,这在典型的 8 卡、双路 64–128 核服务器上是成立的。 - 范围取首个核到最后一个核:
range(start, start + n)里的核编号来自lscpu的 CPU 编号(从 0 开始),而bind_numa之前设置的亲和集是节点内的全部逻辑 CPU 位图。set_cpu_affinity是覆盖而非求交,因此这一步会同时”收窄”逻辑 CPU 范围——绑定 NUMA 之后进程线程原本可以落在该节点所有逻辑 CPU 上,bind_cpu把它们收窄到一段物理核编号。
与 bind_numa 的覆盖关系。 两次绑定叠在一起,要看清楚它们各自的优先级:bind_numa 先把亲和掩码设成”节点内全部逻辑 CPU”,bind_cpu 随后把掩码覆盖为”一段全局编号的物理核”。最终生效的是后者。这一步之所以能成立,依赖的是 Linux lscpu 默认的 CPU 编号方式——按 socket 依次排列,同一 socket 的核是连续编号。双路 8 卡服务器上,bind_numa 把 rank 03 的进程都绑到 socket 0 的核集,3 号卡各自切到 bind_cpu 再按卡号把 0[0, 16]、[16, 32] 这类连续区间,恰好也落在 socket 0 内;4~7 号卡同理落在 socket 1。
所以这里存在一个隐含前提,值得在阅读时心里数清楚:“物理卡号 × 每卡核数”得到的编号区间,必须与”该卡所在 NUMA 节点的核编号区间”一致。真实机器上这个前提常常近似成立,但并非必然:
- 若服务器把 GPU 插在非对称的 PCIe 插槽上(例如 8 卡机里 5 张卡挂在 socket 0、3 张挂在 socket 1),卡号与 socket 的对应关系就不再是”前一半/后一半”,
bind_cpu的切分会让某些卡拿到落在另一个 socket 上的核; - 若容器预先设置了
cpuset,容器内的 CPU 编号可能被重新映射,range(0, 64)这类编号在容器里未必指向同一批物理核; - 若某块卡的
numa_node在 sysfs 里为-1(单 socket 系统或驱动未上报),bind_numa的device_numa_node会抛异常、整段绑定被跳过,此时只剩bind_cpu生效。
代码本身没有校验这一点,这也是把它归为”启发式”而非”拓扑感知”的原因。对部署方来说,最实用的检查方法是在 worker 日志里把 bind_cpu gpu_id: X, cpus: [...] 与 numactl -H 输出的节点核区间对照一遍;两者不一致时,就说明当前机器的插槽布局与代码假设不同,需要显式指定 CUDA_VISIBLE_DEVICES 的排列顺序,让卡号与节点对齐。这一检查也解释了为什么 _bind_numa 失败时引擎仍然选择”告警后继续”:NUMA 绑定失败并不影响正确性,只影响性能特征;而 bind_cpu 无论如何都会执行,保证至少有一个受限的核集。
另一个容易漏掉的地方是进程数与卡数的关系。get_actual_num_gpus() 返回的是物理卡总数,不是”当前 rank 可见的卡数”。在 TP=8、DP=2 的四机部署里,每个节点 8 张卡,每张卡对应一个 worker 进程,公式仍然成立;但若一台 8 卡机上只给某个进程暴露 1 张卡(CUDA_VISIBLE_DEVICES=3),bind_cpu 仍然按”总卡数 8”计算每卡配额,并在物理卡号 3 的区间上绑定——这一步本身是对的,因为它用的是物理卡号而非可见序号。
为什么必须用物理卡号而非 local_rank。 设想一台 8 卡机器以 TP=4、DP=2 的方式跑两份副本,每个副本内部 local_rank 都是 03,但第二份副本通过 3 的区间,第二份落在 4~7 的区间,与它们的 NUMA 节点也自然对齐。这是 CUDA_VISIBLE_DEVICES=4,5,6,7 看到的是后四张卡。如果按 local_rank 切核,第二份副本的 rank 0 会被绑到 [0, 每卡核数],与第一份副本的 rank 0 完全重叠,而 socket 1 的后段反而空置。按物理卡号切,则第一份副本落在物理卡 0gpu_id 这个看似多余的 property 存在的唯一原因:它是把”进程视角的序号”翻译成”机器视角的卡号”的那一步。
结构化输出:异步编译线程的 CPU 预算
第三个使用 deep_cluster 的地方在 LLMEngine。结构化输出需要把语法编译成 XGrammar matcher,这是 CPU 密集且与请求到达节奏相关的工作,DeepInfer 把它放进独立的线程池,并从”每卡核配额”里切出一段专门给这个池子:
1@property2def so_matcher_builder(self) -> SOMatcherBuilder:3 if self._so_matcher_builder is None:4 from deepinfer.structure_output.so_matcher_builder import SOMatcherBuilder5 idle_cpus = get_idle_cpus(self.driver_gpu_id)6 self._so_matcher_builder = SOMatcherBuilder(7 self.tokenizer, self.model_config.get_vocab_size(), self.eos_id,8 max_workers=len(idle_cpus) if idle_cpus else 1,9 bind_cpus=idle_cpus,10 )11 return self._so_matcher_builder1def get_idle_cpus(local_rank: int) -> list[int]:2 phy_cpus = psutil.cpu_count(logical=False)3 num_cpus_per_gpu = phy_cpus // get_actual_num_gpus()4 so_builder_cpus = envs.DEEPINFER_SO_BUILDER_CPUS5 if so_builder_cpus > num_cpus_per_gpu:6 raise RuntimeError(7 f"num_cpus_per_gpu={num_cpus_per_gpu} 不足以容纳 SO builder 绑核 (so_builder_cpus={so_builder_cpus})")8 start = local_rank * num_cpus_per_gpu9 return list(range(start, start + so_builder_cpus))idle_cpus 并不是”空闲核”,而是该卡配额里前几个核。线程池的 initializer 里做的是线程级亲和:
1def _bind_cpu_affinity() -> None:2 # 0 = 调用线程自身:绑定执行 initializer 的 worker 线程,而非整个进程/主线程。3 os.sched_setaffinity(0, set(bind_cpus))注释点出了本篇文章最值得记住的一条:psutil.Process() 默认针对主线程/进程,而 os.sched_setaffinity(0, …) 中的 0 表示”调用线程自身”。ThreadPoolExecutor 的 initializer 在每个 worker 线程启动时被调用一次,于是每个编译线程各自被绑到同一段核上——它们之间仍然彼此争抢,但不会溢出到其他卡的配额里,也不会被其他卡的 rank 抢占。
这个设计的取舍在于:编译线程需要的是稳定的可预测时延(语法编译跟不上会让投机解码的 matcher 回滚与结构化输出的调度出现空窗),而不是吞吐最大化;主推理线程绑了整段配额,编译线程取前 4 个核(DEEPINFER_SO_BUILDER_CPUS 默认 4),属于”从主线程配额里切一块出来”的共享式划分。引擎对 get_idle_cpus 的调用做了懒构造,并在注释里写明”CPU 配额不足的机型上会直接 raise,不消费 SO 的部署不应为此启动失败”——把异常推迟到第一次真正需要结构化输出时,避免单纯因为 CPU 核数少而拒绝启动。
为什么 XGrammar 需要自己的 CPU 预算
要理解为什么值得为几个 CPU 核专门写一段调度代码,需要回忆结构化输出在引擎里的时间线。XGrammar 的 matcher 在编译时需要构建词表 trie、识别每个 token 的”接受路径”,编译完成后才能在解码时逐 token 推进 FSM、生成 bitmask。在 DeepInfer 里,这两件事被分开了:编译在后台线程池中进行,解码时的 matcher 推进仍然在调度主路径上。
编译的代价与模型词表大小、语法复杂度强相关:一个 EBNF 语法可能要为十几万词表项逐一推导状态转移。若这段编译与推理主线程同核竞争,就会出现一种很难定位的抖动——请求的 TTFT 本来只受调度与 KV 预算控制,却因为后台语法在编译而多出几毫秒乃至几十毫秒;而当多个请求同时要求不同语法时,竞争会更明显。
切出 DEEPINFER_SO_BUILDER_CPUS(默认 4)个核只做编译,并不能让编译变快多少(这 4 个核本来也在主线程的配额里),但它把编译的抖动从主线程的 CPU 时间轴上”隔离”出去:推理主线程在这段时间内仍然可以完成 prepare、调度与 H2D 提交,而不必和 trie 构建抢同一个核的缓存与发射端口。对以 P99 TTFT 为目标的在线服务来说,这种隔离的价值远大于几个核的原始算力。
多模态引擎侧的 NUMA 绑定
LLMEngine 初始化时,如果模型带多模态处理器,还会单独把 rank 0 绑一次:
1mm_enabled = hasattr(model_class, "multi_modal_mapper") and engine_type != EngineType.COMPLETION2if mm_enabled:3 _bind_numa(local_rank=0)4 self.multi_modal_processor = MultimodalProcessor(...)多模态处理(图像解码、缩放、切 tile)在 CPU 上进行,输出的 tensor 随后要拷进 GPU。绑定到 rank 0 所在 GPU 的 NUMA 节点,让这条路径的 host 缓冲区同样落在本地内存。严格来说,多模态请求若由 rank 0 以外的进程处理则未必正确;在当前引擎里多模态处理器统一放在 driver(rank 0)侧,所以绑 rank 0 与该职责边界一致。
设备平台与 HCCL:另一处”GPU 之外”的初始化
deepinfer/platform.py 里,get_platform() 使用同一个设备发现入口决定走 CUDA 还是 NPU:
1@functools.cache2def get_platform() -> Platform:3 from deep_cluster.hardware import accelerator_kind4 platform_class = PlatformNPU if accelerator_kind() == "ascend" else PlatformGPU5 return platform_class()PlatformNPU 的构造里,除了把 ASCEND_RT_VISIBLE_DEVICES 与 CUDA_VISIBLE_DEVICES 对齐,还会设置一组 HCCL 环境变量:
1os.environ["CUDA_VISIBLE_DEVICES"] = ascend_devices # 若只有一方设置2os.environ["TASK_QUEUE_ENABLE"] = "0"3os.environ["HCCL_BUFFSIZE"] = "16"4os.environ.setdefault("HCCL_CONNECT_TIMEOUT", "240")5os.environ.setdefault("HCCL_EXEC_TIMEOUT", "200")把这些放在”绑核”这篇文章里讲,是因为它们同属一个主题:进程外的通信线程与设备可见性。TASK_QUEUE_ENABLE=0 关掉 NPU 的任务队列卸载,让每个算子提交仍是同步语义;HCCL_BUFFSIZE=16(单位 MB)限制每通道的通信缓冲,缓冲越大每次集合通信的额外显存越多,而 Expert Parallel 的 dispatch/combine 在每步都有大量小包;超时使用 setdefault 而不是直接覆盖,是为了让运维能按集群规模调整,而不被代码写死。
一个读代码时容易混淆的对照:逻辑核与物理核
把 deep_cluster 里所有”核”的用法整理一遍:
| 位置 | 单位 | 来源 | 作用 |
|---|---|---|---|
nvidia.device_numa_node | 节点号 | sysfs numa_node | 定位 GPU 所属内存域 |
bind_numa | 节点号 → 逻辑 CPU 位图 | libnuma numa_node_to_cpus | 进程级亲和 + 内存策略 |
set_cpu_affinity | 显式核集 | 调用方传入 | 覆盖进程亲和集 |
bind_cpu | 物理核编号区间 | lscpu/psutil 物理核数 | 按物理卡号均分配额 |
SOBuilder initializer | 物理核编号区间(线程级) | get_idle_cpus | 编译线程不外溢 |
cpu.py 里的 physical_core_cpus_by_numa 则提供了”物理核按节点分组”的版本,仅供 Ascend 的 npu-smi 拓扑解析用来判断 NPU 与哪些核最接近:
1key = (numa_node, socket, core)2cores[key] = min(cpu, cores.get(key, cpu))同一 (节点, socket, core) 下的多个逻辑 CPU(超线程兄弟)只保留编号最小的那个,得到”每物理核一个代表编号”。
对工程实践的几点启发
第一,先”量”再”绑”。 部署前用 numactl -H、nvidia-smi topo -m 与 cat /sys/bus/pci/devices/*/numa_node 把 GPU、IB 网卡与内存域的实际关系画出来,再用 _bind_numa 的日志确认每个 worker 落到了预期节点。多机异构(比如不同代际的 CPU 插槽混部)时,启发式切分可能失效。
第二,容器环境是这套逻辑最容易失效的地方。 capsys 裁剪 sysfs、缺少 libnuma.so.1、cpuset 已经预先限定了核集,都会让绑定”看似成功、实则无效”。set_mempolicy 在容器内若无相应 capability 也可能报 EPERM。此时 _bind_numa 的告警会如实出现,可以作为排查入口。
第三,CPU 侧线程模型要和绑核一起看。 DeepInfer 把主推理线程放在”每卡配额”上,又把结构化输出编译从配额里切出一段;另外 NCCL/HCCL 内部的轮询线程由通信库自行部署。若部署时再叠加 taskset 或 cgroup 的 CPU 上限,可能出现主线程配额被压缩到只剩几个核、编译线程反而与通信线程同核的情况。
第五,用一段可复现的小程序验证平台假设。 device.py 的 _root 参数意味着这套发现逻辑可以直接对伪造的 /proc、/sys 目录运行。在真实部署前,把目标机器的 numa_node、proc/driver/nvidia/gpus 结构复制到临时目录,跑一遍 which_numa,比在线上”看日志猜”更快定位插槽布局与代码假设的偏差。对 Ascend 平台同理,把 /sys/class/ubcore 的条目结构复制出来,验证 device_numa_node 的折算结果是否与 npu-smi info -t topo 一致。
第四,绑核的收益要落到指标上。 比较 TTFT 的 P99/TPOT 的抖动,而不是平均吞吐:绑核主要改善的是尾延迟,均值层面的差异往往被批大小与带宽主导。做对照实验时,固定同一 batch 形状、只在”绑与不绑”之间切换,才看得清 NUMA 落位对 H2D 与通信的真实影响。
小结
deep_cluster 很小,但把三件事统一放在了”不依赖加速器运行时”的约束下:设备发现走 procfs/sysfs,NUMA 查询走 sysfs 与 libnuma,进程绑定走 libnuma 与 psutil。worker/base.py 则给出了一条清晰的启动顺序——先绑定内存与 CPU,再建 CUDA 上下文,然后按物理卡号切 CPU 配额;LLMEngine 在其上再切一块给结构化输出的异步编译线程,并对失败采取”告警降级”。这些代码不参与任何一次前向的计算,却决定了同一份模型在同一台机器上尾延迟能有多稳。
与前面各篇的联系:设备可见性映射直接决定了 DeepInfer 源码拆解(十三):DP Attention 与 Wide-EP 里各 rank 如何被放进并行组;结构化输出线程池服务于 DeepInfer 源码拆解(六):结构化输出与确定性 中 XGrammar 的异步编译管线;而 H2D 缓冲区的落位与 DeepInfer 源码拆解(二十二):InputMetadata 的构造 里每步输入张量的位置互相印证。下一篇把同一套进程视图扩展到集合通信:parallel_utils 里的自定义 AllReduce、UserBuffer 与通信组建立过程,看看 DeepInfer 如何把 NCCL 的接口封装成适合自身调度节奏的形状。
参考资料
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



