Rust in Everything

vLLM:API server 与 EngineCore

主线位置:整条「一个请求的一生」在 vLLM 里的走法。默认路径上每一段都是 Python(模型前向是 Python 调 CUDA kernel,分词和反分词调用 Rust 写的 HF tokenizers 库);VLLM_USE_RUST_FRONTEND=1 时,HTTP、模板与分词、反分词与流式返回换成 Rust,调度、KV、前向、采样仍是 Python。

版本:vLLM v0.32.0

先说结论:

  • 单卡、vllm serve 不加参数时有两个进程。API server 就是启动它的那个进程,跑 uvicorn + FastAPI 和 AsyncLLM:HTTP、聊天模板、分词、反分词、SSE 都在这里。EngineCore 是它拉起的子进程:调度、KV 管理、前向、采样都在这里,单卡时模型不另起 worker 进程。
  • 两个进程之间是一对 ZMQ 套接字:请求走 API server 的 ROUTER 到 EngineCore 的 DEALER,输出走 EngineCore 的 PUSH 到 API server 的 PULL。消息用 msgspec 编成 msgpack。
  • EngineCore 里有三个线程:一个忙循环跑调度和 GPU 步,两个 IO 线程分别收和发 ZMQ。IO 线程收发时释放 GIL,和 GPU 步重叠。
  • 打开 VLLM_USE_RUST_FRONTEND=1 后,Python 主进程只负责拉起和看守;API server 换成独立的 vllm-rs 进程,接在同一对 ZMQ 套接字上,EngineCore 一行不改。

进程结构

flowchart LR
  client(["HTTP 客户端"])
  subgraph API["API server 进程(Python,启动进程本身)"]
    loop["asyncio 事件循环(uvloop)<br/>FastAPI 路由、OpenAIServingChat<br/>AsyncLLM、OutputProcessor"]
    pool["渲染线程池<br/>默认 1 个线程<br/>聊天模板、分词"]
    rsock["ROUTER(bind)"]
    psock["PULL(bind)"]
  end
  subgraph CORE["EngineCore 进程(Python + CUDA)"]
    inth["输入 IO 线程<br/>DEALER(connect)"]
    busy["忙循环线程<br/>Scheduler、KV 管理<br/>前向、采样"]
    outh["输出 IO 线程<br/>PUSH(connect)"]
  end
  client <-->|"HTTP / SSE"| loop
  loop <--> pool
  loop --> rsock
  rsock -->|"msgpack"| inth
  inth -->|"input_queue"| busy
  busy -->|"output_queue"| outh
  outh -->|"msgpack"| psock
  psock --> loop

时序

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

sequenceDiagram
  participant C as 客户端
  participant L as API server 事件循环
  participant P as 渲染线程池
  participant I as EngineCore 输入线程
  participant B as EngineCore 忙循环
  participant O as EngineCore 输出线程
  C->>L: POST /v1/chat/completions(stream)
  L->>P: 套聊天模板、分词
  P-->>L: prompt token ids
  L->>L: InputProcessor 生成 EngineCoreRequest
  L->>I: ZMQ ROUTER 发出 ADD(msgpack)
  I->>I: 解码,算前缀块哈希
  I->>B: 放进 input_queue
  loop 每一步,直到请求结束
    B->>B: schedule、前向、采样、update_from_output
    B->>O: EngineCoreOutputs 放进 output_queue
    O->>L: ZMQ PUSH(msgpack)
    L->>L: 增量反分词、查停止串
    L-->>C: SSE 一块 data
  end
  L-->>C: data: [DONE]

一段一段走

启动:谁拉起谁

vllm serve 的入口在 vllm/entrypoints/cli/serve.py。单卡默认 api_server_count 是 1,又没开 Rust 前端,于是在当前进程里直接跑 API server:

vllm/entrypoints/cli/serve.py#L151

        elif args.api_server_count > 1 or rust_frontend_path:
            run_multi_api_server(args)
        else:
            # Single API server (this process).
            args.api_server_count = None
            uvloop.run(run_server(args))

