nano-vLLM 推理实验手册

先跑通 → 观察执行 → 修改机制 → 用证据验收

实验包 v6 · 固定源码 bb823b3 · Qwen3-0.6B · Linux / A800 参考环境

在浏览器阅读本手册,在 GPU 实验机终端执行命令。验证范围见同目录 VALIDATION.md。

打开完整在线教程

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

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

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

从可运行引擎到可验证的推理服务 7 个阶段,同一个系统逐步演进;每阶段保留上一阶段的能力与证据。 继续在同一个系统上迭代 5.1 固定基线 产物:固定配置与基线 5.2 跟透请求 / 数值保护 产物:请求追踪与数值回归 5.3 可观测单卡服务 产物:服务入口与事件日志 5.4 调度改造闭环 产物:调度实验与回归报告 5.5 深入 GPU 执行 产物:GPU 执行剖析 5.6 多副本与 TP 产物:扩展策略对照结果 5.7 生命周期与验收 产物:生命周期验收报告 进阶 · 可选支线 混合 Prefill/Decode 批次 非主线前置 同一请求接口 · 同一负载驱动 · 持续回归与测量

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

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

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

当前配套包:nanovllm-inference-lab-v6.zip。本文是该包的离线操作手册。

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

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

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

对象

本章固定值

用途

nano-vLLM 仓库

GeeeekExplorer/nano-vllm

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

源码提交

bb823b3e06983d71485a8e1f23715ebd87d98ef8(包版本 0.2.0)

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

模型

Qwen/Qwen3-0.6B

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

模型 revision

c1899de289a04d12100db370d81485cdf75e47ca

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

唯一实验附件

nanovllm-inference-lab-v6.zip

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

参考 Python 依赖

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

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

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

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

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

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

bash
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。下面的检查要求目标目录尚不存在,以保护已有实验修改;已经解压过时,进入原目录继续。

bash
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。后续修改源码或实验文件时,记录改动即可。

plaintext
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 固定提交,建立自己的修改分支

bash
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 复用它们,并跳过对应安装行。两条路径选择其一,记录实际版本。

bash
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、配置和权重一起固定

bash
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,不要占用或终止其他人的任务。

bash
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。阅读入口和引擎实现是不同文件。

bash
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 已返回的位置。修改引擎文件后,按对应函数与语句重新定位断点。

plaintext
b nanovllm/engine/llm_engine.py:52
b nanovllm/engine/llm_engine.py:54
c

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

plaintext
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 行查看回写后的状态。这是区分“算出候选”和“把候选追加进请求”的最直接方法。

plaintext
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 在三轮之间继续。

plaintext
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 行断点;预热之外的第一轮应该只有这两条请求,且没有前缀缓存命中。

bash
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,应同时查看这两个队列:

plaintext
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。使用新进程,让请求与前缀缓存处于受控初始状态。

bash
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 已构造输入和槽位后停下。

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

plaintext
p len(seq), seq.num_cached_tokens, seq.num_blocks, list(seq.block_table)
plaintext
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。先读补丁,再应用,能清楚看到自己到底修改了哪一个文件。

bash
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 容量,两项参数分别记录。

bash
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,再反向应用。不要撤销不属于这份补丁的改动。

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

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

bash
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 增加。

bash
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 事件;这是学习用的薄接口。

bash
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,便于同一份代码内只切换策略做对照。

bash
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 形状;稳态结论还需更充分的预热与重复测量。

bash
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,只用于定位。

bash
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 的性能收益再通过相同工作量比较。

bash
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 使用相同配置。补丁还给退出流程增加幂等保护、资源归属和有界子进程等待,便于重复启动实验。

bash
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;按实际卡号替换。

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

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

bash
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 执行:

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

bash
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 后提交相同全局负载:

bash
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 场景可稳定制造等待中取消、生成中取消、队列满、慢消费者、引擎异常与关闭;再在真实模型的本机服务上抽样验证同样的外部行为。

