AI Infra · LLM Serving · Source Reading

nano-vllm 源码剖析:
把 vLLM 的精髓读进 1200 行代码

vLLM 官方仓库数十万行代码,劝退了很多想搞懂 LLM 推理引擎的人。DeepSeek 工程师俞星凯从零写出的 nano-vllm 把核心机制——连续批处理、PagedAttention 分页 KV Cache、前缀缓存、CUDA Graph、张量并行——浓缩进约 1200 行 Python,且吞吐不输 vLLM。本文逐文件精读这份代码,回答一个问题:一个现代 LLM 推理引擎最少需要哪些零件,它们如何咬合在一起。

分享主题 nano-vllm 源码精读 代码版本 GeeeekExplorer/nano-vllm @ main 参考文献 7 篇(含 arXiv)
~1,200 行
核心包代码量(README);含示例脚本全仓约 1,450 行(本文 wc -l 统计)
1,434 tok/s
RTX 4070 Laptop 实测吞吐,略高于 vLLM 的 1,362 tok/s(项目 benchmark)
256 tok/块
KV Cache 分页粒度 kvcache_block_size,内部碎片上限即一个块
15k+ Stars
2025-06 发布并登上 GitHub Trending(Trendshift 在册),截至 2026-10 约 15.6k stars
01为什么需要专门的推理引擎 02全景:一次 generate 的旅程 03调度器:连续批处理与抢占 04块管理器:分页 KV Cache 与前缀缓存 05ModelRunner:双路径执行与 CUDA Graph 06Attention 层:FlashAttention 的三种吃法 07张量并行:多进程与权重切分 08模型与算子:那些省行数的巧思 09对照 vLLM:砍掉了什么,怎么读这份代码
prompts str / token_ids LLMEngine llm_engine.py · 90 行 Scheduler tokenizer BlockManager 分页 + 前缀缓存 ModelRunner rank 0 · 主进程 KV Cache 池 [2,L,B,256,h,d] Worker ×N spawn + NCCL GPU 输出 token / step 调度回路:schedule() → model_runner.call("run") → postprocess(),每个 iteration 重复一次,直到 waiting / running 全空
图0nano-vllm 全景地图:所有请求从 LLMEngine 进入,由 Scheduler 决定每个 iteration 跑谁、由 BlockManager 管显存分页、由 ModelRunner 把调度结果翻译成 GPU 上的 varlen batch;多卡时 rank 0 通过共享内存指挥 worker 进程。
01

为什么需要专门的推理引擎

Prefill / Decode · KV Cache · 批处理

直接用 HuggingFace model.generate() 逐条跑请求,功能上完全正确,但在服务场景下会浪费掉大部分 GPU 算力。nano-vllm 要解决的所有问题,都可以追溯到三个结构性矛盾。

Prefill 与 Decode 是两种完全不同的负载

自回归生成分两个阶段。Prefill 一次性处理整个 prompt,$n$ 个 token 并行过模型,计算量 $\sim 2Pn$($P$ 为参数量),是 compute-bound 的;Decode 每步只处理 1 个新 token,却要把全部权重和整条 KV Cache 从显存搬一遍,算术强度极低,是 memory-bandwidth-bound 的。把两个阶段混在一个静态 batch 里跑,两边的效率都会被拖累。nano-vllm 的调度器每个 iteration 只跑一种阶段(schedule() 返回 is_prefill 标志),正是对这个二分法的直接回应。

KV Cache 的显存账

Decode 阶段为了不重复计算历史 token 的注意力,必须缓存每层每头的 K/V。每个 token 的 KV 占用为:

$$ \text{bytes/token} = 2 \times L \times n_{kv} \times d_{head} \times \text{sizeof}(\text{dtype}) $$

以 nano-vllm 默认支持的 Qwen3-0.6B 为例($L{=}28$、$n_{kv}{=}8$、$d_{head}{=}128$、bf16),每 token 约 $2{\times}28{\times}8{\times}128{\times}2 = 114{,}688$ 字节 ≈ 112 KB;一条 4096 token 的序列就要约 448 MB——已经赶上 0.6B 模型 bf16 权重本身(约 1.2 GB)的三分之一。换成 70B 级模型,单条长序列的 KV Cache 轻松超过 10 GB。KV Cache 才是推理显存管理的主角,这正是 nano-vllm 用整块 BlockManager 来管它的原因。

静态批处理的两笔浪费

