10308 words
52 minutes
Nano-vLLM 源码阅读指引

1. 项目概述#

1.1 这是什么?#

Nano-vLLM 是一个大语言模型(LLM)推理引擎。它的作用是:

给定一个训练好的模型(如 Qwen3-0.6B)和用户的输入文本,快速生成输出文本。

你可以把它理解为”模型的运行环境”——就像 JVM 运行 Java 程序一样,nano-vLLM 负责高效地运行大语言模型。

1.2 为什么叫 nano-vLLM?#

它是对著名项目 vLLM 的简化复刻。vLLM 有几十万行代码,而 nano-vLLM 只有约 1200 行 Python 代码,所以叫 “nano”(纳米级)。虽然精简,但它实现了 vLLM 的几个核心技术:

技术一句话解释
Prefix Caching(前缀缓存)相同的输入前缀只算一次,后续复用
Tensor Parallelism(张量并行)把模型切分到多个 GPU 上并行计算
CUDA Graph(CUDA 图)把 GPU 操作录制成”录像带”,减少启动开销
Torch Compile(即时编译)让 PyTorch 自动优化计算图
Chunked Prefill(分块预填充)把长输入切成小块处理,避免卡顿

1.3 项目文件结构#

项目文件结构
├── pyproject.toml # 项目配置(依赖、版本等)
├── example.py # 使用示例
├── bench.py # 性能测试脚本
└── nanovllm/ # 主代码包
├── __init__.py # 包的入口
├── config.py # 配置类
├── sampling_params.py # 采样参数
├── llm.py # 顶层 LLM 类(对外接口)
├── engine/ # 引擎核心
│ ├── sequence.py # 序列状态管理
│ ├── block_manager.py # KV-Cache 块管理
│ ├── scheduler.py # 请求调度器
│ ├── model_runner.py # 模型执行器
│ └── llm_engine.py # 引擎主控制器
├── layers/ # 神经网络基础组件
│ ├── activation.py # 激活函数
│ ├── attention.py # 注意力机制
│ ├── embed_head.py # 词嵌入和输出头
│ ├── layernorm.py # 层归一化
│ ├── linear.py # 线性层(全连接层)
│ ├── rotary_embedding.py # 旋转位置编码
│ └── sampler.py # 采样器
├── models/ # 模型定义
│ └── qwen3.py # Qwen3 模型结构
└── utils/ # 工具函数
├── context.py # 上下文传递
└── loader.py # 模型权重加载

2. 前置知识:大语言模型推理基础#

在阅读源码之前,需要理解几个核心概念。如果你已经了解,可以跳过。

2.1 Token(词元)#

大语言模型不直接处理文字,而是处理”token”。一个 token 大约等于 0.75 个英文单词或 0.5 个汉字。

Token(词元)
"Hello, world!" → ["Hello", ",", " world", "!"] → [15496, 11, 995, 0]

推理过程就是:输入一串 token ID,模型输出下一个最可能的 token ID。

2.2 自回归生成(Autoregressive Generation)#

大语言模型是一步一步生成文本的:

自回归生成(Autoregressive Generation)
输入: "介绍一下你自己"
步骤1: 模型处理所有输入 token,输出 token "我"
步骤2: 模型处理 "介绍一下你自己" + "我",输出 token "是"
步骤3: 模型处理 "介绍一下你自己" + "我" + "是",输出 token "AI"
... 直到输出结束符或达到最大长度

2.3 Prefill 和 Decode(预填充和解码)#

这是推理引擎中最重要的两个阶段:

  • Prefill(预填充):第一次处理用户输入时,一次性把所有输入 token 喂给模型。这个阶段计算量大(要算很多 token),但可以并行。
  • Decode(解码):之后每生成一个新 token,只需要处理这一个 token。计算量小,但必须串行(因为每次依赖上一轮的输出)。
Prefill 和 Decode(预填充和解码)
Prefill 阶段: [你, 好, 请, 介绍, 自己] → 一次性处理 5 个 token → 输出 token "我"
Decode 阶段: [我] → 处理 1 个 token → 输出 token "是"
Decode 阶段: [是] → 处理 1 个 token → 输出 token "AI"

类比:Prefill 像是读题、理解题意;Decode 像是逐字写答案。

2.4 KV-Cache(键值缓存)#

在 Decode 阶段,每次只处理一个新 token,但模型仍然需要”看到”之前的所有 token。如果每次都重新计算所有历史 token,效率会非常低。

KV-Cache 的核心思想是:把每个 token 计算出的 Key 和 Value(注意力机制中的中间结果)存起来,下次只需要用缓存的 K、V 和新 token 的 Q(Query)做运算。

KV-Cache(键值缓存)
第1步: 处理 [你] → 缓存 K₁V₁
第2步: 处理 [好] → 需要看 K₁V₁ + 新算 K₂V₂,缓存 K₁V₁K₂V₂
第3步: 处理 [请] → 需要看 K₁V₁K₂V₂ + 新算 K₃V₃,缓存 K₁V₁K₂V₂K₃V₃
...

2.5 批处理(Batching)#

实际部署中,可能同时有多个用户请求。批处理就是把多个请求拼成一个大矩阵一起计算,提高 GPU 利用率。

3. 整体架构图#

flowchart TD A["<b>用户调用</b><br/>llm = LLM(model_path)<br/>outputs = llm.generate(prompts, sampling_params)"] B["<b>LLMEngine</b><br/>· 接收请求 → 转成 Sequence → 交给 Scheduler<br/>· 循环调用 step() 直到所有请求完成<br/>· 返回解码后的文本"] C["<b>Scheduler</b><br/>· 管理等待队列<br/>· 管理运行队列<br/>· 决定本轮计算谁<br/>· 调用 BlockManager"] D["<b>ModelRunner</b><br/>· 运行神经网络模型<br/>· 管理 GPU 上的 KV-Cache<br/>· CUDA Graph 加速<br/>· Tensor Parallelism"] E["<b>BlockManager</b><br/>· KV-Cache 分配<br/>· 前缀缓存命中<br/>· 块的引用计数"] F["<b>Qwen3ForCausalLM</b><br/>· Transformer 模型结构<br/>· 多层 DecoderLayer"] A --> B B --> C B --> D C --> E D --> F

简单理解

  • Scheduler = 调度员,决定”现在该算谁”
  • BlockManager = 内存管理员,管理 KV-Cache 的分配和回收
  • ModelRunner = 真正的计算工人,在 GPU 上跑模型

4. 逐文件解析#

4.1 入口与配置层#

4.1.1 nanovllm/__init__.py#

from nanovllm.llm import LLM
from nanovllm.sampling_params import SamplingParams

这段代码在做什么?

这是 Python 包机制。当你写 from nanovllm import LLM 时,Python 就会从这个文件找到 LLM 类。它只暴露了两个东西给外部使用:LLM(推理引擎)和 SamplingParams(采样参数),说明这是最简单的 API 设计——你只需要这两个就能用。

4.1.2 nanovllm/config.py#

@dataclass(slots=True)
class Config:
model: str # 模型文件夹路径
max_num_batched_tokens: int = 16384 # 一批最多处理多少个 token
max_num_seqs: int = 512 # 一批最多同时处理多少个请求
max_model_len: int = 4096 # 模型最大上下文长度
gpu_memory_utilization: float = 0.9 # 最多使用 GPU 显存的 90%
tensor_parallel_size: int = 1 # 用几张 GPU(1=单卡)
enforce_eager: bool = False # 是否禁用 CUDA Graph
hf_config: AutoConfig | None = None # HuggingFace 的模型配置
eos: int = -1 # 结束符的 token ID
kvcache_block_size: int = 256 # 每个 KV-Cache 块的大小
num_kvcache_blocks: int = -1 # KV-Cache 块的数量(自动计算)

