1. 1. 1. 模型究竟在计算什么
    1. 1.1. 1.1 文本、token ID 与向量:先认清输入
    2. 1.2. 1.2 一次 Attention 怎样得到输出
      1. 1.2.1. 1.2.1 先走通一个 Head:三个位置,各自读取并输出
      2. 1.2.2. 1.2.2 扩到 128 维:每个位置得到一条输出向量
      3. 1.2.3. 1.2.3 扩到 32 个 Head:各位置拼接,再经过 MLP
    3. 1.3. 1.3 完整模型:Attention 之后还有什么
      1. 1.3.1. 1.3.1 从最后位置的表示,到下一 token 的 ID
    4. 1.4. 1.4 Prefill 与 Decode:同一模型,两种工作量
      1. 1.4.1. 1.4.1 Prefill:各层处理已知输入位置
      2. 1.4.2. 1.4.2 Decode:新位置逐层计算,历史从 KV 接入
    5. 1.5. 1.5 为什么缓存逐层 K/V 就够了
      1. 1.5.1. 1.5.1 Context window:整个生成过程的上下文预算
    6. 1.6. 1.6 对照真实模型:Qwen3-0.6B
      1. 1.6.1. 1.6.1 走完一个真实 Block
      2. 1.6.2. 1.6.2 走完 28 层,选出 t5
  2. 2. 2. 一个请求怎样跑完 nano-vLLM
    1. 2.1. 2.1 先看架构:谁拥有状态,谁驱动计算
      1. 2.1.1. 2.1.1 再看硬件:CPU 发出工作,GPU 在哪里存、在哪里算
    2. 2.2. 2.2 启动一次:模型目录怎样变成常驻资源
    3. 2.3. 2.3 请求入队:先认识三个进度量
    4. 2.4. 2.4 沿着 step 走三轮:调度、执行、回写
    5. 2.5. 2.5 候选如何成为输出,EOS 如何结束请求
  3. 3. 3. 多个请求怎样共享 GPU 和显存
    1. 3.1. 3.1 Batching:多条请求怎样变成一次模型执行
    2. 3.2. 3.2 Continuous Batching:每轮重新决定成员
    3. 3.3. 3.3 Paged KV:把显存容量交给真正需要它的请求
    4. 3.4. 3.4 页表怎样驱动 GPU:写入、读取与回收
      1. 3.4.1. 3.4.1 沿 A5 走一遍:从逻辑位置到显存数值
      2. 3.4.2. 3.4.2 写满、换成员、结束:页如何继续使用
    5. 3.5. 3.5 Prefix Cache:相同前缀怎样复用,下一轮对话怎样进入
    6. 3.6. 3.6 调度预算:选谁、算多少、空间够不够
    7. 3.7. 3.7 空间不足时怎样恢复,如何理解整体收益
  4. 4. 4. 怎样进一步加速,以及用上多张卡
    1. 4.1. 4.1 先建立成本账本:容量、带宽与延迟
    2. 4.2. 4.2 FlashAttention:减少 Attention 中间结果的显存往返
    3. 4.3. 4.3 CUDA Graph 与 pinned memory:减少执行外围开销
    4. 4.4. 4.4 多张卡先分清两种分工
    5. 4.5. 4.5 Tensor Parallel:分开算什么,通信合并什么
  5. 5. 5. 动手改造:从跑通到可测量的推理服务
    1. 5.1. 5.1 Setup:固定环境、源码、模型与运行入口
      1. 5.1.1. 5.1.1 固定实验对象:机器、代码、模型和依赖各是什么
      2. 5.1.2. 5.1.2 下载哪一个 ZIP,解压在哪里,先打开什么
      3. 5.1.3. 5.1.3 Clone 固定提交,建立自己的修改分支
      4. 5.1.4. 5.1.4 使用现有 Python 3.11.2,新建一个实验虚拟环境
      5. 5.1.5. 5.1.5 下载一次完整模型:Tokenizer、配置和权重一起固定
      6. 5.1.6. 5.1.6 先跑单卡基线:明确什么才叫 setup 完成
      7. 5.1.7. 5.1.7 用编辑器打开,设置断点,重新登录后怎样接着做
    2. 5.2. 5.2 跟踪请求与 KV:建立改造前的正确性护栏
      1. 5.2.1. 5.2.1 案例 A:跟踪一条请求的三轮计算
      2. 5.2.2. 5.2.2 案例 B:两条请求如何同批执行和退出
      3. 5.2.3. 5.2.3 案例 C:跨越第 256 个 KV 槽位
      4. 5.2.4. 5.2.4 案例 D:增加每轮日志,观察分块 Prefill
      5. 5.2.5. 5.2.5 数值护栏:沿固定轨迹比较 nano-vLLM 的 logits
    3. 5.3. 5.3 建立最小服务:让同一条请求可接入、可观测
      1. 5.3.1. 5.3.1 一个执行循环拥有 Engine,其余入口只提交消息
      2. 5.3.2. 5.3.2 先统一指标,再接一层薄 HTTP
    4. 5.4. 5.4 第一次调度改造:让长 Prefill 与进行中的 Decode 轮流推进
      1. 5.4.1. 5.4.1 先找等待发生在哪里
      2. 5.4.2. 5.4.2 对照补丁完成修改,再执行真实调度器的 CPU 验收
      3. 5.4.3. 5.4.3 固定到达轨迹,比较收益与代价
    5. 5.5. 5.5 顺着瓶颈进入 GPU:测量一次执行,再做一处改进
      1. 5.5.1. 5.5.1 同时看 CPU 与 GPU 时间线
      2. 5.5.2. 5.5.2 做一次 eager / Graph 对照,并完成一处边界修复
    6. 5.6. 5.6 把同一个服务扩到两张卡:独立副本与 TP
      1. 5.6.1. 5.6.1 先隔离实例,再启动两个副本
      2. 5.6.2. 5.6.2 同样两张卡,换成一个 TP=2 引擎
    7. 5.7. 5.7 把工程闭环做完整:故障、回归与最终证据
      1. 5.7.1. 5.7.1 在生命周期边界验证,而不只看最终文字
      2. 5.7.2. 5.7.2 把结果整理成可复查的证据
  6. 6. 6. 带着这些理解再看 vLLM
    1. 6.1. 6.1 从教学引擎到生产系统,增加了哪些责任
    2. 6.2. 6.2 怎样做有解释力的对照
    3. 6.3. 6.3 用独立环境体验离线接口与本机服务
      1. 6.3.1. 6.3.1 独立环境与离线补全
      2. 6.3.2. 6.3.2 本机在线服务与流式返回
  7. 7. 附录 A. 数值与模型概念详解
    1. 7.1. A.1 Softmax:怎样把分数变成权重
    2. 7.2. A.2 Norm 与 LayerNorm:归一化究竟在处理什么
    3. 7.3. A.3 RMSNorm:不减均值,只按均方根调整尺度
    4. 7.4. A.4 同一个向量,比较四种归一化结果
    5. 7.5. A.5 SiLU 与 SwiGLU:MLP 中的非线性和门控
    6. 7.6. A.6 GQA:多个 Q Head 共享一组 K/V,但各自计算权重
    7. 7.7. A.7 RoPE:旋转 Q/K 的分量,让匹配分数感知位置
    8. 7.8. A.8 回到同一轮请求:核对形状与计算轴
  8. 8. 附录 B. 按问题选读的深入实验
    1. 8.1. B.1 数学与缓存等价:固定输入再比较
      1. 8.1.1. B.1.1 原理练习:从文本、token 到采样概率
      2. 8.1.2. B.1.2 原理练习:手算并验证一次因果 Attention
      3. 8.1.3. B.1.3 原理练习:区分已知 token 与已计算 KV
      4. 8.1.4. B.1.4 原理练习:验证 KV Cache 省掉了哪些计算
      5. 8.1.5. B.1.5 原理练习:估算显存并区分延迟与吞吐
      6. 8.1.6. B.1.6 真实模型对照:有缓存与无缓存的 logits
    2. 8.2. B.2 请求与调度日志:还原选择、回写和抢占
      1. 8.2.1. B.2.1 请求生命周期:从日志还原两条请求的一生
      2. 8.2.2. B.2.2 调度实验:分块 Prefill 与抢占
      3. 8.2.3. B.2.3 分页实验:从逻辑位置算到物理 cache slot
    3. 8.3. B.3 执行实验:把 Runner 元数据接到模型形状
    4. 8.4. B.4 性能实验:eager 与 CUDA Graph
    5. 8.5. B.5 边界修复:CUDA Graph 没有覆盖桶时回退 eager
    6. 8.6. B.6 前缀缓存实验:512-token 命中与引用计数
    7. 8.7. B.7 迁移到多轮对话:构造第二次请求
    8. 8.8. B.8 验证速查:数值、状态、指标与排错
      1. 8.8.1. B.8.1 正确性:固定输入路径,再比较输出
      2. 8.8.2. B.8.2 机制:用状态解释这一轮发生了什么
      3. 8.8.3. B.8.3 性能:先写清楚测量范围
      4. 8.8.4. B.8.4 出错时,找到最早不符合预期的边界
  9. 9. 附录 C. 配套阅读与版本边界
    1. 9.1. C.1 授权中文全译:从另一个视角阅读 nano-vLLM
    2. 9.2. C.2 架构与请求流程
    3. 9.3. C.3 从推理引擎到在线服务
    4. 9.4. C.4 vLLM 首发博客:分页如何转化为服务收益
    5. 9.5. C.5 模型与成本基础
Macduan Notes

大模型推理入门与 nano-vLLM 源码详解

这篇教程面向有编程或系统开发经验、希望通过读源码和动手改造入门大模型推理的读者。我们先看清模型每轮计算什么,再跟踪一个请求怎样经过 nano-vLLM;随后增加请求数和 GPU 数量,理解调度、KV 管理与执行优化如何共同影响吞吐和延迟。最终目标是能解释一次请求、修改一项机制,并用正确性与性能证据判断改动。

大模型推理,就是用训练好的模型,根据已有文本逐步生成后续内容。Prompt 先经 Tokenizer 转成 token ID,再查 Embedding Table 得到向量;这些向量依次经过多层 Transformer Block,最后由 LM Head 给词表中的候选 token 打分。选出一个 token 后,把它接到序列末尾,继续计算下一个,直到满足结束条件。

图 0-1 从 Prompt 到下一个 token:模型结构、Block 内部与逐轮生成回路

图 0-1 从 Prompt 到下一个 token:模型结构、Block 内部与逐轮生成回路

先沿图看清一次生成的完整路径。第一次处理整段已知输入,称为 Prefill;随后每轮输入刚选出的 token、复用各层缓存的历史 K/V,称为 Decode。两阶段运行同一套模型,具体计算和缓存机制在第 1.4~1.5 节展开。

阅读按六章推进:模型计算 → 单请求引擎 → 多请求与 KV → 执行优化与多卡 → 改造实验 → 对照 vLLM。已有模型基础可从第 2 章开始;动手入口是第 5.1 节。数值概念详解在附录 A,深入实验在附录 B,配套译文和延伸阅读在附录 C。

源码固定为 GeeeekExplorer/nano-vllm:bb823b3e06983d71485a8e1f23715ebd87d98ef8;模型固定为 Qwen3-0.6B:c1899de289a04d12100db370d81485cdf75e47ca。第 1 章先使用 4096 维教学模型,之后统一切换到真实 Qwen3 配置。所有脚本输出和性能结论都应在自己的实验机验收。

1. 模型究竟在计算什么

接下来把这张总览图逐步放大:先认清 token 和向量,再看 Attention 如何读取上下文、MLP 如何更新特征,最后理解 Prefill、Decode 和逐层 KV Cache。本文以常见的因果 decoder-only Transformer 为例。

先用隐藏宽度 4096 的教学配置手算,Head 从一个数扩到 128 维,再扩到 32 个头;本章末尾切换到真实 Qwen3-0.6B。后面的引擎与实验统一使用真实配置:隐藏宽度 1024、28 层、16 个 Q Head、8 个 KV Head、每头 128 维。

1.1 文本、token ID 与向量:先认清输入

先把输入文本变成模型能够计算的向量。Tokenizer 按配套词表和切分规则,把文本编码成 token ID;一个 token 可以是词、词的一部分、汉字、标点或字节片段。假设这段输入包含三个 token,按先后顺序记作 t1、t2、t3。

每个 ID 对应 Embedding Table 中的一行。以下用词表大小 100000、向量宽度 4096 的教学配置说明查表过程:

E∈R100000×4096,xi=E[id⁡(ti)]\displaystyle E\in\mathbb{R}^{100000\times4096},\qquad x_i=E[\operatorname{id}(t_i)]

例如 ID=1234 时,取出 E[1234]E[1234],得到形状为 [4096] 的向量;保留位置轴时写为 [1,4096]。三个位置按行排列:

X⏟[3,4096]=[x1x2x3]\displaystyle \underbrace{X}_{[3,4096]}=\begin{bmatrix}x_1\\x_2\\x_3\end{bmatrix}

4096 表示每条向量有 4096 个特征分量,也就是 4096 个数。 [1,4096] 中,第一轴表示位置,第二轴表示特征;三个位置排成 [3,4096]。每个分量是模型学到的一部分表示,具体语义通常由多个分量共同承载。

查表得到的是初始 embedding。它们进入 Transformer 后,逐层更新为包含上下文的隐藏表示(hidden state),在 Block 之间仍保持 4096 维。相同 token ID 的初始 embedding 相同,后续隐藏表示可以随上下文和位置改变。

Tokenizer 的编号与 Embedding Table 的行号配套。Tokenizer 通常在模型预训练前单独训练或复用,随后固定;Embedding Table 和各层权重参与模型的梯度训练。4096 这样的宽度由模型架构确定,训练学习其中的数值。不同模型也可以共用兼容的 Tokenizer。

1.2 一次 Attention 怎样得到输出

1.2.1 先走通一个 Head:三个位置,各自读取并输出

假设输入前缀有 t1、t2、t3 三个位置,正在做 Prefill。先看某一层的一个 Head:三个位置各自发起查询,分别得到 a1、a2、a3。 t1 读取自己,t2 读取 t1~t2,t3 读取 t1~t3。这里用 u1、u2、u3 表示各位置在本层经过 Norm、用于 Q/K/V 投影的输入。

Attention 的核心是:每个位置用自己的 Q,与可见位置的 K 匹配,算出权重,再按权重汇总对应的 V。 一个 Head 就是这样一条计算分支。下面先看三个位置的完整关系,再放大 t3 的计算;三个输出都会继续进入各自位置的后续网络。

图 1-1 单头 Attention 的完整位置关系|三个 Q 各自匹配可见 K、加权 V,得到 a1、a2、a3;下方放大 t3 的加权求和。

图 1-1 单头 Attention 的完整位置关系|三个 Q 各自匹配可见 K、加权 V,得到 a1、a2、a3;下方放大 t3 的加权求和。

第一步:三个位置各自生成 Q、K、V。 每个输入向量与投影矩阵相乘,得到相应结果:

q1=u1WQ,k1=u1WK,v1=u1WVq2=u2WQ,k2=u2WK,v2=u2WVq3=u3WQ,k3=u3WK,v3=u3WV\displaystyle \begin{aligned}q_1&=u_1W_Q,& k_1&=u_1W_K,& v_1&=u_1W_V\\[8pt]q_2&=u_2W_Q,& k_2&=u_2W_K,& v_2&=u_2W_V\\[8pt]q_3&=u_3W_Q,& k_3&=u_3W_K,& v_3&=u_3W_V\end{aligned}

同一层、同一个 Head 中,各位置共享同一套 WQ、WK、WV。 这些矩阵是训练好的参数;q、k、v 是本次输入经过投影得到的结果。Q 提供查询,K 用来匹配,V 提供被读取的内容。

第二步:每个 Q 与自己可见的 K 匹配,再把分数转成位置权重。 q1 匹配 k1;q2 匹配 k1、k2;q3 匹配 k1、k2、k3。每条查询分别对自己的分数做缩放和 Softmax。以 t3 为例,得到 s31、s32、s33;第一个下标 3 表示查询来自 t3,第二个下标表示被读取的位置:

[s31,s32,s33]=[q3⋅k1, q3⋅k2, q3⋅k3]\displaystyle [s_{31},s_{32},s_{33}]=[q_3\cdot k_1,\ q_3\cdot k_2,\ q_3\cdot k_3]
[w31,w32,w33]=softmax⁡ ⁣([s31,s32,s33]dhead)\displaystyle [w_{31},w_{32},w_{33}]=\operatorname{softmax}\!\left(\frac{[s_{31},s_{32},s_{33}]}{\sqrt{d_{\mathrm{head}}}}\right)

d_head 是每条 Q/K 向量的分量数。t3 的三个分数一起做 Softmax,得到总和为 1 的 w31、w32、w33;其中 w32 表示 t3 从 t2 读取信息的权重。同理,t2 的两个权重 w21、w22 总和为 1;t1 只有一个可读位置,所以 w11=1。

第三步:每个位置用自己的权重加权 V,得到自己的输出。 三个位置分别计算:

a1=w11v1a2=w21v1+w22v2a3=w31v1+w32v2+w33v3\displaystyle \begin{aligned}a_1&=w_{11}v_1\\[8pt]a_2&=w_{21}v_1+w_{22}v_2\\[8pt]a_3&=w_{31}v_1+w_{32}v_2+w_{33}v_3\end{aligned}

a1、a2、a3 分别是 t1、t2、t3 在这个 Head 中读取到的信息。加权求和发生在每条查询内部,得到的三个输出仍各自对应一个位置。 接下来用 t3 的一组具体数字,把同一条计算链手算一遍。

用 1 维 Head 手算一遍。 让每条 q、k、v 各只有一个数:4096 维输入乘 [4096,1] 的投影矩阵,得到 [1,1]。假设上述投影得到 q3=1,k1=0、k2=1、k3=2,v1=10、v2=20、v3=30;沿刚才的同一条计算链,分数与权重如下(此时除以 √1,分数保持不变):

可读位置

k

v

q3×k

Attention 权重

t1

0

10

0

0.0900

t2

1

20

1

0.2447

t3

2

30

2

0.6652

a3≈0.0900×10+0.2447×20+0.6652×30≈25.75\displaystyle a_3\approx0.0900\times10+0.2447\times20+0.6652\times30\approx25.75

本例的 V 各只有一个分量,所以 a3 也是一个数。把 V 换成 128 维向量,操作仍然是“分别加权,再逐分量相加”,这就是下一节要展开的情况。

三个输出都继续沿各自位置的路径经过后续网络。 在完整 Block 中,各位置的 Head 输出经过拼接、输出投影、残差和 Norm 后进入 MLP,再经残差形成该位置的 Block 输出;多头与 MLP 的衔接在第 1.2.3 节展开。

下一层用这些 Block 输出生成各位置新的 Q/K/V。要让 t3 在下一层读取到 t1、t2 已加工的信息,本层就需要算出 a1、a2,并完成它们的后续 MLP 等处理。这就是 Prefill 中前面位置也要计算 Q 的原因。

选择下一个 token 时,才取最后位置的最终表示。 为了预测 t4,模型使用 t3 走完所有 Block 后的表示,再经过最终 Norm、LM Head 和采样。中间各层处理所有输入位置,模型末尾用最后位置预测后续 token,这是两个不同的环节。

把同一过程用于 Decode 时,历史位置已经生成的 K/V 可以直接从该层缓存读取;本轮只为新位置生成 Q/K/V、计算 Attention 输出,并继续完成该位置的 MLP 等处理;旧位置各层的 K/V 直接复用。历史 K/V 的来源仍是图中的投影过程,逐层缓存怎样接入完整模型见第 1.4~1.5 节。

1.2.2 扩到 128 维:每个位置得到一条输出向量

保持上一节的三个位置和单个 Head,只把每条 q、k、v 从一个数扩为 128 个特征分量。q1、q2、q3 仍各自查询,a1、a2、a3 也各自保留;变化的是每条向量的宽度。 每个位置的输入 uiu_i 为 [1,4096],共享的 WQ、WK、WV 各为 [4096,128],投影结果各为 [1,128]:

qi⏟[1,128]=ui⏟[1,4096] WQ⏟[4096,128]ki⏟[1,128]=ui⏟[1,4096] WK⏟[4096,128]vi⏟[1,128]=ui⏟[1,4096] WV⏟[4096,128],i=1,2,3\displaystyle \begin{aligned}\underbrace{q_i}_{[1,128]}&=\underbrace{u_i}_{[1,4096]}\,\underbrace{W_Q}_{[4096,128]}\\[8pt]\underbrace{k_i}_{[1,128]}&=\underbrace{u_i}_{[1,4096]}\,\underbrace{W_K}_{[4096,128]}\\[8pt]\underbrace{v_i}_{[1,128]}&=\underbrace{u_i}_{[1,4096]}\,\underbrace{W_V}_{[4096,128]},\qquad i=1,2,3\end{aligned}

这里 i 是位置编号,128 是向量中数值分量的数量。每个投影分量由输入的 4096 个数加权组合得到。上一节的三条加权求和公式仍然适用:每个 w 是一个标量,乘整条 V,再按分量相加,因此三个位置的输出都是:

a1, a2, a3∈R1×128\displaystyle a_1,\ a_2,\ a_3\in\mathbb{R}^{1\times128}

下面只放大 t3 这一条查询。 它能读取三个位置,把对应的 k、v 分别按行排列:

K⏟[3,128]=[k1k2k3],V⏟[3,128]=[v1v2v3]\displaystyle \underbrace{K}_{[3,128]}=\begin{bmatrix}k_1\\k_2\\k_3\end{bmatrix},\qquad\underbrace{V}_{[3,128]}=\begin{bmatrix}v_1\\v_2\\v_3\end{bmatrix}

q3 与一条 k 点积,就是 128 对分量分别相乘再求和,得到一个匹配分数。与三个位置匹配后得到三个分数,经缩放和 Softmax 变成三个标量权重,再加权三条 V:

scores⏟[1,3]=q3⏟[1,128] KT⏟[128,3]weights⏟[1,3]=softmax⁡ ⁣(scores128)a3⏟[1,128]=weights⏟[1,3] V⏟[3,128]\displaystyle \begin{aligned}\underbrace{\mathrm{scores}}_{[1,3]}&=\underbrace{q_3}_{[1,128]}\,\underbrace{K^{\mathsf T}}_{[128,3]}\\[10pt]\underbrace{\mathrm{weights}}_{[1,3]}&=\operatorname{softmax}\!\left(\frac{\mathrm{scores}}{\sqrt{128}}\right)\\[10pt]\underbrace{a_3}_{[1,128]}&=\underbrace{\mathrm{weights}}_{[1,3]}\,\underbrace{V}_{[3,128]}\end{aligned}

例如 t2 的权重为 0.2,就把 v2 的全部 128 个分量都乘 0.2;三条加权向量逐分量相加,得到 a3。这里 K/V 的列对应特征,scores 的列对应可读位置;相同的行列记法可以描述不同的计算轴。

除以 √128 是为了控制点积分数的尺度:在分量近似独立、零均值且单位方差的简化假设下,128 项之和的标准差约为 √128。缩放可避免 Softmax 过早集中到极少数位置;1 维例子除以 √1,所以数值不变。Softmax 的数字推导见附录 A.1 节。

图 1-2 单头 128 维|每个位置生成自己的 Q/K/V,并得到一条 [1,128] 的输出;每个标量权重作用于整条 V。

图 1-2 单头 128 维|每个位置生成自己的 Q/K/V,并得到一条 [1,128] 的输出;每个标量权重作用于整条 V。

1.2.3 扩到 32 个 Head:各位置拼接,再经过 MLP

现在保留 t1、t2、t3 三个位置,把单个 Head 扩展成 32 个 Head;每个 Head 的 q、k、v 和输出仍各有 128 个分量。每个位置都在 32 个 Head 中分别读取信息,再把本位置的 32 个结果拼接。 在这里的标准多头注意力 MHA 示例中,各头有自己的 WQ、WK、WV,同一个头在三个位置之间共享参数;因此,每个位置在每个头中都有自己的 Q/K/V。

用 i 表示位置编号,用上标 (h) 表示 Head 编号。例如 a2(5)a_2^{(5)} 是 t2 在第 5 个 Head 中的输出。所有位置、所有头的输出形状相同:

ai(h)∈R1×128,i=1,2,3,h=1,2,…,32\displaystyle a_i^{(h)}\in\mathbb{R}^{1\times128},\qquad i=1,2,3,\quad h=1,2,\ldots,32

先在同一个位置内部拼接,再用 WO 混合各头的信息。 记 cic_i 为位置 i 的拼接结果:

ci⏟[1,4096]=[ai(1)∣ai(2)∣⋯∣ai(32)]\displaystyle \underbrace{c_i}_{[1,4096]}=\left[a_i^{(1)}\mid a_i^{(2)}\mid\cdots\mid a_i^{(32)}\right]
oi⏟[1,4096]=ci⏟[1,4096] WO⏟[4096,4096],i=1,2,3\displaystyle \underbrace{o_i}_{[1,4096]}=\underbrace{c_i}_{[1,4096]}\,\underbrace{W_O}_{[4096,4096]},\qquad i=1,2,3

于是,t1 的 32 个头汇成 o1,t2 的汇成 o2,t3 的汇成 o3。三个位置各保留一条 [1,4096] 向量,继续沿各自的位置路径计算。

接着,每个位置都经过残差、Norm 和 MLP。 用 hih_i 表示这个 Block 最初的输入(前文的 uiu_i 是它经过 Attention 前归一化后的结果),先把 Attention 输出 oio_i 加回 hih_i,得到 ziz_i;再对 ziz_i 归一化、经过 MLP,最后加回 ziz_i:

zi=hi+oihinext=zi+MLP⁡ ⁣(Norm⁡MLP(zi)),i=1,2,3\displaystyle \begin{aligned}z_i&=h_i+o_i\\[8pt]h_i^{\mathrm{next}}&=z_i+\operatorname{MLP}\!\left(\operatorname{Norm}_{\mathrm{MLP}}(z_i)\right),\qquad i=1,2,3\end{aligned}

这里 hinexth_i^{\mathrm{next}} 是位置 i 的 Block 输出,也是下一个 Block 对应位置的输入。三个位置共享本层同一套 WO 和 MLP 参数,分别计算;每条 Block 输出仍是 [1,4096]。 下一层再从各位置更新后的表示生成新的 Q/K/V。这样,从单头到多头,始终都是“各位置读取信息 → 各位置加工表示 → 交给下一层”的同一条主线。

图 1-3 多头与 MLP|同一位置的 32 个 Head 输出拼接为 [1,4096];三个位置分别经过投影、残差、Norm 和 MLP,进入下一 Block。

图 1-3 多头与 MLP|同一位置的 32 个 Head 输出拼接为 [1,4096];三个位置分别经过投影、残差、Norm 和 MLP,进入下一 Block。

1.3 完整模型:Attention 之后还有什么

普通 decoder-only 模型由多个 Transformer Block 顺序堆叠。每个 Block 通常包含一个 Self-Attention 子层和一个 MLP 子层;多个 Head 是同一个 Attention 内并列执行的分支。每个 Block 的 Attention 都要对 V 加权汇总。

第一层输入来自 Embedding,后续层输入来自上一层输出。常见 Pre-Norm 结构如下;残差把子层输出逐分量加回主线,使信息可以沿这条主线继续传递:

跟踪一个位置;Block 输入 x 和输出 x_next 均为 [1,4096]:

u=Norm⁡1(x)o=Attention⁡(u)h=x+o第一次残差相加z=Norm⁡2(h)m=activation⁡(zWup) Wdownxnext=h+m第二次残差相加\displaystyle \begin{aligned}u&=\operatorname{Norm}_1(x)\\[4pt]o&=\operatorname{Attention}(u)\\[4pt]h&=x+o \qquad \text{第一次残差相加}\\[4pt]z&=\operatorname{Norm}_2(h)\\[4pt]m&=\operatorname{activation}(zW_{\mathrm{up}})\,W_{\mathrm{down}}\\[4pt]x_{\mathrm{next}}&=h+m \qquad \text{第二次残差相加}\end{aligned}

Attention 内部依次完成 Q/K/V 投影、匹配与加权、多头拼接和输出投影;x_next 传给下一 Block。

图 1-4 模型结构图|Embedding → 多层 Transformer → 最终 Norm → LM Head;放大每层的 Attention、MLP 和两次残差。

图 1-4 模型结构图|Embedding → 多层 Transformer → 最终 Norm → LM Head;放大每层的 Attention、MLP 和两次残差。

Norm 在每个位置内部调整特征的数值尺度。MLP 则按行做扩维、非线性变换和缩回,例如 [T,4096]→[T,11008]→[T,4096]。三个位置共享本层同一套 MLP 参数,可以按行合并计算。Attention 在位置之间交换信息,MLP 加工当前行中已经融合的特征;本章末尾会把这里简化的 MLP 换成真实的 SwiGLU。

传给下一层的是完成 Attention、MLP 和残差更新后的完整 x_next。下一层使用自己的参数,根据这份更新后的输入生成新的 Q/K/V。

1.3.1 从最后位置的表示,到下一 token 的 ID

处理 t1~t3 时,各层不断更新这三个位置。最后一个 Block 完成后,取 t3 的最终表示,经最终 Norm 和 LM Head 得到词表分数 logits,再由 Sampler 选出 ID,作为 t4。各位置训练时学习预测紧接着的 token,所以续写取的是最后位置的输出。

记 x3(L)x_3^{(L)} 为 t3 的最后一个 Block 输出,下一步是:

