# 第 1 篇:这一步算几个 token,KV 放在哪
接第 0 篇。一层 KV 的乘法会算了。这篇只做一件事:拿一条 4 个 token 的请求,到 vLLM 0.11.0 里对上两句话。谁决定这一步算几个 token。这些 token 的 KV 页在哪领。训练还没开始,GRPO、FSDP 先不写。
机器没变:verl_env 里的 vLLM 0.11.0。下面的函数都在这个环境的 site-packages/vllm/v1/ 里。
一条短请求
还是 Qwen2.5-0.5B。第 0 篇算过,一层、一个 token 是 \(2 \times 2 \times 64 \times 2 = 512\) 字节。24 层就是 12 KB。下面每个 token 都按 12 KB 记。
假设「今天天气真好」被切成 4 个 token:t0 = 今天,t1 = 天气,t2 = 真,t3 = 好。这只是为了数数,真实分词不一定按词切。模型接着说出 t4、t5 后停。
| 时刻 | 名单上有 | 已写下 KV | 这一步算 | 算完后的 KV |
|---|---|---|---|---|
| 请求刚到 | t0–t3,4 个 | 0 | 4 个 | 48 KB |
| 说出 t4 | 5 个 | 4 | 1 个(t4) | 60 KB |
| 说出 t5 | 6 个 | 5 | 1 个(t5) | 72 KB |
第一行是第 0 篇里的 prefill:一次把 prompt 的 KV 写完。后两行是 decode:每次只补最新的那一个。采样得到的新 token 先进入名单,它的 KV 要等下一步才写。
\(QK^\top\) 那张分数表仍然用完就扔。表里变长的是「已写下 KV」那一列。
调度器只做减法
core/sched/scheduler.py 的 schedule 开头写着:这里没有单独的 prefill 阶段,也没有单独的 decode 阶段。每条请求带着两个数往前赶。
num_tokens_with_spec:名单上已经有多少 token。prompt 加上已经生成出来的,再加上投机解码多放进来的那些。num_computed_tokens:其中多少个已经写下了 KV。
这一步要算的个数就是两者的差,再被两样东西截短:这一步还剩的 token 预算 token_budget,以及模型允许的最大长度。预算一开始等于 max_num_scheduled_tokens,每接进一条请求就减掉它分到的个数。
用上面的表对:
- 刚到。名单 4,已写 0,差 4。预算够的话,这一步就排 4 个。
- 说出 t4 之后。名单 5,已写 4,差 1。这一步排 1 个。
- 说出 t5 之后。同样再排 1 个。
预算如果只剩 2,第一行会被拆开。这一步只算 t0 和 t1,写下 2 份 KV;下一步预算恢复了,再算 t2 和 t3。算的还是「名单减去已写 KV」。
6 个 token 只领 1 页
72 KB 是这 6 个 token 真正写下的 KV。如果按最大长度 2048 给这条请求留一块连续空位,0.5B 上要占 \(2048 \times 12\text{ KB} = 24\text{ MB}\)。
CUDA 上用户没指定页长时,platforms/cuda.py 把 block_size 设成 16。一页 16 个 token,整模型 \(16 \times 12\text{ KB} = 192\text{ KB}\)。6 个 token 向上取整,只要 1 页。页里还有 10 格空着。t0 到 t5 都写在这同一页里,要等第 17 个 token 才领第 2 页。
这个取整在 core/single_type_kv_cache_manager.py:
已经领过的页减掉,只向页池再要新的。core/kv_cache_coordinator.py 把不同注意力类型的页数加在一起。普通因果注意力走上面这一句。coordinator 只算页数、发页号,不申请 GPU 显存。
core/kv_cache_manager.py 的 allocate_slots 先问空闲页够不够。够,就把新页号记到这条请求上,返回给 schedule。不够,返回空。schedule 收到空,就把正在跑的请求里优先级最低的那条踢回等待队列,调用 free 还掉它的页,并把它的 num_computed_tokens 清成 0。它下次回来要重新写 KV。
那几 GB 是启动时买下的空白页
一条请求进进出出,不会每次 malloc 一块新显存。大缓冲在 worker/gpu_model_runner.py 的 initialize_kv_cache_tensors,引擎启动时做一次。_allocate_kv_cache_tensors 按 kv_cache_config 里的大小 torch.zeros 出一块原始缓冲,再按页、页长、KV 头、头维度 reshape 成每一层用的形状。nvidia-smi 里那几 GB 是这一池子空白页,加上权重和 CUDA 上下文。这条请求的 72 KB 只是其中 6 格。
之后每个 step 走 execute_model:
_update_states收下schedule发来的页号,放进这条请求的block_ids。_prepare_inputs按页号做出这一步的注意力元数据。- 模型前向往这些页里写新的 K、V。
页池的大小在启动时就定了。这一步只决定往第几页的第几格写。
浙公网安备 33010602011771号