文章

LLM CPU–GPU 数据通路生产实战:用 NUMA 亲和、Pinned Memory 与异步拷贝降低推理抖动

本文从大模型推理中容易被忽视的 CPU 到 GPU 数据通路出发,系统讲解 NUMA 亲和、页锁定内存、异步拷贝、拓扑调度与容器对齐等关键环节,并结合性能回放与上线门禁,帮助团队有效减少主机侧传输抖动、尾延迟和跨节点资源错配。

为什么 GPU 很忙,推理仍然会抖

大模型推理优化通常把注意力放在 GPU Kernel、KV Cache、批处理和并行策略 上,但一条请求在进入模型前,仍要经过 Tokenize、组批、主机内存写入、Host-to-Device(H2D)拷贝和 CUDA 调度。输出阶段也可能把少量结果搬回 CPU。

在单路服务器上,这些开销容易被 GPU 计算掩盖;到了双路或多路 NUMA 服务器,问题会明显复杂:推理进程可能运行在 Socket 0,批次缓冲区却由 Socket 1 的内存页承载,而目标 GPU 又挂在 Socket 0 的 PCIe Root Complex 下。数据必须跨 NUMA 互连再进入 PCIe,路径更长,也更容易与其他工作负载争用。

因此,线上常见一种误判:GPU 利用率看起来不低,但首 Token 延迟和 P99 仍周期性升高。真正的瓶颈可能不是模型计算,而是主机侧数据准备、远端 NUMA 访问、小块拷贝、同步等待或错误的资源放置。

核心原理:把主机到设备视为一条完整流水线

1. CPU、内存和 GPU 存在物理邻近关系

在 NUMA 服务器上,每颗 CPU 都有本地内存节点。GPU 通过 PCIe Switch 或 Host Bridge 连接到某个 CPU Socket。推理进程使用的 CPU 核、内存页和 GPU 越接近,数据路径通常越直接。

nvidia-smi topo -m 可以显示 GPU、NIC、CPU 与内存亲和关系,并区分 PIX、PXB、PHB、NODE、SYS 和 NVLink 等路径。生产环境不应只记录”节点有几张 GPU”,还应保存:

  • GPU 到 CPU 核的 affinity;
  • GPU 最近的 NUMA CPU 与 memory node;
  • GPU、NIC、NVMe 是否共享 PCIe 路径;
  • 进程实际 CPUSet 和内存分配节点;
  • 容器调度后是否仍保持拓扑对齐。

2. Pinned Memory 解决的是可 DMA 的主机缓冲区

普通 pageable memory 在参与 H2D 拷贝时,CUDA 往往需要先把数据暂存到页锁定区域。Pinned Memory(页锁定内存) 不能被操作系统换出,更适合 DMA,通常可获得更稳定的主机到设备传输。

但它不是免费资源。锁页本身有成本,过量页锁定会挤压系统可分页内存,影响整个节点。正确做法不是对所有对象临时调用 pin_memory(),而是维护有上限、可复用、按 Shape 分档的 staging buffer 池。

3. non_blocking 只是异步请求,不等于自动重叠

PyTorch 的 tensor.to(device, non_blocking=True) 可以请求异步复制,但要形成真正的计算与传输重叠,通常还需要:

  1. 源 Tensor 已经位于 Pinned Memory;
  2. H2D 操作进入独立或合适的 CUDA Stream;
  3. 下一阶段计算通过 CUDA Event 等待,而不是全局同步;
  4. staging buffer 在拷贝完成前不被复用或覆盖;
  5. 硬件和当前工作负载允许 Copy Engine 与计算并发。

也就是说,优化对象不是一个参数,而是 缓冲区生命周期、Stream 依赖和批次流水线

工程落地:六步建立可验证的数据通路

第一步:为每种节点建立拓扑清单

先在节点初始化阶段采集并固化拓扑:

nvidia-smi topo -m
nvidia-smi topo -C -i 0
nvidia-smi topo -M -i 0
numactl --hardware
lscpu -e=CPU,NODE,SOCKET,CORE

不要假设同一机型的所有节点完全一致。PCIe 插槽、BIOS、MIG、虚拟化和设备故障替换都可能改变实际路径。建议把拓扑结果生成指纹,并与节点标签、GPU UUID 和驱动版本一起进入资产清单。

第二步:绑定推理进程的 CPU 与主机内存

确认目标 GPU 最近的 NUMA 节点后,可以用 numactl 做受控实验:

numactl \
  --cpunodebind=0 \
  --membind=0 \
  python -m vllm.entrypoints.openai.api_server \
  --model /models/example

--cpunodebind 约束进程运行在哪些 NUMA 节点的 CPU 上,--membind 约束内存页从哪些节点分配。严格绑定可能在本地内存不足时导致分配失败,因此上线前必须验证容量,并准备降级为 preferred 或较宽的节点集合。

