跳转至
手写 mini-sglang 阶段二 · 排得好:同时服务很多请求

重叠调度:藏起 CPU 开销

一个小模型 decode 一步,GPU 只要几毫秒;而调度器每一轮在 CPU 上要做的事——收消息、组 batch、准备元数据、处理上一轮的结果、发消息——同样要几毫秒。如果两者轮流进行,GPU 有将近一半的时间在等 CPU。重叠调度(出自 NanoFlow,SGLang 0.4 引入)让 CPU 在 GPU 计算第 N+1 轮的同时处理第 N 轮的结果。mini-sglang 的实现只多了十几行,但它改变了"什么时候知道什么",由此引出了本书在复刻时发现的四个问题。

自测:能答上来就可以跳过本章

  1. 发射第 N+1 轮时,CPU 还不知道第 N 轮采样出的 token。第 N+1 轮的输入从哪来?
  2. 一个请求在第 N 轮采样出了 EOS,它会不会出现在第 N+1 轮?
  3. 为什么调度器和引擎要用两条不同的 CUDA stream?
  4. 重叠调度下,处理第 N 轮结果时,req.device_len 反映的是第几轮之后的状态?
自测参考答案(先自己答,再展开对照)
  1. 从 GPU 上的 token pool:第 N 轮在 GPU 上采样出的 token 直接写进 token pool,第 N+1 轮的前向从 token pool 读取输入,CPU 不需要知道它是什么。
  2. 可能会:发射第 N+1 轮时 CPU 还没处理第 N 轮的结果,不知道它已经结束,所以多调度了一轮;处理结果时丢弃这个多余的 token。
  3. 让调度器准备下一轮(拷贝元数据、组 batch)的工作和引擎的计算在不同的 stream 上并行,并用事件建立依赖;都放在一条 stream 上就又串行了。
  4. 第 N+1 轮之后:CPU 上的请求状态在发射时就提前推进了,比正在处理的结果领先一轮。

本章要写的文件:scheduler/scheduler.py 中的 overlap_loop、run_forever,以及 _process_last_data、_free_req_resources、_process_one_msg 里与重叠有关的部分。

到这一章为止的文件:62 个(新建 0,修改 1,提前引入 0)
minisgl/
├── __init__.py
├── core.py
├── env.py
├── attention/  (4 个文件,不变)
├── distributed/  (3 个文件,不变)
├── engine/  (5 个文件,不变)
├── kernel/  (2 个文件,不变)
├── kvcache/  (5 个文件,不变)
├── layers/  (8 个文件,不变)
├── llm/  (2 个文件,不变)
├── message/  (4 个文件,不变)
├── models/  (7 个文件,不变)
├── moe/  (2 个文件,不变)
├── scheduler/
│   ├── table.py
│   ├── __init__.py
│   ├── config.py
│   ├── utils.py
│   ├── decode.py
│   ├── prefill.py
│   ├── scheduler.py  ← 本章修改
│   ├── cache.py
│   ├── io.py
├── utils/  (8 个文件,不变)
这一步的 main:examples/ch11_overlap.py——它只用到上面这些文件;python tools/steps.py check 会逐章搭出这棵树、跑这个 main。

动画:重叠调度,以及它带来的四个问题(约 2 分钟)

两种循环

minisgl/scheduler/scheduler.py · Scheduler.overlap_loop
def overlap_loop(self, last_data: ForwardData | None) -> ForwardData | None:
    """重叠循环:先发射第 N 轮的计算,再在 CPU 上处理第 N-1 轮的结果。

    GPU 执行第 N 轮的同时,CPU 在做第 N-1 轮的结果处理和第 N+1 轮的调度,CPU 开销被藏起来。
    能这样做的前提是:第 N 轮的输入 token 不需要 CPU 知道——它由第 N-1 轮的采样结果在 GPU 上
    直接写进 token_pool(见 _forward)。
    """
    self._defer_slot_free = True
    if last_data is None:  # 没有在途的 batch:之前推迟释放的请求槽可以放心复用了
        self._release_deferred_slots()
    blocking = not (last_data is not None or self.prefill_manager.runnable
                    or self.decode_manager.runnable)
    for msg in self.receive_msg(blocking=blocking):
        self._process_one_msg(msg)
    forward_input = self._schedule_next_batch()
    ongoing = None
    if forward_input is not None:
        with self.engine_stream_ctx:
            self.engine.stream.wait_stream(self.stream)  # 等本轮的元数据准备好
            ongoing = (forward_input, self._forward(forward_input))
    self._process_last_data(last_data)
    return ongoing