h3⏟[1,4096]=Norm⁡final ⁣(x3(L))logits⏟[1,Vocab]=h3⏟[1,4096] Wvocab⏟[4096,Vocab]id⁡(t4)=Sampler⁡(logits, 生成设置)\displaystyle \begin{aligned}\underbrace{h_3}_{[1,4096]}&=\operatorname{Norm}_{\mathrm{final}}\!\left(x_3^{(L)}\right)\\[8pt]\underbrace{\mathrm{logits}}_{[1,\mathrm{Vocab}]}&=\underbrace{h_3}_{[1,4096]}\,\underbrace{W_{\mathrm{vocab}}}_{[4096,\mathrm{Vocab}]}\\[8pt]\operatorname{id}(t_4)&=\operatorname{Sampler}(\mathrm{logits},\,\text{生成设置})\end{aligned}

下一轮用选出的 ID 查表,得到 t4 自己的初始向量:

x4(0)⏟[1,4096]=E[id⁡(t4)]\displaystyle \underbrace{x_4^{(0)}}_{[1,4096]}=E[\operatorname{id}(t_4)]

LM Head 负责给词表候选打分,Sampler 负责从中选择,可以取最高分,也可以按分布采样。它与 Attention 的权重有不同用途:Attention 决定从哪些位置读取信息,Sampler 决定输出哪个 token。具体采样设置及 nano-vLLM 的支持范围见第 2.5 节。

1.4 Prefill 与 Decode:同一模型,两种工作量

Prefill 处理已知前缀,并选出第一个新 token;随后每轮 Decode 输入刚选出的 token,再选出下一个。 两阶段使用同一套模型和参数,区别在于新算的位置数,以及是否已有历史 K/V 可用。这里假设前缀没有缓存命中;prompt 包含模板、系统提示和历史消息编码后的全部输入。

图 1-5 推理全貌:Prefill 建立前缀,Decode 逐轮接续

图 1-5 推理全貌:Prefill 建立前缀,Decode 逐轮接续

轮次

本轮输入

Embedding 后

前向后各层 KV 覆盖

本轮选出

Prefill

t1~t3

[3,4096]

t1~t3

t4

Decode 1

t4

[1,4096]

t1~t4

t5

Decode 2

t5

[1,4096]

t1~t5

t6

图 1-6 三轮时序图|把“本轮输入”“已经算过 KV 的位置”和“刚选出的 token”分开看。

图 1-6 三轮时序图|把“本轮输入”“已经算过 KV 的位置”和“刚选出的 token”分开看。

选出 t4 时,KV 覆盖到已输入的 t3;继续生成时,下一轮输入 t4 并建立它的 KV。若 t4 已触发 EOS 或输出长度上限,生成就到此结束。这里的 Decode 是模型执行阶段,Tokenizer 的 decode 则是把 ID 还原成文本。

1.4.1 Prefill:各层处理已知输入位置

三个 ID 查表得到 X=[3,4096]。进入某个 Block 后,Norm 和投影对三行一起计算。只看一个 Head,Q、K、V 各为 [3,128];下面的 Softmax 沿每行允许读取的位置计算:

scores⏟[3,3]=Q⏟[3,128] KT⏟[128,3]P⏟[3,3]=softmax⁡ ⁣(scores128+causal_mask)A⏟[3,128]=P⏟[3,3] V⏟[3,128]\displaystyle \begin{aligned}\underbrace{\mathrm{scores}}_{[3,3]}&=\underbrace{Q}_{[3,128]}\,\underbrace{K^{\mathsf T}}_{[128,3]}\\[10pt]\underbrace{P}_{[3,3]}&=\operatorname{softmax}\!\left(\frac{\mathrm{scores}}{\sqrt{128}}+\mathrm{causal\_mask}\right)\\[10pt]\underbrace{A}_{[3,128]}&=\underbrace{P}_{[3,3]}\,\underbrace{V}_{[3,128]}\end{aligned}

最后一行就是 t3 的加权汇总结果:

a3⏟[1,128]=∑j=13P3jvj=P31v1+P32v2+P33v3\displaystyle \underbrace{a_3}_{[1,128]}=\sum_{j=1}^{3}P_{3j}v_j=P_{31}v_1+P_{32}v_2+P_{33}v_3

因果 mask 把未来位置的分数设为 −∞:t1 只读 t1,t2 读 t1~t2,t3 读 t1~t3。同一层的输入已经就绪,所以三个查询可以并行,t3 不用等待 t2 的本层 Attention 输出。每个位置再完成多头拼接、WO、残差、Norm 和 MLP,整层输出 [3,4096] 传给下一 Block。层与层仍依次执行。

图 1-7 Prefill 路径图|放大 t3 的完整层内计算;t1、t2 也各自完成这条路径,为下一层提供更新后的输入。

图 1-7 Prefill 路径图|放大 t3 的完整层内计算;t1、t2 也各自完成这条路径,为下一层提供更新后的输入。

这些中间结果构成下一层的输入:下一层 t3 所读取的 k1/v1、k2/v2,来自本层经过 Attention 和 MLP 更新后的 t1、t2。因此 Prefill 在中间层更新全部输入位置,同时保存各层生成的 K/V,为后续 Decode 做准备。

1.4.2 Decode:新位置逐层计算,历史从 KV 接入

输入刚选出的 t4,查表得到一行 [1,4096]。进入某层后,先 Norm,再生成 q4、k4、v4;对一个 Head,它们各为 [1,128]。新 K/V 纳入该层缓存后,可读范围从三个位置扩到四个:

K⏟[4,128]=[Kold⏟[3,128]k4⏟[1,128]],V⏟[4,128]=[Vold⏟[3,128]v4⏟[1,128]]\displaystyle \underbrace{K}_{[4,128]}=\begin{bmatrix}\underbrace{K_{\mathrm{old}}}_{[3,128]}\\\underbrace{k_4}_{[1,128]}\end{bmatrix},\qquad\underbrace{V}_{[4,128]}=\begin{bmatrix}\underbrace{V_{\mathrm{old}}}_{[3,128]}\\\underbrace{v_4}_{[1,128]}\end{bmatrix}
scores⏟[1,4]=q4⏟[1,128] KT⏟[128,4]weights⏟[1,4]=softmax⁡ ⁣(scores128)a4⏟[1,128]=weights⏟[1,4] V⏟[4,128]\displaystyle \begin{aligned}\underbrace{\mathrm{scores}}_{[1,4]}&=\underbrace{q_4}_{[1,128]}\,\underbrace{K^{\mathsf T}}_{[128,4]}\\[10pt]\underbrace{\mathrm{weights}}_{[1,4]}&=\operatorname{softmax}\!\left(\frac{\mathrm{scores}}{\sqrt{128}}\right)\\[10pt]\underbrace{a_4}_{[1,128]}&=\underbrace{\mathrm{weights}}_{[1,4]}\,\underbrace{V}_{[4,128]}\end{aligned}

四个权重分别乘四条 V,再逐分量相加得到 a4。t4 接着完成多头拼接、WO、残差、Norm、MLP 和残差,把更新的一行传给下一 Block;下一层再读取下一层自己的历史 K/V。走完全部层,才能选出 t5。

图 1-8 Decode 路径图|新 t4 走完整层,历史 t1~t3 只通过该层 KV Cache 接入 Attention。

图 1-8 Decode 路径图|新 t4 走完整层,历史 t1~t3 只通过该层 KV Cache 接入 Attention。

Decode 复用旧位置的 K/V,新位置仍逐层完成 Attention 和 MLP。 历史长度决定本轮要读取多少 K/V。“追加 K/V”描述的是逻辑增长;高效引擎通过向预分配槽位写入新值实现它。

1.5 为什么缓存逐层 K/V 就够了

先看哪些结果不变。加入 t4 后,t1~t3 仍看不到 t4:第一层旧位置的可读前缀、输入和参数都没变,输出不变;第二层收到的旧输入也没变,如此逐层推下去,每一层旧位置的 hidden state、a 和 K/V 都不变。这是因果约束带来的可复用性,前提是原有前缀、位置处理和模型参数没有改变。

再看未来需要什么。新 q4 与历史匹配时只需要旧 K,汇总历史内容时只需要旧 V;它不直接读取旧 Q、旧 a 或旧 MLP 输出。旧 a 和 MLP 输出在之前已经形成更深层的输入,并生成了更深层的 K/V。缓存所有层的 K/V,就保留了新位置跨位置计算直接需要的历史数据。

图 1-9 跨层依赖图|旧位置先完成浅层计算,建立深层 K/V;新位置随后逐层读取缓存,不重建旧位置的计算链。

图 1-9 跨层依赖图|旧位置先完成浅层计算,建立深层 K/V;新位置随后逐层读取缓存,不重建旧位置的计算链。

第 l 层 K/V 来自该层输入,承接前 l−1 层加工过的信息,随后才参与计算本层 a。它们保存的是未来查询直接需要的历史数据。新位置的残差与 MLP 使用自己的当前表示继续计算。引擎另外保存 token ID、位置和请求进度,分别用于组织输入与控制生成。

在本章 MHA 配置下,每层 K 和 V 各可组织为 [B,32,S,128];只看一个请求、一个头,就是两张 [S,128] 矩阵。每个 Block 分别保存一组这样的 K/V,供新位置在相应层读取。

图 1-10 缓存对照图|无缓存每轮重跑完整前缀;有缓存首次处理三行,后面每轮只处理一行,两者最后查询的可读范围相同。

图 1-10 缓存对照图|无缓存每轮重跑完整前缀;有缓存首次处理三行,后面每轮只处理一行,两者最后查询的可读范围相同。

上面三轮,无缓存共处理 3+4+5=12 个位置,有缓存处理 3+1+1=5 个。若 prompt 长 S、选出 G 个新 token,有缓存需要处理 S+G−1 个位置。这些数字比较位置执行次数;实际速度还取决于每轮读取历史 K/V 的成本。如何用同一条 token 路径验证缓存与全量重算的结果,见第 5.7.1 节。

1.5.1 Context window:整个生成过程的上下文预算

处理 t4 时,本轮新计算一行,全注意力的可见范围是 t1~t4。Context window 是模型或服务支持的上下文容量;请求预算包含系统提示、模板、输入历史和不断追加的输出,贯穿 Prefill 与后续 Decode。

达到上限时,服务按配置拒绝请求、停止生成或截断输入。滑动窗口是一种单独的 Attention 设计:例如每次最多直接读取 1024 个位置、包含自己,那么处理 t1025 时可见 t2~t1025。本文其余示例采用全注意力,其可读历史随生成过程累积。

延伸阅读:The Illustrated GPT-2。 原理核对:Hugging Face — How caching works。

1.6 对照真实模型:Qwen3-0.6B

仍跟踪 t1~t3 已完成 Prefill、刚选出 t4 的场景。本轮输入 t4,目标是选出 t5。现在把教学模型换成真实配置,观察形状如何变化;逐层读取历史、更新当前表示的计算主线保持一致。

Qwen3-0.6B 有 28 个 Block,隐藏宽度是 1024。Q 有 16 个 Head,每头 128 维,总宽度为 2048;K、V 各有 8 个 Head,总宽度为 1024。Attention 得到 16 个单头结果后,拼成 2048 维,再由输出投影映射回 1024 维,与残差主线相加。

图 1-11 Qwen3-0.6B 结构图|先沿顶部走完整个模型,再向下放大一个 Block 和其中的 Attention;S 表示单条请求本轮可读的位置数,包含当前位置。

图 1-11 Qwen3-0.6B 结构图|先沿顶部走完整个模型,再向下放大一个 Block 和其中的 Attention;S 表示单条请求本轮可读的位置数,包含当前位置。

配置

前面的教学模型

Qwen3-0.6B

Block 数 / 隐藏宽度

L / 4096

28 / 1024

Q Head 数 / KV Head 数

32 / 32,MHA

16 / 8,GQA

每头宽度

128

128

MLP

简化的两层投影

SwiGLU,gate/up 各 3072 维

归一化与位置编码

用 Pre-Norm 说明主线

RMSNorm、Q/K Head Norm、RoPE

词表

记为 Vocab

151936,Embedding 与 LM Head 共享权重

这些宽度由模型配置确定。按行向量右乘权重的数学写法,WQ 是 [1024,2048],WK、WV 各是 [1024,1024],WO 是 [2048,1024]。Qwen3-0.6B 分别设置隐藏宽度 1024 和 head_dim=128,16 个 Q Head 的总宽度为 2048。对照 PyTorch 代码时,Linear 的参数通常按转置形状存储。

配置来源:Qwen3-0.6B 官方 config.json。 模型目录、Tokenizer 与源码如何对接,见第 2.2 节。

1.6.1 走完一个真实 Block

t4 的 ID 查表后得到 [1,1024] 的向量。第一层接收它的初始 Embedding,后面的层接收上一层更新后的表示。下面只跟踪某一个 Block;“本层缓存”始终指这个 Block 自己的 K/V。

先调整尺度,再生成 Q/K/V。输入经过 RMSNorm,沿这一行的 1024 个特征调整数值尺度,宽度仍为 1024。三条投影分别得到 Q=[1,16,128]、K=[1,8,128]、V=[1,8,128]。这里三个轴依次表示位置、Head、特征;Q 有 16 个头,K/V 各有 8 个头。

让 Q/K 的匹配考虑位置。Q、K 分别在每个 Head 的 128 个特征上再做一次 RMSNorm,然后经过 RoPE:按 t4 的逻辑位置旋转成对分量,使后续点积包含位置信息。V 不做这两步。处理后的 k4 与 v4 写入本层缓存;历史 K 已在之前处理好,无须随新 token 到来重新旋转。

用 GQA 读取历史,仍然各自计算权重。本层的 16 个 Q Head 分成 8 组,每两个查询头共享一组 K/V。加入当前位置后,这组 K/V 包含 t1~t4;每个查询头都独立完成“q 与 K 匹配 → 缩放与 Softmax → 用权重对 V 加权求和”,得到自己的 [1,128] 输出。组内共享 K/V,各查询头保留独立的查询、位置权重和输出。

拼接各头结果,回到残差主线。16 个单头输出拼成 [1,2048],经 WO 投影回 [1,1024],再与这个 Block 原来的输入逐分量相加。Attention 已把可见历史融入 t4 的当前表示,但本层计算还没有结束。

用 MLP 加工当前表示,再交给下一层。这条更新后的向量经过 RMSNorm,进入 SwiGLU MLP:gate、up 两路各投影到 3072 维,gate 经过 SiLU 后与 up 逐分量相乘,再投影回 1024 维。最后做第二次残差相加,得到下一 Block 的输入。MLP 不再直接读取历史 token,却仍是 t4 每层必须完成的计算。

沿这条路径,RMSNorm 调整数值尺度,RoPE 让匹配感知位置,GQA 减少需要保存的 K/V 头数,SwiGLU 负责当前特征的非线性变换。详细推导见:归一化(A.2–A.4)、SiLU 与 SwiGLU(A.5)、GQA(A.6)、RoPE(A.7)。

1.6.2 走完 28 层,选出 t5

t4 带着上一层输出进入下一层,再用下一层自己的参数生成 Q/K/V、读取下一层自己的缓存。28 个 Block 顺序执行,结构相同,但参数和缓存各不相同。旧位置 t1~t3 的信息通过逐层 K/V 接入,本轮把 t4 的新表示逐层传下去。

最后一个 Block 完成后,t4 的表示经过最终 RMSNorm 和 LM Head,得到词表分数;Sampler 才从中选出 t5。下表核对这一轮的关键形状,省略批次轴:

阶段

本轮计算与形状

输入

ID(t4) → Embedding,得到 [1,1024]。

每层:生成 Q/K/V

RMSNorm → 投影,Q 为 [1,16,128],K/V 各为 [1,8,128]。

每层:处理与写入

Q/K Head Norm → Q/K RoPE → 新 K/V 写入本层缓存。

每层:读取与汇总

读取本层 K/V,各为 [4,8,128];每个 Q Head 单独计算位置权重,再对 V 加权求和。

每层:输出投影

16 个 Head 的输出拼接为 [1,2048],经 WO 回到 [1,1024]。

每层:后续计算

残差相加 → RMSNorm → SwiGLU → 残差相加 → 下一 Block。

走完 28 层

最终 RMSNorm → LM Head,得到 logits [1,151936]。

选出下一个 token

Sampler 选出 t5;此时各层缓存覆盖到 t4。

这一章建立了计算模型:每轮新输入一些位置,它们经过完整网络,各层历史通过 KV Cache 接入。接下来把模型放进一个常驻程序,跟踪请求如何提交、执行、回写和结束。

2. 一个请求怎样跑完 nano-vLLM

模型定义一次前向计算,推理引擎把多次计算组织成一次完整回答。nano-vLLM 是一个精简的 Python 推理引擎:调用方给出模型目录、prompt 和生成设置;它维护请求进度、管理 KV 空间、逐轮调用 GPU,最后交回输出。张量运算与通信复用 PyTorch/NCCL,Attention 复用 FlashAttention,配置和分词复用 Transformers。

一个简单的生成循环足以处理单请求。请求越来越多以后,程序还要决定本轮算谁、给它多少缓存、何时接入下一条、何时结束并释放资源。推理引擎把这些决定与模型执行连接起来。本文固定提交提供本地 Python 接口;HTTP、增量输出与取消是第 5 章逐步增加的服务能力。

2.1 先看架构:谁拥有状态,谁驱动计算

图 2-1 nano-vLLM 单卡系统架构:组件归属与 CPU/GPU 数据交接

图 2-1 nano-vLLM 单卡系统架构:组件归属与 CPU/GPU 数据交接

LLM 是 LLMEngine 的薄封装。Engine 持有 Tokenizer、Scheduler 和 ModelRunner;Scheduler 持有 BlockManager。单卡模式下,它们处于同一个 Python 进程。CPU 负责组织工作,GPU 保存模型权重和 KV 数值并完成张量计算。

组件

接住什么

交出什么

LLMEngine

prompt、生成设置

建立请求,驱动每轮计算,整理输出

Sequence

一条请求的 token ID 与设置

当前进度、停止状态与页表

Scheduler

等待与运行请求、资源预算

本轮请求集合与工作量;执行后更新状态

BlockManager

空间需求、已缓存前缀

物理页号、引用计数和前缀索引

ModelRunner

本轮选中的 Sequence

GPU 输入与缓存地址;模型和采样返回的候选 ID

模型与 Sampler

新输入、权重、历史 KV、采样设置

新 KV、logits、下一 token ID

从数据库内核视角看,Engine 类似执行协调器,Scheduler 类似运行时调度,BlockManager 类似内存池的页分配管理,ModelRunner 驱动具体算子。这里的调度位于每轮请求热路径。模型结构和参数已经给定;生成过程反复执行这套算子图,并用上一轮选出的 token 决定下一轮输入。

2.1.1 再看硬件:CPU 发出工作,GPU 在哪里存、在哪里算

先把“GPU”分成存储和计算两部分。以 A800 这类设备为例,大容量的 HBM 显存保存模型权重、KV 池和需要落到显存的中间张量;GPU 芯片内有许多 SM(Streaming Multiprocessor,流式多处理器),真正执行乘法、归约、Softmax 等操作。HBM 的带宽很高,但计算仍要把所需数据送到 SM 附近;数据放得下和数据取得快,是两个问题。

图 2-2 CPU、HBM、L2 与 SM:跨设备传输、GPU 内部搬运和计算各发生在哪里。

图 2-2 CPU、HBM、L2 与 SM:跨设备传输、GPU 内部搬运和计算各发生在哪里。

位置

负责什么

放到推理中怎样理解

CPU 与主机内存

运行 Python 引擎,维护请求、队列、页号和引用计数

决定本轮算谁,准备 ID、位置和缓存访问元数据

HBM:GPU 的设备内存

容量大,保存跨算子、跨轮使用的张量

常驻权重、各层 KV 池,以及部分激活和工作区

L2 cache

GPU 芯片内、供各 SM 访问的硬件缓存

复用近期访问的权重或 KV 数据,命中情况随访问变化

SM 内的 L1 与 shared memory

L1 由硬件管理;共享内存由程序显式组织,供线程块内协作

计算内核可把矩阵小块放在附近重复使用;Ampere 上二者共享部分物理存储资源,但用途不同

SM 的寄存器

保存线程的操作数与中间状态

例如点积的部分和、Softmax 的归一化状态、输出累加值

SM 的执行单元

通用运算单元与 Tensor Cores 执行相应指令

合适的矩阵乘内核利用 Tensor Cores;地址计算、归约等也依赖通用运算

一轮模型前向由多个 GPU kernel 组成。Kernel 是提交给 GPU 的计算程序,通常包含许多 CUDA thread blocks;GPU 将这些线程块安排到可用 SM,在 SM 内按 warp 调度,NVIDIA 的一个 warp 含 32 个线程。矩阵乘或 Attention 的内核会把大任务切成 tile,由线程协作处理;具体如何切分取决于内核和输入形状。一条请求可以跨多个 SM 执行,一个 SM 也会先后参与多条请求的计算。

数据搬运有两个尺度。启动时,模型权重经主机—设备链路加载到 HBM。正常生成时,CPU 主要传入本轮 ID、positions 和页表等元数据,GPU 采样后取回新 ID;历史 KV 留在 GPU。GPU 内部则持续访问 HBM、L2 和 SM 内的存储。常见分块内核通过共享内存和寄存器复用数据,具体路径由指令与内核决定,共享内存只是其中一种组织方式。

例如计算一批输入的 Q 投影,内核取出输入矩阵的一块和权重矩阵的一块,在 SM 内做乘加;多行输入可以复用同一权重块。计算 Attention 时,内核取出当前 Q 和相应的 K/V 小块,维护分数归一化与输出累加。这解释了后面两条主线:Batching 扩大可一起计算的工作量,KV 管理决定历史数据放在哪里、应读哪些位置。

术语约定:Transformer Block 是模型的一层计算;KV block/page 是软件划分的缓存分配单位;CUDA thread block 是内核的线程组织;tile 是一次协作处理的数据小块。本文的 KV 页由普通 GPU 张量上的索引实现,页表由推理引擎维护。它与操作系统、GPU 硬件虚拟内存中的页表属于不同机制。

2.2 启动一次:模型目录怎样变成常驻资源

模型目录包含配置、Tokenizer 资产和权重。Tokenizer 决定文本如何切成 ID;config 给出隐藏宽度、层数、head 数等结构参数;权重填入相应的 Embedding、投影和 MLP。引擎在加载模型时读取这些配套资产,确定后续计算的结构。

本文固定提交直接构建 Qwen3ForCausalLM,加载的是与这份实现兼容的模型资产;支持其他架构需要增加相应模型实现和加载适配。Qwen3-0.6B 的 hidden_size=1024,16 个 Q heads、8 个 KV heads,每头 128 维;Tokenizer 与词表来自配套模型资产。输入模型的首先是整数 ID,一批 T 个新位置经 Embedding 后才成为 [T,1024]。

Runner 加载权重、预热并建立 GPU KV 池;启用 CUDA Graph 时还会预先捕获执行图。这些是启动准备。后续每轮只更新输入和必要元数据,权重、历史 K/V 不会从 CPU 全量重传。

配置、Tokenizer 和权重各有职责:配置决定网络与张量形状,Tokenizer 给文本编号,权重决定每次计算的数值。缓存池的形状由层数、KV Head 数、head_dim、dtype、页大小和可用显存共同确定。启动完成后,这些资源供很多请求和很多轮计算复用。

from nanovllm import LLM, SamplingParams

llm = LLM(model_path, enforce_eager=True)
outputs = llm.generate(
    ["介绍一下篮球"],
    SamplingParams(temperature=0.8, max_tokens=32),
)
最小调用示意;完整路径与安装步骤见 5.1

这里的字符串是普通输入示意。使用聊天模型时,应用先按配套 chat template 组织角色、历史和生成提示。generate 会等待这一批请求完成,再返回各条请求的文本与 ID;实验先用明确的三个合法 ID 跟踪位置,避免把分词长度与中文字符数混在一起。

2.3 请求入队:先认识三个进度量

假设输入是 t1、t2、t3,最多输出三个 token。Engine 创建 Sequence,将它交给 Scheduler 的 waiting 队列。Sequence 保存整数 ID 和生成状态;历史向量的数值存放在 GPU 的 KV 池中。

字段

含义

本例刚入队

num_tokens

已知 token ID 总数,包含输入和已接受输出

3

num_cached_tokens

有效 KV 已覆盖的前缀长度

0

num_scheduled_tokens

本轮安排新算的位置数,调度时填写

尚未调度,值为 0

block_table

请求的逻辑页到物理 KV 页的映射

尚未分配,为空

2.4 沿着 step 走三轮:调度、执行、回写

Engine 的核心循环只有三次交接,后面的缓存、合批和多卡都围绕它展开:

seqs, is_prefill = self.scheduler.schedule()
token_ids = self.model_runner.call("run", seqs, is_prefill)
self.scheduler.postprocess(seqs, token_ids, is_prefill)
固定提交 LLMEngine.step 的核心三行

图 2-3 一条请求从提交到结束:结果回写后再进入下一轮。

图 2-3 一条请求从提交到结束:结果回写后再进入下一轮。

第一轮:Prefill。Scheduler 为输入安排 KV 页,给出三个待计算位置。ModelRunner 准备 ID、位置 0/1/2 和新 KV 的写入地址;GPU 查表得到 [3,1024],逐层执行并写入 t1~t3 的 KV。最终用 t3 的表示得到 logits,Sampler 选出 t4。postprocess 将缓存长度推进到 3,接受 t4,已知 ID 总数变成 4。

第二轮:Decode t4。Scheduler 安排一个新位置。ModelRunner 输入 t4 的 ID、位置 3、本请求的页表和有效长度。GPU 在每层生成 t4 的 Q/K/V,读取本层 t1~t3 的缓存并纳入当前位置,再完成 Attention、MLP 和后续层。选出 t5 后,缓存覆盖到 t4,已知 ID 覆盖到 t5。

第三轮:Decode t5。同样处理一个新位置,选出 t6。由于已接受三个输出,达到 max_tokens=3,Scheduler 结束请求并释放页引用;Engine 将 t4~t6 整理为结果。模型和 KV 池继续常驻,供后续请求使用。

本轮

执行前:已知 / 已缓存 / 本轮新算

本轮输入 → 新输出

回写后:已知 / 已缓存

Prefill

3 / 0 / 3

t1~t3 → t4

4 / 3

Decode 1

4 / 3 / 1

t4 → t5

5 / 4

Decode 2

5 / 4 / 1

t5 → t6

6 / 5(结束清理前)

表中的执行前状态是 schedule 返回后、GPU 尚未开始的时刻;结束行是清理前的状态。普通 Decode 轮间,已知长度通常比 KV 长度多 1,因为刚选出的 ID 要到下一轮输入时才生成 KV。回写完成后,num_scheduled_tokens 清零,等待下一轮调度。

图 2-4 生成循环中的状态交接:同一条 Sequence 持续推进。

图 2-4 生成循环中的状态交接:同一条 Sequence 持续推进。

2.5 候选如何成为输出,EOS 如何结束请求

LM Head 将最后位置的表示变成词表分数,Sampler 选择候选 ID。通用采样可以取最高分或按概率抽样;本文固定 nano 实现使用温度采样,要求 temperature > 1e-10,没有单独的 greedy、top-k、top-p 接口。模型输入尚未全部处理完的分块 Prefill,会计算候选,但 postprocess 只推进缓存进度,完整输入处理结束后才接受输出。

EOS 是模型可预测的特殊结束 token。模型通过训练学到何时给结束标记较高分数;Sampler 选中它后,引擎按停止设置结束这次生成。检查同样适用于 Prefill 产生的首个输出,也适用于后续 Decode。历史输入中的结束标记作为输入计算,新选出的结束标记才触发本轮停止判断;达到 max_tokens 也会结束请求。

一次回答结束以后,应用可以继续同一个聊天会话。常见请求式接口会把“旧问题+旧回答+新问题”作为下一条请求的已知输入;新问题先经过 Prefill 建立 KV,再 Decode 新回答。第 3.5 节把这一过程与前缀复用连接起来。

读源码的入口:先看 engine/llm_engine.py 的 add_request、step、generate;再沿本轮执行进入 scheduler.py、model_runner.py 的 prepare_prefill / prepare_decode / run,最后回到 postprocess。每看一个函数,都确认它接收什么、更新什么、把什么交给下一步。

3. 多个请求怎样共享 GPU 和显存

一个请求跑通以后,下一步是让多条请求共享同一张 GPU。CPU 选择本轮工作,Runner 打包新位置和访问元数据,GPU 批量计算并把新 K/V 写入持久缓存。下面用同一组请求依次观察组批、换成员和显存读写,再讨论容量、前缀共享与调度预算。

3.1 Batching:多条请求怎样变成一次模型执行

本章沿同一组请求观察:A 的已知输入是 A1、A2、A3,B 是 B1、B2;B 完成后,新的 C 带着 C1、C2 加入。A1 等符号代表 token ID,数字表示它在所属请求中的位置。先使用单卡 Qwen3-0.6B、eager 执行和充足预算;具体页号到第 3.4 节再展开。

Batch 是本轮一起执行的一组工作。要同时区分请求数和新 token 行数:Prefill 时一条请求贡献多个新位置,普通 Decode 时每条被选中的请求贡献一个新位置。设本轮有 B 条请求,第 i 条新算 q_i 个位置,则总输入行数 T 等于所有 q_i 之和。模型的一次前向接收这 T 行;它内部仍包含 Embedding、各层投影、Attention、MLP 等多次算子执行。

图 3-1 同一组 A、B:Prefill 打包五行,下一轮 Decode 打包两行;共享计算,保留请求边界。

图 3-1 同一组 A、B:Prefill 打包五行,下一轮 Decode 打包两行;共享计算,保留请求边界。

本轮

CPU 准备并传入 GPU

GPU 中的新表示

Attention 的可见范围

首次 Prefill

