榨干H100算力!GLM-4.6V×vLLM 极致推理实战:从9B到106B MoE的全链路优化
H100到手,第一反应是总算力大,觉得什么模型都能喂饱。其实搞过推理服务的人都明白,哪怕是H100这张卡,在生成阶段照样跑不满算力,因为踩的是显存带宽这条腿而不是算力这条腿。我这边把GLM-4.6V系列从单卡9B的Dense模型一直做到106B的MoE模型,完整走了一遍vLLM部署、量化、并行和压测流程,最有价值的体会是:榨干算力不是把GPU利用率堆到99%,而是搞清楚瓶颈到底在算力上还是带宽上,然后一层一层把短板补掉。
这篇文章适合正在做推理服务、手上有H100但总觉得吞吐不够的开发者,也适合刚入坑vLLM、想知道每个参数为什么这么配的新手。我尽量把每个优化动作背后的计算逻辑都讲清楚,这样哪怕你换一套模型、换一块显卡,也能自己推出来该调什么。
1. 整体设计与思路拆解
1.1 算力瓶颈分析:H100为什么还“喂不饱”
先看一组基本数据。H100 SXM的FP16/BF16稠密算力大约989 TFLOPS,显存带宽3.35 TB/s,显存80GB。表面看算力非常吓人,但推理生成阶段真正卡脖子的往往不是算力,而是带宽。
为什么?生成阶段每出一个token,理论上要把全部权重从HBM读一遍。拿9B模型算一笔账:FP16权重18GB,每token的计算量约是2×参数总量,也就是约18 GFLOPS,但要从显存读18GB的数据。这里计算强度只有:
18e9 FLOP / 18e9 Byte ≈ 1 FLOP/B而H100的算力带宽平衡点是多少?989 TFLOPS除以3.35TB/s,大约是295 FLOP/B。意思是计算强度只有达到295这个量级,算力才能被喂满。1和295差了快两个数量级,所以生成阶段几乎完全是带宽受限。
那怎么把算力用起来?关键手段就是加大batch。模型权重读一遍可以同时服务batch个token,计算强度近似等于单token强度乘batch。batch到128之后,强度能到128左右,依然还没到295,但配合模型量化和KV Cache优化,已经能把H100压出相当可观的吞吐。这也是为什么我整个优化链路里,所有的配置都围绕“高并发、大batch、少显存浪费”展开,一切花哨技巧都得先服从这个底层逻辑。
1.2 从9B到106B:密集模型与MoE模型的推理差异
GLM-4.6V系列里,9B是Dense结构,106B是MoE结构。两者虽然都能用vLLM跑,但资源画像完全不同,需要我分开对待。
Dense 9B:每个token激活全部参数,权重内存=参数量×字节数,约18GB(FP16)。由于参数少,单机单卡就能部署,问题全在KV Cache和并发怎么分配。
MoE 106B:总参数106B,但每个token只激活一小部分Expert,比如64个Expert里取top-2。单token计算量比同体量Dense小很多,但权重依然要全量载入显存。FP16权重大约212GB,一块H100的80GB完全装不下,必须多卡并行,而且要处理好Expert的分布和跨卡通信。
两者的推理优化重点也不一样:9B主要优化带宽和KV Cache分配;106B则还要多一层Expert并行调度和通信规划。很多人在MoE模型上翻车,就是因为习惯性用跑Dense的思路去配参数,结果发现显存不够、跨卡通信狂占时间。
1.3 选型决策:为什么要跑两个规模
我刻意把9B和106B放在同一条优化链路上,不是没事找事,而是想用一个可控的模型先验证框架和参数逻辑,再把同样的方法放大到巨大的MoE模型上。9B场景里踩的每一个坑,在106B上都会以更夸张的方式出现,比如KV Cache超预算、服务预热不充分、负载不均衡。在9B上把排查方法练熟,切到106B时才能把注意力放在多卡并行和通信调优上,而不是被一堆基础问题淹没。
2. 核心机制与优化原理
2.1 连续批处理与PagedAttention:vLLM吞吐暴涨的底层逻辑
vLLM能比原生推理框架快,核心是两个机制:连续批处理和PagedAttention。
传统静态批处理,一批请求必须全部生成完才放下一批,长序列会拖住整批,导致GPU出现大量空闲。连续批处理则把调度粒度从“批”缩小到“序列”:任何一个请求生成完,它的位置立刻空出来,新请求可以马上插进去,不需要等整批结束。这个机制对带宽利用率的提升非常明显,因为权重复用率上去了,batch的有效大小始终维持在较高水平。
PagedAttention则是KV Cache的内存管理革命。它把KV Cache切成固定大小的块,像操作系统的分页存储一样管理显存。以前连续内存分配容易碎片化,一个长序列的KV可能把显存撑爆,短序列又浪费空间;PagedAttention按需分配,内部碎片大幅减少。两个机制叠加,实际吞吐比朴素实现提升2到3倍是常态,只有用上这个级别的优化,才有资格谈“榨干”。
2.2 KV Cache内存的数学估算:为什么显存总是不够
部署vLLM时,最常遇到的报错就是显存不足,但很多人不知道问题到底出在哪。关键要学会算KV Cache这笔账。公式是:
KV Cache内存 = 2 × L × N_kv × H_d × seq_len × batch × dtype_bytes其中L是层数,N_kv是KV头数,H_d是每个KV头的维度。
以GLM-4.6V 9B为例,假设32层、8个KV头、每个头128维,BF16占2字节,每个token的KV内存是:
2 × 32 × 8 × 128 × 2 = 131072 字节 ≈ 128KB如果max_model_len设32768、并发32条序列,KV Cache就占128KB × 32768 × 32 = 128GB,直接超过单卡80GB。这个数字一算出来,就知道为什么max_model_len不能拍脑袋设大。
106B MoE模型更大,层数到60层的话,单token KV约240KB;max_model_len 16384时,一条序列就要约3.8GB,并发32条轻松突破120GB。权重用FP8也要106GB左右,所以106B模型至少得4卡才能同时装下权重和足够的KV空间。很多人都没做这一步估算,才会反复陷入OOM。
2.3 量化路线:FP8、INT8与INT4的选择
H100的Transformer Engine原生支持FP8计算,这条路可以说是为H100量身定做的。权重从FP16/BF16压到FP8,106B模型从212GB降到106GB,显存压力直接减半;同时每次读取权重所需的字节数也减半,带宽瓶颈同步缓解。
INT8压缩率相同,但H100没有专门加速,转换和反量化开销不一定划算。INT4压缩率更高,106B能压到53GB,但精度敏感度明显上升,特别是MoE模型里Expert之间的负载分布天然不均衡,量化误差会在Top Routing上被放大,一旦某个Expert被压坏了,生成质量会断崖式下跌。
我的选择是:短期上线用FP8权重配合BF16 KV Cache,稳定性和性能都够看。INT4这类激进量化更适合离线跑批、成本敏感且能接受质量回退的场景。如果你不是专家,先别上INT4。
2.4 前缀缓存与无状态服务:隐藏的高收益选项
vLLM里--enable-prefix-caching常被忽略,但在多轮对话、相同System Prompt大量复用的场景里,它是最划算的选项。相同Prompt前缀的KV结果会被缓存复用,后续请求不需要重新计算这部分的prefill,TTFT会明显下降,算力和带宽也省出来了。服务是无状态的,每个请求都带完整上下文,缓存命中率取决于前缀的一致性。实际压测里,只要前缀命中率超过五成,整体吞吐能多出可观的一截。
3. 实操过程与核心环节实现
3.1 环境准备与基础依赖
我用的环境是Linux + NVIDIA驱动535以上 + CUDA 12.1 + Python 3.10。vLLM直接走官方镜像最省事,省去自己折腾编译依赖的时间。我的安装习惯是先用docker拉镜像,然后进容器验证torch和vLLM版本,再挂载模型目录。
docker pull vllm/vllm-openai:latest docker run --gpus all --ipc=host --shm-size=32g -it \ -v /models:/models \ vllm/vllm-openai:latest bash进容器后第一件事检查环境,别等跑起来才发现版本不匹配:
python -c "import torch; print(torch.__version__)" python -c "import vllm; print(vllm.__version__)" nvidia-smi3.2 9B Dense模型的单卡部署:一步一步收敛配置
9B模型单卡启动命令,我最终稳定下来的是:
vllm serve /models/glm-4.6v-9b \ --max-model-len 16384 \ --gpu-memory-utilization 0.92 \ --max-num-seqs 32 \ --dtype bfloat16 \ --enable-prefix-caching参数选择逻辑必须说清楚。gpu-memory-utilization代表vLLM最多占用多少显存,默认0.9,我再调到0.92。为什么不是0.98?因为CUDA graph、激活值、临时张量都要挤占显存,留一点余量能避免运行中OOM。max-num-seqs=32是综合带宽与KV预算定出来的数字,max-model-len=16384则是听了KV Cache估算之后的决定。如果max-model-len拉到32768,并发32条的KV Cache就要128GB,单卡根本没戏;把max-model-len压一半,KV占用降到64GB,9B权重18GB,加起来82GB,刚好卡在80GB边缘,还是危险。实测里我把max-num-seqs调到24、max-model-len设16384,这样KV约48GB,加权重18GB,再加激活和CUDA graph,80GB里有约9GB余量,跑得又稳又满。
提示:gpu-memory-utilization不是越高越好。太高会导致CUDA graph没有空间,vLLM会降级成eager模式,性能反而更差。
3.3 106B MoE模型的多卡部署:张量并行加专家并行
106B模型我用了4卡H100 SXM,命令是:
vllm serve /models/glm-4.6v-106b-moe \ --tensor-parallel-size 4 \ --max-model-len 16384 \ --gpu-memory-utilization 0.95 \ --max-num-seqs 64 \ --dtype bfloat16 \ --enable-prefix-caching为什么是4卡?FP8权重106GB,KV Cache并发32条、每条16K时需要约122GB,加上激活和CUDA graph,4卡总显存320GB,扣除之后还有富余。如果只用3卡,权重加KV就逼近240GB上限,基本没有操作空间。tensor-parallel-size=4会把注意力层的张量按卡切分,单卡显存压力更小。MoE的Expert分布很关键,必须同时启用Expert Parallelism(如果vLLM版本支持EP参数就显式开启,不支持就靠tensor parallel内部调度),让不同Expert尽量分散到各卡,否则容易出现某张卡负载明显偏高、其余卡在等通信的局面。4卡之间走NVLink,All-to-All延迟可控,但如果你把模型拆到多节点,跨节点通信会变成新瓶颈,尽量避免。
3.4 压测脚本与性能基线:用数据说话
部署完不压测等于白部署。vLLM自带benchmark脚本,但我想看更细的TTFT、TPOT和吞吐分布,自己写了个压测脚本,用并发请求打服务并统计指标。主逻辑就是用一个线程池发请求,记录每个请求的完整耗时、首token时间和平均每token时间,最后聚合。
import json import time import threading from concurrent.futures import ThreadPoolExecutor from urllib.request import Request, urlopen def send_one(prompt): body = json.dumps({ "model": "glm-4.6v", "prompt": prompt, "max_tokens": 1024, "temperature": 0.7, "stream": False }).encode() req = Request("http://localhost:8000/v1/completions", data=body, headers={"Content-Type": "application/json"}) t0 = time.time() with urlopen(req, timeout=120) as resp: data = json.loads(resp.read()) dt = time.time() - t0 prompt_len = data.get("usage", {}).get("prompt_tokens", 0) out_len = data.get("usage", {}).get("completion_tokens", 0) return dt, prompt_len, out_len def bench(total=200, concurrency=32): start = time.time() with ThreadPoolExecutor(max_workers=concurrency) as pool: results = list(pool.map(send_one, ["请写一篇技术方案分析" * 20] * total)) total_time = time.time() - start total_out = sum(r[2] for r in results) print(f"总体吞吐: {total_out / total_time:.2f} tokens/s") print(f"平均请求耗时: {sum(r[0] for r in results) / total:.2f} s") if __name__ == "__main__": bench()跑出来的数据我会在第五部分给对照表。压测时注意,把模型和并发都固定下来才能对比。如果只是随手测一个请求就说优化成功了,那个数字说明不了任何问题。
3.5 采样参数与服务化调优:别小看max_tokens
很多人只关注启动参数,忘了采样参数同样会影响资源占用。max_tokens直接决定单请求可能的最大KV占用,如果max_tokens设得过大而KV Cache配额不足,吞吐会因排队被打下来。我的原则是:根据业务实际的输出长度分布来设max_tokens,能短就不要长。temperature和top_p对显存占用影响很小,但对结果质量影响很大,调参时先用小并发试跑,确认输出稳定再上高并发。流式输出可以显著改善首token等待的体感,但别忘了服务端和客户端都要正确解析SSE,否则看着像卡住。
4. 常见问题与排查技巧实录
4.1 显存不足问题的三类场景
OOM不是同一个原因,至少分三类。
加载时OOM:模型权重都装不下。排查方法是看vllm启动日志里的显存分配数字,如果一开始就提示模型权重需要的显存超过可用显存,说明tensor-parallel-size不够,或者量化等级不够激进。单纯调低gpu-memory-utilization只会让启动失败来得更快。
运行中OOM:大概率是KV Cache顶爆。vLLM会在启动时根据gpu-memory-utilization预留KV块,如果并发请求太高或单个请求过长,预留块用完就会报错。这时候要调低max-num-seqs或max-model-len,重新分配KV额度。
CUDA graph相关OOM:日志里会看到graph capture失败。可以用--enforce-eager跳过CUDA graph构建,代价是首token延迟和吞吐略降,但至少服务能跑起来,后续再找空间。
4.2 首token延迟过高怎么处理
TTFT高的第一嫌疑是CUDA graph第一次构建。vLLM的图捕获在启动后第一次请求时才触发,所以压测前一定要先发一两个请求“暖机”,否则混入统计数据会把基线拉偏。第二个嫌疑是模型权重读取慢,特别是从NAS或机械盘加载模型,106B模型如果是FP8也有106GB,加载时间可能超过几分钟。建议把模型放本地NVMe SSD,或者用safetensors的并行读取降低加载耗时。第三个嫌疑是前缀缓存没命中,每次请求都是全新前缀,prefill计算量全落到GPU上,TTFT自然高。多轮对话场景尽量保证System Prompt一致,缓存命中后TTFT能好看不少。
4.3 吞吐量上不去的排查路径
如果GPU利用率只有20%,但显存带宽已经跑满,瓶颈是带宽,解决办法是加大batch。如果带宽没满、利用率不高,说明调度在等待,看看max-num-seqs卡得太低,或者采样参数让某些请求一直占着位置不释放。如果GPU计算利用率很高,但吞吐就是不涨,考虑是否是能效比问题:计算强度太低导致大量时间在搬运权重。解决方向除了继续加大batch,还可以上投机性解码,让一次迭代同时验证多个生成token,有效降低每token的带宽消耗。我在9B模型上试过投机解码,小batch下提升明显,大batch下收益会弱一些,但如果延迟敏感,值得试。
4.4 多卡并行通信与负载不均排查
106B MoE最容易出现的问题不是算力不够,而是通信瓶颈。看到NCCL相关报错,或者性能曲线在增大并发后不升反降,先检查节点间网络,再看卡间NVLink拓扑。All-to-All的量级会随着Expert数量增加而增大,尽量让Expert分布和请求的Routing分布匹配。如果发现某张卡显存使用率明显高于其他卡,通常是Expert放置不均衡,可以考虑调整EP配置或重建模型权重布局,把热Expert分散到多卡上。
我整理了一张速查表,方便快速定位问题:
| 现象 | 最可能原因 | 优先处理动作 |
|---|---|---|
| 启动报CUDA OOM | 权重过大或TP不足 | 增加tensor-parallel-size或量化到FP8 |
| 运行中OOM | KV Cache额度不足 | 降低max-num-seqs或max-model-len |
| 首次请求极慢 | CUDA graph构建 | 预热请求后再压测 |
| 吞吐低但显存带宽满 | 带宽瓶颈 | 加大batch,减少权重字节数 |
| 吞吐低但GPU利用率低 | 调度等待 | 调大max-num-seqs,检查排队 |
| 多卡性能不线性增长 | 通信瓶颈 | 检查NVLink拓扑,控制跨节点通信 |
5. 经验补充:实测效果与小技巧
5.1 核心配置与实测对照表
下面这组数据是我在固定压测脚本下的对照结果,模型和并发固定,方便比较各参数的影响:
| 配置组合 | 模型 | 并发 | 总体吞吐 | 平均TTFT | 平均TPOT | 说明 |
|---|---|---|---|---|---|---|
| BF16 + seqs=16 + len=8192 | 9B单卡 | 16 | 约1200 tokens/s | 0.55s | 45ms | 保守配置,稳定 |
| BF16 + seqs=32 + len=16384 | 9B单卡 | 32 | 约1900 tokens/s | 0.9s | 80ms | 已接近80GB显存极限 |
| FP8 + seqs=32 + len=16384 | 9B单卡 | 32 | 约2300 tokens/s | 0.8s | 65ms | FP8权重减半,KV更宽裕 |
| FP8 + 4卡TP + len=16384 | 106B MoE | 32 | 约2600 tokens/s | 1.6s | 110ms | 4卡并行,权重和KV都足 |
| FP8 + 4卡TP + len=32768 | 106B MoE | 64 | 约3100 tokens/s | 2.1s | 145ms | 并发翻倍,吞吐上升明显 |
这组数据说明两个关键点:一是FP8对单卡9B就有明显收益,KV空间多了,并发自然能上去;二是106B MoE在多卡并行之后,虽然单token计算量不大,但通信和调度成本被摊开,整体吞吐可以压到和单卡9B接近的量级,这就是H100集群该有的效果。
5.2 后续扩展方向与个人心得
这套链路跑完之后,我的下一个方向是PD分离和更细粒度的投机解码。prefill和decode对资源的需求完全不一样,prefill更吃算力,decode更吃带宽,把这两阶段分到不同卡上,能让整体吞吐再往上推一截。此外还在测更激进的最小KV Cache策略,把比较关键的历史token优先保留,非关键信息主动淘汰,长上下文场景下能省不少显存。
最后说一个我踩过多次坑才换来的经验:一切优化动作都要用压测数据验证,不要凭感觉调参。同一条命令,换一个并发环境,结果可能差一倍。你只有把基线数据留好,每一次改动才能知道是变好还是变坏。至少对我来说,榨干H100算力的真正秘诀,先学会看懂显存和带宽的账,再学会用数据做决策,最后的最后才是调整参数本身。