大模型推理入门与 nano-vLLM 源码详解
这篇教程面向有编程或系统开发经验、希望通过读源码和动手改造入门大模型推理的读者。我们先看清模型每轮计算什么,再跟踪一个请求怎样经过 nano-vLLM;随后增加请求数和 GPU 数量,理解调度、KV 管理与执行优化如何共同影响吞吐和延迟。最终目标是能解释一次请求、修改一项机制,并用正确性与性能证据判断改动。
大模型推理,就是用训练好的模型,根据已有文本逐步生成后续内容。Prompt 先经 Tokenizer 转成 token ID,再查 Embedding Table 得到向量;这些向量依次经过多层 Transformer Block,最后由 LM Head 给词表中的候选 token 打分。选出一个 token 后,把它接到序列末尾,继续计算下一个,直到满足结束条件。
图 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 的教学配置说明查表过程:
例如 ID=1234 时,取出 ,得到形状为 [4096] 的向量;保留位置轴时写为 [1,4096]。三个位置按行排列:
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 的加权求和。
第一步:三个位置各自生成 Q、K、V。 每个输入向量与投影矩阵相乘,得到相应结果:
同一层、同一个 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,第二个下标表示被读取的位置:
d_head 是每条 Q/K 向量的分量数。t3 的三个分数一起做 Softmax,得到总和为 1 的 w31、w32、w33;其中 w32 表示 t3 从 t2 读取信息的权重。同理,t2 的两个权重 w21、w22 总和为 1;t1 只有一个可读位置,所以 w11=1。
第三步:每个位置用自己的权重加权 V,得到自己的输出。 三个位置分别计算:
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 |
本例的 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 也各自保留;变化的是每条向量的宽度。 每个位置的输入 为 [1,4096],共享的 WQ、WK、WV 各为 [4096,128],投影结果各为 [1,128]:
这里 i 是位置编号,128 是向量中数值分量的数量。每个投影分量由输入的 4096 个数加权组合得到。上一节的三条加权求和公式仍然适用:每个 w 是一个标量,乘整条 V,再按分量相加,因此三个位置的输出都是:
下面只放大 t3 这一条查询。 它能读取三个位置,把对应的 k、v 分别按行排列:
q3 与一条 k 点积,就是 128 对分量分别相乘再求和,得到一个匹配分数。与三个位置匹配后得到三个分数,经缩放和 Softmax 变成三个标量权重,再加权三条 V:
例如 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.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 编号。例如 是 t2 在第 5 个 Head 中的输出。所有位置、所有头的输出形状相同:
先在同一个位置内部拼接,再用 WO 混合各头的信息。 记 为位置 i 的拼接结果:
于是,t1 的 32 个头汇成 o1,t2 的汇成 o2,t3 的汇成 o3。三个位置各保留一条 [1,4096] 向量,继续沿各自的位置路径计算。
接着,每个位置都经过残差、Norm 和 MLP。 用 表示这个 Block 最初的输入(前文的 是它经过 Attention 前归一化后的结果),先把 Attention 输出 加回 ,得到 ;再对 归一化、经过 MLP,最后加回 :
这里 是位置 i 的 Block 输出,也是下一个 Block 对应位置的输入。三个位置共享本层同一套 WO 和 MLP 参数,分别计算;每条 Block 输出仍是 [1,4096]。 下一层再从各位置更新后的表示生成新的 Q/K/V。这样,从单头到多头,始终都是“各位置读取信息 → 各位置加工表示 → 交给下一层”的同一条主线。
图 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]:
Attention 内部依次完成 Q/K/V 投影、匹配与加权、多头拼接和输出投影;x_next 传给下一 Block。
图 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,所以续写取的是最后位置的输出。
记 为 t3 的最后一个 Block 输出,下一步是:
下一轮用选出的 ID 查表,得到 t4 自己的初始向量:
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 逐轮接续
轮次 | 本轮输入 | 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”分开看。
选出 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 沿每行允许读取的位置计算:
最后一行就是 t3 的加权汇总结果:
因果 mask 把未来位置的分数设为 −∞:t1 只读 t1,t2 读 t1~t2,t3 读 t1~t3。同一层的输入已经就绪,所以三个查询可以并行,t3 不用等待 t2 的本层 Attention 输出。每个位置再完成多头拼接、WO、残差、Norm 和 MLP,整层输出 [3,4096] 传给下一 Block。层与层仍依次执行。
图 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 纳入该层缓存后,可读范围从三个位置扩到四个:
四个权重分别乘四条 V,再逐分量相加得到 a4。t4 接着完成多头拼接、WO、残差、Norm、MLP 和残差,把更新的一行传给下一 Block;下一层再读取下一层自己的历史 K/V。走完全部层,才能选出 t5。
图 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;新位置随后逐层读取缓存,不重建旧位置的计算链。
第 l 层 K/V 来自该层输入,承接前 l−1 层加工过的信息,随后才参与计算本层 a。它们保存的是未来查询直接需要的历史数据。新位置的残差与 MLP 使用自己的当前表示继续计算。引擎另外保存 token ID、位置和请求进度,分别用于组织输入与控制生成。
在本章 MHA 配置下,每层 K 和 V 各可组织为 [B,32,S,128];只看一个请求、一个头,就是两张 [S,128] 矩阵。每个 Block 分别保存一组这样的 K/V,供新位置在相应层读取。
图 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 表示单条请求本轮可读的位置数,包含当前位置。
配置 | 前面的教学模型 | 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 数据交接
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 内部搬运和计算各发生在哪里。
位置 | 负责什么 | 放到推理中怎样理解 |
|---|---|---|
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),
)这里的字符串是普通输入示意。使用聊天模型时,应用先按配套 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)图 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.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 打包两行;共享计算,保留请求边界。
本轮 | CPU 准备并传入 GPU | GPU 中的新表示 | Attention 的可见范围 |
|---|---|---|---|
首次 Prefill | input_ids=[A1,A2,A3,B1,B2] | 2 条请求、5 个新位置 | 前 3 行属于 A,后 2 行属于 B;请求内部再按因果顺序读取 |
下一轮 Decode | input_ids=[A4,B3] | 2 条请求、2 个新位置 | 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。
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:固定成员的静态批处理与逐轮组批的对照。
静态安排中,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:按最大长度连续预留,与按页领取容量的对照。
每页 4 槽是教学配置,固定 nano 版本默认 256。这个对照采用“按最大长度预留”的连续基线;连续存储也可以设计动态扩容。分页的主要收益是减少过量预留并简化空闲空间组合,同时保留页尾未用容量。每个有效位置所需的 KV 数据量由模型决定。
先算一个 token 的成本。对各层形状相同、K/V 精度相同的标准 MHA/GQA/MQA,令 L 为层数,H_kv 为实际 KV Head 数,d_head 为每头宽度,b_kv 为每个元素字节数:
Qwen3-0.6B 单卡 BF16 中,L=28、H_kv=8、d_head=128、b_kv=2,所以每个位置跨全部层占 114688 bytes,即 112 KiB。设同时保存 KV 的请求数为 B,请求 i 的有效长度为 S_i:
这是没有前缀共享时的有效数据量。分页分配量还要计入尾页空槽和为已知输入预留的位置,实际 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.1 沿 A5 走一遍:从逻辑位置到显存数值
CPU 先为 A 的逻辑页 0、1 建立映射 [7,2]。A5 的零基位置 p=4,因此它落在逻辑页 1、页内偏移 0;查表得到物理页 2。Runner 将“页号+偏移”展平为 token 槽索引 8。C3 的位置为 2,落在物理页 5、偏移 2,展平后为 22。
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,但各自计算权重和输出。
页 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 单请求的尾页增长:填完旧页,再为下一个新位置领取新页。
回收首先改变使用权。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,可计算没有前缀共享时的容量:
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 多轮对话:旧回答变成新请求的已知输入;缓存复用跨越请求边界。
第一轮问“科比是谁?”,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 重建未命中的缓存。
恢复请求再次进入 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 表示激活与算子工作区同时存活的峰值。
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 从单请求单卡,到多请求单卡,再到多卡分工。
方式 | 每张卡保存什么 | 请求怎么走 | 主要限制 |
|---|---|---|---|
独立副本 | 完整模型、自己的 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 合并同一输出的部分和。
放入 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 留在负责的卡上。
本地 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 同一个项目的七个阶段:从单卡基线到可验证的推理服务
每节先给可运行的检查点,再指出具体文件与函数,最后留一个逐渐减少提示的小改动。合批、分页、前缀复用、Graph 和 TP 从已有源码中观察;服务入口、跨轮调度与实例隔离由配套实现和补丁演练。混合 Prefill/Decode 批次保留为进阶支线。
配套实验包:nanovllm-inference-lab-v6.zip。包内含本章、vLLM 体验和附录 B 的离线手册,以及运行脚本、数值对照工具和源码补丁;章节与在线教程一致。上游仓库与模型权重按 Setup 单独下载。包内 VALIDATION.md 区分已完成的本地检查和待实验机完成的 GPU 验证。
附件下载: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在 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]第一轮应为 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]轮次 | 模型本轮处理的位置 | 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()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-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)from nanovllm.utils.context import get_context
p positions.tolist()
p get_context().context_lens.tolist()
p get_context().slot_mapping.tolist()时刻 | 位置与物理块 | 应得到的映射/状态 |
|---|---|---|
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.jsonlCUDA_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终端 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.jsoncd "$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.txtimport 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)在已激活的 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 vllmcurl -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 的权重:先对每个分数取指数,再除以所有指数之和。
这里 m 是候选数,i 指正在计算的候选,j 遍历所有候选;zᵢ 是输入分数,pᵢ 是归一化后的权重。三个候选的手算结果是:
exp(z) 就是 e 的 z 次方,e 约为 2.718。较大分数得到较大权重;除以共同总和后,各项非负且总和为 1。输入三个数,输出仍是三个数。得到权重后,Attention 用它汇总 V,采样流程用它选择 token。
实际计算通常先减去同一组分数的最大值。分子与分母同时乘上同一个因子,因此结果不变,却能避免大正数取指数溢出:
把 [1,2,3] 变为 [−2,−1,0],指数约为 [0.13534,0.36788,1],归一化后仍得到上面的权重。因果 mask 在 Softmax 前将被屏蔽位置的分数设为负无穷,使其指数值和最终权重为 0;可见位置保留原分数。该计算要求至少存在一个可见位置。
Attention 与采样使用相同的 Softmax,但候选轴不同。 Attention 的候选是当前 query 可以读取的 key 位置;采样的候选是词表里的 token。对一个 d 维 query,Attention 的完整后半段是:
M 是加到分数上的 mask:允许位置取 0,屏蔽位置取负无穷。wⱼ 是一个标量权重,vⱼ 是该位置已经生成或缓存的 d 维 Value。每个 vⱼ 乘以对应的 wⱼ,再沿位置求和,得到当前 query 的 d 维输出向量 a。
若只用一维 Value 手算,取 v₁=10、v₂=20、v₃=30,并沿用上面的三个权重:
这里的一维只是把向量加权变成可手算的标量。真实 d=128 时,同一组位置权重分别作用于 V 的 128 个坐标;最终得到 128 个数。
采样时,LM Head 先给出词表 logits,再由温度 τ 调整分数差距:
同一组分数 [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.2 Norm 与 LayerNorm:归一化究竟在处理什么
数学里的 norm 常指“范数”,例如 L2 范数用一个数衡量向量长度:
结构图中的 Norm 通常指 normalization layer(归一化层),用于调整特征数值的尺度。LayerNorm 根据均值与方差进行调整,RMSNorm 根据均方根进行调整;两者的具体计算如下。
以一个 token 的三个特征 x=[1,2,3] 为例,LayerNorm 先计算这一行的均值,再看各分量偏离均值的程度:
D 是这一行的特征数,μ 是均值,σ² 是分母取 D 的总体方差。对 x=[1,2,3],D=3:
先忽略很小的 ε,并取 γ=[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 的关键区别是,它不先减均值:
D 是当前向量的特征数;γᵢ 是第 i 个特征的可学习缩放,ε 是稳定分母的小常数。本文 Qwen3 的 RMSNorm 没有 β 偏移项。对同一个三维向量:
在这个例子中,三个数都除以同一个尺度,因而保留 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)在 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.5 SiLU 与 SwiGLU:MLP 中的非线性和门控
MLP 需要对同一位置的特征做非线性加工。这里先解释逐元素函数 SiLU,再把它放进双分支的 SwiGLU;图与公式都以当前 token 已经归一化后的隐藏表示为输入。
先分清层次:SiLU 是作用于单个数的激活函数,SwiGLU 是用它构成的门控结构。 SiLU 的全称是 Sigmoid Linear Unit,也等价于固定 β=1 的 Swish。它对输入数 x 计算 x×sigmoid(x),在向量上则逐个分量独立应用。
线性投影重新组合特征,激活函数引入非线性;连续的纯线性投影可以合并为一次线性变换。SiLU 通过逐元素的非线性加工,让 MLP 表达更复杂的特征关系:
这里先把 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 输出:
并置表示矩阵乘法;⊙ 表示对应坐标相乘,不沿特征求和。三组 W 是训练得到的投影参数,推理中固定;g、u、z 则随当前输入改变。
gate 与 up 来自同一个 x 的两组不同投影。SiLU(g) 对 up 的对应分量进行连续数值调制,使各分量的贡献随输入内容变化。公式采用行向量右乘权重的数学形状,PyTorch Linear 的权重通常按其转置布局存储。
图 A-3 SwiGLU:同一输入分成 gate/up 两路,在逐元素乘法处汇合,再投影回残差宽度。
把中间宽度缩成 3 做手算。若 g=[−1,0,1]、u=[2,3,4],则:
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,各自计算位置权重和单头输出。
只放大第一组:q⁽¹⁾、q⁽²⁾ 各为 [1,128],共用 K⁽¹⁾、V⁽¹⁾,各为 [S,128]。上标表示 Head 编号,两个 q 来自不同的投影分支。这里的 q、k 已完成模型要求的 Head Norm 与 RoPE。
两个头各自得到一组位置权重和一个 128 维输出。16 个头全部完成后,沿特征方向拼接为 2048 维,再通过输出投影回到隐藏宽度:
用两个一维 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 数据量为:
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、b 竖写成列向量,便于直接展示二维旋转矩阵。列写是这里的数学记法,模型的实际张量布局保持原样:
这个教学例子取 90°,用于展示方向变化和长度保持。实际旋转角由位置 p 与该分量对的频率 ω 共同决定。
128 维 Head 分成 64 对,每对使用不同角频率。本文固定源码采用前后半段配对,零起始索引为 (0,64)、(1,65)、…、(63,127)。第 r 对的频率与位置角为:
d=128;r 是分量对编号,p 是 token 的逻辑位置;θ 是配置中的 rope_theta,本模型取 1000000。r=0 的频率为 1,后续频率逐渐降低,以不同转速表达位置。实现中的分量配对布局应与模型权重约定保持一致。
为什么会得到相对位置? 仍只看同一对分量,把旋转前的 q、k 竖写成列向量。q 位于 p,k 位于 j;利用旋转矩阵的转置等于反向旋转:
两次绝对旋转在点积中留下的是位置差 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 在数学上是:
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]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)下面保留原有 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.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-3 新 ID 与元数据从 CPU 传入,权重和 KV 常驻 GPU。
图 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-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.jsonDecode 批量 | 预期执行路径 | 重点 |
|---|---|---|
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 之后的平均输出间隔,则 ,其中 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 节的整页大小估算池容量:
没有前缀共享时,按每条请求需要覆盖的 个位置分配整页,这些页面必须放得进池中:
最后一行描述特定时刻的容量,运行中还需为 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用于核对推理计算与带宽成本。