input_ids=[A1,A2,A3,B1,B2]
positions=[0,1,2,0,1]
cu_seqlens_q=[0,3,5]

2 条请求、5 个新位置
Embedding 后 X=[5,1024]

前 3 行属于 A,后 2 行属于 B;请求内部再按因果顺序读取

下一轮 Decode

input_ids=[A4,B3]
positions=[3,2]
context_lens=[4,3]

2 条请求、2 个新位置
Embedding 后 X=[2,1024]

A4 读 A1~A4;B3 读 B1~B3,长度含本轮刚写入的位置

cu_seqlens_q 是各请求在紧凑输入中的累计边界:[0,3,5] 表示 A 占第 0~2 行,B 占第 3~4 行。它让算子知道每行属于谁;positions 则保留该行在原请求中的位置,供 RoPE 等计算使用。首次没有旧缓存时,K 的累计长度同样是 [0,3,5]。这种变长打包可以直接使用五个有效位置,具体内核仍可按自身要求对齐数据。

哪些一起算,哪些按请求隔开?投影和 MLP 对每行使用同一组模型参数,可以写成一个矩阵乘。Attention 也使用批量内核,但要按请求边界、各自页表和有效长度读取 K/V。A 的 query 只聚合 A 的上下文;在首次 Prefill 中,A2 还受因果约束,只读取 A1、A2。

Y=XW,X∈RT×D,W∈RD×H,Y∈RT×H\displaystyle Y=XW,\qquad X\in\mathbb{R}^{T\times D},\quad W\in\mathbb{R}^{D\times H},\quad Y\in\mathbb{R}^{T\times H}

D 是输入特征数,H 是这次投影的输出宽度。单卡 Qwen3-0.6B 中 D=1024;每层 Q 形成 [T,16,128],K、V 各形成 [T,8,128]。16 个 Q heads 分组使用 8 个 KV heads。Attention 的各头结果经拼接、输出投影回到 [T,1024],随后 MLP 和下一层继续处理这 T 行。Prefill 走完全部层后,从 A3、B2 的最终表示各取一行,分别预测 A4、B3。

为什么合批通常更高效?只算一行时,投影和 MLP 也要访问大量权重,矩阵乘规模却很小。把多行放进同一次算子执行后,内核可以让更多输入行复用同一权重 tile 的读取,摊薄提交开销,并提供更多可并行的工作。权重常驻 HBM,计算时仍需按块搬运;实际重复读取次数由内核、缓存和矩阵形状共同决定。

Attention 的工作量则还取决于各请求的历史长度。把两个新 query 合成两行,并不会让它们只读取两行 KV;上例 Decode 要分别覆盖 4 和 3 个位置。因而增大 batch 可能提高总吞吐,也会增加单轮工作量。判断收益时,同时观察整机吞吐和单请求延迟。

3.2 Continuous Batching:每轮重新决定成员

Batching 回答“这一轮怎样一起算”,Continuous Batching 回答“下一轮还让谁一起算”。静态批处理先固定一组请求,等整组结束再接入下一组;长短回答混在一起时,先完成的请求留下了空闲容量。连续批处理在每轮完成并回写状态后重新组批:移除结束请求,在预算允许时接入等待请求,再准备下一次前向。

继续使用 A、B。设 B 最多输出 2 个 token,A 继续生成;新的 C 在第 1 轮执行期间到达,带着 C1、C2 等待下一调度边界。下面给出本教程固定 nano 版本的阶段顺序:它优先处理 Prefill,一次 step 只执行一种阶段。驱动在本轮结束后才把 C 交给 Scheduler。

轮次

本轮 GPU 新输入

本轮接受的输出

下一轮前的状态

0:Prefill A、B

[A1,A2,A3,B1,B2]

A4、B3

A、B 转入 running,各自 KV 留在 GPU

1:Decode A、B

[A4,B3]

A5、B4

B 达到输出上限,释放页引用;A 等待继续

2:Prefill C

[C1,C2]

C3

A 保留已缓存的 A1~A4;C 转入 running

3:Decode A、C

[A5,C3]

A6、C4

A、C 各推进一个新位置

图 3-2 B 完成后接入 C:固定成员的静态批处理与逐轮组批的对照。

图 3-2 B 完成后接入 C:固定成员的静态批处理与逐轮组批的对照。

静态安排中,C 要等 A 也结束;连续安排中,C 可以利用已经释放的容量进入后续轮次。这里 C 的 Prefill 会让 A 等待一轮,之后 A、C 才一起 Decode。横轴表达的是调度顺序,各轮耗时不同;是否提升吞吐、是否拉长 A 的输出间隔,要通过相同负载测量。将 Prefill 与 Decode 放进同一个 batch,是更进一步的实现,本固定版本采用分轮执行。

每轮重建的是工作描述,跨轮保留的是请求状态。Scheduler 选出 Sequence,Runner 重建本轮 ID、positions、请求边界和缓存地址张量;模型权重与仍被请求使用的 KV 继续驻留。batch 中的行代表“本轮第几项工作”,Sequence 和它的页表代表“历史属于谁”。即使下一轮成员或排列变化,历史 KV 仍可沿原页表访问。

调度要同时管计算预算和缓存容量。本轮最多选几条请求、Prefill 最多新算多少 token、接入或增长是否有足够 KV 页,都会影响组批。长 Prefill 可以被拆成 chunk,但连续的 Prefill 仍可能让已有 Decode 等待;更大 batch 也可能增加每轮耗时。第 3.6 节展开这些限制,第 5.4 节再实际修改阶段选择与请求轮转。

固定版本的 generate 会先把给定输入全部入队,再循环 step;要重放“C 后来才到达”的场景,驱动在 step 边界调用 add_request。第 5.3 节把这个边界做成可持续接入的服务,流式输出与取消再沿相同的请求生命周期处理。

3.3 Paged KV:把显存容量交给真正需要它的请求

先看同一时间点的两种存储方案:在第 3.2 节的第 3 轮写入后,A 有 5 个有效 KV 位置,C 有 3 个。假设两条请求的容量上限都为 12,简单的连续预留方案会为它们占住 24 个位置;8 个有效位置占所领取容量的 8/24≈33.3%。

分页把缓存池划成固定槽数的页。暂用每页 4 槽示意,A 领取两页,C 领取一页,共占 12 槽;同样 8 个有效位置占到 8/12≈66.7%。请求可以领取池中不同位置的空闲页,通过自己的页表维持逻辑顺序,增长时增加页号,结束时归还引用。

图 3-3 同样的 A5、C3:按最大长度连续预留,与按页领取容量的对照。

图 3-3 同样的 A5、C3:按最大长度连续预留,与按页领取容量的对照。

每页 4 槽是教学配置,固定 nano 版本默认 256。这个对照采用“按最大长度预留”的连续基线;连续存储也可以设计动态扩容。分页的主要收益是减少过量预留并简化空闲空间组合,同时保留页尾未用容量。每个有效位置所需的 KV 数据量由模型决定。

先算一个 token 的成本。对各层形状相同、K/V 精度相同的标准 MHA/GQA/MQA,令 L 为层数,H_kv 为实际 KV Head 数,d_head 为每头宽度,b_kv 为每个元素字节数:

CKV=2LHkvdheadbkv(bytes/token)\displaystyle C_{\mathrm{KV}}=2L H_{\mathrm{kv}}d_{\mathrm{head}}b_{\mathrm{kv}}\quad(\mathrm{bytes/token})

Qwen3-0.6B 单卡 BF16 中,L=28、H_kv=8、d_head=128、b_kv=2,所以每个位置跨全部层占 114688 bytes,即 112 KiB。设同时保存 KV 的请求数为 B,请求 i 的有效长度为 S_i:

MKV,logical=CKV∑i=1BSi;Si=S ⇒ MKV,logical=2LHkvdheadbkvBS\displaystyle M_{\mathrm{KV,logical}}=C_{\mathrm{KV}}\sum_{i=1}^{B}S_i;\qquad S_i=S\ \Rightarrow\ M_{\mathrm{KV,logical}}=2L H_{\mathrm{kv}}d_{\mathrm{head}}b_{\mathrm{kv}}BS

这是没有前缀共享时的有效数据量。分页分配量还要计入尾页空槽和为已知输入预留的位置,实际 GPU 驻留量则可能是整个预分配 KV 池。三种口径分别回答“数据有多少”“请求占住多少容量”“显存实际分配了多少”。

3.4 页表怎样驱动 GPU:写入、读取与回收

先看 GPU 里真实保存的对象。Runner 在启动时建立一个持久 CUDA 张量;单卡 Qwen3-0.6B 的形状为 [2,28,N,256,8,128],依次表示 K/V、28 层、N 个物理页、每页 256 个 token 槽、8 个 KV heads、每头 128 个分量。每层 Attention 绑定自己的 k_cache 和 v_cache 视图,各为 [N,256,8,128]。CPU 的 BlockManager 管页号和引用,浮点数值由 GPU 算子读写。

一个 slot 对应一个 token 在某层的全部 KV heads。选定层、物理页和页内位置后,K 是 [8,128],V 也是 [8,128];BF16 下各 2 KiB、合计 4 KiB。K、V 放在各自的区域,用相同页号和偏移配对。同一个物理页号还用于索引其他层,但各层保存独立数值;跨层和跨 K/V 的同号片段在上述大张量中相隔存放。默认每页 256 槽时,一个页号覆盖全部层的容量合计为 28 MiB。

把上一节的例子落到地址上。仅为画清页边界,下面把每页槽数 P 取为 4;固定源码的实际默认值仍为 256。用 A 的页表 [7,2]、C 的页表 [5] 表示第 3 轮的示意布局:A 输入 A5,开始填第二页;C 输入 C3,继续填先前 Prefill 领取的页。页号用于演示映射,实际日志中的编号由空闲队列顺序决定。B 结束后释放的页面也会回到这个队列,供后续分配使用。

图 3-4 沿 A5、C3 的当前层执行:CPU 准备地址,GPU 写入新 K/V,再按页读取有效历史。

图 3-4 沿 A5、C3 的当前层执行:CPU 准备地址,GPU 写入新 K/V,再按页读取有效历史。

3.4.1 沿 A5 走一遍:从逻辑位置到显存数值

CPU 先为 A 的逻辑页 0、1 建立映射 [7,2]。A5 的零基位置 p=4,因此它落在逻辑页 1、页内偏移 0;查表得到物理页 2。Runner 将“页号+偏移”展平为 token 槽索引 8。C3 的位置为 2,落在物理页 5、偏移 2,展平后为 22。

g=⌊pP⌋,b=block_table[g],o=p mod P,s=bP+o\displaystyle g=\left\lfloor\frac{p}{P}\right\rfloor,\qquad b=\mathrm{block\_table}[g],\qquad o=p\bmod P,\qquad s=bP+o

g 是逻辑页号,b 是池内物理页号,o 是页内 slot 偏移,s 是展平后的 token 槽索引。页表保存的是这些软件页号,GPU kernel 将它们换算成张量偏移。它们属于普通 GPU 内存分配内部的寻址信息。

本轮元数据

值

告诉 GPU 什么

input_ids / positions

[A5,C3] / [4,2]

本轮两条新输入,以及各自在原请求中的位置

block_tables

[[7,2],[5,-1]]

每条请求的逻辑页依次位于哪些物理页;-1 补齐表宽

slot_mapping

[8,22]

本轮第 0、1 行的新 K/V 分别写到哪一个 token 槽

context_lens

[5,3]

本轮写入后,A 有 5 个、C 有 3 个有效 KV 位置

这些元数据先来自 CPU 的 Sequence 和页表,Runner 再准备锁页主机缓冲并拷入 CUDA 张量。一个属于 CPU 的 Python 页号列表,和传给算子的 GPU block_tables 张量,是同一映射在两个执行侧的表示。真正的 K/V 数值始终由 GPU 计算。

写入:当前层的新 K/V 进入 HBM 中的缓存池。本轮两行先经过当前层的 Norm、QKV 投影及相应的 Q/K Norm、RoPE,得到 Q=[2,16,128],新 K、V 各为 [2,8,128]。Attention.forward 调用 store_kvcache;Triton 写缓存 kernel 读取 slot_mapping,把第 0 行写到槽 8、第 1 行写到槽 22。存入的 K 已包含该层使用的位置变换,Q 则继续用于本轮 Attention。

在某层 K 视图中,每个 token 槽连续存放 8×128=1024 个元素。A5 的起点是 8×1024 个元素;BF16 下,相对本层 k_cache 起点偏移 16 KiB。V 使用自己的 v_cache 起点加上同样的偏移。于是一个抽象的“slot 8”,最终变成了 GPU store 指令访问的具体内存位置。

读取:页表确定去哪儿,长度确定读多少。Decode 调用 flash_attn_with_kvcache,传入当前 Q、该层 K/V 池、block_tables 和 context_lens。A 的 query 依次读取页 7 的四个槽和页 2 的第一个槽;C 读取页 5 的前三个槽。页 2、页 5 其余槽可能留有未初始化或旧内容,有效长度会把它们排除在本次 Attention 的数学范围之外。

在 GPU 内核里,所需 K/V 以数据小块送往 SM;硬件缓存和片上缓冲服务于这些访问。对 A 的一个 Q head,先用各位置的 K 计算分数,对五个有效位置归一化,再用权重加权相应的 V。GQA 中两个 Q heads 可以读取同一个 KV head,但各自计算权重和输出。

zj=qA5kAjT128,wj=ezj∑r=15ezr,aA5=∑j=15wjvAj\displaystyle z_j=\frac{q_{A5}k_{Aj}^{\mathsf T}}{\sqrt{128}},\qquad w_j=\frac{e^{z_j}}{\sum_{r=1}^{5}e^{z_r}},\qquad a_{A5}=\sum_{j=1}^{5}w_jv_{Aj}

页 7 与页 2 共同构成这次归一化范围。高效内核分块维护全局归一化和加权累加状态,最终得到每个头的 a。输出投影、残差和 MLP 再产生两行新的 hidden state,进入下一层;下一层使用自己的 K/V 数值与同一份位置映射,重复上述过程。

Prefill 的读路径要分两种情况。正常运行中,新 K/V 都会写进已建立的 KV 池;Attention 本次从哪里读取,则由是否已有缓存决定:

阶段

当前层生成什么

Attention 本次读取什么

首次、无前缀命中的 Prefill

所有本轮新位置的 Q/K/V

直接使用本轮新算的紧凑 K/V 张量,并按请求边界与因果掩码计算

后续 Prefill chunk,或命中前缀

仅本轮未缓存位置的 Q/K/V

经页表读取 KV 池,包含已有缓存与本轮刚写入的部分

普通 Decode

每条被选中请求一个位置的 Q/K/V

经页表和有效长度读取 KV 池,包含当前位置

固定实现的两条 Prefill 路径都调用 flash_attn_varlen_func,有旧缓存时将页表和池视图交给后端。首次 Prefill 为后续轮次留下缓存,后续 chunk 和 Decode 再复用它们。源代码入口是 model_runner.py 的 prepare_prefill / prepare_decode,以及 layers/attention.py 的 store_kvcache 和 forward。

3.4.2 写满、换成员、结束:页如何继续使用

把 A 的轨迹单独展开:Prefill 写入 A1~A3;Decode A4 填满页 7;下一次真正安排 Decode A5 时,BlockManager 为它增加页 2。A 因 C 的 Prefill 暂停一轮时,页 7 和其中的四个位置仍保留。下图以 t1~t5 表示同一条请求的五个位置,对应这里的 A1~A5。

图 3-5 单请求的尾页增长:填完旧页,再为下一个新位置领取新页。

图 3-5 单请求的尾页增长:填完旧页,再为下一个新位置领取新页。

回收首先改变使用权。B 结束后,CPU 减少其页引用;引用归零的页进入可重新分配集合。GPU KV 池仍是启动时那块张量,B 的旧数值可以暂时留在里面。后续请求领取回收页后,其 Prefill 覆盖对应位置,新长度决定后续读取范围。写入完成并满足执行依赖之后,后续计算才使用这些新值。

组批与页布局因此可以独立变化。A、B 变成 A、C 时,Runner 更新当前工作列表和元数据。A 的历史不必为“重新组批”整体搬动,C 则使用自己的页表。一个独占尾页的空槽留给所属请求增长;共享完整前缀时由引用计数保护共同页,见第 3.5 节。

固定版本接纳 Prefill 时按整个已知 prompt 分配页面,所以分块 Prefill 中可能已领取尚未写满、甚至尚未写入的页面。以 T_page 表示每页槽数、A_i 表示为请求 i 需要覆盖的位置数、N_pool 表示池的总页数,结合第 3.3 节的 C_KV,可计算没有前缀共享时的容量:

Cpage=CKVTpage,Massigned=Cpage∑i⌈AiTpage⌉,Mpool=CpageNpool\displaystyle C_{\mathrm{page}}=C_{\mathrm{KV}}T_{\mathrm{page}},\qquad M_{\mathrm{assigned}}=C_{\mathrm{page}}\sum_i\left\lceil\frac{A_i}{T_{\mathrm{page}}}\right\rceil,\qquad M_{\mathrm{pool}}=C_{\mathrm{page}}N_{\mathrm{pool}}

A_i 包含为已知但尚未计算的位置预留的容量;有效 KV 长度 S_i 只统计已经计算好的前缀。请求增减改变池内已分配页数,预分配池的总显存通常保持不变。实际默认每页 256 槽,本模型每页号跨层合计 28 MiB;仅有 3 个有效位置时,数据量是 336 KiB,但已领取容量是一整页。

分页主要改善容量管理。页表让内核直接消费分散页面,页内规则布局仍有利于合并访存,省去为整理连续历史而额外进行的整段拷贝。相对于本来已经连续存储的 K/V,页表也增加寻址工作;有效历史的数据量由模型和上下文决定。下一章的 FlashAttention 进一步减少的是内核中间结果的显存往返。

3.5 Prefix Cache:相同前缀怎样复用,下一轮对话怎样进入

上一节解决了“给请求分空间”和“沿页表读数据”。如果新请求恰好需要一段已经计算过的相同前缀,还可以直接引用现成的 K/V。因果 Attention 保证:在 token、位置和模型计算语义相同的前提下,这段前缀的逐层结果相同。Prefix Cache 用前缀索引寻找这些完整块,让新请求的页表指向已有物理页。

用实际页大小 256 做一个容量例子:P、Q 各有 620 个输入,前 512 个相同且对应缓存仍有效。它们可以共享前两页,各自保留一张尾页,共占四个物理页;两条请求的未缓存后缀各自 Prefill,生成时继续填自己的尾页。共享页中的 K/V 可被多个 query 读取,每个 query 仍计算自己的权重与输出。

引用计数管理使用权,前缀索引管理复用资格。P 结束时减少引用,Q 仍持有的页继续受保护;引用归零意味着该页可重新分配。若旧内容和前缀索引尚有效,后来的匹配请求也可能重新引用它;被覆盖或驱逐后,旧前缀就要重新计算。固定 nano 使用匹配的完整块,并保留请求最后一逻辑块供本次计算。更完整的写时复制属于后续扩展。

这也解释多轮对话。Session 是应用层的一段对话,request 是引擎完成一次回答的任务,KV Cache 则是可复用的计算结果。常见请求式聊天接口在每轮提交新 request,由应用携带系统提示、旧问题、旧回答和新问题,再套用 chat template。

图 3-6 多轮对话:旧回答变成新请求的已知输入;缓存复用跨越请求边界。

图 3-6 多轮对话:旧回答变成新请求的已知输入;缓存复用跨越请求边界。

第一轮问“科比是谁?”,Prefill 已知输入后逐 token 生成,遇到 EOS 或输出上限结束。第二轮问“他拿过几次冠军?”,新请求携带“旧问题+旧回答+新问题”。匹配且仍存在的历史 KV 直接复用,未缓存后缀先做 Prefill,再 Decode 新回答。未缓存部分还可能包含模板分隔符、历史尾页和上轮最后一个尚未生成 KV 的输出 token。

nano-vLLM 每次 add_request 创建新 Sequence。vLLM 常见的 /v1/chat/completions 用法同样由应用每轮提交新请求;另一些服务提供显式有状态会话,接口和保留策略会不同。判断复用时,关注实际输入前缀和缓存可用性,而不仅是会话 ID。

Chunked Prefill 则发生在同一个 request 内:一个已知长输入分几轮计算,前一块的 KV 留给后一块读取。多轮对话改变请求边界,分块 Prefill 改变一条请求的计算批次,二者都可以利用已经存在的 KV。

源码入口是 engine/block_manager.py 的分配、前缀匹配与引用释放;边界实验见附录 B.6–B.7。工业实现可继续参考 vLLM 官方的 Automatic Prefix Caching 与 前缀缓存设计。

3.6 调度预算:选谁、算多少、空间够不够

现在可以把调度看成一个有资源约束的循环:waiting 保存尚需 Prefill 的请求,running 保存后续待生成的请求;Scheduler 选择本轮成员与新算位置,BlockManager 检查缓存容量,Runner 再把选择转换成 GPU 输入。约束的单位不同,先分清各自回答的问题。

限制

单位

固定版本中的作用

max_num_seqs

请求数 / 单批

本轮最多选择多少条 Sequence;运行队列总长度可以更大

max_num_batched_tokens

新 token 数 / Prefill 批

限制这一轮新计算的 Prefill 位置数

KV 池的可用页

物理页数

能否接纳新请求,或为已有请求跨页增长分配空间

context window / 引擎长度上限

单请求的累计 token 数

限制该请求可容纳的已知输入与后续生成长度

用一个长输入变体看 chunk:A 有 700 个输入,B 有 32 个,按 A、B 顺序入队;每轮 Prefill 预算 512,最多两条请求,缓存充足且没有前缀命中。第一轮只能新算 A 的前 512 个位置;第二轮再算 A 剩下 188 个和 B 的 32 个。A 的前 512 个 K/V 留在 GPU,第二轮的新 query 会读取它们。

轮次

GPU 新算位置

Attention 覆盖的 KV 长度

输出处理

1:Prefill

A 的 512 行

A:512

A 输入未处理完,只推进缓存,丢弃中间候选

2:Prefill

A 的 188 行+B 的 32 行

A:700;B:32

A、B 各接受首个输出

3:Decode

A、B 各 1 行

A:701;B:33

各接受下一个输出

第二轮的 220 行怎样落到算子参数?input_ids 只包含本轮新位置,positions 分别为 A 的 512~699、B 的 0~31;cu_seqlens_q=[0,188,220] 划分 query 行,cu_seqlens_k=[0,700,732] 表示每条请求完整可读的 KV 长度。后者的 732 是累计长度,不要求把这 732 个缓存位置搬成一个连续张量;block_tables 仍把访问导向各自的物理页。

因此,“本轮只算 188 个新位置”和“这些 query 可读取 700 个位置”可以同时成立。Runner 同时传入写地址、读页表和长度边界,才能让分块计算保持完整的因果语义。固定版本在首次准入时仍按整个 prompt 分配页,chunk 首先限制的是本轮计算量。

预算之外还需要调度目标。Prefill 优先有利于尽早处理等待中的输入,但连续长 Prefill 会延长已有请求的 ITL;优先 Decode 则可能推迟新请求的 TTFT。跨轮交替、按请求轮转和限制单轮工作量分别改善不同问题,单纯按轮公平也不等于按实际耗时公平。第 5.4 节先用确定性状态流验证策略,再用固定到达负载比较延迟与吞吐。

3.7 空间不足时怎样恢复,如何理解整体收益

空间管理也有完整生命周期:准入时领取页,逐层写入有效 KV,Decode 时继续填槽或跨页;结束时释放引用。如果请求增长时无页可用,引擎还需要选择等待、拒绝或抢占。固定 nano 的 Decode 分支采用重算式抢占:从运行队列尾部选择请求,暂停并释放其页引用,保留 CPU 上的 token ID,放回 waiting。

图 3-7 KV 空间不足时暂停请求;恢复时用保留的 token ID 重建未命中的缓存。

图 3-7 KV 空间不足时暂停请求;恢复时用保留的 token ID 重建未命中的缓存。

恢复请求再次进入 Prefill,可复用仍有效的前缀,其余部分重新计算。此前生成并接受的 token 也已经成为它的已知序列,所以能够作为输入重建历史 K/V。释放某条请求的引用能腾出多少物理页,要看这些页是否仍被其他请求共享;GPU 的整个 KV 池始终作为常驻空间管理。

一轮 GPU 执行结束后,postprocess 把本轮已计算的位置计入 num_cached_tokens;完整已知输入处理完才接受候选输出,然后检查停止条件、回收引用,并清零 num_scheduled_tokens。这个回写边界让下一轮知道哪些输入已算完、哪个新 ID 尚待输入、哪些容量可以再分配。

至此,一次 step 的闭环就是:CPU 选工作和分空间,Runner 准备本轮张量与映射,GPU 逐层写新 KV、读有效历史并选出候选,CPU 回写进度与使用权。Batching 扩大一次计算,Continuous Batching 持续补充工作,分页与前缀复用为更多有效工作提供容量或减少重复计算;实际收益最终落实到吞吐、TTFT 和 ITL。

4. 怎样进一步加速,以及用上多张卡

前两章已经把请求、batch 和 KV 地址接到 GPU 上。现在沿同一执行链看成本:容量够不够、HBM 与 SM 之间搬多少数据、CPU 提交花多久,以及多卡多出哪些通信。优化技术的作用,可以放回这些具体位置来理解。

4.1 先建立成本账本:容量、带宽与延迟

模型启动时,权重加载到 GPU,通常长期驻留;请求到来后,逐层生成的 K/V 需要跨轮保留,当前激活与算子工作区则随执行变化。显存容量决定数据能否放下,计算和带宽利用率描述设备是否忙碌;请求容量要把这些长期与临时占用一起计入。

组成

保存什么

主要由什么决定

模型权重

Embedding、投影、MLP、Norm、LM Head 等

参数量、权重 dtype、量化附加数据和实际分片

KV Cache

各层已处理位置的 K、V

KV Head 数、层数、上下文、并发、dtype 与池大小

当前激活与工作区

hidden state、Q、Attention/MLP 中间值、内核临时空间

本轮新位置数、算子、融合与内存复用

其他运行时占用

元数据、CUDA/NCCL 状态、图或分配器未计入前项的空间

框架、执行模式及多卡配置

用于容量规划的总显存公式:先按不重叠的类别估算同一时刻的驻留量,再为峰值误差留余量。常见的预分配 KV 池场景可写成:

单位统一为 bytes;M_work_peak 表示激活与算子工作区同时存活的峰值。

Mpeak≈Mweights+MKV,pool+Mwork,peak+MotherMweights≈P bw+Mweight,extraMplan=Mpeak+Mmargin≤MGPU,budget\displaystyle \begin{aligned}M_{\mathrm{peak}}&\approx M_{\mathrm{weights}}+M_{\mathrm{KV,pool}}+M_{\mathrm{work,peak}}+M_{\mathrm{other}}\\[8pt]M_{\mathrm{weights}}&\approx P\,b_w+M_{\mathrm{weight,extra}}\\[8pt]M_{\mathrm{plan}}&=M_{\mathrm{peak}}+M_{\mathrm{margin}}\le M_{\mathrm{GPU,budget}}\end{aligned}

P 是去重后的参数个数,b_w 是每个权重元素的字节数;共享的 Embedding/LM Head 只算一次。BF16 估算中可先忽略量化附加数据 M_weight_extra。M_GPU_budget 是给本引擎的可用显存,M_margin 是安全余量。缓存池指预先为 KV 留出的整片空间;没有固定池时,按峰值实际分配的 KV 容量计算。

标称 0.6B 模型按 BF16 每参数 2 bytes 粗算,权重约 1.2 GB≈1.12 GiB,适合做初步预算;精确值按实际参数量和存储格式计算。GiB 按 1024³ bytes,GB 按 1000³ bytes。权重和 KV 可以各自采用不同精度,计算时分别使用对应的元素字节数。

激活与工作区按同时存活的峰值计算。普通推理只执行前向计算,许多临时缓冲可以跨层复用。公式先帮助建立预算,实际还需测量;分配器统计、执行模式和安全余量的核对方法放在附录 B.8.3。

再看每轮的时间花在哪里。第 2.1.1 节给出了 HBM、L2、SM 的位置,第 3.4 节已经追踪过 A5 的缓存地址。现在固定一轮 [A5,C3]:CPU 准备两行输入和映射;GPU 的投影、MLP 读取权重,Attention 读取各自历史,kernel 之间通过依赖衔接,最后将候选 ID 交回 CPU。性能优化要先定位这条链路中的主要成本。

观察点

值得检查什么

可能对应的改进

CPU 准备、GPU 提交之间的间隙

输入打包、Python 调用、同步等待与 kernel launch 的时间

减少热路径工作,验证 CUDA Graph 是否覆盖实际形状

QKV 与 MLP 矩阵乘

矩阵行数、计算时间、权重搬运、Tensor Core 利用情况

合批、合适的内核与精度;结合延迟约束选择 batch

Attention

上下文长度、KV 流量、中间张量落显存、页表访问

分页感知内核、FlashAttention、适合模型的缓存精度

多卡归约与同步

通信耗时、消息大小、拓扑和各 rank 的等待

重新比较 TP 与独立副本,检查通信与计算安排

小 batch Decode 每请求只新算一行,权重读取和提交开销可能突出;历史很长时,KV 读取可能成为主要成本;Prefill 的许多新位置更容易形成较大的矩阵乘。这些是定位线索,实际瓶颈由模型、输入形状、内核和硬件共同决定。先用 profiler 的 CPU/GPU 时间线找到长耗时或空隙,再针对热点内核检查计算与访存指标。

