课程 07 · 实验
前六课都在读代码。这一课把 nano-vllm 跑起来,用三组对照实验验证那些「理论上的取舍」到底值多少——这组数字就是你面试时可以拿出来的作品。
在线站点未打包完整上游仓库。请按固定源码获取步骤创建 vendor/nano-vllm,再执行本课命令。下方硬件示例以4070 Ti为参考,按自己的环境核对。
在 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。
cd ~/vllm-learn/vendor/nano-vllm
pip install -e . # 装 nanovllm 本身 + 依赖(torch/triton/flash-attn/xxhash)
如果 flash-attn 这一步编译很久或失败,两个备选:
pip install flash-attn --no-build-isolation(需要本机有 nvcc 与匹配的 torch)。模型可从 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/。放别处就改这两行,或者建个软链接。
python example.py
应该看到两条 prompt 的完整输出。第一次跑会慢——要加载权重、warmup、分配 KV、录图(enforce_eager=True 时不录)。如果卡住了:
enforce_eager=True 下仍 OOM → 把 gpu_memory_utilization 降到 0.8。flash_attn 报 sm 不支持 → 换 wheel 版本。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 另开一个窗口观察 | ______ |
每改一个参数,只改一个,其余保持不动。每组跑两遍取第二遍。
把 bench.py 第 15 行的 enforce_eager=False 改成 True,再跑一次。
预期:decode 吞吐明显下降,prefill 几乎不变(prefill 本来就不走图)。如果你看到的是「两者都降」,说明瓶颈不在启动开销——记录下来,这是有价值的异常。
在 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 的定义强得多。
最少做一张表;有时间就画曲线(吞吐 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/,面试时可以直接打开讲。
把你的结果填进这个表(也可以直接写进 experiments/results.md):
| 实验 | 配置 | 吞吐 (tok/s) | 观察 |
|---|---|---|---|
| 基线 | 默认 | ||
| ① | enforce_eager=True | ||
| ② | max_num_seqs=64 | ||
| ③ | 长前缀 × 2 次 |
数字不对劲就来问。 把 nvidia-smi 截图、tqdm 的输出、以及你改的参数发给我,我帮你判断是环境问题还是理解偏差。