源码精读:ModelRunner —— GPU 前向引擎
谁该读这一篇? 想搞清"Scheduler 把 batch 交给 GPU 后到底发生了什么"的工程师;准备实现新模型 / 调 attention backend 的开发者。 前置阅读:
03-scheduler.md、02-core-concepts/05-token-attention.md。 耗时: 50 分钟 学完能: 1. 在 3500+ 行的model_runner.py里快速定位主线; 2. 区分forward_extend(prefill)/forward_decode/forward_idle三条路径; 3. 知道ForwardBatch这个 dataclass 在调度和执行之间起的桥梁作用; 4. 解释 CUDA Graph 在哪一步被捕获和回放; 5. 看到一个 forward 时延问题能在源码里定位到瓶颈位置。
1. ModelRunner 的位置
Scheduler.run_batch
└── self.model_worker.forward_batch_generation(batch)
└── ModelRunner.forward(forward_batch) ← 本章主角
├── forward_extend(...)
├── forward_decode(...)
└── forward_idle(...)
源码:model_runner.py(3570 行)。
2. 关键数据结构:ForwardBatch
class ForwardBatch:
forward_mode: ForwardMode # EXTEND / DECODE / IDLE
batch_size: int
input_ids: torch.Tensor # [total_tokens]
req_pool_indices: torch.Tensor # [batch_size]
seq_lens: torch.Tensor # [batch_size]
out_cache_loc: torch.Tensor # 新算 KV 写到哪些 slot
token_to_kv_pool: TokenToKVPool # KV cache 容器
req_to_token_pool: ReqToTokenPool # 逻辑 token → 物理 slot 映射
attn_backend: AttentionBackend # 选哪个 backend
sampling_info: SamplingBatchInfo # 采样参数(一 batch 多种)
# ... 多模态、LoRA、speculative 等可选字段
每个 Scheduler 决策一步产出一个 ForwardBatch。它是 Scheduler 和 ModelRunner 的唯一接口。
ForwardMode 三种:
class ForwardMode(IntEnum):
PREFILL = 1
EXTEND = 2 # 等同 prefill(变种)
DECODE = 3
IDLE = 4
...
注意 PREFILL 和 EXTEND 在源码里大量场景被统一处理(都是"一次喂多 token")。
3. ModelRunner.init(341+)
主要工作(简化):
def __init__(self, server_args, ...):
self.load_model() # 1. 加载权重(1236)
self.init_attention_backend() # 2. 选 attention backend
self.init_lora() # 3. LoRA
self.init_token_to_kv_pool() # 4. KV cache 容器
self.init_req_to_token_pool() # 5. 请求 → token 映射
self.init_sampler() # 6. 采样器
if cuda_graph_enabled:
self.init_cuda_graphs() # 7. 录 CUDA Graph
7 步初始化。最耗时的是 1(load_model)和 7(capture cuda graphs)。
4. load_model(1236+)
简化:
def load_model(self):
# 1) 根据 model_config 找对应模型类
model_cls = get_model_architecture(self.model_config) # 在 srt/models/ 里
# 2) 实例化(先在 meta device 上,省显存)
with init_empty_weights():
self.model = model_cls(config=self.model_config, ...)
# 3) 把权重逐层 load 到 GPU
if self.server_args.load_format == "auto":
load_safetensors_weights(self.model, self.model_path)
else:
...
# 4) post-process: tie weights, init RoPE, ...
self.model.post_init()
srt/models/<name>.py 里每个模型都遵循同样的接口:
- 继承
nn.Module - 暴露
forward(input_ids, positions, forward_batch, ...) -> hidden_states - 暴露
compute_logits(hidden_states) -> logits
要加新模型就写一个 <name>.py,在 model_loader/ 注册。
5. 主路径:forward(3183+)
def forward(self, forward_batch: ForwardBatch, ...):
self.attn_backend.init_forward_metadata(forward_batch) # 预算 attention 元数据
if forward_batch.forward_mode == ForwardMode.DECODE:
return self.forward_decode(forward_batch)
elif forward_batch.forward_mode == ForwardMode.IDLE:
return self.forward_idle(forward_batch)
elif forward_batch.forward_mode.is_extend():
return self.forward_extend(forward_batch)
else:
raise ValueError(...)
三个 forward 子方法对应三种执行模式。下面分别看。
6. forward_extend(prefill)(3057+)
简化伪代码:
def forward_extend(self, forward_batch):
# 不走 CUDA Graph(prompt 长度可变)
with torch.no_grad():
hidden_states = self.model.forward(
forward_batch.input_ids,
forward_batch.positions,
forward_batch, # attention backend 用
)
# 取每个请求最后一个 token 的 hidden state,算 logits
last_token_hs = self.extract_last_token_hidden(hidden_states, forward_batch)
logits = self.model.compute_logits(last_token_hs)
next_tokens = self.sampler.sample(logits, forward_batch.sampling_info)
return ModelRunnerOutput(
next_token_ids=next_tokens,
# ... logprobs, embeddings 等可选
)
要点:
- 不开 CUDA Graph:因为 prompt 长度每次不同,graph 捕获代价不划算。
- 算最后一个 token 的 logits:prefill 阶段我们只关心"prompt 末尾下一步该出啥 token",不算中间 token 的 logits。
- attention backend 已经在
init_forward_metadata里建好 wrapper(FlashInfer 模式下 wrapper 对每个请求建索引)。
7. forward_decode(3008+)
def forward_decode(self, forward_batch):
if cuda_graph_can_replay(forward_batch):
return self.cuda_graph_runner.replay(forward_batch) # ★ 走 graph
else:
# 回退到非 graph 路径(batch size 没匹配的 graph)
with torch.no_grad():
hidden = self.model.forward(...)
logits = self.model.compute_logits(hidden)
next_tokens = self.sampler.sample(logits, ...)
return ModelRunnerOutput(next_token_ids=next_tokens)
Decode 永远是 1 token / 请求,batch shape 高度规整 → CUDA Graph 收益巨大。
cuda_graph_runner.replay 只是把预先录好的 graph 提交给 stream,比正常 forward 省 30-50% 时延。
8. forward_idle(3125+)
def forward_idle(self, forward_batch):
# 没有真请求,但要让 CUDA stream 不空转(保持 metric 节奏)
...
return ModelRunnerOutput(...)
罕用,主要在 disaggregated 模式下让 worker 保持就绪。
9. CUDA Graph 捕获
源码:cuda_graph_runner.py(800+ 行)。
启动时 init_cuda_graphs:
def init_cuda_graphs(self):
# 给一组常见 batch size 都录一份 graph
capture_batch_sizes = [1, 2, 4, 8, 16, 32, 64, 128, 256] # 可配
for bs in capture_batch_sizes:
dummy_batch = make_dummy_decode_batch(bs)
with torch.cuda.graph(graph) as g:
self.model.forward(dummy_batch.input_ids, ...)
self.cuda_graph_pool[bs] = g
运行时 cuda_graph_can_replay 检查:
- batch size 是否在 pool 里。
- attention backend 元数据是否兼容(FlashInfer 要 page table 形状一致)。
- 不能有 prefill 请求(CUDA Graph 不支持变长输入)。
不匹配就 fallback 到普通 forward。
Capture 阶段是启动慢的主因(数十秒)。
10. Overlap 模式下的 forward
enable_overlap=True 时 Scheduler 在 GPU 还在跑时就准备下一批;ModelRunner 这边:
def forward_batch_generation(self, batch, on_publish=None):
with self.forward_stream_ctx:
# 在 forward_stream 上跑 model
output = self.forward(batch)
if on_publish:
on_publish(...) # spec_v2 模式中途回调
return output
要点:
- 用 separate
forward_stream避免和schedule_stream抢; on_publish是 spec_v2(投机解码 v2)在 verify 后 / draft 前的回调点。
11. 一次 forward 的 GPU 时间组成
粗略(H100、Llama-3-8B、batch=32 decode):
| 阶段 | 时间 |
|---|---|
| Embedding lookup + RoPE | 0.02 ms |
| 32 层 attention(FlashInfer paged) | 0.7 ms |
| 32 层 MLP(含 down/up) | 0.6 ms |
| LayerNorm + residual | 0.05 ms |
| Sampler(含 mask) | 0.1 ms |
| 总计(CUDA Graph) | ~1.5 ms |
| 同 batch 无 CUDA Graph | ~2.5 ms |
CUDA Graph 省的是 kernel launch 开销,约 1 ms / forward。
12. 加新模型的流程
- 写
srt/models/my_model.py,模仿llama.py结构。 - 在
model_loader/__init__.py注册映射。 - 如果 attention 形状特殊(如 MLA),在 attention backend 加分支。
- 单元测试 + 跑一个 prompt 验证。
PR 历史里有大量"add support for X model",照抄即可。
13. 权重热更新(1598+)
def update_weights_from_disk(self, recv_req):
...
def update_weights_from_distributed(self, recv_req):
...
def update_weights_from_tensor(self, recv_req):
...
def update_weights_from_ipc(self, recv_req):
...
四条更新路径,对应 RL 训练 / online 微调场景。
- from_disk:从模型文件重新加载(最慢)。
- from_distributed:从训练侧 NCCL 广播。
- from_tensor:直接传 tensor。
- from_ipc:CUDA IPC handle(最快)。
详见 08-production-deployment/09-versioning.md 的"模型权重切换"小节。
14. 关键源码索引
| 内容 | 文件:行 |
|---|---|
| 类定义 | model_runner.py:338 |
__init__ |
model_runner.py:341 |
load_model |
model_runner.py:1236 |
forward 入口 |
model_runner.py:3183 |
forward_extend |
model_runner.py:3057 |
forward_decode |
model_runner.py:3008 |
forward_idle |
model_runner.py:3125 |
ForwardBatch |
forward_batch_info.py:274 |
ForwardMode |
forward_batch_info.py:75 |
| CUDA Graph runner | cuda_graph_runner.py |
| 模型注册 | model_loader/ |
15. 小结
- ModelRunner 是 Scheduler 和 GPU 之间的薄包装层,主入口
forward按 mode 分派。 forward_extend跑 prefill(不用 CUDA Graph);forward_decode跑 decode(默认走 CUDA Graph);forward_idle是空跑。ForwardBatch是接口,包含 input_ids、KV pool 引用、attention backend、采样信息等。- CUDA Graph 在启动时按多个 batch size 预捕获,运行时 replay。
- 加新模型只动
srt/models/和model_loader/注册。
16. 自检
forward_extend为何不开 CUDA Graph?什么场景下 prefill 也用 graph?
答案
原因:CUDA Graph 录制时记录张量地址 + 形状,replay 时形状必须**完全一致**。Prefill 每请求的 prompt 长度不同,input_ids shape 每次变化,没法用同一张 graph。 场景:**固定 chunked prefill chunk size** 时可以——所有 prefill chunk 都是相同 token 数(如 4096),形状一致可 capture。SGLang 的 `piecewise_cuda_graph_runner.py` 就是干这件事,对长 prompt 业务能给 prefill 也加上 graph 加速。- CUDA Graph 在哪个 batch size 下命中?哪种情况会 fallback?
答案
命中条件([`cuda_graph_runner.py:replay`](../sglang/python/sglang/srt/model_executor/cuda_graph_runner.py)):(a) 纯 decode batch(forward_mode == DECODE);(b) batch_size 在 `graph_pool` 里有匹配(默认 1,2,4,8,...,max_running_requests);(c) attention backend 元数据兼容(FlashInfer page table 行数不爆);(d) 无 LoRA adapter 切换、无 spec draft tree 形状变化。 Fallback 触发:含 prefill 请求、batch 含未 capture 过的 LoRA、spec decoding tree 形状变、grammar mask 切换状态。 Padding 机制:实际 batch=5,没 5 的 graph,会 padding 到 8 跑(算 3 个 dummy),比 fallback 到 eager 仍快。ForwardBatch.forward_mode有几种值?分别表示什么?
答案
[`forward_batch_info.py:75`](../sglang/python/sglang/srt/model_executor/forward_batch_info.py) `ForwardMode`: - `PREFILL` / `EXTEND`:一次喂 N>1 个新 token(首次 prompt 或 chunked prefill 续片); - `DECODE`:每请求 1 token 的 decode 步; - `IDLE`:无真实请求时的空跑(保持 CUDA stream 节奏、metric 周期不断); - 其它特殊态:`MIXED`(同 batch prefill+decode)、`SPLIT_PREFILL`(长 prompt 切片)、`DLLM` 模式等。 `forward()` 入口(`model_runner.py:3183`)按 mode 分派到 `forward_extend` / `forward_decode` / `forward_idle`。- 加新模型 PR 需要改哪些文件?
答案
最小集: (1) [`srt/models/- 看到 forward 时延突增,应该先看哪几个数?
答案
按优先级: (a) **`cuda_graph_fallback_count`**:fallback 多 → 形状不规则(LoRA / spec / prefill 占比),graph 没用上。 (b) **`batch_size` 分布**:是不是经常落在 graph capture 列表的"洞"里 → padding 浪费或 fallback。 (c) **attention backend 选择**:日志看是不是 fallback 到 Triton(FlashInfer plan 失败)。 (d) **nvidia-smi util / SM occupancy**:util 100% 但 token/s 没涨 → 算力外的瓶颈(NCCL / 内存带宽)。 (e) **是否量化生效**:FP8 模型但是日志显示 BF16 dtype,说明 fallback 到非量化路径。 配合 nsys profile 看 kernel 时间分布最快定位。17. 下一步
05-radix-tree.md— KV cache 索引细节。06-attention-backends.md— Attention kernel 选择。04-optimizations/04-cuda-graph.md— CUDA Graph 进阶。- 源码:
model_runner.py、forward_batch_info.py。