这段代码在做什么?

Config 是一个数据类(dataclass),用来集中管理所有配置项。@dataclass(slots=True) 的意思是:

  • @dataclass:Python 会自动生成 __init____repr__ 等方法,不用手写
  • slots=True:节省内存,禁止动态添加属性

__post_init__ 方法__init__ 之后自动调用,做一些校验和初始化:

def __post_init__(self):
assert os.path.isdir(self.model) # 1. 确保模型目录存在
assert self.kvcache_block_size % 256 == 0 # 2. 块大小必须是 256 的倍数
assert 1 <= self.tensor_parallel_size <= 8 # 3. GPU 数量在 1-8 之间
self.hf_config = AutoConfig.from_pretrained(self.model) # 4. 读取模型配置文件
self.max_model_len = min(self.max_model_len, self.hf_config.max_position_embeddings) # 5. 限制最大长度

为什么 kvcache_block_size 必须是 256 的倍数? 为了内存对齐。GPU 内存按 128 字节对齐,256 个 token × 每个 token 的 KV 数据量能更好地利用内存带宽。

4.1.3 nanovllm/sampling_params.py#

@dataclass(slots=True)
class SamplingParams:
temperature: float = 1.0 # 温度:控制随机性(越低越确定)
max_tokens: int = 64 # 最多生成多少个 token
ignore_eos: bool = False # 是否忽略结束符(一直生成到 max_tokens)
def __post_init__(self):
assert self.temperature > 1e-10, "greedy sampling is not permitted"

这段代码在做什么?

控制文本生成的参数:

  • temperature(温度):控制输出有多”随机”。温度=0 时每次都选概率最高的 token(贪心解码),温度越高越随机。这里禁止了贪心解码(temperature 必须大于一个极小值)。
  • max_tokens:生成的最大 token 数量,防止模型无限输出。
  • ignore_eos:如果设为 True,即使模型输出了结束符(EOS),也会继续生成直到 max_tokens。

4.1.4 nanovllm/llm.py#

from nanovllm.engine.llm_engine import LLMEngine
class LLM(LLMEngine):
pass

这段代码在做什么?

LLM 类直接继承 LLMEngine,不添加任何新方法。这是为了:

  1. 简化接口:用户只需 from nanovllm import LLM
  2. 命名清晰LLM 是给用户看的,LLMEngine 是内部实现
  3. 扩展性:未来可以在 LLM 中重写方法而不影响 LLMEngine

4.2 引擎层(核心调度系统)#

引擎层是整个项目的心脏。

4.2.1 nanovllm/engine/sequence.py — 序列状态管理#

class SequenceStatus(Enum):
WAITING = auto() # 等待中(还没开始算)
RUNNING = auto() # 运行中(正在处理)
FINISHED = auto() # 已完成

什么是 Sequence(序列)?

一个序列就是一个用户的请求。它包含:

  • 输入的 token 列表
  • 已经生成的 token 列表
  • 当前状态(等待/运行/完成)
  • KV-Cache 的块表(记录缓存存在哪)
class Sequence:
block_size = 256 # 类变量:每个块的大小
counter = count() # 类变量:自增计数器(从0开始,每次创建序列+1)
def __init__(self, token_ids, sampling_params):
self.seq_id = next(Sequence.counter) # 自动分配唯一 ID
self.status = SequenceStatus.WAITING # 初始状态:等待
self.token_ids = copy(token_ids) # 深拷贝 token 列表
self.last_token = token_ids[-1] # 最后一个 token(decode 时需要)
self.num_tokens = len(self.token_ids) # 当前总 token 数
self.num_prompt_tokens = len(token_ids) # 输入 token 数(不含生成)
self.num_cached_tokens = 0 # 被前缀缓存命中的 token 数
self.num_scheduled_tokens = 0 # 本轮计划处理的 token 数
self.is_prefill = True # 是否处于预填充阶段
self.block_table = [] # KV-Cache 块表
self.temperature = ... # 从采样参数复制
self.max_tokens = ...
self.ignore_eos = ...

关键属性解释

属性含义举例
num_prompt_tokens原始输入的 token 数输入 “Hello world” = 2 个 token
num_tokens当前总 token 数(输入+已生成)如果生成了 “Hi” = 2+1=3
num_cached_tokens被缓存的 token 数(前缀命中)如果前 4 个 token 之前算过 = 4
num_scheduled_tokens本轮要计算的 token 数prefill 时可能是 10,decode 时是 1
block_tableKV-Cache 块的编号列表[3, 7, 12] 表示数据存在第 3、7、12 号块

重要方法详解

@property
def num_blocks(self):
return (self.num_tokens + self.block_size - 1) // self.block_size

计算当前 token 需要多少个块。这是向上取整的整数除法技巧:

  • 256 个 token → (256+255)//256 = 1
  • 257 个 token → (257+255)//256 = 2
  • 1 个 token → (1+255)//256 = 1
def block(self, i):
return self.token_ids[i*self.block_size: (i+1)*self.block_size]

取第 i 个块中的 token:

  • block(0)token_ids[0:256]
  • block(1)token_ids[256:512]
def append_token(self, token_id):
self.token_ids.append(token_id)
self.last_token = token_id
self.num_tokens += 1

每生成一个新 token,追加到序列末尾,并更新计数。

def __getstate__(self):
last_state = self.last_token if not self.is_prefill else self.token_ids
return (self.num_tokens, self.num_prompt_tokens, self.num_cached_tokens,
self.num_scheduled_tokens, self.block_table, last_state)
def __setstate__(self, state):
# 从序列化状态中恢复
...

这两个方法是 Python 的序列化协议。当需要把 Sequence 对象通过 pickle 传输时(这在多 GPU 模式下会发生),__getstate__ 决定保存什么,__setstate__ 决定如何恢复。关键优化:如果序列不在 prefill 阶段,只保存 last_token 而不是整个 token_ids 列表,因为 decode 阶段只关心最后一个 token。

4.2.2 nanovllm/engine/block_manager.py — KV-Cache 块管理#

这是整个项目中最巧妙的模块之一,实现了 vLLM 的 PagedAttention 思想。

为什么需要块管理?

KV-Cache 是 GPU 显存的最大消耗者。如果每个序列都预留最长的连续空间(比如 4096 个 token 的位置),显存很快就用完了。而且大多数请求并不会真的用到 4096。

核心思想:把 KV-Cache 分成固定大小的”块”(Block),按需分配,用完回收。

这就像操作系统中的虚拟内存分页——不需要连续的大块内存,而是用很多小块拼起来。

class Block:
def __init__(self, block_id):
self.block_id = block_id # 块的编号
self.ref_count = 0 # 引用计数:几个序列在用这个块
self.hash = -1 # 内容的哈希值(用于前缀缓存匹配)
self.token_ids = [] # 块中存储的 token ID

Block(块) 是一个简单的数据结构:

  • ref_count:记录有多少个序列在使用这个块。当 ref_count 降到 0 时,块可以被回收。
  • hash:块内 token 的哈希值。如果两个请求的前缀哈希相同,说明它们的前缀也相同,可以共享 KV-Cache。
  • token_ids:块内存储的具体 token ID(用于验证哈希碰撞)。
class BlockManager:
def __init__(self, num_blocks, block_size):
self.blocks = [Block(i) for i in range(num_blocks)] # 所有块
self.hash_to_block_id = dict() # 哈希 → 块ID 映射
self.free_block_ids = deque(range(num_blocks)) # 空闲块队列
self.used_block_ids = set() # 已使用块集合

