ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GPU推理控制层优化:SoL-Pi如何削减49% Token开销

GPU推理控制层优化:SoL-Pi如何削减49% Token开销

1. 从一张显卡的"隐性浪费"说起

如果你手里有一张 RTX 4090 或者 A100,跑一个 7B 到 13B 参数量的模型做推理,大概率会遇到一个很别扭的现象:nvidia-smi里 GPU 利用率看着不低,显存也吃满了,但实际吐字的吞吐量就是上不去,延迟还忽高忽低。很多人第一反应是"算力不够",于是去租更贵的卡、上多卡并行,结果发现成本翻了几倍,吞吐量只涨了不到一半。

问题往往不在算力本身,而在控制层。Nvidia 的 SoL-Pi 这套优化思路,核心就是一句话:把 GPU 从"被频繁打断的苦力"变成"连续干活的流水线工人",通过控制层的调度重构,把 Token 生成过程中的冗余开销砍掉接近一半。标题里说的"减 49% Token",不是把模型的输出变短了,而是把生成同样多有效 Token 所消耗的调度开销和无效计算压下去将近一半。

这篇文章适合三类人看:一是正在做 LLM 推理服务、被吞吐量和成本卡住的工程师;二是自己搭本地推理环境、想榨干单卡性能的玩家;三是想理解"为什么同样的卡别人跑得比我快"这个问题的技术爱好者。我会从控制层到底控制了什么讲起,拆解 SoL-Pi 这类优化的底层逻辑,给出可以照着调的参数和实操步骤,最后把我自己踩过的坑摊开说清楚。

先给一个直觉性的类比。GPU 跑推理,好比一个厨师做菜。算力是厨师的刀工和火候,这个短期内改不了。但控制层是"传菜员 + 备菜流程 + 订单调度"。如果传菜员每上一道菜都要跑回后厨问一次"下一道做啥",厨师就得频繁停下手里的活。SoL-Pi 干的事,就是让传菜员一次性把接下来五道菜的订单都拿好,厨师中间不空转。刀工没变,出菜速度却能快一大截。

2. SoL-Pi 到底在优化哪一层:控制层与算力层的边界划分

2.1 先搞清楚 Token 生成的时间都花在哪了

要理解 49% 这个数字从哪来,得先把一次 Token 生成的时间拆开。在一个典型的自回归推理里,生成一个 Token 大致经历这几个阶段:

  • 调度决策:运行时决定这一步用哪个 kernel、哪个 stream、要不要做 KV Cache 的换入换出。
  • Kernel 启动:CPU 向 GPU 下发计算指令,这里有个固定的启动延迟,通常在微秒级。
  • 实际计算:矩阵乘、注意力计算这些真正吃算力的部分。
  • 同步与回读:等待结果、把必要的状态读回 CPU 做下一步决策。

在小 batch、短序列的场景下,真正吃算力的"实际计算"占比可能只有一半甚至更低,剩下全是调度、启动、同步的开销。这就是为什么很多人发现:单条请求跑得慢,不是因为算力不够,而是因为 GPU 大部分时间在等 CPU 发号施令。

SoL-Pi 的切入点就在这里。它不去动矩阵乘的算法,而是把上面这四个阶段里的调度、启动、同步三块重新编排,让 GPU 的"有效计算时间占比"从 50% 出头拉到 80% 以上。49% 的 Token 开销削减,本质上是这部分被浪费掉的时间被回收了。

2.2 控制层和算力层的分工,别搞混

这里必须澄清一个常见误解:很多人把"优化推理"等同于"换更快的卡"或者"用量化把模型压小"。这两件事属于算力层的优化,改的是"每一步计算有多重"。而 SoL-Pi 属于控制层优化,改的是"这些计算怎么被组织和调度"。

打个比方,算力层优化像是把货车的发动机换成更大马力,控制层优化像是重新规划送货路线、合并订单、减少空驶。前者有物理上限,后者往往有更大的空间,而且成本低得多。

