Rust in Everything

为什么服务大模型难

主线位置:「一个请求的一生」里的调度、KV 块分配和前向三段,以及「一张 24 GB 的卡上放一个 8B 模型」这条线的起点。这几段在两个引擎里都是 Python 编排、CUDA kernel 计算(SGLang 的前缀树核心在条件满足时是 Rust)。本页不读代码,只用算式说明它们为什么难。
版本:vLLM v0.32.0、SGLang v0.5.22

先说结论。下面所有数字都是估算,由公式和模型公开配置算出,不是实测;实测待 C3-1、C3-2。

  • 预填充算力受限,解码访存受限。 预填充一次读一遍权重、算 n 个 token,每读一字节权重做约 n 次浮点运算;解码每步也读一遍权重,却只为 B 个正在生成的请求各算一个 token,每字节约 B 次。B 远小于 GPU 的「算力 / 带宽」比时,解码的时间由读显存决定。
  • KV cache 吃掉权重之外的显存。 Llama-3.1-8B 每个 token 的 KV 是 128 KiB。一张 24 GiB 的卡按 vLLM 默认的 0.92 比例、扣掉约 14.96 GiB 的 bf16 权重,留给 KV、激活和其他开销的不超过约 7.12 GiB,全给 KV 也只够约 5.8 万个 token。一个 8K 上下文的请求就要 1 GiB。
  • 请求长度差别大。 按最大长度给每个请求预留 KV,实际用得少时浪费大半;长 prompt 的预填充会挡住其他请求的解码步。前者引出分页的 KV(02 部分),后者引出连续批处理和分块预填充(03 部分)。
flowchart LR
  req["新请求<br/>n 个 prompt token"] --> pre["预填充<br/>读一遍权重,算 n 个 token<br/>算力受限"]
  pre --> kvw["写入 n 个 token 的 KV"]
  kvw --> dec["解码步<br/>读一遍权重 + 读全部 KV<br/>每个请求只算 1 个 token<br/>访存受限"]
  dec -->|"未结束"| dec
  dec -->|"结束"| free["释放 KV"]
  kvw -.-> mem[("显存<br/>权重 + KV + 激活")]
  dec -.-> mem

用哪个模型的数

用 Llama-3.1-8B。下表是模型仓库里 config.json 的公开值;head_dim 在该配置里没有单列,按 hidden_size / num_attention_heads 算出。

字段记号值
num_hidden_layersL32
hidden_sizeH4096
num_attention_headsn_h32
num_key_value_headsn_kv8
头维 H / n_hd128
intermediate_sizeF14336
vocab_sizeV128256
max_position_embeddings—131072

权重按 bf16 存,每个参数 2 字节。估算脚本只用 Python 标准库,输入就是上表。

权重:约 16.06 GB

每层的参数:

  • 注意力:Q 和 O 各 H × n_h·d,K 和 V 各 H × n_kv·d,合计 4096×4096×2 + 4096×1024×2 = 41,943,040。
  • MLP(gate、up、down 三个矩阵):3 × H × F = 176,160,768。
  • 两个 RMSNorm 的权重:2H,可以忽略。

32 层加上输入嵌入和不共享的输出层(各 V × H):

total params=8,030,261,248 (8.030 B)
weights bf16 = 16.06 GB = 14.96 GiB

8.03 B 和这个模型公开的参数量对得上,说明上表的形状没抄错。

每 token 的计算量:约 15 GFLOP 加注意力

前向里的线性层,每个参数对每个 token 做一次乘加,也就是 2 次浮点运算。输入嵌入是查表,不算;输出层算。参与矩阵乘的参数约 7.505 B:

linear FLOPs per token ~ 2*N = 15.01 GFLOP

注意力的 QKᵀ 和 PV 两次矩阵乘,和上下文长度 c 成正比:每个新 token 约 4·L·c·n_h·d 次。

attention FLOPs per token at ctx=1024: 0.537 GFLOP
attention FLOPs per token at ctx=4096: 2.147 GFLOP
attention FLOPs per token at ctx=8192: 4.295 GFLOP

上下文在几千以内时,线性层占大头。

预填充和解码:每读一字节做多少事