观察硬件缓存时,也要保持两个层次:软件 KV Cache 决定跨轮复用哪些计算结果,L1/L2 决定一次具体内存访问能否命中片上数据。保留 KV 可以省去旧位置的投影、MLP 等重算;本轮 Attention 仍要读取其所需的 K/V,读取成本继续受上下文和硬件缓存行为影响。

用户看到的是“请求发出 → 网络与排队 → 输入处理与 Prefill → 首 token → 多轮 Decode → 结束”。权重加载通常在启动时完成;如果测到冷启动,应单独说明。不同指标回答不同问题:

指标

怎样理解

回答什么问题

TTFT:首 token 延迟

从提交请求到收到第一个输出

多久开始看到回答;客户端计时还包括网络和排队

ITL:输出间隔

同一请求相邻输出 token 的到达时间差

开始回答之后,输出是否持续、平滑

输出吞吐

同一观察区间内,输出 token 总数除以区间时长

描述系统总体产出,需结合单请求延迟一起观察

增加 batch 可能提高总吞吐,也可能延长某条请求的等待,因此要同时观察吞吐和延迟。离线 generate() 在整批完成后返回,适合测整批耗时;客户端 TTFT 和 ITL 则需要记录流式输出的时间点。具体计时示例、平均输出间隔 TPOT 和测量边界见附录 B.8.3。

这个成本账本帮助选择下一步优化:减少重复计算、减少数据搬运、摊薄提交开销,或将工作分配到更多 GPU。显存估算与计时练习见附录 B.1.5。

4.2 FlashAttention:减少 Attention 中间结果的显存往返

第 3 章解决了 K/V 放在哪里,FlashAttention 进一步优化“取出后怎样计算”。朴素 Prefill Attention 先生成 QK 转置的分数矩阵,Softmax 后形成概率矩阵,再乘 V。对长度 S 的一个请求、一个头,这些中间矩阵各有 S×S 个元素;完整写到 HBM 后再读出,会增加临时空间和数据流量。

FlashAttention 把这条计算链组织成 tile。内核取出一小块 Q 和相应 K/V,利用 SM 内的寄存器、共享内存等资源计算局部分数与输出贡献,然后继续处理后面的 K/V 小块。它保留每个 query 的当前最大分数、指数和与加权累加值,遇到新块时按新的最大值重新缩放,最终得到覆盖整个有效上下文的归一化结果。

回到 A5:物理页 7 和页 2 中的五个有效位置,共同决定这个 query 的 Softmax 分母与最终输出。内核可以分几次读取它们,同时维护累计状态。相同思想用于长 Prefill,可以避免把完整分数、概率矩阵落到显存。数学目标仍是缩放点积 Attention,浮点执行顺序改变时数值可能有小差异。

KV page 与计算 tile 各自服务于不同目的。Page 是缓存池的分配和寻址单位;tile 是内核为线程协作、寄存器和共享内存容量选择的计算分块,二者大小无须相同。分页感知的后端先沿 block_table 找到数据,再用高效内核消费这些数据。本文固定 nano 在 Attention 层调用成熟后端完成这两部分。

因此应分别验收两类收益:分页是否减少过量预留、提高可容纳请求数;FlashAttention 是否减少中间结果的显存往返、降低实际执行时间。最终吞吐仍由完整请求链路决定,单个技术名称无法替代测量。

4.3 CUDA Graph 与 pinned memory:减少执行外围开销

CUDA Graph 复用执行安排。普通 eager 模式每轮由 CPU 逐个提交 GPU 操作。Graph 在初始化时捕获可复用的操作与依赖,后续把新 ID、位置、有效长度和页表填入固定地址缓冲区,再重放。执行安排保持稳定,实际输入和 KV 内容持续更新。

请求数变化时,用已捕获的 batch 桶承接实际请求,并隔离填充行。例如实际有 9 条请求,可以使用容量为 16 的桶;真实行返回结果,填充行的 context_lens 为 0、slot_mapping 为 -1,避免写入真实请求的 KV。这里的填充是 Graph 执行形状的处理,与 Prefill 的请求边界是不同问题。

本提交捕获 Embedding、Decoder Blocks 和最终 Norm,LM Head 与 Sampler 在图外;Prefill 使用 eager 路径。桶覆盖范围、缓冲地址稳定性和填充行隔离都属于正确性边界。第 5.5 节与附录 B.4–B.5 提供实际路径检查,不能仅凭开关名称推断某次运行必然命中了 Graph。

Pinned memory 提供异步传输所需的主机缓冲条件。它是锁页 CPU 内存,nano 用来传输本轮 ID、位置和缓存元数据,并使用 non_blocking 提交。跨轮 KV 数值留在 GPU。异步提交能为传输和计算重叠创造条件;是否实际重叠,还取决于流、依赖和负载,需要测量。

把它接回连续组批:A、B 变成 A、C 后,Graph 可以重放同一个容量桶,但 Runner 仍要更新 ID、positions、slot_mapping、页表和有效长度。复用的是执行安排,数据归属仍由本轮元数据决定。一次 CPU 异步提交也只表示主机可以较早继续运行;拷贝与依赖它的计算是否重叠,需要看实际 stream、依赖和时间线。

实验按第 5.5 节比较同一负载下的 eager 与 Graph,并把逐轮调试日志从性能测量中移除。先核对结果和缓存隔离,再检查启动开销是否确实减少;如果热点仍在 Attention 的历史读取,执行图本身不会消除这部分 KV 流量。

4.4 多张卡先分清两种分工

图 4-1 从单请求单卡,到多请求单卡,再到多卡分工。

图 4-1 从单请求单卡,到多请求单卡,再到多卡分工。

方式

每张卡保存什么

请求怎么走

主要限制

独立副本

完整模型、自己的 KV 池与队列

A 去副本 0,B 去副本 1

每份模型仍须放得下,需要路由与隔离

Tensor Parallel(TP)

同一个模型的权重和 KV 分片

各卡协作处理同一批 A、B

层内需要交换和归约结果

独立副本主要扩大请求吞吐,不会自动让一个请求由两卡各算一半。nano 已有 TP;第 5.6 节在已验证的单卡服务上增加独立副本与路由,再与 TP 对照。TP=2 中 rank 0、rank 1 属于同一通信组;两个独立 TP=1 引擎则各有自己的 rank 0。

单卡 Engine 和 ModelRunner 在同一进程;本实现 TP 通常每张 GPU 对应一个 rank 进程,rank 0 同时负责协调与本卡执行,其他 rank 协作计算同一批请求的模型分片。独立副本则各自拥有引擎、队列、模型和 KV 池,由外部路由把不同请求分过去。

4.5 Tensor Parallel:分开算什么,通信合并什么

用 Y=XW 理解两种基本切法。Column Parallel 把 W 按输出特征拆开,两卡读完整 X,各算不同输出坐标。Row Parallel 把输入特征与 W 的对应行拆开,各卡算出的都是同一输出的部分贡献,最终通过逐元素相加得到完整输出。

图 4-2 Column Parallel 得到不同输出坐标;Row Parallel 合并同一输出的部分和。

图 4-2 Column Parallel 得到不同输出坐标;Row Parallel 合并同一输出的部分和。

放入 Qwen3-0.6B,TP=2 时每卡负责 8 个 Q heads、4 个 KV heads,每头仍为 128 维。各卡都处理同样的 token 位置和同样长的历史,只是 head 不同;700-token 上下文不会变成每卡 350 个位置。本地 Attention 结束后,o_proj 产生残差宽度上的部分和,再用 all-reduce 逐元素相加。MLP 的 gate/up 分输出特征,down 投影后同样需要归约。

KV 因此也随 KV heads 分片:每位置跨 28 层的总量仍为 112 KiB,在本例均匀分给两卡,每卡 56 KiB。Norm 等小参数和残差表示可以在各卡复制,容量按实际切分方式分别计算。数学公式按 XW 描述,而 PyTorch 权重存为 [out,in];读代码时先把输入、输出特征轴与参数存储轴对应起来。

Engine 在主进程调度,由 rank 0 把本轮方法名和请求元数据通知其他 worker;各 rank 再共同进入模型。CPU 的共享内存与 Event 传递任务通知,GPU 的 NCCL 集合通信交换张量计算结果,两条通路各自承担一类协作。

图 4-3 CPU 同步任务,GPU 合并张量;历史 KV 留在负责的卡上。

图 4-3 CPU 同步任务,GPU 合并张量;历史 KV 留在负责的卡上。

本地 Q/K/V 和 Attention 直接使用本卡 head 的缓存。Embedding、o_proj 和 MLP 的 down 投影等位置通过 all-reduce 合并贡献;LM Head 计算各自的词表分数,再 gather 到 rank 0 拼词表、统一采样。统一采样后,下一轮所有 rank 协同处理同一个已选 token。

多卡也按实际存储计算。若 TP 均匀切分 KV Head,单卡公式把 H_kv 换成 H_kv,local;本模型 TP=2 时每卡 4 个 KV Head,即 56 KiB/token、14 MiB/块。单卡容量由分片与复制共同决定:部分张量在各卡复制,KV Head 太少时也可能复制。各层配置不同时应逐层求和;MLA、滑动窗口等结构按实际缓存张量另算。

TP 分担容量和乘法,却增加同步和通信,尤其小模型、小 batch 的 Decode 不一定更快。实验先比较“单卡能否放下”,再比较相同工作量下的吞吐和延迟。后续路线从单卡合批开始,自己增加独立副本,再与已有 TP 对照;不把更多并行方式当作必须一次学完的术语清单。

选择并行方式时先回答容量问题:单卡能否容纳模型、KV 和工作区?再回答效率问题:当前负载更适合多个独立副本,还是多卡协同执行一份模型?第 5 章用相同请求集合、相同两张 A800 比较两种方案,同时记录初始化之外的吞吐、延迟和通信成本。

5. 动手改造:从跑通到可测量的推理服务

本章把 nano-vLLM 变成一个逐步演进的工程项目:先建立可复现基线和正确性护栏,再加可观测入口,改调度、分析 GPU 执行,扩到两张卡,最后验证取消、过载与资源回收。每一步沿用前一步的代码、请求格式和日志,形成“观察现象—提出假设—修改—验证—测量—解释”的闭环。

图 5-1 同一个项目的七个阶段:从单卡基线到可验证的推理服务

图 5-1 同一个项目的七个阶段:从单卡基线到可验证的推理服务

每节先给可运行的检查点,再指出具体文件与函数,最后留一个逐渐减少提示的小改动。合批、分页、前缀复用、Graph 和 TP 从已有源码中观察;服务入口、跨轮调度与实例隔离由配套实现和补丁演练。混合 Prefill/Decode 批次保留为进阶支线。

配套实验包:nanovllm-inference-lab-v6.zip。包内含本章、vLLM 体验和附录 B 的离线手册,以及运行脚本、数值对照工具和源码补丁;章节与在线教程一致。上游仓库与模型权重按 Setup 单独下载。包内 VALIDATION.md 区分已完成的本地检查和待实验机完成的 GPU 验证。

附件下载:nanovllm-inference-lab-v6.zip

nanovllm-inference-lab-v6.zip

5.1 Setup:固定环境、源码、模型与运行入口

5.1.1 固定实验对象:机器、代码、模型和依赖各是什么

参考硬件与系统为 Linux x86_64、Python 3.11.2(GCC 12.2.0)、CUDA Toolkit 12.9.86、驱动 535.261.03 和 NVIDIA A800-SXM4-80GB。本实验以系统已能正常运行 CUDA 为前提,Python 依赖安装在独立虚拟环境中。先使用一张已分配的空闲 GPU 完成基线,再使用同机两张卡进行 TP 对照。

对象

本章固定值

用途

nano-vLLM 仓库

GeeeekExplorer/nano-vllm

上游引擎源码,不使用其他 fork

源码提交

bb823b3e06983d71485a8e1f23715ebd87d98ef8(包版本 0.2.0)

所有断点、补丁和预期调度以此为准

模型

Qwen/Qwen3-0.6B

BF16、28 层、1024 hidden、16 Q heads / 8 KV heads

模型 revision

c1899de289a04d12100db370d81485cdf75e47ca

同时固定权重、config、Tokenizer 和 chat template

唯一实验附件

nanovllm-inference-lab-v6.zip

本文脚本、操作手册、源码补丁;不含上游仓库、模型权重或虚拟环境

参考 Python 依赖

PyTorch 2.8.0+cu129 / Triton 3.4.0 / FlashAttention 2.8.3 / Transformers 4.57.1

新建实验 venv 的明确组合;GPU 运行由实验机验收

先区分三个目录:nano-vllm/ 是通过 Git 获取并修改的引擎源码;practice/、e2e/、labs/ 是 ZIP 中的服务、负载与验收入口;models/Qwen3-0.6B/ 是单独下载的模型资产。CHECKPOINTS.md 给出各阶段命令、预期证据和失败后的检查位置。

5.1.2 下载哪一个 ZIP,解压在哪里,先打开什么

下载 nanovllm-inference-lab-v6.zip。包内包含实验手册、quick_walk.py、原理与 GPU 实验、结果分析工具、源码日志补丁和教学小模型;上游源码与模型权重按后面的 Setup 单独获取。

若浏览器和 GPU 在不同机器,在下载附件的电脑执行下面两行,把 YOUR_USER@YOUR_GPU_HOST 换为你的实际 SSH 地址。若文件已在实验机上,跳过传输。

scp "$HOME/Downloads/nanovllm-inference-lab-v6.zip" YOUR_USER@YOUR_GPU_HOST:~/
ssh YOUR_USER@YOUR_GPU_HOST

以下命令都在 GPU 实验机执行。ZIP 自带 nanovllm-inference-lab/ 顶层目录,直接解压到 $HOME。下面的检查要求目标目录尚不存在,以保护已有实验修改;已经解压过时,进入原目录继续。

test ! -e "$HOME/nanovllm-inference-lab" && unzip "$HOME/nanovllm-inference-lab-v6.zip" -d "$HOME"
cd "$HOME/nanovllm-inference-lab"
ls README.md 实验手册.html env.sh e2e labs patches
sha256sum -c SHA256SUMS
source env.sh

先阅读 README.md,再用浏览器打开“实验手册.html”查看详细步骤;实验命令在 GPU 机器的终端执行。首次解压后运行完整性校验,结果应全部为 OK。后续修改源码或实验文件时,记录改动即可。

nanovllm-inference-lab/
├── README.md / 实验手册.html     总入口与完整步骤
├── CHECKPOINTS.md              各阶段命令与验收
├── env.sh                      LAB_ROOT / NANO_DIR / MODEL_DIR
├── practice/                   Worker、HTTP、负载与逐事件报告
├── e2e/                        基线、源码、数值和调度验收
├── labs/、pedagogy/             按需深入的原理与 GPU 实验
├── patches/                    日志、Graph、调度与实例隔离补丁
├── workloads/                  固定到达轨迹
├── notes/、runs/                你的观察、原始日志与报告
├── validation/                 发布时的本地验证与范围
├── nano-vllm/                  Setup 中 git clone 创建
├── models/Qwen3-0.6B/          Setup 中下载
└── .venv-nano/                 实验虚拟环境

5.1.3 Clone 固定提交,建立自己的修改分支

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
git clone https://github.com/GeeeekExplorer/nano-vllm.git "$NANO_DIR"
git -C "$NANO_DIR" checkout --detach "$NANO_COMMIT"
git -C "$NANO_DIR" switch -c lab/a800-walkthrough
git -C "$NANO_DIR" rev-parse HEAD
git -C "$NANO_DIR" status --short
python e2e/source_walk.py verify --repo "$NANO_DIR"

rev-parse 应输出表中的完整提交号,status --short 应为空,最后输出 SOURCE PASS。clone 仅在首次创建仓库时执行,后续始终在这个固定提交的学习分支上工作,保持版本可复现。本章 Runner 直接创建 Qwen3ForCausalLM,模型资产也固定使用配套的 Qwen3-0.6B。

5.1.4 使用现有 Python 3.11.2,新建一个实验虚拟环境

以下使用 python 表示 Python 3.11.2。先核对 python --version,命令名不同时使用对应解释器的完整路径创建 .venv-nano。推荐按表中的依赖版本进行独立安装;已有可工作 Torch/Triton/FlashAttention 组合时,可用 python -m venv --system-site-packages .venv-nano 复用它们,并跳过对应安装行。两条路径选择其一,记录实际版本。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
python --version
python -m venv .venv-nano
source .venv-nano/bin/activate
python -m pip install pip==25.2
python -m pip install -r requirements-nano.txt
python -m pip install torch==2.8.0 --index-url https://download.pytorch.org/whl/cu129
python -m pip install triton==3.4.0
TORCH_CUDA_ARCH_LIST="8.0" MAX_JOBS=4 NVCC_THREADS=2 \
  python -m pip install flash-attn==2.8.3 --no-build-isolation --no-deps
python -m pip install --no-deps --no-build-isolation -e "$NANO_DIR"
python -m pip check
python -m pip freeze > runs/environment.txt
python -c 'import nanovllm, torch, flash_attn, triton, transformers; print(nanovllm.__file__); print(torch.__version__, torch.version.cuda, flash_attn.__version__, triton.__version__, transformers.__version__)'
nvidia-smi --query-gpu=index,name,uuid,memory.total,driver_version --format=csv
nvcc --version

参考新装路径应显示 torch 2.8.0+cu129、CUDA 12.9、flash_attn 2.8.3、triton 3.4.0、transformers 4.57.1;pip check 应通过。FlashAttention 缺少合适 wheel 时会进入编译,保留完整输出并等待本次安装结束。-e 表示 editable 安装:修改 nano-vllm/nanovllm/ 下的 Python 源文件后,重新启动程序即可生效。核对导入路径落在 NANO_DIR,确保实际运行的是自己修改的源码。

Setup 仅准备实验目录和 Python 依赖,系统驱动与 CUDA 保持既有配置。下面的依赖检查、小计算和生成基线用于记录本次运行的实际环境与结果。

5.1.5 下载一次完整模型:Tokenizer、配置和权重一起固定

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-nano/bin/activate
python e2e/download_model.py --model "$MODEL_DIR" --weights | tee runs/model-download.log
ls "$MODEL_DIR"
python -c 'import os; from transformers import AutoConfig, AutoTokenizer; p=os.environ["MODEL_DIR"]; c=AutoConfig.from_pretrained(p, local_files_only=True); t=AutoTokenizer.from_pretrained(p, local_files_only=True); print(c.model_type, c.hidden_size, c.num_hidden_layers, c.num_attention_heads, c.num_key_value_heads, c.head_dim, c.dtype); print(type(t).__name__, len(t))'
python e2e/final_report.py --root "$LAB_ROOT" --init-notes

下载脚本固定使用 Qwen/Qwen3-0.6B 和表中的 revision;--weights 会一并下载 safetensors 权重。目录应含 config.json、tokenizer.json、tokenizer_config.json 和模型 .safetensors。核对 model_type=qwen3、隐藏宽度 1024、28 层、16 个 Q Head、8 个 KV Head、head_dim=128、BF16,并保持下载配置中的 rope_scaling 原值。下载中断时重跑同一命令即可。

5.1.6 先跑单卡基线:明确什么才叫 setup 完成

示例用 CUDA_VISIBLE_DEVICES=0。若你被分配的是物理卡 2,把命令前缀改成 CUDA_VISIBLE_DEVICES=2;进程内部仍把这张卡称作 cuda:0。选择自己的空闲 GPU,不要占用或终止其他人的任务。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-nano/bin/activate
CUDA_VISIBLE_DEVICES=0 python e2e/doctor.py | tee runs/setup-doctor.json
CUDA_VISIBLE_DEVICES=0 python e2e/smoke.py --model "$MODEL_DIR" 2>&1 | tee runs/setup-smoke.log
CUDA_VISIBLE_DEVICES=0 python e2e/quick_walk.py --case A --trace --out runs/quick-A.json 2>&1 | tee runs/quick-A.log

三个验收分别是:doctor 输出 PASS,验证依赖导入与小 CUDA 运算;smoke 输出 PASS、一个结果、恰好两个输出 token 和正数 kv_blocks;quick A 输出 PASS、steps=3、output_counts=[3]。smoke 使用 chat template,quick A 直接传入三个合法 token ID,便于数位置。这一步检查运行链路与计数,性能在后续实验中单独测量。

这些入口默认 eager、TP=1、KV block size=256、最大上下文 1024、Prefill token budget=512。gpu_memory_utilization=0.5 用于确定 KV 池预分配预算,因此 0.6B 模型也可能占用较多显存。eager 关闭 CUDA Graph 回放,仍使用 FlashAttention 和 KV Cache。完成 5.6 实例隔离前,同机 nano 实验应顺序运行,因为本提交使用固定 localhost:2333 和共享内存名 nanovllm。TP=2 由一次引擎启动创建两个协作 rank。

5.1.7 用编辑器打开,设置断点,重新登录后怎样接着做

使用 VS Code Remote-SSH 连接 GPU 机器,然后 Open Folder 打开 $HOME/nanovllm-inference-lab;Python 解释器选 .venv-nano/bin/python。也可直接用远端 vim。先打开 e2e/quick_walk.py,再打开 nano-vllm/nanovllm/engine/llm_engine.py、scheduler.py、model_runner.py。阅读入口和引擎实现是不同文件。

source "$HOME/nanovllm-inference-lab/env.sh"
source "$LAB_ROOT/.venv-nano/bin/activate"
cd "$NANO_DIR"
CUDA_VISIBLE_DEVICES=0 python "$LAB_ROOT/e2e/quick_walk.py" --case A --debug

--debug 在模型初始化完成后进入 Python 自带 pdb,避免把 warmup 当成真实请求。此时用 b engine_module.LLMEngine.step 设置函数断点,再 c 继续;用 n 逐行执行,s 进入函数,p 查看变量,q 退出。也可直接在 IDE 里对 step、schedule、prepare_prefill、prepare_decode、postprocess 设置断点。交互调试直接连接终端;tee 仅用于非交互运行的日志收集。后面给出的行号只适用于未修改的固定提交,应用补丁后按函数名和语句定位。

关闭终端后,重新执行 source env.sh、source .venv-nano/bin/activate、cd 到需要的目录即可;不重新解压、不重新 clone、不重新下载模型。进程不会因为修改 Python 文件而热更新,修改后必须结束自己的旧进程并重新启动。

5.2 跟踪请求与 KV:建立改造前的正确性护栏

先完成四个有限案例:单请求、两请求合批、跨页增长、分块 Prefill。每次都把调度前、调度后与回写后的状态分开,最后用固定 token 轨迹比较 logits。后续改调度和服务时,重复这些案例,确认原来的请求语义保持。

5.2.1 案例 A:跟踪一条请求的三轮计算

步骤 1|先写下预测。输入 ID 列表有 3 个元素,依次记为 t1、t2、t3。预期 Prefill 选出 t4,Decode 1 输入 t4 并选出 t5,Decode 2 输入 t5 并选出 t6。输出是 3 个 token,但只有后两轮属于 Decode;t6 被选出后达到上限,不会再输入模型计算自身 KV。

步骤 2|设置两个断点。在初始 pdb 提示符输入以下命令。行号仅对应固定提交;52 行位于 schedule 已返回、模型尚未运行的位置,54 行位于 postprocess 已返回的位置。修改引擎文件后,按对应函数与语句重新定位断点。

b nanovllm/engine/llm_engine.py:52
b nanovllm/engine/llm_engine.py:54
c
pdb 命令:c 继续,n 执行当前行,s 进入函数,p 查看表达式,q 退出

在 52 行查看下面两项。为避免 warmup 导致编号偏移,认准同一个 seq_id,不假定它从 0 开始。各字段依次为请求 ID、总 token 数、已缓存位置数、本轮待算位置数、物理块表。

p is_prefill
p [(s.seq_id, s.num_tokens, s.num_cached_tokens, s.num_scheduled_tokens, list(s.block_table)) for s in seqs]
断点 52:此轮要算什么

第一轮应为 True、total=3、cached=0、scheduled=3。用 n 执行模型调用,停到 53 行时查看 token_ids:候选已经产生,但尚未执行 postprocess,所以 Sequence 的 total 仍为 3。再执行 n,到 54 行查看回写后的状态。这是区分“算出候选”和“把候选追加进请求”的最直接方法。

p [(s.seq_id, s.num_tokens, s.num_cached_tokens, s.num_completion_tokens, s.status.name, list(s.block_table)) for s in seqs]
断点 54:此轮完成后请求变成什么

轮次

模型本轮处理的位置

postprocess 后应看到什么

1:Prefill

t1、t2、t3;positions=[0,1,2]

total=4,cached=3,输出数=1,RUNNING

2:Decode

t4;positions=[3]

total=5,cached=4,输出数=2,RUNNING

3:Decode

t5;positions=[4]

total=6,输出数=3,FINISHED;缓存已释放,cached=0、blocks=[]

最后一轮模型已计算到 t5 的 KV,随后请求完成,deallocate 清除该请求的缓存计数和块表。因此 54 行看到的 cached=0 表示请求元数据已经清理;GPU 数值所在页面交回缓存池管理。total=cached+1 描述未结束的普通 Decode 请求,完成后应按清理状态解释。

步骤 3|再看 Runner 的实际输入。重跑 A,在初始 pdb 只设置下面这个断点。216 行位于 prepare_prefill/prepare_decode 返回之后,能够同时看到输入张量和已准备好的 Attention 上下文。随后按 c 在三轮之间继续。

b nanovllm/engine/model_runner.py:216
c
p input_ids.shape, positions.tolist()
from nanovllm.utils.context import get_context
p get_context().slot_mapping.tolist()
p None if get_context().context_lens is None else get_context().context_lens.tolist()
pdb:每轮输入及 KV 地址元数据

A 的 input_ids 形状依次为 [3]、[1]、[1];Decode 的 context_lens 依次为 [4]、[5],包括当前即将写入的 KV 位置。进入模型后,Qwen3-0.6B 的 Embedding 输出分别是 [3,1024]、[1,1024]、[1,1024];各层 Q 为 [T,16,128],K/V 各为 [T,8,128]。查看 Attention 时,沿新 Q、该层历史与当前 K/V、位置权重、加权求和这条链路追踪。

验收问题:为什么生成 3 个 token 只有 2 轮 Decode?为什么刚采样出的 token 比 KV Cache 多一个位置?为什么最后一轮 cached 又变成 0?能用上述断点说明这三件事,就进入 B。

图 5-2 同一条 token 序列中的已知范围、有效 KV 范围和本轮新算范围。

图 5-2 同一条 token 序列中的已知范围、有效 KV 范围和本轮新算范围。

图 5-3 候选只有被接受后才成为输出,EOS 在停止检查处生效。

图 5-3 候选只有被接受后才成为输出,EOS 在停止检查处生效。

5.2.2 案例 B:两条请求如何同批执行和退出

步骤 1|运行案例 B。输入长度分别为 3/4,输出限制分别为 2/5。启动一个新进程,沿用实验 A 的 52/54 行断点;预热之外的第一轮应该只有这两条请求,且没有前缀缓存命中。

source "$HOME/nanovllm-inference-lab/env.sh"
source "$LAB_ROOT/.venv-nano/bin/activate"
cd "$NANO_DIR"
CUDA_VISIBLE_DEVICES=0 python "$LAB_ROOT/e2e/quick_walk.py" --case B --model "$MODEL_DIR" --debug

步骤 2|看本轮批次与下一轮队列。52 行的 seqs 是本轮工作集合;54 行仍保留这份集合,所以也包含刚完成的请求。下一轮候选来自 scheduler.running 与 waiting,应同时查看这两个队列:

p [s.seq_id for s in self.scheduler.running]
p [s.seq_id for s in self.scheduler.waiting]
p len(self.scheduler.block_manager.free_block_ids)

轮次

本轮执行

回写后

1:Prefill

R1 的 3 个位置 + R2 的 4 个位置,共 7 个新位置

各生成 1 个 token;total/cached 分别为 4/3、5/4

2:Decode

R1、R2 各算 1 个新位置

R1 输出达到 2,退出 running 并释放其块;R2 输出为 2,继续

3、4:Decode

只有 R2,每轮算 1 个位置

R2 输出数依次为 3、4

5:Decode

只有 R2,算 1 个位置

R2 输出达到 5;waiting 与 running 都为空

这个案例中,两条请求各占一个物理 KV 块,前缀各自独立。第二轮结束时空闲块数应增加 1,最后再增加 1;物理块 ID 以本次运行日志为准。deallocate 释放请求持有的引用,KV 数值与可复用前缀由缓存池的回收规则继续管理。

步骤 3|对比参数。临时增加 --max-num-seqs 1,用新进程运行 B,先预测每轮选择谁。这个版本优先选择可调度的 Prefill,再结合日志核对 R1、R2 的推进次序。随后去掉该参数,恢复默认值 2。本变体用于验证调度行为,性能另做受控测量。

验收问题:R1/R2 的 2+5=7 个输出怎样在 5 轮模型运行中完成?R1 完成后为何仍出现在本轮 seqs 中?本实验展示逐轮移除完成请求;当前 generate 同步返回整个批次结果,动态接入与在线交付留待后续扩展。

5.2.3 案例 C:跨越第 256 个 KV 槽位

步骤 1|用正好一块的 prompt。案例 C 输入 list(range(100,356)),恰好 256 个合法 token ID,输出上限为 3。真实 GPU 路径的块大小须为 256 的倍数,本实验固定为 256。使用新进程,让请求与前缀缓存处于受控初始状态。

source "$HOME/nanovllm-inference-lab/env.sh"
source "$LAB_ROOT/.venv-nano/bin/activate"
cd "$NANO_DIR"
CUDA_VISIBLE_DEVICES=0 python "$LAB_ROOT/e2e/quick_walk.py" --case C --model "$MODEL_DIR" --debug