bash
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 结果保留为待验证。

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

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

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

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

6. 带着这些理解再看 vLLM

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

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

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

已经掌握的机制

进入生产系统后继续追问

生成循环与 Sequence

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

连续批处理与 token 预算

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

分页与前缀缓存

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

ModelRunner 与优化后端

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

独立副本与 TP

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

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

6.2 怎样做有解释力的对照

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

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

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

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

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

6.3.1 独立环境与离线补全

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

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

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


model_dir = os.environ["MODEL_DIR"]
llm = LLM(model=model_dir, max_model_len=2048,
          gpu_memory_utilization=0.5, generation_config="vllm")
params = SamplingParams(temperature=0.6, max_tokens=32)
for result in llm.generate(["The sky was", "The river was"], params):
    print(result.prompt, result.outputs[0].text)

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

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

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

bash
source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source .venv-vllm/bin/activate
vllm serve "$MODEL_DIR" \
  --served-model-name qwen3-demo \
  --host 127.0.0.1 --port 8000 \
  --max-model-len 2048 --gpu-memory-utilization 0.5 \
  --generation-config vllm
bash
curl -N http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"qwen3-demo","messages":[{"role":"user","content":"用一句话解释什么是推理。"}],"temperature":0.6,"max_tokens":64,"stream":true,"chat_template_kwargs":{"enable_thinking":false}}'

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

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

附录 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 与人为分数的教学计算,模型权重在后续实验中加载。

bash
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 解释这些对照能证明什么。先预测修改未来位置是否影响过去位置,再运行验证。

bash
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 维教学模型的实际参数。

plaintext
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 的数值关系。

python
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 位置跟踪,不预先假定中文文字怎样切分。

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

bash
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。两条路径都应使用同一组模型参数、位置语义和停止条件。

python
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)
python
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 追踪同一位置。

bash
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 性能在后续实验中测量。

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

bash
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 写下每轮哪些请求还活跃,再运行:

bash
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 下的前三轮选择。

bash
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 显存耗尽。

bash
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 公式。

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

bash
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、没有其他负载。

bash
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,再让脚本检查桶内、补齐与越界批量。

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

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

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

Decode 批量

预期执行路径

重点

1

graph:1

精确命中捕获桶

9

graph:16

补齐行 slot_mapping=-1、context_lens=0

16

graph:16

桶上边界

17、20

eager

无覆盖桶时完成正常前向

探针记录实际 model 调用或 graph.replay,而非仅根据配置推测路径。回归同时检查:每轮历史有效 KV 保持不变;Graph 补齐元数据安全;新位置在各层写出的 K/V 与 eager 接近;所有对应 logits 在门槛内。新 KV 的初始最大绝对误差门槛为 0.1、相对 L2 为 0.02,与 logits 一样需要结合 BF16 基线复核。结果为 PASS 且 routes 全为 true,才完成本次有限案例验收。

步骤 4|观察代价。本节采集带 Python 探针、数组拷贝与检查,用于正确性。性能使用 附录 B.4 的无探针 benchmark;可使用下方命令比较 batch=17、上限=20 时的回退与显式 eager,并单列 Graph 初始化成本。无需以“速度必须提升”作为本次修复的成功标准,目标是覆盖缺失时仍正确完成请求。

bash
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;这与两个请求同时首次入队不同。

bash
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 张量。

bash
source "$HOME/nanovllm-inference-lab/env.sh"
cd "$LAB_ROOT"
source ".venv-nano/bin/activate"
python e2e/cpu_scheduler.py refs --repo "$NANO_DIR" --out runs/prefix-cache-refs.json

验收:两个共享块的 ref_count 依次 [0,0]→[1,1]→[2,2]→[1,1]→[0,0];620 长度命中 2 块,恰好 512 长度最多检查并命中 1 块(can_allocate 使用 range(num_blocks−1))。两项均 PASS。notes/prefix-cache.md 解释 ref_count=0 不代表 GPU 显存池已归还,也不代表 hash 立即失效;旧块被重新分配覆盖时会清理旧映射。