GPU 有两个上限:算力 P(FLOP/s)和显存带宽 W(B/s)。一段计算做 F 次运算、读写 M 字节,耗时不少于 max(F / P, M / W)。F / M 叫算术强度。强度高于 P / W 时受算力限制,低于时受带宽限制。P / W 是卡的标称值之比,代入自己那张卡的规格表即可;本页不代入具体型号。

预填充。 一个 n 个 token 的 prompt 一次前向就算完:权重读一遍(约 16.06 GB),线性层做 n × 15.01 GFLOP。强度约等于 n。

prefill n=512: linear 7.68 TFLOP over 16.06 GB weights -> 478 FLOP/byte
prefill n=2048: linear 30.74 TFLOP over 16.06 GB weights -> 1914 FLOP/byte

几百、几千 token 的 prompt,强度就到了几百、几千 FLOP/byte。只要 n 大于自己那张卡的 P / W,预填充就是算力受限;prompt 越长越明显。

解码。 每步为 B 个请求各生成一个 token。权重还是要整读一遍,线性层只做 B × 15.01 GFLOP,强度约等于 B。另外每个请求要把自己全部上下文的 KV 读一遍。

B=1 ctx=1024: bytes=16.19 GB flops=15.5 GFLOP t>=16.19 ms agg<=62 tok/s intensity=1.0
B=16 ctx=1024: bytes=18.21 GB flops=248.7 GFLOP t>=18.21 ms agg<=879 tok/s intensity=13.7
B=48 ctx=1024: bytes=22.50 GB flops=746.2 GFLOP t>=22.50 ms agg<=2,133 tok/s intensity=33.2

这里的耗时下界按一个假设的带宽 W = 1 TB/s 算,只为了看比例,不对应任何一张卡。读法:

  • B = 1 时强度是 1,时间几乎全花在读权重上。每步至少 16 ms,单个请求不超过约 62 token/s。
  • 加大 B,权重的读取被 B 个请求分摊,总吞吐上限近似按 B 增长,单步时间只慢一点。这就是服务端要把很多请求拼成一批解码的原因。
  • 但 KV 的读取不能分摊。每个请求读自己的 KV,注意力部分的强度是 4·L·c·n_h·d ÷(c × 2·L·n_kv·d·2)= n_h / n_kv = 4 FLOP/byte,和 B 无关。B 和上下文越大,KV 在每步读取量里占的比例越大。

结论:预填充想要算力,解码想要带宽和大批量;同一张卡上两种负载混在一起,怎么排是调度器的事(03 部分)。

KV cache:每 token 128 KiB

每层、每个 KV 头,为每个 token 存一个 K 向量和一个 V 向量,各 d 个元素:

KV 每 token 字节数 = 2 × L × n_kv × d × 字节数
                  = 2 × 32 × 8 × 128 × 2
                  = 131072 B = 128 KiB

开头的 2 是 K 和 V 两份。如果这个模型不用 GQA(n_kv = n_h = 32),每 token 就是 512 KiB,是现在的 4 倍。

一个请求的 KV 随上下文线性增长:

KV for one request ctx=1024: 0.125 GiB
KV for one request ctx=4096: 0.500 GiB
KV for one request ctx=8192: 1.000 GiB
KV for one request ctx=131072: 16.000 GiB

24 GiB 的卡:能放多少