数据结构选择

  • free_block_idsdeque(双端队列):因为需要从头部取块(分配时 popleft),尾部放回(回收时 append)。
  • used_block_idsset:因为需要 O(1) 时间判断块是否在使用中。
  • hash_to_block_iddict:O(1) 时间通过哈希查找块。

核心方法详解

@classmethod
def compute_hash(cls, token_ids, prefix=-1):
h = xxhash.xxh64() # 创建 xxHash 对象(很快的哈希算法)
if prefix != -1:
h.update(prefix.to_bytes(8, "little")) # 把前一个块的哈希也混入
h.update(np.array(token_ids).tobytes()) # 把 token ID 数组转成字节混入
return h.intdigest() # 返回 64 位哈希值

为什么要把前一个块的哈希也混入? 这叫”链式哈希”。如果不混入,两个不同位置的相同 token 序列会产生相同的哈希,导致错误匹配。例如:

  • 序列 A 的第 1 个块:[1,2,3] → 哈希 H₁
  • 序列 B 的第 2 个块:[1,2,3] → 如果只看内容,哈希也是 H₁,但前缀不同!

把前一个块的哈希混入后:只有前缀完全相同,这个块的哈希才会相同

def can_allocate(self, seq):
"""检查能否为一个序列分配 KV-Cache,返回前缀命中的块数"""
h = -1
num_cached_blocks = 0
num_new_blocks = seq.num_blocks
for i in range(seq.num_blocks - 1):
token_ids = seq.block(i)
h = self.compute_hash(token_ids, h)
block_id = self.hash_to_block_id.get(h, -1)
if block_id == -1 or self.blocks[block_id].token_ids != token_ids:
break # 前缀匹配中断
num_cached_blocks += 1
if block_id in self.used_block_ids:
num_new_blocks -= 1 # 这个块已经被其他序列用了,不占用新空间
if len(self.free_block_ids) < num_new_blocks:
return -1 # 空闲块不够,无法分配
return num_cached_blocks

这段代码是关键中的关键。 它遍历序列的所有块,逐个检查:

  1. 计算当前块的哈希
  2. hash_to_block_id 中查找是否有相同的哈希
  3. 如果找到且 token 完全相同 → 前缀缓存命中!这个块不用重新计算
  4. 如果已命中的块已被其他序列使用 → 不需要新分配,增加引用计数即可
  5. 最后检查空闲块是否够用
def allocate(self, seq, num_cached_blocks):
"""为序列分配 KV-Cache 块"""
h = -1
# 第一步:处理前缀命中的块(增加引用计数)
for i in range(num_cached_blocks):
token_ids = seq.block(i)
h = self.compute_hash(token_ids, h)
block_id = self.hash_to_block_id[h]
block = self.blocks[block_id]
if block_id in self.used_block_ids:
block.ref_count += 1 # 共享块,引用+1
else:
block.ref_count = 1 # 之前空闲的块,标记为使用
self.free_block_ids.remove(block_id)
self.used_block_ids.add(block_id)
seq.block_table.append(block_id)
# 第二步:为新块分配空间
for i in range(num_cached_blocks, seq.num_blocks):
seq.block_table.append(self._allocate_block())
seq.num_cached_tokens = num_cached_blocks * self.block_size
def deallocate(self, seq):
"""释放序列占用的 KV-Cache 块"""
for block_id in reversed(seq.block_table):
block = self.blocks[block_id]
block.ref_count -= 1
if block.ref_count == 0:
self._deallocate_block(block_id) # 没有其他人引用,真正释放
seq.num_cached_tokens = 0
seq.block_table.clear()

注意:释放是从后往前做的reversed),因为释放顺序不影响正确性,但反过来做能避免某些边界情况。

def can_append(self, seq):
"""检查序列是否需要并能够分配一个新块"""
return len(self.free_block_ids) >= (len(seq) % self.block_size == 1)

这行代码很精妙:len(seq) % self.block_size == 1 的意思是”当前 token 数刚好超出上一个块的边界 1 个(即需要新块但还没分配)“。如果是 True 且有空闲块,返回 True。

def may_append(self, seq):
"""如果需要,为序列追加一个新块"""
if len(seq) % self.block_size == 1:
seq.block_table.append(self._allocate_block())
def hash_blocks(self, seq):
"""计算并存储序列块的哈希值(为前缀缓存做准备)"""
start = seq.num_cached_tokens // self.block_size
end = (seq.num_cached_tokens + seq.num_scheduled_tokens) // self.block_size
if start == end: return
h = self.blocks[seq.block_table[start - 1]].hash if start > 0 else -1
for i in range(start, end):
block = self.blocks[seq.block_table[i]]
token_ids = seq.block(i)
h = self.compute_hash(token_ids, h)
block.update(h, token_ids) # 更新块的哈希和内容
self.hash_to_block_id[h] = block.block_id # 注册到哈希表

为什么要在算完后哈希? 因为只有模型计算完成后,KV-Cache 中才有有效数据。这时候给块打上哈希标签,后续的请求才能通过哈希匹配来复用。

4.2.3 nanovllm/engine/scheduler.py — 请求调度器#

Scheduler(调度器)很像操作系统的进程调度器——决定哪个请求在什么时候被处理。

class Scheduler:
def __init__(self, config):
self.max_num_seqs = config.max_num_seqs # 最多同时处理多少请求
self.max_num_batched_tokens = config.max_num_batched_tokens # 一批最多多少 token
self.eos = config.eos # 结束符 token ID
self.block_size = config.kvcache_block_size
self.block_manager = BlockManager(...)
self.waiting = deque() # 等待队列(还没开始或被打断的请求)
self.running = deque() # 运行队列(正在处理的请求)

两个队列

  • waiting:新来的请求或因为内存不够被打断的请求
  • running:正在处理的请求
def schedule(self) -> tuple[list[Sequence], bool]:

这是最核心的方法,一次调用称为一个 step。返回:

  • list[Sequence]:本轮要处理的序列
  • boolTrue = prefill 阶段,False = decode 阶段

调度策略(两个阶段)

阶段一:Prefill(预填充)

while self.waiting and len(scheduled_seqs) < self.max_num_seqs:
seq = self.waiting[0]
remaining = self.max_num_batched_tokens - num_batched_tokens
if remaining == 0:
break # 1. 本轮 token 预算用完
if not seq.block_table:
num_cached_blocks = self.block_manager.can_allocate(seq)
if num_cached_blocks == -1:
break # 2. KV-Cache 不够
num_tokens = seq.num_tokens - num_cached_blocks * self.block_size
else:
num_tokens = seq.num_tokens - seq.num_cached_tokens # 被打断后继续
if remaining < num_tokens and scheduled_seqs: # 3. 只对第一个序列做 chunked prefill
break
if not seq.block_table:
self.block_manager.allocate(seq, num_cached_blocks)
seq.num_scheduled_tokens = min(num_tokens, remaining)
num_batched_tokens += seq.num_scheduled_tokens
if seq.num_cached_tokens + seq.num_scheduled_tokens == seq.num_tokens:
seq.status = SequenceStatus.RUNNING # 该序列 prefill 完成
self.waiting.popleft()
self.running.append(seq)
scheduled_seqs.append(seq)

调度逻辑解读

  1. Token 预算:每轮最多处理 max_num_batched_tokens 个 token。如果剩下的预算不够处理下一个序列了,就停止。
  2. KV-Cache 检查:先检查有没有足够的内存分配给这个序列。
  3. Chunked Prefill:如果第一个序列的剩余 token 数超过预算,允许部分处理(chunked prefill)。但只有第一个可以被分块scheduled_seqs 非空时不接受分块),这样保证后面的序列都能完整处理。
  4. 分配和标记:分配 KV-Cache 后,如果该序列的所有 token 都处理完了,就把它从 waiting 移到 running。

