1. 从调度视角重新认识 NVIDIA 硬件
做 GPU 调度绕不开一个基础问题:你到底在调度什么?很多刚接触集群调度或者推理加速的朋友,上来就去看 Kubernetes 的 device plugin、去看 vLLM 的 PagedAttention,结果发现文档越看越晕。原因很简单——你还没建立起“硬件结构决定调度策略”这条主线。我写这个系列,第一篇文章就打算把 NVIDIA 的硬件结构彻底摊开,从调度器的视角重新审视每一层硬件组件。
所谓调度,本质上是“在正确的时间,把正确的任务放到正确的计算单元上”。放在 NVIDIA 的场景里,这个“计算单元”不是一个模糊的“GPU”,而是从 CPU 侧到 GPU 内部 SM(Streaming Multiprocessor)的一整条层级链路。任务从主机端发起,经过 PCIe 或 NVLink 传输,到达 GPU 显存,再由 GPU 内部的线程调度器分发到具体的计算核心。这一整条路径上,每一个环节都有资源约束,也都对应着不同的调度决策点。
这篇文章适合谁看?如果你是做 GPU 集群管理、推理服务性能优化、或者正在研究 vLLM、TensorRT-LLM 这类框架的 scheduling 实现,建议花十分钟把硬件结构搞清楚。否则后面讨论“为什么这个调度策略能提升吞吐”“为什么显存分配要这样设计”的时候,你会缺少一个关键的认知底座。
2. 完整计算链路的宏观拆解:从 CPU 到 GPU 的每一步
2.1 CPU 与 GPU 的分工边界在哪
先看整条链路的起点。任何 GPU 任务都始于 CPU 侧的一段代码,哪怕是最简单的cudaMemcpy,也需要 CPU 先发起调用。CPU 的角色是“控制面”,负责逻辑判断、任务编排、内存分配;GPU 的角色是“数据面”,负责大规模并行计算。这个分工决定了调度器必须同时管理两个侧面的资源状态。
CPU 侧的资源状态包括:当前有多少个进程在排队、每个进程需要多大的显存、数据传输的源地址和目的地址是否就绪。GPU 侧的资源状态包括:SM 占用率、显存剩余量、计算队列深度、NVLink 带宽占用等。一个成熟的调度器(比如 Kubernetes 的 gpu-device-plugin 配合 scheduler-extender)要做的就是把这两侧的状态统一抽象成可量化的资源视图,然后做出分配决策。
这里面有个常见的认知误区:有人觉得 GPU 调度就是“分配显存大小”。实际上显存只是 GPU 资源的一种,而且往往是最好管理的一种。真正的调度难点在 SM 计算资源的分配和上下文切换的代价控制。举个例子,一个 A100 80GB 的卡,显存划分成两个 40GB 的实例很容易,但如果两个任务都需要大量计算资源,它们会在 SM 层面互相争抢,导致各自的吞吐都上不去。这就是为什么新架构里出现了 MIG(Multi-Instance GPU)技术——后面我详细讲。
2.2 系统拓扑:PCIe 和 NVLink 决定数据传输的上限
现代 GPU 服务器里,CPU 和 GPU 之间、GPU 和 GPU 之间,通过两种主流总线互联:PCIe 和 NVLink。这两者的带宽差异极大,直接决定了数据移动的策略。
PCIe Gen4 x16 的单向带宽大约是 32GB/s,双向 64GB/s。看起来不小,但和 GPU 内部的计算速度比起来就差远了——A100 的 FP16 算力是 312 TFLOPS,如果计算一个 FP16 矩阵,每秒钟需要消费的数据量远超 PCIe 能提供的带宽。换句话说,PCIe 是瓶颈,而且是严重瓶颈。
NVLink 的出现就是为了解决这个问题。以 A100 为例,第三代 NVLink 单链路带宽是 50GB/s 双向,一张卡通常有 12 条 NVLink 链路,总双向带宽达到 600GB/s。到了 H100 的第四代 NVLink,单卡总带宽进一步提升到 900GB/s。这意味着什么?意味着如果你有多卡互联的需求,数据通过 NVLink 传输比通过 PCIe 快一个数量级,调度器在做数据放置决策时,必须优先考虑数据在 NVLink 域内的局部性。
实际调度时的选择逻辑是这样的:如果一个任务需要频繁和其他 GPU 上的任务交换中间结果(比如张量并行的 Transformer 模型),调度器应该把这几张卡放在同一个 NVLink 域内。在 DGX 系列服务器里,8 张 GPU 通过 NVSwitch 全互联,任何两张卡之间都能享受 NVLink 带宽。而在普通双卡服务器里,GPU 之间的通信可能要走 PCIe Switch,甚至经过 CPU 的 PCIe Root Complex,延迟和带宽都会有明显劣化。
2.3 数据搬运中的隐藏成本:拷贝与映射的取舍
在 CPU 与 GPU 之间搬运数据,有两种基本方式:显式拷贝(cudaMemcpy)和统一寻址(Unified Memory)。这两种方式在调度器设计中的含义完全不同。
显式拷贝的优点是行为可预测——调度器清楚知道数据传输什么时候开始、什么时候结束、占用了多少 PCIe 带宽。缺点是编程复杂,需要手动管理两块内存的生命周期。统一寻址的优点是透明,GPU 缺页时自动从 CPU 内存迁移数据,缺点是性能不可控——你无法预知 GPU 什么时候会触发缺页中断,数据迁移的延迟实时变化,这对调度器来说是极大的不友好。
从我实际做调度的经验来看,凡是追求确定性延迟的场景(比如在线推理),必须用显式拷贝,并且最好用 CUDA Stream 将传输和计算重叠起来。凡是追求开发效率的原型验证场景,可以用统一寻址,但要做好性能波动的心理准备。调度器的设计必须感知这两种模式,否则你无法准确估算一个任务的实际执行时间。
3. GPU 内部的核心微观结构:从 SM 到线程束的工作原理
3.1 SM 的内部构造与各组件职责
前面讲的是数据如何到达 GPU,现在讲数据到了 GPU 之后怎么被计算。GPU 的计算核心是 SM(Streaming Multiprocessor),一个 GPU 上有几十到上百个 SM,每个 SM 内部又分成几个处理块。
以 Ampere 架构的 GA100 为例,每个 SM 包含 4 个处理块,每块有 32 个 FP32 CUDA Core 和 32 个 INT32 单元,另外还有 4 个 Tensor Core(第三代)。寄存器文件(Register File)大小是 256KB,Shared Memory 最大 164KB。这个配置决定了每个 SM 能同时容纳多少线程、多少 warp。调度器的核心任务之一,就是决定把多少个 block 放进同一个 SM,这个决策直接受限于寄存器和共享内存的容量。
这里有个关键概念叫 occupancy(占用率),指的是实际活跃的 warp 数占理论上限的比例。Occupancy 越高,越能隐藏访存延迟——一个 warp 在等数据回来的时候,调度器可以切到另一个 warp 执行计算。但这不代表 occupancy 越高越好。过高的 occupancy 意味着每个线程分到的寄存器变少,可能导致寄存器溢出(spill),反而拖慢性能。
实战里我踩过这个坑:在调一个 NLP 模型的 kernel 时,把 block 大小从 256 改成 512,occupancy 确实提升了,但性能反而下降了 15%。原因是寄存器不够用,部分变量被压到 local memory,产生了额外的访存开销。调度器在设计时,不能只看 SM 占用率这个单一指标,要综合考察寄存器压力、shared memory 用量、访存模式等多个维度。
3.2 Warp 调度机制对任务切分的影响
Warp(线程束)是 NVIDIA GPU 调度的基本单位,一个 warp 包含 32 个线程。GPU 的 warp scheduler 每周期从可执行的 warp 中选一个发出一条指令。这个机制对上层调度器有深刻影响:当你想把一个任务拆成细粒度的小任务去填充 GPU 时,你真正"填充"的是 warp slot,而不是线程 slot。
在上面提到的 Ampere SM 中,每个 SM 有 4 个 warp scheduler,每个 scheduler 最多管理 16 个 warp,所以单个 SM 最多可以容纳 64 个 warp,也就是 2048 个线程。这个数字是所有任务拆分时的硬约束。如果你设计了一个 kernel,每个 block 有 1024 个线程,那么一个 SM 最多同时容纳 2 个这样的 block;如果每个 block 只有 128 个线程,SM 可以容纳 16 个 block(理论上限,实际还要看寄存器和 shared memory)。
从调度器的视角看,block 的粒度设计不只是 GPU 内部的事,它直接影响你能不能把一个 GPU 有效地分给多个任务。比如 A100 有 108 个 SM,如果你用的推理框架(比如 TensorRT-LLM)默认把整个 GPU 当成一个执行单位,那么并发多个请求时,block 的分配由框架内部决定,你无法细粒度控制。这也是很多人觉得"GPU 利用率上不去"的根源——不是 GPU 不行,而是你给的并行度不够,或者任务拆得太粗。
3.3 异步执行与 CUDA Stream 的资源隔离
CUDA Stream 是 GPU 任务管线的核心抽象。同一个 Stream 里的操作按顺序执行,不同 Stream 之间的操作可以并发。这个并发能力是调度器实现计算和传输重叠的基础——一个 Stream 在做 kernel 计算,另一个 Stream 可以同时做数据拷贝。
但 Stream 不是免费的。每个 Stream 在硬件上占用一定的资源,创建太多 Stream 反而增加调度开销。GPU 硬件层面有多个 DMA 引擎(Copy Engine)和多个计算队列,SM 层面的 warp scheduler 会轮转服务来自不同 Stream 的任务。设计调度器时,Stream 的数量需要根据任务特征动态调整,固定的 Stream 池通常是一种次优解。
在实际落地时,我常用的模式是"动态 Stream 池":预先创建一组可复用的 Stream,任务到达时从池中取一个,执行完归还。这样避免了频繁创建/销毁 Stream 的系统调用开销,同时保持了并发度可控。
4. 内存层级与访存模式:调度器必须吃透的隐性约束
4.1 显存带宽与存储层级:不是所有数据都一样快
GPU 的片上存储层级从快到慢依次是:寄存器 -> Shared Memory -> L1 Cache -> L2 Cache -> 显存(HBM)-> CPU 内存。每一级的带宽和延迟差异是两个数量级起步的量级。寄存器访问没有延迟,Shared Memory 的延迟大约 20~30 个周期,L2 Cache 命中在 200 个周期左右,而 HBM 显存的延迟高达 400~600 个周期。
调度器在做任务放置时,需要估算任务的访存模式:如果一个任务的数据是流式的(读取一次不再复用),把它安排在带宽优先的配置上(比如让多个 SM 共享 L2 的尾部(tail)部分);如果一个任务的数据局部性强(反复访问同一块数据),应该优先利用 Shared Memory。这个决策在集群调度层面通常被忽略,但在单卡多租户场景(比如 MIG)中非常重要。
有一个值得注意的细节是 L2 Cache 的划分。Ampere 架构支持 L2 Cache 的 Set-aside(预留)功能,可以为特定流设置固定的 L2 容量。这在多任务共享 GPU 时会显著影响性能。比如一个任务对延迟敏感,你可以把它设置为 20MB L2 预留;另一个任务带宽型,不需要太多 cache,走默认策略即可。调度器如果能感知并控制这些"软资源",对混合负载的性能隔离会有质的提升。
4.2 显存分配策略对调度器的影响
显存的分配和回收是调度器最容易感知的资源维度。NVIDIA 驱动层的显存分配默认是惰性的(lazy allocation)——cudaMalloc只保留虚拟地址空间,物理显存页面在实际访问时才映射。这带来一个坑:任务启动时占用的显存可能远小于它峰值需要的显存,两个任务的显存使用量可能在运行中动态变化。
对调度器来说,这意味着单纯记录"已分配显存"是不准确的,必须同时跟踪"保留显存"和"实际使用显存"两个指标。在 Kubernetes 环境下,通过 nvidia-smi 查询到的显存使用量是实际使用量,而 dmon 里记录的 FB_USED 是设备端实际分配的物理显存。两者的口径差异会导致调度误判——以为显存还够用,结果新任务一启动就 OOM。
解决思路有两个层面:一是调度器层面对显存预留做保守估计,按任务声明峰值分配;二是利用 CUDA 的显存池机制,比如通过 CUDA 的 VMM(Virtual Memory Management)接口做细粒度管理。后者更灵活,但实现复杂度高,适合对显存管理有极端需求的场景(比如 vLLM 的 continuous batching 就是自己管理 KV Cache 显存)。
4.3 显存带宽的计算示例:这个任务到底快不快
来看一个实际的计算示例。假设我们要在 A100 上搬运一个 2GB 的矩阵做矩阵乘法。A100 的 HBM2e 带宽是 2TB/s 左右(注意后面和 PCIe 带宽的对比)。理论上的最小搬运时间是 2GB / 2TB/s = 1ms。这个数据说明的是:一旦数据已经在显存里,GPU 内部的读取速度是很快的。
但如果你是从 CPU 内存通过 PCIe 拷贝进 GPU,PCIe Gen4 x16 的带宽约 32GB/s,传送 2GB 需要 62.5ms。这个时间差异达到两个数量级。换句话说,数据放错了位置,性能损失是灾难性的。调度器的核心职责之一,就是把数据提前安排在正确的位置——所谓 data locality(数据局部性)。
这个计算同样提醒我们:在评估一个调度策略时,要分清"这个延迟花在哪一段链路上"。有不少人做推理性能分析,发现端到端延迟高,就一头扎进 kernel 优化里。实际上,如果数据从网络接收到 GPU 显存的路径上没有优化,比如 socket 收包后先拷到 CPU 内存再拷到 GPU,那这一段的传输延迟可能远大于 GPU 计算本身。调度器应该优先优化数据传输路径。
5. 多 GPU 扩展与卡间互联:大模型时代逃不开的调度问题
5.1 从 PCIe 到 NVSwitch:卡间通信的演进
大模型训练和推理绕不开多卡并行。一张 A100 显存 80GB,放不下 70B 参数模型的权重(至少需要 140GB 的 FP16 权重和对应的优化器状态),必须多卡拆分。拆分方式有数据并行、张量并行、流水线并行等,不同并行方式对卡间通信的需求完全不同。
数据并行(Data Parallelism)的特点是每个 GPU 持有完整模型副本,通信集中在梯度同步阶段。梯度同步的量级和模型大小相关,但频率低,通常通信带宽要求不高。张量并行(Tensor Parallelism)把每个 Transformer 层的矩阵切成多块放在不同的 GPU 上,计算过程中需要进行多次 AllReduce 通信,对带宽和延迟都极其敏感。流水线并行(Pipeline Parallelism)又不一样,它是按层切分,通信集中在层间交接处,频率较低但单次数据量大。
调度器在多卡分配时,要根据并行策略来决定 GPU 的物理位置。张量并行的 GPU 之间必须有高速互联(最好是 NVLink 直接相连),数据并行的 GPU 可以分散在不同节点(但最好同构)。在我实际管理的一台 8 卡 DGX 上,GPU 之间走 NVSwitch 全互联,任何两卡之间的通信带宽都一样,调度就很简单。但在普通 2 卡或 4 卡的服务器上,GPU 可能挂在不同的 PCIe Switch 下,跨 Switch 的通信带宽会折损,这就是调度器需要注意的 "NUMA 亲和性" 问题在 GPU 场景的映射。
5.2 通信计算重叠:一张卡同时做计算和通信
高性能的调度策略必须支持通信和计算的重叠。这个能力依赖于硬件的异步执行支持——GPU 的 DMA 引擎可以独立于 SM 工作,NVLink 传输可以在 kernel 执行的同时进行。重点在于调度器要主动拆出通信阶段,不与其他计算阶段串行。
以流水线并行中的 micro-batch 切分为例:把一个大 batch 切成多个 micro-batch,让当前 micro-batch 计算的同时,上一个 micro-batch 的梯度在后台做 AllReduce。这要求调度器能识别并分离计算任务和通信任务,把它们放到不同的 CUDA Stream 中。框架层面(比如 Megatron-LM)已经实现了这种机制,但如果你自己写调度器,需要理解底层原理,否则很容易做串行化处理,白白浪费硬件能力。
一个值得实践的优化技巧是带宽控制(bandwidth throttling)。NVLink 是全双工,但如果同时发起大量通信任务,NVSwitch 内部的仲裁可能造成延迟抖动。调度器可以为不同优先级的通信设置不同的带宽上限(通过 NCCL 的NCCL_MAX_NCHANNELS或环境变量控制),低优先级通信避开高优先级通信的敏感期。这在多租户共享 GPU 集群时非常有用。
5.3 MIG 与多租户隔离的硬件基础
MIG(Multi-Instance GPU)是 NVIDIA 在 Ampere 架构引入的硬件分区技术,能把一块物理 GPU 切成多个相互隔离的 GPU 实例。A100 40GB 可以切成最多 7 个实例,H100 有更灵活的切分模式。每个 MIG 实例拥有独立的 SM、显存带宽、L2 Cache 切片,实例之间天然隔离。
从调度器的立场看,MIG 是真正的"硬隔离"手段——一个 MIG 实例上的任务不会因为其他实例的任务导致性能波动。这与时间片共享(MPS,Multi-Process Service)有本质区别。MPS 只是把多个进程的 kernel 合并调度到同一批 SM 上,没有硬件隔离,一个进程的 kernel 可能阻塞其他进程的 kernel。
但在使用 MIG 时要注意几个细节。第一,MIG 实例的切割粒度是固定的,你必须按硬件支持的模板来切,不能任意自定义大小。第二,不是所有 CUDA 功能都支持 MIG——例如 UVM(Unified Virtual Memory)在 MIG 上受限,某些 GPU 间的 P2P 通信也不支持跨 MIG 实例。第三,MIG 模式下 GPU 显存被切片,小实例的显存可能不足以跑大的 batch,这会影响你在推理服务中的 batch 大小设置。
实际部署推理服务时,我用过"4 个 A100 切成 2 个 40GB 实例 + 4 个 10GB 实例"的组合,小请求走小实例,大请求走大实例。这个组合在资源利用率和性能隔离之间取得了不错的平衡。关键前提是调度器必须支持 MIG 实例的声明和资源上报——在 Kubernetes 场景下,你可以用 NVIDIA 的 device plugin 的 MIG 模式,它能自动把 MIG 实例注册为可调度的设备。
6. 影响调度决策的关键数据速查(Ampere 架构版)
为了让上面的讨论更容易落地,我把几个核心指标整理成一张对照表。这里的数值以 A100(GA100)为例,不同型号会有差异,但量级关系是通用的。
| 硬件维度 | 典型数值 | 对调度器的意义 |
|---|---|---|
| SM 数量 | 108(A100 80GB) | 总计算槽位的上限,决定最大并行 block 数 |
| 单 SM 最大线程数 | 2048 | 任务 block 内线程数的设计参考 |
| 寄存器文件 | 256KB/SM | 限制每个 SM 同时容纳的 block 数 |
| Shared Memory | 164KB/SM | 影响 kernel 数据复用策略的选择 |
| L2 Cache | 40MB(A100) | 可预留(Set-aside),支持多任务缓存隔离 |
| HBM 带宽 | ~2TB/s | 判断任务是否访存密集的基准 |
| PCIe Gen4 x16 带宽 | ~32GB/s 单向 | CPU-GPU 数据传输的瓶颈点 |
| NVLink(第三代) | 50GB/s 每链路,600GB/s 全卡 | 多卡任务放置的亲和性依据 |
| MIG 切分粒度 | 1g.5gb 到 7g.40bg 不等 | 多租户隔离的最小分配单位 |
这张表不是给你背参数的,而是当你设计调度策略时,拿这些数去估算你的决策边界。比如你猜一个任务在 A100 上跑 100ms,如果它要从 CPU 传 1GB 数据进来,PCIe 传输就要 32ms——直接占掉了近三分之一的时间预算。这种数量级的估算能力,是调度系统设计的基本功。
7. 一些容易被忽视的硬件细节与我的实践心得
组原层面讲了这么多,最后分享几个实操中容易踩坑的硬件细节。不是原理性的,全是血泪教训。
第一,GPU 的功耗和散热会限制实际频率。A100 的 TDP 是 400W,如果你把多张卡的 SM 都跑满,整机功耗超过供电上限后,GPU 会自动降频(power capping)。调度器如果不监控当前功耗状态,单纯按"SM 空闲"来判断能不能塞新任务,很可能导致所有卡一起降频,整体吞吐反而下降。我在生产环境里遇到过两次这种情况,后来加了功耗监控,在调度决策中引入了一个"降频惩罚因子",效果立竿见影。
第二,nvidia-smi显示的显存使用量存在滞后性。CUDA 缓存机制导致显存不会立即释放——一个进程结束后,部分显存页面会被驱动缓存,供下一个进程复用。如果你用nvidia-smi -q -d MEMORY去量化剩余显存,看到的值往往偏保守。精确的方式是用 NVML(NVIDIA Management Library)的nvmlDeviceGetMemoryInfo,配合nvidia-smi pmon看实时状态。
第三,PCIe 链路的降速问题。服务器经过长时间运行后,PCIe 链路可能因为信号完整性退化,从 Gen4 降到 Gen3 甚至更低。用lspci -vv能查当前链路速率。如果你发现某几张卡的数据传输性能突然变差,先查这个,不要一头扎进代码里优化。
第四,时钟频率和功耗的开关状态会影响 GPU 的响应延迟。默认情况下,GPU 为了省电会进入低功耗状态,任务到来时需要几百毫秒来提升频率到工作状态。对延迟敏感的场景(比如实时推理),一定要把 GPU 的性能模式设置为持续模式(nvidia-smi -pm 1),并锁定最大时钟频率(nvidia-smi -lgc)。这个操作在初始化阶段就做,不要等流量来了再配。
第五,如果要大规模部署 GPU 调度,强烈建议把 NVML 嵌入到你的监控体系里,而不是依赖外部工具。NVML 提供的指标非常丰富:温度、功耗、SM 占用率、显存带宽利用率、PCIe 吞吐、NVLink 错误计数等。调度器不仅要看"资源够不够",还要看"资源用得好不好",这些指标是持续调优的依据。
这个系列的第一篇就到这里。硬件的结构决定调度的边界和策略,把这一层吃透了,后面讨论具体的调度算法、集群调度器设计、推理框架的 batch 策略时,你会有完全不同的理解深度。下一篇我计划深入剖析 GPU 的计算流与调度器如何利用流并行来提高设备利用率,尤其会讲清楚 OpenCL 和 CUDA 在调度模型上的关键差异——如果你在用跨平台异构计算框架,这部分会很有参考价值。