还要警惕 AutoNUMA。NVIDIA 的 CUDA 最佳实践文档指出,自动 NUMA balancing 在某些 GPU 工作负载中可能降低性能。是否关闭或调整,必须基于目标内核、发行版和真实压测决定,而不是照搬统一配置。

第三步:建立有界的 Pinned Buffer Pool

推荐按输入形状或 Token 数量建立少量 Bucket,例如短、中、长三类,而不是为每个请求单独锁页。池需要记录:

  • 总页锁定字节数;
  • 空闲、传输中和可回收缓冲区数量;
  • 单次等待时间;
  • 超过上限后的 pageable fallback 次数;
  • Buffer 对应的 CUDA Event 是否已经完成。

一个简化的 PyTorch 示例:

import torch

h2d_stream = torch.cuda.Stream()
ready_event = torch.cuda.Event()

# 生产环境应由有界池管理,而不是每个请求重新创建。
host_ids = torch.empty(
    (32, 4096), dtype=torch.int32, pin_memory=True,
)

def copy_batch_to_gpu(batch_ids: torch.Tensor) -> torch.Tensor:
    rows, cols = batch_ids.shape
    # 先将组批结果写入可复用的 pinned staging buffer。
    host_view = host_ids[:rows, :cols]
    host_view.copy_(batch_ids)
    # 在独立 Stream 上发起异步 H2D。
    with torch.cuda.stream(h2d_stream):
        device_ids = host_view.to("cuda", non_blocking=True)
    ready_event.record(h2d_stream)
    # 当前计算 Stream 只等待本批数据,不做 device-wide synchronize。
    torch.cuda.current_stream().wait_event(ready_event)
    return device_ids

真实实现必须保证:在 ready_event 完成之前,host_view 不会重新分配给下一批请求。否则会出现难以复现的数据覆盖问题。

第四步:合并小拷贝,避免请求级碎片化传输

CUDA 最佳实践建议将多个小传输聚合为较大的连续传输。对 LLM Serving 来说,应优先在 CPU 侧完成:

  • Token ID 拼接;
  • position、slot、sequence length 等元数据打包;
  • Shape Bucket 对齐;
  • 一次或少量 H2D,而不是每个请求、每个字段分别拷贝。

聚合并不意味着盲目扩大批次。目标是减少固定传输开销,同时不破坏 TTFT 和调度公平性。

第五步:让容器调度保持拓扑对齐

在 Kubernetes 中,仅申请 nvidia.com/gpu: 1 并不能自动保证 CPU、内存和 GPU 位于同一 NUMA 节点。对延迟敏感的独占推理 Pod,可评估:

  • CPU Manager 的 static policy;
  • Topology Manager 的 restrictedsingle-numa-node
  • pod scope,确保 sidecar 和主容器尽量使用共同 NUMA 集合;
  • 明确的 CPU request/limit 和 Guaranteed QoS;
  • 与 NVIDIA Device Plugin、节点资源管理器的兼容性。

single-numa-node 会在无法满足单节点对齐时拒绝 Pod,适合稳定性优先的专用节点,不适合未经容量规划就全局开启。

第六步:用时间线证明优化有效

不要只比较总吞吐。Nsight Systems 的 CUDA Trace 可以在时间线上显示 H2D/D2H memory operations、CUDA API、Kernel 和 Stream,从而回答:

  • H2D 是否发生在预期 Stream;
  • Copy 与 Kernel 是否真正重叠;
  • 是否存在大量短小 memcpy;
  • CPU 线程是否在同步 API 上长时间阻塞;
  • Pinned、Pageable 或 Unified Memory 是否引入额外路径;
  • 拷贝完成后,GPU 是否仍因调度空洞而等待。

性能回放至少覆盖短输入、长输入、突发并发、混合 Shape、不同 GPU 插槽和不同 NUMA 绑定方式。

建议监控的指标

至少建立以下指标,并按节点、GPU、NUMA、模型和输入 Bucket 分组:

指标说明
h2d_copy_duration_secondsH2D 延迟分布
h2d_bytes_total实际搬运字节数
h2d_copy_count每批拷贝次数
pinned_pool_bytes / pinned_pool_wait_seconds池容量与等待时间
pageable_fallback_total退化为 pageable 的次数
remote_numa_access节点级 NUMA miss 指标
Tokenize/Batch/Copy/Prefill 分段耗时各阶段耗时分解
GPU idle gap、TTFT、TPOT 与 P99端到端延迟指标
拓扑指纹、CPUSet、GPU UUID部署版本关联

出现尾延迟异常时,应能把一次请求还原成:在哪个 CPU 上组批、从哪个 NUMA 节点取内存、复制到哪张 GPU、经历多少次 memcpy,以及哪一步发生等待。

适用场景