步骤 2|先在 A 的两个断点看整体。Prefill 处理 positions=0…255,写完一块 KV,并选出第 257 个 token;此时 total=257、cached=256、block_table 仍只有一个物理块。Sequence.num_blocks 按已知 token 长度计算会是 2,但 len(block_table) 暂时是 1:刚选出的最后一个 token 将在下一轮输入模型,其 KV 所需的第二块也在下一轮执行前分配。

步骤 3|再跟一次 may_append。重跑 C,在初始 pdb 设置下列两个断点。107 行进入 may_append 的条件判断,216 行在 Runner 已构造输入和槽位后停下。

b nanovllm/engine/block_manager.py:107
b nanovllm/engine/model_runner.py:216
c

第一次先在 Runner 中看到 Prefill;继续后,下一轮会进入 may_append。在这里查看 len(seq)、seq.num_cached_tokens、seq.num_blocks 和 seq.block_table,应该是 257、256、2 和一个物理块。因为 257 % 256 == 1,执行分配语句后,块表变成两个物理块。然后继续到 Runner 216 行,查看 positions、context_lens、slot_mapping。

p len(seq), seq.num_cached_tokens, seq.num_blocks, list(seq.block_table)
在 may_append 中查看;用 n 逐步执行到块追加完成
from nanovllm.utils.context import get_context
p positions.tolist()
p get_context().context_lens.tolist()
p get_context().slot_mapping.tolist()
在 ModelRunner.run 的 216 行查看

时刻

位置与物理块

应得到的映射/状态

Prefill

positions=0…255;块表 [p0]

槽位范围为 p0×256 … p0×256+255;回写后 total=257、cached=256

Decode 1

position=256;先追加物理块 p1

context_lens=[257];slot_mapping=[p1×256];回写后 total=258、cached=257

Decode 2

position=257;复用已有尾块 p1

context_lens=[258];slot_mapping=[p1×256+1];选出第三个输出后结束并释放两块

统一公式为 slot = block_table[position // 256] × 256 + position % 256。p0、p1 是运行时观察到的物理块编号,实际位置由块表映射和块内偏移共同确定。store_kvcache 将本层新 K/V 写入对应槽位,同一块表映射在各层定位各层自己的缓存切片。

验收问题:为什么 Prefill 结束时已知 token 有 257 个,却只分配一块 KV?为什么第二轮必须分配新块,第三轮不用?为什么最后选出的第 259 个 token 没有自己的 KV?能用块表、position 和 slot 三项数值回答,就已读通分页缓存的主路径。

再检查有效长度如何限定读取范围。案例 C 的第一个 Decode 结束后,第二页只写入一个位置;下一轮有效上下文增至 258 时,只读取第二页前两个位置。沿 layers/attention.py 的长度参数核对这条边界,确认其余预分配槽位被排除在 Attention 之外。页大小保持 256。

5.2.4 案例 D:增加每轮日志,观察分块 Prefill

先完成 A/B/C,再动源码。第一个改动只增加观测,不改数学计算:在 nano-vllm/nanovllm/engine/llm_engine.py 的 step() 内、schedule() 返回后、ModelRunner.run 之前,打印本轮阶段及每条序列的 total/cached/scheduled。补丁已放在 patches/01-step-trace.patch。先读补丁,再应用,能清楚看到自己到底修改了哪一个文件。

source "$HOME/nanovllm-inference-lab/env.sh"
source "$LAB_ROOT/.venv-nano/bin/activate"
cd "$NANO_DIR"
git status --short
git diff
less "$LAB_ROOT/patches/01-step-trace.patch"
git apply --check "$LAB_ROOT/patches/01-step-trace.patch"
git apply "$LAB_ROOT/patches/01-step-trace.patch"
git diff -- nanovllm/engine/llm_engine.py
NANO_LAB_TRACE=1 CUDA_VISIBLE_DEVICES=0 python "$LAB_ROOT/e2e/quick_walk.py" --case A --out "$LAB_ROOT/runs/modified-A.json" 2>&1 | tee "$LAB_ROOT/runs/modified-A.log"

预期看到三条 event=engine_step:Prefill 的 total/cached/scheduled 为 3/0/3;两次 Decode 分别为 4/3/1、5/4/1。这里记录的是本轮 forward 前的状态。NANO_LAB_TRACE 不设为 1 时不打印这些日志。若补丁检查失败,先看当前 diff,不要反复强行应用或用 reset --hard 清掉修改。

第二个改动观察调度行为。打开 e2e/quick_walk.py,找到 --budget 的 default=512,将它改成 256;入口把这个值传给 Config.max_num_batched_tokens。也可先用下面的参数对照,预测结果后再修改默认值。max_num_batched_tokens 控制本轮 Prefill 的计算预算,kvcache_block_size 控制每个 KV 页的 token 容量,两项参数分别记录。

cd "$LAB_ROOT"
NANO_LAB_TRACE=1 CUDA_VISIBLE_DEVICES=0 python e2e/quick_walk.py --case D --budget 512 --trace --out runs/budget512.json 2>&1 | tee runs/budget512.log
NANO_LAB_TRACE=1 CUDA_VISIBLE_DEVICES=0 python e2e/quick_walk.py --case D --budget 256 --trace --out runs/budget256.json 2>&1 | tee runs/budget256.log
python e2e/check_budget_cpu.py --repo "$NANO_DIR" --out runs/budget-cpu.json

输入固定为 A=700、B=32

budget=512

budget=256

Prefill 1

A:512

A:256

Prefill 2

A:188 + B:32

A:256

Prefill 3

已进入 Decode

A:188 + B:32

输出限制均为 3 token

总共 4 次模型运行

总共 5 次模型运行

中间 chunk 尚未处理完整个 prompt,Scheduler.postprocess 会丢弃 Runner 返回的中间候选,仅推进计算进度。因此第一个 512 或 256 位置 chunk 之后,A.total 仍是 700。较小预算增加一轮 Prefill;本实验带日志,用于验证调度与状态,吞吐影响需另行测量。CPU 验收器执行同一提交的真实 Scheduler,使用合成采样 ID 驱动状态变化。

性能实验前关闭日志,并把 quick_walk.py 的默认值恢复为 512。若想保留源码日志改动,自己确认 git diff 后提交到学习分支即可;若只撤销本章补丁,先检查 reverse patch,再反向应用。不要撤销不属于这份补丁的改动。

cd "$NANO_DIR"
git diff -- nanovllm/engine/llm_engine.py
git apply --reverse --check "$LAB_ROOT/patches/01-step-trace.patch"
git apply --reverse "$LAB_ROOT/patches/01-step-trace.patch"
python "$LAB_ROOT/e2e/source_walk.py" verify --repo "$NANO_DIR"
unset NANO_LAB_TRACE

保留源码日志补丁时,source_walk.py verify 会报告源码变化;阅读修改后的源码可以加 --allow-modified。基线指纹在 5.1 核对一次,后续通过 diff 记录改动。完成 A/B/C/D 后,继续下一节的 logits 对照,再进入 5.3 的可观测服务。

5.2.5 数值护栏:沿固定轨迹比较 nano-vLLM 的 logits

前面的日志证明了请求、位置和页的变化。本节再验证模型计算:让 nano-vLLM 与 Transformers 读取同一份 Qwen3-0.6B 权重,沿同一条预设 token 路径前进,逐步比较最后位置的整行 logits。logits 是词表上每个候选 token 的分数,比较它能把前向计算的差异与随机采样的差异分开。

怎样对照。nano 路径使用真实 Scheduler、BlockManager、ModelRunner 和分页 KV Cache;HF 路径每一步完整重算当前前缀,use_cache=False、Attention 后端为 eager。两者都采用 BF16、TP=1、相同位置 ID。驱动程序绕过 Sampler,将预设的下一个 ID 交给 postprocess,这种固定输入轨迹的方法称为 teacher forcing。这里的续写 ID 用于检验计算,不作为模型回答。

案例

输入与执行

有效对照行数

短请求

3 个输入,随后两轮单 token Decode

3

跨页

255 个输入;两轮 Decode 依次写尾页最后槽位和新页第一个槽位

3

分块 Prefill

700 个输入,token budget=256;依次处理 256、256、188 个位置,再 Decode 两轮

3

异长合批

长度 3 与 5 的两个请求,同时推进,按请求 ID 对齐结果

6

共比较 15 行 logits,每行对应一个请求在某一步的最后位置;分块 Prefill 的中间候选由 postprocess 丢弃,不计为回答或最终前缀的对照行。各案例重新建立调度与分配元数据,因此本实验覆盖完整/分块 Prefill、Decode、跨页与异长合批;前缀命中另由附录 B.6 验证。

步骤 1|顺序采集两个实现。继续使用 5.1 的环境。两个命令是独立进程,前一个结束后再运行后一个;采集器会核对实际权重与配置的 SHA256,并记录导入的 Runner 路径和源码 hash。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-nano/bin/activate
set -o pipefail
CUDA_VISIBLE_DEVICES=0 python e2e/logits_check.py collect \
  --backend nano --execution eager --suite baseline \
  --model "$MODEL_DIR" --out runs/engine-nano 2>&1 | tee runs/engine-nano.log
CUDA_VISIBLE_DEVICES=0 python e2e/logits_check.py collect \
  --backend hf --suite baseline \
  --model "$MODEL_DIR" --out runs/engine-hf 2>&1 | tee runs/engine-hf.log
python e2e/logits_check.py compare \
  --lhs runs/engine-nano --rhs runs/engine-hf \
  --max-abs 0.5 --rel-l2 0.02 --out runs/engine-logits-check.json

每次采集生成同前缀的 .json 与 .npz:JSON 保存输入轨迹、配置与实际执行记录,NPZ 保存整行分数。COLLECTED 只表示采集完整;最后一个 compare 命令才判断数值。原始分数需要保留,便于定位第一处差异;附录 B.5 的 Graph 合批实验数组会更大,应为 runs/ 留出数百 MB 空间。

步骤 2|读验收结果。初始教学门槛为:每行最大绝对误差不超过 0.5、相对 L2 误差不超过 0.02,并检查 15 行 top-1。它们是待实验机校准的起点,尚未在 A800 上实测,不代表通用生产容差。

结果/退出码

含义

下一步

PASS / 0

15 行完整,误差均在门槛内,top-1 全部一致

保存基线,再进入 5.3 可观测服务;执行数值深入验证见附录 B.5

REVIEW / 2

误差在门槛内,但至少一行 top-1 不同

查看该行误差和 reference_top1_margin,解释接近候选的翻转

FAIL / 1

数值越界或执行路径检查失败

定位第一处异常,核对输入、位置、dtype 与后端

ERROR / 非零

采集缺失、权重不同、轨迹不同或其他运行异常

先修复对照条件,再比较

把最坏误差、top-1 一致行数和首个异常位置写入 notes/engine-logits.md。出现 REVIEW 或 FAIL 时先保留日志并查原因;调整容差需要给出重复基线与误差分布依据。通过本节说明这些固定案例的前向结果符合设定门槛;采样、EOS、TP 和服务取消仍有各自的验证任务。

5.3 建立最小服务:让同一条请求可接入、可观测

前一节已经能解释模型怎样推进请求。现在给这套循环加一个稳定入口:请求可以随时提交,生成结果逐 token 返回,每次等待都有时间记录。后面的调度、GPU 和多卡实验都继续使用这个入口,逐步改造同一个系统。

5.3.1 一个执行循环拥有 Engine,其余入口只提交消息

打开 practice/worker.py。Worker._run() 在专属线程内创建并独占 Engine;submit() 把请求放入有界队列;执行循环在 step 边界接纳请求,调用原有 Engine.step(),再把新接受的输出交给各请求的事件流。HTTP 线程负责收发消息,模型加载、调度、KV 管理和 GPU 调用都留在同一个 owner 中。

位置

你要找到的动作

保持的约定

Worker.submit / _admit

建立 request_id,校验输入,进入 Engine

外部 request_id 映射到内部 Sequence;入口排队与引擎队列分开观察

Worker._run

保存 Sequence 引用,调用 step,比较输出数变化

以 postprocess 接受的 completion_token_ids 为准;中间 Prefill chunk 只推进 KV

EventStream / Worker._finish

按请求发布 token 与 finished

输出有序、终止一次;消费速度受限时占用有界

practice/run.py

生成负载、接入本机 HTTP、发送请求

网络入口和进程内入口复用同一个 Worker

先跑 CPU 全链路模拟,再换真实模型。模拟覆盖动态加入、逐 token 输出和生命周期,用于快速检查接口;日志明确标记 simulated,不代表运行过 Qwen3 或 GPU。

source "$HOME/nanovllm-inference-lab/env.sh"
source "$LAB_ROOT/.venv-nano/bin/activate"
cd "$LAB_ROOT"
python practice/run.py simulate --out runs/runtime-cpu.jsonl
python practice/run.py trace --out workloads/mixed.jsonl --requests 12 \
  --prompt-tokens 32 --long-prompt-tokens 768 --long-every 4 \
  --max-tokens 64 --interval-ms 40 --seed 17
CUDA_VISIBLE_DEVICES=0 python practice/run.py load \
  --source "$NANO_DIR" --model "$MODEL_DIR" \
  --trace workloads/mixed.jsonl --mode wall --warmup-requests 2 --policy prefill_first \
  --max-model-len 1024 --batch-tokens 256 --batch-seqs 4 --max-active 8 \
  --tp 1 --eager --out runs/prefill-first.jsonl

真实模型入口可以直接运行未修改的固定提交。trace 固定每条请求的 ID、token 输入、输出长度和计划到达时刻;load --mode wall 按这些时刻独立发送,初始化后先执行两条独立前缀的预热请求,再开始计时;这只覆盖有限形状,正式性能对照还需确认目标批次已充分预热。这里使用合法 token ID 构造工作量,并固定输出长度,目的是比较执行行为。回答质量测试另用真实文本与 chat template。

第一次阅读日志时,选定一条 request_id,串起 submitted → admitted → token 1…N → finished。再找到一个中间 Prefill chunk:它有 step 记录,但该请求还没有正式 token 事件。到达轨迹会在运行开始后继续送入请求,因此可以观察进行中的 Decode 怎样受到新 Prefill 的影响。

5.3.2 先统一指标,再接一层薄 HTTP

所有事件携带 request_id、worker_id、round 和单调时钟 t_ns。日志中的 observer=engine 表示引擎侧观测,observer=client 表示客户端侧观测;同一个 token 可能在两侧各记录一次。统计输出数量时选择一个观测点,分析延迟时保持同一时钟与同一起止边界。

指标

本章怎样计算

它回答什么

入口等待

引擎 admitted 时刻 − submitted 时刻

请求进入 Engine 前等了多久

引擎 TTFT / ITL

以引擎正式接受首 token、相邻 token 为终点

调度和执行给生成进度带来的延迟

客户端 TTFT / ITL

客户端发起至首 token 接收;相邻 token 接收间隔

加上入口、传输和消费后的实际体验

共同窗口吞吐

全部输出数 ÷ 全局首提交至最终完成的窗口时长

这份有限负载的整体完成效率

发送滞后、拒绝与失败

实际发送与计划到达的差;各结束原因计数

压测器是否跟得上,系统有没有靠丢请求显得更快

吞吐与延迟放在一起解释。固定到达频率时,系统可能提前完成计算、等待下一条请求,这时整体吞吐主要由负载决定;提高到达强度、让工作持续积压,才能进一步测容量。正式对照保留原始到达文件,重复运行并报告分布;--mode round 按调度轮次注入,只用于解释机制。

终端 A:启动本机服务。先结束上一条 load 命令,单独启动下面的进程。本节保持单实例,使用固定提交的默认通信资源;实例隔离在 5.6 增加。

cd "$LAB_ROOT"
CUDA_VISIBLE_DEVICES=0 python practice/run.py serve \
  --source "$NANO_DIR" --model "$MODEL_DIR" --tp 1 --eager \
  --policy prefill_first --port 8801 --out runs/server-single.jsonl

终端 B:提交并读取流。同样激活实验环境,等服务输出 ready 后再运行。接口监听 127.0.0.1,/generate 每行返回一个 JSON 事件;这是学习用的薄接口。

source "$HOME/nanovllm-inference-lab/env.sh"
source "$LAB_ROOT/.venv-nano/bin/activate"
cd "$LAB_ROOT"
python practice/run.py client --endpoints http://127.0.0.1:8801 \
  --prompt "Hello" --max-tokens 32 --request-id demo --out runs/client.jsonl

本节的小改动。在 Worker._admit() 增加一个诊断字段,记录接纳时已有多少活跃请求;在输出日志中按 request_id 找到它,确认动态请求确实在 step 边界加入。改完重跑 CPU 模拟与真实模型入口,检查正式输出数、顺序和终止事件保持不变。将 diff 和观察写入 notes/service-runtime.md。

完成标志是:同一套请求驱动既能在进程内运行,也能经过 HTTP 返回逐 token 事件;能够区分入口等待、引擎生成间隔与客户端接收间隔。随后关闭本节服务,进入调度对照。

5.4 第一次调度改造:让长 Prefill 与进行中的 Decode 轮流推进

当短请求已经开始回答,新的长 prompt 进入队列,接下来的输出间隔可能突然拉长。固定提交的 Scheduler 优先处理可执行的 Prefill;即使 Prefill 已分块,多个 chunk 仍可能连续占用多轮。先用 5.3 的同一份负载复现现象,再做一次边界清楚的修改。

5.4.1 先找等待发生在哪里

打开 nanovllm/engine/scheduler.py 的 schedule(),沿 waiting 中的 Prefill 分支看返回点,再看 running 中的 Decode 分支。日志中把长 prompt 的几个 Prefill step 与已有请求的 token 事件排在同一时间线上:已有请求的间隔增加,是否恰好发生在连续 Prefill 期间?如果没有复现,先检查到达间隔、长 prompt 大小和并发是否真正形成重叠。

本次只改变两件相互配套的调度行为:两种阶段都可执行时交替选取;一个 Decode 批次完成后,把已服务的请求放到队尾,让后面的请求获得机会。每轮仍只含一种阶段,Runner 继续接收原有的 (seqs, is_prefill)。当首选阶段受 KV 容量限制而无法执行时,尝试另一个可推进的阶段。

5.4.2 对照补丁完成修改,再执行真实调度器的 CPU 验收

步骤 1|先看差异,再落代码。确保 5.2 的日志补丁已按对应步骤撤回或被完整记录。先在编辑器打开 Config 与 Scheduler,对照补丁定位以下位置:Config 的 scheduling_policy、schedule 的阶段选择、拆出的 Prefill/Decode 选择函数、Decode 后的队列放置。保留默认 prefill_first,新增 balanced,便于同一份代码内只切换策略做对照。

cd "$NANO_DIR"
git status --short
git diff
less "$LAB_ROOT/patches/03-fair-scheduler.patch"
git apply --check "$LAB_ROOT/patches/03-fair-scheduler.patch"
git apply "$LAB_ROOT/patches/03-fair-scheduler.patch"
git diff -- nanovllm/config.py nanovllm/engine/scheduler.py
cd "$LAB_ROOT"
python e2e/scheduler_practice.py --repo "$NANO_DIR" --out runs/scheduler-cpu.json

也可以手工完成这些编辑,再与补丁核对;手改和直接应用二选一。一次成功应用后,后续步骤继续使用该源码,记录 diff 即可。

步骤 2|先用可解释的有限场景证明调度改变。e2e/scheduler_practice.py 从本地 Git 的固定提交提取原始文件到临时目录,对照原版、当前源码的默认策略与 balanced,调用真实 Scheduler、Sequence 和 BlockManager,用合成 token 驱动 postprocess。它检查长 Prefill 干扰、running 超过单批容量、chunk 候选处理、KV 不足时的切换和结束后的回收。上面的命令直接生成 runs/scheduler-cpu.json;检查无需 GPU,也不会修改你的工作树。

验收分两层:原版与补丁默认策略的逐轮状态应一致;balanced 的阶段间隔与请求覆盖应按设计变化。尤其查看“批次最多 2 条、已有 6 条待 Decode”的场景:仅交替 Prefill/Decode 仍可能反复服务队首两个请求,队列轮转才解决这个问题。轮数证明调度机制,毫秒延迟另由真实模型负载测试。

5.4.3 固定到达轨迹,比较收益与代价

下面沿用 5.3 的模型、输入文件、批次预算和准入上限,分别运行两种策略。每次启动新引擎,保持相同的冷前缀状态;模型初始化与两条请求预热排除在负载窗口之外。预热覆盖有限的 Prefill/Decode 形状;稳态结论还需更充分的预热与重复测量。

cd "$LAB_ROOT"
unset NANO_LAB_TRACE
for policy in prefill_first balanced; do
  CUDA_VISIBLE_DEVICES=0 python practice/run.py load \
    --source "$NANO_DIR" --model "$MODEL_DIR" \
    --trace workloads/mixed.jsonl --mode wall --warmup-requests 2 --policy "$policy" \
    --max-model-len 1024 --batch-tokens 256 --batch-seqs 4 --max-active 8 \
    --tp 1 --eager --out "runs/scheduler-$policy.jsonl"
done

先核对请求、终止和输出数,再比较已有短请求的 ITL、长请求的 TTFT、整体吞吐与拒绝数。长请求更晚拿到首输出,可能正是为进行中的 Decode 让出了时间;把两类请求分开读,才能看见代价。至少重复三轮,保留每轮原始 JSONL;报告工具的命令见 5.7。

接下来自己改一个旋钮。把“严格交替”改为最多连续两个 Prefill 批次后服务一个 Decode 批次,或反过来先保证 Decode 间隔。一次只做一种策略,补充相应 CPU 场景,再重放同一到达文件。用 notes/scheduling-change.md 记录:预期改善什么、实测改善多少、哪类请求付出了代价。

本提交在首次分配时为整段 prompt 领取 KV 块。缩小本轮 token budget 主要减少一次计算量,初始 KV 预留仍要覆盖 prompt;容量问题应同时看 BlockManager 与准入。请求公平也有适用条件:上述轮转针对已准入且可执行的请求,持续过载和无限到达需要额外的拒绝与服务策略。

进阶支线:同一批混合 Prefill 与 Decode。完成主线后再考虑将全局 is_prefill 改为逐请求的 batch plan,连同 Runner 输入、Attention 元数据、logits 取行和 postprocess 一起验证。先限于 TP=1、eager,证明混合结果正确,再扩展 Graph 与 TP。

5.5 顺着瓶颈进入 GPU:测量一次执行,再做一处改进

调度决定这一轮做什么,执行层决定这轮怎样完成。现在仍使用同一个 Worker 和到达文件,把耗时拆到输入准备、模型调用和设备执行,找出一个值得修改的位置。先给出证据,再选择优化。

5.5.1 同时看 CPU 与 GPU 时间线

运行一个短 profile。practice/worker.py 的 StepProfiler 在初始化与显式请求预热之后启用 torch.profiler,并标记 step、prepare_prefill、prepare_decode 与 run_model;输出包含 CPU/CUDA 活动的 Chrome trace 和算子汇总。此路径带观测开销,日志标记为 profile,只用于定位。

cd "$LAB_ROOT"
CUDA_VISIBLE_DEVICES=0 python practice/run.py load \
  --source "$NANO_DIR" --model "$MODEL_DIR" \
  --trace workloads/mixed.jsonl --mode wall --warmup-requests 2 --policy balanced \
  --max-model-len 1024 --batch-tokens 256 --batch-seqs 4 --max-active 8 \
  --tp 1 --eager --profile-dir runs/profile-eager \
  --out runs/profile-eager.jsonl
ls runs/profile-eager

用支持 Chrome trace 的本地查看器打开生成文件。先选一轮 Decode,把 CPU 的输入准备、GPU kernel 和下一轮开始对应起来;再选一轮长 Prefill 作对照。CPU range 包含提交与等待,并不等于 GPU kernel 时长,应结合设备轨道判断空洞发生在哪。

时间线现象

回到哪里看

本次可验证的假设

许多很短的 kernel,轮间存在提交空隙

ModelRunner.run_model 与 Graph 分支

减少重复 launch 能否缩短输出间隔

prepare_decode 的 CPU 时间突出

列表构造、张量创建与搬运

减少重复元数据构造或复用缓冲区是否有价值

模型计算持续占据设备

矩阵计算与 Attention kernel

增大有效批次、换执行策略是否改善吞吐

设备空闲而入口排队仍高

owner 循环、队列和输出消费

瓶颈是否位于 CPU 或服务路径

5.5.2 做一次 eager / Graph 对照,并完成一处边界修复

先阅读附录 B.5 的故障复现和数值验证步骤,再在这里或 B.5 中选择一处应用 patches/02-graph-eager-fallback.patch,同一工作树只应用一次。补丁让没有可用 Graph 桶的批次走 eager 路径;验收重点是保持功能与数值,Graph 的性能收益再通过相同工作量比较。

cd "$NANO_DIR"
git diff
git apply --check "$LAB_ROOT/patches/02-graph-eager-fallback.patch"
git apply "$LAB_ROOT/patches/02-graph-eager-fallback.patch"
cd "$LAB_ROOT"
for execution in eager graph; do
  CUDA_VISIBLE_DEVICES=0 python practice/run.py load \
    --source "$NANO_DIR" --model "$MODEL_DIR" \
    --trace workloads/mixed.jsonl --mode wall --warmup-requests 2 --policy balanced \
    --max-model-len 1024 --batch-tokens 256 --batch-seqs 4 --max-active 8 \
    --tp 1 --"$execution" --out "runs/execution-$execution.jsonl"
done

对照运行关闭 profiler 和逐轮详细日志;核对输出与完成数,按附录 B.5 的固定轨迹 logits 步骤验证 eager/Graph 分支。Graph 捕获属于初始化,回放属于运行时,应分别记录。当前 profile 只覆盖 owner/rank 0;研究 TP 通信时还需收集其他 rank 的设备轨迹。

本节交付一条有证据的执行结论。在 notes/execution-change.md 中附一段时间线、具体函数、你的修改或配置 diff,以及修改前后的正确性和性能记录。如果 Graph 没有带来收益,就说明工作量、批次填充或非 launch 开销为何主导。若继续改元数据缓冲区,先限于单卡 Decode,逐项检查有效长度、slot_mapping 和旧缓冲内容的覆盖,再扩展批次形状。

5.6 把同一个服务扩到两张卡:独立副本与 TP

一张卡的路径已经可测量,再让两张卡分工。独立副本让不同请求在不同 GPU 上执行;TP 让同一批请求的模型计算由两张 GPU 合作。继续使用原来的客户端与请求文件,改变服务端部署方式。

5.6.1 先隔离实例,再启动两个副本

固定提交把进程组地址和共享内存名写死。打开 patches/04-replica-isolation.patch,沿 Config → LLMEngine → ModelRunner 查看实例资源:每个独立引擎使用独立通信地址与共享内存名,同一个 TP 引擎的 ranks 使用相同配置。补丁还给退出流程增加幂等保护、资源归属和有界子进程等待,便于重复启动实验。

cd "$NANO_DIR"
git diff
less "$LAB_ROOT/patches/04-replica-isolation.patch"
git apply --check "$LAB_ROOT/patches/04-replica-isolation.patch"
git apply "$LAB_ROOT/patches/04-replica-isolation.patch"
cd "$LAB_ROOT"
nvidia-smi topo -m
python e2e/principles.py shapes --tp 2 --out runs/tensor-parallel-shapes.json

依次打开终端 A、B,各激活相同环境。在导入 PyTorch 之前用 CUDA_VISIBLE_DEVICES 隔离 GPU。下面假设分配到物理卡 0 和 1;按实际卡号替换。

CUDA_VISIBLE_DEVICES=0 python practice/run.py serve \
  --source "$NANO_DIR" --model "$MODEL_DIR" --tp 1 --eager \
  --policy balanced --batch-seqs 4 --max-active 8 \
  --port 8801 --worker-id replica-a \
  --dist-init tcp://127.0.0.1:29501 --shm-name nano_replica_a \
  --out runs/server-replica-a.jsonl
终端 A:副本 a
CUDA_VISIBLE_DEVICES=1 python practice/run.py serve \
  --source "$NANO_DIR" --model "$MODEL_DIR" --tp 1 --eager \
  --policy balanced --batch-seqs 4 --max-active 8 \
  --port 8802 --worker-id replica-b \
  --dist-init tcp://127.0.0.1:29502 --shm-name nano_replica_b \
  --out runs/server-replica-b.jsonl
终端 B:副本 b

终端 C:先单副本,再双副本。两个服务 ready 后运行。端点轮询路由已经在 practice/run.py 的 run_load() 中实现,输入 request_id 保持全局唯一。比较冷前缀时,每个方案前重新启动对应服务;分别保存服务端日志。

python practice/run.py load --endpoints http://127.0.0.1:8801 \
  --trace workloads/mixed.jsonl --mode wall --warmup-requests 2 --out runs/single-endpoint.jsonl

单副本测量完成后,在终端 A、B 正常结束自己的服务,再重新执行上面两个启动命令,等待二者 ready;这样双副本对照从新的缓存状态开始。随后在终端 C 执行:

python practice/run.py load \
  --endpoints http://127.0.0.1:8801,http://127.0.0.1:8802 \
  --trace workloads/mixed.jsonl --mode wall --warmup-requests 2 --out runs/replicas.jsonl

负载报告尚未探测远端 policy/TP 时会标为 null;保留服务端的启动命令与日志,核对每个实例的实际参数。

核对请求集合完全对应、两个 worker 都实际承担请求、服务执行区间有重叠,再看共同窗口吞吐和各类请求延迟。若负载很轻,两张卡可能只是在一起等待到达,接着降低 interval-ms 生成一份更密集的轨迹,并让所有方案重放这份新文件。

5.6.2 同样两张卡,换成一个 TP=2 引擎

先用 Ctrl-C 正常结束自己的两个副本,确认退出完成,再启动 TP=2。不要让对照方案同时争用这两张 GPU。

CUDA_VISIBLE_DEVICES=0,1 python practice/run.py serve \
  --source "$NANO_DIR" --model "$MODEL_DIR" --tp 2 --eager \
  --policy balanced --batch-seqs 8 --max-active 16 \
  --port 8801 --worker-id tp-two \
  --dist-init tcp://127.0.0.1:29503 --shm-name nano_tp_two \
  --out runs/server-tp2.jsonl

另一个终端等待 ready 后提交相同全局负载:

python practice/run.py load --endpoints http://127.0.0.1:8801 \
  --trace workloads/mixed.jsonl --mode wall --warmup-requests 2 --out runs/tp2.jsonl

这里两个副本各允许 4 条进入单批、8 条活跃请求;TP 引擎对应设置为 8 和 16,先对齐全局上限。实际批次仍由到达、阶段和 KV 容量决定;逐卡记录缓存池大小、显存峰值与通信,解释两种方案实际占用的资源。

Qwen3-0.6B 的 TP=2 每个 rank 处理 8 个 Q heads、4 个 KV heads。沿 layers/linear.py 和 models/qwen3.py 看列切分、行切分以及合并位置,再与第 4 章的模型图对应。形状检查证明静态分工,真实前向、NCCL 与性能仍以 GPU 运行结果为准。

本节的小改动。在轮询路由基础上增加一个你能解释的策略,例如按已分配的预计 token 工作量选择副本;保留请求 ID 和共同到达文件,比较负载偏斜与尾延迟。先做离线可复现的路由决策,再考虑实时反馈。记录在 notes/parallel-service.md,说明为什么该负载更适合副本或 TP。

5.7 把工程闭环做完整:故障、回归与最终证据

一个请求能够正常生成,是这条学习路线的起点。最后沿同一个入口检查取消、过载、慢消费者和关闭:请求终止时应有清楚原因,资源归属应能追到 owner,异常之后仍能重复启动。

5.7.1 在生命周期边界验证,而不只看最终文字

先重跑 python practice/run.py simulate --out runs/runtime-cpu.jsonl。CPU 场景可稳定制造等待中取消、生成中取消、队列满、慢消费者、引擎异常与关闭;再在真实模型的本机服务上抽样验证同样的外部行为。

python practice/run.py client --endpoints http://127.0.0.1:8801 \
  --prompt "Hello" --max-tokens 64 --request-id cancel-demo \
  --cancel-after 3 --out runs/client-cancel.jsonl

客户端收到第三个 token 后向 /cancel 提交取消。取消在 owner 的安全边界处理;网络和执行可能已经推进,因此以 finished 的原因与之后不再产生该请求的正式输出为判据,而不是把输出总数硬定为 3。

场景

如何制造

检查什么

取消

提交后取消;或收到部分输出后取消

一次终止、正确原因、waiting/running 不再持有它、活跃块引用释放

过载

减小 --queue-depth / --max-active,提高到达强度

明确拒绝或等待;请求集合与结束原因可核对;内存有界

慢消费者

服务 --event-capacity 8;客户端 --read-delay-ms 1000

有界输出与 slow-consumer 策略;其他请求继续推进

关闭与异常

生成中 Ctrl-C;CPU 场景注入执行失败

结束活跃请求、关闭自己拥有的资源、再次启动成功

慢读 HTTP 还会受操作系统 socket 缓冲影响,短回答未必立即触发背压;CPU 场景直接停止读取事件流,可稳定检查有界队列行为。TP 的某个设备调用或集合通信卡死,需要外层进程超时与故障隔离;本章的幂等退出与有界 join 只覆盖可到达的清理路径。

本节的小改动。选择一个故障场景,先写出“允许出现什么、最终必须恢复什么”,再改结束原因或准入策略,并补上可重复的场景。检查 Worker._finish()、_release_pending()、close(),把请求集合与 KV 回收证据记入 notes/service-lifecycle.md。

5.7.2 把结果整理成可复查的证据

先用下列命令汇总逐事件日志,每个比较同时生成 JSON 与同名 Markdown,再完成工程报告。统计按 engine/client 观测点分开;模拟、按轮注入和 profiler 日志单列为机制证据。报告比较同一 run 跨所有副本的完整窗口,保留每次实验的尾部;缺失的 GPU 结果保留为待验证。

python practice/run.py report \
  --inputs runs/scheduler-prefill_first.jsonl,runs/scheduler-balanced.jsonl \
  --out runs/scheduler-comparison.json
python practice/run.py report \
  --inputs runs/execution-eager.jsonl,runs/execution-graph.jsonl \
  --out runs/execution-comparison.json
python practice/run.py report --inputs runs/replicas.jsonl,runs/tp2.jsonl \
  --out runs/parallel-comparison.json
cd "$LAB_ROOT"
git -C "$NANO_DIR" diff > runs/engine-changes.patch
git -C "$NANO_DIR" rev-parse HEAD > runs/source-head.txt
python -m pip freeze > runs/environment.txt
python e2e/final_report.py --root "$LAB_ROOT"
cat runs/final-report.md
tar -czf learning-evidence.tar.gz \
  README.md VALIDATION.md CHECKPOINTS.md env.sh requirements-cpu.txt \
  practice e2e labs patches workloads runs notes

最终报告只需回答一个连贯的问题:在什么负载与资源预算下,系统的哪个边界限制了体验或吞吐;你改了哪段代码;哪些正确性证据证明行为保持;收益是多少,代价由谁承担。至少附一条请求时间线、一份固定轨迹数值对照、一组调度或执行 before/after、一组两卡部署对照,以及一次取消或过载后的回收记录。

报告的每项结论标明证据类型:真实 GPU 实测、真实调度器的 CPU 状态流、完整接口的 CPU 模拟,或静态估算。保留缺失项及原因。公式、统计口径、显存预算和常见排错边界集中在附录 B.8;前缀共享与多轮对话的深入练习见 B.6–B.7。

这条主线完成后,你应能从请求入口一路追到调度、缓存、GPU 执行和多卡通信,并完成一次“提出假设—修改—验证—测量—解释”的闭环。第 6 章再用同样的问题观察 vLLM:哪些责任已有成熟实现,哪些优化仍依赖工作负载与部署约束。

6. 带着这些理解再看 vLLM

完成 nano-vLLM 的阅读和改造后,再进入 vLLM,重点是把已经掌握的系统问题迁移过去:请求在哪等待、本轮计算多少、KV 由谁管理、模型由谁执行、结果如何交付、故障怎样清理。生产系统围绕同一条链路,适配更多模型、硬件、负载和服务约束。

6.1 从教学引擎到生产系统,增加了哪些责任

vLLM 的价值来自调度、缓存、模型后端和服务接口共同工作。分页释放的容量需要被 Scheduler 利用;页表需要被 GPU 内核高效读取;持续接入需要请求身份、结果路由、取消、背压和监测。稳定的服务还要在升级、异常和不同模型配置下保持这些关系正确。

已经掌握的机制

进入生产系统后继续追问

生成循环与 Sequence

请求 ID 怎样跨 API、引擎、执行进程和流式连接传递?终止如何保证唯一?

连续批处理与 token 预算

不同到达速率和长短请求下,怎样平衡吞吐、TTFT 与 ITL?

分页与前缀缓存

缓存命中、共享、驱逐、隔离、抢占恢复和模型差异怎样处理?

ModelRunner 与优化后端

不同形状、精度和硬件怎样选择算子、Graph 桶与回退路径?

独立副本与 TP

路由、拓扑、通信、容量和故障恢复怎样共同决定部署方案?

读源码时按职责找落点,而不是要求类名与 nano-vLLM 一一相同。先锁定所读版本,跟踪一条最简单的请求,再逐项打开特性。更多功能往往带来更多状态和约束,仍可以用同一套输入、输出与生命周期问题理解。

6.2 怎样做有解释力的对照

每次选一个问题,例如“同样两张卡,两个副本还是一个 TP=2 引擎更适合当前负载”。固定模型、Tokenizer、聊天模板、精度、输入输出长度、全局请求集合、缓存冷热和计时范围,再记录版本与实际后端。先测正确性,再测吞吐、延迟分位数与显存,最后解释收益和代价。

正确性对照尽量采用同一条 token 输入轨迹,比较对应位置的 logits、请求边界和 KV 状态。自由采样的结果可能因随机性或数值差异很快分叉,仅比较最终回答相同与否不足以定位问题。性能对照则区分初始化、预热和正式运行,说明是否包含排队、传输、分词与网络。

异步接入表示多条请求可以同时等待和推进,流式输出表示一条请求完成前逐步交付结果。客户端收到的网络数据块可能包含多个模型 token,测量输出间隔时要说明观察的是内部 token 事件还是网络分块。

6.3 用独立环境体验离线接口与本机服务

本节在独立环境中体验 vLLM,可在完成 nano-vLLM 改造主线后尝试。模型复用 5.1 的 MODEL_DIR;vLLM 使用独立的 .venv-vllm 环境安装依赖,运行记录单独保存,便于与 nano-vLLM 结果对照。

6.3.1 独立环境与离线补全

以下复用 env.sh 中的 MODEL_DIR,指向 5.1 下载的固定模型目录。vLLM 使用另一个 .venv-vllm 环境,不覆盖本章源码实验环境。

安装方式参考 vLLM 官方 Quickstart;实际运行前按所用版本的安装页核对 GPU、驱动与依赖兼容性。下面在 .venv-vllm 中安装和运行。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
python -m venv .venv-vllm
source .venv-vllm/bin/activate
python -m pip install --upgrade pip
# 仅用于独立体验;不是本章 nano 的固定依赖组合
python -m pip install vllm
python -m pip freeze > runs/vllm-environment.txt
import os
from vllm import LLM, SamplingParams


model_dir = os.environ["MODEL_DIR"]
llm = LLM(model=model_dir, max_model_len=2048,
          gpu_memory_utilization=0.5, generation_config="vllm")
params = SamplingParams(temperature=0.6, max_tokens=32)
for result in llm.generate(["The sky was", "The river was"], params):
    print(result.prompt, result.outputs[0].text)
选做:离线补全;MODEL_DIR 来自第 5.1 节的 env.sh

在已激活的 vLLM 环境中运行上面的 Python 示例。它调用文本补全接口;若要按聊天消息问答,应使用聊天接口或先应用相同的 chat template。完成离线体验后先退出该进程,释放其 GPU 资源,再启动下面的本机服务。

6.3.2 本机在线服务与流式返回

这是仅供本机访问的学习服务,监听 127.0.0.1,在同一台 GPU 机器的另一终端发送请求。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-vllm/bin/activate
vllm serve "$MODEL_DIR" \
  --served-model-name qwen3-demo \
  --host 127.0.0.1 --port 8000 \
  --max-model-len 2048 --gpu-memory-utilization 0.5 \
  --generation-config vllm
选做:在 GPU 机器启动仅本机可访问的学习服务
curl -N http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"qwen3-demo","messages":[{"role":"user","content":"用一句话解释什么是推理。"}],"temperature":0.6,"max_tokens":64,"stream":true,"chat_template_kwargs":{"enable_thinking":false}}'
在同一台机器的另一终端观察流式返回