阶段二:Decode(解码)

while self.running and len(scheduled_seqs) < self.max_num_seqs:
seq = self.running.popleft()
while not self.block_manager.can_append(seq):
if self.running:
self.preempt(self.running.pop()) # 内存不够,抢占最后一个 running 序列
else:
self.preempt(seq) # 只有它自己,只能抢占自己
break
else:
seq.num_scheduled_tokens = 1
seq.is_prefill = False
self.block_manager.may_append(seq)
scheduled_seqs.append(seq)

Decode 阶段特点

  • 每个序列每次只处理 1 个 token
  • 如果内存不够分配新块,就抢占(preempt)其他序列
  • 抢占策略:抢占 running 队列最后面的序列(像栈一样 pop 最后一个)
def preempt(self, seq):
seq.status = SequenceStatus.WAITING
seq.is_prefill = True
self.block_manager.deallocate(seq) # 释放 KV-Cache
self.waiting.appendleft(seq) # 放回等待队列头部(优先处理)

抢占意味着:把运行中的序列踢出去,释放它的 KV-Cache,放回等待队列。下次轮到它时需要重新计算所有 KV-Cache(没有前缀缓存的话)。

def postprocess(self, seqs, token_ids, is_prefill):
for seq, token_id in zip(seqs, token_ids):
self.block_manager.hash_blocks(seq) # 给刚算完的块打哈希标签
seq.num_cached_tokens += seq.num_scheduled_tokens
seq.num_scheduled_tokens = 0
if is_prefill and seq.num_cached_tokens < seq.num_tokens:
continue # chunked prefill 未完成,不追加 token
seq.append_token(token_id) # 追加生成的新 token
if (not seq.ignore_eos and token_id == self.eos) or \
seq.num_completion_tokens == seq.max_tokens:
seq.status = SequenceStatus.FINISHED # 达到结束条件
self.block_manager.deallocate(seq)
self.running.remove(seq)

模型输出后,Scheduler 负责:

  1. 给计算过的块打哈希(为前缀缓存)
  2. 更新计数
  3. 追加新生成的 token
  4. 检查是否结束(遇到 EOS 或达到 max_tokens)

4.2.4 nanovllm/engine/model_runner.py — 模型执行器#

这是最复杂的文件,负责在 GPU 上真正运行模型。

class ModelRunner:
def __init__(self, config, rank, event):
# 1. 初始化分布式通信
dist.init_process_group("nccl", "tcp://localhost:2333", ...)
torch.cuda.set_device(rank)
# 2. 创建模型
self.model = Qwen3ForCausalLM(hf_config)
load_model(self.model, config.model) # 加载权重
# 3. 准备
self.sampler = Sampler()
self.warmup_model() # 预热:跑一遍空数据
self.allocate_kv_cache() # 分配 KV-Cache 显存
if not self.enforce_eager:
self.capture_cudagraph() # 录制 CUDA Graph

allocate_kv_cache 方法详解

def allocate_kv_cache(self):
# 计算还剩多少可用显存
free, total = torch.cuda.mem_get_info()
used = total - free
peak = torch.cuda.memory_stats()["allocated_bytes.all.peak"]
current = torch.cuda.memory_stats()["allocated_bytes.all.current"]
# 计算每个块占多少字节
num_kv_heads = hf_config.num_key_value_heads // self.world_size
head_dim = hf_config.hidden_size // hf_config.num_attention_heads
block_bytes = 2 * num_layers * block_size * num_kv_heads * head_dim * dtype_size
# 2 = K 和 V 两份,每个 token 每层都有各自的 K、V
# 计算可以分配多少块
config.num_kvcache_blocks = int(
total * gpu_memory_utilization - used - peak + current
) // block_bytes
# 创建 KV-Cache 张量(形状:(2, 层数, 块数, 块大小, KV头数, 头维度))
self.kv_cache = torch.empty(2, num_layers, num_blocks, block_size, num_kv_heads, head_dim)
# 把缓存分配给每个 Attention 层
for module in self.model.modules():
if hasattr(module, "k_cache") and hasattr(module, "v_cache"):
module.k_cache = self.kv_cache[0, layer_id]
module.v_cache = self.kv_cache[1, layer_id]

显存计算公式

nanovllm/engine/model_runner.py — 模型执行器
KV-Cache 总字节数 = 2(K和V) × 层数 × 块数 × 块大小(token数) × KV头数 × 头维度 × 每个元素字节数

例如 Qwen3-0.6B:2 × 28层 × N块 × 256token × 8KV头 × 128维度 × 2字节(bf16) = 每个块约 3.6MB

def prepare_prefill(self, seqs):
input_ids = [] # 所有待计算的 token ID
positions = [] # 每个 token 在序列中的位置
cu_seqlens_q = [0] # 累积查询长度(用于 varlen attention)
cu_seqlens_k = [0] # 累积键长度
slot_mapping = [] # 每个 token 的 KV-Cache 位置
for seq in seqs:
start = seq.num_cached_tokens # 之前缓存了多少
seqlen_q = seq.num_scheduled_tokens # 本轮算多少
end = start + seqlen_q
input_ids.extend(seq[start:end]) # 只取要算的部分
positions.extend(range(start, end)) # 位置编码
# ... 累积长度用于 Flash Attention
# ... 计算 slot_mapping 用于 KV-Cache 存储

关键:cu_seqlens_qcu_seqlens_k 这是 Flash Attention 的”可变长度序列”输入格式。假设有 3 个序列,长度分别为 5, 3, 4:

nanovllm/engine/model_runner.py — 模型执行器
cu_seqlens = [0, 5, 8, 12]

Flash Attention 通过这个数组知道每个序列从哪里开始、哪里结束,不需要填充。

slot_mapping 是每个 token 在 KV-Cache 中的位置:

nanovllm/engine/model_runner.py — 模型执行器
slot = 块编号 × 块大小 + 块内偏移

例如:block_table = [3, 7],block_size = 256

  • 第 0~255 个 token → slot 3×256+0 到 3×256+255
  • 第 256~511 个 token → slot 7×256+0 到 7×256+255
def prepare_decode(self, seqs):
for seq in seqs:
input_ids.append(seq.last_token) # 只有最后一个 token
positions.append(len(seq) - 1) # 最后的位置
context_lens.append(len(seq)) # 当前总长度
slot_mapping.append( # KV-Cache 写入位置
seq.block_table[-1] * self.block_size + seq.last_block_num_tokens - 1
)

Deocde 阶段每次只处理 1 个 token——seq.last_token

def run(self, seqs, is_prefill):
input_ids, positions = self.prepare_prefill(seqs) if is_prefill \
else self.prepare_decode(seqs)
temperatures = self.prepare_sample(seqs)
logits = self.run_model(input_ids, positions, is_prefill)
token_ids = self.sampler(logits, temperatures).tolist()
return token_ids

run 方法整合了四个步骤:准备数据 → 运行模型 → 采样 → 返回 token。

CUDA Graph 部分

def capture_cudagraph(self):
max_bs = min(max_num_seqs, 512)
# 创建固定大小的输入张量
input_ids = torch.zeros(max_bs, dtype=torch.int64)
positions = torch.zeros(max_bs, dtype=torch.int64)
# ...
self.graph_bs = [1, 2, 4, 8, 16, 32, 48, 64, ..., 512]
# 为每个批大小录制一个 CUDA Graph
for bs in reversed(self.graph_bs):
graph = torch.cuda.CUDAGraph()
# 先跑一次让 CUDA 预热
outputs[:bs] = self.model(input_ids[:bs], positions[:bs])
# 录制
with torch.cuda.graph(graph, self.graph_pool):
outputs[:bs] = self.model(input_ids[:bs], positions[:bs])
self.graphs[bs] = graph