传统 serving 把一批请求拼成大 tensor 一起跑、一起收尾,浪费来自两处:

两笔浪费各自对应一篇奠基论文和一个 nano-vllm 的核心模块:

[P1]
Orca: A Distributed Serving System for Transformer-Based Generative Models
Yu et al. · OSDI 2022 · 提出 iteration-level scheduling(连续批处理):每个 iteration 都重新组队,完成即退出、到达即加入
OSDI'22
[P2]
Efficient Memory Management for Large Language Model Serving with PagedAttention
Kwon et al. · SOSP 2023 · vLLM 原始论文:借鉴 OS 虚拟内存分页思想管理 KV Cache,把浪费压到一个块以内
arXiv:2309.06180
静态批处理:陪跑与等待 seq A seq B seq C seq D 排队等待 灰 = 陪跑空转;batch 整体收尾后 D 才能进来 连续批处理(iteration-level scheduling) seq A seq B seq C D 在 B 结束的下一个 step 立即补位 →
图1上图:静态批处理中短序列陪跑、新请求整批等待;下图:连续批处理在每个 iteration 边界重组 batch,完成的立刻退出、等待的立刻补位。nano-vllm 的 Scheduler.schedule() 92 行实现了后者。
KEY TAKEAWAY

推理引擎的本质 = 调度器(让 GPU 每步都满载)× 显存管理器(让每 MB 显存都装真 token)。nano-vllm 的全部代码都是围绕这两件事展开的。

02

全景:一次 generate 的旅程

llm_engine.py · sequence.py

代码地图:21 个文件,1450 行

nano-vllm 只支持 Qwen3 一个模型族,换取了极致的可读性。下表逐文件列出 nanovllm 包(19 个文件、1,385 行)的职责划分,另有 bench.py / example.py 共 65 行:

文件职责行数对应 vLLM 子系统
engine/llm_engine.py顶层引擎:启动 worker、主循环 step()、进度条与吞吐统计90LLMEngine
engine/scheduler.py连续批处理调度:prefill 优先、chunked prefill、抢占92Scheduler(v1)
engine/block_manager.py分页 KV Cache 分配 / 释放 / 前缀缓存哈希120BlockManager + PrefixCache
engine/sequence.py请求的状态对象:token 流、block_table、状态机83Sequence / SequenceGroup
engine/model_runner.py模型执行:显存测量、输入拼装、CUDA Graph、多卡指令分发257ModelRunner + Worker
layers/attention.pyTriton KV 写入 kernel + FlashAttention 调用75Attention backend
layers/linear.py并行 Linear:Replicated / Column / Row 三种基础形态,外加 QKV / Merged 两种合并列并行156tensor_parallel layers
layers/{rotary,layernorm,sampler,embed_head,activation}.pyRoPE、RMSNorm(融合残差)、采样、Embedding/LMHead、SiLU198对应同名 layers
models/qwen3.pyQwen3 模型搭建与权重打包映射216model registry 的一项
utils/{context,loader}.py全局 attention 上下文;safetensors 权重加载55—
config.py · sampling_params.py · llm.py · __init__.py全局配置(块大小 / 批上限 / TP 等)、采样参数、LLM 类与包入口43—
表内各行合计 1,385 行;加上 bench.py / example.py 的 65 行,全仓 Python 共 1,450 行(wc -l)。

主循环:step() 的三段论

LLMEngine.generate() 把所有 prompt 包成 Sequence 塞进调度器后,就是一个朴素的 while 循环。每个 iteration 固定三段:

没有异步流水线、没有流式输出、没有 API server——这是离线批推理的最小闭环,也因此每一步都能在 90 行的 llm_engine.py 里被一眼看穿。

Sequence:一个请求的全部状态

Sequence 是贯穿引擎的「单据」:token_ids(prompt + 已生成)、block_table(逻辑块 → 物理块号)、num_cached_tokens / num_scheduled_tokens 两个游标,外加 WAITING → RUNNING → FINISHED 状态机。它有一个容易被忽略但非常实用的设计:__getstate__ / __setstate__ 让序列在被 pickle 发给 worker 进程时,decode 阶段只携带 last_token 而非整条 token 流——多卡通信的带宽就这样被省下来了(详见第 7 章)。

读码顺序建议

从 example.py → llm_engine.py(主循环)→ sequence.py(数据模型)入手,先建立「每个 step 每条序列出一个 token」的心智模型,再下钻 scheduler / block_manager / model_runner。