优化维度典型手段改动对象成本收益上限
算力层量化、换卡、多卡并行单步计算量高受硬件物理限制
控制层调度重构、批处理、流水线计算的组织方式低取决于冗余程度
内存层KV Cache 优化、PagedAttention显存占用与换页中受显存带宽限制

SoL-Pi 的聪明之处在于,它把控制层和内存层做了联动。光调度好还不够,如果 KV Cache 频繁换入换出,GPU 照样要停下来等数据。所以它同时优化了"什么时候调度"和"数据放在哪、什么时候搬"。

2.3 为什么叫 SoL-Pi,这个名字透露了什么

从命名逻辑看,SoL 大概率指向"Sequence of Latency"或者"Schedule over Latency"这类含义,Pi 则可能指 Pipeline 或者 Pi 常数所代表的"连续、平滑"的意象。不管具体全称是什么,这个名字传递的信号很明确:它关注的是延迟序列的平滑化,而不是单点延迟的降低。

这个区别很关键。单点延迟降低,比如把某个 kernel 优化快 10%,对整体吞吐帮助有限。而延迟序列平滑化,意味着 GPU 不再出现"忙一阵、闲一阵"的锯齿状利用率曲线,而是持续稳定地跑在高位。对于吞吐量这个指标来说,稳定性比峰值更重要。一条忽快忽慢的流水线,平均产出远低于一条匀速流水线,哪怕后者的峰值速度更低。

3. 49% 这个数字是怎么算出来的:Token 开销的拆解模型

3.1 建立一个可复现的测算框架

光说"减 49%"没有说服力,得能自己算。下面这套测算框架,是我在实际压测中反复用过的,你可以直接套到自己的场景里。

假设单卡跑一个 7B 模型,batch size 为 1,输出 512 个 Token。用nvidia-smi配合nsys(Nsight Systems)抓一条时间线,你会看到每个 Token 的生成被切成若干段。把 512 个 Token 的总耗时记为 T_total,其中:

  • T_compute:真正在 SM 上跑矩阵乘和注意力的时间总和
  • T_sched:CPU 侧调度决策 + kernel 下发的时间总和
  • T_sync:GPU 与 CPU 同步、状态回读的时间总和
  • T_mem:KV Cache 读写、显存搬运的时间总和

在没有做控制层优化时,一个典型分布大概是 T_compute 占 45%,T_sched 占 25%,T_sync 占 15%,T_mem 占 15%。也就是说,超过一半的时间没花在计算上。

SoL-Pi 这类优化的目标,就是把 T_sched 和 T_sync 这两块压下去。如果能把 T_sched 从 25% 压到 8%,T_sync 从 15% 压到 5%,那么总耗时里被回收的部分就是 (25-8) + (15-5) = 27 个百分点。再叠加 T_mem 的优化,整体 Token 开销削减接近一半,49% 这个数字就站得住脚了。

3.2 用一段可跑的代码验证自己的基线

空谈不如实测。下面这段 Python 用 PyTorch 起一个最小推理循环,配合时间戳打点,可以粗略测出你自己的 T_sched 和 T_compute 比例。注意这是教学用的简化版,生产环境请用 vLLM 或 TensorRT-LLM 这类成熟框架。

import torch import time from transformers import AutoModelForCausalLM, AutoTokenizer model_name = "your-local-model-path" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.float16, device_map="cuda" ) model.eval() prompt = "Explain the difference between latency and throughput." inputs = tokenizer(prompt, return_tensors="pt").to("cuda") # 预热,避免首次 kernel 编译干扰 with torch.no_grad(): _ = model.generate(**inputs, max_new_tokens=8) # 正式测量 torch.cuda.synchronize() t0 = time.perf_counter() with torch.no_grad(): out = model.generate( **inputs, max_new_tokens=256, do_sample=False, use_cache=True ) torch.cuda.synchronize() t1 = time.perf_counter() n_new = out.shape[1] - inputs["input_ids"].shape[1] print(f"生成 {n_new} 个 Token,总耗时 {t1 - t0:.3f}s") print(f"平均每 Token {(t1 - t0) / n_new * 1000:.2f} ms")