按 24 GiB 算,vLLM 默认让引擎用总显存的 0.92(vllm/config/cache.py#L102)。SGLang 的对应参数 mem_fraction_static 默认 None,启动时按机器算,07 部分再讲。

24 GiB * 0.92 = 22.08 GiB; minus weights = 7.12 GiB
  upper bound tokens if all left goes to KV = 58,347
    concurrent requests at ctx=1024: 57.0
    concurrent requests at ctx=4096: 14.2
    concurrent requests at ctx=8192: 7.1

这是上界。剩下的 7.12 GiB 还要放前向的激活峰值、CUDA graph、通信缓冲和 PyTorch 之外的显存,vLLM 启动时先用假输入跑一次前向测出峰值(vllm/v1/worker/gpu_worker.py#L676),再把余下的分给 KV。实际能分到多少块、多少并发,待实测(C3-1)。

还有一个直接后果:这个模型的最大位置数是 131072,一个满长请求的 KV 要 16 GiB,比整块余量还大。vLLM 启动时会检查「至少放得下一个最大长度的请求」,放不下就报错并给出估计的可用长度(vllm/v1/core/kv_cache_utils.py#L1003);把 max_model_len 设为 -1 时则二分搜索出能放下的最大长度(kv_cache_utils.py#L2414)。所以在 24 GiB 的卡上跑这个模型,要么调小 max_model_len,要么让它自动适配。

flowchart TB
  card["24 GiB 显存"] --> util["引擎可用:× 0.92 = 22.08 GiB<br/>(vLLM 默认)"]
  card --> rest["其余 1.92 GiB 不用"]
  util --> w["bf16 权重 14.96 GiB"]
  util --> left["余量 ≤ 7.12 GiB"]
  left --> act["激活峰值、CUDA graph 等<br/>待实测(C3-1)"]
  left --> kv["KV cache<br/>每 token 128 KiB<br/>≤ 58,347 token"]

长度差别大:碎片和调度

真实流量里,prompt 从几十到几万 token,输出从一个词到几千 token,而且输出长度事先不知道。这带来两类问题。

显存碎片。 最朴素的做法是请求一进来就按最大长度预留一段连续的 KV。设最大长度 8192,而请求实际只用到 1024:

prealloc 8192 for actual 1024: waste 87.5%

按上面的余量,每个请求预留 1 GiB,只能同时放 7 个;按实际用量放可以到 57 个。两个引擎都把 KV 切成固定大小的块,用到哪里分到哪里,一个请求最多浪费不到一块。块表怎么管、块大小怎么选,见 02 KV cache。块化之后还能做另一件事:不同请求的相同前缀共用同一批块,vLLM 按块哈希找,SGLang 用基数树找,也在 02。

调度。 静态批处理把一批请求一起开始、一起结束,短请求要陪长请求跑完,空出来的位置浪费掉。连续批处理每一步都重新组批:有请求结束就立刻放新请求进来。但新请求要先预填充,一个几万 token 的 prompt 一次算完会占住好几步的时间,正在解码的请求就卡住了。解决办法是把长预填充切成块,和解码 token 拼在同一步里,每步的 token 数有上限。vLLM 的分块预填充默认开(vllm/config/scheduler.py#L134)。显存不够时还要决定先停哪个请求、停下的 KV 是丢掉重算还是换出。这些在 03 调度。

❗IMPORTANT
动脑筋:把模型换成一个形状相同、但不用 GQA 的版本(n_kv = 32),上面三个数各变成多少:每 token 的 KV、24 GiB 卡上的 token 上界、解码时注意力部分的算术强度?先自己算,再看文末。

动脑筋答案

MHA KV per token = 512 KiB, tokens fit = 14,587, attn intensity = 1.0
  • 每 token 的 KV 变成 2 × 32 × 32 × 128 × 2 = 512 KiB,是 GQA 版的 4 倍。
  • 同样 7.12 GiB 余量只够约 1.46 万个 token,1K 上下文只能并发约 14 个请求。
  • 注意力部分的强度是 n_h / n_kv = 1,比 GQA 的 4 更低:每读一字节 KV 只做一次运算。

GQA 让多个查询头共用一组 KV 头,KV 的显存和读取量都按 n_h / n_kv 缩小。这是 KV cache 层面最便宜的一刀,代价在模型训练时就付了。服务端能做的是另外几刀:分块、前缀共享、KV 量化(02 部分)。

小结

  • 以下均为估算,实测待 C3-1、C3-2。Llama-3.1-8B:bf16 权重约 16.06 GB(14.96 GiB),每 token 线性层约 15.01 GFLOP,KV cache 每 token 128 KiB。
  • 预填充的算术强度约等于 prompt 长度 n,n 超过卡的「算力 / 带宽」比就算力受限;解码的强度约等于批大小 B,注意力部分固定在 n_h / n_kv = 4,访存受限。把很多请求拼成一批解码,是为了分摊权重的读取。
  • 24 GiB 卡按 vLLM 默认 0.92,扣掉权重最多剩约 7.12 GiB,全给 KV 约 58,347 个 token;激活等开销另扣,实际数字待实测(C3-1)。一个 131072 长度的请求要 16 GiB,放不下,vLLM 启动时会报错或自动缩短 max_model_len。
  • 长度差异带来两件事:按最大长度预留会浪费大半显存(8192 对 1024 浪费 87.5%),引出分块的 KV;长预填充会挡住解码,引出连续批处理和分块预填充。

💬 关联讨论

发起讨论
加载中...