与 normal_loop 比,只有一处不同:先发射本轮,再处理上一轮。overlap_loop 接收上一轮的数据、返回本轮的数据,run_forever 把它们串起来:

minisgl/scheduler/scheduler.py · Scheduler.run_forever
@torch.inference_mode()
def run_forever(self) -> NoReturn:
    if ENV.DISABLE_OVERLAP_SCHEDULING:
        with self.engine_stream_ctx:
            self.engine.stream.wait_stream(self.stream)
            while True:
                self.normal_loop()
    else:
        data = None
        while True:
            data = self.overlap_loop(data)
用一个请求、3 个 token 看事件顺序(示例的前半部分):

普通循环:  发射1 处理1 发射2 处理2 发射3 处理3
重叠循环:  发射1 发射2 处理1 发射3 处理2 处理3

在 GPU 上,"发射"只是把 kernel 放进 stream 的队列,立即返回;"处理"要等结果拷回 CPU。重叠循环里,处理第 1 轮时 GPU 已经在算第 2 轮,CPU 的开销被藏在 GPU 计算的时间里:

普通循环:CPU 和 GPU 轮流工作 CPU GPU 调度1 计算 1 处理1 调度2 计算 2 处理2 调度3 计算 3 处理3 调度4 计算 4 处理4 GPU 空闲 重叠循环:发射第 N+1 轮后再处理第 N 轮 CPU GPU 调度1 计算 1 调度2 计算 2 调度3 处理1 计算 3 调度4 处理2 计算 4 调度5 处理3 计算 5 调度6 处理4 计算 6 前提:下一轮的输入由上一轮的采样结果在 GPU 上写进 token pool;请求状态在发射时就推进(complete_one)。 代价:处理第 N 轮时 CPU 状态已领先一轮;在第 N 轮遇到 EOS 的请求会被多调度一轮。
普通循环与重叠循环的 CPU / GPU 时间线

三个前提

能这样做,依赖前面几章早已埋下的三个设计。

第一,下一轮的输入不需要经过 CPU。 第 N+1 轮 decode 的输入是第 N 轮采样出的 token,发射第 N+1 轮时 CPU 还不知道它是什么。但 _forward 把采样结果直接在 GPU 上写进了 token pool 的下一个位置,第 N+1 轮再从 token pool 里取输入(第 7 章的 write_tuple 和 input_tuple)。GPU 按 stream 顺序执行,读一定发生在写之后。

第二,调度所需的状态在 CPU 上提前推进。 Engine.forward_batch 在发射后立即对每个请求调用 complete_one(),所以调度第 N+1 轮时,请求的 cached_len、device_len 已经是"第 N 轮之后"的值,可以正确地算出位置、分配 KV 页。唯一不知道的是第 N 轮的采样结果——也就是请求是否在第 N 轮遇到了 EOS。所以一个在第 N 轮遇到 EOS 的请求,会被多调度一轮。

第三,两条 stream。 调度器在自己的 stream 上准备元数据(把位置、下标等从锁页内存异步拷到 GPU),引擎在另一条 stream 上计算。发射前 self.engine.stream.wait_stream(self.stream) 让计算等元数据拷完;而调度器 stream 上下一轮的准备工作不必等当前的计算。ForwardInput 把这一轮用到的所有张量(包括下标)保存到处理结果之后,防止它们在 GPU 还没用完时被释放、内存被复用。

由此带来的四个问题

"CPU 状态提前一轮"和"请求可能被多调度一轮"这两点,让处理结果变得微妙。官方实现处理了其中一种情况(防止重复释放),本书在复刻时又发现了四个问题,示例和测试都能复现:

examples/ch11_overlap.py
"""第 11 章:重叠调度的事件顺序,以及按官方逻辑判断结束时出现的两个问题。"""

from typing import List, Set

import torch
from minisgl.core import Req, SamplingParams
from minisgl.env import ENV
from minisgl.llm import LLM
from minisgl.message import DetokenizeMsg
from minisgl.scheduler.prefill import ChunkedReq