CUDA Graph 是什么? 正常的 PyTorch 调用每次都要经过”Python → CUDA 驱动 → GPU 内核启动”的流程,对于小批量(decode 时只有几个 token),这个开销很大。CUDA Graph 把整个 GPU 操作序列录下来,之后只需”播放”即可,大幅减少开销。

为什么按不同批大小录制? 因为每次 decode 的序列数量不同。代码录制了 [1, 2, 4, 8, 16, 32, 48, ..., 512] 这些大小,实际运行时选一个刚好够用的。

Tensor Parallelism(张量并行)通信

def call(self, method_name, *args):
if self.world_size > 1 and self.rank == 0:
self.write_shm(method_name, *args) # 主进程写共享内存
method = getattr(self, method_name, None)
return method(*args)

当使用多 GPU 时:

  1. 主进程(rank 0)把方法名和参数写到共享内存(SharedMemory)
  2. 其他 GPU 进程通过 loop() 方法不断等待并读取共享内存
  3. 读取后执行相同的方法(比如 run),这样所有 GPU 同时计算各自的部分
  4. 模型内部通过 NCCL(NVIDIA 集合通信库)自动同步中间结果

4.2.5 nanovllm/engine/llm_engine.py — 引擎主控制器#

这是最顶层的引擎,对外提供 generate 方法。

class LLMEngine:
def __init__(self, model, **kwargs):
# 1. 过滤出 Config 需要的参数
config_fields = {field.name for field in fields(Config)}
config_kwargs = {k: v for k, v in kwargs.items() if k in config_fields}
config = Config(model, **config_kwargs)
# 2. 启动多 GPU 进程(如果需要)
ctx = mp.get_context("spawn")
for i in range(1, config.tensor_parallel_size):
event = ctx.Event()
process = ctx.Process(target=ModelRunner, args=(config, i, event))
process.start()
self.ps.append(process)
self.events.append(event)
# 3. 主进程也创建自己的 ModelRunner
self.model_runner = ModelRunner(config, 0, self.events)
# 4. 加载分词器
self.tokenizer = AutoTokenizer.from_pretrained(config.model, use_fast=True)
config.eos = self.tokenizer.eos_token_id
# 5. 创建调度器
self.scheduler = Scheduler(config)
# 6. 注册退出回调
atexit.register(self.exit)

多进程架构

多进程架构
GPU 0 (主进程): 用户交互 + 调度 + 模型计算(主份)
GPU 1 (子进程): 模型计算(分片) ← 通过共享内存通信
GPU 2 (子进程): 模型计算(分片) ← 通过共享内存通信
def step(self):
seqs, is_prefill = self.scheduler.schedule() # 1. 调度:选谁算
token_ids = self.model_runner.call("run", seqs, is_prefill) # 2. 执行:算
self.scheduler.postprocess(seqs, token_ids, is_prefill) # 3. 后处理:更新状态
outputs = [(seq.seq_id, seq.completion_token_ids) # 4. 收集完成的
for seq in seqs if seq.is_finished]
return outputs, num_tokens

主循环(generate 方法)

def generate(self, prompts, sampling_params, use_tqdm=True):
# 把所有 prompt 添加为请求
for prompt, sp in zip(prompts, sampling_params):
self.add_request(prompt, sp)
# 循环直到所有请求完成
while not self.is_finished():
output, num_tokens = self.step()
# num_tokens > 0 → prefill,=吞吐量
# num_tokens < 0 → decode,绝对值是序列数
for seq_id, token_ids in output:
outputs[seq_id] = token_ids # 收集完成的序列
# 按顺序返回结果
return [{"text": self.tokenizer.decode(token_ids), "token_ids": token_ids}
for token_ids in outputs.values()]

吞吐量计算的小技巧

  • Prefill 时 num_tokens 是正数(实际处理的 token 数)
  • Decode 时 num_tokens 是负数(-序列数),取反后就是每秒生成的 token 数

4.3 神经网络层#

4.3.1 nanovllm/layers/activation.py — 激活函数#

class SiluAndMul(nn.Module):
@torch.compile
def forward(self, x):
x, y = x.chunk(2, -1) # 在最后一维切成两半
return F.silu(x) * y # 前一半过 SiLU,后一半直接乘

SiLU(Sigmoid Linear Unit,也叫 Swish)SiLU(x) = x × sigmoid(x)

这个 SiluAndMulSwiGLU 激活函数的核心部分。它把输入切成两半:

  • 前一半:做 SiLU 激活
  • 后一半:做门控(直接乘)

@torch.compile 让 PyTorch 把这个函数编译成优化的 CUDA 代码。

4.3.2 nanovllm/layers/attention.py — 注意力机制#

Triton 内核:KV-Cache 存储

@triton.jit
def store_kvcache_kernel(key_ptr, key_stride, value_ptr, value_stride,
k_cache_ptr, v_cache_ptr, slot_mapping_ptr, D):
idx = tl.program_id(0)
slot = tl.load(slot_mapping_ptr + idx)
if slot == -1: return # -1 表示不需要存储
# 从 key/value 张量读取
key = tl.load(key_ptr + idx * key_stride + tl.arange(0, D))
value = tl.load(value_ptr + idx * value_stride + tl.arange(0, D))
# 写入 KV-Cache 的指定位置
tl.store(k_cache_ptr + slot * D + tl.arange(0, D), key)
tl.store(v_cache_ptr + slot * D + tl.arange(0, D), value)

这是一个 Triton 内核(GPU 上运行的函数)。它的工作是:把模型刚算出来的 K、V 值,存储到 KV-Cache 张量的正确位置。

为什么需要自定义 Triton 内核? 因为 KV-Cache 的存储是”散射”操作——每个 token 的 K、V 要写到不同的 slot 位置。普通的 PyTorch 索引操作不够高效,Triton 可以直接生成优化的 GPU 代码。

Attention 前向传播

class Attention(nn.Module):
def forward(self, q, k, v):
context = get_context()
# 第一步:把当前 K、V 存到缓存
if k_cache.numel() and v_cache.numel():
store_kvcache(k, v, k_cache, v_cache, context.slot_mapping)
if context.is_prefill:
if context.block_tables is not None: # 有前缀缓存
k, v = k_cache, v_cache # 用缓存中的 K、V
o = flash_attn_varlen_func(...) # Flash Attention(变长序列)
else: # decode
o = flash_attn_with_kvcache(...) # Flash Attention(带缓存)
return o

两种 Flash Attention 函数

  • flash_attn_varlen_func:用于 prefill,处理变长序列。一次性算所有 query token 对历史 key/value 的注意力。
  • flash_attn_with_kvcache:用于 decode,只算 1 个 query token,key 和 value 从缓存中读取。

前缀缓存的接入点:当检测到有前缀缓存(block_tables is not None)时,直接用缓存中的 K、V 替换刚算出的 K、V。

4.3.3 nanovllm/layers/linear.py — 线性层#

这个文件实现了张量并行所需的各种线性层变体。

基本概念:线性层就是 output = input × weight^T + bias

在张量并行中,权重矩阵被切分到多个 GPU 上:

列并行(ColumnParallelLinear):把输出维度切开

列并行(ColumnParallelLinear)
原始: [input] × [====weight====] = [====output====]
GPU 0: [input] × [==weight_0==] = [==output_0==] (前半输出)
GPU 1: [input] × [==weight_1==] = [==output_1==] (后半输出)

每个 GPU 算一部分输出,最后拼起来。