这套治理对以下场景更有价值:

  • 双路或多路 CPU 的多 GPU 推理服务器;
  • 输入 Shape 波动大、组批元数据多的在线 Serving;
  • 模型较小、GPU 计算时间较短,主机侧开销占比上升;
  • VLM、语音或 Embedding 服务,需要频繁搬运较大输入;
  • CPU Offload、KV Offload 或分层缓存方案;
  • GPU、NIC 和 NVMe 共享复杂 PCIe 拓扑的节点;
  • Kubernetes 中需要独占 CPU 核和低尾延迟的推理 Pod。

对于计算占绝对主导、单路 CPU、单 GPU 且输入很小的服务,收益可能有限,应该先 Profiling 再投入改造。

常见误区

误区一:开启 pin_memory 就一定更快

临时把 pageable Tensor 转为 pinned Tensor 本身是阻塞操作。如果每个请求都现场锁页,额外成本可能抵消传输收益。应优先预分配和复用,并限制池大小。

误区二:non_blocking=True 就代表已经异步

如果源内存不是 pinned、Stream 关系不正确,或代码随后执行全局同步,就不会形成预期重叠。必须用时间线确认,而不是只看 API 参数。

误区三:GPU 利用率高说明数据路径没有问题

利用率是时间窗口内的聚合指标,无法直接暴露微小但频繁的空洞。短暂 H2D 阻塞可能显著抬高 TTFT 和 P99,却不一定明显降低平均 GPU 利用率。

误区四:CPU Offload 等于免费扩展显存

vLLM 文档明确提示,CPU Offload 依赖快速的 CPU–GPU 互连,因为模型的一部分会在 Forward 过程中从主机侧访问或搬运。跨 NUMA 或拥塞 PCIe 会把显存问题转化为持续延迟问题。

误区五:NUMA 绑定越严格越好

严格 membind 在本地内存不足时可能直接失败;过窄 CPUSet 也可能让 Tokenizer、网络线程和调度线程争用。应按角色拆分 CPU、预留容量,并保留可回滚配置。

上线检查清单

  • 已采集 GPU、CPU、NUMA、NIC、NVMe 的拓扑和版本指纹。
  • 推理进程的 CPUSet、memory policy 与目标 GPU 邻近。
  • Pinned Memory 采用有界池,存在容量、等待与 fallback 指标。
  • staging buffer 在 CUDA Event 完成前不会被重复使用。
  • 小型 H2D 已合理合并,未引入过大的组批等待。
  • Nsight Systems 已证明 H2D 与计算存在预期重叠。
  • 已比较默认、绑定和降级三套配置的 TTFT、P99 与吞吐。
  • Kubernetes 拓扑策略已在容量不足和 Pod 重调度场景验证。
  • CPU Offload 或 Unified Memory 已独立评估,不与普通路径混为一谈。
  • 上线门禁包含拓扑变化、远端 NUMA 访问和 pageable fallback 告警。
  • 已准备关闭绑定、缩小 pinned pool 或切换同步复制的回退开关。

参考资料

  1. NVIDIA, CUDA C++ Best Practices Guide 13.3https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html
  2. PyTorch, A guide on good usage of non_blocking and pin_memory()https://docs.pytorch.org/tutorials/intermediate/pinmem_nonblock.html
  3. NVIDIA, nvidia-smi Documentation — Topology Commandshttps://docs.nvidia.com/deploy/nvidia-smi/index.html
  4. NVIDIA, Nsight Systems User Guide — CUDA Tracehttps://docs.nvidia.com/nsight-systems/UserGuide/index.html
  5. Kubernetes, Control Topology Management Policies on a nodehttps://kubernetes.io/docs/tasks/administer-cluster/topology-manager/
  6. Linux man-pages, numactl(8)https://man7.org/linux/man-pages/man8/numactl.8.html
  7. vLLM, Engine Arguments — CPU Offloadhttps://docs.vllm.ai/en/latest/configuration/engine_args/

常见问题

开启 pin_memory 和 non_blocking 后,H2D 拷贝就一定能与计算重叠吗?
不一定。真正的重叠还要求源数据位于页锁定内存、拷贝运行在合适的 CUDA Stream、GPU 具备并发复制能力,并且后续计算通过 Event 正确等待数据就绪。
NUMA 绑定是否应该在所有 GPU 推理节点上强制开启?
不应直接全局强制。应先确认 GPU、CPU 和内存节点的实际拓扑,再通过压测比较绑定前后的 H2D 延迟、CPU 开销和尾延迟;资源不足时,严格 membind 还可能导致分配失败。
CPU Offload 能否等价地扩展 GPU 显存而不影响性能?
不能。CPU Offload 会把部分权重或状态放在主机内存,并在推理过程中访问或搬运,性能高度依赖 CPU–GPU 互连、NUMA 位置和访问模式,必须单独做吞吐与尾延迟验证。