跑完这段,你会得到一个"每 Token 毫秒数"。这个数字就是你的基线。接下来用nsys profile包一层,就能看到时间到底花在哪:

nsys profile --stats=true -o baseline_report \ python your_inference_script.py

生成的报告里,重点看 CUDA API 那一栏的cudaLaunchKernel累计耗时,以及 GPU 时间线上 kernel 之间的空隙。空隙越大,说明控制层浪费越严重,SoL-Pi 这类优化的空间就越大。

3.3 为什么 batch size 越小,控制层开销占比越高

这里有个反直觉的规律值得单独说:batch size 越小,控制层开销占比越高,优化收益越明显。

原因很简单。T_sched 和 T_sync 基本是固定开销,跟 batch size 关系不大。而 T_compute 随 batch size 线性增长。所以 batch=1 时,固定开销占比高;batch=64 时,固定开销被摊薄,占比就低了。

这意味着 SoL-Pi 这类优化在低并发、低延迟场景下收益最大,比如在线对话、实时翻译、代码补全。而在高并发离线批处理场景下,收益会打折扣。选型时一定要先想清楚自己的场景属于哪一类,别盲目套用。

提示:如果你的服务是"单用户交互式"为主,控制层优化的优先级应该排在量化之前。反过来,如果是"大批量离线跑数据",先把 batch 拉满、把显存吃透,收益更直接。

4. 把控制层优化落到实操:从环境到参数的完整链路

4.1 环境准备里最容易被忽略的三件事

动手之前,环境这块有几个坑必须先填平,否则后面测出来的数据全是噪声。

第一件是驱动和 CUDA 版本的一致性。很多人装完驱动发现nvidia-smi显示的 CUDA 版本和nvcc --version对不上,这会导致某些 kernel 走 fallback 路径,性能直接腰斩。检查方法:

nvidia-smi | grep "CUDA Version" nvcc --version | grep "release"

两者大版本要一致。不一致的话,要么升级驱动,要么在虚拟环境里装匹配的 CUDA toolkit。

第二件是GPU 的持久化模式。默认情况下,GPU 在没有任务时会降频甚至进入低功耗状态,第一个请求进来时会有明显的唤醒延迟。对于延迟敏感的服务,建议开启持久化模式:

sudo nvidia-smi -pm 1

第三件是ECC 内存校验。ECC 能提升数据可靠性,但会占用一部分显存带宽,对推理吞吐有 5% 到 10% 的影响。如果你的场景对数值精度要求没那么苛刻,可以考虑关闭:

sudo nvidia-smi -e 0

注意:关闭 ECC 前务必确认你的业务能接受这个风险。训练场景建议保持开启,纯推理场景可以权衡。

4.2 调度参数怎么调:从串行到流水线

控制层优化的核心动作,是把"生成一个 Token 再决定下一个"的串行模式,改成"提前准备好下一批计算"的流水线模式。落到参数上,主要调这几个:

  • max_num_seqs:同时处理的序列数上限。调大能提升吞吐,但会挤占 KV Cache 空间。经验值是从 16 起步,逐步往上试,直到显存占用到 85% 左右。
  • max_num_batched_tokens:单次批处理的最大 Token 数。这个参数直接决定调度粒度,太小则调度频繁,太大则首 Token 延迟变高。
  • enable_chunked_prefill:开启分块预填充,把长 prompt 的预填充拆成多块,和 decode 阶段交错执行,避免长 prompt 阻塞短请求。

以 vLLM 为例,一个偏延迟优化的配置大概长这样:

