1. 从一次显存告警说起:为什么要做推理过程的可视化
凌晨两点,监控面板弹出一条显存溢出告警,某个线上推理服务的响应延迟从 800ms 飙到 4.2s,紧接着一批请求开始超时。我打开日志翻了半天,只看到“CUDA out of memory”这一行冷冰冰的报错,至于显存到底是被哪一层吃掉的、KV Cache 涨到了多少、batch 里哪条请求是长尾元凶,一概不知。那次排查花了将近三个小时,最后靠手动加打印才定位到问题——一个没做长度截断的输入把整个 batch 的 KV Cache 撑爆了。
这件事之后我就一直在想:大模型推理的硬件层,对我们这些做应用和部署的人来说,基本是个黑盒。模型跑在 GPU 上,显存、算力、带宽这些资源怎么被消耗的,我们只能靠猜。市面上讲推理优化的文章不少,但大多停留在“调大 batch size”“开量化”这种经验层面,很少有人把 GPU 内部到底发生了什么讲清楚。于是就有了 WatchMachineGo 这个项目——一套把 LLM 推理过程中的 GPU 硬件行为可视化的工具,目标很朴素:让每一次推理的显存占用、算力利用、访存瓶颈都能被看见,然后基于这些数据做有针对性的优化。
这篇文章我会把整个项目的设计思路、核心实现、踩过的坑和实测数据完整拆一遍。适合谁看?如果你正在做 LLM 推理部署、被显存和延迟问题折磨过、想搞清楚 GPU 上到底发生了什么,那这篇内容应该能帮到你。不需要你精通 CUDA,但最好对 Transformer 结构和 PyTorch 有一点基础认知,不然有些地方会有点吃力。
2. 整体设计思路:为什么是“可视化 + 优化”而不是单纯调参
2.1 核心问题拆解:推理性能到底卡在哪
在动手写代码之前,我先花了一周时间梳理 LLM 推理的性能瓶颈到底出在哪些环节。结论是三个字:显存墙。具体来说,推理阶段的资源消耗可以拆成这么几块:
- 模型权重:这部分是固定的,7B 模型 FP16 大概 14GB,INT8 大概 7GB,INT4 大概 3.5GB。这块没什么优化空间,除非换量化方案。
- KV Cache:这是推理阶段最动态、最容易失控的部分。它的计算公式是
2 × batch_size × seq_len × num_layers × num_heads × head_dim × dtype_size。注意这里的seq_len是输入长度加输出长度,长文本场景下它会线性膨胀。 - 激活值:前向传播过程中产生的中间结果,跟 batch size 和序列长度强相关。
- 临时缓冲区:CUDA kernel 执行时的 workspace,容易被忽略但量不小。
我实测过一个 7B 模型在 A100 80GB 上的情况:权重占 14GB,剩下 66GB 看起来很多,但当 batch size 开到 32、序列长度 2048 时,KV Cache 能吃掉 40GB 以上,留给激活值和缓冲区的空间就非常紧张了。这就是为什么很多服务在压测时 batch 一开大就 OOM。
问题的关键在于,这些数字在运行时是动态变化的,静态估算根本不准。所以我需要的是一个能实时采集、实时展示的工具,而不是事后算账。
2.2 方案选型:为什么用 PyTorch Hook + 独立采集进程
可视化工具的实现路径有好几条,我对比过三种:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 直接改推理框架源码 | 在 vLLM/TGI 里插桩 | 数据最准 | 侵入性强,升级框架就废 |
| CUDA Profiler 全家桶 | nsight / nvprof | 数据最全 | 太重,实时性差,学习成本高 |
| PyTorch Hook + 独立采集 | 用 forward hook 抓张量,独立进程读 GPU 指标 | 轻量、非侵入、可实时 | 需要自己处理数据同步 |
我最终选了第三条路。核心原因是非侵入——我不想为了看数据去改推理框架的代码,那样每次框架升级都要重新适配。PyTorch 的register_forward_hook可以在不改模型定义的前提下拿到每一层的输入输出张量,配合torch.cuda.memory_allocated()和torch.cuda.memory_reserved()就能拿到显存数据。GPU 的算力利用率、显存带宽这些硬件指标,则通过一个独立的采集进程去读,避免和推理主进程抢资源。
这里有个设计上的取舍:Hook 抓张量会带来额外开销。我的做法是只在采样模式下开启 Hook,正常推理时关闭。采样模式下每 N 次推理抓一次数据,这样对性能的影响可以控制在 5% 以内。实测下来,7B 模型在采样模式下的吞吐下降大概 3% 到 4%,完全可以接受。
2.3 数据流设计:从 GPU 到可视化面板
整个数据流是这样的:
推理进程 (PyTorch) ├── forward hook 抓取每层张量形状/显存 ├── torch.cuda 接口读显存分配 └── 写入共享内存队列 ↓ 采集进程 (独立) ├── 读共享内存队列 ├── 读 GPU 硬件指标 (利用率/带宽/温度) └── 聚合后推给前端 ↓ 可视化面板 (Web) ├── 显存占用时序图 ├── 每层显存分布热力图 └── 算力/带宽利用率曲线用共享内存而不是网络传输,是因为推理进程和采集进程在同一台机器上,共享内存的延迟在微秒级,不会成为瓶颈。前端用 WebSocket 推数据,保证实时性。
提示:共享内存队列一定要设上限,否则推理进程写得太快、采集进程读得太慢时会把内存吃光。我设的是 10000 条,满了就丢最老的,保证不阻塞推理。
3. 核心细节解析:显存、算力、带宽三块怎么抓
3.1 显存可视化:把 KV Cache 单独拎出来算
显存这块是最核心的。PyTorch 提供了几个关键接口:
torch.cuda.memory_allocated():当前实际被张量占用的显存torch.cuda.memory_reserved():PyTorch 向 CUDA 申请的总显存(包含缓存)torch.cuda.max_memory_allocated():峰值占用
但光有这几个数字不够,我需要知道显存被谁占了。这里的关键是区分三块:模型权重、KV Cache、激活值。
模型权重好办,在加载完模型后读一次memory_allocated()就是权重占用。KV Cache 需要单独算,因为它是推理过程中动态增长的。我的做法是在 attention 层加 hook,拿到key和value张量的形状,然后按公式算:
def calc_kv_cache_size(batch_size, seq_len, num_layers, num_heads, head_dim, dtype_size=2): # dtype_size: FP16=2, FP32=4, INT8=1 return 2 * batch_size * seq_len * num_layers * num_heads * head_dim * dtype_size这个公式里的2是 key 和 value 各一份。实测下来,这个计算值和memory_allocated()的增量能对上,误差在 3% 以内,主要来自内存对齐的 padding。
激活值就是总占用减去权重和 KV Cache。虽然是个“差值”,但在可视化面板上单独列出来很有用,因为激活值的峰值往往出现在前向传播的中间层,而不是最后。
3.2 算力利用率:SM 占用率和 Tensor Core 使用率
算力利用率这块,我用的是pynvml库读 GPU 的底层指标。关键指标有两个:
- SM 占用率(SM Occupancy):流多处理器有多少在干活。这个值低说明 GPU 在摸鱼,可能是 kernel 太小或者并行度不够。
- Tensor Core 使用率:矩阵乘有没有走 Tensor Core。LLM 推理里大部分计算是矩阵乘,如果没走 Tensor Core,性能会差好几倍。
这里有个坑:pynvml读到的 SM 占用率是整卡的,如果你的推理进程只用了部分 GPU 或者和其他进程共享 GPU,这个值会失真。我的做法是在采集进程里同时记录进程级的 GPU 使用情况,通过nvidia-smi的进程查询接口拿到当前进程的 SM 占用,两者对照着看。
实测数据:7B 模型 FP16 推理,batch size 为 1 时 SM 占用率只有 15% 左右,batch size 开到 16 能到 60%,32 能到 85%。这说明小 batch 下 GPU 根本没吃饱,延迟敏感场景可以适当增大 batch 来提升吞吐。
3.3 显存带宽:被忽视的隐形瓶颈
显存带宽是 LLM 推理里最容易被忽视的瓶颈。很多人只盯着算力,觉得 GPU 算得快就行,但实际上 decode 阶段是访存密集型的——每生成一个 token,都要把整个 KV Cache 读一遍。7B 模型在序列长度 2048 时,KV Cache 大概 2GB,生成 100 个 token 就要读 200GB 的数据。A100 的显存带宽是 2TB/s,理论上 0.1 秒能读完,但实际因为访存模式不连续,效率会打折扣。
带宽的采集我用的是pynvml的nvmlDeviceGetMemoryInfo配合时间差来估算,更精确的做法是用 CUDA 的cupti接口,但那个太重了。实测下来,decode 阶段的带宽利用率能到 70% 到 80%,而 prefill 阶段只有 30% 左右——这解释了为什么 prefill 是算力瓶颈、decode 是带宽瓶颈。
注意:带宽利用率的估算受采样频率影响很大。采样间隔太短(比如 10ms)会因为读数抖动导致误差,太长(比如 1s)又会漏掉峰值。我实测下来 100ms 是个比较平衡的值。
4. 实操过程:从零搭一套可视化环境
4.1 环境准备与依赖安装
先列一下我用的环境,这套配置在 A100 和 4090 上都跑通过:
# 基础环境 Python 3.10 PyTorch 2.1.0 + CUDA 12.1 pynvml 11.5.0 fastapi 0.104.0 uvicorn 0.24.0 websockets 12.0 numpy 1.24.0安装命令:
pip install torch==2.1.0 --index-url https://download.pytorch.org/whl/cu121 pip install pynvml fastapi uvicorn websockets numpy这里有个细节:pynvml的版本要和驱动匹配。我遇到过驱动版本太老、pynvml读不到某些指标的情况,升级驱动后解决。如果你用的是容器环境,记得把 GPU 设备透传进去,否则pynvml会报找不到设备。
4.2 推理进程的 Hook 注入
Hook 注入的核心代码不复杂,但有几个细节要注意:
import torch from collections import deque class MemoryTracker: def __init__(self, sample_interval=10): self.sample_interval = sample_interval self.call_count = 0 self.records = deque(maxlen=10000) self.hooks = [] def attach(self, model): for name, module in model.named_modules(): if isinstance(module, torch.nn.Linear): hook = module.register_forward_hook(self._make_hook(name)) self.hooks.append(hook) def _make_hook(self, name): def hook(module, input, output): self.call_count += 1 if self.call_count % self.sample_interval != 0: return self.records.append({ 'layer': name, 'input_shape': tuple(input[0].shape) if input else None, 'output_shape': tuple(output.shape), 'allocated': torch.cuda.memory_allocated(), 'reserved': torch.cuda.memory_reserved(), }) return hook关键点在于sample_interval的控制。如果每层每次都抓,开销会很大,尤其是层数多的模型。我实测过,7B 模型 32 层,每层都抓的话吞吐下降 15% 以上。设成 10 之后降到 3% 左右。
还有一个坑:Hook 里不要做耗时操作。我一开始在 hook 里直接算 KV Cache 大小,结果因为 Python 的 GIL 和计算开销,性能掉得厉害。后来改成只记录原始数据,计算放到采集进程里做,性能就正常了。
4.3 采集进程与数据聚合
采集进程独立跑,通过共享内存队列拿数据:
import pynvml import time from multiprocessing import shared_memory class GPUMonitor: def __init__(self, device_id=0, interval=0.1): pynvml.nvmlInit() self.handle = pynvml.nvmlDeviceGetHandleByIndex(device_id) self.interval = interval def sample(self): util = pynvml.nvmlDeviceGetUtilizationRates(self.handle) mem = pynvml.nvmlDeviceGetMemoryInfo(self.handle) return { 'timestamp': time.time(), 'gpu_util': util.gpu, 'mem_util': util.memory, 'mem_used': mem.used, 'mem_total': mem.total, }采集频率设成 100ms,也就是每秒 10 个点。这个频率下,CPU 占用大概 1% 到 2%,可以忽略。数据聚合的时候,我把推理进程的显存数据和采集进程的硬件指标按时间戳对齐,对齐窗口设成 50ms,超过这个窗口的数据就丢弃,避免时间错位导致图表失真。
4.4 前端可视化面板
前端我用的是轻量的方案:FastAPI 提供 WebSocket 接口,前端用原生 JS 加 Canvas 画图,没上 React 那套重家伙。原因很简单——这个工具是给自己用的,不需要复杂的交互,能实时看到曲线就行。
面板上主要三块:
- 显存时序图:横轴时间,纵轴显存,三条线分别是权重、KV Cache、激活值。
- 每层显存热力图:横轴层号,纵轴时间,颜色深浅表示显存占用。
- 算力/带宽双轴曲线:左轴算力利用率,右轴带宽利用率。
实测下来,这套面板在 1080p 屏幕上刷新率能到 30fps,完全够用。数据量大的时候(比如跑了几个小时),前端会做降采样,保证不卡。
5. 实测数据与优化案例:可视化到底能发现什么
5.1 案例一:长尾请求导致的显存尖峰
这是开头提到的那个问题的复现。我在测试环境里模拟了一个场景:batch 里有 8 条请求,其中 7 条长度在 200 左右,1 条长度 1800。可视化面板上能清楚看到,KV Cache 的曲线在长请求进入后突然抬升,然后一直维持在高位,直到这条请求结束才降下来。
优化方案很简单:按长度分桶。把长度相近的请求放到同一个 batch 里,避免长尾请求拖累整个 batch 的显存。实测下来,同样的吞吐下,显存峰值降低了 35%,P99 延迟从 4.2s 降到 1.8s。
这个优化如果没有可视化,很难发现——因为从平均延迟看,问题并不明显,只有 P99 才暴露出来。
5.2 案例二:量化方案的显存收益实测
我用可视化工具对比了 FP16、INT8、INT4 三种量化方案在 7B 模型上的表现:
| 量化方案 | 权重显存 | KV Cache 显存 | 吞吐 (tokens/s) | 首 token 延迟 |
|---|---|---|---|---|
| FP16 | 14.0 GB | 8.2 GB | 1250 | 180 ms |
| INT8 | 7.1 GB | 4.3 GB | 1580 | 210 ms |
| INT4 | 3.6 GB | 2.2 GB | 1720 | 260 ms |
数据很有意思:INT4 的吞吐最高,但首 token 延迟也最高。原因是量化后的反量化操作增加了计算量,prefill 阶段受影响明显。所以如果你的场景是长文本生成(decode 为主),INT4 划算;如果是短查询(prefill 为主),FP16 反而更合适。
这个结论如果只看文档是得不出来的,必须实测。可视化工具的价值就在于,它把“量化到底省了多少显存、代价是什么”这件事变得一目了然。
5.3 案例三:batch size 与吞吐的非线性关系
很多人以为 batch size 翻倍吞吐就翻倍,实测下来完全不是。我用可视化工具扫了一遍 batch size 从 1 到 64 的情况:
- batch 1 到 8:吞吐线性增长,GPU 利用率从 15% 涨到 55%。
- batch 8 到 32:吞吐增长放缓,GPU 利用率到 85%,但显存开始吃紧。
- batch 32 到 64:吞吐几乎不涨,显存溢出风险大增,延迟翻倍。
拐点在 batch 32 左右。这个拐点跟模型大小、序列长度、GPU 型号都有关,没有通用公式,只能实测。可视化工具能帮你快速找到自己场景下的拐点。
提示:找拐点的时候,建议固定序列长度,只变 batch size。如果序列长度也在变,数据会很难解读。
6. 常见问题与排查技巧实录
6.1 Hook 导致性能下降太多怎么办
这是最常见的问题。排查思路:
- 先确认
sample_interval是不是设得太小。默认 10 是个比较稳的值,设成 1 的话性能肯定崩。 - 检查 hook 里有没有做耗时操作。任何张量运算、Python 循环、IO 操作都要挪到采集进程。
- 如果还是慢,考虑只 hook 关键层(比如 attention 层和 FFN 层),不要 hook 所有 Linear 层。
我踩过的一个坑:在 hook 里调了torch.cuda.synchronize(),本意是想让显存读数更准,结果每次 hook 都同步一次,性能直接掉了一半。后来发现memory_allocated()本身不需要同步,去掉就好了。
6.2 显存数据对不上是什么原因
可视化面板上的显存数字和nvidia-smi对不上,一般有三个原因:
- PyTorch 的缓存机制:
memory_reserved()包含缓存,memory_allocated()不包含。面板上要明确标注用的是哪个。 - 其他进程占用:如果 GPU 上有其他进程,
nvidia-smi显示的是总量,而 PyTorch 只报自己的。 - 内存对齐:CUDA 分配显存有对齐要求,实际占用会比理论值大一点,误差在 5% 以内算正常。
排查的时候,先看memory_reserved()和nvidia-smi的差值,如果差得不多,说明是缓存问题;如果差很多,说明有其他进程在抢显存。
6.3 可视化面板数据延迟高怎么优化
数据延迟高通常是采集进程或前端的问题。排查顺序:
- 看采集进程的 CPU 占用。如果超过 10%,说明采集频率太高或者聚合逻辑太重。
- 看共享内存队列的积压情况。如果队列一直满,说明采集进程读得太慢。
- 看前端渲染。如果数据量大,前端降采样没做好,会卡。
我的经验是,采集频率 100ms、队列上限 10000、前端降采样到 1000 个点,这套参数下延迟能控制在 200ms 以内,体感上基本是实时的。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 推理吞吐骤降 | Hook 开销大 | 关闭 Hook 对比 | 调大 sample_interval |
| 显存数据对不上 | 缓存/其他进程 | 对比 reserved 和 nvidia-smi | 明确标注口径 |
| 面板数据延迟高 | 采集/前端瓶颈 | 看 CPU 和队列积压 | 降采样、降频率 |
| GPU 利用率低 | batch 太小 | 扫 batch size | 增大 batch |
| 带宽利用率高 | decode 访存密集 | 看 prefill/decode 阶段 | 优化 KV Cache 布局 |
7. 后续可以扩展的方向
这套工具目前只覆盖了单卡单进程的场景,实际生产环境里还有几个方向值得做。一是多卡并行,张量并行和流水线并行下的显存分布跟单卡完全不同,可视化需要区分每张卡的数据。二是多进程共享 GPU,多个推理服务跑在同一张卡上时,怎么区分各自的显存占用是个难题,目前只能靠 CUDA 的进程级接口,精度有限。三是自动调优,基于采集到的数据,自动推荐 batch size、量化方案、KV Cache 策略,这个还在探索阶段。
我个人在实际操作中的体会是,可视化工具最大的价值不是告诉你“现在发生了什么”,而是帮你建立对推理过程的直觉。用久了之后,看到显存曲线就能猜到问题出在哪,这种直觉是看文档看不来的。如果你也在做推理部署,建议花点时间搭一套自己的可视化环境,哪怕功能简单,也比盲调强得多。