<aside> 🎯
KV Cache 的池子多大,决定了我们最多能同时服务多少个请求;decode 每一步读 KV 要花多久,决定了单个请求回答得有多快。前者是容量问题,算的是「装得下多少 token」;后者是带宽问题,算的是「一步要多久」。这两件事用的参数完全不同,面试题里却特别喜欢把它们混在一起问,所以这一页我们先把它们分开算清楚。
</aside>
flowchart LR
G["GPU 显存"] --> W["模型权重"]
G --> K["KV Cache 池"]
G --> C["CUDA Graph 缓冲"]
G --> A["激活值与临时张量"]
W -. "由 mem-fraction-static 控制" .-> K
文档里对 --mem-fraction-static 的描述是「权重 + KV 池占总显存的比例」。这个说法方向没错,但拿来算数就会算错,因为它的分母并不是「总显存」,而是启动时量出来的一个值。把它的真实算法搞清楚,是后面所有 OOM 排查的前提。
SGLang 用同一个工具函数 get_available_gpu_memory 量两次空闲显存(先 empty_cache,再 torch.cuda.mem_get_info;多卡时对各 rank 取 MIN,最紧的那张卡说了算)。
第一次是 pre_model_load_memory,在 init_torch_distributed 刚结束、还没加载权重的时候量。注意它不是卡的物理容量,而是「此刻驱动认为还能给我多少」,已经扣掉了 CUDA context、NCCL buffer,以及同卡上其它进程占的显存。第二次是 post_model_load_memory,在权重加载完、LoRA 等预分配做完、但 KV 池还没建的时候量。
两次之差就是权重真实吃掉的显存,这比「参数量 × 字节数」要准。记 y = --mem-fraction-static,把 pre 按 y : (1-y) 切成两块:静态区放权重和 KV,动态区留给激活、CUDA Graph buffer 和其它运行时开销。于是:
$$ \text{KV 可用} = y \cdot \text{pre} - (\text{pre} - \text{post}) = \text{post} - (1-y)\cdot \text{pre} $$
对应源码就是 _profile_available_bytes 里那一行 rest_memory = post_model_load_memory - pre_model_load_memory * (1 - mem_fraction_static)。算出来的字节数除以每 token 的 KV 字节(第 3 节),就得到 max_total_num_tokens;如果结果小于等于 0,启动会直接报 Not enough memory. Please try to increase --mem-fraction-static.
不传 --mem-fraction-static 的话,arg_groups/memory_hook.py 会先估一个「动态区需要多少 MB」,再反推 y:
reserved_mem(MB) = 512 常量(attention backend 元数据等)
+ activation_tokens × 1.5 激活,按 chunked_prefill_size(至少 2048)估
+ tp_size × pp_size / 8 × 1024 并行度大时的通信 buffer 余量
+ reserve_for_graph_mb CUDA Graph
+ reserve_for_deepep_a2a_mb 开 DeepEP 时的 all-to-all buffer
显存大于 60 GB 的卡上,reserved_mem 至少 10 GB
mem_fraction_static = (gpu_mem - reserved_mem) / gpu_mem
代入 H100 80 GB、chunked prefill 8192、TP=8:512 + 12288 + 1024 ≈ 13.8 GB,再加 CUDA Graph 的几 GB,y 大约落在 0.78 到 0.82。从这条公式能直接看出一件事:chunked prefill 越大,自动算出来的 y 越小。所以有人调大了 --chunked-prefill-size,发现 KV 容量悄悄变小了,并不是错觉。PD 的 decode 实例改用 max_running_requests × num_draft_tokens 来估激活,VLM 还会再额外压低一点,给视觉编码器留位置。
文档说「OOM 就调小」,这只对了一半。关键是先分清 OOM 发生在什么时候。
启动就失败,报 Not enough memory,说明静态区扣完权重后连 KV 池都分不出来,这时要调大 y。举个例子,H100 80 GB 上跑一个 60 GB 权重的模型,假设 init 后 pre ≈ 78 GB:
y = 0.70 动态区 0.3 × 78 = 23.4 GB post = 78 - 60 = 18 GB KV = 18 - 23.4 = -5.4 GB → 启动失败
y = 0.85 动态区 11.7 GB KV = 18 - 11.7 = 6.3 GB → 可以启动
同样的 y=0.7 放到 96 GB 的 H20 上,却刚好能跑:96 × 0.7 − 60 ≈ 7.2 GB。同一份启动脚本换一张卡就起不来,绝大多数都是这个原因。还有一点要留意,pre 会被同卡的其它进程吃掉,卡上挂着监控或训练进程时,调大 y 也未必救得回来,得先把显存腾出来。
运行一段时间后才 OOM,情况正好反过来。SGLang 自己不会因为 KV 不够而 OOM,KV 满了只会 retract(页面 03 第 5 节)。所以运行时 OOM 只可能是动态区留少了(激活峰值、CUDA Graph、投机解码的额外 buffer),或者同卡有别的进程在涨显存,RL 里训练引擎和推理引擎同卡就是典型场景。这时才是调小 y,或者分别压 --chunked-prefill-size 和 --max-running-requests。