03

调度器:连续批处理与抢占

scheduler.py · 92 行

调度器维护两个队列:waiting(新请求 / 被抢占的请求)和 running(正在生成)。schedule() 每次被调用时做出一个全局决策:这一步是 prefill 还是 decode,跑哪些序列,各跑多少个 token。两个硬约束来自配置:max_num_seqs = 512(一步最多多少条序列)和 max_num_batched_tokens = 16384(一步最多多少个 token)。

Prefill 优先,且允许「切块」

只要 waiting 非空,本轮就是 prefill 轮:调度器从队首开始依次接纳新请求,直到序列数或 token 预算耗尽。这里藏着一个 vLLM v1 同款的重要机制——chunked prefill:

切块 prefill 的意义在于:一条 8k token 的长 prompt 不会独占 GPU 好几步,decode 中的老请求最多只被推迟一个 iteration,TTFT(首 token 延迟)与 TPOT(逐 token 延迟)之间取得平衡。代价是切块边界上 KV 还没写全,注意力要靠 cu_seqlens 正确掩蔽(第 6 章)。

Decode 轮:FCFS + 队尾抢占

当没有 prefill 可做时进入 decode 轮:每条 running 序列各生成 1 个 token。问题是有时候显存不够了——某条序列写满当前块、需要追加新块,而空闲块已耗尽。nano-vllm 的处理是教科书式的 preemption(抢占):

抢占选队尾是 FCFS 下的自然选择:队首序列跑得更久、沉没成本更高。而「重算而非换出」在小模型 + 高带宽场景下往往比 PCIe swap 更划算,也让 BlockManager 少了一整套 CPU 池。

最精妙的咬合:抢占会被前缀缓存「兜底」

被抢占序列释放块时,块只是引用计数归零回到空闲队列,哈希映射仍然保留(见第 4 章)。于是它重新进入 can_allocate() 时,只要这些块还没被别人复用,就会逐块命中、直接「复活」——名义上是 recompute,实际上重算被前缀缓存免掉了;只有已被复用的块才需要真正重算。抢占机制和 APC 本来各自独立,在这里形成了一层免费的容错。

postprocess:调度器也是记账员

模型跑完后,postprocess() 做三件账:给新填满的块计算哈希(喂给前缀缓存,见第 4 章)、推进 num_cached_tokens 游标、把新 token 追加进序列并判断结束条件(token == eos 或达到 max_tokens)。结束的序列当场释放全部物理块——注意这些块只是引用计数归零回到空闲队列,哈希映射仍然保留,这正是前缀缓存能命中「已经结束的历史请求」的关键。

注意:与 vLLM 的差距

nano-vllm 的调度是 FCFS,没有优先级、没有 max_num_partial_prefills 之类的细粒度控制,也不支持 prefill/decode 混合 batch(vLLM v1 已支持同一步混跑)。作为教学实现这是有意的删减,但高并发在线服务场景下这些正是拉开延迟差距的地方。

KEY TAKEAWAY

92 行调度器装下了连续批处理的全部要义:iteration 级重组、prefill 优先 + 队首切块、显存不足时队尾抢占重算。读懂它,vLLM v1 那几千行调度代码就只剩下工程复杂度,没有思想上的新东西了。

04

块管理器:分页 KV Cache 与前缀缓存

block_manager.py · 120 行

像操作系统管内存一样管 KV Cache

PagedAttention 的核心类比是虚拟内存:每条序列看到的「逻辑块」是连续的(第 0 块装 token 0–255,第 1 块装 256–511……),但物理块可以散落在显存任意位置,靠 block_table(逻辑块号 → 物理块号)做地址翻译,attention kernel 拿着这张表去真正读数据。nano-vllm 里块大小固定 256 token:

seq X(system prompt + 问题一) seq Y(同一 system prompt + 问题二) 块0 块1 块2 块0 块1 块2' block_table: [7, 3, 12] block_table: [7, 3, 15] ← 前两块共享 物理块池 #7 #3 #12 #15 空闲 空闲 hash → block_id: h(块0) → 7 h(块0+块1) → 3 ref_count(#7) = 2(X、Y 共享)
图2分页与前缀缓存:序列 X、Y 共享前两个物理块(ref_count=2),Y 从第三个块开始分叉写自己的私有块;右侧哈希表让「system prompt 相同」这件事可以在 O(块数) 内被识别。

