Rust in Everything

连续批处理

主线位置:请求分好词、进了引擎之后,在「排队 → 进批 → 每一步拿几个 token」这一段。vLLM 在引擎核心进程的 Scheduler.schedule(),SGLang 在调度器进程的 Scheduler.get_next_batch_to_run(),两边都是 Python。SGLang 按前缀排序时查的前缀树,核心在 Linux 上默认是 Rust。

版本:vLLM v0.32.0、SGLang v0.5.22

结论:

  • 两边都是每步重组:上一步做完的请求离开,新到的请求在预算允许时进来,不用等整批做完。
  • vLLM v1 没有预填充阶段和解码阶段。每个请求只有「已算到第几个 token」,调度器在一个 token 预算里先喂运行中的请求、再收新请求。解码 token 和预填充块进同一次前向。
  • SGLang 分两种批。能组出预填充批就只跑预填充;组不出才让运行中的请求解码一步。默认不混。
  • 24 GB 卡上 vLLM OpenAI 服务的默认预算是每步 2048 个 token、最多 256 个请求;SGLang 的分块大小默认 4096。都是按显存分档写在源码里的。
sequenceDiagram
  participant W as 等待队列
  participant S as 调度器
  participant R as 运行中
  participant G as 前向
  Note over S: vLLM 一步
  S->>R: 逐个给 token(解码 1 个,预填充给剩余预算)
  S->>W: 还有预算且这一步没抢占,按序收新请求
  S->>G: 一个批:解码行 + 预填充块
  Note over S: SGLang 一步
  S->>W: 试组预填充批(PrefillAdder)
  alt 组得出
    S->>G: 只跑预填充批
  else 组不出
    S->>R: 检查显存,解码一步
    S->>G: 只跑解码批
  end

vLLM v1:一个预算,两段循环

没有阶段,只有「追上」

调度器开头的注释把模型说得很清楚:

vllm/v1/core/sched/scheduler.py

        # There's no "decoding phase" nor "prefill phase" in the scheduler.
        # Each request just has the num_computed_tokens and
        # num_tokens_with_spec. num_tokens_with_spec =
        # len(prompt_token_ids) + len(output_token_ids) + len(spec_token_ids).
        # At each step, the scheduler tries to assign tokens to the requests
        # so that each request's num_computed_tokens can catch up its
        # num_tokens_with_spec.

一个刚进来的请求,差的是整段 prompt;一个在解码的请求,差的是上一步采样出来的 1 个 token;被分块的长 prompt,差的是剩下那段。三种情况走同一套代码。

第一段:先喂 running

scheduler.py

        token_budget = self.max_num_scheduled_tokens
        # ...
        # First, schedule the RUNNING requests.
        req_index = 0
        while req_index < len(self.running) and token_budget > 0:
            request = self.running[req_index]
            # ...
            num_new_tokens = (
                request.num_tokens_with_spec
                + request.num_output_placeholders
                - request.num_computed_tokens
            )
            if 0 < long_prefill_token_threshold < num_new_tokens:
                num_new_tokens = long_prefill_token_threshold
            num_new_tokens = min(
                num_new_tokens, token_budget, input_budget - draft_slots
            )

(循环开始、算 num_new_tokens)

  • running 是一个按进入顺序排的列表。排在前面的先拿预算。
  • 一个还没做完预填充的请求也在 running 里,它会拿走 min(剩余 prompt, 剩余预算)。这就是分块预填充(下一页)。
  • 拿到 token 数之后要给它分 KV 块(allocate_slots)。分不到就抢占别的请求,见抢占。

第二段:再收 waiting

scheduler.py

        # Next, schedule the WAITING requests.
        if not preempted_reqs and self._pause_state != PauseState.PAUSED_ALL:
            # ...
            while token_budget > 0:
                # ...
                num_running = len(self.running) + self.num_waiting_for_streaming_input
                if num_running >= self.max_num_active_reqs:
                    break
                request = request_queue.peek_request()

(并发上限检查)

  • 这一步只要抢占过任何请求,就不收新请求。显存刚不够,没有理由再放人进来。
  • 并发上限是 max_num_active_reqs,不设时等于 max_num_seqs。
  • 新请求先查前缀缓存(num_computed_tokens == 0 时,第 960 行),命中的部分不算进本步的 token。
  • 收进来的请求直接追加到 running 末尾(第 1345 行),下一步就在第一段里。
  • 分 KV 块失败时直接停止扫描(break),不会为等待中的请求去抢占运行中的请求。

队列顺序由 policy 决定:默认 fcfs,另一个选项是 priority(数值小的先,平手看到达时间)(config/scheduler.py)。

预算和并发上限的默认值

SchedulerConfig 里写的 2048 / 128(第 41 行、第 43 行)注释说「主要为测试方便」。真正生效的是 EngineArgs 按显存和入口填的值(get_batch_defaults):

设备max_num_batched_tokens(LLM 类 / OpenAI 服务)max_num_seqs(同左)
显存 ≥ 160 GiB(B200 等)16384 / 163841024 / 1024
显存 ≥ 70 GiB 且不是 A100(H100、H200)16384 / 81921024 / 1024
其他 GPU(含 24 GB 卡、A100)8192 / 2048256 / 256
CPU 后端4096 × 卡数 / 2048 × 卡数256 × 卡数 / 128 × 卡数

TPU 另有一档。再往后还有两处修正(_set_default_max_num_seqs_and_batched_tokens_args):

  • --performance-mode throughput 时,没手设的两项都翻倍(第 3129 行);默认模式是 balanced(config/vllm.py)。
  • 关掉分块预填充且没手设预算时,预算抬到 max(max_model_len, 默认值)(第 3140 行),否则长 prompt 一步放不下。

