Skip to main content

03 - 三种调度器与模型运行器

07 篇说「每段配一套匹配它的调度」。这一篇讲「配」具体是什么意思。

最难的是自回归那段。它要管的事很杂:显存怎么分给一堆同时在跑的请求、该挑哪几个凑成一批、装不下时踢谁出去、怎么少发点指令。这些写一套好几千行,而且属于「写对不难、写快极难」—— SGLang 在上面磨了两年。

你不想重写,但也不能直接用:SGLang 的调度器自带外壳,自己开 ZMQ 收请求、自己管分词器。而在多阶段流水线里请求是从上一个阶段的队列来的,分词早在预处理做完了。你要的只是它中间那块核心。

把一个自带外壳的东西剥出核心来用,两种做法差别巨大:

做法省不省事一年之后
抄一份改(分叉)省事上游每次升级都要手工合并,最终再也跟不上。已有 SGLang 的下游项目这么栽过
只调公开方法,不碰内部字段(组合)麻烦上游升级基本无感

SGLang-Omni 选了后者,并写了三条纪律防止它慢慢滑向前者。

一、三种调度器接的是三类计算

07 篇分出的三类计算,在这里一一对应到三种调度器。它们对外是同一个接口,对内的结构完全不同:

三者对外接口完全一致 —— inboxoutboxstart()stop()abort(request_id)。Stage 因此不需要对调度器类型做任何分支,这是整套架构能不断加新模型而不塌的原因。

对内则完全不同:

OmniSchedulerSimpleSchedulerCode2WavScheduler
接哪类计算自回归无状态一次性有状态流式
内部循环复用 SGLang 的事件循环:选批 → run_batch → 处理结果,跑在独立线程inbox.get()fn(data)outbox.put(),可选 batch_compute_fn 本地攒批按消息类型分三支:new_request 初始化、stream_chunk 累积并解码、stream_done 冲出剩余
持有什么状态KV cache、树缓存、运行批、请求池 —— 最重什么都不持有每请求的因果偏移、注意力缓存、音频缓冲
中止时要做什么归还 KV 页无事可做最容易漏的一个
谁在用thinker、talker_ar、tts_engine、ASR 引擎预处理、编码器、聚合、文本 decode、批量声码器 —— 占阶段总数一多半code2wav、流式 vocoder
实现规模最大,但主体是复用的几十行中等,难在状态管理

选哪个不是风格问题,是问「这一段是哪一类计算」 —— 要 KV cache 的走第一个,算完就走的走第二个,要边生成边出声的走第三个。

二、复用上游调度器的边界

它不是「参考 SGLang 写了一个」,而是真的继承并复用 SGLang 的 Scheduler。边界划得很清楚:

具体内容
直接复用get_next_batch_to_run()process_batch_result()self_check_during_idle() —— 也就是 KV cache 管理、prefill/decode 调度、树缓存、扩散式 LLM 支持
覆盖重写init(跳过 ZMQ 与分词器与指标)、recv_requests()(改从 inbox 取,并把流式块路由到每请求状态)、process_input_requests()(走 request_builder 转换)、run_batch()(转交 ModelRunner)、send_to_tokenizer()(空实现)
完全不用ZMQ 通道、分词器初始化、语法后端、指标导出、PD 分离、LoRA、投机解码、流水线并行、看门狗

有一条已知的不兼容值得记:它明确拒绝 SGLang 的重叠调度_event_loop_overlap 会直接拒绝运行,原因是在那条循环上 Req.inflight_middle_chunks 的递减会滞后一轮。这是组合式复用的典型代价 —— 上游的某些优化路径与你的扩展假设冲突,正确做法是显式拒绝并写明原因,而不是默默跑出错的结果

组合的三条纪律

这套组合关系有前车之鉴:一些 RL 方向的 SGLang 分叉,因为让「组合」慢慢漂移成了「事实上的分叉」,最后升级成本高到无法承受。RFC 里写死了三条:

纪律具体怎么做现在的妥协
① 钉版本,不追踪主线理想是跟着上游 main前提是 CI 得跑在真的 Scheduler 上而不是 mock 上,那个 CI 目前太贵,所以先钉版本
② 把复用面压到最小PrefillManagerDecodeManager 当黑盒用,只碰公开方法,不读不写内部属性没有妥协,这条是硬的
③ 优先往上游推需要上游没干净暴露的东西时,首选给上游加钩子或抽方法,而不是在下游打补丁上游 PR 成本高,还做不到常态化,但方向是这个
一条能带走的通用判据

组合与分叉之间只隔着一条线:有没有碰上游的内部属性。

评估任何「基于 X 二次开发」的项目健康度,看它碰了多少 X 的内部属性就够了 —— 这个数字比 star 数、比 README 写得多好都准。

三、简单调度器与带条件的攒批门

攒批的朴素写法是「无条件等 N 毫秒,等到就发」。这个写法在高并发时没问题,低并发时却给每个请求平白加了 N 毫秒 —— 明明后面没人了还在傻等。

