Rust in Everything

SGLang:TokenizerManager、Scheduler、DetokenizerManager

主线位置:整条「一个请求的一生」在 SGLang 里的走法。默认路径上 HTTP、模板与分词、调度、反分词都是 Python,前向是 Python 调 CUDA kernel;KV 前缀匹配用的树核心在 Linux 上条件默认是 Rust。SGLANG_RUST_SERVER=1 时,HTTP、分词、反分词换成嵌在调度器进程里的 Rust 线程,调度、KV、前向、采样仍是 Python。

版本:SGLang v0.5.22

先说结论:

  • 单卡、sglang serve 不加参数时有三个核心进程。主进程跑 uvicorn + FastAPI 和 TokenizerManager,聊天模板和分词都在它的 asyncio 事件循环里同步做。Scheduler 是子进程,调度、KV 管理、前向、采样都在这里。DetokenizerManager 是另一个子进程,专门把 token id 按批解码成文本。
  • 三个进程用三段 ZMQ PUSH→PULL 连成一个环:主进程 → Scheduler → DetokenizerManager → 主进程。没有请求-应答的配对,每段都是单向的。
  • 序列化默认是 pickle(SGLANG_USE_PICKLE_IPC 默认 True),设为假才走 msgspec 的 msgpack。
  • Scheduler 默认走重叠循环:本批在 GPU 上跑的时候,CPU 处理上一批的结果并排下一批。
  • 打开 SGLANG_RUST_SERVER=1 后,不再起 DetokenizerManager 和 Python 的 HTTP 服务;HTTP、分词、反分词变成 rank 0 调度器进程里的 Rust 线程,和 Python 调度循环之间走进程内的 flume 通道。

进程结构

flowchart LR
  client(["HTTP 客户端"])
  subgraph MAIN["主进程(Python)"]
    loop["asyncio 事件循环<br/>FastAPI、OpenAIServingChat<br/>TokenizerManager"]
  end
  subgraph SCHED["Scheduler 进程(Python + CUDA)"]
    sl["调度循环(重叠)<br/>KV 管理、前缀树<br/>TpModelWorker 前向、采样"]
  end
  subgraph DETOK["DetokenizerManager 进程(Python)"]
    dl["事件循环<br/>按批反分词"]
  end
  client <-->|"HTTP / SSE"| loop
  loop -->|"PUSH→PULL<br/>scheduler_input_ipc"| sl
  sl -->|"PUSH→PULL<br/>detokenizer_ipc"| dl
  dl -->|"PUSH→PULL<br/>tokenizer_ipc"| loop

和 vLLM 比,有两处不同。第一,反分词单独一个进程,主进程收到的已经是文本。第二,通道是环,不是一对;Scheduler 发出的东西不直接回主进程,而是先经过 DetokenizerManager。

时序

一个流式的 /v1/chat/completions 请求:

sequenceDiagram
  participant C as 客户端
  participant M as 主进程事件循环
  participant S as Scheduler
  participant D as DetokenizerManager
  C->>M: POST /v1/chat/completions(stream)
  M->>M: 套聊天模板、分词(同步)
  M->>M: TokenizerManager 登记请求状态
  M->>S: ZMQ PUSH(默认 pickle)
  loop 每一步,直到请求结束
    S->>S: 收请求、组批、前向、采样
    S->>D: ZMQ PUSH 一批 token id
    D->>D: batch_decode
    D->>M: ZMQ PUSH 一批文本
    M->>M: 追加到请求状态,唤醒等待者
    M-->>C: SSE 一块 data
  end
  M-->>C: data: [DONE]

一段一段走

启动:谁拉起谁