所以一张 24 GB 卡起 vllm serve,默认每步最多 2048 个 token、最多 256 个请求同时在 running 里。还有一个独立的 max_num_scheduled_tokens,不设时等于 max_num_batched_tokens,投机解码会用到它。

SGLang:预填充优先,两种批

一步怎么定

python/sglang/srt/managers/scheduler.py

        if new_batch is not None:
            # Run prefill first if possible
            ret = new_batch
        else:
            # Run decode (skip for prefill-only batches)
            if not running_batch.is_empty() and not running_batch.is_prefill_only:
                decode_batch = self.update_running_batch(running_batch)

new_batch 来自 get_new_batch_prefill。上一步的预填充批在本步开头先并进 running_batch(get_next_batch_to_run)。于是:

  • 有新请求可以进,这一步就是纯预填充,运行中的请求这一步不出 token。
  • 没有新请求(或者显存 / 并发不允许),这一步是纯解码,update_running_batch 先检查下一步解码的显存,不够就 retract。
  • 默认的事件循环是重叠调度(CPU 准备下一批和 GPU 跑当前批重叠,--disable-overlap-schedule 关,默认不关,schedule.py)。它改变的是 CPU 时间藏在哪里,不改变上面「先预填充」的选择。

有一个让解码先跑几轮的开关 --prefill-decode-interval:一个预填充批之后先跑 N 步解码再考虑下一个预填充。默认解析成 0,即关闭(validation_hook.py),只有 Qwen3-VL 在 Hopper 上的一组配置会自动设成 22。

PrefillAdder:三笔预算

组预填充批的是 PrefillAdder(schedule_policy.py),调度器这样构造它:

scheduler.py

        adder = self.get_prefill_adder_class()(
            self.page_size,
            self.tree_cache,
            self.token_to_kv_pool_allocator,
            running_batch,
            self.new_token_ratio_tracker.current,
            self.max_prefill_tokens,
            chunked_prefill_size,
            running_bs if self.is_mixed_chunk else 0,
            # ...
        )
预算来自默认作用
rem_input_tokens--max-prefill-tokens16384(schedule.py)一个预填充批的输入 token 上限
rem_chunk_tokens--chunked-prefill-size按显存分档,24 GB 卡是 4096(下一页)一个批里预填充 token 的上限;超过就切块
显存预算KV 池剩余 + 可驱逐的前缀树—新请求的输入加上预计还要生成的 token 要放得下

第三笔是 SGLang 和 vLLM 差别较大的地方。SGLang 收新请求时,把运行中每个请求「还可能生成多少 token」按一个比例预留出来(_get_running_request_total_token_offset):

        ratio = 1.0 if req.sampling_params.ignore_eos else self.new_token_ratio
        return (
            min(
                (req.sampling_params.max_new_tokens - len(req.output_ids)),
                CLIP_MAX_NEW_TOKENS,
            )
            * ratio
        )

这个比例 new_token_ratio 初值 0.7 × --schedule-conservativeness(默认 1.0),每个没有 retract 的解码步往下衰减,600 步降到初值的 0.14 倍;发生 retract 时改成按当前批实际进度重估的值(见抢占),调度器空闲时复位到初值(environ.py、new_token_ratio_tracker.py)。vLLM 不做这种预估,只要求新请求的整段输入放得下(scheduler_reserve_full_isl,默认开,config/scheduler.py),之后不够了靠抢占。

收请求的循环按 waiting_queue 当前顺序走(第 4081 行),add_one_req 返回不是 CONTINUE 就停。

调度策略

--schedule-policy 默认 fcfs(schedule.py),可选值分两类(schedule_policy.py):

类策略排序依据
看缓存lpm在前缀树里命中最长的先(_sort_by_longest_prefix)
看缓存dfs-weight、hrrn、shortest-prefill-first树上的 DFS 权重;按 token 计的响应比老化;未命中部分最短的先(标为实验)
不看缓存fcfs、lof、random、routing-key到达顺序;最长输出先;随机;运行批里常见的路由键先

priority 也在可选列表里,优先级调度另由 --enable-priority-scheduling 打开。两条回退规则:

lpm 的意义:前缀相同的请求挨着进批,前一个算好的 KV 还在树里,后一个直接命中。这依赖前缀树,见 02 部分。

运行中请求的上限

--max-running-requests 默认不设(schedule.py),实际上限由请求槽位池和 KV 池的大小定,放到 07 部分讲显存预算时展开。

对照:Rust 写的调度器

两个引擎的调度器都是 Python。纯 Rust 的 mistral.rs 自己写了分页注意力的调度器,默认常量是每步 4096 个 token、预填充块 512、连续解码至多 8 步就要让预填充进来(scheduler/mod.rs,判断函数 completion_is_due)。它也是「这一步做解码还是做预填充」二选一的结构,和 SGLang 的默认形状接近。

吞吐和延迟

两边在同一张卡上并发 1–256 的吞吐、TTFT、TPOT 曲线:待实测(C3-2)。

小结

  • vLLM v1:一个 token 预算,先 running 后 waiting;解码和预填充块在同一次前向。抢占过的步不收新请求。
  • vLLM 的默认预算按显存分档,24 GB 卡起 OpenAI 服务是 2048 token / 256 请求。
  • SGLang:能组预填充批就只跑预填充,否则解码;默认不混。收新请求时按 new_token_ratio 给运行中请求预留未来的 token。
  • SGLang 默认 fcfs;lpm 按前缀树命中长度排,队列超过 128 时临时退回 fcfs。

💬 关联讨论

发起讨论
加载中...