B.7 迁移到多轮对话:构造第二次请求

这项练习在现有前缀实验上新增一个驱动程序。用配套 chat template 生成 R1 的完整 ID,得到回答后,将旧消息、完整回答与新问题重新套模板,构造 R2。先打印两次请求的 token 前缀,确认相同范围,再观察缓存命中。

两次请求使用同一个引擎,记录 R2 的 cached、scheduled 与 block_table;另启动一个新引擎作为冷缓存对照。命中粒度和最后块策略以本提交为准,不以字符串相同或 session ID 相同推定命中。验收要能解释:哪些前缀仍在、哪些后缀必须新算,以及上轮最后输出和模板标记为什么也可能落在后缀里。

B.8 验证速查:数值、状态、指标与排错

B.8.1 正确性:固定输入路径,再比较输出

对有无 KV Cache、完整与分块 Prefill、eager 与 Graph、TP=1 与 TP=2 做数值对照时,应固定模型、位置语义和每一步输入 token。两次独立随机采样可能很早就走向不同 token;此后 logits 不同可能只是输入变了,不一定是实现错误。

数值对照时预先指定同一条续写 token 路径,让两个实现每一步都输入相同 ID,再比较对应位置的整行 logits。常用三个观察量:

观察量

怎样理解

需配合检查

最大绝对误差

逐项相减并取绝对值,再取最大值;找最严重的单个词表分数偏差。

误差分布及整体量级。

相对 L2 误差

差值向量的 L2 长度,除以参考向量的 L2 长度;分母需防止为零。

局部最大误差。

top-1 是否一致

两行 logits 的最大项是否指向相同 token ID。

最大项相同不代表整行数值相同。

浮点累加顺序、dtype 和后端可能造成小差异;两个候选分数极接近时,小误差也可能改变 top-1。因此先报告误差和测试条件,再设定合理阈值,不用一次文本相同或一个万能 PASS 代替数值分析。

最小验证从单层因果 Attention 开始:追加新位置后,旧位置结果不变;新 Q 读取历史 K/V 的最后一行结果,与完整重算对应。之后再扩展到真实模型的多层缓存、RoPE 和 GQA。附录 B.1.2、B.1.4、B.1.6 提供对应练习。

进阶边界:仅需要最后位置 logits 时,最后一个 Block 在数学上可只算最后 query 及其后续 MLP,同时仍生成所有已知位置的该层 K/V。中间层需要为下一层建立各位置的表示,因此保留全位置计算;常规实现也可能统一采用全行执行。

B.8.2 机制:用状态解释这一轮发生了什么

机制验证回答“状态是否按设计变化”。用 seq_id 串起同一条请求,分别记录调度前、调度后、回写后三个时点:num_tokens、num_cached_tokens、num_scheduled_tokens、block_table,以及候选是否被接受。只打印一串数字而不写时点,容易把“尚未计算”和“已经完成”混为一谈。

例如 A 有 700 个已知位置、预算 512:调度后为 700 / 0 / 512,第一轮回写后为 700 / 512 / 0,输出数仍为 0;第二轮补完 188 个位置并接受首输出后,才成为 701 / 700 / 0。看到一个 sampled token 只证明采样器给了候选,不证明输出已经追加。

改动对象

必须保住的关系

可以怎样验证

批处理与路由

请求不丢、不重复,结果回到正确请求

提交、接纳、完成各阶段的 id 集合对应

分页与前缀共享

槽位不串写,活跃共享块不覆盖

块表、有效长度、引用计数与释放前后状态

流式与取消

只发布正式接受的 token,终止只发生一次

事件顺序、输出数、结束原因与缓存回收

多卡