前缀缓存:给每一块内容算链式哈希

nano-vllm 实现了 vLLM 的 Automatic Prefix Caching(APC)的精髓。做法是给每个写满的块算一个 链式哈希:

$$ h_i = \text{xxh64}\big(h_{i-1} \;\|\; \text{bytes}(\text{token\_ids 块 } i)\big) $$

「链式」意味着块的哈希不仅取决于自身内容,还取决于之前所有块——两个序列只有从头开始逐块相同才算命中,避免了「中间碰巧相同」的误命中。hash_to_block_id 字典把哈希映射到物理块,新请求进来时 can_allocate() 逐块比对:

注意比对范围只到 range(seq.num_blocks - 1)——最后一个没写满的块不参与缓存,它的内容还会增长;缓存的最小单位永远是一个写满的 256 token 块。

命中的收益直接体现在 prefill 输入上:num_tokens = seq.num_tokens - num_cached_blocks * block_size,缓存部分根本不进模型;attention 时再通过 block_table 从 KV Cache 读回(第 6 章)。多轮对话、共享 system prompt 的批量任务因此获得数倍加速。

两个工程细节

为什么用 xxhash 而不是 Python hash()

xxhash.xxh64 是快速非加密哈希,对 token id 序列的字节视图直接计算,避免 Python 对象开销;64 位输出配合「链式 + 逐块内容比对」(blocks[block_id].token_ids != token_ids 的二次校验)把碰撞概率压到可以忽略。

KEY TAKEAWAY

block_table + ref_count + 链式哈希 三件套,120 行同时实现了 PagedAttention 的按需分配和 APC 的跨请求共享。这是全仓库「行数 / 思想密度」比最高的文件。

05

ModelRunner:双路径执行与 CUDA Graph

model_runner.py · 257 行,全仓最重的文件

调度器管「跑谁」,ModelRunner 管「怎么跑」。它做了四件事:加载模型、测量显存并划分 KV Cache 池、把调度结果翻译成 attention kernel 需要的扁平输入、为 decode 路径提前录制 CUDA Graph。

先热身再量显存:KV Cache 池是怎么定大小的

初始化顺序非常讲究:warmup_model() 先用假数据跑一遍最大 batch 的前向,让 PyTorch 缓存分配器达到峰值;然后 allocate_kv_cache() 读取真实显存占用,把剩余预算全部划给 KV Cache:

$$ \text{num\_blocks} = \frac{\text{total} \times 0.9 - \text{used} - \text{peak} + \text{current}}{2 \times L \times 256 \times n_{kv} \times d_{head} \times \text{sizeof}(\text{dtype})} $$

分子是 gpu_memory_utilization(默认 0.9)预算下减去权重和预热峰值后的富余;分母正是第 1 章的「每 token 字节数」乘上 256。减 peak 加回 current 是在补偿 PyTorch 分配器的保留显存——这种「先压测再切蛋糕」的做法与 vLLM 的 profile_run 如出一辙。KV Cache 本体是一块形状为 [2, L, num_blocks, 256, n_kv, d] 的巨型张量,随后按层切片,把视图直接挂到每个 Attention 模块的 k_cache / v_cache 属性上——层与缓存池之间没有拷贝,只有指针。

prepare_prefill:把 N 条序列拍平成一维 varlen batch

nano-vllm 不做 padding。prefill 时所有序列的待算 token 被拼接成一维长向量,序列边界用累积长度数组 cu_seqlens_q / cu_seqlens_k 标记(FlashAttention 的 varlen 接口约定)。同时构建 slot_mapping:第 $i$ 个输入 token 的 KV 应该写到物理池的哪个槽位——逐块把 block_table[i] * 256 + 块内偏移 展开。若存在前缀缓存命中(cu_seqlens_k > cu_seqlens_q),还要额外传 block_tables,让 kernel 去缓存里读历史 KV。所有张量都走 pin_memory + cuda(non_blocking=True),主机到设备的拷贝与计算重叠。

prepare_decode + CUDA Graph:消灭 kernel launch 开销

Decode 每步每条序列只有 1 个 token,计算量极小,此时 Python 侧逐层 launch kernel 的 CPU 开销会超过 GPU 计算本身。解法是 CUDA Graph:把整次 decode 前向录制成一张图,之后每步只改输入缓冲区、一次 graph.replay() 整体重放。nano-vllm 的实现要点:

