Skip to content

EngineCore:v1 エンジンの真のコア

源码版本v0.25.1

役割

LLMEngine は v1 アーキテクチャでは単なる薄殻です。InputProcessor で外部から入ってきた prompt を EngineCoreRequest に加工し、OutputProcessorEngineCoreOutputsRequestOutput に翻訳し、本当の仕事はすべて EngineCore に投げます。LLMEngine.__init__ の中の self.engine_core = EngineCoreClient.make_client(...)(llm_engine.py:105-111)こそがエンジンコア (EngineCore) の本当の入り口です。

エンジンコア (EngineCore) 自身は 3 つのものをまとめます:model_executor(実行器、つまり executor_class のインスタンス、ワーカーをまたいだ前向き計算を担当)、scheduler(スケジューラ、waiting/running キュー、連続バッチング (continuous batching)、プリエンプションを担当)、KVCacheManager(KV キャッシュマネージャ、_initialize_kv_caches でプロファイリング後に各 GPU が KV cache に使えるメモリ量を決定)。コンストラクタでこの 3 点を初期化し(core.py:122-158)、その後 step 関数ポインタを self.step または self.step_with_batch_queue に定め(core.py:221-223)、以降のループで繰り返し呼び出します。

step() がやることは 1 つだけです。スケジュール、前向き計算、出力の回収(core.py:479-508)です。具体的には scheduler.schedule()SchedulerOutput を取り出し、model_executor.execute_model(..., non_block=True) が即座に future を返し、その隙にスケジューラが grammar bitmask を計算し、future の result() が取れたら scheduler.update_from_output() に渡して token をリクエストに書き戻し、EngineCoreOutputs を生成します。これが v1 の 1 イテレーションで、すべての上位 API(LLM.generateAsyncLLMEngine.generate)は最終的にこの数十行に行き着きます。

設計動機

なぜ LLMEngine / EngineCore / EngineCoreProc / EngineCoreClient を 4 層に分けるのか?

  • プロセス分離:EngineCoreProcEngineCore のサブクラス(core.py:896-897)で、独立したバックグラウンドプロセスに投げて走らせます。フロントエンドの Python がクラッシュしても、前向き計算中の worker を道連れにせず、worker の OOM が API プロセスを一緒に巻き込むこともありません。
  • ZMQ の疎結合:前後端は ZMQ socket + 2 つの queue.Queue で橋渡し(core.py:915-916)します。ZMQ は socket IO の際に GIL を解放するため、GPU forward と本当に重ねられます。入出力はそれぞれ独立スレッド(core.py:980-1001)を持ち、シリアライズ/デシリアライズも forward の裏に隠せます。
  • 同プロセスの兜底:多プロセスを有効にしないとき InprocClient は直接 self.engine_core = EngineCore(...)(core_client.py:286-287)で構築し、get_output() はその場で step_fn() を呼び出し、v0 流の LLMEngine.add_request() / .step() という同期用法を残します。
  • 非同期入口:AsyncMPClient は多プロセス + asyncio で走ります。サーバーシナリオでは AsyncLLM がこれを通じて EngineCore を await 可能なオブジェクトに包み、フロントエンドをブロックしません。
  • DP 拡張:run_engine_core は複数 DP rank の場合、各 rank に 1 つの EngineCoreProc を起動し(core.py:1192-1200)、MoE は DPEngineCoreProc を使い、非 MoE は独立した DP=1 として扱い、フロントエンドは DPLBAsyncMPClient でロードバランスします。

主要ファイル

データフロー

リクエストがクライアントから入ってくると、まず LLMEngine で InputProcessor により EngineCoreRequest に加工され、その後 engine_core.add_request(...) で投げ込まれます。多プロセスモードではこの呼び出しは最終的に MPClient でシリアライズされ、ZMQ 経由で EngineCoreProc の input socket に送られ、process_input_sockets スレッドがデコード後に put_nowaitinput_queue に入れます(core.py:915-916)。メインループ run_busy_loop が目を覚まし、キューから取り出して _handle_client_request でディスパッチ(core.py:1372-1405)し、ADD タイプは最終的に self.scheduler.add_request(request)(core.py:372-403)に到達します。

python
# vllm/v1/engine/core.py L1259-L1267
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()

    raise SystemExit