HTTP 模式的入口是 launch_server(http_server.py#L2901),它的文档字符串把结构说得很直接:

python/sglang/srt/entrypoints/http_server.py#L2915

    - The engine consists of three components:
        1. TokenizerManager: Tokenizes the requests and sends them to the scheduler.
        2. Scheduler (subprocess): Receives requests from the Tokenizer Manager, schedules batches, forwards them, and sends the output tokens to the Detokenizer Manager.
        3. DetokenizerManager (subprocess): Detokenizes the output tokens and sends the result back to the Tokenizer Manager.

真正拉进程的是 Engine._launch_subprocesses(engine.py#L1121):

  1. 每张卡起一个 Scheduler 进程(engine.py#L889),入口 run_scheduler_process(scheduler.py#L6076)。前向由 Scheduler 自己持有的 TpModelWorker 执行(scheduler.py#L1009),不另起 worker 进程。
  2. 起 DetokenizerManager 进程(engine.py#L1055)。detokenizer_worker_num 默认 1(fields/serving.py#L54),大于 1 时多起几个 worker 再加一个分发进程。
  3. 在主进程里建 TokenizerManager(engine.py#L1327)。tokenizer_worker_num 默认 1(fields/serving.py#L50)。

之后主进程跑 uvicorn(http_server.py#L2952)。

张量并行大于 1 时有多个 Scheduler 进程,只有 rank 0 从主进程收请求,再广播给其他 rank(scheduler_components/request_receiver.py#L80)。这部分留给 05。

通道和序列化

三段通道都在各自的 init_ipc_channels 里建:

段发送端接收端
主进程 → SchedulerTokenizerManager 的 PUSH(tokenizer_manager.py#L668)Scheduler rank 0 的 PULL(ipc_channels.py#L38)
Scheduler → DetokenizerManagerScheduler 的 PUSH(ipc_channels.py#L64)DetokenizerManager 的 PULL(detokenizer_manager.py#L126)
DetokenizerManager → 主进程DetokenizerManager 的 PUSH(detokenizer_manager.py#L133)TokenizerManager 的 PULL(tokenizer_manager.py#L664)

收发都经过 io_struct.py 里的一组函数:

python/sglang/srt/managers/io_struct.py#L2710

def sock_send(socket: zmq.Socket, obj: Any, flags: int = 0) -> None:
    if _USE_PICKLE_IPC:
        socket.send_pyobj(obj, flags=flags, protocol=pickle.HIGHEST_PROTOCOL)
        return

    socket.send(msgpack_encode(obj), flags=flags)

_USE_PICKLE_IPC 来自 SGLANG_USE_PICKLE_IPC,默认 True(environ.py#L361)。所以默认路径传的是 pickle;msgpack 分支要求每个消息类型都是 msgspec.Struct,否则直接报错。这一点和 vLLM 不同:vLLM 的引擎边界只有 msgpack 一种。

HTTP 进来,模板与分词

路由在 http_server.py#L1803,交给 OpenAIServingChat.handle_request(serving_base.py#L73)。聊天请求在这一层就套好模板、编成 token id:

python/sglang/srt/entrypoints/openai/serving_chat.py#L1846

        else:
            rendered_prompt = self.tokenizer_manager.tokenizer.apply_chat_template(
                messages,
                tokenize=False,
                # ...
            )
            prompt_ids = parallel_prompt_encode(
                self.tokenizer_manager.tokenizer, rendered_prompt, encode_kwargs
            )

这段在 _render_and_encode_chat_template(serving_chat.py#L1800)里,直接在事件循环上调用,没有丢进线程池。parallel_prompt_encode 对长 prompt 会把文本切开、交给 HF tokenizers 的 encode_batch 在它自己的线程池里并行编码,条件不满足时退回普通的 encode。模板渲染的结果有一个小缓存,命中就跳过。

拿到 token id 后,TokenizerManager.generate_request(tokenizer_manager.py#L896)不用再分词。原生 /generate 接口送来的是文本时,才在 _tokenize_texts(#L1021)里分词;能把并发请求攒成一批分词的 enable_dynamic_batch_tokenizer 默认关(fields/serving.py#L295)。

_send_one_request(#L1769)把请求包成 TokenizedGenerateReqInput,经 _dispatch_to_scheduler(#L683)调 sock_send 发出。这是同步的 send,PUSH 套接字在队列没满时立即返回。

Scheduler:重叠循环

disable_overlap_schedule 默认 False(fields/schedule.py#L187),于是 enable_overlap 为真(scheduler.py#L495),单卡、不分离时进入 event_loop_overlap(scheduler.py#L5997):

python/sglang/srt/managers/scheduler.py#L1975

    def event_loop_overlap(self):
        """A scheduler loop that overlaps the CPU processing and GPU computation."""
        # ...
        while True:
            # ...
            # Receive requests
            self.ingest_requests()
            # ...
            # Get the next batch to run
            plan = self.get_next_batch_to_run(
                running_batch=self.running_batch, last_batch=self.last_batch
            )
            # ...
            # Launch the current batch
            if batch:
                batch_result = self.run_batch(batch)
                # ...
                self.result_queue.append((batch.copy(), batch_result))
            # ...
            # Process the last batch
            if self.last_batch:
                if not disable_overlap_for_batch:
                    pop_and_process()

每一圈:

  1. ingest_requests(#L2098)用非阻塞方式把 PULL 上积攒的请求全收下来,放进等待队列。
  2. get_next_batch_to_run(#L3766)决定这一批是预填充还是解码、放哪些请求;前缀匹配和 KV 分配在这里发生(02、03 部分)。
  3. run_batch(#L4490)把这一批发给 GPU,不等它算完。
  4. 趁 GPU 在算,process_batch_result(#L4921)处理上一批的结果:追加新 token、判断结束、释放 KV,再 stream_output。

源码里留了一个例外:连续两批都是预填充时,可以先处理完上一批再发新一批,换第一批更早出首 token。它由 SGLANG_DISABLE_CONSECUTIVE_PREFILL_OVERLAP 控制,默认 False(environ.py#L642),也就是默认照样重叠。

stream_output(scheduler_components/output_streamer.py#L119)把这一批里所有要输出的请求打成一个 BatchTokenIDOutput,发给 DetokenizerManager(#L219)。和 vLLM 一样,调度器进程只发 token id。

DetokenizerManager:按批解码

DetokenizerManager 的主循环只有几行:

python/sglang/srt/managers/detokenizer_manager.py#L180

    def event_loop(self):
        """The event loop that handles requests"""
        while True:
            with self.soft_watchdog.disable():
                recv_obj = sock_recv(self.recv_from_scheduler)
            output = self._request_dispatcher(recv_obj)
            if output is not None:
                sock_send(self.send_to_tokenizer, output)
            self.soft_watchdog.feed()

handle_batch_token_id_out(#L444)对整批请求一起调 tokenizer.batch_decode(#L276),产出 BatchStrOutput 发回主进程。vLLM 是每个请求一个增量反分词器;SGLang 是每批一次批量解码,放在独立进程里,不占主进程的事件循环。

回到主进程:唤醒和 SSE

TokenizerManager 的 handle_loop(tokenizer_manager.py#L2463)是事件循环上的一个后台任务,第一次收请求时创建(#L2438)。它从 PULL 收 BatchStrOutput,在 _handle_batch_output(#L2478)里按请求 id 找到状态,把输出追加进 out_list,再 event.set() 唤醒等待者(#L2777)。

等待者是这个请求的 _stream_one_response(#L1946),它把输出逐块交给 _generate_chat_stream(serving_chat.py#L1965)。后者拼 OpenAI 格式的 SSE 块,工具调用和推理内容的解析也在这里,最后发 data: [DONE](#L2304)。

每一段在哪、是什么语言

段进程源文件默认SGLANG_RUST_SERVER=1
HTTP 进来主进程entrypoints/http_server.pyPythonRust(调度器进程内的线程)
模板与分词主进程entrypoints/openai/serving_chat.pyPythonRust
请求状态、发给调度器主进程managers/tokenizer_manager.pyPython,ZMQ,默认 pickleRust 线程 → flume 通道 → Python
组批、前缀匹配、KV 分配Schedulermanagers/scheduler.py、mem_cache/Python;树核心条件默认 Rust不变
前向、采样Schedulermanagers/tp_worker.py、model_executor/Python 调 CUDA不变
发 token idScheduler → DetokenizerManagerscheduler_components/output_streamer.pyPython,ZMQPython → flume 通道 → Rust
反分词DetokenizerManagermanagers/detokenizer_manager.pyPythonRust
唤醒、SSE、解析主进程tokenizer_manager.py、serving_chat.pyPythonRust

前缀树核心的 Rust 实现和它的回退条件见 树核心。

打开 Rust 服务之后

SGLANG_RUST_SERVER 默认 False(environ.py#L1902)。设为 1 后,_launch_subprocesses 只起 Scheduler,不起 DetokenizerManager,也不建 TokenizerManager:

python/sglang/srt/entrypoints/engine.py#L1292

        # The embedded Rust server (started inside the rank-0 scheduler) owns
        # the API server, tokenization, and detokenization. In that mode we do
        # not start the Python detokenizer subprocess(es) or tokenizer manager.
        # Do not use RayEngine with the Rust server, as it is not supported.
        if envs.SGLANG_RUST_SERVER.get():
            scheduler_init_result.wait_for_ready()

主进程也不跑 Python 的 HTTP 服务,只等调度器退出(http_server.py#L2943)。HTTP 端口由 rank 0 调度器进程里的 Rust 线程监听:调度器初始化时调 maybe_init_rust_server(scheduler.py#L2341),由 RustServer.launch(rust_server/server.py#L124)启动 PyO3 扩展里的服务线程,然后把收请求的来源换掉:

python/sglang/srt/managers/scheduler.py#L2352

        rust_server = self.get_rust_server_class().launch(self)
        self.rust_server = rust_server
        # The rust server *is* the ingress source: SchedulerRequestReceiver
        # drains its request ring (rust_server_mode) instead of a zmq socket.
        self.recv_from_tokenizer = rust_server

出口也换了:stream_output 看到 rust_server 存在,就调 push_generation 而不是发给 DetokenizerManager(output_streamer.py#L216)。Rust 和 Python 之间是两条进程内的 flume 通道,传的是 msgpack 字节和命名缓冲区(rust/sglang-server/src/tokenizer_manager/channel.rs#L1)。

flowchart LR
  client(["HTTP 客户端"])
  mainp["主进程(Python)<br/>只等调度器退出"]
  subgraph SCHED["rank 0 Scheduler 进程"]
    subgraph RT["Rust 线程(PyO3 扩展)"]
      rh["HTTP、模板、分词<br/>反分词、SSE"]
    end
    subgraph PY["Python 调度线程"]
      sl["调度循环、KV、前缀树<br/>前向、采样"]
    end
    rh -->|"flume:请求"| sl
    sl -->|"flume:token id"| rh
  end
  mainp -.->|"拉起"| SCHED
  client <-->|"HTTP / SSE"| rh

换掉的是表里标「Rust」的段:HTTP、模板与分词、反分词、SSE,加上两条通道。不变的是调度循环以内的一切。进程从三个变成两个,而且 Rust 和 Python 调度循环共用一个进程,没有 ZMQ 也没有 pickle。Rust 服务注册了 /generate、/v1/chat/completions、/v1/completions、/v1/models、/health 等接口(如 rust/sglang-server/src/api_server/http/openai/chat.rs#L49);离线的 sgl.Engine 不支持这个模式,会直接报错(engine.py#L309)。

它和 vLLM 的独立进程方案的对比见 两种 Rust 前端。默认路径和 Rust 服务在同一模型上的吞吐和延迟对比,待实测(C3-2)。

小结

  • 单卡默认三个核心进程:主进程(HTTP、模板、分词、TokenizerManager)、Scheduler(调度、KV、前向、采样)、DetokenizerManager(反分词),全是 Python,前向调 CUDA。
  • 三段 ZMQ PUSH→PULL 连成环;默认传 pickle(SGLANG_USE_PICKLE_IPC 默认 True),设为假才走 msgpack。
  • 聊天模板和分词在主进程的事件循环上同步做,长 prompt 交给 HF tokenizers 的线程池并行编码;反分词在独立进程里按批做。
  • Scheduler 默认重叠循环:GPU 算本批时,CPU 处理上一批结果并排下一批;连续两批预填充时不重叠是可选项,默认关。
  • SGLANG_RUST_SERVER=1:不起 DetokenizerManager 和 Python HTTP,rank 0 调度器进程里多出 Rust 线程,进出走 flume 通道;离线 Engine 不支持。

💬 关联讨论

发起讨论
加载中...