LLMEngine : la coquille synchrone du moteur v1
Responsabilités
Dans l'architecture v1, LLMEngine est la fine coquille d'entrée pour les API synchrones (LLM.generate, LLM.chat). Elle ne lance pas de forward, n'ordonnance pas, n'alloue pas de KV cache ; elle se contente de coller les trois ensemble : InputProcessor transforme le prompt entrant en EngineCoreRequest, OutputProcessor traduit les EngineCoreOutputs en RequestOutput, et le vrai travail est confié à EngineCore. La dernière ligne de __init__, self.engine_core = EngineCoreClient.make_client(...) (llm_engine.py:105-111), est le vrai point d'entrée du cœur du moteur (EngineCore) — make_client choisit parmi InprocClient, SyncMPClient et AsyncMPClient selon qu'on est multi-processus ou asyncio.
L'intérêt de cette couche est de préserver l'usage synchrone LLMEngine.add_request() / .step() de l'ère v0 : en mode in-process, InprocClient détient directement l'instance d'EngineCore (core_client.py:286-292), get_output() appelle step_fn() en place, front et back partagent la mémoire, pas de ZMQ, pas de busy loop ; en mode multi-processus, SyncMPClient lance un EngineCoreProc en arrière-plan (core.py:896-897), LLMEngine expose la même API synchrone, simplement pontée par ZMQ en dessous. C'est pourquoi LLMEngine est très peu de code (448 lignes) : beaucoup de méthodes ne font que transmettre l'appel à engine_core (sleep/wake, profile, lora, reset_prefix_cache sont toutes des transmissions d'une ligne).
Motivation de conception
- Préserver l'API synchrone v0 : la classe
LLMexpose les méthodes synchronesgenerate/chat, et en interneLLMEngine.add_request()+LLMEngine.step()en boucle (llm_engine.py:296-334) ; pas besoin d'asyncio pour faire tourner v1. C'est la clé pour que v1 remplace v0 sans casser les workflows d'inférence offline. - Trois backends, une seule interface : la sélection 3-en-1 de
make_client(core_client.py:83-105) fait qu'aucune méthode deLLMEnginen'a à se soucier de savoir si on est in-process, multi-processus synchrone ou multi-processus asynchrone ; il suffit d'avoir une instance d'EngineCoreClient, et le backend est choisi à la construction. - Transformation entrée/sortie laissée à la couche shell :
InputProcessor(input_processor.py:36) gère le multimodal (multimodal), la tokenization, la priority, les trace_headers, LoRA ;OutputProcessor(output_processor.py:417) gère la détokenization, le découpage en streaming, le matching de stop string, et la conversion desEngineCoreOutputsenRequestOutput. Ces deux salles de données sales restent dans la couche shell ; EngineCore ne voit que les structures puresEngineCoreRequest/EngineCoreOutputs. - Échantillonnage multiple
n>1: quandSamplingParams.nest supérieur à 1,add_requestutiliseParentRequest(parallel_sampling.py:13) pour dérouler une requête parent en n sous-requêtes poussées dans EngineCore et OutputProcessor (llm_engine.py:279-294) ; la couche output réagrège ensuite les flux de tokens des sous-requêtes en une requête parent. - Filet de sécurité à la sortie du processus : quand
multiprocess_mode=False, un finalizer à weak ref sur le modèle driver est posé (llm_engine.py:129-133) ; à la GC deLLMEngine, il appelle_cleanup_instance_cachespour libérer la mémoire GPU retenue par les byte code hooks.
Fichiers clés
LLMEngine class:48-49—class LLMEngine:, docstring auto-proclaméeLegacy LLMEngine for backwards compatibility..LLMEngine.__init__:51-141— assemble renderer / InputProcessor / OutputProcessor / EngineCoreClient, puis lance StatLoggerManager et le cleanup finalizer.make_client appel:105-111—EngineCoreClient.make_client(multiprocess_mode=..., asyncio_mode=False, ...)choisit le backend.from_vllm_config:143-158— construit depuisVllmConfig;multiprocess_modeest décidé parenvs.VLLM_ENABLE_V1_MULTIPROCESSING.add_request:218-294— valide request_id,input_processor.process_inputs, branchementParentRequestsin>1, puisengine_core.add_request.step:296-334—engine_core.get_output()→output_processor.process_outputs→abort_requests→logger_manager.record.abort_request:212-216— marque d'abord dans OutputProcessor, puis laisse EngineCore retirer la requête du scheduler.sleep / wake_up:361-376— sleep/wake traversant renderer + engine_core, StatLoggerManager enregistre l'état sleep.do_log_stats_with_interval:394-401— utiliseVLLM_LOG_STATS_INTERVALpour throttler les logs et éviter un flush à chaque step._get_driver_model_for_cleanup:431-434— suit la chaînemodel_executor.driver_worker.model_runner.modelpour récupérer le modèle driver, à des fins de libération mémoire par le finalizer.
Flux de données
Une inférence synchrone, c'est for req in requests: add_request(...) puis while has_unfinished_requests(): step(). La structure de step() est directe : tirer un batch d'EngineCoreOutputs depuis engine_core, le passer à OutputProcessor pour le traduire, renvoyer au abort les requêtes terminées en avance par stop string, et au passage enregistrer des statistiques.
# vllm/v1/engine/llm_engine.py L296-L314
def step(self) -> list[RequestOutput | PoolingRequestOutput]:
if self.should_execute_dummy_batch:
self.should_execute_dummy_batch = False
self.engine_core.execute_dummy_batch()
return []
# 1) Get EngineCoreOutput from the EngineCore.
with record_function_or_nullcontext("llm_engine step: get_output"):
outputs = self.engine_core.get_output()
# 2) Process EngineCoreOutputs.
with record_function_or_nullcontext("llm_engine step: process_outputs"):
iteration_stats = IterationStats() if self.log_stats else None
processed_outputs = self.output_processor.process_outputs(
outputs.outputs,
engine_core_timestamp=outputs.timestamp,
iteration_stats=iteration_stats,
)
self.output_processor.update_scheduler_stats(outputs.scheduler_stats)Il y a ensuite des étapes 3 et 4 (llm_engine.py:317-332) : l'étape 3 passe processed_outputs.reqs_to_abort à engine_core.abort_requests, parce que les stop strings ne sont visibles qu'après détokenization, EngineCore lui-même n'en sait rien ; l'étape 4, si logger_manager est non vide et qu'il y a des scheduler_stats, appelle record + do_log_stats_with_interval.
add_request prend la branche fan-out quand n>1 (llm_engine.py:279-294) :
# vllm/v1/engine/llm_engine.py L279-L294
# Fan out child requests (for n>1).
parent_req = ParentRequest(request)
for idx in range(n):
request_id, child_params = parent_req.get_child_info(idx)
child_request = request if idx == n - 1 else copy(request)
child_request.request_id = request_id
child_request.sampling_params = child_params
# Make a new RequestState and queue.
self.output_processor.add_request(
child_request, prompt_text, parent_req, idx
)
# Add the request to EngineCore.
self.engine_core.add_request(child_request)
return req_idLe dernier child réutilise l'objet request pour économiser une copie ; l'ID de la requête parent est produit par ParentRequest.get_child_info(idx), et l'OutputProcessor, qui récupère la référence parent et l'idx, peut ensuite réagréger les sorties des children.
Limites et échecs
- EngineCoreRequest est déprécié : si
add_requestreçoit directement unEngineCoreRequest, on déclenche unwarning_oncede dépréciation indiquant que depuis v0.18 il faut utiliserRenderer.render_cmpl()/render_chat()(llm_engine.py:235-248) ; si lerequest_idpassé ne correspond pas àEngineCoreRequest.request_id, c'est ce dernier qui l'emporte, le premier est ignoré. - Dummy batch en mode DP : en mode data parallel external launcher, si un rank n'a rien à faire mais que d'autres tournent encore,
has_unfinished_requests_dpmetshould_execute_dummy_batchà True (llm_engine.py:197-203) ;step()commence alors par lancer un dummy batch pour occuper la place (llm_engine.py:297-300) afin de ne pas bloquer la communication collective. model_executorn'est accessible qu'en mode in-process : uniquement dans la brancheif not multiprocess_mode:on aself.model_executor = self.engine_core.engine_core.model_executor(llm_engine.py:123-125) ; en mode multi-processus cet objet n'est pas accessible depuis le frontal, donc tout code qui touche àmodel_executordoit d'abord s'assurer quemultiprocess_mode=False.- Throttling des logs de stats :
do_log_stats_with_intervalcontrôle l'intervalle minimal viaVLLM_LOG_STATS_INTERVAL(llm_engine.py:394-401) ;_last_log_timeest une instance à initialisation paresseuse au premier accès, ce qui contourne la nécessité de l'initialiser dans__init__. - sleep couplé au renderer :
sleep(level>=1)nettoie d'abord le cache multimodal du renderer avant d'appelerengine_core.sleep(llm_engine.py:361-367) ; après un wake_up, les embeddings multimodaux peuvent être invalides et doivent être recalculés par le renderer. - Le backend d'EngineCore est fixé par une variable d'env : dans
from_vllm_config,multiprocess_mode=envs.VLLM_ENABLE_V1_MULTIPROCESSING(llm_engine.py:157) ;from_engine_argsle re-définit une fois (llm_engine.py:174-176), donc l'option multi-processus est figée à la configuration, pas de bascule à runtime.
Résumé
LLMEngine est la coquille fine du chemin synchrone v1 : valider les entrées, gérer le multimodal et le streaming via InputProcessor / OutputProcessor, choisir le backend via EngineCoreClient.make_client, puis boucler sur add_request + step. Elle délègue tout le gros travail à EngineCore (en in-process directement, sinon via ZMQ), et ne conserve que les affaires de la couche shell : compat v0, fan-out ParentRequest, throttling des stats, couplage sleep/wake. Pour descendre : /engine/engine-core pour l'assemblage du cœur, /engine/engine-core-proc pour l'implémentation du processus arrière ZMQ, les trois implémentations client à /client/inproc-mp, et l'intérieur du scheduler à /scheduler/scheduler.
Voir la documentation officielle : Documentation vLLM · README