预计阅读 7 分钟

源码精读:ModelRunner —— GPU 前向引擎

谁该读这一篇? 想搞清"Scheduler 把 batch 交给 GPU 后到底发生了什么"的工程师;准备实现新模型 / 调 attention backend 的开发者。 前置阅读: 03-scheduler.md02-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

源码:forward_batch_info.py:274

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+)

源码:model_runner.py: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+)

源码:model_runner.py: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. 加新模型的流程

  1. srt/models/my_model.py,模仿 llama.py 结构。
  2. model_loader/__init__.py 注册映射。
  3. 如果 attention 形状特殊(如 MLA),在 attention backend 加分支。
  4. 单元测试 + 跑一个 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. 自检

  1. 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 加速。
  1. 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 仍快。
  1. 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`。
  1. 加新模型 PR 需要改哪些文件?
答案 最小集: (1) [`srt/models/.py`](../sglang/python/sglang/srt/models/) —— `ForCausalLM(nn.Module)` 含 `forward`、`compute_logits`;可仿 `llama.py` / `qwen2.py`。 (2) [`srt/model_loader/`](../sglang/python/sglang/srt/model_loader/) —— 在 ModelRegistry 注册架构名映射。 (3) [`srt/configs/`](../sglang/python/sglang/srt/configs/) —— 如果 HF config 有特殊字段,加 ModelConfig 子类做映射。 (4) [`srt/layers/attention/attention_registry.py`](../sglang/python/sglang/srt/layers/attention/attention_registry.py) —— 如果是 MLA / 新 attention 变体,加 backend dispatch 分支。 (5) [`test/srt/`](../sglang/test/) 加单元测试覆盖 forward 正确性。 (6) docs / README 模型支持列表。
  1. 看到 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. 下一步

上游源码:sglang/python/sglang/srt/model_executor/