显存预算
主线位置:不在单个请求的路径上,而在服务启动时。这一步决定「KV 块分配」那一段手里有多少块。vLLM 在 worker 进程(单卡时就是 EngineCore 进程)里测显存、在 EngineCore 里把字节换成块数;SGLang 在启动参数解析时算比例、在调度器进程的模型加载之后定 KV 池大小。两边都是 Python,打开 Rust 前端不影响这一段。
版本:vLLM v0.32.0、SGLang v0.5.22
结论:
- 显存分五块:权重、KV 池、前向的激活峰值、CUDA graph、其他(CUDA 上下文、通信缓冲等)。两个引擎都把 KV 放在最后,其他几块先扣,剩下的切成块或槽位。
- 两个参数的含义不同。 vLLM 的
gpu_memory_utilization(默认 0.92)是整个引擎能用的显存比例;启动时用假输入跑一次前向,测出激活峰值,再估 CUDA graph,余下全给 KV。SGLang 的mem_fraction_static是权重加 KV 池占的比例,不设时按显存、分块大小、CUDA graph 批大小用公式算,不跑前向测。 - 估算(Llama-3.1-8B、bf16、24 GiB):vLLM 默认留给 KV 的上限约 7.12 GiB(58,347 个 token),还要再扣激活和 graph;SGLang 默认比例算出来是 0.704,KV 池约 1.94 GiB(15,879 个 token)。实际块数待实测(C3-1)。
- 最大长度放不下时两边做法相反:vLLM 启动就报错,给出估计的可用长度;SGLang 照常启动,把单个请求的长度上限降到 KV 池的大小。
flowchart TB total["总显存 T"] --> v["vLLM:T × gpu_memory_utilization"] total --> s["SGLang:T × mem_fraction_static"] v --> vw["权重 + 其他<br/>(启动前后空闲显存之差)"] v --> va["激活峰值<br/>(假前向实测)"] v --> vg["CUDA graph<br/>(估计)"] v --> vk["KV 池 = 余下全部"] s --> sw["权重"] s --> sk["KV 池 = 余下全部"] total --> sr["SGLang 预留 T × (1 − f)<br/>激活、graph 等,按公式"]
一个公式,两种切法
记总显存 T、权重 W、激活峰值 A、CUDA graph G、其他 O,KV 池为 K:
vLLM: K = T × gpu_memory_utilization − (W + O + A) − G
SGLang: K = T × mem_fraction_static − W − O(O 指模型加载后仍占着的那部分)
mem_fraction_static 不设时 = (T − R) / T,R 是按公式估的激活与 graph 预留
vLLM 的 A、G 是启动时测出来的;SGLang 的 R 是按配置算出来的。下面分别走源码。
vLLM:先测,再分
预算:总显存乘比例
def request_memory(
init_snapshot: MemorySnapshot,
cache_config: CacheConfig,
external_weight_memory: int = 0,
) -> int:
# ...
requested_memory = math.ceil(
init_snapshot.total_memory * cache_config.gpu_memory_utilization
)
默认比例 0.92(vllm/config/cache.py#L102),全仓库没有别处改写它。比例是对总显存算的,所以同一张卡上已经有别的进程占着显存时,启动时的空闲显存可能不够这个数,直接报错(第 761 行,见常见报错)。
测:假输入跑一次前向
KV 池之外的部分,在 determine_available_memory 里测(gpu_worker.py#L634)。EngineCore 初始化 KV 时调用它(core.py#L315)。
- 在
memory_profiling上下文里跑profile_run(gpu_worker.py#L693)。假前向的 token 数是max_num_batched_tokens(gpu_model_runner.py#L6193,max_num_tokens在 #L516 取自它)。所以每步 token 预算越大,测出的激活峰值越大,KV 越少。 - 测完算两项(vllm/utils/mem_utils.py#L312):
result.total_consumed = (
result.before_create.free_memory - result.after_profile.free_memory
)
# ...
result.transient_peak_headroom = (
result.after_profile.torch_peak - result.after_profile.torch_allocated
)
result.non_kv_cache_memory = result.total_consumed + result.transient_peak_headroom
total_consumed 用 mem_get_info 的空闲显存之差,权重、CUDA 上下文、通信缓冲都算在里面;transient_peak_headroom 是 PyTorch 分配器在前向中的峰值减去前向后还占着的部分,即激活峰值。
- CUDA graph 另估。在 CUDA 上且没有关 graph 时,调
profile_cudagraph_memory()得到一个估计值;环境变量VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS默认1(envs.py#L2221),所以默认从 KV 里扣掉它:
vllm/v1/worker/gpu_worker.py#L740
self.available_kv_cache_memory_bytes = (
self.requested_memory
- profile_result.non_kv_cache_memory
- cudagraph_memory_estimate_applied
)
启动日志里的 Available KV cache memory: … GiB 就是这个数(#L760)。
也可以跳过比例,直接给 KV 的字节数:--kv-cache-memory-bytes(cache.py#L257)。设了它,仍跑一次假前向编译模型,但不再测、不看 gpu_memory_utilization(gpu_worker.py#L649)。CUDA graph 捕获之后,worker 会在日志里给出一个建议值(#L1001):按实测的权重、激活、graph 再留 150 MiB 余量,算出「填满预算」和「填满整卡」两个 KV 字节数。
分:字节换成块
vllm/v1/core/kv_cache_utils.py#L1627
num_blocks = available_memory // bytes_per_block
num_blocks = may_override_num_blocks(vllm_config, num_blocks)
bytes_per_block 是所有层一块的字节数。8B 模型 32 层、每层每块 64 KiB,一块 2 MiB,16 个 token(见分页注意力)。--num-gpu-blocks-override 能直接改块数,字段注释说它用来测试抢占(cache.py#L135)。
算完块数之前先检查:KV 至少要放得下一个 max_model_len 长的请求(_check_enough_kv_cache_memory),放不下就报错并二分估出能放下的长度。max_model_len 设成 -1 时不报错,改为自动缩到能放下的最大值(_auto_fit_max_model_len)。
最后打一行日志(#L2329):GPU KV cache size: N tokens, Maximum concurrency for M tokens per request: X。X 是「块数 ÷ 一个满长请求要的块数」(get_max_concurrency_for_kv_cache_config),是按最坏情况算的并发,实际请求短得多时能同时跑更多。
另外两个参数不分显存
max_num_seqs:running 里最多几个请求,24 GB 卡起 OpenAI 服务默认 256(get_batch_defaults,分档见连续批处理)。它不直接分显存,只是上限;KV 不够时靠抢占。max_num_batched_tokens:每步 token 预算,同一张卡默认 2048。它通过上面第 1 步影响激活峰值,从而间接影响 KV。
SGLang:先算比例,加载后再量
mem_fraction_static:一个公式
字段默认 None(schedule.py#L27)。启动参数解析时由 handle_gpu_memory_settings 填上(memory_hook.py#L47)。函数注释写明了它的定义:mem_fraction_static = (model weights + KV cache pool) / GPU memory capacity。
先按显存分档定两个量:分块大小 chunked_prefill_size 和解码 CUDA graph 的最大批 max_bs。20–35 GB 那一档是 4096 和 48(tp < 4 时)(#L101)。然后算预留,单位 MiB:
python/sglang/srt/arg_groups/memory_hook.py#L306
elif cfg.chunked_prefill_size > 0:
activation_tokens = max(cfg.chunked_prefill_size, 2048)
# ...
reserved_mem = 512
reserved_mem += activation_tokens * 1.5
# Some adjustments for large parallel size
reserved_mem += cfg.tp_size * cfg.pp_size / 8 * 1024
reserved_mem += reserve_for_graph_mb(server_args)
if gpu_mem is not None and gpu_mem > 60 * 1024:
reserved_mem = max(reserved_mem, 10 * 1024)
# ...
mem_fraction_static = (
round((gpu_mem - reserved_mem) / gpu_mem, 3)
if gpu_mem is not None
else 0.95
)
- 激活按每个分块 token 1.5 MiB 预留,和模型大小无关。
reserve_for_graph_mb(#L359):解码 graph 预留max_bs× 2 MiB;预填充 graph 每个捕获的 token 档 8 MiB。CUDA 上预填充 graph 默认开(breakable,cuda_graph_config.py#L119),分块 4096 时捕获 50 档。- 显存大于 60 GiB 的卡至少预留 10 GiB。
gpu_mem读的是nvidia-smi报的总显存(MiB),不是空闲显存。- 另有一条「graph 捕获后再定 KV」的路径会跳过 graph 和激活预留,由
SGLANG_ENABLE_POST_CAPTURE_KV_SIZING打开,默认关(environ.py#L589)。
多模态模型在此基础上再乘一个按视觉编码器大小定的系数(adjust_mem_fraction_for_vlm)。手动传了 --mem-fraction-static,整段跳过。
KV 池:加载后的空闲减预留
比例定了,KV 池的字节数在模型加载之后量:
python/sglang/srt/mem_cache/kv_cache_configurator.py#L2688
slack_gb = pre_model_load_memory * (1 - get_schedule().mem_fraction_static)
# ...
rest_memory = available_gpu_memory - slack_gb - mm_reservation_gb
pre_model_load_memory 是加载模型前的空闲显存,available_gpu_memory 是加载后的。两者之差是权重和加载时留下的其他占用。所以在空卡上,KV ≈ T × f − W。rest_memory 不大于 0 时报错,并给出最小可行的比例(#L2724)。
字节换成 token 数,再按页对齐(pool_configurator.py#L614):max_total_num_tokens = available_bytes // cell_size。cell_size 是一个 token 在所有层的 KV 字节数,8B 模型 128 KiB。SGLang 按 token 分槽位(见分页注意力的对照一节),这里没有「块数」,只有 token 数。--max-total-tokens 可以直接给上限(schedule.py#L39)。
max_running_requests:默认按 KV 池推
字段默认 None(schedule.py#L31),由 KV 池大小推出:
python/sglang/srt/mem_cache/kv_cache_configurator.py#L2800
def resolve_max_num_reqs(self, token_capacity: int) -> int:
# ...
estimated = int(token_capacity / self.model_config.context_len * 512)
estimated = max(min(estimated, 4096), 2048)
max_num_reqs = get_schedule().max_running_requests
if max_num_reqs is not None:
requested_per_worker = max_num_reqs // self.attn_dp_size
max_num_reqs = min(requested_per_worker, token_capacity // 2)
else:
requested_per_worker = None
max_num_reqs = min(estimated, token_capacity // 2)
估计值夹在 2048 和 4096 之间,再不超过「KV 池 token 数的一半」。它定的是请求槽位池 req_to_token 的行数。对 24 GiB 卡上的 8B 模型,这个数是 2048,远大于 KV 实际能撑的并发;真正限制进批的是 KV 池和收请求时的 new_token_ratio 预留(连续批处理)。
单个请求的上限:上下文长度和 KV 池取小
python/sglang/srt/managers/tp_worker.py#L549
max_req_len = min(
self.model_config.context_len - 1,
kv_capacity - 1,
)
max_req_input_len = max_req_len - 5
context_len 默认取模型 config.json(--context-length 默认 None,model.py#L104)。KV 池比上下文长度小时不报错,只是把单个请求的上限降下来;超过的请求在调度器里被拒(见常见报错)。这和 vLLM 启动即报错不同。
启动日志一行给出全部结果(scheduler.py#L1234):max_total_num_tokens=…, chunked_prefill_size=…, max_prefill_tokens=…, max_running_requests=…, context_len=…。
chunked_prefill_size 也在分显存
分块大小进了预留公式,每个 token 1.5 MiB。把它从 4096 降到 2048,预留少约 3 GiB,KV 多出同样多。代价是长 prompt 要切成更多块,SGLang 默认不混批时运行中的请求要停更多步(分块预填充)。
演算:24 GiB 卡、8B 模型、bf16
以下全是估算。输入和为什么服务大模型难一致:Llama-3.1-8B,权重 14.96 GiB,KV 每 token 128 KiB;总显存按整 24 GiB(24576 MiB)算。实际的卡报出的总显存会略少,空卡上也有 CUDA 上下文的占用。vLLM 一栏没有扣激活、graph 和其他,是上界;SGLang 一栏按公式算比例,假设加载前空闲显存等于总显存。脚本只用 Python 标准库。
SGLang 的默认比例(tp = pp = 1,分块 4096,解码 graph max_bs 48,预填充 graph 50 档):
reserved = 512 + 4096 × 1.5 + 1 × 1 / 8 × 1024 + (48 × 2 + 50 × 8)
= 512 + 6144 + 128 + 496 = 7280 MiB
mem_fraction_static = round((24576 − 7280) / 24576, 3) = 0.704
两边的 KV 池:
| 配置 | 给权重加 KV 的预算 | KV 池上限 | KV token 上限 | 说明 |
|---|---|---|---|---|
| vLLM 默认 0.92 | 22.08 GiB(再扣激活、graph) | ≤ 7.12 GiB | ≤ 58,347(≤ 3,646 块) | 激活与 graph 实测后再扣 |
| vLLM 0.95 | 22.80 GiB | ≤ 7.84 GiB | ≤ 64,245 | 同上 |
| SGLang 默认(0.704) | 16.90 GiB | ≈ 1.94 GiB | ≈ 15,879 | 预留 7.11 GiB 给激活、graph |
| SGLang 分块 2048(比例 0.831) | 19.94 GiB | ≈ 4.99 GiB | ≈ 40,849 | 只改分块大小 |
| SGLang 手设 0.85 | 20.40 GiB | ≈ 5.44 GiB | ≈ 44,584 | 预留只剩 3.6 GiB,会不会 OOM 待实测 |
两边默认值差得多,原因是含义不同:vLLM 的 0.92 里还要扣激活和 graph,SGLang 的 0.704 里已经扣过了,而且公式按分块 token 数一刀切地留了 6 GiB 激活,对 8B 模型偏保守。哪个实测更接近,要看 vLLM 测出的激活峰值。
能放多长、多少个:
max_model_len | 一个满长请求的 KV | vLLM 默认(≤ 7.12 GiB) | SGLang 默认(≈ 1.94 GiB) |
|---|---|---|---|
| 131072(模型默认) | 16 GiB | 启动报错 | 能启动,单请求上限降到约 15,878 token |
| 32768 | 4 GiB | 最坏并发 ≤ 1.78 | 单请求上限仍约 15,878 |
| 8192 | 1 GiB | 最坏并发 ≤ 7.12 | 最坏并发约 1.9 |
「最坏并发」是每个请求都用满 max_model_len 时的数,对应 vLLM 日志里的 Maximum concurrency。实际请求短时,并发由 token 总数决定:比如每个请求平均 2K token,vLLM 的上界约 28 个。
实际的块数、token 数、最大并发:待实测(C3-1)。读法是看两行日志:vLLM 的 GPU KV cache size: … tokens,SGLang 的 max_total_num_tokens=…。
调的顺序
- 先定最长请求:vLLM 设
--max-model-len(业务里最长的输入加输出),或设-1让它自动适配;SGLang 设--context-length,或接受「KV 池多大,单请求就多长」。 - 看启动日志里的 KV token 数,换算成业务需要的并发。
- KV 不够:vLLM 调高
--gpu-memory-utilization,或调低--max-num-batched-tokens让激活峰值变小;SGLang 手设--mem-fraction-static,或调小--chunked-prefill-size。两边都可以把 KV 存成 FP8(--kv-cache-dtype,见 KV 量化),每 token 的字节减半;或者用量化的权重,把 W 变小(04 部分)。 - 运行中 OOM:反方向调。vLLM 的日志会给出
--kv-cache-memory的建议值,可以照着设。
小结
- vLLM:预算 = 总显存 ×
gpu_memory_utilization(默认 0.92);用max_num_batched_tokens个 token 跑一次假前向,测出权重、其他占用和激活峰值,再扣估计的 CUDA graph,余下按每块字节数切成块。放不下一个max_model_len的请求就启动失败,-1自动缩。 - SGLang:
mem_fraction_static= (权重 + KV) / 总显存,默认 = (T − R) / T,R = 512 + 1.5 × 分块大小 + 并行项 + graph 预留(MiB),不跑前向测。KV 池 = 加载后空闲 − 加载前空闲 × (1 − f)。KV 池小于上下文长度时不报错,单请求上限随之变小。max_running_requests默认 2048 到 4096,不是真正的瓶颈。 - 估算:24 GiB 卡上 8B bf16,vLLM 默认 KV ≤ 7.12 GiB,SGLang 默认约 1.94 GiB;SGLang 把分块降到 2048 可到约 4.99 GiB。实际数字待实测(C3-1)。