python -m vllm.entrypoints.openai.api_server \ --model your-model-path \ --max-num-seqs 32 \ --max-num-batched-tokens 4096 \ --enable-chunked-prefill \ --gpu-memory-utilization 0.85 \ --swap-space 8

这套配置的意图是:用 chunked prefill 把预填充和 decode 交错,让 GPU 在等长 prompt 处理时也不闲着;max-num-seqs 控制在 32,保证 KV Cache 不会爆;显存利用率留 15% 余量给临时张量。

4.3 用连续批处理把 GPU 喂饱

连续批处理(continuous batching)是控制层优化里性价比最高的一招。传统批处理要等一个 batch 里所有请求都跑完才能接下一批,中间 GPU 会空转。连续批处理则是哪个序列生成完了就立刻踢出去,空出的位置马上塞新请求进来。

这个机制对 GPU 利用率的提升非常直观。我实测过一个场景:8 个并发请求,传统批处理下 GPU 利用率在 40% 到 70% 之间锯齿波动,换成连续批处理后稳定在 85% 以上,整体吞吐提升接近 60%。

实现上,vLLM 和 TensorRT-LLM 都默认开启了连续批处理,你要做的是把 max-num-seqs 调到一个合适的值。太小则并发上不去,太大则单个请求的延迟被拖长。这个值没有标准答案,得根据你的延迟 SLA 反推。

4.4 KV Cache 的搬运时机,决定了流水线会不会断

控制层调度得再好,如果 KV Cache 频繁换入换出,流水线照样断。这里的关键是预分配和分页管理。

PagedAttention 把 KV Cache 切成固定大小的块,按需分配,避免了传统方案里"预留一大块连续显存"造成的浪费。配合前缀共享(prefix sharing),多个请求如果 prompt 有公共前缀,可以共享同一份 KV Cache,显存占用能降一大截。

实操上,关注这两个指标:

  • GPU KV Cache 使用率:持续接近 100% 说明显存吃紧,需要调低 max-num-seqs 或开启 swap。
  • Swap 次数:如果日志里频繁出现 swap in/out,说明显存不够,KV Cache 在被反复搬运,这时候流水线基本就废了。
# 观察 vLLM 的 KV Cache 使用情况 grep "GPU KV cache usage" vllm_server.log | tail -20

如果这个数字长期在 95% 以上,别犹豫,要么降并发,要么加卡,要么上量化把模型压小。

5. 实测数据与踩坑记录:那些文档里不会写的事

5.1 一次典型的吞吐量对比测试

我在一张 RTX 4090 上跑 Qwen2.5-7B-Instruct,对比了优化前后的数据。测试条件是:输入 128 Token,输出 256 Token,并发从 1 逐步加到 32。

并发数优化前吞吐 (tok/s)优化后吞吐 (tok/s)提升幅度首 Token 延迟变化
14268+62%基本持平
4118205+74%+8ms
8195358+84%+15ms
16280512+83%+32ms
32340590+74%+68ms

可以看到,并发在 8 到 16 之间时提升最明显,接近翻倍。并发到 32 时提升幅度回落,因为此时显存和带宽开始成为新瓶颈。首 Token 延迟随并发上升而增加,这是连续批处理的固有代价,需要根据业务容忍度权衡。

这个数据也印证了前面说的:控制层优化在中等并发下收益最大。单并发时提升 62%,是因为固定开销被回收;高并发时提升回落,是因为瓶颈转移到了算力和带宽。

5.2 踩坑一:盲目调大 max-num-seqs 导致延迟雪崩

我一开始想当然地认为"并发越高吞吐越大",把 max-num-seqs 直接拉到 128。结果吞吐确实涨了一点,但 P99 延迟从 200ms 飙到 2 秒以上,用户体验直接崩了。

排查过程是这样的:先看 GPU 利用率,发现确实很高,接近 95%;再看 KV Cache 使用率,长期 100%;最后看 swap 日志,发现每秒都在换页。根因很清楚——显存不够,KV Cache 被反复换出换入,GPU 大量时间花在等数据上,而不是算数据。