PATH = "models/Qwen3-0.6B"
KW = dict(dtype=torch.float32, max_running_req=8, num_page_override=512, max_seq_len_override=128)


def trace_events(overlap: bool) -> None:
    ENV.DISABLE_OVERLAP_SCHEDULING.value = not overlap
    llm = LLM(PATH, **KW)
    step = {"n": 0}
    orig_forward, orig_process = llm._forward, llm._process_last_data

    def forward(fi):
        step["n"] += 1
        fi.batch.step = step["n"]
        print(f"    发射第 {step['n']} 轮({fi.batch.phase})")
        return orig_forward(fi)

    def process(data):
        if data is not None:
            print(f"    处理第 {data[0].batch.step} 轮的结果")
        return orig_process(data)

    llm._forward, llm._process_last_data = forward, process
    llm.generate(["The capital of France is"], SamplingParams(max_tokens=3, ignore_eos=True))
    llm.shutdown()


print("普通循环:")
trace_events(overlap=False)
print("重叠循环:")
trace_events(overlap=True)


class UpstreamLogicLLM(LLM):
    """把 _process_last_data 换成官方的判断方式(其余不变),用来复现问题。"""

    def _process_last_data(self, last_data) -> None:
        if last_data is None:
            return
        batch, (_, next_tokens_cpu, copy_done) = last_data[0].batch, last_data[1]
        copy_done.synchronize()
        reply: List[DetokenizeMsg] = []
        new_finished: Set[Req] = set()
        with self.cache_manager.lazy_free_region():
            for i, req in enumerate(batch.reqs):
                if isinstance(req, ChunkedReq):
                    continue
                req.append_host(next_tokens_cpu[i].unsqueeze(0))
                token = int(next_tokens_cpu[i].item())
                finished = not req.can_decode  # 官方:按 can_decode 判断
                if not req.sampling_params.ignore_eos:
                    finished |= token == self.eos_token_id
                reply.append(DetokenizeMsg(uid=req.uid, next_token=token, finished=finished))
                if finished and req not in self.finished_reqs:  # 官方:只防重复释放
                    self.decode_manager.remove_req(req)
                    self._free_req_resources(req)
                    new_finished.add(req)
                elif batch.is_prefill:
                    self.cache_manager.cache_req(req, finished=False)
        self.finished_reqs = new_finished
        self.send_result(reply)


def replies_of(cls, prompt: str, params: SamplingParams, eos: int | None = None):
    ENV.DISABLE_OVERLAP_SCHEDULING.value = False
    llm = cls(PATH, **KW)
    if eos is not None:
        llm.eos_token_id = eos
    replies = []
    orig = llm.offline_send_result
    llm.send_result = lambda reply: (replies.extend(reply), orig(reply))
    llm.generate([prompt], params)
    llm.shutdown()
    return [(m.next_token, m.finished) for m in replies]


print("\n问题一:max_tokens=3,发给 detokenizer 的 (token, finished):")
for cls in (UpstreamLogicLLM, LLM):
    print(f"  {cls.__name__:<17}", replies_of(cls, "The capital of France is",
                                          SamplingParams(max_tokens=3, ignore_eos=True)))
print("问题二:把 eos 设成第 3 个输出 token(576),max_tokens=8:")
for cls in (UpstreamLogicLLM, LLM):
    print(f"  {cls.__name__:<17}", replies_of(cls, "The capital of France is",
                                          SamplingParams(max_tokens=8), eos=576))
运行结果
普通循环:
    发射第 1 轮(prefill)
    处理第 1 轮的结果
    发射第 2 轮(decode)
    处理第 2 轮的结果
    发射第 3 轮(decode)
    处理第 3 轮的结果
重叠循环:
    发射第 1 轮(prefill)
    发射第 2 轮(decode)
    处理第 1 轮的结果
    发射第 3 轮(decode)
    处理第 2 轮的结果
    处理第 3 轮的结果

问题一:max_tokens=3,发给 detokenizer 的 (token, finished):
  UpstreamLogicLLM  [(12095, False), (13, True), (576, True)]
  LLM               [(12095, False), (13, False), (576, True)]