decode 一步的两种走法 eager:embed → norm → qkv → rope → attn → … → lm_head → sampler launchlaunchlaunchlaunch … 每层一次 CPU→GPU 提交,kernel 间隙 GPU 空转 graph.replay():bs=37 → 向上取整到桶 48,整张图一次提交 graph[48]:整条前向 + RoPE + 采样,一次 launch graph_bs = [1, 2, 4, 8,  16, 32, …, 512]
图3CUDA Graph 分桶策略:decode 按 batch size 分桶录图,运行时 padding 到最近的桶重放,把数十次 kernel launch 压缩成一次。
实践建议

小模型 + 小 batch 的 decode 场景,CUDA Graph 往往能带来数倍端到端加速——nano-vllm 能在 0.6B 模型上反超 vLLM,很大一部分功劳属于这套「录制一次、零开销重放」的机制。调试期记得开 enforce_eager=True,否则报错栈会被图重放吞掉。

06

Attention 层:FlashAttention 的三种吃法

attention.py · context.py

全局 Context:一次「不优雅但诚实」的传参

attention 前向需要一堆与层无关的元数据(cu_seqlens、slot_mapping、block_tables……)。vLLM 用精心设计的 attention metadata 对象层层透传;nano-vllm 则直接设了一个模块级全局变量 _CONTEXT:ModelRunner 在每次 run() 前 set_context(),Attention 层里 get_context() 自取,跑完 reset_context()。这是教科书会批评的写法,但它让 75 行代码承载了 vLLM 里分散在多个抽象层中的逻辑——对学习者而言,数据流向反而更显眼。

写缓存:一个 20 行的 Triton kernel

store_kvcache 把本步算出的 K/V 按 slot_mapping 散写进物理池:每个 program 处理一个 token,读出其目标槽位 slot,把 num_heads × head_dim 的向量连续写入 k_cache[slot] 和 v_cache[slot]。slot == -1 的哑元直接返回——这就是上一章 CUDA Graph 填充位不产生副作用的原因。分页管理的全部复杂性在调度侧已经消化完,kernel 看到的只是一张「逻辑位置 → 物理槽位」的映射表。

读缓存:三种调用形态

场景kernelK/V 从哪来
prefill(无前缀命中)flash_attn_varlen_func本步新算的 K/V,因果掩蔽由 causal=True 配合 varlen 边界保证
prefill(有前缀命中)flash_attn_varlen_func + block_tablek, v 直接换成 k_cache, v_cache:已缓存前缀从物理池读,新 token 刚写入同一池,一次 kernel 调用同时看到新旧 KV
decodeflash_attn_with_kvcacheq 只有 1 个 token,全部历史 KV 按 block_table + cache_seqlens 从物理池分页读入

最妙的是第二行:前缀命中时 prefill 并不需要单独的「读缓存再拼接」逻辑,而是把 query 限定为新 token、把 K/V 整个指向缓存池——FlashAttention 的 block_table 接口天然支持「一部分 KV 是旧的」,PagedAttention 论文里 kernel 层的分页读取在这里落地为一次函数调用。

[P3]
FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness
Dao et al. · NeurIPS 2022 · 分块计算 + online softmax,把 attention 的 HBM 读写降一个量级;nano-vllm 直接调用其官方实现
arXiv:2205.14135
[P4]
FlashAttention-2: Better Attention with Better Work Partitioning
Dao · 2023 · 改进并行划分与 warp 分工,vLLM / nano-vllm 的 paged KV 支持即在 flash-attn 2.x 中提供
arXiv:2307.08691
KEY TAKEAWAY

nano-vllm 没有自己实现分页注意力 kernel,而是把调度侧维护好的 block_table / slot_mapping / cu_seqlens 精确喂给 FlashAttention 的三个接口。推理引擎的核心竞争力在「元数据的正确性」,kernel 交给最专业的实现——这也是 vLLM 后端架构的真实思想。

07

张量并行:多进程与权重切分

linear.py · model_runner.py · embed_head.py

进程模型:主进程是「遥控器」,worker 是「执行器」

