晚上八点,AI 大饭店进入晚高峰:用户请求像客人一样涌进大门。后厨有几十口灶台,每一口都由 GPU 的 SM(流式多处理器)扮演;冷藏库则由显存扮演,堆满了食材和半成品。你作为后厨总管,最头疼两件事:灶台常常空转,冷藏库却乱成一团——一整扇猪肉占不进去,只剩下能塞一根葱的小格子。这就是 GPU 利用率和显存管理的日常:SM 占用率像灶台的上座率,显存碎片像冷库被切成零碎格子。本文用一堂实战课,教你像优秀总管一样装上监控摄像头(Nsight)、让半成品按页取用(PagedAttention)、把食材做成浓缩包(量化)、把同款菜品合并出餐(批处理),让每一口灶台都滚烫,每一块冷库都物尽其用。
掌握 GPU 利用率和显存管理,是 AI 工程化岗位从「能跑模型」到「会跑得快、跑得省」的分水岭,也是大规模部署和面试中最常被追问的实战能力。
目录
可视化演示
展示SM Occupancy计算占用率、显存碎片化优化前后对比、PagedAttention分页管理KV Cache的原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
SM Occupancy计算占用率 每个SM单元的Warp调度状态实时可视化 活跃Warp占比反映计算资源利用程度 Occupancy=ActiveWarps/MaxWarps 提高占用率是GPU优化核心目标
GPU利用率指标:给后厨装上温度计
把 GPU 想象成 AI 大饭店的后厨:每一顿饭都由多口灶台(SM)同时烹饪,每口灶台还能接待好几组厨师。这里最小的调度单位叫 warp(线程束),你可以把它理解成一组固定人数的厨师小队。如果灶台灯亮着但厨师小队没坐满,菜就不会出锅,这就是 GPU 利用率低下最常见的状态。
SM Occupancy 是后厨管理中最基础的体温计,它的定义是:单个 SM 上真正在活动的线程束数量,除以该 SM 理论上最多能容纳的线程束数量。它衡量的是「灶台座位坐满没有」,而不是「菜炒得有多快」。一个 kernel 的计算利用率很高,也可能是在等食材——也就是等显存数据,这时真正拖后腿的是访存瓶颈。
实战中使用 Nsight Compute 打开 kernel 分析报告,重点看 SM Activity(计算活动)和 Memory Throughput(访存吞吐)。如果两者都快接近 100%,说明 ALU 在等数据,典型访存瓶颈;如果两者都低于 50%,则要怀疑 Occupancy 太低、启动开销过大或者 GPU 根本没吃饱。就像后厨总管要同时看灶台、切菜台和传菜口,不能只盯一个画面下结论。
- 活动线程束数单个 SM 上当前已经就位、可以参与执行的线程束数量。这个数字高,说明灶台上的厨师小队多,GPU 有机会隐藏访存延迟。
- 最大线程束数由硬件寄存器数量、共享内存大小等资源决定的上限。它像一口灶台最多能站多少组厨师,站不下就只能排队。
- 计算百分比把两者相除再乘上 100%,得到 0% 到 100% 的 Occupancy 指标,用来横向对比不同 kernel 的资源饱满度。
显存碎片化:冷库被切成了乐高拼图
显存就像后厨的冷库,每次请求都要向管理员申请一块地方放中间产物。如果管理员坚持给你一整块连续货架,而冷库已经被多次占用和释放切得七零八落,就会出现很多「塞不下整块食材」的小空档,这就是外部碎片。外部碎片最直观的后果是:明明仓库总空闲空间很充足,但一整块大内存就是申请不到。
还有另一种看不见的浪费叫内部碎片。管理员为了对齐效率,习惯按 512MB 的货架大小分配:你只需要 300MB,他仍给你 512MB,剩下 212MB 被悄悄锁死,别人不能用。两者叠加,显存利用率会显著下降。工程上常用外部碎片率来量化冷库的混乱程度。
在 PyTorch 中,显存由 Caching Allocator 管理,它会把释放的块缓存起来复用,但长时间训练会产生碎片。推荐设置 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True 或调节 max_split_size_mb 来缓解;推理服务中则可以用块级页式分配,从根上绕开「必须连续」的约束。这就像把冷库改造成一格一格可组合的冰柜,小格子也能灵活拼出大空间。
- 最大连续空闲块冷库里能完整放下一扇猪肉的最长连续空货架长度。碎片多了,这个值会非常小。
- 总空闲显存所有零碎空位加起来的剩余空间。它看起来很大,但不代表能用。
- 1 减比率用 1 减去可用连续块占比,得到碎片率。比率越大,说明空闲空间越难被大请求利用。
KV Cache优化:留住有用的半成品
大模型生成句子时,每生成一个 token,注意力层都要重新看一遍历史上所有 token。如果每次都把已经切好的葱姜蒜扔掉重新切,后厨会累死。于是聪明的主厨把处理过的 K 矩阵和 V 矩阵当作半成品留在台面上,供后续每个新 token 直接取用,这批半成品就叫 KV Cache。
KV Cache 是推理显存占用的主要大头。它的体积随序列长度、层数、隐藏维度和请求数线性增长,非常夸张。一个 7B 模型的权重可能是 14GB,但长对话中的 KV Cache 很快也会达到十几 GB。理解它的计算公式,是优化显存的第一步。
优化 KV Cache 的常用手段包括:前缀共享(多个请求共用餐前准备)、KV Cache 量化为 INT8/INT4、滑动窗口注意力只保留最近若干 token,以及用 PagedAttention 按块动态分配。永远记住:KV Cache 是随请求动态增长的,不是一次性分配完成的,所以管理它比管理权重更需要精细操作。
- 系数 2Transformer 的注意力机制既要缓存 Key 矩阵,也要缓存 Value 矩阵,所以有双份数据。
- 层数 L模型的每一层都有自己的 K、V,12 层就要存 12 层半成品。
- 隐藏维度 H每个 token 的向量维度,相当于每道菜需要切好多少种配料。
- 序列长度 T当前已生成的 token 总数。对话越长,T 越大,缓存持续累积。
- 精度字节数 bFP16 每个元素占 2 字节,INT8 占 1 字节。降低精度能直接缩小半成品体积。
PagedAttention:按页取菜的分页冷库
传统 KV Cache 分配是一锤子买卖:每个请求一开始就申请一整块连续显存,而且只能按最大可能长度预留。就像客人还没点菜,你就把一整头猪放进制冷柜,结果客人只吃了两片肉,剩余空间还因为被占用而无法挪作他用。连续分配加上动态增长,让显存浪费率常常超过 50%。
PagedAttention 的灵感来自操作系统里的虚拟内存分页。它把 KV Cache 切成固定大小的块,逻辑上每个请求的序列是连续的,但物理上这些块可以散落在显存的任意空位。哪里有空格子就放哪里,再用一张「页表」把散落的块串起来。它需要多少块,就只占多少块。
vLLM 推理框架就是靠 this 思想把 KV Cache 利用率从 50% 以下提升到 90% 以上。更妙的是,多个请求如果有相同的前缀,页表可以让它们共享同一批物理块,这样系统提示词占用的显存几乎可以被所有会话复用。分页是一套优雅的「冷库改造方案」,它放弃了物理连续,换来了更高的格子和用率。
- token 总数当前请求需要缓存的序列长度,也就是已经处理过多少字。
- 块大小每一页能放的 token 数量,比如 16 个 token 一块。块大小往往取 2 的幂,方便位运算。
- 向上取整如果 token 数不能整除块大小,最后多出来的几个 token 也要单独占一个块,所以用向上取整。
批处理与量化:合并出餐与浓缩汤包
后厨高峰期,如果一位客人一趟只点一个菜,灶台要不断烧热、关火、切换,效率极低。动态批处理就像把门口等位的客人按照相同口味合并成一大锅炒:一次处理更多 token,让每个 SM 的线程束尽量坐满。连续批处理(Continuous Batching)还会把正在生成的请求和刚到达的请求混在一起,让 GPU 永远在干活。
另一个降低显存压力的手段是量化。FP16 的权重每个数占 2 字节,INT8 只占 1 字节,相当于把食材做成浓缩汤包,体积小一半,味道却大体保留。量化的收益不只是省显存,还因为访存数据量变小,瓶颈从内存搬运中释放出来,真正提高了计算效率。
不过量化不是无代价的。它通常需要少量校准数据来估算数值范围,少数敏感层可能需要保留高精度。批处理同样有取舍:批量越大,单请求延迟越高,所以工程上要在吞吐和延迟之间拨动天平。好主厨会看客人能不能等:能等的客人拼大锅,急着吃的客人单独开小灶。
- 压缩前显存模型中权重或 KV Cache 在原始精度下占用的空间。
- 压缩后位宽量化后的每个元素占多少 bit,比如 INT8 为 8 bit。
- 原始位宽量化前的位宽,比如 FP32 是 32 bit,FP16 是 16 bit。
- 整体乘算用原显存乘上位数比例,得到压缩后显存。FP32 到 INT8 是四分之一,FP16 到 INT8 是一半。
性能Profiling:回放厨房监控录像
后厨管理水平再高,也需要摄像头。Nsight 就是这样一套录像回放系统:它不仅能看平均利用率,还能逐帧、逐 kernel 地展示 GPU 内部每个阶段花了多长时间。有的时间真的在炒菜(计算),有的时间却是在等切好的菜从仓库送到台面(访存)。定位瓶颈,就是分清主厨在等锅还是在等菜。
用 Nsight Compute 打开一个 kernel,可以看到 SM Activity、Memory Throughput、Warp State 等指标。其中访存等待时间很关键:如果总执行时间里很大一部分是在等显存数据,那么再怎么提升 SM 频率也没用。可以把它写成一个简单比例:
完整优化流程是一个闭环:先用 Nsight 做 Profile,定位是哪类 kernel 慢;再根据指标判断是 Occupancy 不足、访存瓶颈还是碎片化严重;然后针对性调整批处理、PagedAttention 或量化策略;最后重新 Profile 验证收益。优化不是一锤子买卖,而是像后厨试菜一样,不断回放录像、尝菜、改配方。
- 访存等待时间线程束因为等待显存数据返回而停摆的时间。它包含对全局内存、显存带宽的等待。
- 内核总时间该 kernel 从开始到结束的完整执行时间,既包含计算也包含等待。
- 相除得比例等待时间占总时间的比例越高,说明访存路径越可能是瓶颈;比例低则更可能是计算或占用率问题。
AI 应用场景
大模型推理服务的吞吐瓶颈治理
阶段:推理部署与在线服务优化
解决的问题:在线聊天机器人请求长、并发高,传统 KV Cache 按最大长度连续分配,显存碎片严重,一张 A100 只能支撑几十路并发,频繁 OOM。AI 推理引擎使用 PagedAttention 按块动态分配 KV Cache,配合动态批处理把 prefill 和 decode 请求混在一起执行,再用 INT8 量化 KV Cache,让显存占用下降、SM 占用率明显提升。
具体案例:某私有化 ChatGLM 服务原先 8 卡 A100 在 128 并发时就报 CUDA OOM;迁移到支持 PagedAttention 的推理框架后,同样显存支撑到 512 并发,吞吐提升约 3 倍,平均首 token 延迟反而下降。
训练任务 OOM 排查与显存整理
阶段:训练性能调优与故障排查
解决的问题:训练脚本报 CUDA OOM,但 nvidia-smi 显示显存还有空闲。传统排查只盯显存总量,不知道 PyTorch Caching Allocator 会缓存释放块并产生碎片。AI 工程师用 torch.cuda.memory_summary() 看分配器内部状态,再设置 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,让显存段随需要扩张,减少碎片,最后用 Nsight 确认瓶颈从显存搬到了计算。
具体案例:一个 GPT 风格模型训练任务 batch size 只能开到 32,开 40 就 OOM;启用 expandable segments 并把 max_split_size_mb 调至 256 后,batch 稳定开到 40,训练吞吐提升 25%。
GPU 集群利用率可观测性平台
阶段:资源调度与可观测性
解决的问题:企业 K8s 集群中 GPU 显存平均占用 80%,但 SM 利用率只有 20%,资源被「占着不用」。传统调度只看显存是否够用,导致大量计算闲置。平台工程师部署 DCGM 采集 GPU 利用率指标,并用 Nsight 周期性对典型模型做深度 Profile,把「SM 利用率」和「显存占用」作为联合调度条件。
具体案例:某公司监控平台发现两个推理服务分别占用半张卡显存但计算时段错峰,于是将它们合并调度到同一张 GPU 上,再将空出的卡分配给新任务,集群整体 SM 利用率从 22% 提升到 58%。
Python 演示
import random class BlockAllocator: def __init__(self, total_blocks=32, paged=False): self.blocks = [False] * total_blocks self.paged = paged self.total_blocks = total_blocks def alloc(self, need): if need <= 0 or need > self.total_blocks: return -1 if not self.paged: for start in range(self.total_blocks - need + 1): if not any(self.blocks[start:start + need]): for i in range(start, start + need): self.blocks[i] = True return start return -1 free_idx = [i for i, used in enumerate(self.blocks) if not used] if len(free_idx) < need: return -1 for i in free_idx[:need]: self.blocks[i] = True return free_idx[:need] def simulate(seq, paged): allocator = BlockAllocator(32, paged) fails = 0 for need in seq: if allocator.alloc(need) == -1: fails += 1 return fails def main(): random.seed(7) seq = [random.randint(1, 4) for _ in range(300)] naive_fail = simulate(seq, False) paged_fail = simulate(seq, True) print("连续分配失败次数:", naive_fail) print("分页分配失败次数:", paged_fail) print("分页减少失败:", naive_fail - paged_fail) if __name__ == "__main__": main()
class BlockAllocator:给连续分配和分页分配共用一个冷库模型,只是分配策略不同。if not any(self.blocks[start:start + need]):连续分配要求找到一整段空闲块,任一小格被占用就跳过,这很容易产生碎片。free_idx = [i for i, used in enumerate(self.blocks) if not used]分页方式先把所有空闲格编号收集起来,不要求连续。for i in free_idx[:need]:` `self.blocks[i] = True取前 need 个空闲格散落占用,像按页把菜谱放进不同冷库格。naive_fail = simulate(seq, False)跑一遍连续分配策略,统计失败次数。paged_fail = simulate(seq, True)用同样的请求序列跑分页分配,对比碎片影响。
常见误区
误区我看到 `nvidia-smi` 里显存已经用了 90%,那 GPU 肯定是在满负荷工作,利用率不可能低。
正解显存是仓库,GPU 是灶台。仓库堆满只能说明备料多,不代表灶台在炒菜。很多推理服务显存占用接近 100%,但 SM Activity 只有 20%:数据放在显存里没人取用。判断利用率要看 SM 占用率、计算利用率和访存吞吐的组合,不能只看显存数字。
为何常见:nvidia-smi 界面里显存数值最显眼,新手容易把「存储占用」误当成「计算繁忙」。
误区Occupancy 是 100% 才叫优化到位,所以我应该不惜一切代价把每个 SM 的活动线程束拉满。
正解Occupancy 高确实能帮助隐藏访存延迟,但它不是免费的。过高的 Occupancy 会抢占寄存器和共享内存,导致单个线程束可用资源变少,甚至触发寄存器溢出。有时候把 Occupancy 从 100% 降到 75%,减少资源竞争,kernel 反而跑得更快。就像灶台每口都坐满厨师,但大家只有一把菜刀,结果互相等待。
为何常见:性能优化常常只看单一指标,忽略了资源总量是有限且相互制约的。
误区上了 vLLM 和 PagedAttention 之后,显存碎片和 KV Cache 占用就完全不用管了。
正解PagedAttention 解决的是「KV Cache 物理块不连续」和「动态长度导致预留浪费」的问题,但 KV Cache 的总字节并没有消失。序列很长、batch 很大时,分页同样会耗尽所有物理块。它必须和量化、连续批处理、前缀共享配合使用,才能真正把单卡并发拉上去。
为何常见:框架宣传的收益容易让人以为一劳永逸,忽略了显存总容量守恒这一基本事实。
误区量化就是把模型精度砍掉一大截,只要用了 INT8,回答质量一定会肉眼可见地变差。
正解现代量化会做校准,统计权重和激活的数值范围,再选择合适的缩放系数。对于很多大模型,weight-only INT8 或 INT4 与 FP16 的生成质量差距很小。真正的问题是少数敏感层可能出现离群值,这时可以做混合精度或 per-channel 缩放,而不是全盘否定量化。
为何常见:网上流传的极端量化案例多是没有校准、直接截断的结果,使初学者形成刻板印象。
误区我又不写 CUDA kernel,看 `nvidia-smi` 就够了,Nsight 是底层专家的事。
正解nvidia-smi 只能看到平均利用率和显存总量,无法告诉你 kernel 是在等计算还是在等访存,更不能定位到哪一行代码慢。Nsight Compute 能给出 SM 占用率、访存吞吐、Warp Stall 原因;Nsight Systems 能看到整个推理链路的时间线。AI 工程化工程师同样需要靠它做部署优化。
为何常见:Nsight 界面信息量大,看起来像底层工具,容易吓退应用层工程师。
面试题
Q1. SM Occupancy 达到 100%,最准确的理解是?
- A.所有显存通道都在全速访问
- B.单个 SM 的活动线程束达到上限← 正确答案
- C.GPU 上每个计算核心都满负载
- D.全部显存块都已被请求占用
答案:B。SM Occupancy 的定义就是活动线程束数与最大线程束数的比值,100% 表示每口灶台可容纳的厨师小队都到齐了。它不直接等于显存通道满载或所有核心满载,其他选项都是不同维度的资源指标。
Q2. 下面哪个组合最可能说明发生了访存瓶颈?
- A.SM Activity 极低,显存带宽也很低
- B.SM Activity 极高,显存带宽接近峰值← 正确答案
- C.SM Activity 极高,显存带宽很低
- D.SM Activity 极低,内核数量很少
答案:B。SM Activity 高说明计算单元想干活,但显存带宽接近峰值说明 ALU 大量时间在等数据,这就是典型的访存瓶颈。A 更像启动开销不足,C 更像计算密集型,D 与访存瓶颈无关。
Q3. 显存外部碎片最主要的来源是?
- A.多次大小不同的分配与释放← 正确答案
- B.编译器自动插入的内存对齐
- C.驱动程序缓存了太多对象
- D.GPU 内核频繁发生缺页中断
答案:A。外部碎片来自不同大小的内存块被反复分配和释放后,空闲空间被切成无法容纳大请求的小格子。内存对齐主要造成内部碎片,驱动缓存和缺页中断不是外部碎片的核心来源。
Q4. KV Cache 存在的主要原因是什么?
- A.相当于融合了梯度累积
- B.避免重复计算历史 K 和 V← 正确答案
- C.让注意力矩阵变得更稀疏
- D.为反向传播保存梯度快照
答案:B。生成阶段每新增一个 token 都要与历史所有 token 计算注意力,如果每次重新计算历史的 K、V,代价极高。KV Cache 把已经算好的 Key 和 Value 留作半成品,推理时直接复用。它与梯度无关,也不会让注意力变稀疏。
Q5. PagedAttention 相对连续分配的突出优势是?
- A.把模型权重压缩到四分之一
- B.允许 KV 块存储在非连续物理块← 正确答案
- C.让每个请求独占整块显存
- D.完全消除 KV Cache 的总字节
答案:B。PagedAttention 借鉴操作系统分页,把 KV 切成固定大小的块,逻辑上连续,物理上可以散落存放。它提高了显存利用率,但不压缩权重,也不减少 KV 总字节。
Q6. 忽略其他因素,单个请求的序列长度从 512 增长到 1024,KV Cache 占用大约变为?
- A.保持基本不变
- B.变为原来的两倍← 正确答案
- C.变为原来的四倍
- D.变为原来的八倍
答案:B。KV Cache 大小与序列长度成正比,公式中 T 从 512 翻倍到 1024,缓存总量也翻倍。它不会变成四倍或八倍,因为层数和隐藏维度没有变,批大小也视为不变。
Q7. Nsight 分析发现 kernel 时间较短但 SM Activity 不高,优先排查哪项?
- A.显存带宽与访存等待占比← 正确答案
- B.CUDA 驱动版本是否过旧
- C.显存总容量是否足够大
- D.操作系统内核版本号
答案:A。kernel 时间短且 SM 不忙,往往说明计算单元在等显存数据返回,也就是访存等待占比高。此时优先看 Memory Throughput 和 Warp Stall 原因。驱动版本和显存容量不是首要排查对象。
Q8. 以下哪些做法能提升推理服务的 GPU 利用率?
- A.使用动态批处理合并请求← 正确答案
- B.对 KV Cache 做量化压缩← 正确答案
- C.拒绝所有并发请求
- D.使用 PagedAttention 分页← 正确答案
答案:ABD。动态批处理让多个请求共享一次 kernel 执行,量化减少访存压力,PagedAttention 提升并发请求的显存容纳能力,三者都能提高资源利用率。拒绝并发会让 GPU 空闲,完全相反。
Q9. 关于 INT8 量化,下列说法正确的是?
- A.相比 FP16 权重大约节省一半显存← 正确答案
- B.通常需要少量校准数据← 正确答案
- C.量化后结果必然严重劣化
- D.部分敏感层可保留高精度← 正确答案
答案:ABD。FP16 每个元素 2 字节,INT8 是 1 字节,所以显存约省一半。量化前做校准可以降低误差,敏感层也可以保留高精度做混合精度。说「必然严重劣化」是不了解校准机制的错误结论。
Q10. 治理显存碎片化,正确策略组合是?
- A.用缓存池预分配并复用对象← 正确答案
- B.采用块级页式分配管理内存← 正确答案
- C.频繁申请小显存对象更灵活
- D.使用扩充段和显存整理接口← 正确答案
答案:ABD。缓存池复用、页式分配、expandable segments 都是从分配策略上减少外部碎片。频繁申请小对象反而会加剧碎片化,因此 C 是错误做法。