SGLang-Omni 的 Whisper 路径加了个条件:只在「还有别的请求正在构建中」时才等。两种负载下的表现完全不同:

同一个攒批门,两种负载下的行为高并发 · 后面还有请求在构建 → 值得等请求 A 到等待,最多 6 ms请求 B 到 →两个一起发,一趟 ViT省掉一次权重搬运低并发 · 后面没有待构建的活 → 不等请求 A 到立刻放行,零等待不给它平白加 6 ms对照:无条件等 6 ms 的朴素写法请求 A 到傻等 6 ms才发,且只有它自己低负载下每个请求都白等这 6 ms
判据是「后面还有没有待构建的请求」,不是「时间到没到」。攒批逻辑写成无条件超时是很常见的偷懒做法,代价正好落在最不该付的场景上 —— 低负载时单请求延迟本该最好。

它简单到只有一句话:inbox.get() → 算 → outbox.put()。但可选的 batch_compute_fn 上有个做得挺细的东西 —— 带条件的攒批门

Whisper 路径的默认值:目标攒 2 个请求,但只在「还有别的请求正在构建中」时才等,且最多等 6 毫秒;单个请求、或者后面已经没有待构建的活了,立刻放行。

对比一下常见的偷懒写法「无条件等 N 毫秒」:低负载时每个请求都白等 N 毫秒,而那正是延迟最该好看的时候。攒批的等待应该由「是否真的还有东西要攒」触发,而不是由时钟触发。

四、模型运行器

模型特有的代码最后全收在这一层。它的形状是一条带插入点的流水线 —— 基类跑完整条链,子类只填自己需要的那几个钩子:

红色的四个是钩子,其余全是基类的。两个具体的子类只填了很少的东西:

运行器填了哪个钩子干什么
ThinkerModelRunnercustom_prefill_forward前向之前把图像、视频、音频、deepstack 的嵌入注进 batch
FeedbackARModelRunnerbefore_*post_*前向前写上一步的反馈缓冲,前向后抽取码本与新反馈

把「怎么算」留在模型里、「什么时候算」交给运行器,好处是运行器可以统一实现异步解码这类优化 —— 一步前瞻就加在这一层,所有走 OmniScheduler 的模型直接受益,不用各自改。

形状是一条带钩子的流水线:

ForwardBatch → before_*() → custom_*_forward() 或标准前向 → post_*() → 采样 / 输出处理
↑ 改状态 ↑ 换前向路径 ↑ 取结果

基类拥有全部共性机制:ForwardBatch 构造、采样、logit 处理、重复惩罚、输出处理、转成调度器输出。子类只填钩子。

两个已有的子类:

  • ThinkerModelRunner:Qwen-omni 的 thinker。它唯一的模型特有工作是在前向之前,把图像、视频、音频、deepstack 的多模态嵌入注进 forward batch。
  • FeedbackARModelRunner:给「下一步解码依赖上一步在同一个运行器内产生的反馈」这类模型用。

反馈式那一类的单步内部长这样:

一个解码步,橙色是运行器负责的,青色是模型 forward 内部负责的运行器 · 前WRITE_BUFFERS写入上一步的反馈放进模型自己的缓冲区这一步是运行器做的,因为它知道「上一步」是哪一步;模型只知道当下。模型 FORWARD 内部 · 四步① 主干前向隐状态 → logits② 从 logits 采第一个码这一码定调,其余以它为条件③ 次级头自回归解出其余码本层④ 合并输出写回缓冲供下一步读③ 是 RVQ 层间依赖的直接体现:第 k 层量化的是前 k−1 层剩下的残差,所以必须串行。运行器 · 后EXTRACT_OUTPUT取出码本与新反馈推进 outbox:流式块或结果策略对象只有三个方法:write_buffers / extract_output /prefill_forwardOUT OF SCOPE它明确不覆盖:跨阶段的反馈生产者和消费者在不同调度器里、要靠 relay 通信的那种反馈循环,不归这个运行器管。抽象的边界写清楚,比抽象本身更重要。
Qwen3-Omni 的 talker 和 Fish S2-Pro 都是这个形状。把「写缓冲 / 读结果」交给运行器、把「怎么算」留在模型里,好处是运行器可以统一实现异步解码这类优化 —— 07 篇的一步前瞻正是加在这一层,所有走 OmniScheduler 的模型都能直接受益。

五、统一采样种子

还有一件事被收进了运行器基类:请求级的 seed。之前每个模型各自处理,有的直接丢弃。现在基类有一个 _install_sampling_seeds 钩子,在采样之前把每行的种子装进 forward_batch.sampling_info

两个细节值得抄:

  • 混批时给没带种子的行一个由 request_id 派生的兜底种子,保证张量并行的各个 rank 拿到一致的值。否则 rank 之间采样发散,输出直接错乱。
  • 没有任何请求带种子时严格空操作,走原来的路径,逐位不变。

这类「给一个公共契约补齐实现」的改动,最容易在边角上翻车,所以「不带种子时逐位不变」应该是验收条件而不是期望。

下一篇04 - 控制面与数据面