导读:架构与复刻路线¶
动手之前,先把整个系统看一遍:一个请求从 HTTP 进来,经过哪些进程、变成哪些数据结构、在哪一步被计算,最后怎样一个 token 一个 token 地流回客户端。这一章给出 mini-sglang 的全貌、模块之间的依赖、我们的复刻顺序,以及怎样在一台没有 GPU 的机器上验证每一步。
自测:能答上来就可以跳过本章
- mini-sglang 由哪几类进程组成?一个 TP=4 的服务一共有几个进程?
- 调度器进程里,"调度器"和"引擎"分别负责什么?
- 为什么模型的
forward()不需要任何参数? - 官方实现只支持 CUDA,本书靠哪三个手段在 CPU 上验证 GPU 相关的代码?
自测参考答案(先自己答,再展开对照)
- 三类:API Server(HTTP 入口)、tokenizer / detokenizer 进程、每个 TP rank 一个调度器进程(里面有调度器和引擎)。TP=4 时是 1 + 1 + 4 = 6 个进程。
- 调度器决定"这一轮算什么、KV 放在哪里":接收请求、选出 batch、分配 KV、处理结果;引擎负责"算":执行模型的前向和采样。
- 当前 batch 的所有信息(请求、位置、注意力元数据)放在一个全局的
Context里,模型的每一层需要时直接从那里读取,所以forward()不需要传参数。 - 设备抽象(同一份代码在 CPU 上跑)、与 GPU 实现同接口的参考实现和替身(比如注意力后端、FlashInfer / FlashAttention 的假实现),以及模拟器(CUDA Graph 仿真、CUDA kernel 的 CPU 模拟器、Triton 解释器)。
先看结果¶
这是最终要做出来的东西:离线接口 LLM 在 CPU 上跑 Qwen3-0.6B,三个请求一起生成。
"""导读:用离线接口跑几个请求,看看最终要做出来的东西长什么样。"""
import time
import torch
from minisgl.core import SamplingParams
from minisgl.llm import LLM
llm = LLM("models/Qwen3-0.6B", dtype=torch.float32, max_running_req=8,
num_page_override=2048, max_seq_len_override=512)
prompts = ["The capital of France is", "List three prime numbers:", "def fibonacci(n):"]
t = time.perf_counter()
outputs = llm.generate(prompts, SamplingParams(max_tokens=16, ignore_eos=True))
elapsed = time.perf_counter() - t
for p, o in zip(prompts, outputs):
print(f"{p!r} -> {o['text']!r}")
print(f"3 个请求 x 16 个 token,CPU 上用时 {elapsed:.2f} 秒")
llm.shutdown()
'The capital of France is' -> ' Paris. The capital of Italy is Rome. The capital of Spain is Madrid.'
'List three prime numbers:' -> ' 2, 3, 5, 7, 11,'
'def fibonacci(n):' -> '\n if n == 0:\n return 0\n elif n'
3 个请求 x 16 个 token,CPU 上用时 0.72 秒
python -m minisgl --model Qwen/Qwen3-0.6B,之后用任何 OpenAI 客户端访问 http://127.0.0.1:1919/v1/chat/completions。
进程结构¶
mini-sglang 是一个多进程系统。以 TP=2 为例:
- API Server(主进程):接收 HTTP 请求,给每个请求分配
uid,把文本发给 tokenizer;再把收到的增量文本以 SSE 流式写回客户端。 - tokenizer / detokenizer:分词和增量反分词。默认两者共用一个进程(
--num-tokenizer 0)。 - 调度器进程:每个 TP rank 一个。每个进程里有一个
Scheduler(决定这一轮算哪些请求、分配 KV 缓存)和一个Engine(持有模型和 KV 池,执行前向和采样)。只有 rank 0 与 tokenizer 通信;它把收到的消息原样广播给其他 rank,保证所有 rank 做出完全相同的调度决策。
所以 TP=4 的服务一共有 1 + 1 + 4 = 6 个进程(API Server、tokenizer、4 个调度器)。进程之间的控制消息走 ZMQ,张量并行的数据走 NCCL。
官方实现
启动逻辑在 server/launch.py · launch_server(第 40~113 行),调度器主循环在 scheduler/scheduler.py · Scheduler.overlap_loop(第 83~106 行)。官方文档 docs/structures.md 有一张进程图,与上图一致。
一个请求的旅程¶
- 客户端
POST /v1/chat/completions。API Server 分配uid = 7,发送TokenizeMsg(uid=7, text=[消息列表], sampling_params)。 - tokenizer 套用对话模板、分词,发送
UserMsg(uid=7, input_ids=张量)给调度器 rank 0。 - 调度器把它放进 prefill 等待队列。某一轮调度时,
PrefillAdder在 Radix Cache 里查找最长的已缓存前缀,检查剩余显存够不够、有没有空闲的请求槽,够就接纳:分配一行 page table、锁住命中的前缀。 _prepare_batch为这一轮要算的 token 分配 KV 页,算出每个 token 的位置、要写入的 KV 位置,注意力后端准备好元数据。Engine.forward_batch:模型前向 → 取每个请求最后一个位置的 logits → 采样。采样结果一方面直接写回 GPU 上的token_pool(作为下一轮的输入),另一方面异步拷回 CPU。- 调度器处理结果:把新 token 追加到请求上,判断是否结束,发送
DetokenizeMsg(uid=7, next_token, finished)。prefill 刚结束的请求,提示词的 KV 被插入 Radix Cache。 - 之后每一轮 decode 重复第 4~6 步,直到遇到 EOS 或达到
max_tokens。结束时释放请求槽,把 KV 交还给缓存。 - detokenizer 把 token 增量地变成文本,发送
UserReply(uid=7, incremental_output, finished);API Server 把它写进 SSE 流。
这条路径上的每一步,都对应本书的一章。
模块与依赖¶
python/minisgl/ 下的模块,按依赖从底到顶:
| 模块 | 内容 | 章节 |
|---|---|---|
utils、env |
日志、注册表、ZMQ 队列、HF 工具;(我们加的)设备抽象 | 各章用到时介绍 |
core |
SamplingParams、Req、Batch、Context |
核心数据结构 |
distributed |
TP 信息、all-reduce / all-gather | 张量并行 |
kernel |
算子:RMSNorm、RoPE、写 KV、查表……(CPU 参考实现 + GPU 版本) | 算子层、CUDA kernel |
layers |
BaseOP 体系与各种层 |
算子层 |
models |
模型配置、模型结构、权重加载 | 模型与权重加载 |
kvcache |
KV 池、前缀缓存(naive 与 radix) | KV 池、Radix Cache |
attention |
注意力后端:torch(我们加的)、FlashInfer、FlashAttention | 注意力后端、GPU 注意力 |
moe |
MoE 后端:torch 参考实现、Triton fused MoE | MoE |
engine |
Engine、Sampler、GraphRunner |
Engine 与采样器、CUDA Graph |
scheduler |
调度器与各个 Manager | 第三部分全部 |
message |
进程间消息与序列化 | 消息与 ZMQ |
tokenizer |
tokenizer / detokenizer 进程 | Tokenizer |
server、llm |
API Server、启动器;离线接口 LLM |
API Server、调度器骨架 |
有两个贯穿全局的设计值得先记住:
- 全局上下文
Context。 模型的forward()没有参数:输入 token、位置、注意力元数据都挂在"当前 batch"上,而当前 batch 放在进程级的全局Context里。任何一层需要这些信息时,调用get_global_ctx().batch读取。这让模型代码非常干净,代价是模型只能在ctx.forward_batch(batch)这个上下文里运行。 - 注册表
Registry。 命令行里的--attn fa,fi、--cache-type radix、--moe-backend fused都是名字,由注册表映射到具体实现。新增一种后端只需要写一个类、注册一个名字。
复刻路线¶
这本书不是"先造零件、最后组装"。起点是第 0 步:126 行、不 import minisgl 的最小引擎,读权重、手写前向、朴素的 KV cache、贪心解码,和 Hugging Face 逐 token 对齐。之后分四个阶段,每一章换掉它的一个朴素部件:
- 阶段一 · 算得对(第 1~6 章):把骨架换成正式结构——
Req/Batch/Context、算子层、模型与权重加载、分页 KV 池、注意力后端、Engine与采样器。终点是阶段一的里程碑:同一个提示词、同样的 16 个 token,外加骨架做不到的"4 个请求一个 batch"。 - 阶段二 · 排得好(第 7~11 章):调度器接管阶段一里手工填 batch 的那几行——连续批处理、准入控制、Radix Cache、分块 prefill、重叠调度,对外只剩
LLM.generate。里程碑:8 个请求一次 generate,共享前缀少算四分之三。 - 阶段三 · 服务化(第 12~15 章):消息、tokenizer 进程、调度器的收发、API Server,变成多进程的 OpenAI 兼容服务。里程碑:起服务、流式、8 个并发。
- 阶段四 · 更快、更大(第 16~21 章):张量并行、GPU 注意力后端、CUDA Graph、自定义 kernel、MoE,以及基准测试。里程碑:TP=2 与 graph 回放输出不变;速度要上真卡。
一章一个文件。 每一章开头有一棵"到这一章为止"的文件树(本章新建、修改、为了让 main 跑起来提前引入的文件都标出来),章末那个 main 只用到树里的文件。python tools/steps.py check 会按这棵树逐章把包搭出来、跑每一章的 main——它保证没有任何一章偷偷用了后面章节的东西。
每章都遵循同样的结构:章首自测 → 文件树与这一步的 main → 原理 → 我们的实现 → 官方实现对照 → 测试与运行结果 → 练习 → 小结。每个阶段结束时,你手里都有一个比上一阶段更强、同样能跑、同样和 Hugging Face 对齐的引擎。
在 CPU 上验证 GPU 代码¶
开发机没有 GPU 是常态。官方代码里 CUDA 专有的东西有四类,本书分别处理:
| 官方用到的 | 本书的做法 | 在哪一章 |
|---|---|---|
torch.cuda.Stream、Event、锁页内存 |
utils/device.py:CUDA 上原样使用,CPU 上换成什么也不做的 NullStream、NullEvent |
Engine、重叠调度 |
| FlashInfer / FlashAttention 的注意力 kernel | 多一个 PyTorch 参考后端 torch;测试时再用"同接口的假 FlashInfer / 假 sgl_kernel"验证官方后端代码的参数语义 |
注意力后端、GPU 注意力 |
| CUDA Graph | CPU 上的仿真 EmulatedGraph:replay 时只能读固定缓冲区,漏拷任何输入都会让结果出错 |
CUDA Graph |
| 自定义 CUDA / Triton kernel | CUDA kernel 用 nvcc 12.9、13.4 编译,并在 CUDA 手册的 CPU 模拟器上运行自检;Triton kernel 用解释器模式运行 | CUDA kernel、MoE |
在 GPU 上,同一份代码自动选择 FlashInfer / FlashAttention(--attn auto)、开启 CUDA Graph、使用自定义 kernel 和 Triton fused MoE。
与官方的差异:设备抽象
官方的 Engine.__init__ 第一件事就是 torch.cuda.set_device,调度器里直接创建 torch.cuda.Stream()。我们把这几处换成 create_stream(device)、create_event(device)、pin(device) 等函数,行为在 CUDA 上完全相同。另外 EngineConfig 多了 device 和 cpu_kv_cache_bytes 两个字段。
环境¶
git clone https://github.com/AnranS/ai-infra-handbooks && cd ai-infra-handbooks/minisgl
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]" # CPU 版 torch 即可;GPU 上再装 ".[gpu]"
# 模型:放到 models/ 下(本书用 float32 跑,与 HF 对齐时最稳定)
modelscope download --model Qwen/Qwen3-0.6B --local_dir models/Qwen3-0.6B
官方仓库建议也克隆一份放在旁边,读每章时对照:
在 Mac 上跑
整本书的代码都不要求 GPU:没有 CUDA 时 Engine 自动选 CPU,torch.cuda.Stream / Event / 锁页内存都退化成空操作(见 utils/device.py),FlashInfer 和 FlashAttention 换成同接口的 PyTorch 假实现,CUDA Graph 换成 EmulatedGraph。macOS 上装好依赖就能跑 PYTHONPATH=python:tests pytest -q tests,张量并行那几章走 gloo、照样是真的多进程。两点提醒:
- 不会用 MPS。
_pick_device只认 CUDA,其余一律 CPU。这是有意的——本书所有测试都要和 Hugging Face 在 float32 下逐 token 对齐,MPS 的 float32 精度和算子覆盖都不够稳。 tools/check.py不是给读者用的。它是维护者的全量校验,需要官方仓库的指定 commit 和两套 nvcc 工具链。读者跑pytest就够。
CPU 上的线程数
PyTorch 在 CPU 上默认使用"逻辑核数"个线程。decode 时的矩阵乘很小(批大小只有几),32 个线程之间的同步开销会让它慢几十倍:在本书的开发机上,默认设置下 decode 一步要 2.4 秒,把线程数降到物理核数后只要 40 毫秒。所以我们的 Engine 在 CPU 上会把线程数设为 物理核数 / TP 数(除非你设置了 OMP_NUM_THREADS)。
怎么讲清楚
讲"讲讲一个推理引擎的架构"的时候,可以用 mini-sglang 当骨架:API Server(HTTP、OpenAI 接口)+ tokenizer / detokenizer 进程 + 每个 TP rank 一个调度器进程,控制消息走 ZMQ,张量走 NCCL;TP=4 时一共 1 + 1 + 4 个进程。调度器进程里,调度器决定"这一轮算哪些请求、KV 放在哪",引擎负责"算"(模型、注意力后端、采样、CUDA Graph);模型从全局的 Context 读取当前 batch,所以 forward() 不需要参数。再讲一个请求的旅程(分词 → 排队 → prefill → 逐步 decode → 反分词流式返回 → 释放 KV),并说出多进程的理由:绕开 GIL,让 CPU 上的工作和 GPU 计算并行。
练习¶
1. 数进程。 用 mini-sglang 部署 TP=8 的服务,一共有几个进程?哪些进程之间传的是控制消息、哪些之间传的是张量?分别用什么通信?
参考答案
1 个 API Server + 1 个 tokenizer / detokenizer 进程 + 8 个调度器进程(每个 TP rank 一个,里面是调度器和引擎),共 10 个。请求、token 化后的请求和生成的结果这类控制消息走 ZMQ:API Server → tokenizer → rank 0 的调度器,rank 0 再把原始消息转发给其他 rank,结果从 rank 0 经 detokenizer 回到 API Server;8 个调度器进程之间的张量(张量并行的 all-reduce 等)走 NCCL。
2. 复刻的顺序。 复刻路线是"算得对 → 排得好 → 服务化 → 变快变大"。为什么不先写 CUDA Graph、自定义 kernel 这些性能优化?
参考答案
性能优化不改变结果,只改变执行方式,所以它们的正确性只能拿一个已经正确的版本来比对:先让参考实现和 HF 逐 token 对齐,之后每加一个优化(重叠调度、CUDA Graph、FlashInfer、自定义 kernel),都用同一组贪心生成的结果检查有没有变。反过来先做性能,出了错分不清是模型、调度还是优化本身的问题。另外,调度和服务化决定了性能优化的边界:比如 CUDA Graph 要求输入放在固定的缓冲区里,重叠调度要求输入 token 留在 GPU 上,这些都要在数据结构定下来之后才好做。
小结¶
- mini-sglang = API Server + tokenizer/detokenizer + 每个 TP rank 一个调度器进程;控制消息走 ZMQ,张量走 NCCL。
- 调度器决定"这一轮算什么、KV 放哪",引擎负责"算";模型从全局
Context读取当前 batch。 - 复刻顺序:算得对 → 排得好 → 服务化 → 变快变大;每一步都与 HF 逐 token 对齐。
- GPU 专有的部分用设备抽象、参考实现、同接口替身和模拟器在 CPU 上验证。
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题