独立副本相互隔离;TP ranks 执行同一批工作

实例/rank 标识、资源名、张量形状与通信记录

物理块号可以不同,逻辑位置到实际槽位的关系必须正确。机制日志会增加 CPU 打包、同步甚至 GPU 数据回传开销,所以用它证明流程,不用它测性能。详细日志与正式计时分开运行。

B.8.3 性能:先写清楚测量范围

性能指标必须写清楚起止时刻和单位。先区分请求体验与整体产出:

指标

测量范围

简单例子

输出吞吐(token/s)

共同测量窗口内完成的输出 token 总数 ÷ 窗口时长;离线整批计时通常包含 Prefill 与 Decode。

1000 个输出 / 2 秒 = 500 token/s。

TTFT(首 token 延迟)

从约定入口提交请求,到收到第一个输出。若从客户端计时,还包括网络与排队。

提交后 100 ms 收到首输出:TTFT=100 ms。

ITL(输出间隔)

同一请求相邻两次 token 输出的时间差;首 token 前的等待不算在内。

随后在 130、160 ms 输出:两个 ITL 均为 30 ms。

p50 / p95

对一组延迟从小到大排序后的第 50 / 95 百分位;需注明统计的是 TTFT 还是 ITL。

p95 TTFT=800 ms:约 95% 请求的首输出不晚于该值。

内部 token 事件、服务端发送与客户端接收分别对应三个观测点。测量时固定观测位置,分别报告总吞吐、首输出延迟和后续输出间隔,才能说明系统效率与用户体验各自的变化。

例如请求在 0 秒发出,4 个输出在 0.12、0.16、0.21、0.25 秒到达,则 TTFT=120 ms,ITL=[40,50,40] ms。若用 TPOT 表示首 token 之后的平均输出间隔,则 TPOT=tlast−tfirstG−1,其中 G 是输出 token 数,至少为 2;本例约为 43.33 ms。客户端 TTFT 和 ITL 需要记录客户端逐次接收时刻;离线 generate() 的整批耗时用于统计离线吞吐。

比较前固定模型、请求集合、输入/输出长度、到达模式、采样设置、设备和软件。加载、编译、Graph 捕获单独计时;正式性能测试先预热,再重复多轮,保留分布或中位数,不挑最好一次。测固定工作量时,可让两组都生成相同数量的输出,避免 EOS 提前结束造成不公平比较。

还要固定前缀缓存是冷还是热。模型已预热,不代表前缀缓存必须命中;如果一组首次运行、另一组已复用相同 prompt,两者新算工作量就不同。记录命中位置数,把冷缓存与热缓存分别比较。

GPU 执行是异步的。CPU 时钟只包住一次提交可能测不到真正完成时间;使用合适的同步边界或 CUDA Events,区分提交、设备执行与端到端时间。多副本的总吞吐用全部完成输出除以共同测量窗口,不把每张卡独立测出的最好吞吐简单相加。

显存统计分别记录:memory_allocated 表示张量占用,memory_reserved 表示包含这些张量在内的分配器保留量;nvidia-smi 还可能包含 CUDA 上下文和其他库。KV 池中的空闲块仍占预分配显存,请求结束后可供后续请求复用。TP 应逐卡统计峰值,再按分析目标汇总。

先做静态预算,再用实测校正。按第 4 章的总显存公式扣除权重、当前工作区、其他运行时和安全余量,得到 KV 预算;再用第 3.4.2 节的整页大小估算池容量:

MKV,budget=MGPU,budget−Mweights−Mwork,peak−Mother−MmarginNpool≤⌊MKV,budgetCpage⌋

没有前缀共享时,按每条请求需要覆盖的 Ai 个位置分配整页,这些页面必须放得进池中:

∑i=1B⌈AiTpage⌉≤Npool

最后一行描述特定时刻的容量,运行中还需为 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 增加幂等保护。资源清理仅针对本次创建的实例。