Rust in Everything

显存预算

主线位置:不在单个请求的路径上,而在服务启动时。这一步决定「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:先测,再分

预算:总显存乘比例

vllm/v1/worker/utils.py#L716

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)。

  1. 在 memory_profiling 上下文里跑 profile_run(gpu_worker.py#L693)。假前向的 token 数是 max_num_batched_tokens(gpu_model_runner.py#L6193,max_num_tokens 在 #L516 取自它)。所以每步 token 预算越大,测出的激活峰值越大,KV 越少。
  2. 测完算两项(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 分配器在前向中的峰值减去前向后还占着的部分,即激活峰值。

  1. 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.9222.08 GiB(再扣激活、graph)≤ 7.12 GiB≤ 58,347(≤ 3,646 块)激活与 graph 实测后再扣
vLLM 0.9522.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.8520.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一个满长请求的 KVvLLM 默认(≤ 7.12 GiB)SGLang 默认(≈ 1.94 GiB)
131072(模型默认)16 GiB启动报错能启动,单请求上限降到约 15,878 token
327684 GiB最坏并发 ≤ 1.78单请求上限仍约 15,878
81921 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=…。

调的顺序

  1. 先定最长请求:vLLM 设 --max-model-len(业务里最长的输入加输出),或设 -1 让它自动适配;SGLang 设 --context-length,或接受「KV 池多大,单请求就多长」。
  2. 看启动日志里的 KV token 数,换算成业务需要的并发。
  3. 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 部分)。
  4. 运行中 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)。

💬 关联讨论

发起讨论
加载中...