这里的 stream=true 让客户端逐步接收结果,适合观察首输出和输出间隔;网络 chunk 不一定严格对应一个模型 token。Qwen3 的 thinking 设置会影响输入模板与输出形式,示例显式关闭 thinking,便于先观察简短回答。离线与在线的模型前向原理相同,在线路径额外涉及请求排队、序列化和传输。

走到这里,学习目标已经从“记住一组术语”变为“能解释状态变化、定位资源瓶颈、提出小幅改造、验证正确性并用实测说明取舍”。后续可以沿调度、内存管理、执行内核或分布式推理继续深入;先选择与自己观察到的问题相关的一条。

附录 A. 数值与模型概念详解

正文已经在首次使用时说明各概念的作用。本附录保留公式、数值例子和示意图,适合在需要核对计算轴、缩放方式或模型结构时查阅。

第 1 章负责把真实模型的执行路线串起来,本章把路线中的关键概念展开。每节先说明它解决什么问题,再给出变量、公式和手算例子,最后对照示意图与 nano-vLLM 实现。可以按顺序学习,也可以从正文链接跳到单独一节;以下小向量均为便于手算而构造的教学示例。

先确定它们在 第 1.6 节模型总图 中的位置:RMSNorm 调整子层输入;Attention 内用 GQA 安排头的共享关系,用 RoPE 处理 Q/K 的位置,再用 Softmax 将分数变成权重;MLP 内部用 SiLU 构成 SwiGLU 门控。这些算子分布在同一个 Block 的相应子层中。

本章采用 Qwen3-0.6B 的实际尺寸:隐藏宽度 1024,Q Head 数 16,KV Head 数 8,每头宽度 128,MLP 中间宽度 3072。前面的教学例子采用 4096 维教学配置,从这里开始以本章参数为准。为与推理引擎章节一致,先固定以下记号:

记号

表示什么

贯穿例子

N

本轮选中的请求数

A、B 两条请求:N=2。

T

本轮所有请求合计新计算的位置数

A 新算 188,B 新算 32:T=220。

S

单条请求本轮的 K/V 上下文范围长度,含历史与本轮新位置;每条 query 还要受因果 mask 限制

A 的 K/V 范围有 700 个位置;位置 512 只能读其中 0~512。

D / d

当前隐藏向量宽度 / 单个 Attention Head 宽度

残差流 D=1024;单头 d=128。

τ

采样温度,正数

控制词表采样分布的集中程度。

形状 [T,D] 的两个轴分别是位置与特征。下文 x 指当前层、当前步骤的隐藏表示;需要手算时会明确改用三维小向量。公式统一采用行向量右乘权重的数学写法,PyTorch Linear 的参数通常按其转置布局存储。

A.1 Softmax:怎样把分数变成权重

假设有三个候选,分数 z=[1,2,3],数值大小表示相对偏好。Softmax 将它们转换为非负、总和为 1 的权重:先对每个分数取指数,再除以所有指数之和。

pi=exp⁡(zi)∑j=1mexp⁡(zj),i=1,…,m\displaystyle p_i=\frac{\exp(z_i)}{\sum_{j=1}^{m}\exp(z_j)},\qquad i=1,\ldots,m

这里 m 是候选数,i 指正在计算的候选,j 遍历所有候选;zᵢ 是输入分数,pᵢ 是归一化后的权重。三个候选的手算结果是:

z=[1,2,3]exp⁡(z)≈[2.71828,7.38906,20.08554]∑jexp⁡(zj)≈30.19287softmax⁡(z)≈[0.09003,0.24473,0.66524]\displaystyle \begin{aligned}z&=[1,2,3]\\ \exp(z)&\approx[2.71828,7.38906,20.08554]\\ \sum_j\exp(z_j)&\approx30.19287\\ \operatorname{softmax}(z)&\approx[0.09003,0.24473,0.66524]\end{aligned}

exp(z) 就是 e 的 z 次方,e 约为 2.718。较大分数得到较大权重;除以共同总和后,各项非负且总和为 1。输入三个数,输出仍是三个数。得到权重后,Attention 用它汇总 V,采样流程用它选择 token。

实际计算通常先减去同一组分数的最大值。分子与分母同时乘上同一个因子,因此结果不变,却能避免大正数取指数溢出:

pi=exp⁡(zi−zmax⁡)∑jexp⁡(zj−zmax⁡),zmax⁡=max⁡jzj\displaystyle p_i=\frac{\exp(z_i-z_{\max})}{\sum_j\exp(z_j-z_{\max})},\qquad z_{\max}=\max_j z_j

把 [1,2,3] 变为 [−2,−1,0],指数约为 [0.13534,0.36788,1],归一化后仍得到上面的权重。因果 mask 在 Softmax 前将被屏蔽位置的分数设为负无穷,使其指数值和最终权重为 0;可见位置保留原分数。该计算要求至少存在一个可见位置。

Attention 与采样使用相同的 Softmax,但候选轴不同。 Attention 的候选是当前 query 可以读取的 key 位置;采样的候选是词表里的 token。对一个 d 维 query,Attention 的完整后半段是:

w⏟[1,S]=softmax⁡ ⁣(q⏟[1,d]KT⏟[d,S]d+M)\displaystyle \underbrace{w}_{[1,S]}=\operatorname{softmax}\!\left(\frac{\underbrace{q}_{[1,d]}\underbrace{K^{\mathsf T}}_{[d,S]}}{\sqrt d}+M\right)
a⏟[1,d]=wV⏟[S,d]=∑j=1Swjvj\displaystyle \underbrace{a}_{[1,d]}=w\underbrace{V}_{[S,d]}=\sum_{j=1}^{S}w_jv_j

M 是加到分数上的 mask:允许位置取 0,屏蔽位置取负无穷。wⱼ 是一个标量权重,vⱼ 是该位置已经生成或缓存的 d 维 Value。每个 vⱼ 乘以对应的 wⱼ,再沿位置求和,得到当前 query 的 d 维输出向量 a。

若只用一维 Value 手算,取 v₁=10、v₂=20、v₃=30,并沿用上面的三个权重:

a≈0.09003×10+0.24473×20+0.66524×30≈25.7521\displaystyle a\approx0.09003\times10+0.24473\times20+0.66524\times30\approx25.7521

这里的一维只是把向量加权变成可手算的标量。真实 d=128 时,同一组位置权重分别作用于 V 的 128 个坐标;最终得到 128 个数。

采样时,LM Head 先给出词表 logits,再由温度 τ 调整分数差距:

pi=exp⁡(zi/τ)∑jexp⁡(zj/τ),τ>0\displaystyle p_i=\frac{\exp(z_i/\tau)}{\sum_j\exp(z_j/\tau)},\qquad \tau>0

同一组分数 [1,2,3]

输出概率

效果

τ=0.5

[0.01588, 0.11731, 0.86681]

更集中于高分项。

τ=1

[0.09003, 0.24473, 0.66524]

原始 Softmax 分布。

τ=2

[0.18632, 0.30720, 0.50648]

更平缓。

Sampler 按概率选出一个离散 token ID。温度控制采样分布的集中程度,答案质量还需单独评价;Attention 中的 √d 缩放则用于控制模型内部的点积分数尺度。

对应正文:第 1.2.1–1.2.2 节的 Attention 权重、第 1.3.1 节的词表输出、第 2.5 节的采样实现。

图 A-1 同一个 Softmax,作用于不同候选轴:Attention 汇总上下文,采样选择输出 token。

图 A-1 同一个 Softmax,作用于不同候选轴:Attention 汇总上下文,采样选择输出 token。

A.2 Norm 与 LayerNorm:归一化究竟在处理什么

数学里的 norm 常指“范数”,例如 L2 范数用一个数衡量向量长度:

∥x∥2=∑i=1Dxi2\displaystyle \lVert x\rVert_2=\sqrt{\sum_{i=1}^{D}x_i^2}

结构图中的 Norm 通常指 normalization layer(归一化层),用于调整特征数值的尺度。LayerNorm 根据均值与方差进行调整,RMSNorm 根据均方根进行调整;两者的具体计算如下。

以一个 token 的三个特征 x=[1,2,3] 为例,LayerNorm 先计算这一行的均值,再看各分量偏离均值的程度:

μ=1D∑j=1Dxj,σ2=1D∑j=1D(xj−μ)2\displaystyle \mu=\frac{1}{D}\sum_{j=1}^{D}x_j,\qquad \sigma^2=\frac{1}{D}\sum_{j=1}^{D}(x_j-\mu)^2
x^i=xi−μσ2+ε,yi=γix^i+βi\displaystyle \widehat{x}_i=\frac{x_i-\mu}{\sqrt{\sigma^2+\varepsilon}},\qquad y_i=\gamma_i\widehat{x}_i+\beta_i

D 是这一行的特征数,μ 是均值,σ² 是分母取 D 的总体方差。对 x=[1,2,3],D=3:

μ=2,x−μ=[−1,0,1],σ2=1+0+13=23\displaystyle \mu=2,\qquad x-\mu=[-1,0,1],\qquad \sigma^2=\frac{1+0+1}{3}=\frac23

先忽略很小的 ε,并取 γ=[1,1,1]、β=[0,0,0],输出约为 [-1.22474,0,1.22474]。减去均值使这行围绕 0,再除以标准差调整尺度。对非恒定向量,在这些简化条件下,结果均值为 0、方差为 1;实际输出的统计量还会受到 ε 和学习到的 γ/β 影响。

γ(gamma)是逐特征缩放参数,β(beta)是逐特征偏移参数,标准 LayerNorm 可学习它们。它们在模型训练中更新、普通推理中固定;μ 和 σ² 每次由当前输入重新计算。ε(epsilon)是很小的稳定项,用于避免零或过小的分母引发数值问题。

在本文的 Transformer 隐藏状态上,LayerNorm 沿每个 token 自己的 D 个特征计算。输入 [220,1024] 时,各行分别得到均值和方差,统计量可保留成 [220,1],再广播到各自那一行的 1024 个分量;输出仍为 [220,1024]。

A.3 RMSNorm:不减均值,只按均方根调整尺度

RMSNorm 是 Root Mean Square Normalization,均方根归一化。它先衡量当前向量的整体数值大小,再用这个尺度调整各个分量,使后续计算面对较稳定的输入尺度。计算逐行进行,一行 [1,1024] 输入后仍得到一行 [1,1024]。

RMS(均方根)的计算顺序是:每个数平方 → 取平均 → 开平方。RMSNorm 让同一行的各个分量除以共同的尺度,再乘各自可学习的缩放参数 γ。与 LayerNorm 的关键区别是,它不先减均值:

RMS⁡(x)=1D∑j=1Dxj2,yi=γixi1D∑j=1Dxj2+ε\displaystyle \operatorname{RMS}(x)=\sqrt{\frac{1}{D}\sum_{j=1}^{D}x_j^2},\qquad y_i=\gamma_i\frac{x_i}{\sqrt{\frac{1}{D}\sum_{j=1}^{D}x_j^2+\varepsilon}}

D 是当前向量的特征数;γᵢ 是第 i 个特征的可学习缩放,ε 是稳定分母的小常数。本文 Qwen3 的 RMSNorm 没有 β 偏移项。对同一个三维向量:

x=[1,2,3]13∑jxj2=1+4+93=143RMS⁡(x)≈2.16025y  (ε=0, γi=1)≈[0.46291,0.92582,1.38873]\displaystyle \begin{aligned}x&=[1,2,3]\\ \frac{1}{3}\sum_jx_j^2&=\frac{1+4+9}{3}=\frac{14}{3}\\ \operatorname{RMS}(x)&\approx2.16025\\ y\ \ (\varepsilon=0,\ \gamma_i=1)&\approx[0.46291,0.92582,1.38873]\end{aligned}

在这个例子中,三个数都除以同一个尺度,因而保留 1:2:3 的比例。忽略 ε 且 γ 全为 1 时,非零向量的输出均方根为 1,L2 范数为 √D。学习到的逐特征 γ 会进一步调整比例与整体尺度,输出仍是可正可负的特征值。

为什么有用?若把 [1,2,3] 整体放大成 [10,20,30],均方根也放大十倍;忽略 ε 且采用相同 γ 时,两者得到相同的归一化输出。这减弱了输入整体幅度变化对后续子层的影响。γ 在训练中学到、推理时固定,分母则按当前 token 的当前向量重新计算。部署时应保持与训练权重匹配的归一化结构。

本文固定源码的核心计算如下:变量 var 保存 mean(x²),即平方的平均值;rsqrt(a) 表示 1/√a。源码先转成 float 做统计和归一化,再转回原 dtype 并乘权重:

x = x.float()
var = x.pow(2).mean(dim=-1, keepdim=True)
x.mul_(torch.rsqrt(var + self.eps))
x = x.to(orig_dtype).mul_(self.weight)
摘自 nano-vLLM 的 RMSNorm.rms_forward

在 Qwen3 中,残差流进入 Attention 前、进入 MLP 前各有一次 RMSNorm,走完所有 Block 后还有最终 RMSNorm。源码中的 input_layernorm、post_attention_layernorm 都构造为 RMSNorm,沿每个 token 的 1024 个特征计算。Attention 内的 Q/K Norm 则在拆头之后,分别沿每个 token、每个 Head 的 128 个特征计算;V 直接保留对应投影结果。

源码:RMSNorm 与融合的 add_rms_forward;源码:Qwen3 中各 Norm 的类型与位置。

A.4 同一个向量,比较四种归一化结果

把 x=[1,2,3] 放进不同算子,差异就很直观。下表统一忽略 ε,LayerNorm/RMSNorm 的 γ 全为 1,LayerNorm 的 β 全为 0;数值保留五位小数。

操作

输出

满足什么条件

Softmax

[0.09003,0.24473,0.66524]

非负,三个数之和为 1

L2 归一化:x/||x||₂

[0.26726,0.53452,0.80178]

平方和为 1,即 L2 范数为 1

LayerNorm

[-1.22474,0,1.22474]

均值为 0,方差为 1

RMSNorm

[0.46291,0.92582,1.38873]

平方的平均值为 1,即均方根为 1

四种操作都保持 [1,3] 的形状,输出的含义由计算目标决定:Softmax 生成权重,L2 归一化控制向量长度,LayerNorm 调整均值与方差,RMSNorm 调整均方根。模型应采用与训练结构一致的算子。

图 A-2 LayerNorm 与 RMSNorm:沿每个 token 的特征轴计算统计量,逐行调整尺度。

图 A-2 LayerNorm 与 RMSNorm:沿每个 token 的特征轴计算统计量,逐行调整尺度。

A.5 SiLU 与 SwiGLU:MLP 中的非线性和门控

MLP 需要对同一位置的特征做非线性加工。这里先解释逐元素函数 SiLU,再把它放进双分支的 SwiGLU;图与公式都以当前 token 已经归一化后的隐藏表示为输入。

先分清层次:SiLU 是作用于单个数的激活函数,SwiGLU 是用它构成的门控结构。 SiLU 的全称是 Sigmoid Linear Unit,也等价于固定 β=1 的 Swish。它对输入数 x 计算 x×sigmoid(x),在向量上则逐个分量独立应用。

线性投影重新组合特征,激活函数引入非线性;连续的纯线性投影可以合并为一次线性变换。SiLU 通过逐元素的非线性加工,让 MLP 表达更复杂的特征关系:

sigmoid⁡(x)=11+exp⁡(−x),SiLU⁡(x)=x sigmoid⁡(x)\displaystyle \operatorname{sigmoid}(x)=\frac{1}{1+\exp(-x)},\qquad \operatorname{SiLU}(x)=x\,\operatorname{sigmoid}(x)
SiLU⁡([−1,0,1])≈[−0.26894,0,0.73106]\displaystyle \operatorname{SiLU}([-1,0,1])\approx[-0.26894,0,0.73106]

这里先把 x 当作一个数;向量输入时逐坐标应用同一个函数。sigmoid 的每个输出仅由对应输入决定,而 Softmax 在一组候选之间共同归一化。

sigmoid(x) 在 0 到 1 之间,但乘回 x 后,SiLU 的输出可以为负,也可以大于 1。与把负数全部清零的 ReLU 不同,SiLU 是平滑变化的;它保持输入形状。[1,3072] 经过 SiLU,仍是 [1,3072]。

SwiGLU 可以理解为 Swish 门控线性单元(GLU,Gated Linear Unit)的一个变体。在本文模型里,MLP 取 Attention 与残差相加、再经 RMSNorm 后的当前 token 表示 x,用两组不同权重投影出 gate 与 up。gate 经 SiLU 后,逐特征调制 up,最后再投影回隐藏宽度:

设输入宽度 D=1024,中间宽度 I=3072。g 表示 gate 分支,u 表示 up 分支,z 是门控后的中间向量,m 是 MLP 输出:

g⏟[1,I]=x⏟[1,D]Wgate⏟[D,I]u⏟[1,I]=x⏟[1,D]Wup⏟[D,I]z⏟[1,I]=SiLU⁡(g)⊙um⏟[1,D]=zWdown⏟[I,D]\displaystyle \begin{aligned}\underbrace{g}_{[1,I]}&=\underbrace{x}_{[1,D]}\underbrace{W_{\mathrm{gate}}}_{[D,I]}\\[6pt]\underbrace{u}_{[1,I]}&=\underbrace{x}_{[1,D]}\underbrace{W_{\mathrm{up}}}_{[D,I]}\\[6pt]\underbrace{z}_{[1,I]}&=\operatorname{SiLU}(g)\odot u\\[6pt]\underbrace{m}_{[1,D]}&=z\underbrace{W_{\mathrm{down}}}_{[I,D]}\end{aligned}

并置表示矩阵乘法;⊙ 表示对应坐标相乘,不沿特征求和。三组 W 是训练得到的投影参数,推理中固定;g、u、z 则随当前输入改变。

gate 与 up 来自同一个 x 的两组不同投影。SiLU(g) 对 up 的对应分量进行连续数值调制,使各分量的贡献随输入内容变化。公式采用行向量右乘权重的数学形状,PyTorch Linear 的权重通常按其转置布局存储。

图 A-3 SwiGLU:同一输入分成 gate/up 两路,在逐元素乘法处汇合,再投影回残差宽度。

图 A-3 SwiGLU:同一输入分成 gate/up 两路,在逐元素乘法处汇合,再投影回残差宽度。

把中间宽度缩成 3 做手算。若 g=[−1,0,1]、u=[2,3,4],则:

z=SiLU⁡(g)⊙u≈[−0.26894×2, 0×3, 0.73106×4]≈[−0.53788,0,2.92423]\displaystyle z=\operatorname{SiLU}(g)\odot u\approx[-0.26894\times2,\ 0\times3,\ 0.73106\times4]\approx[-0.53788,0,2.92423]