问题二:把 eos 设成第 3 个输出 token(576),max_tokens=8:
  UpstreamLogicLLM  [(12095, False), (13, False), (576, True), (6722, False)]
  LLM               [(12095, False), (13, False), (576, True)]

问题一:结束标记提前了一个 token

官方用 finished = not req.can_decode 判断是否达到 max_tokens。但处理第 N 轮时,第 N+1 轮已经发射,device_len 已经被推进了一步,can_decode 反映的是"第 N+1 轮之后"的状态。于是 max_tokens=3 的请求在第 2 个 token 时就被标记为结束(上面的 (13, True)),第 3 个 token 又发了一条带结束标记的消息。

离线接口恰好不受影响(它记下了每一条消息的 token),但在线服务的前端收到第一条 finished=True 就结束响应,客户端只会收到 max_tokens - 1 个 token。修正方法是改用与调度节奏无关的量——CPU 上已经收到的 token 数:

finished = len(req.input_ids) >= req.max_device_len

问题二:EOS 之后多发一条过期消息

一个在第 N 轮遇到 EOS 的请求会被多调度一轮(前提二)。官方用 finished_reqs 记录上一轮结束的请求,处理第 N+1 轮时跳过它们的资源释放,但仍然为它们生成了回复消息——上面的 (6722, False)。前端已经删除了这个请求,会忽略这条消息;detokenizer 却会为它新建一个解码状态,而且永远等不到结束,造成一点内存泄漏。修正:在循环开头直接跳过这些请求的一切处理。

minisgl/scheduler/scheduler.py · Scheduler._process_last_data
def _process_last_data(self, last_data: ForwardData | None) -> None:
    if last_data is None:
        return
    batch, (_, next_tokens_cpu, copy_done) = last_data[0].batch, last_data[1]
    copy_done.synchronize()  # 等采样结果拷回 CPU;这也意味着这个 batch 以及之前的 batch 都算完了
    self._release_deferred_slots()
    reply: List[DetokenizeMsg] = []
    new_finished: Set[Req] = set()
    with self.cache_manager.lazy_free_region():
        for i, req in enumerate(batch.reqs):
            if isinstance(req, ChunkedReq):  # 分块中的请求这一轮没有输出
                continue
            if req in self.finished_reqs:
                # 重叠调度下,请求在上一轮已经结束(遇到 EOS),但结束的消息还没处理,这一轮就已经
                # 把它带上了。这一轮的结果是多余的,直接丢弃(官方实现会多发一条消息,前端恰好会忽略)。
                continue
            next_token = next_tokens_cpu[i]
            req.append_host(next_token.unsqueeze(0))
            token = int(next_token.item())
            # 判断是否达到 max_tokens 不能用 req.can_decode:重叠调度下,处理第 N 轮结果时
            # 第 N+1 轮已经发射,req 的 device_len 已经被推进了一步,会提前一个 token 判定结束。
            # 用 CPU 侧已经收到的 token 数判断,与调度节奏无关。
            finished = len(req.input_ids) >= req.max_device_len
            if not req.sampling_params.ignore_eos:
                finished |= token == self.eos_token_id
            reply.append(DetokenizeMsg(uid=req.uid, next_token=token, finished=finished))
            if finished:
                self.decode_manager.remove_req(req)
                self._free_req_resources(req)
                new_finished.add(req)
            elif batch.is_prefill:  # prefill 刚结束:立刻把提示词的 KV 交给前缀缓存
                self.cache_manager.cache_req(req, finished=False)
    self.finished_reqs = new_finished
    self.send_result(reply)

问题三:请求槽过早复用

请求 X 在第 N 轮采样出 EOS 发射 N+1 (含 X) 处理 N: X 结束、释放 调度 N+2: 请求槽可以复用吗? 处理 N+1: X 的结果是过期的 GPU:第 N+1 轮计算中(会往 X 那一行 token pool 写采样结果) 第 N+2 轮 官方:立即把 X 的请求槽分给新请求 → 调度器 stream 与引擎 stream 写同一行,没有先后保证 本书:推迟到"处理 N+1"(在途 batch 已算完)再归还请求槽;X 的过期结果整体丢弃 同一套逻辑也修正了:finished 提前一个 token、过期消息、prefill 在途时 abort 导致页重复释放
请求在第 N 轮遇到 EOS 之后发生了什么

