请求从哪里进入,经过哪些进程
Mini-SGLang 的在线推理从 FastAPI 收到 HTTP 请求开始,到客户端拿到 SSE 文本片段或最终 JSON 结束。中间还要经过分词、进程通信、请求调度、KV cache 管理和采样。Transformer forward 是计算量最大的一段,但它只是整条链路的一部分。
python -m minisgl 最终会走到 server/launch.py 里的 launch_server()。它先读取模型路径、张量并行规模、KV cache 页大小和 attention backend 等配置,然后创建 API Server,启动后端进程。
在线模式下,每个 TP rank 对应一个 Scheduler 进程,Scheduler 和它持有的 Engine 在同一进程内;CPU 侧另有 tokenizer 和 detokenizer worker;FastAPI 主进程只负责 HTTP 与 SSE。
--num-tokenizer 默认是 0。这个值并不表示系统没有 tokenizer:启动器仍会创建一个 detokenizer worker,并让它同时接收 API Server 发来的 TokenizeMsg 和 Scheduler 返回的 DetokenizeMsg。也就是说,默认配置用同一个进程完成分词和增量解码。
把 --num-tokenizer 设为正数后,系统才会另外启动 tokenizer worker;PUSH/PULL socket 在这些 worker 之间分发请求,detokenizer 仍只有一个。
后端进程启动后不会立刻放行 HTTP 服务。启动器通过 multiprocessing.Queue 等待就绪信号:所有 TP rank 都要完成 Engine 初始化和 CPU barrier,但只由 rank 0 Scheduler 回报;每个 tokenizer 和 detokenizer 也各回报一次。
主进程收齐 num_tokenizer + 2 个信号后才开始对外服务。此时权重、KV cache、attention backend 和 CUDA Graph 都已经准备好。
先看控制消息。ZMQ 和 msgpack 传的是请求 UID、采样参数、CPU 上的一维 token tensor,以及每轮生成的 token ID。多卡时,rank 0 从 tokenizer 收到原始消息,再通过 PUB/SUB 把同一份字节广播给其他 rank;Gloo broadcast 负责同步这一轮有多少条消息。随后,各 rank 各自跑一遍相同的调度代码。decode batch 还会按 UID 排序,防止同一组请求在不同进程里出现不同顺序。
模型计算用的是另一套通信。词表并行 Embedding、行并行投影和分片 LM Head 分别需要 All-Reduce 或 All-Gather,GPU tensor 默认交给 PyNCCL;关闭 PyNCCL 后,代码改用 torch.distributed 的 NCCL backend。简单说,Gloo 对齐 CPU 控制状态,NCCL/PyNCCL 合并 GPU 上的计算结果。
请求到来前,Engine 准备了什么
Scheduler 构造时先创建 Engine。这里有个硬条件:进程此前不能初始化过 CUDA。Engine 会绑定当前 TP rank 对应的 GPU,创建自己的 CUDA stream,再把 TP 信息放进进程内的全局 Context。这些状态不跨进程共享。
一个 Scheduler 进程只有一个 Engine,所以当前 Batch、page table、attention backend 和 KV cache 都可以从这个 Context 里取。
Engine 先用 Hugging Face AutoConfig 读取模型配置,再整理成项目内部的 ModelConfig。后者只留下推理需要的字段,包括层数、Q/KV head 数、head dimension、hidden size、词表大小、RoPE 配置和 MoE 参数。模型类则根据 checkpoint 的 architectures[0] 从注册表里查找。Qwen3 dense 在这里对应 Qwen3ForCausalLM。
模型结构先建在 meta device 上,此时还没有真正分配权重显存。Mini-SGLang 没有继承 torch.nn.Module,而是用 BaseOP、OPList 和自定义 state_dict 递归管理权重。
接下来,models/weight.py 逐个读取 safetensors tensor,按 TP rank 切分后直接放到目标 GPU。checkpoint 里的 Q、K、V 会合并成运行时的 qkv_proj,gate 和 up 会合并成 gate_up_proj。行并行层切输入维,列并行层切输出维,Embedding 和 LM Head 切词表范围。加载后的权重布局正好对应前向里的融合算子。
通信组在权重加载前建立。权重加载完成后,Engine 比较前后的空闲显存,用差值估算模型占用,再决定 KV cache 能分多少页。单页 KV 的大小为 2 × 层数 × 本 rank 的 KV head 数 × page_size × head_dim × dtype 字节数,其中 2 分别对应 K 和 V。
没有显式设置 --num-pages 时,页数由 memory_ratio 和剩余显存计算。各 rank 还会交换显存统计;差距超过 2 GiB 就终止初始化,因为 TP rank 必须使用相同的缓存容量。
KV buffer 的主要形状是 [2, 层数, 页数 + 1, page_size, 本地 KV heads, head_dim]。其中多出的那一页专供 CUDA Graph 的 dummy request 使用。page table 也多留一行,共有 max_running_req + 1 行,每行覆盖模型允许的最大序列长度。表里存的不是页号,而是展平后的物理 token slot。Scheduler 用请求行号和逻辑位置查表,就能找到这一 token 的 K/V 写入位置。
显存和页表准备好后,Engine 创建 attention backend、Sampler 和 GraphRunner。attention_backend=auto 时,SM100 使用 TensorRT-LLM;SM90 的 prefill 使用 FlashAttention、decode 使用 FlashInfer;其他 CUDA 架构使用 FlashInfer。
TensorRT-LLM 只接受 16、32、64 的 page size,因此选中它时配置会改成 64;当前 FlashInfer 路径则固定使用 page size 1。
GraphRunner 接着为若干 decode batch size 捕获 CUDA Graph。到这里,Scheduler 才进入主循环。
从 HTTP 请求到 token IDs
请求进入 /v1/chat/completions 后,FastAPI 接收 messages 或 prompt。聊天消息保留角色和内容的列表结构,普通 prompt 保留原字符串。FrontendManager.new_user() 为请求分配递增 UID,并创建回复列表与 asyncio.Event。temperature、top-k、top-p、最大生成长度和 ignore_eos 被放进 SamplingParams,再和文本一起组成 TokenizeMsg。
TokenizeMsg 通过异步 ZMQ PUSH socket 发往 tokenizer。聊天消息先经过 apply_chat_template(..., add_generation_prompt=True),再调用 encode;普通字符串直接编码。结果是一维 torch.int32 CPU tensor。
worker 随后生成 UserMsg,保留 UID 和采样参数,用 token IDs 替换原始文本。msgpack 把 tensor 记录成字节缓冲区和 dtype,Scheduler 收到后再还原成新的 CPU tensor。
API Server 没有为每个 HTTP 请求单独开接收 socket。FrontendManager 第一次发送消息时会启动一个常驻 listener,统一读取 detokenizer 返回的 UserReply。listener 按 UID 把回复放入 ack_map,再唤醒对应的 event。
请求协程一次取走当前积累的全部回复,若还没结束就继续等待;收到 finished=True 后,相关列表和 event 一起删除。HTTP 并发量因此不会直接增加 ZMQ 连接数。
配置多个 tokenizer 后,PUSH/PULL 只负责把不同请求分给不同 worker,并不会把同一个请求拆开并行处理。当前 TokenizeManager 仍是逐条调用 tokenizer,worker 的 local_bs 在启动器里也固定为 1。真正的批处理发生在 Scheduler。
客户端中途断开时,stream_with_cancellation() 会发送 AbortMsg,tokenizer worker 再把它换成 AbortBackendMsg。Scheduler 按 UID 去 prefill 等待队列和 decode 运行集合里查找请求;找到后释放请求表行,并处理已经占用的缓存页。
时序图把 Scheduler 和 Engine 分开画,只是为了标出职责。每个 rank 上,两者实际位于同一进程:Scheduler 管 CPU 侧的请求状态、batch 和显存页;Engine 管模型、GPU stream、attention backend、KV tensor、CUDA Graph 与采样。
Scheduler 怎样表示和组织请求
UserMsg 到达 Scheduler 后,第一步是检查长度。最大序列长度来自 RoPE 配置,也可以由命令行覆盖。prompt 已经占满这个长度时,请求直接丢弃;如果仍可生成,但 max_tokens 超过剩余空间,Scheduler 会把它缩到允许的最大值。通过检查的请求进入 PrefillManager.pending_list。
请求在等待队列里是 PendingReq,进入 batch 后才变成 Req。读这段调度代码时,三个长度必须分清:cached_len 是已经有 KV 的前缀长度,device_len 是当前序列在设备侧的逻辑长度,max_device_len 等于输入长度加最大输出长度。本轮真正要计算的 token 数就是 extend_len = device_len - cached_len。
新请求第一次进入 prefill 时,device_len 等于 prompt 长度,cached_len 来自前缀缓存。一次 forward 完成后,complete_one() 先把当前 device_len 记为新的 cached_len,再让 device_len 加一。随后采样出的 token 会直接写到 token_pool[table_idx, old_device_len]。所以下一轮 decode 的 extend_len 恰好为 1:旧位置已经有 KV,刚生成的 token 是唯一的新输入。
先查前缀,再决定能不能进入 batch
PrefillAdder 查 prefix cache 时用的是 input_ids[:-1],不是完整 prompt。最后一个 prompt token 必须留在 extend 区域,因为系统至少要计算一个新位置,才能得到首个输出 logits。命中 Radix Cache 后,handle 会返回匹配长度和对应的物理 KV slot。Scheduler 接着检查请求表还有没有空行,并估算“未缓存 prompt + 最大输出”需要多少 token。
正在 decode 的请求也会预留剩余 token 和页内空隙。
第一次容量检查通过后,Scheduler 会锁住命中的 Radix handle。锁会提高路径节点的引用计数,这些页暂时不能被驱逐。于是代码还要再算一次容量:刚才可驱逐的部分空间,现在可能已经变成受保护空间。两次检查都通过后,TableManager 才分配请求行。命中前缀的 token IDs 和物理位置会复制到这行的 token pool 与 page table;没有命中时,cached_len 为 0,后续流程相同。
长 prompt 怎样分块 Prefill
单次 prefill 最多接收 max_extend_tokens 个新 token,默认预算是 8192。长 prompt 放不进剩余预算时,PrefillAdder 创建 ChunkedReq,只把当前片段写进 token pool。它继续使用原来的请求表行和 cache handle,但 can_decode 永远返回 false。
Engine 仍然执行 forward,只是 Scheduler 不采用中间块末尾的采样结果。下一轮调度会把同一个 pending request 放回队首,从新的 cached_len 继续处理。
等最后一块能放进预算时,请求恢复为普通 Req,这一轮的最后位置才用来生成首个输出 token。Chunked Prefill 不改变最终计算结果。它限制的是单个 batch 的 extend token 数,也就是长 prompt 在一次 forward 中产生的中间张量规模。
Batch 怎样变成 GPU 输入
Scheduler 先尝试调度 prefill;没有可运行的 prefill 时,才从 DecodeManager 取 decode batch。DecodeManager 用集合保存请求,但生成 batch 时会按 UID 排序。多卡进程收到相同消息后,只有请求顺序一致,后面的 All-Reduce 和 All-Gather 才能对齐到同一批样本。
_prepare_batch() 先看 decode batch 能否使用已经捕获的 CUDA Graph。可以时,请求列表会补到最近的已捕获 batch size,空位用 dummy request 填满。然后 CacheManager 为每个真实请求分配新页,范围从 ceil(cached_len / page_size) 到 ceil(device_len / page_size),并把展平后的物理 token slot 写入 page table。
position 由各请求的 [cached_len, device_len) 依次拼接。输入映射包含两个 tensor:一个为每个 extend token 重复请求表行号,另一个保存 token 的逻辑位置。用这两个 tensor 索引 token_pool,就得到本轮的 input_ids。
输出映射则指向每个普通请求的 (table_idx, device_len);ChunkedReq 使用 -1,因为它产生的 token 不会进入下一轮。输入映射再查 page table,得到 batch.out_loc,也就是新 K/V 的物理写入地址。
最后,attention backend 根据序列长度、extend 长度和 page table 生成自己的 metadata;Sampler 把每个请求的采样参数整理成 GPU tensor。走到这一步,Python 里的 Batch 已经变成模型需要的 token、position、KV 写地址和 attention 索引。
12-token prompt 为例
设 page size 为 4。某个 prompt 有 12 个 token,最多再生成 3 个;Radix Cache 已经保存了它的前 8 个 token。PrefillAdder 实际查询前 11 个 token,匹配长度再按整页对齐,所以得到 cached_len = 8。请求拿到一行 table 后,逻辑位置 0 到 7 直接引用旧物理 slot,位置 8 到 11 是本轮的 extend 区域。
页分配从 ceil(8 / 4) = 2 开始,在 ceil(12 / 4) = 3 结束,因此只需要一张新页。positions 是 8、9、10、11,input mapping 把它们映射到同一 table row,out_loc 给出新页中的四个物理 token slot。模型只重算 prompt 的后四个 token,attention 仍可通过 page table 读取前八个 token 的旧 KV。
首轮 forward 完成后,cached_len 变为 12,device_len 变为 13。假设采样得到 token x,它会写入 token pool 的逻辑位置 12。Scheduler 把已经计算的 12-token prompt 放进 Radix Cache,再把请求移入 decode。
下一轮的输入就是 x,extend 区域只剩位置 12。旧 prompt 恰好占满三页,而 ceil(13 / 4) = 4,所以 Scheduler 要再分配一页。模型在这一页为 x 写入一格 K/V,采样得到 y,并把 y 放到位置 13。再下一轮,位置 13 仍落在同一页,页数不变。page table 保存的是“逻辑位置到物理 slot”的映射,物理页是否连续不影响 decode。
如果第一次 prefill 只剩 2 个 token 的预算,请求会先以 ChunkedReq 计算位置 8、9,再沿用同一 table row 计算位置 10、11。第一块末尾的 logits 被忽略,最后一块结束时才采样 x。已有八个 token 的缓存页在两次 forward 之间一直由 handle 锁住。
Prefill 和 Decode 为什么能共用一套 Forward
模型侧只有一套 Forward 代码。Batch.phase 区分 prefill 和 decode,差异主要在输入规模、attention metadata 以及能否使用 CUDA Graph。设真实请求数为 B,本轮所有 extend token 的总数为 T。prefill 中,T 往往远大于 B;decode 中,每个请求只有一个新 token,所以 T = B,另加可能存在的 dummy padding。
Prefill 时,每个新 query 都要访问它之前的完整上下文,包括 Radix Cache 命中的旧 KV,以及本轮刚写入的 K/V。FlashAttention、FlashInfer 和 TensorRT-LLM 会根据 cached_len、device_len 与 page table,生成各自需要的 cumulative sequence length 或 paged indices。每层 AttentionLayer 都是先把新 K/V 写到 batch.out_loc,再让 backend 读取完整 KV。
模型会为 prefill 的每个新 token 产生 hidden state,但 LM Head 只需要每个请求最后一个位置的 logits。attention metadata 的 get_last_indices(B) 会找出这 B 个位置,LM Head 只取对应的 hidden state。因此,首轮采样看到的 logits 仍是 [B, vocab_size]。decode 本来就是一请求一行,不需要再筛。
这样一来,模型层不用为长 prompt 和单 token 分别写两套结构。它只从进程内 Context 取得当前 batch;两种 phase 的差异留在 attention backend、LM Head 的末位置选择和 GraphRunner 中。
Paged KV Cache 和 Radix Cache 各管什么
这两个名字很容易混在一起。Paged KV Cache 是 GPU 上的物理存储:MHAKVCache 保存每层、每个 token 的 K/V,page table 把请求的逻辑位置映射到物理 token slot。Radix Cache 则是 CPU 侧的前缀索引,它记录“token 前缀对应哪些物理 slot”,本身不再存一份 K/V。
每层 AttentionLayer 算出新的 K、V 后,MHAKVCache.store_kv() 会调用 CUDA store kernel。一个 warp 负责一个 token,把 K 和 V 写到 out_loc 指定的位置。随后,attention backend 拿到当前 query、这一层的 K/V buffer、page table 和序列长度。旧 KV 可以散在不同物理页里,paged attention 会按表读取,不必先拼成连续 tensor。
Radix 树的节点保存一段 token key,以及同样长度的物理 slot tensor。查找沿 token 前缀向下进行;只命中节点的一部分时,节点会在匹配点拆开。page size 大于 1 时,匹配和插入长度都按整页对齐。handle 指向命中路径的末端,并记录累计缓存长度。需要 matched indices 时,代码沿父指针回到根节点,再把各段 value 按顺序拼起来。
请求还在运行时,handle 路径上的 ref_count 大于 0,这些节点计入 protected size。prefill 完成后,cache_req(finished=False) 会把刚算出的前缀插入树中,解锁旧 handle,再锁住新 handle;decode 继续使用的 KV 因而不会被驱逐。请求结束时,cache_req(finished=True) 仍会留下可复用前缀,但不再加锁,不能组成整页的尾部 slot 会回到空闲列表。
两个并发请求也可能算出同一段前缀。插入返回值会指出哪一段已经由另一个请求写进 Radix 树,后插入的请求便释放自己的重复页,继续使用树里的那份。显存不足时,Radix Cache 从 ref_count == 0 的叶节点开始驱逐,优先回收时间戳较早的节点。释放出的物理 slot 会重新进入空闲页池。
cache_type=naive 时,prefix cache 始终返回长度 0,也不会保留可驱逐前缀。请求结束后,它占用的页直接回到空闲列表。naive 和 radix 使用相同的 page table 与 K/V tensor,区别只在跨请求的前缀复用。
一次 Qwen3 Forward
模型调用从 engine/engine.py 进入。Engine 在 Context.forward_batch(batch) 中设置当前 batch:普通路径调用 Qwen3ForCausalLM.forward(),满足条件的 decode 则由 GraphRunner 重放同一套计算。
下面统一用 T 表示本轮参与前向的 token 数,B 表示真实请求数,H 表示 hidden size,I 表示 MLP intermediate size,V 表示词表大小,P 表示 TP size。
| 位置 | 单 rank 主要形状 | 说明 |
|---|---|---|
| 输入 token | [T] | prefill 为各请求 extend 段拼接;decode 通常 T = B |
| Embedding 输出 | [T, H] | 本地词表查询后经 All-Reduce 得到完整 hidden state |
| Q | [T, Q_heads/P, head_dim] | Q head 按 rank 切分 |
| K、V | [T, local_KV_heads, head_dim] | KV head 可切分;head 数少于 P 时按规则复制 |
| Attention 本地输出 | [T, H/P] | 输出投影后 All-Reduce 回到 [T, H] |
| MLP 激活 | [T, I/P] | gate/up 融合投影,SiLU 与逐元素乘法融合 |
| 本地 logits | [B, ceil(V/P)] | prefill 先选择每个请求的最后位置 |
| 完整 logits | [B, V] | 跨 rank All-Gather 后交给 Sampler |
input_ids 先经过 VocabParallelEmbedding。每个 rank 只保存自己的词表区间;indexing kernel 对落在本地范围内的 token 读取 embedding,其余位置写零。一次 All-Reduce 把各 rank 的结果相加,每个 rank 最终都拿到完整的 [T, H] hidden state。
接下来进入 Qwen3DecoderLayer。第一层的 RMSNormFused 返回归一化结果,同时把原输入留作 residual;后续层用 fused_add_rmsnorm 在一个 kernel 里完成残差相加和归一化。hidden state 随后进入融合 QKV 投影。每个 rank 只计算本地 Q heads 与 KV heads,Qwen3 还会分别对 Q、K 再做一次 RMSNorm。
RoPE 按 batch.positions 原地旋转 Q、K。Scheduler 已经把 positions 设为每个请求的 [cached_len, device_len),所以命中 Radix Cache 的旧前缀不会重复计算位置编码,新 token 则接着旧位置向后排。AttentionLayer 随后调用 KV store kernel,把 K、V 写入本层的物理 slot,再由选中的 backend 计算 paged attention。
attention 输出仍按 head 分在各 rank 上,展平后的形状是 [T, H/P]。LinearOProj 的权重沿输入维切分,各 rank 先得到一个 [T, H] 部分和,再通过 All-Reduce 合成完整输出。第二个 fused add RMSNorm 之后是门控 MLP:gate_up_proj 沿输出维切分,得到两组本地 I/P 激活;silu_and_mul 计算门控;down_proj 沿输入维切分,最后再做一次 All-Reduce。
所有 Decoder Layer 跑完后,模型执行最后一次 fused RMSNorm。ParallelLMHead 的权重按词表切分;如果 checkpoint 绑定了输入和输出 embedding,这里直接复用 VocabParallelEmbedding 的权重。prefill 会先取每个请求最后一个位置的 hidden state,本地矩阵乘得到分片 logits。All-Gather 收齐各 rank 的结果并重排,再裁掉词表切分带来的尾部 padding,得到 [B, V]。
这条 Qwen3 dense 路径里的 TP 通信点并不多:Embedding 后有一次 All-Reduce;每层 attention 的 O Projection 后一次;每层 MLP 的 Down Projection 后一次;最后,LM Head 做一次 All-Gather。QKV 和 gate/up 属于列并行投影,下一步仍可在本地分片上计算,所以这里不用立即通信。
用一组具体尺寸看分片会更直观。假设 H = 4096,Q head 数为 32,KV head 数为 8,head_dim = 128,TP size 为 4。每个 rank 持有 8 个 Q head 和 2 个 KV head,本地融合 QKV 输出宽度为 (8 + 2 × 2) × 128 = 1536。
attention 算完后,本地 8 个 Q head 展平为 1024 维;O Projection 使用近似 [4096, 1024] 的本地权重,生成 4096 维部分和,再由四个 rank 做 All-Reduce。
Embedding 沿词表切分。假设词表有 150000 项,每个 rank 大约保存四分之一。一个 token ID 只落在某个 rank 的词表范围内,其他 rank 的 indexing 结果为零;All-Reduce 后,所有 rank 都得到相同的 4096 维 embedding。LM Head 走相反方向:各 rank 对同一 hidden state 计算各自的词表 logits,再通过 All-Gather 拼成完整的 150000 维分布。
KV head 数少于 TP size 时,代码会复制 KV head,而不会继续拆分单个 head。权重加载和 AttentionLayer 都使用 allow_replicate 规则,所以 checkpoint 分片、运行时张量形状与 KV cache 的本地 head 数能够对应起来。
Attention Backend 在哪里接管计算
在 AttentionLayer 看来,FlashAttention、FlashInfer 和 TensorRT-LLM 提供的是同一套接口:准备 batch metadata、执行当前层 attention,并为 CUDA Graph 的 capture/replay 提供稳定缓冲区。上层只传 Q、K、V、层号和 Batch,不需要知道 backend 内部使用 cumulative sequence length、ragged indices 还是 block table。
差别都藏在 backend 里面。FlashAttention 支持 page size 大于 1,它会构造 Q/K 的累计长度、完整 cache sequence length 和按页抽取的 page table,再调用 sgl_kernel.flash_attn_with_kvcache。
FlashInfer 当前把 KV cache 看成 page size 1 的 ragged pages,prefill 与 decode 使用不同 wrapper;plan 过程借助 pinned CPU staging buffer,并用 CUDA event 防止下一次规划过早覆盖它。TensorRT-LLM 同时提供 context 和 decode kernel,直接读取 block table 与序列长度。
HybridBackend 只负责按 phase 转发。配置为 fa,fi 时,prefill 的 metadata 和 forward 交给 FlashAttention,decode 交给 FlashInfer。CUDA Graph 相关调用也只转给 decode backend,因为 GraphRunner 不捕获 prefill。
自定义 kernel 没有铺满整个项目,它们集中在几处数据搬运和热点计算上。Embedding indexing 与 KV store 通过 TVM FFI 按形状即时编译 CUDA 模板,Radix key 比较使用 AOT C++ 扩展,PyNCCL wrapper 封装 NCCL communicator、All-Reduce、All-Gather 和可选的对称内存。
MoE 的专家矩阵乘与归并则使用 Triton。Python 层先确定索引和数据位置,kernel 再按这些信息执行。
Overlap Scheduling 怎样让 CPU 和 GPU 同时工作
Scheduler 和 Engine 各有一条 CUDA stream。Scheduler stream 准备 page allocation、positions、input/output mapping 和 attention metadata;Engine stream 跑模型与采样。提交 batch 前,Engine stream 会等待 Scheduler stream,保证索引和 H2D copy 已经就绪。
关闭 overlap 时,循环按“收消息、排 batch、执行 forward、处理结果”的顺序运行。默认开启的 overlap loop 多保存一个 last_data:本轮先收消息并排出当前 batch,在 Engine stream 上提交 forward,然后才处理上一批的 CPU 结果。这样 GPU 计算当前 token 时,CPU 可以同步上一批的 D2H event、判断结束条件、维护 Radix handle,并把 token 发给 detokenizer。
这套时序能成立,是因为采样出的 token 会直接写入 GPU token pool。它不必先回到 Python 再传回 GPU;下一轮 _make_input_tuple() 只要指向对应逻辑位置,同一条 Engine stream 就会保证先写后读。CPU 副本主要用于判断 EOS、维护 Req.input_ids 和向前端返回文本。设置 MINISGL_DISABLE_OVERLAP_SCHEDULING=1 后,循环恢复为顺序执行,模型与缓存接口不变。
把相邻批次记作 A 和 B。第一次循环提交 prefill A:_forward(A) 依次排入模型计算、采样和 token-pool 写入。Python 调用返回时,GPU 可能仍在执行 A,但请求的 cached_len、device_len 和 DecodeManager 已经向前推进;ForwardOutput 中还保存了 stream 末尾的 copy event。
第二次循环已经可以排出 decode B。Scheduler stream 为 B 准备新页、position 和 metadata,再让 Engine stream 等待这些工作。B 的模型任务排在 A 的 token-pool 写入之后,所以它一定能读到 A 采样出的 token。B 提交后,CPU 才调用 _process_last_data(A),等待 A 的 copy event,并把结果送给 detokenizer。
第三次循环提交 C,再处理 B。此后,GPU 持续计算当前 batch,CPU 同时收尾上一批。若上一轮已经判定请求结束,DecodeManager 会移除它;但 overlap 状态下可能已经提交了包含该请求的后续 batch,finished_reqs 用来防止 table row 和 cache handle 被释放两次。这也是为什么 CPU 文本状态会比 GPU 调度状态晚一轮。
CUDA Graph 负责减少 decode 阶段的 kernel launch 开销。GraphRunner 预先捕获 batch size 1、2、4 和若干个 8 的倍数。运行时,真实 batch 会向上补到最近的已捕获规模,缺口由 dummy request 填充。dummy request 的 page table 永远指向额外的 dummy KV page,输入 token 为 0;Sampler 最后只读取真实请求对应的前 batch.size 行 logits。
capture 阶段固定 input、position、out_loc 和 logits buffer。replay 前,GraphRunner 把本轮数据复制进去,attention backend 再更新 capture 专用的 page table、sequence length 或 wrapper metadata。
prefill 的 token 数变化太大,仍走 eager forward。只有 decode 的 padded batch size 落在已捕获范围内时,GraphRunner 才会 replay。
Token 怎样采样、解码并返回
Sampler 先判断整个 batch 是否都是 greedy。代码里的条件是 (temperature <= 0 或 top_k == 1) 且 top_p == 1。如果每个请求都满足,直接对 logits 做 argmax,不创建 sampling 参数 tensor。
只要 batch 里有一个非 greedy 请求,整批都会进入 FlashInfer sampling kernel。greedy 行的 temperature 被设为 1e-6,既避免除零,也让分布几乎退化到最大 logit。top_k < 1 时用完整词表大小,top-p 被限制在 [1e-6, 1]。
如果整批都没有 top-k 或 top-p 限制,对应参数直接传 None,FlashInfer 会走更短的路径。采样策略只影响 logits 之后的选择,不改变模型 forward 和 KV cache 布局。
forward 结束后,Engine 先对每个请求调用 complete_one(),再得到 next_tokens_gpu。这份 tensor 一路直接写回 token pool,另一路通过 non-blocking copy 复制到 pinned CPU 内存,并在 Engine stream 上记录 event。
Scheduler 的 _process_last_data() 等 event 完成后,跳过中间 ChunkedReq,把普通请求的新 token 追加到 CPU input_ids。
请求有两种结束条件:生成长度耗尽;或者采样 token 等于 EOS,且没有设置 ignore_eos。未结束的 prefill 请求会把已计算 prompt 写入 prefix cache,再进入 DecodeManager。结束的请求从 DecodeManager 移除,释放请求表行,并把仍可复用的前缀交给 cache manager。只有 rank 0 会向 detokenizer 发送 DetokenizeMsg(uid, next_token, finished)。
DetokenizeManager 按 UID 保存收到的 token、已确认文本和三个 offset。它不能简单地逐 token 调用 decode,因为某个 token 对应的文本可能要等后继 token 到来后才能确定。
代码会反复解码一小段上下文,再用 surrounding offset 去掉旧前缀;遇到 Unicode replacement character 时先不发送。换行可以立即输出,CJK 字符可以按字符输出,其他文本通常等到最后一个空格再提交。请求结束后,这些状态从 map 中删除。
以拆成两个 token 的英文单词为例。第一个 token 单独解码时可能只是一个不稳定词片,DetokenizeManager 会先留住它。第二个 token 到达后,它把 surrounding token 和新 token 一起解码,再从已确认文本的末尾切出新增部分。中文文本不靠空格划分词界,检测到 CJK 末字符时可以直接发送。sent_offset 保证每个 UserReply 只带尚未发出的后缀。
如果 finished token 正好是 EOS,DetokenizeManager 不会把它放进 decoded_ids。最后一条 UserReply 仍带 finished=True,但响应文本里不会出现 EOS 的可见表示。
UserReply 回到 API Server 后,后台 listener 按 UID 放入对应列表并触发 event。流式请求把增量文本包装成 OpenAI 风格的 SSE chunk,最后发送 finish chunk 和 [DONE];非流式请求则在 wait_for_ack() 中累加文本,等到 finished=True 后一次返回 JSON。两种返回方式共用后端链路,只在 API 层的消费方式上不同。
离线调用、MoE 和其他分支
离线 LLM 没有另起一套 Engine。它直接继承 Scheduler,把 offline_mode 设为 true,再覆盖消息收发:prompt 在当前进程分词,UserMsg 从内存列表读取,DetokenizeMsg 写进结果 map。调度、cache、模型前向和采样都沿用在线路径。当前离线接口只构造单 rank 配置。
Qwen3 MoE 的变化集中在 Decoder Layer 的 MLP。每个 rank 都有一份 router 线性层,用它为 token 选择 top-k expert;专家 gate/up 与 down 权重按 intermediate dimension 分片。Triton kernel 负责 token 对齐、专家矩阵乘和 top-k 结果合并,最后再做 All-Reduce。Attention、KV cache、Scheduler 和返回链路都没有变化。
Llama、Qwen2、Mistral 和 Qwen3 共用 BaseOP、并行线性层、Embedding、AttentionLayer 与 Engine。模型注册表只负责选出具体的 Decoder Layer,服务链路不会因为模型家族不同而另起一套。切换 attention backend 也是同样的边界:上层仍然传 Batch、page table 和 K/V 写地址。
下一轮 Decode 如何开始
最后把一轮 decode 串起来看。请求已经有一段 cached KV,token pool 的下一个逻辑位置放着上一轮采样 token。Scheduler 在需要跨页时补一张物理页,并准备这个位置的 position、page-table 映射和 attention metadata。
Engine 取出 token embedding,依次经过每层 QKV、RoPE、KV 写入、paged attention 与 MLP。LM Head 汇集完整词表 logits,Sampler 再选出一个新 token。
新 token 写到 token pool 的下一个位置,同时异步复制到 CPU。GPU 可以先提交下一轮计算;CPU 稍后判断 EOS 或长度、更新 Radix Cache,并把 token ID 交给 detokenizer。只要请求没有结束,这个循环就会继续:读入上一轮 token,写入一格新 KV,再生成一个 token。结束时,Scheduler 释放请求表行,只留下能够复用的整页前缀。
下一轮就从这个新 token 重新开始。HTTP 层收到的是一段段文本,模型每轮看到的只是一个 token;Scheduler 用请求长度、token pool、page table 和 cache handle 维护两边共享的状态。
发表评论
未登录评论需提供昵称和联系邮箱,提交后默认进入审核。
目录
作者
ThunGuo
Server R&D Engineer