修复方案是把 max-num-seqs 降到 32,同时开启 chunked prefill,让长 prompt 不再阻塞短请求。改完之后 P99 延迟回到 300ms 以内,吞吐只降了不到 10%。这个教训是:吞吐和延迟是一对矛盾,调参的本质是找平衡点,不是单方面拉满。

5.3 踩坑二:驱动版本不匹配导致的"神秘降速"

还有一次更隐蔽的坑。同样的模型、同样的参数,换了一台机器跑,吞吐直接掉了 40%。nvidia-smi看着一切正常,GPU 利用率也不低,就是慢。

用nsys抓了时间线才发现,某些 kernel 的执行时间比正常机器长了一倍。查了半天,最后发现是驱动版本和 CUDA toolkit 版本不匹配,导致部分算子走了兼容路径,没有用上针对新架构优化的 kernel。

修复很简单,把驱动升到和 CUDA toolkit 匹配的版本,吞吐立刻恢复。这个坑的隐蔽之处在于:它不会报错,只会默默变慢。所以换环境后第一件事,就是跑一遍基线测试,和已知正常的机器对比。

5.4 踩坑三:ECC 和持久化模式的组合影响

前面提到关闭 ECC 能提升吞吐,但这里有个组合效应容易被忽略。如果你同时关闭了 ECC 又没开持久化模式,GPU 在空闲时降频,第一个请求进来时会有明显的唤醒延迟,而且这个延迟在关闭 ECC 后反而更明显,因为显存状态切换的开销变大了。

正确的做法是:要么两个都调,要么两个都不动。如果决定关闭 ECC 追求吞吐,就顺手把持久化模式打开,让 GPU 保持在高频状态。如果业务对稳定性要求高,那就保持 ECC 开启,接受那 5% 到 10% 的性能损失。

6. 这套优化思路还能往哪延伸

6.1 多卡场景下的控制层协调

单卡的控制层优化做完了,多卡场景会更复杂。多卡推理里,控制层不仅要调度单卡内的计算,还要协调卡间的通信。这时候 SoL-Pi 这类思路的延伸方向是把通信也纳入流水线,让卡间数据传输和计算重叠起来,而不是算完再传、传完再算。

具体做法上,张量并行(TP)适合把单层计算拆到多卡,流水线并行(PP)适合把不同层分到不同卡。前者通信频繁但数据量小,后者通信少但需要处理流水线气泡。选哪种,取决于你的模型大小和卡间带宽。

6.2 从推理延伸到微调场景

控制层优化的思路同样适用于微调。微调时 GPU 利用率低的问题往往更严重,因为数据加载、梯度同步这些环节的开销占比更高。把数据预取、梯度累积、通信重叠这些控制层手段用上,微调的吞吐也能有明显提升。

6.3 监控体系的搭建

优化做完不是终点,得有一套监控体系持续盯着。我建议至少监控这几个指标:GPU 利用率、KV Cache 使用率、每 Token 延迟的 P50/P99、swap 次数、请求队列长度。这些指标能帮你在问题恶化之前就发现苗头。

# 一个简单的 GPU 利用率采样脚本 while true; do nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total \ --format=csv,noheader >> gpu_log.csv sleep 5 done

把这份日志和你的服务日志对齐时间戳,就能看出性能波动和系统状态之间的关联。

我个人在实际操作中的体会是,控制层优化最考验的不是技术深度,而是对瓶颈的准确判断。很多人一上来就调参数,调了半天不知道自己在优化什么。正确的顺序永远是:先测量,找到真正的瓶颈,再针对性地调。49% 这个数字不是靠某一个参数调出来的,而是把调度、同步、内存搬运这几块的浪费一块块抠出来的。你先把自己的基线测准,剩下的就是按图索骥。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进