请求在第 N 轮遇到 EOS,处理第 N 轮结果时它的资源被释放,其中包括它在 page table / token pool 里占的那一行。但此刻第 N+1 轮正在 GPU 上跑,其中还有这个请求:它会在引擎 stream 上把采样结果写进 token pool 的这一行。如果下一轮调度立即把这一行分给新请求,调度器 stream 上对这一行的写入(新请求的提示词)就和引擎 stream 上的旧写入没有先后保证,新请求的输入可能被覆盖。

KV 页没有这个问题:新请求写 KV 的 batch 在引擎 stream 上一定排在第 N+1 轮之后。请求槽的问题在于它被调度器 stream写入。修正:重叠模式下,释放的请求槽推迟到"下一次处理结果"时才真正归还——那时 copy_done.synchronize() 已经保证在途的 batch 算完了。没有在途 batch 时(last_data is None)立即归还,否则只有一个请求槽的交互模式会卡住。

minisgl/scheduler/scheduler.py · Scheduler._free_req_resources
def _free_req_resources(self, req: Req) -> None:
    # 请求槽(page table / token pool 的一行):重叠调度下,GPU 上可能还有一个包含这个请求的
    # batch 在跑(它会往 token_pool 的这一行写采样结果)。如果此刻把这一行分给新请求,调度器流上
    # 对这一行的写入会与引擎流上的旧 batch 竞争。所以推迟到下一次处理结果、确认在途 batch 算完之后。
    # KV 页不需要推迟:新请求写 KV 的 batch 在引擎流上一定排在旧 batch 之后。
    if self._defer_slot_free:
        self._deferred_slots.append(req.table_idx)
    else:
        self.table_manager.free(req.table_idx)
    self.cache_manager.cache_req(req, finished=True)

问题四:prefill 在途时收到 abort

客户端断开连接会触发 abort。如果 abort 到达时,这个请求的 prefill 恰好已经发射、结果还没处理,官方的流程是:abort 释放请求(cache_req(finished=True),提示词的 KV 进入 Radix Cache);随后处理 prefill 结果时,请求没有结束,于是再调用一次 cache_req(finished=False)——对一个已经释放的请求。第二次插入发现前缀已在缓存中,把"重复的那份"释放掉,而这些页正是缓存里那份:同一批页既在空闲列表里又在缓存树里。之后它们可能被分给新请求,覆盖掉缓存的 KV;调度器空闲时的完整性检查也会报错(Radix Cache 下复现为 free_pages(1024) + cache_pages(5) != num_pages(1024))。

修正只需一行:abort 时把请求加入 finished_reqs,在途 batch 的结果就会被问题二的逻辑整体丢弃。

与官方的差异

以上四处都是对官方重叠调度的修正,每一处都有对应的测试:把修正改回官方写法,测试就会失败。它们在 CPU 上的复现依赖"处理第 N 轮时第 N+1 轮已经发射"这个时序,与是否真的有 GPU 并发无关——问题三的测试直接检查"分配请求槽时,它不属于任何在途 batch 中的请求"这个不变量。

官方实现

在 CPU 上意味着什么

CPU 上没有异步执行:NullStream、NullEvent 都是空操作,"发射"时计算就已经完成。所以在 CPU 上重叠调度不会更快,但调度逻辑完全相同,上面四个问题照样出现、照样被测试抓住。性能上的收益要在 GPU 上测:官方 README 建议用 MINISGL_DISABLE_OVERLAP_SCHEDULING=1 做消融。模型越小、每步 GPU 时间越短,CPU 开销的占比越大,重叠的收益越明显。第 21 章给出在 GPU 上做这个消融的方法。

测试