z 经 W_down 投影后得到最终 MLP 输出。源码将 gate/up 两次投影合并成 gate_up_proj:先得到 [T,6144],拆为两个 [T,3072],对 gate 逐元素应用 SiLU 后与 up 相乘,得到 [T,3072],最后映射回 [T,1024]。

这里的 SiLU 和 Softmax 是固定数学函数;gate/up/down 的投影权重与 Norm 的 γ 等属于训练得到的模型参数。同一层 MLP 对所有 token 逐行应用同一组投影权重和激活函数。

源码:SiluAndMul 的拆分与逐元素乘法。对应正文第 2.5 节。

A.6 GQA:多个 Q Head 共享一组 K/V,但各自计算权重

GQA 在保留 Attention 匹配与加权汇总计算的基础上,让多个 Q Head 共用历史 K/V。先看头的配对关系,再看每个 Q Head 如何独立完成计算。

GQA 是 Grouped-Query Attention,分组查询注意力。在普通 MHA 中,每个 Q Head 对应自己的一组 K/V;GQA 将 Q Head 分组,让同组的几个 Q Head 读取同一组 K/V。这里“一组 K/V”指某个 KV Head 在所有可读位置上的 K 矩阵和 V 矩阵,每个位置仍保留各自的向量。

以 Qwen3-0.6B 为例:16 个 Q Head、8 个 KV Head,每头 d=128。Q Head 1、2 共享 KV Head 1,Q Head 3、4 共享 KV Head 2,依此类推。这里头编号从 1 开始方便阅读,源码索引从 0 开始。

对 Decode 的一个新位置,Q 为 [1,16,128],新 K、V 各为 [1,8,128]。若本请求连同当前共有 S 个可读位置,该层历史加当前的 K、V 各为 [S,8,128]。同组 Q Head 读取其中同一个 KV Head 对应的 [S,128] 矩阵。

图 A-4 GQA 配对:同组 Q Head 共享历史 K/V,各自计算位置权重和单头输出。

图 A-4 GQA 配对:同组 Q Head 共享历史 K/V,各自计算位置权重和单头输出。

只放大第一组:q⁽¹⁾、q⁽²⁾ 各为 [1,128],共用 K⁽¹⁾、V⁽¹⁾,各为 [S,128]。上标表示 Head 编号,两个 q 来自不同的投影分支。这里的 q、k 已完成模型要求的 Head Norm 与 RoPE。

w(1)⏟[1,S]=softmax⁡ ⁣(q(1)(K(1))T128+M)a(1)⏟[1,128]=w(1)V(1)w(2)⏟[1,S]=softmax⁡ ⁣(q(2)(K(1))T128+M)a(2)⏟[1,128]=w(2)V(1)\displaystyle \begin{aligned}\underbrace{w^{(1)}}_{[1,S]}&=\operatorname{softmax}\!\left(\frac{q^{(1)}(K^{(1)})^{\mathsf T}}{\sqrt{128}}+M\right)\\[6pt]\underbrace{a^{(1)}}_{[1,128]}&=w^{(1)}V^{(1)}\\[10pt]\underbrace{w^{(2)}}_{[1,S]}&=\operatorname{softmax}\!\left(\frac{q^{(2)}(K^{(1)})^{\mathsf T}}{\sqrt{128}}+M\right)\\[6pt]\underbrace{a^{(2)}}_{[1,128]}&=w^{(2)}V^{(1)}\end{aligned}

两个头各自得到一组位置权重和一个 128 维输出。16 个头全部完成后,沿特征方向拼接为 2048 维,再通过输出投影回到隐藏宽度:

A⏟[1,2048]=Concat⁡ ⁣(a(1),…,a(16)),o⏟[1,1024]=AWO⏟[2048,1024]\displaystyle \underbrace{A}_{[1,2048]}=\operatorname{Concat}\!\left(a^{(1)},\ldots,a^{(16)}\right),\qquad \underbrace{o}_{[1,1024]}=A\underbrace{W_O}_{[2048,1024]}

用两个一维 query 观察独立加权的结果:取 q⁽¹⁾=1、q⁽²⁾=−1,共享 K=[0,1,2]ᵀ、V=[10,20,30]ᵀ,三个位置均可见。由于 d=1,缩放分母为 1:

query

分数

权重

加权输出

q⁽¹⁾=1

[0,1,2]

[0.09003,0.24473,0.66524]

a⁽¹⁾≈25.7521

q⁽²⁾=−1

[0,−1,−2]

[0.66524,0.24473,0.09003]

a⁽²⁾≈14.2479

真实模型是每头 128 维,原理相同。采用行向量右乘权重的数学写法,W_Q 为 [1024,2048],W_K、W_V 各为 [1024,1024]。因此 Q 总宽度为 2048,残差流隐藏宽度为 1024。

为什么要共享?Decode 持续读取历史 K/V;在其他条件相同的情况下,KV 数据量与 KV Head 数成正比。若每元素占 b 字节,一个层的有效 KV 数据量为:

MKV,layer=2SHKVdb,MGQAMMHA=HKVHQ=816=12\displaystyle M_{\mathrm{KV,layer}}=2S H_{\mathrm{KV}}db,\qquad \frac{M_{\mathrm{GQA}}}{M_{\mathrm{MHA}}}=\frac{H_{\mathrm{KV}}}{H_Q}=\frac{8}{16}=\frac12

H_Q、H_KV 分别是 Q Head 与 KV Head 数;2 代表 K、V 两份数据。以 S=1024、d=128、BF16 的 b=2 为例,本模型每层有效 KV 为 4 MiB,同样 16 个 Q Head 的 MHA 为 8 MiB。这里统计有效数据量;引擎预分配池与其他显存开销的估算见第 4 章。

GQA 仍保留 16 个 Q Head 的匹配与加权计算。KV 缓存量在上述条件下减半,计算量和实际延迟则需分别分析。GQA 的头数与投影权重在训练结构中相互配套,部署时应按模型配置加载。

结构

固定 16 个 Q Head 时的 KV Head 数

配对关系

MHA

16

每个 Q Head 对应自己的一组 K/V。

GQA(本文)

8

每两个 Q Head 共享一组 K/V。

MQA

1

所有 Q Head 共享唯一一组 K/V。

配置与实现可对照 Qwen3-0.6B 官方配置和固定版本的 Qwen3Attention。

A.7 RoPE:旋转 Q/K 的分量,让匹配分数感知位置

RoPE 解决“向量怎样携带位置关系”的问题。先把一对特征画成二维箭头,再扩展到一个 128 维 Head,最后看它怎样影响 Q/K 点积以及 KV Cache。

RoPE 是 Rotary Position Embedding,旋转位置编码。因果 mask 控制可读范围,RoPE 按位置旋转 Q/K 的分量,让点积同时利用内容与位置关系。旋转在 Head 原有特征空间内完成,128 维输入仍得到 128 维输出。

先把 128 维降到两个数:取 q 或 k 中的一对分量 [a,b],把它看成平面上的一个箭头。位置为 p 时,按角度 φ=p×ω 旋转,ω 是这一对分量使用的角频率。旋转会混合这两个分量,但不改变这一对的长度,也不改变整个向量的特征数:

图 A-5 RoPE:一对分量在二维平面旋转;双方的位置角之差进入 Q/K 点积。

图 A-5 RoPE:一对分量在二维平面旋转;双方的位置角之差进入 Q/K 点积。

下面把这一对特征坐标 a、b 竖写成列向量,便于直接展示二维旋转矩阵。列写是这里的数学记法,模型的实际张量布局保持原样:

[a′b′]=[cos⁡φ−sin⁡φsin⁡φcos⁡φ]⏟R(φ)[ab],φ=pω\displaystyle \begin{bmatrix}a'\\b'\end{bmatrix}=\underbrace{\begin{bmatrix}\cos\varphi&-\sin\varphi\\\sin\varphi&\cos\varphi\end{bmatrix}}_{R(\varphi)}\begin{bmatrix}a\\b\end{bmatrix},\qquad \varphi=p\omega
a′=acos⁡φ−bsin⁡φ,b′=asin⁡φ+bcos⁡φ\displaystyle a'=a\cos\varphi-b\sin\varphi,\qquad b'=a\sin\varphi+b\cos\varphi
R ⁣(π2)[10]=[01],(a′)2+(b′)2=a2+b2\displaystyle R\!\left(\frac{\pi}{2}\right)\begin{bmatrix}1\\0\end{bmatrix}=\begin{bmatrix}0\\1\end{bmatrix},\qquad (a')^2+(b')^2=a^2+b^2

这个教学例子取 90°,用于展示方向变化和长度保持。实际旋转角由位置 p 与该分量对的频率 ω 共同决定。

128 维 Head 分成 64 对,每对使用不同角频率。本文固定源码采用前后半段配对,零起始索引为 (0,64)、(1,65)、…、(63,127)。第 r 对的频率与位置角为:

ωr=θ−2r/d,φp,r=pωr,r=0,…,d2−1\displaystyle \omega_r=\theta^{-2r/d},\qquad \varphi_{p,r}=p\omega_r,\qquad r=0,\ldots,\frac d2-1

d=128;r 是分量对编号,p 是 token 的逻辑位置;θ 是配置中的 rope_theta,本模型取 1000000。r=0 的频率为 1,后续频率逐渐降低,以不同转速表达位置。实现中的分量配对布局应与模型权重约定保持一致。

为什么会得到相对位置? 仍只看同一对分量,把旋转前的 q、k 竖写成列向量。q 位于 p,k 位于 j;利用旋转矩阵的转置等于反向旋转:

q~p=R(pω)q,k~j=R(jω)k\displaystyle \widetilde q_p=R(p\omega)q,\qquad \widetilde k_j=R(j\omega)k
q~pTk~j=qTR(pω)TR(jω)k=qTR ⁣((j−p)ω)k\displaystyle \begin{aligned}\widetilde q_p^{\mathsf T}\widetilde k_j&=q^{\mathsf T}R(p\omega)^{\mathsf T}R(j\omega)k\\[6pt]&=q^{\mathsf T}R\!\left((j-p)\omega\right)k\end{aligned}

两次绝对旋转在点积中留下的是位置差 j−p。固定旋转前 q=k=[1,0]ᵀ,取教学频率 ω=π/4;位置 p=2、j=1 时,点积为 cos(−π/4)≈0.70711。把两者同时移到 p=3、j=2,角度差不变,点积仍相同。

上述相对位置关系成立于固定旋转前 q、k 的条件下。完整模型的输出还受隐藏表示、上下文和 mask 影响;完整 128 维点积由 64 对分量的贡献相加而成,最终权重由内容与位置共同决定,随距离变化可以呈非单调关系。

在本文 Qwen3 路径里,隐藏表示先投影出 Q/K/V;Q、K 各自经过 Head RMSNorm 和 RoPE,再用旋转后的 Q/K 算分数与权重,最后加权汇总对应的 V。Q 保持 [1,16,128],新 K、V 保持 [1,8,128];RoPE 仅更新 Q/K 的分量数值。

本文 KV Cache 保存已做 Head Norm 和 RoPE 的 K,以及对应的 V。在固定位置编码配置下,Decode 按新位置编号处理新 Q/K,历史 K 保持原位置的旋转结果并直接复用。逻辑位置用于位置编码,物理槽位用于定位存储;搬移缓存时保留原来的逻辑位置信息。

标准 RoPE 按位置与频率规则计算正弦、余弦。本文配置 rope_theta=1000000、rope_scaling=null;theta 控制旋转频率的分布;模型可可靠使用的上下文范围还取决于训练与配置,扩展时需要专门验证。固定源码 RotaryEmbedding 预先生成 cos/sin 表,按 positions 取行;apply_rotary_emb 用 chunk 分成前后半段,计算两路旋转,再沿特征轴拼回。

A.8 回到同一轮请求:核对形状与计算轴

代码中的 dim=-1 表示最后一个轴,具体含义由张量布局决定。沿用正文 N=2、T=220 的第二轮 Prefill:A 先前缓存 512,本轮新算 188,K/V 范围 S_A=700;B 新算 32,S_B=32。下面核对同一批输入在不同算子中的计算对象。

位置与输入

在哪一组数上计算

输出与含义

残差流 RMSNorm:[220,1024]

每个 token 自己的 1024 个特征

[220,1024],调整每行特征尺度

Q 的 RMSNorm:[220,16,128]

每个 token、每个 Q head 的 128 个特征

[220,16,128],不把不同头或 token 混在一起

K 的 RMSNorm:[220,8,128]

每个 token、每个 KV head 的 128 个特征

[220,8,128];本文 V 不经过对应的 Q/K Norm

RoPE:Q [220,16,128]、K [220,8,128]

每个位置、每个 Head 内的 64 对分量,按该位置编号旋转

Q/K 形状不变;V 不做这一步旋转

GQA:16 个 Q Head,8 个 KV Head

同一请求内,每两个 Q Head 读取同一 KV Head;各自沿可读位置计算权重

仍得到 16 个 Head 输出,各 128 维;拼接宽度为 2048

A 的单个 head 分数:[188,700]

每条 query 对应的 key 位置,应用 causal mask

[188,700] 的权重;FlashAttention 不必完整物化它

词表 logits:[2,151936]

每条请求的 151936 个候选 token

[2,151936] 的概率,再采样成两个 token id

SwiGLU 的两路输入:各 [220,3072]

相同位置、相同特征逐元素相乘

[220,3072],随后 down_proj 回到 1024 维

残差相加将同形状的当前输入与子层输出逐元素相加,保留位置轴与特征轴。一个 Pre-Norm Block 在数学上是:

h=x+Attn⁡ ⁣(RMSNorm⁡1(x)),y=h+MLP⁡ ⁣(RMSNorm⁡2(h))\displaystyle h=x+\operatorname{Attn}\!\left(\operatorname{RMSNorm}_1(x)\right),\qquad y=h+\operatorname{MLP}\!\left(\operatorname{RMSNorm}_2(h)\right)

x、h、y 均为 [T,1024];Attn 已包含各头汇总、拼接与输出投影。融合的 Add+RMSNorm 改变执行组织,不改变先相加再归一化的数学关系;源码的双路径存储方式见 附录 B.3 节。

附录 B. 按问题选读的深入实验

以下练习共用第 5.1 节的环境、固定源码和实验包。先完成主线中的单请求与合批,再按当前问题选择数学、调度、缓存或执行实验。每项保留运行命令、观察方法和验收条件;已有日志能回答问题时,可直接复用对应证据。

下面按主题提供原理与机制练习,可根据当前问题选择。已有日志能回答问题时直接复用;同一主题的命令、运行结果与学习笔记使用一致的名称。

B.1 数学与缓存等价:固定输入再比较

B.1.1 原理练习:从文本、token 到采样概率

对应阅读第 1.1 节和第 1.3.1–1.4 节:从 token id、向量到 logits,再走三轮生成。先回答“一个 token 一定是一个汉字吗”。练习使用三 token 中文示例;完整无缓存对照见第 1.5 节。

步骤 1|运行真实分词与教学采样。脚本对一段固定中英文文本做 encode/decode,对人为给定的 [2,1,0] 做 softmax,并打印关闭 thinking 的 Qwen3 chat template。此处运行 tokenizer 与人为分数的教学计算,模型权重在后续实验中加载。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/principles.py tokenizer --model "$MODEL_DIR" --out runs/tokenizer.json
cat runs/tokenizer.json

步骤 2|对照输出写记录。打开 notes/tokenizer.md,抄下真实 ids、token_pieces 和 decoded;看到单个中文 token piece 像乱码时,对照完整 decode,token 的内部字节表示不一定是独立可读字符。记录 chat_template_text 如何包含角色边界与生成提示。

预期与验收:decoded 与原文逐字相同;probabilities 约为 [0.66524,0.24473,0.09003],greedy_index=0;随机采样频率接近概率但不要求完全相等。输出 check=PASS。补写两句:权重推理时通常固定,上下文/缓存随生成增长;greedy 每次取最大值,采样按分布抽取。卡住时:缺 tokenizer.json 就重跑 5.1 的分词下载;不要用不存在的模型路径继续。

B.1.2 原理练习:手算并验证一次因果 Attention

步骤 1|预测因果约束并运行实验。对应阅读第 1.2.1–1.2.3 节与第 1.4.1 节:从单头到多头,再理解因果 mask。下面实验采用 Q=K=V=X;本节末尾另保留不同 V 的手算对照,附录 B.8.1 解释这些对照能证明什么。先预测修改未来位置是否影响过去位置,再运行验证。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python labs/01_attention_numpy.py | tee runs/attention-reference.log
python e2e/principles.py attention --out runs/attention.json
sed -n '1,150p' e2e/principles.py

步骤 2|手算最后一行。X=[[1,0],[0,1],[1,1]]。写出 Q/K/V=[3,2]、score=[3,3]、output=[3,2];最后一行 score=[1/√2,1/√2,2/√2],softmax 后权重约 [0.2483,0.2483,0.5035],加权输出约 [0.7517,0.7517]。把过程写入 notes/attention.md。

步骤 3|验证 mask 的作用。同一脚本将最后一个位置改为 [9,-5],分别保留和去掉 causal mask,自动对比前两个位置。验收:masked_earlier_delta=0,unmasked_earlier_delta>0(本输入约 8.15455),各行权重和为 1,check=PASS。能解释为什么不允许较早位置读取未来信息。卡住时:ModuleNotFoundError: numpy 说明没有激活 .venv-nano 或安装未完成。

补充对照。下面两组独立示例分别采用 Q=K=V,以及 V 与 Q/K 取不同数值。按各自输入核对输出,观察匹配权重与被加权内容的不同作用。

实验 01 使用三位置、二维的独立例子 Q=K=V=X,验证因果 mask 和缓存等价性:修改最后位置,检查较早输出不变;追加一个向量,比较全量重算与新 Q 读取旧 K/V。它不使用第一章 4096 维教学模型的实际参数。

attention weights:
 [[1.     0.     0.    ]
 [0.3302 0.6698 0.    ]
 [0.2483 0.2483 0.5035]]
last output: [0.7517 0.7517]
future change leaves earlier positions unchanged: True
cached last output: [0.6304 0.8374]
max error: 0.0
query positions: full=4, cached=1; KV positions=4
本机实际运行输出,省略终端环境提示

先预测去掉 causal mask 后哪个断言会失败,再自行修改观察,并解释 max error=0 成立的原因。本例验证单层单头的计算关系;真实模型还需逐项检查分层缓存、RoPE、GQA 与数值精度。

补充手算:让 V 与 Q/K 不同。 仍用三个位置、每头二维,直接给定 Q=K=[[1,0],[0,1],[1,1]],V=[[1,0],[0,2],[1,2]]。q3=[1,1] 的三个缩放分数为 [0.7071,0.7071,1.4142],softmax 约为 [0.248255,0.248255,0.503490]。

按这些权重加权 V,输出约为 [0.751745,1.503490]。这里 Q/K 决定“如何匹配”,V 决定“混合什么数”;V 刻意取成不同数值,便于区分职责。下面验证这段局部 Attention 的数值关系。

import numpy as np
q = np.array([[1., 0.], [0., 1.], [1., 1.]])
k = q.copy()
v = np.array([[1., 0.], [0., 2.], [1., 2.]])
scores = q @ k.T / np.sqrt(2.)
future = np.triu(np.ones(scores.shape, dtype=bool), k=1)
scores = np.where(future, -np.inf, scores)
e = np.exp(scores - scores.max(axis=-1, keepdims=True))
weights = e / e.sum(axis=-1, keepdims=True)
print(weights[-1])         # [0.24825508 0.24825508 0.50348984]
print((weights @ v)[-1])   # [0.75174492 1.50348984]
局部 Attention 手算;3 个位置、每头 2 维

B.1.3 原理练习:区分已知 token 与已计算 KV

步骤 1|预测三轮时序并运行推演。回看第 1.4 节的三轮时序,再读第 1.4.1–1.5 节。沿第 1.6.2 节的真实模型路径,假设输入 t1~t3、共生成 t4~t6,预测需要几轮 forward,并分别记录每轮已知 token 数与已计算 KV 数。这里按 token 位置跟踪,不预先假定中文文字怎样切分。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/principles.py timeline --out runs/generation-timeline.json
cat runs/generation-timeline.json

步骤 2|逐项核对三轮。第 1 轮 Prefill 输入 t1,t2,t3,KV 覆盖 t1–t3,预测 t4;第 2 轮 Decode 输入 t4,KV 到 t4,预测 t5;第 3 轮输入 t5,KV 到 t5,预测 t6。把 rounds 整理成“forward 输入 / KV 覆盖范围 / 刚输出 token”三列表。

验收:每轮 known_token_count=kv_count+1;第一轮输入宽度 3,后两轮各 1;check=PASS。能回答“输出 t4 时为什么还没有 t4 的 KV”。这一项属于符号推演验收。卡住时:若把新输出 token 立刻算进 KV,回到生成循环,区分 forward 与采样这两个动作。

B.1.4 原理练习:验证 KV Cache 省掉了哪些计算

步骤 1|运行缓存等价性对照。对应阅读第 1.5 节的因果性、逐层依赖与缓存对照。先在 CPU 上验证等价关系与位置计数,附录 B.1.6 节再用真实模型比较 logits。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/principles.py cache --out runs/kv-cache-math.json
python labs/01_attention_numpy.py | tee runs/attention-cache-equivalence.log

步骤 2|核对输入位置数。S=64、生成 G=8 时,不缓存路径的每轮输入是 64–71,求和 540;缓存路径是 [64,1,1,1,1,1,1,1],求和 71。脚本用同一固定输入比较“完整重算最后位置”与“只算新 query 并读取历史 K/V”。

验收:max_error=0 或处于浮点舍入量级,full_last 与 cached_last 一致,位置总数为 540/71,check=PASS。在 notes/kv-cache-math.md 写清:新 query 读取历史 K/V,历史 Q 可在本轮使用后释放;540/71 衡量处理位置数,GPU 加速比另行测量。卡住时:固定两条路径的输入 token,再逐步对比同一位置输出。

选做:沿完整小模型追踪无缓存计算

先对照两段逻辑伪代码:无缓存每轮输入完整前缀,有缓存只在首轮输入 prompt,之后输入刚选出的 token。两条路径都应使用同一组模型参数、位置语义和停止条件。

ids = tokenizer.encode(prompt)
new_ids = []
for _ in range(max_new_tokens):
    logits = model_forward(ids)   # 每轮重算全部已知位置
    next_id = choose(logits[-1])
    ids.append(next_id)
    new_ids.append(next_id)
    if next_id == eos_id:
        break
answer = tokenizer.decode(new_ids)
无缓存逻辑伪代码;用于对照重复计算
prompt_ids = tokenizer.encode(prompt)
cache = None
pending_ids = prompt_ids
new_ids = []
for _ in range(max_new_tokens):
    logits, cache = model_forward(pending_ids, cache)
    next_id = choose(logits[-1])
    new_ids.append(next_id)
    if next_id == eos_id:
        break
    pending_ids = [next_id]       # 下一轮只计算刚选出的新位置
answer = tokenizer.decode(new_ids)
有缓存逻辑伪代码;model_forward 返回本轮各位置的 logits 及更新后的各层 KV

下面保留原有 00_uncached_walkthrough.py,作为可选的完整网络对照:4 维表示、2 个单头 Block、RMSNorm、加性位置向量、ReLU MLP 和固定随机权重。模型未经训练,6 个 ID 不对应中文,也没有 EOS;固定三轮输入长度为 3→4→5,共处理 12 个位置。它便于逐层追踪向量,不用于评价语言能力。

运行包内 pedagogy/00_uncached_walkthrough.py,在 CPU 上使用内置教学小模型,沿 Embedding→Norm→Q/K/V→Attention→残差→MLP→LM Head 追踪同一位置。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-nano/bin/activate
python pedagogy/00_uncached_walkthrough.py | tee runs/uncached-walkthrough.txt

验收先看 full input ids 长度是否为 3、4、5,hidden state 行数相应变化、列数始终为 4,Attention 分数形状分别为 [3,3]、[4,4]、[5,5]。末尾应出现 total input positions = 12 和 CHECK: PASS。causal prefix error 应接近 0,表示追加未来位置不改变旧前缀表示(允许浮点误差)。No module named numpy 时先检查 CPU 虚拟环境是否激活。

B.1.5 原理练习:估算显存并区分延迟与吞吐

步骤 1|运行显存估算。阅读第 4.1、3.3 节的总显存与有效 KV 公式,再对照第 3.4.2 节的整页容量和附录 B.8.3计时示例。本节使用显存估算和教学时间戳,实际 GPU 性能在后续实验中测量。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/principles.py memory --out runs/memory-budget.json
cat "$MODEL_DIR/config.json"

步骤 2|从配置代入公式。KV bytes/token=2(K/V)×28 层×8 KV heads×128 head_dim×2 bytes(BF16)=114688 bytes=112 KiB。单请求 4096 个已缓存位置=448 MiB,8 请求为 3.5 GiB;一个 256-token block 跨全层占 28 MiB。权重与当前工作区、其他运行时另计。按有效位置估算分配量时需补入块尾空槽;按整页或整个 KV 池计算时,空槽已经包含在内。

步骤 3|算一条教学时间线。请求发出为 0 秒,收到四个 token 的时间是 [0.12,0.16,0.21,0.25] 秒:TTFT=120ms,ITL=[40,50,40]ms,TPOT=(250−120)/(4−1)≈43.33ms。写出“离线输出 token 总数 / generate 总耗时”的吞吐口径为何不同。

验收:memory-budget.json 中 112、448、28、3.5 均吻合,check=PASS;notes/memory-budget.md 同时写出显存估算的假设和指标起止点。卡住时:先检查是否误用了 16 个 Q heads;KV 容量要用 8 个 KV heads,KiB/MiB 按 1024 换算。

B.1.6 真实模型对照:有缓存与无缓存的 logits

本节在 5.1 已安装并通过单卡 smoke 的环境中运行,不再新建 venv 或重复安装。目标是把第 1.5 节的缓存依赖落实到真实 Qwen3 logits;这里使用 Transformers 的参考 forward 做有缓存/无缓存对照,后续引擎调度实验再回到 nano-vLLM。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-nano/bin/activate
CUDA_VISIBLE_DEVICES=0 python labs/02_cached_forward.py --model "$MODEL_DIR" --device cuda --prompt-len 64 --steps 8 --repeats 3 2>&1 | tee runs/cached-forward.log
python e2e/analyze.py cache --log runs/cached-forward.log --out runs/cached-forward-check.json

两条路径使用同一条输入 token 轨迹:无缓存每轮输入宽度 64~71,总计 540;有缓存输入宽度 [64,1,1,1,1,1,1,1],总计 71。先看这些计数,再看 max_abs_logit_error、relative_l2_logit_error、same_top1_steps,以及分开记录的 Prefill 和 Decode 时间。

分析器的 PASS 验证计数及误差为有限值;数值误差应结合具体 BF16 实现设置容差。出现 top-1 差异或误差异常时,保留原始输出,核对相同输入、位置、dtype 与 Attention 后端。540/71 记录处理位置数比,性能加速比由独立计时实验给出。

修改与验收:打开 labs/02_cached_forward.py 阅读两条循环,找到 use_cache 与 past_key_values 的传递位置;先保持参数完成默认验收,再另存日志用 --prompt-len 128 对照。默认 analyze.py cache 的计数断言针对 64/8;变更参数时应同步采用相应的预期位置数。

B.2 请求与调度日志:还原选择、回写和抢占

B.2.1 请求生命周期:从日志还原两条请求的一生

目标与阅读:读第 2 章,重点 add_request、step、generate、Sequence.append_token。本实验启用形状 hook,日志同时留给附录 B.3 节使用。

步骤 1|预测后运行。两条请求输入长 64/96,输出限制为 2/3,ignore_eos=True。先在 notes/lifecycle.md 写下每轮哪些请求还活跃,再运行:

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/source_walk.py engine --repo "$NANO_DIR" > runs/lifecycle-source.txt
CUDA_VISIBLE_DEVICES=0 python labs/03_trace_nanovllm.py   --model "$MODEL_DIR" --case lifecycle --shapes 2>&1 | tee runs/lifecycle.log
python e2e/analyze.py lifecycle --log runs/lifecycle.log --out runs/lifecycle-check.json
cat runs/lifecycle-check.tsv

步骤 2|按同一个 id 读四种事件。before_schedule 看队列;scheduled 看本轮 cached/scheduled/blocks;gpu_inputs 看送进 GPU 的输入;after_postprocess 看 token 接受、状态变化和资源回收。seq_id 和物理块 ID 以本次日志为准,warmup 会影响初始编号。

预期与验收:三轮依次 Prefill、Decode、Decode;首次 postprocess 后 total=65/97,cached=64/96;短请求先完成,最终输出长 2/3。结束时 cached=0、blocks=[] 是 deallocate 的结果;未完成请求通常 total=cached+1。lifecycle-check.json 为 PASS,TSV 能逐行解释。卡住时:分析器报告缺事件先检查原始 log 的 Traceback,不要手工补 JSON。

B.2.2 调度实验:分块 Prefill 与抢占

步骤 1|运行分块 Prefill 并观察进度。读第 3 章 schedule、preempt、postprocess。先手写 700/32 token 输入在 budget=512 下的前三轮选择。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/source_walk.py scheduler --repo "$NANO_DIR" > runs/scheduler-source.txt
CUDA_VISIBLE_DEVICES=0 python labs/03_trace_nanovllm.py   --model "$MODEL_DIR" --case chunk 2>&1 | tee runs/chunked-prefill.log
python e2e/analyze.py chunk --log runs/chunked-prefill.log --out runs/scheduler-check.json
cat runs/scheduler-check.tsv

预期观察:第 1 轮只处理 A 的 512 个位置,total 仍为 700、cached 变为 512;中间 chunk 虽有采样结果,postprocess 不接受为输出。第 2 轮处理 A 的 188+B 的 32=220,cu_q=[0,188,220]、cu_k=[0,700,732];第 3 轮进入 Decode。分析器输出 PASS。