_process_engine_step(core.py:1300-1317)は step_fn() が返す outputs を取り出し、1 件ずつ output_queue.put_nowait(output) します。output スレッドがそれらを ZMQ でフロントエンドに送り返し、MPClient.get_output() がデシリアライズして LLMEngine.step()engine_core.get_output() 呼び出し(llm_engine.py:302-314)に渡し、最後に OutputProcessor.process_outputs が token ストリームを RequestOutput に組み直します。

step 関数本体は次の 3 つの部分からなります:

python
# vllm/v1/engine/core.py L488-L508
if not self.scheduler.has_requests():
    return {}, False
scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
future = self.model_executor.execute_model(scheduler_output, non_block=True)
grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output)
with (
    self.log_error_detail(scheduler_output),
    self.log_iteration_details(scheduler_output),
):
    model_output = future.result()
    if model_output is None:
        model_output = self.model_executor.sample_tokens(grammar_output)

# Before processing the model output, process any aborts that happened
# during the model execution.
self._process_aborts_queue()
engine_core_outputs = self.scheduler.update_from_output(
    scheduler_output, model_output
)

non_block=True により execute_model は即座に future を返し、メインスレッドはこの隙に grammar bitmask を計算します。これが v1 がスケジューラと worker を本当に並行させるための鍵となる動作です。

境界と失敗

  • EngineCoreProc プロセスのクラッシュ:run_engine_coreexcept Exceptionengine_core._send_engine_dead() を呼び(core.py:1229-1235)、output_queueENGINE_CORE_DEAD バイト列を投げ込み(core.py:1470-1474)、フロントエンドの MPClient は受け取った後にエラーを投げ、無限待ちしません。
  • Executor 失敗コールバック:構築時に executor_fail_callbackmodel_executor に登録(core.py:124-125)します。worker 内部でエラーが発生したとき、コールバックが input_queueEXECUTOR_FAILED を入れ、メインループの _handle_client_request が取り出したら raise RuntimeError("Executor failed.")(core.py:1400-1401)します。
  • Shutdown モード:shutdown_timeout == 0 は abort モードで、実行中の全リクエストを即座に FINISHED_ABORTED にします。0 以外は drain モードで、実行中のリクエストすべての完了を待ちます(core.py:1329-1360)。shutdown 中に新しく入ってきた ADD と UTILITY は明示的に拒否されます(core.py:1407-1432)。
  • 非因果注意レイヤー:一部 attention レイヤー(Prefix LM など)は non_causal=True とマークされており、_initialize_kv_caches で検出すると chunked prefill と prefix caching を強制的に無効化(core.py:255-269)します。さもなくば KV cache が汚染されます。
  • batch_queue が CPU を譲る:ある step で前向き計算を走らせなかったが scheduler に未完了リクエストがあるとき(例:リモート KV 転送待ち)、time.sleep(0.001) で自発的に GIL を譲り(core.py:1311-1315)、バックグラウンド転送スレッドが餓死しないようにします。
  • 同プロセスモードには busy loop がない:InprocClient.get_output は直接同期的に step_fn を呼び(core_client.py:289-292)、run_busy_loop を走らせません。これは同プロセスモードでは input/output スレッドがなく、add_request は同期的に scheduler に入ることを意味します。

まとめ

エンジンコア (EngineCore) は v1 のエンジンです。スケジューラ (scheduler)、実行器 (executor)、KV キャッシュ (KV cache) の 3 点を組み立て、step() 一手でスケジュール + 前向き計算 + 回収を繰り返します。サブクラス EngineCoreProc はそれを独立プロセスに放り込み、ZMQ + 2 つの queue で前後端を橋渡しし、GPU 前向き計算とネットワーク IO を本当に重ね合わせます。フロントエンドがどのクライアント(InprocClient、MPClient、AsyncMPClient)を使うかで同期になるか非同期になるかが決まりますが、エンジンコアのレイヤーはすべてのフロントエンドに対して同じコードです。さらに中を見るには /engine/llm-engine で LLMEngine という薄殻が何を包んでいるか、/engine/engine-core-proc で ZMQ のレイヤーを深掘り、クライアントの 3 つの実装は /client/inproc-mp、スケジューラ内部は /scheduler/scheduler を参照してください。

公式資料:vLLM 文档 · README