tests/test_ch11_overlap.py · test_overlap_equals_normal_and_hf
@pytest.mark.parametrize("cache,chunk,page_size", [("radix", 8192, 1), ("naive", 5, 1), ("radix", 7, 4)])
def test_overlap_equals_normal_and_hf(cache, chunk, page_size):
    results = {}
    for overlap in (True, False):
        ENV.DISABLE_OVERLAP_SCHEDULING.value = not overlap
        llm = build_llm(QWEN3, cache_type=cache, max_extend_tokens=chunk, page_size=page_size,
                        num_page_override=1024 // page_size)
        results[overlap] = [o["token_ids"] for o in llm.generate(PROMPTS, greedy(10))]
        llm.cache_manager.check_integrity()
        llm.shutdown()
    assert results[True] == results[False] == hf_greedy(QWEN3, PROMPTS, 10)
重叠与普通两种循环、三种配置组合(radix / naive、分块、page size 4),输出都与 Hugging Face 相同。另外四个测试分别对应上面的四个问题:

tests/test_ch11_overlap.py · test_abort_while_prefill_is_in_flight
def test_abort_while_prefill_is_in_flight():
    """prefill 已经发射、结果还没处理时收到 abort:请求被释放,在途 batch 的结果必须丢弃。"""
    from minisgl.message import AbortBackendMsg

    ENV.DISABLE_OVERLAP_SCHEDULING.value = False
    llm = build_llm(QWEN3, cache_type="radix")
    calls = {"n": 0}
    orig = llm.offline_receive_msg

    def receive(blocking=False):
        calls["n"] += 1
        if calls["n"] == 2:  # 第二轮开始时(prefill 正在"GPU 上")中止它
            return [AbortBackendMsg(uid=0)]
        return orig(blocking)

    llm.receive_msg = receive
    replies = []
    llm.send_result = lambda reply: replies.extend(reply)
    llm.generate(PROMPTS[:1], greedy(8))
    assert replies == []  # 被中止的请求没有任何输出
    llm.cache_manager.check_integrity()
    assert llm.table_manager.available_size == 8
    llm.shutdown()

怎么讲清楚

讲重叠调度:先发射第 N+1 轮,再在 CPU 上处理第 N 轮的结果,调度、组批、反分词这些 CPU 开销就藏在 GPU 计算后面。前提有三:第 N+1 轮的输入 token 在 GPU 上由第 N 轮的采样结果直接写入(CPU 不必知道具体值);请求状态在发射时提前推进;调度器和引擎用两条 stream。代价是 CPU 的状态领先 GPU 一轮:在第 N 轮采样出 EOS 的请求,可能已经被放进了第 N+1 轮,要丢弃它多算的结果;结束判断要用已经收到的 token 数,请求槽要等在途的 batch 算完再复用。收益在小模型、小 batch(GPU 一步很短)时最大。

练习

  1. 在 overlap_loop 里,如果把"发射本轮"和"处理上一轮"的顺序对调回来,但保留 last_data 的传递方式,会得到什么?
  2. 问题二里的"多调度一轮"浪费了一次计算。能不能在调度第 N+1 轮之前就知道第 N 轮有没有 EOS?代价是什么?
  3. 为什么问题三只影响请求槽,而 KV 页可以立即复用?如果新请求命中了前缀缓存,调度器 stream 会往 page table 写入命中的位置,这会不会引出类似的问题?
参考答案
  1. 等价于普通循环(发射后立即等待结果),失去重叠的收益,但逻辑仍然正确。
  2. 可以在发射第 N+1 轮之前 synchronize 第 N 轮的结果,但这正是重叠调度要避免的等待。另一种思路是接受这一轮浪费:一个请求最多多算一个 token,对吞吐的影响只和"每轮结束的请求数"成正比,通常可以忽略。
  3. KV 池只被引擎 stream 上的 kernel 写入,新请求的 KV 写入排在旧 batch 之后;请求槽对应的 token pool 行和 page table 行则会被调度器 stream 写入(新请求的提示词、命中的位置、新分配的页)。命中前缀时写的是新请求自己的那一行,只要这一行不是刚释放、仍被在途 batch 使用的,就没有问题——这正是推迟释放请求槽所保证的。

小结

  • 重叠调度先发射本轮、再处理上一轮,CPU 开销藏在 GPU 计算之后。
  • 前提:输入在 GPU 上由上一轮的采样结果写入;请求状态在发射时提前推进;调度器与引擎用两条 stream。
  • 代价:CPU 状态领先一轮,请求可能在 EOS 之后被多调度一轮。
  • 本书修正了官方的四个问题:结束判断改用已收到的 token 数;丢弃过期结果;请求槽推迟到在途 batch 算完再复用;abort 的请求加入 finished_reqs。

本章练习题(1 道)

在浏览器里直接写代码、跑测试;也可以在本地用 python practice/judge.py 判题。

发现错误或有更好的写法?在 GitHub 上编辑此页提交问题