run_server 建 AsyncLLM(launchers/api_server/entry.py#L95)。AsyncLLM 的构造函数里依次建三样东西:把输入变成 EngineCoreRequest 的 InputProcessor(async_llm.py#L161),把引擎输出变成 RequestOutput 的 OutputProcessor(#L171),和连接 EngineCore 的客户端(#L184)。

客户端先 bind 两个套接字,再拉起 EngineCore 子进程:ROUTER 用来发请求(core_client.py#L663),PULL 用来收输出(#L668),然后 launch_core_engines(#L682)。子进程的入口是 EngineCoreProc.run_engine_core(vllm/v1/engine/utils.py#L283)。

单卡时执行器是 uni(vllm/config/parallel.py#L1079),模型就加载在 EngineCore 进程里。张量并行大于 1 时换成多进程执行器,每张卡一个 worker 进程,EngineCore 通过它们驱动 GPU;这部分留给 05。

HTTP 进来

路由在 chat_completion/api_router.py#L41,交给 OpenAIServingChat.create_chat_completion。流式请求最后包成 StreamingResponse,类型 text/event-stream(#L77)。

模板与分词:放在线程池里

_create_chat_completion 先调 render_chat_request(serving.py#L308),由 OnlineRenderer 套聊天模板、分词。这两步是阻塞的 CPU 活,renderer 用 make_async 把它们丢进一个线程池,不占事件循环:

vllm/renderers/base.py#L142

        pool_workers = config.model_config.renderer_num_workers
        self._executor = _SwappableExecutor(max_workers=pool_workers)
        # ...
        self._tokenize_prompt_async = make_async(
            self._tokenize_prompt, executor=self._executor
        )

HF 渲染器套模板也走同一个池(renderers/hf.py#L965)。池的大小 renderer_num_workers 默认 1(vllm/config/model.py#L408)。多模态预处理另有一个单线程池(base.py#L148),免得分词排在图片处理后面。

交给引擎:一次 ZMQ 发送

渲染出 token ids 后,engine_client.generate(serving.py#L389)进入 AsyncLLM.add_request(async_llm.py#L388):

  1. InputProcessor.process_inputs 把已渲染的输入和采样参数变成 EngineCoreRequest(#L474)。
  2. 在 OutputProcessor 里登记这个请求,挂上一个输出队列 RequestOutputCollector。
  3. 调客户端的 add_request_async(core_client.py#L1296)。

发送本身只有几行:

vllm/v1/engine/core_client.py#L1238

    def _send_input(self, request_type, request, engine=None):
        # ...
        message = (request_type.value, *self.encoder.encode(request))
        return self._send_input_message(message, engine)
    # ...
        return self.input_socket.send_multipart((engine,) + message, copy=False)

self.encoder 是 MsgpackEncoder(#L713),底下是 msgspec 的 msgpack(serial_utils.py#L135)。多帧消息的第一帧是 EngineCore 的身份,ROUTER 按它路由;数据并行时同一个 ROUTER 后面挂多个 EngineCore,就靠这一帧选。

EngineCore:两个 IO 线程夹着一个忙循环

EngineCore 启动时开两个守护线程,注释写明了用意:

vllm/v1/engine/core.py#L1212

            # Background Threads and Queues for IO. These enable us to
            # overlap ZMQ socket IO with GPU since they release the GIL,
            # and to overlap some serialization/deserialization with the
            # model forward pass.

输入线程(core.py#L1831)用 DEALER 连到 API server 的 ROUTER(#L1849),收到 ADD 就解码,再调 preprocess_add_request 把它变成调度器用的 Request(#L1089)。前缀缓存默认开(vllm/config/cache.py#L141),这一步顺带算好 prompt 每个块的哈希,所以哈希也在输入线程里、和 GPU 步并行。最后放进 input_queue(#L1931)。

忙循环在主线程:

vllm/v1/engine/core.py#L1547

    def run_busy_loop(self):
        """Core busy loop of the EngineCore."""
        while self._handle_shutdown():
            # 1) Poll the input queue until there is work to do.
            self._process_input_queue()
            # ...
            # 2) Step the engine core and return the outputs.
            self._process_engine_step()

每一步调 step_fn。它是 step 还是 step_with_batch_queue,取决于批队列是否存在(#L242)。单卡默认开异步调度,此时 max_concurrent_batches 是 2(vllm/config/vllm.py#L577),于是走 step_with_batch_queue(core.py#L698):排好下一批就先发出去,不等上一批的结果,让 CPU 上的调度和 GPU 上的前向重叠。两种 step 的骨架相同:

vllm/v1/engine/core.py#L667

        scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
        future = self.model_executor.execute_model(scheduler_output, non_block=True)
        # ...
                model_output = self.model_executor.sample_tokens(grammar_output)
        # ...
        engine_core_outputs = self.scheduler.update_from_output(
            scheduler_output, model_output
        )

schedule 决定这一步算哪些请求的哪些 token、分哪些 KV 块(03、02 部分);execute_model 和 sample_tokens 跑前向和采样;update_from_output 把新 token 记到请求上,判断哪些请求结束。产出放进 output_queue(#L1609)。

输出线程(#L1933)从 output_queue 取,编码成 msgpack,经 PUSH(#L1952)发回。编码缓冲区会复用,等 ZMQ 发完再回收。

注意 EngineCore 发回的是 token id,不是文本。反分词不在这个进程里。

回到 API server:反分词和流式返回

API server 里有两个后台任务接力。第一个从 PULL 套接字收帧、解码,放进一个 asyncio 队列(core_client.py#L1171)。第二个是 AsyncLLM 的 output_handler(async_llm.py#L845),从队列取一批输出交给 OutputProcessor.process_outputs(#L864),按 VLLM_V1_OUTPUT_PROC_CHUNK_SIZE(默认 128,envs.py#L174)切块,块之间让出事件循环。

process_outputs 对每个请求做增量反分词并检查停止串:

vllm/v1/engine/output_processor.py#L766

                # 2) Detokenize the token ids into text and perform stop checks.
                num_prev_tokens = req_state.detokenizer.num_output_tokens()
                stop_string = req_state.detokenizer.update(
                    new_token_ids, finish_reason == FinishReason.STOP
                )

快速分词器走 HF tokenizers 的 DecodeStream(detokenizer.py#L195)。命中停止串的请求,API server 回头给 EngineCore 发一个 ABORT。反分词这一步在事件循环线程上同步执行,不进线程池。

结果放进请求自己的 RequestOutputCollector(output_processor.py#L802)。AsyncLLM.generate 从队列取出并 yield(async_llm.py#L774),chat_completion_stream_generator(serving.py#L466)把它拼成 OpenAI 格式的 SSE 块,工具调用和推理内容的解析也在这里,最后发 data: [DONE](#L933)。

每一段在哪、是什么语言

段进程 / 线程源文件默认VLLM_USE_RUST_FRONTEND=1
HTTP 进来API server / 事件循环entrypoints/openai/chat_completion/api_router.pyPythonRust(axum)
模板与分词API server / 渲染线程池renderers/base.py、renderers/hf.pyPythonRust
生成 EngineCoreRequestAPI server / 事件循环v1/engine/input_processor.py、async_llm.pyPythonRust
发请求ROUTER → DEALERv1/engine/core_client.py → v1/engine/core.pyPython,msgpackRust 发、Python 收,msgpack 不变
前缀块哈希EngineCore / 输入线程v1/engine/core.pyPythonPython
调度、KV 块分配EngineCore / 忙循环v1/core/sched/、v1/core/kv_cache_manager.pyPythonPython
前向、采样EngineCore / 忙循环(单卡)v1/worker/Python 调 CUDAPython 调 CUDA
发输出PUSH → PULLv1/engine/core.py → v1/engine/core_client.pyPython,msgpackPython 发、Rust 收
反分词、停止串API server / 事件循环v1/engine/output_processor.py、detokenizer.pyPythonRust
工具 / 推理解析、SSEAPI server / 事件循环entrypoints/openai/chat_completion/serving.pyPythonRust

打开 Rust 前端之后

VLLM_USE_RUST_FRONTEND 默认是 0(envs.py#L1475)。设为 1 后,serve 不再走单进程分支,而走 run_multi_api_server(serve.py#L151)。如果同时给了 --api-server-count 大于 1,会打一条警告并改成 1(serve.py#L131)。

这条路上,Python 主进程先 bind 引擎用的 ZMQ 监听套接字(serve.py#L333),拉起 EngineCore(#L336),再用 RustFrontendProcessManager 以 subprocess.Popen 起子进程(#L354)拉起 vllm-rs,把 HTTP 监听套接字和两个 ZMQ 监听套接字的文件描述符传过去:

vllm/v1/utils.py#L304

        listen_fd = sock.fileno()
        input_fd = input_listener.socket.fileno()
        output_fd = output_listener.socket.fileno()
        # ...
            "frontend",
            "--listen-fd",
            str(listen_fd),
            "--input-listener-fd",
            str(input_fd),
            "--output-listener-fd",
            str(output_fd),

Rust 一侧接过这两个描述符,在上面建 ROUTER 和 PULL(rust/src/engine-core-client/src/transport.rs#L358、#L364),消息用 rmp-serde 编码成同样的 msgpack。EngineCore 看到的套接字类型和消息格式都没变,所以它一行不改。

flowchart LR
  client(["HTTP 客户端"])
  sup["vllm serve 主进程(Python)<br/>bind 监听套接字<br/>拉起子进程后只看守"]
  subgraph RS["vllm-rs frontend 进程(Rust)"]
    http["HTTP(axum)、模板、分词<br/>反分词、解析、SSE"]
    rr["ROUTER / PULL<br/>接管继承来的 fd"]
  end
  subgraph CORE["EngineCore 进程(Python + CUDA,不变)"]
    core["输入线程 / 忙循环 / 输出线程"]
  end
  sup -.->|"Popen,pass_fds 传 fd"| RS
  sup -.->|"拉起"| CORE
  client <-->|"HTTP / SSE"| http
  http <--> rr
  rr <-->|"ZMQ + msgpack"| core

换掉的是表里标「Rust」的五段:HTTP、模板与分词、生成请求、反分词、解析与 SSE。不变的是 ZMQ 边界以内的一切:调度、KV、前向、采样。Rust 前端只注册了 completions、chat completions、models、tokenize / detokenize 和健康、指标等接口(rust/src/server/src/routes.rs#L111),/v1/responses、embeddings 等不在其中。它和 SGLang 的做法有什么不同、覆盖差多少,见 两种 Rust 前端。两种前端在同一模型上的吞吐和延迟对比,待实测(C3-2)。

小结

  • 单卡默认两个进程:API server(启动进程本身,Python)和 EngineCore(子进程,Python + CUDA);单卡执行器是 uni,模型就在 EngineCore 里。
  • 请求经 API server 的 ROUTER 到 EngineCore 的 DEALER,输出经 PUSH 回到 PULL,消息是 msgspec 的 msgpack;EngineCore 返回 token id,不返回文本。
  • API server 里,模板和分词在默认 1 个线程的渲染池里做,反分词和停止串在事件循环里做,输出处理按 128 个请求一块让出事件循环。
  • EngineCore 里,输入、输出各一个 IO 线程,和忙循环之间用队列交接;前缀块哈希在输入线程里算;单卡默认开异步调度,批队列深度 2,调度和前向重叠。
  • VLLM_USE_RUST_FRONTEND=1:主进程退成看守,vllm-rs 继承 HTTP 和 ZMQ 监听描述符,接管 HTTP 到 SSE 的五段;EngineCore 不变;只覆盖 completions / chat 等接口,API server 只能有一个。

💬 关联讨论

发起讨论
加载中...