步骤 2|用 CPU 精确触发抢占。教学 block size=4、共 4 块,A/B/C 各 4 token;首轮占 3 块并各采样 1 token。Decode 时 A 获得最后一块,B 需要扩块,于是从 running 尾部抢占 C。这个受控例子直接执行真实 Scheduler,不依赖把 GPU 显存耗尽。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/cpu_scheduler.py preempt --repo "$NANO_DIR" --out runs/scheduler-preemption.json

验收:selected 是 A/B,waiting 是 C,C.cached=0、blocks=[],check=PASS。notes/scheduler.md 解释 C 保留了 token 序列,重新入场要用 Prefill 重建已被释放的 KV。CPU 教学块大小取 4 便于手算,真实 nano Config 的块大小要求为 256 的倍数。

B.2.3 分页实验:从逻辑位置算到物理 cache slot

步骤 1|读取寻址公式并生成实验记录。读第 3 章与 prepare_prefill/prepare_decode 的 slot 公式。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/source_walk.py blocks --repo "$NANO_DIR" > runs/kv-pages-source.txt
python e2e/principles.py slots --out runs/kv-slots.json
python e2e/analyze.py slots --log runs/lifecycle.log --out runs/kv-slots-runtime.json

步骤 2|先算教学例,再对照真实日志。B=4、block_table=[17,5,23],位置 0/6/8 对应 slot 68/22/92。统一公式为 block_table[position//B]×B+position%B。真实实验 B=256,分析器使用每个 scheduled 事件的 block_table 重算各轮前 8 个 slot,与 gpu_inputs 逐个相等才 PASS。

步骤 3|解释边界。长度 255/256/257 对应 1/1/2 个逻辑块;采样出第 257 个 token 时该 token 的 KV 尚未计算,下一轮 forward 前才需 may_append 分配新块。验收:两个 JSON 均 PASS,并在 notes/kv-pages.md 写出逻辑块、物理块、块内偏移的区别。无 GPU 日志时只完成教学计算,真实 slot 项保持未运行。

图 B-1 队列、计算预算和 KV 容量共同决定本轮选择。

图 B-1 队列、计算预算和 KV 容量共同决定本轮选择。

B.3 执行实验:把 Runner 元数据接到模型形状

步骤 1|准备源码与形状对照。读第 3.6 节,手算正文第二轮的 220 个新位置,再复用 B.2.1 与 B.6 的 GPU 日志核对相同规则。两组参数分别记录:lifecycle 输入长 64/96,合计 160;正文 chunk 主例输入长 700/32,第二轮新算 188+32=220。下面 lifecycle 的形状均使用 T=160。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/source_walk.py runner --repo "$NANO_DIR" > runs/runner-source.txt
python e2e/source_walk.py model --repo "$NANO_DIR" > runs/model-source.txt
python e2e/principles.py shapes --tp 1 --out runs/model-shapes.json
python e2e/analyze.py runner --log runs/lifecycle.log --out runs/runner-check.json
python e2e/analyze.py shapes --log runs/lifecycle.log --out runs/model-shapes-check.json
cat runs/prefix-cache-check.json

步骤 2|对照三组元数据。lifecycle Prefill 的 input_shape=[160],cu_q/cu_k 都是 [0,64,160],没有历史 block_tables;首次 Decode 输入 [2],positions=[64,96]、context_lens=[65,97];prefix 的 query=108、key=620,需从页表读取历史缓存。

步骤 3|核对 layer 0 的 hook。首次 Prefill:qkv_proj 输出 [160,4096];Q=[160,16,128],K/V=[160,8,128];o_proj 回到 [160,1024];gate_up=[160,6144];down_proj=[160,1024];LM Head 的 logits=[2,151936]。T=160 是输入位置数,N=2 是本轮序列数;Prefill 在 LM Head 中挑每条序列的最后位置。首次 Decode 的 qkv 输出 [2,4096]。

把实验数据与正文连接起来:lifecycle 的末行索引为 [63,159],chunk 第二轮则为 [187,219],都来自 cu_seqlens_q[1:]−1,最终都是两行 logits。对照本节的残差路径图与第 1.3 节公式,在 notes/model-execution.md 写出 h=x+Attn(Norm1(x))、y=h+MLP(Norm2(h));h 表示经过多头 Attention、输出投影与残差相加后的结果;单头加权结果记为 a。再定位融合 Norm 的 residual 参数在哪里完成相加。

步骤 4|定位权重如何装进去。在 model-source.txt 搜 packed_modules_mapping、load_model,再读 layers/linear.py 的 QKVParallelLinear.weight_loader。TP=1 时 Q/K/V 分别写入 qkv 权重的行区间 [0,2048)、[2048,3072)、[3072,4096)。三个区间宽度分别由 Q/K/V 的头数乘 head_dim 得到:2048、1024、1024,合计 4096。验收:三个 JSON 均 PASS,在 notes/model-execution.md 记录一条真实形状与对应源码函数。如果提示缺 model_shape,用附录 B.2.1 节的 --shapes 命令重跑日志。

图 B-2 拼接输入行、请求内 positions 与累计边界分别表示什么。

图 B-2 拼接输入行、请求内 positions 与累计边界分别表示什么。

图 B-3 新 ID 与元数据从 CPU 传入,权重和 KV 常驻 GPU。

图 B-3 新 ID 与元数据从 CPU 传入,权重和 KV 常驻 GPU。

图 B-4 残差的数学表达与 nano 的双路径实现。hidden_states 与 residual 分别传递,在融合 Norm 中完成残差相加。

图 B-4 残差的数学表达与 nano 的双路径实现。hidden_states 与 residual 分别传递,在融合 Norm 中完成残差相加。

B.4 性能实验:eager 与 CUDA Graph

目标与阅读:读第 4 章 Attention.forward、store_kvcache_kernel、run_model、capture_cudagraph。本实验启用不带 trace/hook 的 benchmark。

运行前对照本节的 FlashAttention 与 CUDA Graph 两张细读图:前者通过分块与在线 Softmax 减少中间矩阵存储,后者复用执行图、每轮更新输入并重新计算。本节固定其余条件,仅比较 eager 与 Graph,所得性能差异对应这一项执行模式变化。

步骤 1|两个独立进程顺序运行。固定 16 请求、输入 128 token、输出 32 token,ignore_eos=True,每轮应有 512 输出 token。正式测量前预热 2 次,测量 3 次;两组只改 enforce_eager。运行期间保持同一 GPU、没有其他负载。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/source_walk.py kernels --repo "$NANO_DIR" > runs/cuda-graph-source.txt
CUDA_VISIBLE_DEVICES=0 python labs/04_benchmark_nanovllm.py   --model "$MODEL_DIR" --eager --seqs 16 --max-num-seqs 32   --input-len 128 --output-len 32 --repeats 3 2>&1 | tee runs/cuda-graph-eager.log
CUDA_VISIBLE_DEVICES=0 python labs/04_benchmark_nanovllm.py   --model "$MODEL_DIR" --seqs 16 --max-num-seqs 32   --input-len 128 --output-len 32 --repeats 3 2>&1 | tee runs/cuda-graph-replay.log
python e2e/analyze.py compare --variable eager   --log runs/cuda-graph-eager.log --other runs/cuda-graph-replay.log --out runs/cuda-graph-comparison.json

步骤 2|读结果与源代码。分别记 init_seconds、三轮吞吐、median_tokens_per_second 和 KV block 数;比较文件给出 Graph/eager 的吞吐比。定位 graph_bs=[1,2,4,8,16,32]:batch=9 选择 16 桶,填充的 slot_mapping=-1 使 KV 写入 kernel 跳过;Graph 捕获主要是 Decode 的 model forward,LM Head 与采样在捕获外。

验收:每轮 output_tokens=512、配置仅 eager 不同、分析器 PASS。保留全部测量轮次,写出设备、软件、负载、重复次数和实际数字,解释 Graph 变快或变慢的原因。这里测量包含 Prefill/Decode 的离线输出吞吐。卡住时:先确认 5.1.6 eager smoke 成功;Graph 初始化失败时保留日志并标明未完成。本实验采用既定桶配置,小于 16 的 max_num_seqs 需另行验证。

图 B-5 FlashAttention:每个 query 跨块维护最大分数 m、指数和 l、未归一化的加权 V 分子 u;最大值变化时同时重标定旧 l/u,遍历完成才得到 u/l。计算 tile 控制分块计算,KV page 控制缓存分配,两种粒度独立选择。

图 B-5 FlashAttention:每个 query 跨块维护最大分数 m、指数和 l、未归一化的加权 V 分子 u;最大值变化时同时重标定旧 l/u,遍历完成才得到 u/l。计算 tile 控制分块计算,KV page 控制缓存分配,两种粒度独立选择。

图 B-6 CUDA Graph:捕获执行安排,运行时更新固定缓冲区再重放。

图 B-6 CUDA Graph:捕获执行安排,运行时更新固定缓冲区再重放。

Graph 桶边界也要测试:本提交在 16 以后按 16 递增捕获,并不会自动覆盖任意 max_num_seqs 尾值。例如上限为 20 时,可能只有到 16 的桶;修改时应确保所有允许批量有覆盖桶,或者显式回退 eager。填充行的 slot_mapping=-1、context_lens=0,不得写真实缓存。

B.5 边界修复:CUDA Graph 没有覆盖桶时回退 eager

现在用一个小边界完成“复现—修改—回归”。CUDA Graph 为预先捕获的 batch size 保存执行图,实际批量可选择更大的桶并补齐。固定提交在 16 以后按 16 递增捕获;max_num_seqs=20 时桶为 [1,2,4,8,16],17 个请求进入 Decode 后,next(...) 找不到覆盖桶而抛出 StopIteration。

步骤 1|复现原版行为。在尚未应用本节补丁的学习分支运行。可以保留 01-step-trace.patch,它修改的是另一处日志逻辑。先保存已有 diff,再让脚本检查桶内、补齐与越界批量。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-nano/bin/activate
set -o pipefail
git -C "$NANO_DIR" diff > runs/before-fallback.diff
CUDA_VISIBLE_DEVICES=0 python e2e/logits_check.py collect \
  --backend nano --execution graph --suite graph --expect-uncovered \
  --model "$MODEL_DIR" --out runs/graph-before 2>&1 | tee runs/graph-before.log

预期在 batch=17 的 Decode 复现错误,输出 expected_failure=true,并保存 runs/graph-before.json。这里的 PASS 仅表示抓到了指定故障;其他异常仍会失败。若已经应用修复,脚本会报告“预期故障未出现”,此时直接核对已有 diff,无需覆盖自己的修改。

步骤 2|阅读并应用最小补丁。打开 nanovllm/engine/model_runner.py 的 run_model:先寻找实际存在且能覆盖当前 batch 的桶;找到则保留原有回放路径,找不到则走相同模型的 eager forward。eager 仍使用 FlashAttention 和 KV Cache。Prefill、显式 enforce_eager 和超过 512 的原有分支保持 eager。

cd "$LAB_ROOT"
git -C "$NANO_DIR" apply --check "$LAB_ROOT/patches/02-graph-eager-fallback.patch"
git -C "$NANO_DIR" apply "$LAB_ROOT/patches/02-graph-eager-fallback.patch"
git -C "$NANO_DIR" diff -- nanovllm/engine/model_runner.py > runs/graph-fallback.diff
git -C "$NANO_DIR" diff --check

也可以按补丁手工修改同一函数。apply --check 发现冲突时先查看已有改动;应用后重新启动实验进程,让 editable 安装加载新代码。本补丁解决运行时缺少覆盖桶的问题,捕获阶段的其他配置边界仍需单独验证;本实验固定上限为 20。

步骤 3|对照 eager 与修复后的 Graph 路径。继续使用固定 token 轨迹,测试 1、9、16、17、20 个异长请求。每个请求先 Prefill,再输入一个新 token 进行 Decode,共采集 126 行 logits。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-nano/bin/activate
set -o pipefail
CUDA_VISIBLE_DEVICES=0 python e2e/logits_check.py collect \
  --backend nano --execution eager --suite graph \
  --model "$MODEL_DIR" --out runs/graph-eager 2>&1 | tee runs/graph-eager.log
CUDA_VISIBLE_DEVICES=0 python e2e/logits_check.py collect \
  --backend nano --execution graph --suite graph \
  --model "$MODEL_DIR" --out runs/graph-patched 2>&1 | tee runs/graph-patched.log
python e2e/logits_check.py compare \
  --lhs runs/graph-patched --rhs runs/graph-eager \
  --max-abs 0.5 --rel-l2 0.02 --kv-max-abs 0.1 \
  --out runs/graph-fallback-check.json

Decode 批量

预期执行路径

重点

1

graph:1

精确命中捕获桶

9

graph:16

补齐行 slot_mapping=-1、context_lens=0

16

graph:16

桶上边界

17、20

eager

无覆盖桶时完成正常前向

探针记录实际 model 调用或 graph.replay,而非仅根据配置推测路径。回归同时检查:每轮历史有效 KV 保持不变;Graph 补齐元数据安全;新位置在各层写出的 K/V 与 eager 接近;所有对应 logits 在门槛内。新 KV 的初始最大绝对误差门槛为 0.1、相对 L2 为 0.02,与 logits 一样需要结合 BF16 基线复核。结果为 PASS 且 routes 全为 true,才完成本次有限案例验收。

步骤 4|观察代价。本节采集带 Python 探针、数组拷贝与检查,用于正确性。性能使用 附录 B.4 的无探针 benchmark;可使用下方命令比较 batch=17、上限=20 时的回退与显式 eager,并单列 Graph 初始化成本。无需以“速度必须提升”作为本次修复的成功标准,目标是覆盖缺失时仍正确完成请求。

CUDA_VISIBLE_DEVICES=0 python labs/04_benchmark_nanovllm.py --model "$MODEL_DIR" --eager --seqs 17 --max-num-seqs 20 --input-len 128 --output-len 32 --repeats 3 2>&1 | tee runs/graph-fallback-eager.log
CUDA_VISIBLE_DEVICES=0 python labs/04_benchmark_nanovllm.py --model "$MODEL_DIR" --seqs 17 --max-num-seqs 20 --input-len 128 --output-len 32 --repeats 3 2>&1 | tee runs/graph-fallback-replay.log
python e2e/analyze.py compare --variable eager --log runs/graph-fallback-eager.log --other runs/graph-fallback-replay.log --out runs/graph-fallback-performance.json

在 notes/graph-fallback.md 留下故障原因、最小 diff、路径矩阵、数值结果与性能观察。这组有限案例验证缺少 Graph 桶时仍能正确完成请求。随后回到第 5 章的多副本、TP 或服务化改造,复用同样的验证方法。

B.6 前缀缓存实验:512-token 命中与引用计数

目标与阅读:读第 3 章,重点 can_allocate、hash_blocks、allocate/deallocate。

步骤 1|执行先 A 后 B 的受控前缀实验。A/B 长度都为 620,前 512 token 相同、后 108 不同。A 完全生成结束后再提交 B;这与两个请求同时首次入队不同。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
CUDA_VISIBLE_DEVICES=0 python labs/03_trace_nanovllm.py   --model "$MODEL_DIR" --case prefix 2>&1 | tee runs/prefix-cache.log
python e2e/analyze.py prefix --log runs/prefix-cache.log --out runs/prefix-cache-check.json
cat runs/prefix-cache-check.tsv

预期观察:prefix_second_request 后 B 首次 scheduled 为 cached=512、scheduled=108;GPU 的 cu_q=[0,108]、cu_k=[0,620],positions 从 512 开始,block_tables 非空。由此解释“本轮只算 108 个 query,却读到 620 个位置的 K/V”。

步骤 2|执行 CPU 引用计数实验。同样调用固定源码 BlockManager,受控登记完整块的 hash,再执行释放、重新激活、并发共享、逐个释放;这里没有计算真正 KV 张量。

source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/cpu_scheduler.py refs --repo "$NANO_DIR" --out runs/prefix-cache-refs.json

验收:两个共享块的 ref_count 依次 [0,0]→[1,1]→[2,2]→[1,1]→[0,0];620 长度命中 2 块,恰好 512 长度最多检查并命中 1 块(can_allocate 使用 range(num_blocks−1))。两项均 PASS。notes/prefix-cache.md 解释 ref_count=0 不代表 GPU 显存池已归还,也不代表 hash 立即失效;旧块被重新分配覆盖时会清理旧映射。

B.7 迁移到多轮对话:构造第二次请求

这项练习在现有前缀实验上新增一个驱动程序。用配套 chat template 生成 R1 的完整 ID,得到回答后,将旧消息、完整回答与新问题重新套模板,构造 R2。先打印两次请求的 token 前缀,确认相同范围,再观察缓存命中。

两次请求使用同一个引擎,记录 R2 的 cached、scheduled 与 block_table;另启动一个新引擎作为冷缓存对照。命中粒度和最后块策略以本提交为准,不以字符串相同或 session ID 相同推定命中。验收要能解释:哪些前缀仍在、哪些后缀必须新算,以及上轮最后输出和模板标记为什么也可能落在后缀里。

B.8 验证速查:数值、状态、指标与排错

B.8.1 正确性:固定输入路径,再比较输出

对有无 KV Cache、完整与分块 Prefill、eager 与 Graph、TP=1 与 TP=2 做数值对照时,应固定模型、位置语义和每一步输入 token。两次独立随机采样可能很早就走向不同 token;此后 logits 不同可能只是输入变了,不一定是实现错误。

数值对照时预先指定同一条续写 token 路径,让两个实现每一步都输入相同 ID,再比较对应位置的整行 logits。常用三个观察量:

观察量

怎样理解

需配合检查

最大绝对误差

逐项相减并取绝对值,再取最大值;找最严重的单个词表分数偏差。

误差分布及整体量级。

相对 L2 误差

差值向量的 L2 长度,除以参考向量的 L2 长度;分母需防止为零。

局部最大误差。

top-1 是否一致

两行 logits 的最大项是否指向相同 token ID。

最大项相同不代表整行数值相同。

浮点累加顺序、dtype 和后端可能造成小差异;两个候选分数极接近时,小误差也可能改变 top-1。因此先报告误差和测试条件,再设定合理阈值,不用一次文本相同或一个万能 PASS 代替数值分析。

最小验证从单层因果 Attention 开始:追加新位置后,旧位置结果不变;新 Q 读取历史 K/V 的最后一行结果,与完整重算对应。之后再扩展到真实模型的多层缓存、RoPE 和 GQA。附录 B.1.2、B.1.4、B.1.6 提供对应练习。

进阶边界:仅需要最后位置 logits 时,最后一个 Block 在数学上可只算最后 query 及其后续 MLP,同时仍生成所有已知位置的该层 K/V。中间层需要为下一层建立各位置的表示,因此保留全位置计算;常规实现也可能统一采用全行执行。

B.8.2 机制:用状态解释这一轮发生了什么

机制验证回答“状态是否按设计变化”。用 seq_id 串起同一条请求,分别记录调度前、调度后、回写后三个时点:num_tokens、num_cached_tokens、num_scheduled_tokens、block_table,以及候选是否被接受。只打印一串数字而不写时点,容易把“尚未计算”和“已经完成”混为一谈。

例如 A 有 700 个已知位置、预算 512:调度后为 700 / 0 / 512,第一轮回写后为 700 / 512 / 0,输出数仍为 0;第二轮补完 188 个位置并接受首输出后,才成为 701 / 700 / 0。看到一个 sampled token 只证明采样器给了候选,不证明输出已经追加。

改动对象

必须保住的关系

可以怎样验证

批处理与路由

请求不丢、不重复,结果回到正确请求

提交、接纳、完成各阶段的 id 集合对应

分页与前缀共享

槽位不串写,活跃共享块不覆盖

块表、有效长度、引用计数与释放前后状态

流式与取消

只发布正式接受的 token,终止只发生一次

事件顺序、输出数、结束原因与缓存回收

多卡

独立副本相互隔离;TP ranks 执行同一批工作

实例/rank 标识、资源名、张量形状与通信记录

物理块号可以不同,逻辑位置到实际槽位的关系必须正确。机制日志会增加 CPU 打包、同步甚至 GPU 数据回传开销,所以用它证明流程,不用它测性能。详细日志与正式计时分开运行。

B.8.3 性能:先写清楚测量范围

性能指标必须写清楚起止时刻和单位。先区分请求体验与整体产出:

指标

测量范围

简单例子

输出吞吐(token/s)

共同测量窗口内完成的输出 token 总数 ÷ 窗口时长;离线整批计时通常包含 Prefill 与 Decode。

1000 个输出 / 2 秒 = 500 token/s。

TTFT(首 token 延迟)

从约定入口提交请求,到收到第一个输出。若从客户端计时,还包括网络与排队。

提交后 100 ms 收到首输出:TTFT=100 ms。

ITL(输出间隔)

同一请求相邻两次 token 输出的时间差;首 token 前的等待不算在内。

随后在 130、160 ms 输出:两个 ITL 均为 30 ms。

p50 / p95

对一组延迟从小到大排序后的第 50 / 95 百分位;需注明统计的是 TTFT 还是 ITL。

p95 TTFT=800 ms:约 95% 请求的首输出不晚于该值。

内部 token 事件、服务端发送与客户端接收分别对应三个观测点。测量时固定观测位置,分别报告总吞吐、首输出延迟和后续输出间隔,才能说明系统效率与用户体验各自的变化。

例如请求在 0 秒发出,4 个输出在 0.12、0.16、0.21、0.25 秒到达,则 TTFT=120 ms,ITL=[40,50,40] ms。若用 TPOT 表示首 token 之后的平均输出间隔,则 TPOT=(tlast−tfirst)/(G−1)\mathrm{TPOT}=(t_{\mathrm{last}}-t_{\mathrm{first}})/(G-1),其中 G 是输出 token 数,至少为 2;本例约为 43.33 ms。客户端 TTFT 和 ITL 需要记录客户端逐次接收时刻;离线 generate() 的整批耗时用于统计离线吞吐。

比较前固定模型、请求集合、输入/输出长度、到达模式、采样设置、设备和软件。加载、编译、Graph 捕获单独计时;正式性能测试先预热,再重复多轮,保留分布或中位数,不挑最好一次。测固定工作量时,可让两组都生成相同数量的输出,避免 EOS 提前结束造成不公平比较。

还要固定前缀缓存是冷还是热。模型已预热,不代表前缀缓存必须命中;如果一组首次运行、另一组已复用相同 prompt,两者新算工作量就不同。记录命中位置数,把冷缓存与热缓存分别比较。

GPU 执行是异步的。CPU 时钟只包住一次提交可能测不到真正完成时间;使用合适的同步边界或 CUDA Events,区分提交、设备执行与端到端时间。多副本的总吞吐用全部完成输出除以共同测量窗口,不把每张卡独立测出的最好吞吐简单相加。

显存统计分别记录:memory_allocated 表示张量占用,memory_reserved 表示包含这些张量在内的分配器保留量;nvidia-smi 还可能包含 CUDA 上下文和其他库。KV 池中的空闲块仍占预分配显存,请求结束后可供后续请求复用。TP 应逐卡统计峰值,再按分析目标汇总。

先做静态预算,再用实测校正。按第 4 章的总显存公式扣除权重、当前工作区、其他运行时和安全余量,得到 KV 预算;再用第 3.4.2 节的整页大小估算池容量:

MKV,budget=MGPU,budget−Mweights−Mwork,peak−Mother−MmarginNpool≤⌊MKV,budgetCpage⌋\displaystyle \begin{aligned}M_{\mathrm{KV,budget}}={}&M_{\mathrm{GPU,budget}}-M_{\mathrm{weights}}\\&-M_{\mathrm{work,peak}}-M_{\mathrm{other}}-M_{\mathrm{margin}}\\[10pt]N_{\mathrm{pool}}\le{}&\left\lfloor\frac{M_{\mathrm{KV,budget}}}{C_{\mathrm{page}}}\right\rfloor\end{aligned}

没有前缀共享时,按每条请求需要覆盖的 AiA_i 个位置分配整页,这些页面必须放得进池中:

∑i=1B⌈AiTpage⌉≤Npool\displaystyle \sum_{i=1}^{B}\left\lceil\frac{A_i}{T_{\mathrm{page}}}\right\rceil\le N_{\mathrm{pool}}

最后一行描述特定时刻的容量,运行中还需为 Decode 增长预留块。可容纳请求数随长度、前缀共享、抢占与回收变化,应结合实际负载动态评估。

用教学数字演算:假设给引擎的预算为 16 GiB,权重暂按 1.2 GB≈1.118 GiB,非 KV 峰值工作区、其他运行时和安全余量合计预留 3 GiB,则 KV 预算约 11.882 GiB。每块 28 MiB,最多可规划 434 块;每条 4096-token 请求占 16 块,因此静态容量最多容纳 27 条这样的请求,尚未保证后续增长和性能。这些数字用于演示预算计算,实际 A800 配置应根据实测调整。

实测超出预算时,先定位增加的项目:并发与上下文主要影响 KV 需求,大 Prefill 增加激活和工作区峰值,通信与执行模式也带来运行时占用。可运行容量同时受 Context window 和显存预算约束;采用预分配池口径时,其内部已领取页面包含在池总量中。

B.8.4 出错时,找到最早不符合预期的边界

现象

先检查的边界

启动或加载失败

确定失败发生在权重加载、warmup、KV 池分配还是 Graph 捕获;再检查对应配置。

不出结果、输出数异常

先看 waiting/running;再核对 num_cached_tokens、num_scheduled_tokens 以及候选接受和停止条件。

前缀不命中或结果串请求

查 token 前缀与登记时点,再查块表、物理块是否已覆盖、实际有效长度。

Graph 失败但 eager 正常

查实际请求数是否有覆盖桶、缓冲区是否足够、填充槽位是否隔离、新输入是否更新。

多卡一直等待

先找每个 worker 最早的错误;再查端口、共享内存名与集合通信的进入顺序。

优化后变慢

先确认两组工作量相同,再区分提交、搬运、填充和通信是否抵消了收益。

最小复现从短输入、少请求、单卡 eager 开始,随后逐项恢复长上下文、Graph、TP。服务化改造需补齐输入校验,为全局 Context 建立并发隔离,为独立实例配置各自的端口与共享内存名,并为 exit/atexit 增加幂等保护。资源清理仅针对本次创建的实例。

附录 C. 配套阅读与版本边界

C.1 授权中文全译:从另一个视角阅读 nano-vLLM

Moncef Abboud 的 Deep Dive into Efficient LLM Inference with nano-vLLM 沿生成循环解释调度、KV Cache 和张量并行,适合作为本教程的第二阅读视角。原文按 CC BY 4.0 授权,中文全译单独收录,保留原文代码并标注译者说明。

主教程的原理分析与实验统一使用提交 bb823b3。配套译文保留作者所分析的实现;阅读调度代码时,先区分以下行为:

观察项

译文中的实现

主教程固定提交 bb823b3

执行阶段

Prefill 优先,与 Decode 分开执行

同样 Prefill 优先,与 Decode 分开执行

前缀推进

按序列整体接纳待计算前缀,未包含分块推进逻辑

按剩余预算安排新位置,允许本轮首个入选序列分块

进度与输出

没有中间 chunk 的处理分支

num_scheduled_tokens 记录本轮工作;中间 chunk 只推进 KV,完整 Prefill 后接受输出

C.2 架构与请求流程

主题一:推理全流程串讲(概览篇)适合参考“具体问题 → 架构 → 请求全流程”的叙述组织。阅读时将文中的组件名称、队列和调度策略与本文固定源码核对。

Structure of Nano-vLLM适合对照组件归属与调用关系;本文系统图以固定上游提交为准。

Neutree:Understanding LLM Inference Engines: Inside Nano-vLLM (Part 1)沿一次请求串联架构、调度和执行。带着“接收什么、留下什么、交出什么”阅读,比孤立记类名更容易与第 2、3 章对应。

C.3 从推理引擎到在线服务

Aleksa Gordić:Inside vLLM: Anatomy of a High-Throughput LLM Inference System分析 vLLM 提交 42172ad(2025-08-09)的 V1 引擎。适合在完成第 5 章后,继续理解异步接入、引擎执行、调度与多 GPU 服务。类名、默认值和后端选择均应结合文章版本理解。

vLLM Quickstart用于核对当前使用入口;Automatic Prefix Caching和Prefix Caching 设计文档用于核对多轮对话、引用释放和缓存驱逐。stable 文档会随版本更新,实验另行记录安装版本。

C.4 vLLM 首发博客:分页如何转化为服务收益

vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention(2023-06-20)从 KV 显存浪费切入,介绍分页访问、共享与写时复制。核心因果链与第 3 章一致:提高可用容量,让更多请求参与有效批处理,再提升整体服务吞吐。

完整前缀共享时,各请求引用同一组只读块;如果多个生成分支需要修改同一个未满块,就需要独占写入或写时复制。引用计数解决何时回收,写入策略解决谁能修改。本文 nano 通过最后逻辑块独占简化这条路径,通用部分块 Copy-on-Write 可以作为进一步改造。

原文性能数字来自当时的 LLaMA-7B/A10G、LLaMA-13B/A100 40GB、ShareGPT 长度分布和不同输出数量。它们展示的是相应系统组合的收益,不能直接推算本教程 Qwen3/A800 的速度。复现时保留硬件、模型、版本、长度分布、缓存冷热和计时条件。

C.5 模型与成本基础

3Blue1Brown:Attention in transformers帮助形成向量与 Attention 的直觉;The Illustrated GPT-2对照完整模型;Hugging Face:How caching works解释缓存复用;Transformer Inference Arithmetic用于核对推理计算与带宽成本。