为什么 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) 可以请求异步复制,但要形成真正的计算与传输重叠,通常还需要:
- 源 Tensor 已经位于 Pinned Memory;
- H2D 操作进入独立或合适的 CUDA Stream;
- 下一阶段计算通过 CUDA Event 等待,而不是全局同步;
- staging buffer 在拷贝完成前不被复用或覆盖;
- 硬件和当前工作负载允许 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 的
restricted或single-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_seconds | H2D 延迟分布 |
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 或切换同步复制的回退开关。
参考资料
- NVIDIA, CUDA C++ Best Practices Guide 13.3 — https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html
- PyTorch, A guide on good usage of non_blocking and pin_memory() — https://docs.pytorch.org/tutorials/intermediate/pinmem_nonblock.html
- NVIDIA, nvidia-smi Documentation — Topology Commands — https://docs.nvidia.com/deploy/nvidia-smi/index.html
- NVIDIA, Nsight Systems User Guide — CUDA Trace — https://docs.nvidia.com/nsight-systems/UserGuide/index.html
- Kubernetes, Control Topology Management Policies on a node — https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/
- Linux man-pages, numactl(8) — https://man7.org/linux/man-pages/man8/numactl.8.html
- vLLM, Engine Arguments — CPU Offload — https://docs.vllm.ai/en/latest/configuration/engine_args/