nano-vllm 的 TP 用 torch.multiprocessing spawn 为 rank 1..N-1 各起一个进程,rank 0 留在主进程,全组用 NCCL 通信(tcp://localhost:2333)。调度器和 BlockManager 只在 rank 0 存在——它每步调完,需要把「跑哪些序列」广播给 worker。这里的通信设计非常轻巧:

只有 rank 0 做采样(prepare_sample 和 sampler(...) 都有 rank == 0 分支),结果也只需 rank 0 知道——下一步调度本来就只发生在 rank 0。

权重切分:列切与行切的 Megatron 范式

ColumnParallelLinear:输出维切半(QKV / gate_up) W 按列切成两半,GPU0 / GPU1 各持一半 y = x·W₁ | x·W₂ → 各自得到一半输出,无需通信 RowParallelLinear:输入维切半(o_proj / down_proj) W 按行切,x 也对应切半;y = x₁W₁ + x₂W₂ → 必须 all_reduce 求和,每层 2 次(attn + mlp)
图4Megatron 式切分:列并行不产生通信,行并行以一次 all_reduce 收尾;一对 Column→Row(qkv_proj→o_proj、gate_up→down_proj)把一个 transformer 层的通信压到两次 all_reduce。

权重加载侧的精妙在 weight_loader 协议:每个参数挂上自己的装载函数,loader.py 遍历 safetensors 时按 packed_modules_mapping 把 HuggingFace 的 q_proj/k_proj/v_proj 三块权重塞进合并后的 qkv_proj 的对应切片,每块再按 rank 取自己的 shard——「合并权重」与「TP 切分」两件麻烦事在同一个函数里完成。细节亮点:

[P5]
Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism
Shoeybi et al. · NVIDIA 2019 · 列并行 + 行并行的 transformer 层内张量并行范式,推理框架的 TP 实现皆源于此
arXiv:1909.08053
注意

共享内存方案简单但有边界:缓冲区固定 1 MB、单次只广播一条指令、无流水线。vLLM 用专门的 worker 消息队列和更复杂的集合通信调度。另外 tcp://localhost:2333 硬编码,单机多实例会撞端口。

08

模型与算子:那些省行数的巧思

qwen3.py · rotary / layernorm / sampler

只支持一个模型族,换来零抽象层

models/qwen3.py 216 行,结构是标准 decoder-only:嵌入 → N 层(RMSNorm → Attention → RMSNorm → MLP)→ RMSNorm → LMHead,注意力采用 GQA(16 个 Q 头、8 个 KV 头)。没有 registry、没有 mapping 表——想加新模型就照抄这个文件。两个 Qwen3 细节被忠实保留:当 qkv_bias=False 时对 q/k 按头做 q_norm / k_norm(head 维度的 RMSNorm),这是 Qwen3 稳定注意力的关键设计;0.6B 开启 tie_word_embeddings,构造时让 lm_head.weight 与 embedding 权重指向同一份数据,不重复占显存、也不重复加载。

残差在层间「流动」的 RMSNorm

常规写法每层是 h = h + attn(norm(h)); h = h + mlp(norm(h)),产生多次独立的 add 与 norm kernel。nano-vllm 采用 vLLM 同款融合:add_rms_forward(x, residual) 一次完成「加残差 + 归一化」,残差以参数形式在层内显式传递(Qwen3DecoderLayer.forward 里的 hidden_states, residual 双返回值)。再叠加 @torch.compile,add、rsqrt、乘权重被融合成更少的 kernel。

RoPE 单例与 torch.compile

所有层共享同一个 RoPE 模块——get_rope 上挂了 @lru_cache(1),全模型只有一份 cos_sin_cache 表(形状 [max_position, 1, head_dim])。前向时按 positions 索引取 cos/sin 做旋转,整个函数同样被 @torch.compile 接管。

采样器:一行 Gumbel 技巧的优雅实现

Sampler 核心逻辑只有寥寥数行,却藏着一个经典技巧——Gumbel-max 采样的指数分布变体:

$$ \arg\max_i \frac{p_i}{\varepsilon_i},\quad \varepsilon_i \sim \text{Exponential}(1) \;\sim\; \text{Categorical}(p) $$

除以指数随机数再取 argmax,数学上等价于按 $p$ 做分类采样(取 log 后即 Gumbel-max 形式),全程无循环、可向量化,还能被 @torch.compile 编译进 CUDA Graph 里。温度除法在 softmax 前的 logits 上完成。值得注意的限制:SamplingParams 断言 temperature > 1e-10,即不允许贪心解码(temperature=0 会除零)——想要 greedy 得自己改两行,这算是教学项目的任性之处。

值得抄走的设计

权重加载的 weight_loader 协议(参数自带装载函数)、KV Cache 池按层切片挂属性、RoPE 单例、Gumbel-max 采样——这些 10 行内的巧思都可以直接搬进自己的项目。

精度细节

RMSNorm 与 RoPE 内部都先升到 float32 计算再转回原 dtype;Sampler 同样先 logits.float()。低精度存储、高精度计算是 LLM 推理的标准卫生。

09

对照 vLLM:砍掉了什么,怎么读这份代码

Benchmark · 边界 · 阅读路线

性能:教学身板,旗舰成绩

项目自带的 benchmark(RTX 4070 Laptop 8GB、Qwen3-0.6B、256 条随机长度请求):

推理引擎输出 tokens耗时 (s)吞吐 (tokens/s)
vLLM133,96698.371,361.84
nano-vllm133,96693.411,434.13

nano-vllm 略胜 5%。这不意味着它「比 vLLM 强」——0.6B 小模型、单机离线批处理恰好是开销敏感而功能需求少的场景,vLLM 为通用性付出的抽象成本在这里体现为固定开销。但它证明了:连续批处理 + 分页 KV + CUDA Graph 这套组合拳,就是 vLLM 性能的主体;其余几十万行是为生产环境的广度付出的代价。

口径说明:bench.py 先跑一次预热请求再计时,吞吐 = Σ max_tokens ÷ 墙钟时间;因设置了 ignore_eos=True,两个引擎都恰好输出 133,966 个 token,对比的是相同工作量下的端到端耗时。

删减清单:nano-vllm 不做什么

能力vLLMnano-vllm影响
模型支持数百种仅 Qwen3去掉模型 registry,模型文件即文档
在线服务OpenAI 兼容 server、流式输出无纯离线批推理;generate 是一次性返回
采样top-p / top-k / greedy / beam 等仅温度采样Sampler 12 行;不支持 temperature=0
调度优先级、混部 prefill+decode、多LoRAFCFS + 队首切块 + 队尾抢占在线高并发延迟表现会弱于 vLLM
抢占恢复recompute / swap 双模式仅 recompute无 CPU 缓存池,实现减半
并行TP / PP / EP / DP、跨机单机 TP ≤ 8 卡共享内存指令分发,无流水线并行
量化 / 投机解码 / 多模态支持无这些正是 vLLM 代码量的主要去向

读源码时的几个「坑位提示」

KEY TAKEAWAY

nano-vllm 是「推理引擎的最小完备集」:它证明 1200 行足以装下现代 LLM serving 的全部核心思想。把它读通之后再去读 vLLM,你会看到同样的骨架外面包着生产的血肉——而不是一堵陌生的墙。

动手建议

三条循序渐进的改造练习:① 给 Sampler 加 top-p / top-k;② 给调度器加 prefill+decode 混合 batch;③ 实现 beam search(提示:需要 fork block_table 并管理 ref_count——PagedAttention 论文的并行采样场景)。每一个都会让你对对应模块的理解深入一层。

10

参考文献

References
  1. GeeeekExplorer/nano-vllm(俞星凯 / DeepSeek)本文剖析的代码本体,README 含 benchmark 数据 · GitHub · 2025-06 开源github.com/GeeeekExplorer/nano-vllm
  2. Efficient Memory Management for Large Language Model Serving with PagedAttentionKwon et al. · SOSP 2023 · vLLM 原始论文,分页 KV Cache 与块管理的出处arXiv:2309.06180
  3. Orca: A Distributed Serving System for Transformer-Based Generative ModelsYu et al. · OSDI 2022 · iteration-level scheduling(连续批处理)的出处OSDI'22
  4. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-AwarenessDao et al. · NeurIPS 2022 · nano-vllm attention 层直接调用的 kernel 库arXiv:2205.14135
  5. FlashAttention-2: Better Attention with Better Work PartitioningDao · 2023 · varlen / paged KV 接口的提供方arXiv:2307.08691
  6. Megatron-LM: Training Multi-Billion Parameter Language Models Using Model ParallelismShoeybi et al. · 2019 · 列/行并行权值切分范式的出处arXiv:1909.08053
  7. Accelerating PyTorch with CUDA GraphsPyTorch Blog · CUDA Graph 捕获与重放机制的官方教程,对应 model_runner.py 的 capture_cudagraphpytorch.org
滚轮缩放 · 拖拽平移 · 双击复位 · ESC 退出