1. 先想明白一件事:推理调度和训练调度要的不是同一个东西
做 AI 模型推理的人,迟早会撞上一个问题:模型在训练阶段 GPU 利用率能拉得很满,等部署成推理服务,GPU 利用率经常掉到百分之二三十,延迟还会在某些时刻突然抖动到几百毫秒。这不是显卡不行,也不是框架不行,大概率是 GPU 调度策略没有跟着推理场景的特点调整过来。AI 模型推理、GPU 调度策略、优化,这三个词放在一起,本质上要解决的只有一件事:怎么让有限的显存和算力,在“延迟达标”和“吞吐最大”之间找到那个平衡点。
很多人把训练时的习惯直接搬到推理上,这是第一个坑。训练任务是长跑,一个 batch 跑多久无所谓,关键是总吞吐量高不高;推理任务是短跑,单个请求的响应速度直接决定用户体验,但服务又要面对大量并发请求,不能一次只服务一个人。这两种诉求是矛盾的。你为了让延迟更低,把 batch size 调小,GPU 算力闲置;为了让 GPU 用满,把 batch 攒得很大,又有一堆请求卡在队列里。调度策略就是在这种矛盾里做取舍。
1.1 训练看吞吐,推理看延迟和稳定
训练阶段,GPU 调度关心的是“算得满不满”。数据加载、梯度同步、kernel 执行,全都在为高吞吐服务。推理阶段不一样,多了一个硬指标叫 SLO(Service Level Objective),常见形式是“P99 延迟不超过 200ms”。同时,推理服务面临的是真实用户请求,请求到达时间完全是随机的,有的高峰期每秒几百个请求,空闲时可能十秒才来一个。GPU 调度策略要保证在流量波动的情况下,既不让延迟破口,也不让 GPU 闲着。
这里有个反直觉的地方:推理时 GPU 利用率低,有时恰恰是“正常的”。因为一个请求从进入到返回,CPU 侧要做 tokenize、采样、调度决策、网络传输,GPU 真正执行模型推理的时间只是其中一部分。你盯着 nvidia-smi 看到利用率低,先别急着怀疑调度,得先把数据准备、CPU 侧的逻辑也要纳入排查范围。我之前优化一个部署在单卡上的 7B 模型服务,把 batch 策略调完之后,nvidia-smi 显示利用率从 35% 提高到 78%,但 P99 延迟反而恶化了,原因就是 CPU 侧的前处理线程成了瓶颈。这个后面展开说。
1.2 推理请求的三个特征:短、变、随时来
推理负载有几个特点,决定了调度策略的设计方向。
第一是请求长度不均匀。同样一个对话模型,有的请求只生成 20 个 token,有的要生成 800 个 token。如果每个请求当成一个独立任务调度,短的很快结束,长的一直占着 GPU,整个服务就会被拖慢。第二是并发量波动大。线上流量有高峰和低谷,调度策略必须能自适应,不能拍脑袋定一个固定 batch size 就不管了。第三是请求之间有优先级差异。比如交互式对话和高并发的离线批处理任务,如果可以放在同一套 GPU 上,交互式请求应该被优先调度,离线任务让出部分资源。
这三个特征,决定了推理调度不能像训练那样“把一批数据喂进去,等全部完成再喂下一批”。它需要更细粒度的调度,细到什么程度?后面讲连续批处理的时候会说到。
1.3 调度策略的三个作用层级
做 GPU 调度优化之前,先得知道自己优化的到底是哪一层,不然会被各种概念绕晕。
- 设备级调度:指 GPU 内部如何分配 SM(流式多处理器)、显存带宽、KV Cache 等资源。通常由 CUDA 运行时、推理框架的计算图执行器、以及底层 kernel 来控制。
- 框架级调度:指推理引擎(比如 vLLM、Triton、TGI)如何决定哪些请求进入当前 batch、以什么顺序执行、什么时候抢占或终止。
- 集群级调度:指 Kubernetes、Slurm 这类资源管理系统如何把 GPU 卡分配给不同服务、不同租户,涉及 GPU 共享、隔离、节点选择等策略。
大部分人对“GPU 调度”的第一反应是集群级,但实际工作中先把框架级调度调明白,收益来得最快。因为很多场景里 GPU 卡本来就是独占的,瓶颈根本不在分配,而在卡内请求的组织方式。当然,当你手上的卡多了,设备和集群级的策略又开始变得重要。这篇文章会按这个顺序一层层讲下去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 批处理策略演进:静态批处理、动态批处理到连续批处理
GPU 推理天生适合批处理,因为矩阵乘法这类计算,批量越大,单位 token 的算力成本越低。但批处理怎么做,不同时代有不同做法,现在各家推理框架主推的“连续批处理”,也不是凭空冒出来的,它是从早期静态批处理一步步演进过来的。理解这条演进脉络,才能明白为什么 vLLM、TensorRT-LLM 这类框架要在调度上做那么多看起来“绕”的设计。
2.1 静态批处理:简单但把延迟全丢了
最早期的推理部署,逻辑很简单:客户端送来的请求攒成固定大小的 batch,比如 batch size = 8,攒满 8 个请求就一起跑,跑完再攒下一批。这种方案的问题非常明显。第一个问题是延迟不稳定:如果流量稀疏,攒 8 个请求可能要等几百毫秒,多出来的等待时间全算在用户头上。第二个问题是“木桶效应”:一个 batch 里只要有 1 个长请求,其他 7 个短请求都得等它跑完才能返回,GPU 明明已经算完了短任务,却不能把结果先交出去。
静态批处理在离线批量推理场景还能用,比如你有一万条数据要批量生成摘要,不关心单条延迟。但只要是面向真实用户的在线服务,这套方案基本可以直接否定。我当时做第一版对话服务的时候图省事用的就是静态批处理,上线之后被测试的同学反馈“偶尔一句话要转好几秒”,其实就是 batch 里混进了长文本生成请求,把整组请求都拖住了。
2.2 动态批处理:攒一批走一批
为了缓解静态批处理的等待问题,主流推理框架最早引入的是动态批处理(dynamic batching)。思路也很直白:设置一个最大等待时间,比如 30ms,在这 30ms 内到达的请求都放进当前 batch,时间到了不管攒没攒满,立即开始推理。这样既保证了 GPU 有一定批量,又不会让请求无限期等下去。
这套机制比静态批处理好了不少,但“攒一批走一批”的模型没变,还是有木桶效应。batch 里的请求仍然要同进同出,短请求等到长请求跑完才能返回。动态批处理在传统 CV 模型、BERT 这类非生成式模型上效果不错,因为每个样品的计算量差不多;但到了 LLM 生成式推理,输出长度差异极大,动态批处理的缺点被放大了。
2.3 连续批处理:把批处理细粒度到 token 级别
连续批处理(continuous batching)的核心思想,是把“请求级别”的调度粒度,压缩到“token 级别”。每个请求生成一个 token 就释放计算资源,让其他请求立刻接上。也就是说,GPU 不再等一个完整的请求序列生成完,而是每个 decode 步骤都重新组合一次 batch。短请求生成的 token 少,很快就结束了,新请求马上可以插入进来;长请求虽然一直在跑,但它每一步只占用一个 token 位置的算力。
用一个比喻可能更好理解:静态批处理像是大客车,必须坐满人才发车,所有乘客同时下车;动态批处理像是公交定时发车,但到站时间由最慢的乘客决定;连续批处理像是地铁,每节车厢门口不断有人上下车,不影响其他乘客。
各大推理框架的实现方式不完全一样,TGI 里叫 continuous batching,vLLM 里加上 PagedAttention 之后实现了更精细的调度,TensorRT-LLM 也有类似机制。连续批处理带来的收益非常直观:同样一张 A10 卡,把动态批处理换成连续批处理,吞吐量能提升 2 到 3 倍,P99 延迟反而更低,就是因为短请求不用再被长请求拖住。
2.4 KV Cache 调度:PagedAttention 带来的底层改变
连续批处理解决了“算力调度”的问题,但还留着一个大坑:KV Cache 的内存管理。生成式模型在推理过程中,每个 token 都要依赖前面所有 token 的 Key 和 Value 向量,这些向量要缓存在显存里。不同请求的序列长度不同,KV Cache 的大小也完全不同。如果参考传统的 natívne 显存分配方式,每个请求一次性预分配最大长度(比如 2048 token)对应的显存,那么大多数请求实际用到的可能只有四分之一,剩下的全浪费了。
浪费的结果就是:显存明明还有剩余,却没法塞进更多请求,batch size 上不去,GPU 算力用不满。vLLM 的 PagedAttention 解决的就是这个问题。它把 KV Cache 按固定大小的“页”(page)划分,每个请求只需要分配实际用到的页,就像操作系统里的虚拟内存分页。新的 token 进来了,就动态申请新页;请求结束了,页立即释放给其他请求用。
PagedAttention 的意义不只是一个显存优化技巧,它是调度策略的底层支撑。没有这种动态的显存管理,连续批处理根本跑不到位,因为请求多了以后,KV Cache 会碎片化,调度器要么拒绝新请求,要么得先做显存整理。现在主流框架的做法是:KV Cache 集中管理,调度器根据当前可用页数决定能接收多少新请求,这已经是一个完整的“显存感知调度”体系了。
3. 框架里的调度参数到底在调什么:vLLM、Triton、Ollama 实测
理论讲完,下面是实战最关心的部分:框架暴露出来的调度参数,每个到底在干什么,应该怎么调。我用 vLLM、Triton Inference Server 和 Ollama 这三个比较有代表性的工具来说明,它们分别对应了研究型部署、生产级推理服务和本地快速验证的典型场景。
3.1 vLLM 的关键参数和调整逻辑
vLLM 是当前社区热度很高的推理框架,因为它把连续批处理和 PagedAttention 叠加在一起,部署起来也比较顺手。启动一个模型常用这么一条命令:
bash复制python -m vllm.entrypoints.openai.api_server \
--model /data/models/Qwen2.5-7B-Instruct \
--tensor-parallel-size 1 \
--gpu-memory-utilization 0.90 \
--max-num-seqs 64 \
--max-num-batched-tokens 8192 \
--max-model-len 4096
几个核心参数背后的调度逻辑:
--gpu-memory-utilization:模型参数之外剩下的显存,有多少比例预留给 KV Cache。调大这个值,能塞进更多并发请求,但如果超过实际可用显存,会直接 OOM。我习惯先跑到 0.92 测试,稳定后再降到 0.88 留缓冲。--max-num-seqs:最多允许多少个请求同时被调度。这个值直接决定并发上限。它和 KV Cache 大小是联动关系,请求越多,平均每个请求能分到的页越少。--max-num-batched-tokens:一个 batch 里最多放多少 token。它会限制了每个 decode 步骤的计算量。如果设得太小,吞吐上不去;设得太大,单个 decode 的延迟会变长,P99 可能破口。
实际调参的顺序,我建议先按模型显存占用算出 KV Cache 上限,再逐步调大 max-num-seqs,观察 P99 延迟曲线是否出现拐点。不用一上来就追求最大并发。vLLM 启动后也可以通过 --enable-prefix-caching 这类参数进一步做调度优化,对于多轮对话场景命中率高的服务,prefix caching 能明显降低首 token 延迟,代价是增加一些显存占用和调度器的哈希计算开销,实测收益通常大于成本。
3.2 Triton Inference Server 的 dynamic batching 配置
生产环境里,Triton Inference Server 仍然是非常常见的选择,尤其是要同时部署多个模型或者做前处理、后处理流水线的场景。Triton 的调度策略主要是 dynamic batching,它允许你配置模型实例并发数和批次窗口。典型配置是在模型仓库的 config.pbtxt 里这样写:
protobuf复制name: "my_model"
platform: "pytorch_libtorch"
max_batch_size: 64
dynamic_batching {
preferred_batch_size: [8, 16, 32]
max_queue_delay_microseconds: 500
}
instance_group {
count: 2
kind: KIND_GPU
}
这里有两个值得注意的点。
preferred_batch_size 告诉调度器尽量凑成哪些 batch size。为什么不是凑满 64?因为 GPU 的并行粒度是按 SM 划分的,batch size 16 和 24 的耗时可能差不了多少,但 16 在多卡并行时更友好。这个参数要配合 profiling 结果来看,不要拍脑袋。max_queue_delay_microseconds 是最大排队时间,单位是微秒,500 表示等 0.5ms,攒不够也直接发车。这个值对延迟影响很大,如果设成 0,其实就等于每来一个请求都单独推理,吞吐掉得厉害;设得太大,延迟又上去了。我的经验是先从 300 到 800 微秒这个区间试,观察不同流量模型下的表现。
instance_group count 的调度意义也值得展开:它决定了模型在 GPU 上有几个实例。如果模型卡占用显存不大,开 2 个实例可以同时处理更多请求,但显存占用翻倍,而且多个实例之间会争抢 GPU 算力。我见过一个团队为了让吞吐翻倍,把 instance 数调到了 4,结果每个实例的延迟都涨了三倍,总吞吐反而没提升。
3.3 本地 GPU 环境的坑:Ollama 切 GPU、PyTorch GPU 版、WSL 的 NVML 报错
本地开发和线上部署环境不一样,调 GPU 调度策略之前,你得先确认 GPU 真的在干活。否则调了半天框架参数,结果卡在 CPU 上推理,那一切都白搭。
Ollama 现在很多人用来本地跑模型,默认情况下它会自动检测 GPU,但有时候你会发现它还是跑在 CPU 上。排查方式很简单:先执行 ollama ps,看输出里有没有 GPU 这一列,如果显示 100% CPU,说明 GPU 没启用。常见原因是安装的时候没有装 CUDA 版运行时,或者环境变量 CUDA_VISIBLE_DEVICES 设置不对。此时可以先把 CUDA_VISIBLE_DEVICES=0 ollama serve 重启服务,确认驱动能被识别。注意,如果你是通过 WSL 跑 Ollama,Windows 侧必须装好 NVIDIA 驱动,WSL 内不需要再装一遍驱动。
说到 WSL,这里有一个非常常见的报错,也是搜索热词里频繁出现的问题:failed to initialize NVML: GPU access blocked by the operating system。这个错误通常出现在 WSL 里跑 Docker 容器访问 GPU 时。根本原因是 WSL 的 GPU 直通没有被正确识别,最常见的情况是 Windows 侧的驱动太老、WSL 内核没有更新,或者容器没加 GPU 参数。我建议的排查链路是:
- 先在 WSL 里执行
nvidia-smi,如果能看到显卡信息,说明驱动直通正常;看不到就去 Windows 更新驱动,执行wsl --update更新 WSL 内核。 - 确认 Docker 容器启动时带了
--gpus all参数。 - 检查容器内的 CUDA 版本和宿主机驱动是否兼容。
这里还要补充一个本地推理容易被忽略的点:CPU 大小核调度。如果你用的是 12 代之后的 Intel CPU,Windows 的大小核调度策略会直接影响推理任务的响应时间。尤其是 Ollama 这类服务,如果进程被调度到效率核上,首 token 延迟会明显偏高。可以把进程 CPU 亲和性手动绑定到性能核,或者在 Windows 电源模式里选择“最佳性能”。这个和 GPU 调度看起来不相关,但实测对本地推理的端到端延迟影响很大。
PyTorch 装 GPU 版也是老生常谈但总有人踩坑的问题。默认执行 pip install torch 装的是 CPU 版,要装 CUDA 版需要指定源:
bash复制pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
装完之后用 torch.cuda.is_available() 验证。如果返回 False,先别怀疑代码,回去看驱动和 CUDA 版本是否匹配。
4. 单卡之外:多卡共享和集群调度策略
单机单卡上的调度策略优化完,下一个必然遇到的问题是多卡和集群。很多人以为多卡推理就是把模型切成几份放到不同卡上跑,其实调度策略在这里扮演的角色远比你想象的复杂。从单卡到多卡,首先要搞清楚的是并行方式对调度的影响。
4.1 模型并行与数据并行在推理调度上的差异
推理里的多卡并行分两大类:模型并行和数据并行。
模型并行是把一个模型切分到多张卡上。张量并行(Tensor Parallelism)是把每一层的权重按列或按行切到多张卡,每一层的计算需要卡间通信;流水线并行(Pipeline Parallelism)是把不同层放到不同卡上,数据按层流动。对调度策略来说,模型并行最大的影响是:每张卡必须协同工作,如果某张卡的利用率不均衡,整个请求都会卡在慢的那张卡上。所以张量并行通常要求卡间有 NVLINK 等高速互联,带宽不够的话,通信开销会吞掉并行带来的收益。
数据并行则是把同一个模型复制到多张卡上,每张卡独立处理不同的请求。对于推理来说,数据并行是最直观的扩容方式:一张卡能并发 16 个请求,两张卡理论上就能并发 32 个。但这里有个调度层的坑:请求分发层(路由)需要知道每张卡当前的负载,否则有的卡忙死,有的卡闲死。我在项目里用过一个简单有效的策略:请求按“最少在途请求数”分发到卡,而不是随机轮询。实现成本低,但能让多卡利用率一致很多。
4.2 单卡细分的三种手段:MIG、MPS、Time-Slicing
GPU 这么大一张卡,如果只跑一个推理服务,算力和显存经常用不满。这时候就需要把一张卡切分给多个服务共享。NVIDIA 提供了三种主要的切分手段,它们的调度策略很不一样。
| 特性 | MIG | MPS | Time-Slicing |
|---|---|---|---|
| 切分粒度 | 硬件级隔离,显存和 SM 物理隔离 | 计算资源抢占,显存共享 | 时间片轮转,显存共享 |
| 隔离性 | 强,故障和显存占用互不影响 | 中,需要配置显存上限保护 | 弱,一个任务可能拖慢其他任务 |
| 性能损失 | 较小,但不可动态调整 | 中,不同进程竞争 SM | 高,时间片切换有开销 |
| 适用场景 | 生产环境,多租户隔离要求高 | 单用户多进程共享计算资源 | 低负载场景,临时共存 |
MIG 是生产环境里我最推荐的方式,因为它从硬件层面做了隔离,一个服务 OOM 不会拖挂旁边的服务。但 MIG 最大的限制是切分方式固定,比如 A100 上只能按 1g、2g、3g 这些固定模板切,显存和算力是绑定的,灵活性不好。MPS 适合多个进程共享一张卡,比如数据并行推理时每个进程占一个 GPU 上下文,但 MPS 的调度器由 CPU 管理,CPU 侧的处理有时会成为瓶颈。Time-Slicing 实现最简单,几乎所有 GPU 都支持,缺点是隔离性太弱,不适合生产环境。
选型的核心逻辑是:先看隔离需求有多强,再用算力需求反推切分方案。如果两个服务都是低 QoS 的离线任务,Time-Slicing 完全可以接受;如果一个服务在线一个服务离线,至少用 MPS 或者优先级的调度;如果都是生产核心服务,直接上 MIG。
4.3 Kubernetes + GPU Operator 的调度策略:Binpack 与 Spread
到了集群层面,最常打交道的调度器是 Kubernetes。默认的 kube-scheduler 不了解 GPU 的拓扑结构,所以 NVIDIA 提供了 GPU Operator,通过 device plugin 和 node feature discovery 把 GPU 资源暴露给 Kubernetes。有了 GPU Operator 之后,你可以给服务声明 GPU 资源,但调度策略本身还需要自己控制。
Kubernetes 调度 GPU 时有两个关键策略:Binpack 和 Spread。Binpack 是尽量把任务堆到少数节点上,从而让空闲节点可以关机或调度其他工作负载;Spread 是尽量分散到不同节点,降低单点故障影响。对于 AI 推理服务,我的建议是优先 Spread,因为推理服务通常有高可用要求,分散部署能容忍节点故障。但如果你的 GPU 集群是按卡计费、且任务是离线批处理,Binpack 能帮你省下不少节点资源。
实际操作里,可以通过 nodeSelector 或 nodeAffinity 配合 nvidia.com/gpu 资源实现。比如给服务声明两张卡:
yaml复制resources:
limits:
nvidia.com/gpu: 2
然后通过 nodeAffinity 让两个副本分布在不同节点上。这里有一个容易被忽略的细节:GPU Operator 对每张卡默认按 1 个资源单位上报,但如果你开启了 MIG,一张物理卡会暴露成多个 MIG 设备,Kubernetes 里的调度单位就变成了 MIG 设备而不是物理卡。你需要确认服务的显存需求能不能塞进对应的 MIG 模板,否则调度器会认为资源足够,但 pod 启动后直接 CUDA error。
4.4 拓扑感知:NVLINK、PCIe Switch 和 NUMA 为什么影响调度
集群级调度最容易忽略的是拓扑。两张卡看起来是“同一台机器上的两张卡”,实际可能在 PCIe 层级上隔得很远,通信带宽差异巨大。NVIDIA 的 NVLink 带宽可以达到数百 GB/s,PCIe 4.0 x16 只有约 32GB/s,如果张量并行的两张卡之间走的是 PCIe 而不是 NVLink,通信开销就会显著拉高单次 decode 的延迟。
更让人头疼的是 NUMA 拓扑。一台 8 卡服务器通常有 2 个 CPU,每个 CPU 管理 4 张卡。GPU 直接挂在哪个 CPU 的 PCIe 控制器下,决定了它访问内存的延迟。如果 Kubernetes 把 GPU 调度到 CPU0 的节点,但 pod 的计算线程跑在 CPU1 上,跨 NUMA 访问会让整体性能掉 10% 到 20%。
要处理这个问题,通常需要做拓扑感知调度:要么用 NVIDIA 的 topologyManager 插件(配合 CPU Manager 和 Topology Manager)让 Kubernetes 自动分配最合适的 CPU 和 GPU 组合;要么自己通过nvidia-smi topo -m 查看卡间拓扑,手动打 label 来控制调度。我个人的经验是:小于 8 卡的小集群,手动 label 比引入一堆插件更可控;到了十几台机器以上的规模,值得花时间把 Topology Manager 配起来,否则每次扩容都有隐性的性能损耗。
5. 一次完整的调度调优记录:从指标到结论
说了这么多原理和参数,最后用一次真实的调优过程把整条链路串起来。事情背景:一个基于 Qwen2.5-7B-Instruct 的对话服务,单张 A10(24GB)部署,用 vLLM 做推理引擎,上线初期面临两个问题:GPU 利用率白天经常在 40% 以下,P99 延迟在高峰期从 150ms 飙到 700ms。
5.1 延迟指标先定清楚:TTFT、TPOT、ITL 和端到端
调优之前,先把指标定清楚,不然你看到“平均延迟 300ms”根本不知道是哪里慢。
- TTFT(Time To First Token):从请求发送到第一个 token 返回的时间。它受 prefill 阶段影响最大,也受排队时间影响。
- TPOT(Time Per Output Token):生成阶段每个 token 的平均耗时,也就是 decode 一步的耗时。它直接反映 GPU 计算的效率。
- ITL(Inter-Token Latency):相邻两个 token 之间的间隔。在多并发情况下,ITL 受连续批处理调度的影响很大,某个请求可能被其他请求打断,ITL 会抖动。
- 端到端延迟:整个请求从发起到所有 token 都返回的耗时。它等于 TTFT + 生成的 token 数乘以 TPOT。
这套指标是调优的“仪表盘”。我当时的初步现象是 TTFT 正常,但高峰期 ITL 波动大,说明问题主要出在 decode 阶段的调度,而不是 prefill 或者首 token 排队。
5.2 用 Profiling 定位瓶颈:Nsight Systems 和 PyTorch Profiler
定位调度瓶颈,光看 nvidia-smi 不够,得用 profiling 工具把时间线拉出来。我用的是 Nsight Systems 和 PyTorch Profiler,前者看系统级的事件,后者看框架内部的算子耗时。命令大致是这样:
bash复制nsys profile -o trace_output python serving_script.py
python -m torch.profiler --activities cuda --output trace.json
Profile 出来之后,重点看两块:一是 GPU kernel 之间有没有大段空闲间隙;二是 CPU 侧的排队和内存拷贝是不是瓶颈。我那次的现象就是在多个 decode kernel 之间出现了明显的空闲区间,这说明 GPU 在等 CPU 提交新的 batch,问题出在调度器和 batch 组织的速度上,而不是 GPU 算力不够。这一点不 profiling 很难意识到,因为 nvidia-smi 只告诉你 GPU 平均利用率低,不会告诉你为什么低。
5.3 一个 7B 模型的调优过程
定位到调度层的问题后,我按下面的顺序调参数,每一步都有明确目的:
第一步,把 gpu-memory-utilization 从 0.85 调到 0.92。这样 KV Cache 从大约 12GB 增加到 14GB,可容纳的并发序列数变多。这里没有遇到 OOM,因为 7B 模型参数本身约占 15GB,A10 的 24GB 里,加上激活值,0.92 是安全的。
第二步,逐步调大 max-num-seqs,从 16 往上加到 64。每调到一档,压测看 P99 延迟曲线。加到 48 的时候,P99 从 700ms 降到 320ms;但加到 64 的时候,P99 又涨回 500ms,说明并发已经超过了 GPU 的算力承受范围。最终定在 48。
第三步,调整 max-num-batched-tokens。初始默认值是 4096,我改成 8192 后发现单个 decode 步骤的延迟变长了,TTFT 也略有上升,但 ITL 的抖动反而小了。原因在于 batch 步子大了之后,调度器每步能处理的请求更多,不至于频繁切换。测试下来这个值定在 6144 是最平衡的。
第四步,打开 enable-prefix-caching。我们的对话服务有大量多轮请求,prefix 命中率大约 60%,TTFT 从 180ms 降到 110ms。这一步几乎零成本,直接收益明显。
四步调整做完,同一个压测场景下,GPU 利用率从 38% 提升到 72%,P99 延迟稳定在 280ms 左右,吞吐从 280 req/min 上升到 620 req/min。这个结果不是“参数越大越好”调出来的,而是每改一个参数都盯着 P99 曲线和显存容量做判断,逼近那个平衡点。
5.4 调优结束之后怎么守住基线
调优最怕的是“调完就忘”。环境一变,流量一变,参数可能又不合适了。所以要守住基线,建议做两件事。
第一件事,把每次的参数组合和压测结果记录下来,至少记下:并发数、batch 参数、P99 延迟、平均吞吐、显存峰值、KV Cache 命中率。这些数据积累起来,下次遇到类似模型,可以直接当初始值复制,不用从头试。
第二件事,上线后做持续监控,重点盯 ITL 和 KV Cache 的利用率。ITL 的抖动往往比平均延迟更早暴露调度问题;KV Cache 如果长期低利用率,说明 max-num-seqs 设小了;如果长期接近上限,说明并发快饱和,该考虑扩容了。本地开发的时候可以在 .bashrc 里加一个 nvidia-smi dmon -s puc 的快捷命令,每隔几秒刷新一行 GPU 利用率、内存和温度信息,观察调度调节后的实时变化。
整个过程走下来,我最大的体会是:GPU 调度策略优化不是“找到某个魔法参数”,而是一套基于指标做决策的方法。先搞清楚推理和训练的差异,再理解批处理演进背后的原因,然后熟练使用每一层暴露出来的调度开关,最后用 profiling 和压测数据做判断依据。这套方法换模型、换框架、换硬件都适用,调优的起点从来不是显卡,而是你对自己业务的延迟目标和流量模型有没有清楚的认知。
