课程 07 · 实验

你自己的第一组数字

前六课都在读代码。这一课把 nano-vllm 跑起来,用三组对照实验验证那些「理论上的取舍」到底值多少——这组数字就是你面试时可以拿出来的作品。

约 90 分钟(含跑 benchmark) 环境:WSL + CUDA 前置:01–06

先准备课程源码目录。

在线站点未打包完整上游仓库。请按固定源码获取步骤创建 vendor/nano-vllm,再执行本课命令。下方硬件示例以4070 Ti为参考,按自己的环境核对。

1环境自检(5 分钟)

在 WSL 里逐条跑,任何一条不符合就先停下来修:

nvidia-smi                      # 能看到 4070 Ti,CUDA Version ≥ 12.1
python -V                       # 3.10 – 3.12(nano-vllm 明确不支持 3.13)
python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_capability())"
                                # 期望 torch 2.4+ / True / (8, 9)
python -c "import triton, flash_attn; print(triton.__version__, flash_attn.__version__)"

四行都对,再继续。特别注意 sm_89:FlashAttention 的预编译 wheel 支持 8.9,但如果你装的是很老的版本,可能只支持到 8.0/8.6,会在运行时抛 no kernel image is available。

2装依赖、下模型(10 分钟)

cd ~/vllm-learn/vendor/nano-vllm
pip install -e .                # 装 nanovllm 本身 + 依赖(torch/triton/flash-attn/xxhash)

如果 flash-attn 这一步编译很久或失败,两个备选:

模型可从 Hugging Face 下载;若访问受限,可选择以下镜像方式:

export HF_ENDPOINT=https://hf-mirror.com
hf download Qwen/Qwen3-0.6B --local-dir ~/huggingface/Qwen3-0.6B
# 旧版 CLI:huggingface-cli download --resume-download Qwen/Qwen3-0.6B --local-dir ~/huggingface/Qwen3-0.6B
路径要对上

example.py 和 bench.py 都写死了 ~/huggingface/Qwen3-0.6B/。放别处就改这两行,或者建个软链接。

3第一次生成(10 分钟)

python example.py

应该看到两条 prompt 的完整输出。第一次跑会慢——要加载权重、warmup、分配 KV、录图(enforce_eager=True 时不录)。如果卡住了:

4基准测试:先跑原版

python bench.py

脚本做的是:256 条请求,输入 100–1024 token,输出 100–1024 token,ignore_eos=True,用总输出 token 数除以总时间得到吞吐。先跑两遍,第二遍的数字才是稳的。

记录三个数:

指标怎么读你的值
总吞吐 (tok/s)tqdm 进度条上的 Prefill / Decode,或脚本打印的 Throughput______
首轮耗时第一次 generate 明显更慢(warmup + 录图)______
显存峰值nvidia-smi -l 1 另开一个窗口观察______

5三组对照实验

每改一个参数,只改一个,其余保持不动。每组跑两遍取第二遍。

实验 ①:CUDA Graph 值多少

把 bench.py 第 15 行的 enforce_eager=False 改成 True,再跑一次。

预期:decode 吞吐明显下降,prefill 几乎不变(prefill 本来就不走图)。如果你看到的是「两者都降」,说明瓶颈不在启动开销——记录下来,这是有价值的异常。

实验 ②:max_num_seqs 与显存

在 LLM(...) 里加 max_num_seqs=64,再跑。然后试 max_num_seqs=256。

预期:调小会让并发上限变低,吞吐下降,但单条请求的延迟更稳;调大不一定更好——如果 256 条序列需要的 KV 块超过显存,调度器会频繁抢占,吞吐反而可能下降。这正是第 03 课那个「三个旋钮互相制约」的实证。

实验 ③:前缀缓存真的有用吗

构造 8 条请求,prompt 的前 800 token 完全相同,只有末尾不同:

prefix = "You are a helpful assistant. " * 40      # 约 800 token
prompts = [prefix + f"Question {i}: ..." for i in range(8)]

import time
t = time.time(); llm.generate(prompts, SamplingParams(max_tokens=32)); print("cold", time.time() - t)
t = time.time(); llm.generate(prompts, SamplingParams(max_tokens=32)); print("warm", time.time() - t)

第二次会快,因为前缀块命中缓存,prefill 只需要算末尾那几十个 token。把 prefix 长度改成 100 token 再跑一次——差距会小很多,因为短前缀跨不过一个 block(256)的边界,压根没被登记哈希。

这个实验的意义

它同时验证了第 02 课的两个结论:前缀缓存按块命中,以及短于一个块的前缀不会被缓存。面试里讲这个细节,比背 PagedAttention 的定义强得多。

6把数字画出来

最少做一张表;有时间就画曲线(吞吐 vs max_num_seqs、延迟 vs batch):

import matplotlib.pyplot as plt
xs = [64, 128, 256, 512]
ys = [ ... ]          # 你测出来的吞吐
plt.plot(xs, ys, marker="o")
plt.xlabel("max_num_seqs"); plt.ylabel("throughput (tok/s)")
plt.title("Qwen3-0.6B on RTX 4070 Ti — nano-vllm")
plt.savefig("throughput.png", dpi=160)

把图存进 experiments/,面试时可以直接打开讲。

7练习 · 闭卷测验

8记录模板

把你的结果填进这个表(也可以直接写进 experiments/results.md):

实验配置吞吐 (tok/s)观察
基线默认
①enforce_eager=True
②max_num_seqs=64
③长前缀 × 2 次

数字不对劲就来问。 把 nvidia-smi 截图、tqdm 的输出、以及你改的参数发给我,我帮你判断是环境问题还是理解偏差。