行并行(RowParallelLinear):把输入维度切开

行并行(RowParallelLinear)
原始: [====input====] × [weight] = [output]
GPU 0: [==input_0==] × [weight_0] = [partial_0] \
GPU 1: [==input_1==] × [weight_1] = [partial_1] → all_reduce → [output]

每个 GPU 算一部分,然后求和(all_reduce)得到完整输出。

class QKVParallelLinear(ColumnParallelLinear):
"""专门为 QKV 投影设计的并行层"""
def __init__(self, hidden_size, head_size, total_num_heads, total_num_kv_heads, bias):
output_size = (total_num_heads + 2 * total_num_kv_heads) * head_size
# 输出 = [Q区域 | K区域 | V区域]
# Q: num_heads × head_size
# K: num_kv_heads × head_size
# V: num_kv_heads × head_size
def weight_loader(self, param, loaded_weight, loaded_shard_id):
# loaded_shard_id 是 "q"、"k" 或 "v"
# 根据 shard_id 决定从 param 的哪个位置开始加载

QKV 合并:在 Transformer 中,Q、K、V 的投影通常合并为一个大矩阵乘法,效率更高。QKVParallelLinear 就是把这个合并矩阵又做了张量并行的切分。

4.3.4 nanovllm/layers/layernorm.py — 层归一化#

class RMSNorm(nn.Module):
def __init__(self, hidden_size, eps=1e-6):
self.eps = eps
self.weight = nn.Parameter(torch.ones(hidden_size)) # 可学习的缩放参数
def rms_forward(self, x):
x = x.float()
var = x.pow(2).mean(dim=-1, keepdim=True) # 计算均方值
x.mul_(torch.rsqrt(var + self.eps)) # 归一化:x / sqrt(var+eps)
x = x.to(orig_dtype).mul_(self.weight) # 缩放
return x

RMSNorm 公式RMSNorm(x)=xmean(x2)+ϵ×γ\text{RMSNorm}(x) = \frac{x}{\sqrt{\text{mean}(x^2) + \epsilon}} \times \gamma

它与标准 LayerNorm 的区别是:不减均值,只除以 RMS(均方根)。这样做计算更快,效果几乎一样。

残差融合优化

def add_rms_forward(self, x, residual):
x = x.float().add_(residual.float()) # 先加残差
residual = x.to(orig_dtype) # 保存为下一层的残差
# ... 然后做 RMSNorm

这个方法把残差加法和归一化合并为一步,减少内存访问次数。

4.3.5 nanovllm/layers/rotary_embedding.py — 旋转位置编码#

RoPE(Rotary Position Embedding) 是一种位置编码方式,让模型知道每个 token 在序列中的位置。

def apply_rotary_emb(x, cos, sin):
x1, x2 = torch.chunk(x.float(), 2, dim=-1) # 在最后一维切成两半
y1 = x1 * cos - x2 * sin # 旋转变换(像2D旋转矩阵)
y2 = x2 * cos + x1 * sin
return torch.cat((y1, y2), dim=-1).to(x.dtype)

直观理解:把向量在复数空间中旋转一个角度,角度取决于位置。位置越靠后,旋转角度越大。这样两个 token 的点积(注意力分数)就包含了它们的相对位置信息。

频率计算

inv_freq = 1.0 / (base ** (torch.arange(0, rotary_dim, 2) / rotary_dim))

不同的维度对应不同的旋转频率。低维旋转慢(捕捉长距离关系),高维旋转快(捕捉短距离关系)。这类似于傅里叶变换中的不同频率分量。

缓存机制

cache = torch.cat((cos, sin), dim=-1).unsqueeze_(1)
self.register_buffer("cos_sin_cache", cache, persistent=False)

预先计算好所有位置的 cos/sin 值并缓存。persistent=False 表示它不保存到模型文件中(因为可以从配置重新计算)。

@lru_cache(1)
def get_rope(head_size, rotary_dim, max_position, base):
return RotaryEmbedding(head_size, rotary_dim, max_position, base)

@lru_cache(1) 确保相同参数的 RoPE 只创建一次,避免重复分配内存。

4.3.6 nanovllm/layers/embed_head.py — 词嵌入和输出头#

class VocabParallelEmbedding(nn.Module):
def __init__(self, num_embeddings, embedding_dim):
self.tp_rank = dist.get_rank() # 当前 GPU 编号
self.tp_size = dist.get_world_size() # 总 GPU 数
# 把词表均匀分到多个 GPU 上
self.num_embeddings_per_partition = num_embeddings // tp_size
self.weight = nn.Parameter(torch.empty(num_embeddings_per_partition, embedding_dim))

词表并行:假设词表有 100000 个词,2 个 GPU:

  • GPU 0 存词 0~49999 的嵌入向量
  • GPU 1 存词 50000~99999 的嵌入向量
def forward(self, x):
if self.tp_size > 1:
# 把不在本 GPU 范围的 token 置为 0
mask = (x >= self.vocab_start_idx) & (x < self.vocab_end_idx)
x = mask * (x - self.vocab_start_idx) # 重新映射到本地索引
y = F.embedding(x, self.weight) # 查表
if self.tp_size > 1:
y = mask.unsqueeze(1) * y # 不属于本 GPU 的输出置 0
dist.all_reduce(y) # 所有 GPU 求和
return y

all_reduce 将所有 GPU 的结果求和并广播,这样每个 GPU 都得到完整的嵌入输出。

LM Head(语言模型头):把隐藏状态映射回词表空间,得到每个词的概率。

class ParallelLMHead(VocabParallelEmbedding):
def forward(self, x):
if context.is_prefill:
# 只取每个序列的最后一个 token 的隐藏状态
last_indices = context.cu_seqlens_q[1:] - 1
x = x[last_indices].contiguous()
logits = F.linear(x, self.weight)
if self.tp_size > 1:
# 每个 GPU 只有部分词表的 logits,需要收集(gather)到主 GPU
dist.gather(logits, all_logits, 0)
logits = torch.cat(all_logits, -1)
return logits

Prefill 优化:prefill 时只取每个序列最后一个位置的输出来计算 logits。因为 prefill 的中间 token 不需要生成下一个 token——它们只是为了填充 KV-Cache。

4.3.7 nanovllm/layers/sampler.py — 采样器#

class Sampler(nn.Module):
@torch.compile
def forward(self, logits, temperatures):
logits = logits.float().div_(temperatures.unsqueeze(dim=1)) # 除以温度
probs = torch.softmax(logits, dim=-1) # softmax 得到概率
# Gumbel-Max 采样技巧
sample_tokens = probs.div_(
torch.empty_like(probs).exponential_(1).clamp_min_(1e-10)
).argmax(dim=-1)
return sample_tokens

采样过程

  1. 温度缩放:logits / temperature。温度越高,概率分布越均匀(越随机)。
  2. Softmax:把 logits 转成概率分布。
  3. Gumbel-Max 技巧probs / Exp(1) 等效于 Gumbel 分布的采样。除以指数分布随机数后再取 argmax,得到的就是从多项式分布中采样的结果。

为什么用 Gumbel-Max 而不是 torch.multinomial 因为 Gumbel-Max 更容易被 torch.compile 优化。

4.4 模型定义层#

4.4.1 nanovllm/models/qwen3.py — Qwen3 模型结构#

这是 Qwen3 模型的完整 Transformer 结构实现。

Qwen3Attention — 注意力层:

class Qwen3Attention(nn.Module):
def forward(self, positions, hidden_states):
qkv = self.qkv_proj(hidden_states) # 1. QKV 投影(合并矩阵乘法)
q, k, v = qkv.split([Q, K, V], -1) # 2. 拆分为 Q、K、V
q = q.view(-1, num_heads, head_dim) # 3. 重塑为多头形状
k = k.view(-1, num_kv_heads, head_dim)
v = v.view(-1, num_kv_heads, head_dim)
if not self.qkv_bias:
q = self.q_norm(q) # 4. QK 归一化(Qwen3 特有)
k = self.k_norm(k)
q, k = self.rotary_emb(positions, q, k) # 5. 应用旋转位置编码
o = self.attn(q, k, v) # 6. 注意力计算
output = self.o_proj(o.flatten(1, -1)) # 7. 输出投影
return output

Qwen3MLP — 前馈网络:

class Qwen3MLP(nn.Module):
def forward(self, x):
gate_up = self.gate_up_proj(x) # 合并的 Gate + Up 投影
x = self.act_fn(gate_up) # SiLU 门控激活
x = self.down_proj(x) # Down 投影
return x

Qwen3DecoderLayer — 一个完整的 Transformer 层:

class Qwen3DecoderLayer(nn.Module):
def forward(self, positions, hidden_states, residual):
# 残差连接:hidden_states + residual
if residual is None:
hidden_states, residual = self.input_layernorm(hidden_states), hidden_states
else:
hidden_states, residual = self.input_layernorm(hidden_states, residual)
# 注意力
hidden_states = self.self_attn(positions, hidden_states)
# 残差 + 归一化
hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
# 前馈网络
hidden_states = self.mlp(hidden_states)
return hidden_states, residual

残差连接的巧妙的实现:注意 add_rms_forward 把残差加法和归一化合为一步。每次返回的 residual 是归一化之前的原始值,作为下一轮的残差输入。

Qwen3Model — 完整模型:

class Qwen3Model(nn.Module):
def forward(self, input_ids, positions):
hidden_states = self.embed_tokens(input_ids) # 词嵌入
residual = None
for layer in self.layers: # 逐层计算
hidden_states, residual = layer(positions, hidden_states, residual)
hidden_states, _ = self.norm(hidden_states, residual) # 最终归一化
return hidden_states

Qwen3ForCausalLM — 因果语言模型:

class Qwen3ForCausalLM(nn.Module):
packed_modules_mapping = {
"q_proj": ("qkv_proj", "q"),
"k_proj": ("qkv_proj", "k"),
"v_proj": ("qkv_proj", "v"),
"gate_proj": ("gate_up_proj", 0),
"up_proj": ("gate_up_proj", 1),
}

packed_modules_mapping 是权重加载的”翻译表”。HuggingFace 的模型文件把 Q、K、V 分开存储(q_projk_projv_proj),但我们的模型把它们合并为一个 qkv_proj。加载时需要把三个权重拼成一个。

4.5 工具层#

4.5.1 nanovllm/utils/context.py — 上下文传递#

@dataclass(slots=True)
class Context:
is_prefill: bool
cu_seqlens_q: torch.Tensor | None # 查询累积长度
cu_seqlens_k: torch.Tensor | None # 键累积长度
max_seqlen_q: int # 最大查询长度
max_seqlen_k: int # 最大键长度
slot_mapping: torch.Tensor | None # KV-Cache 槽位映射
context_lens: torch.Tensor | None # 各序列当前长度
block_tables: torch.Tensor | None # 块表

这是一个全局变量,用于在不同模块间传递本轮计算的上下文信息。相比于把所有参数层层传递,全局变量更简单直接。

线程安全注意:这个设计假设同一时间只有一个推理任务在运行(单线程模型)。如果有多个并发任务,需要改成线程局部存储。

4.5.2 nanovllm/utils/loader.py — 模型权重加载#

def load_model(model, path):
packed_modules_mapping = getattr(model, "packed_modules_mapping", {})
for file in glob(os.path.join(path, "*.safetensors")):
with safe_open(file, "pt", "cpu") as f:
for weight_name in f.keys(): # 遍历所有权重
for k in packed_modules_mapping: # 检查是否需要合并
if k in weight_name:
v, shard_id = packed_modules_mapping[k]
param_name = weight_name.replace(k, v) # 映射到合并后的参数名
param = model.get_parameter(param_name)
weight_loader = getattr(param, "weight_loader")
weight_loader(param, f.get_tensor(weight_name), shard_id)
break
else:
# 不需要合并,直接加载
param = model.get_parameter(weight_name)
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, f.get_tensor(weight_name))

每个参数都有一个 weight_loader 方法,这是因为张量并行时,不同 GPU 需要加载权重的不同分片。例如 ColumnParallelLinearweight_loader 会只取自己负责的那部分。

4.6 示例与基准测试#

4.6.1 example.py#

path = "./Qwen3-0.6B/"
tokenizer = AutoTokenizer.from_pretrained(path)
llm = LLM(path, enforce_eager=True, tensor_parallel_size=1)
sampling_params = SamplingParams(temperature=0.6, max_tokens=256)
prompts = ["introduce yourself", "list all prime numbers within 100"]
prompts = [tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}],
tokenize=False, add_generation_prompt=True,
) for prompt in prompts]
outputs = llm.generate(prompts, sampling_params)
for prompt, output in zip(prompts, outputs):
print(f"Prompt: {prompt!r}")
print(f"Completion: {output['text']!r}")

三步使用流程

  1. 创建 LLM 实例(指定模型路径和配置)
  2. 创建 SamplingParams(指定温度和生成长度)
  3. 调用 llm.generate(prompts, sampling_params)

4.6.2 bench.py#

性能基准测试,生成 256 个随机请求,测量吞吐量。

5. 数据流全景:一次推理请求的完整生命周期#

让我们跟踪 “Hello, world!” 这个输入的完整处理过程:

数据流全景
用户调用: llm.generate(["Hello, world!"], SamplingParams(temperature=0.6, max_tokens=100))

Step 1: 请求进入#

Step 1: 请求进入
LLMEngine.generate()
└─→ add_request("Hello, world!", SamplingParams)
└─→ tokenizer.encode("Hello, world!") = [15496, 11, 995]
└─→ Sequence(token_ids=[15496, 11, 995], sampling_params=...)
seq_id = 0, status = WAITING
num_prompt_tokens = 3, num_tokens = 3
num_cached_tokens = 0
block_table = [] (还没有 KV-Cache)
└─→ scheduler.add(seq) // 加入 waiting 队列

Step 2: 第一轮调度(Prefill)#

Step 2: 第一轮调度(Prefill)
Scheduler.schedule()
└─→ 从 waiting 取出 seq
└─→ BlockManager.can_allocate(seq):
遍历 seq 的块: seq 只有 3 个 token,共 1 个块
没有前缀命中 (这是第一个请求)
空闲块数量 = 1000(假设),1 个够用
返回 num_cached_blocks = 0
└─→ BlockManager.allocate(seq, num_cached_blocks=0):
分配第 1 个块: block_table = [0]
num_cached_tokens = 0
└─→ seq.num_scheduled_tokens = 3 (prefill 全部 3 个 token)
└─→ seq.status = RUNNING
└─→ 返回 ([seq], is_prefill=True)

Step 3: 模型执行(Prefill)#

Step 3: 模型执行(Prefill)
ModelRunner.run(seqs=[seq], is_prefill=True)
└─→ prepare_prefill(seqs):
input_ids = seq[0:3] = [15496, 11, 995] // 取全部 3 个 token
positions = [0, 1, 2] // 位置 0, 1, 2
cu_seqlens_q = [0, 3] // 只有 1 个序列,长度为 3
cu_seqlens_k = [0, 3]
slot_mapping = [0, 1, 2] // 存到块 0 的第 0,1,2 个槽
set_context(is_prefill=True, ...) // 设置全局上下文
└─→ run_model(input_ids, positions, is_prefill=True):
hidden_states = model(input_ids, positions) // 逐层计算
logits = model.compute_logits(hidden_states) // 只取最后 1 个位置 [2] 的 logits
└─→ sampler(logits, temperatures) → 输出 token_id,比如 3156
└─→ 返回 [3156] (token_ids)

Step 4: 后处理#

Step 4: 后处理
Scheduler.postprocess(seqs, token_ids=[3156], is_prefill=True)
└─→ BlockManager.hash_blocks(seq): // 给块打哈希
block 0: token_ids=[15496, 11, 995]
hash = compute_hash([15496, 11, 995], prefix=-1)
hash_to_block_id[hash] = 0 // 注册前缀缓存
└─→ seq.num_cached_tokens = 3
└─→ seq.append_token(3156)
token_ids = [15496, 11, 995, 3156] // 追加
last_token = 3156
num_tokens = 4
└─→ 3156 ≠ eos, num_completion_tokens(1) < max_tokens(100) → 继续

Step 5: 第二轮调度(Decode)#

Step 5: 第二轮调度(Decode)
Scheduler.schedule()
└─→ waiting 为空,从 running 取 seq
└─→ 检查是否需要新块: num_tokens=4, block_size=256
4 % 256 == 4 ≠ 1 → 不需要新块
└─→ seq.num_scheduled_tokens = 1
└─→ seq.is_prefill = False
└─→ 返回 ([seq], is_prefill=False)

Step 6: 模型执行(Decode)#

Step 6: 模型执行(Decode)
ModelRunner.run(seqs=[seq], is_prefill=False)
└─→ prepare_decode(seqs):
input_ids = [3156] // 只有最后一个 token
positions = [3] // 位置 3
context_lens = [4] // 当前总长度为 4
slot_mapping = [2] // 写到块 0 第 2+1=3 个槽
block_tables = [[0, -1, -1, ...]] // 补 -1 到统一长度
set_context(is_prefill=False, ...)
└─→ run_model(input_ids, positions, is_prefill=False):
因为不是 prefill,用 CUDA Graph(小批量优化)
graph.replay() // 播放录制好的 GPU 操作序列
└─→ sampler(logits, temperatures) → 输出下一个 token_id

Step 7~N: 重复 Decode 直到结束#

最终:返回结果#

最终:返回结果
LLMEngine.generate():
所有序列都 FINISHED
└─→ 按 seq_id 排序
└─→ tokenizer.decode(token_ids) // 把 token ID 转回文本
└─→ 返回 [{"text": "Hello! I'm an AI assistant.", "token_ids": [...]}]

6. 关键技术深入解析#

6.1 Prefix Caching(前缀缓存)#

场景:两个请求有相同的前缀

  • 请求 A:“请介绍一下Python语言的特性,包括”
  • 请求 B:“请介绍一下Python语言的特性,以及”

A 和 B 的前缀 "请介绍一下Python语言的特性," 是相同的。如果没有前缀缓存,A 算完后 B 还要重新算一遍这个前缀。有了前缀缓存,B 可以直接复用 A 的 KV-Cache。

实现细节(在 BlockManager 中)

# 1. 每算完一个块,打个哈希标签
block.update(hash, token_ids)
hash_to_block_id[hash] = block_id
# 2. 新请求来了,逐个块检查是否有匹配的哈希
for i in range(seq.num_blocks):
h = compute_hash(seq.block(i), prev_hash)
if hash_to_block_id.get(h) and blocks[id].token_ids == seq.block(i):
# 命中!这个块不用重新算
num_cached_blocks += 1
else:
break # 链断了,后面的不可能命中

关键设计:哈希是链式的(每个块的哈希依赖前一个块的哈希),所以只有连续的前缀才能命中。这保证了正确性——如果中间某个 token 不同,后面的哈希都会不同。

6.2 Chunked Prefill(分块预填充)#

问题:假设一个请求有 4000 个 token 的输入,而 max_num_batched_tokens 是 1024。如果等全部 4000 个 token 一次 prefill,会阻塞其他请求很久。

解决方案:把 prefill 切成小块。

  • 第一轮:处理前 1024 个 token
  • 之后每轮:处理接下来 1024 个 token(同时可以穿插其他请求的 decode)
# Scheduler 中的实现
if remaining < num_tokens and scheduled_seqs:
break # 只对第一个序列允许 chunked prefill
seq.num_scheduled_tokens = min(num_tokens, remaining)
# 在 postprocess 中
if is_prefill and seq.num_cached_tokens < seq.num_tokens:
continue # 还没 prefill 完,不进入 decode

6.3 CUDA Graph(CUDA 图)#

问题:Decode 阶段每次只处理 1 个 token,GPU 内核启动的开销(~5-10 微秒)相对于计算时间(~10-50 微秒)不可忽略。

解决方案:把 GPU 操作录制成图,之后只需 CPU 发起一次调用即可。

CUDA Graph 对比
正常流程: CPU → 内核1启动 → CPU → 内核2启动 → CPU → 内核3启动 → ...
CUDA Graph: CPU → [内核1 → 内核2 → 内核3 → ...](GPU 自动执行)

注意:不是所有场景都能用 CUDA Graph:

  • Prefill 时序列长度变化大,不适合
  • Decode 且批大小 ≤ 512 时,序列数和长度都固定,非常适合

6.4 Tensor Parallelism(张量并行)#

当模型太大,一张 GPU 放不下时,切分到多张 GPU:

张量并行多 GPU 架构
GPU 0: [Embed(0~49999)] → [Layer 0-13] → [LMHead(0~49999)]
GPU 1: [Embed(50000~)] → [Layer 0-13] → [LMHead(50000~)]
通信:Embed 后 all_reduce → 每层内部列/行并行有 all_reduce → LM Head 时 gather

6.5 Gumbel-Max 采样#

通常的采样方式:

probs = softmax(logits / temperature)
token = torch.multinomial(probs, 1)

Gumbel-Max 技巧(等效但更高效):

probs = softmax(logits / temperature)
gumbel_noise = -log(-log(U)) # U ~ Uniform(0,1)
# 等价于:
gumbel_noise = log(Exp(1)) # Exp(1) 指数分布
token = argmax(log(probs) + gumbel_noise)
# 即:
token = argmax(probs / Exp(1))

为什么等效? 这是 Gumbel-Max 定理:向 log-probability 加 Gumbel 噪声后取 argmax,等价于从原分布采样。代码中 probs / Exp(1) 就是这个过程的巧妙实现。

总结#

Nano-vLLM 虽然只有约 1200 行代码,但完整实现了现代 LLM 推理引擎的核心技术:

技术作用实现位置
PagedAttention(KV-Cache 分页)高效管理显存block_manager.py
Prefix Caching(前缀缓存)复用相同前缀的计算block_manager.py
Chunked Prefill(分块预填充)避免长输入阻塞scheduler.py
CUDA Graph减少小批量 GPU 启动开销model_runner.py
Tensor Parallelism(张量并行)多 GPU 并行linear.py, embed_head.py
Torch Compile自动优化计算图各层的 @torch.compile
Flash Attention高效注意力计算attention.py
Gumbel-Max 采样高效随机采样sampler.py

学习建议:如果刚开始接触,可以先重点理解 sequence.pyblock_manager.pyscheduler.pyllm_engine.py 这条链路,这是推理引擎的”调度骨架”。然后再深入 model_runner.pyattention.py 了解 GPU 上的计算细节。

Nano-vLLM 源码阅读指引
https://floratopia.github.io/posts/nano-vllm-learning-note/
Author
Floratopia
Published at
2026-05-13
License
CC BY-NC-SA 4.0