EngineCore : le vrai cœur du moteur v1
Responsabilités
Dans l'architecture v1, LLMEngine n'est qu'une fine coquille : elle utilise InputProcessor pour transformer le prompt entrant en EngineCoreRequest, et OutputProcessor pour traduire les EngineCoreOutputs en RequestOutput ; tout le vrai travail est confié à EngineCore. Cette ligne dans LLMEngine.__init__ — self.engine_core = EngineCoreClient.make_client(...) (llm_engine.py:105-111) — est le point d'entrée réel du cœur du moteur (EngineCore).
Le cœur du moteur (EngineCore) assemble trois choses : model_executor (l'exécuteur, instance de executor_class, responsable du forward inter-workers), scheduler (l'ordonnanceur, responsable des files waiting/running, du batching continu (continuous batching) et de la préemption), et KVCacheManager (le gestionnaire de KV cache, qui détermine via _initialize_kv_caches la quantité de mémoire GPU allouable au KV cache après profiling). Le constructeur initialise ces trois composants (core.py:122-158), puis fixe le pointeur de fonction step à self.step ou self.step_with_batch_queue (core.py:221-223) ; la boucle ultérieure l'appellera en boucle.
step() fait une seule chose : ordonnancer, lancer le forward, récolter les sorties (core.py:479-508). Concrètement : scheduler.schedule() produit un SchedulerOutput, model_executor.execute_model(..., non_block=True) renvoie immédiatement un future, pendant ce temps le scheduler calcule le grammar bitmask, et dès que result() est prêt, scheduler.update_from_output() réécrit les tokens dans la requête et produit les EngineCoreOutputs. C'est une itération v1 : toutes les API du haut (LLM.generate, AsyncLLMEngine.generate) finissent par atterrir sur ces quelques dizaines de lignes.
Motivation de conception
Pourquoi séparer LLMEngine / EngineCore / EngineCoreProc / EngineCoreClient en quatre couches ?
- Isolation de processus :
EngineCoreProcest une sous-classe d'EngineCore(core.py:896-897) qu'on lance dans un processus arrière séparé ; si le Python frontal crash, il n'emporte pas les workers déjà en plein forward, et un OOM worker ne tue pas le processus d'API. - Découplage ZMQ : front et back sont reliés par un socket ZMQ + deux
queue.Queue(core.py:915-916) ; ZMQ libère le GIL pendant le socket IO, ce qui permet un vrai chevauchement avec le forward GPU ; l'entrée et la sortie ont chacune leur thread dédié (core.py:980-1001), et la sérialisation/désérialisation peut être masquée derrière le forward. - Fallback in-process : sans multi-processus,
InprocClientinstancie directementself.engine_core = EngineCore(...)(core_client.py:286-287),get_output()appellestep_fn()en place — on préserve l'usage synchroneLLMEngine.add_request()/.step()de l'ère v0. - Entrée asynchrone :
AsyncMPClientcombine multi-processus + asyncio ; côté serveur,AsyncLLMl'utilise pour exposer EngineCore comme un objetawaitable, sans bloquer le frontal. - Extension DP :
run_engine_corelance unEngineCoreProcpar rank DP quand il y en a plusieurs (core.py:1192-1200) ; MoE utiliseDPEngineCoreProc, non-MoE est traité comme un DP=1 indépendant, et le frontal est équilibré parDPLBAsyncMPClient.
Fichiers clés
EngineCore class:96-97—class EngineCore:, docstring indiqueInner loop of vLLM's Engine.EngineCore.__init__:99-237— assemble model_executor,_initialize_kv_caches, Scheduler, batch_queue, prefix caching hasher._initialize_kv_caches:240-295— récupèreget_kv_cache_specs, profile la mémoire, assemble leKVCacheConfig.EngineCore.step:479-508— les 30 lignes centrales : schedule + execute_model + update_from_output.step_with_batch_queue:519-535— cas PP : utilise une batch queue pour croiser plusieurs batches en asynchrone, priorité au remplissage de la file.EngineCoreProc class:896-897—ZMQ-wrapper for running EngineCore in background process.run_engine_core:1154-1224— point d'entrée du processus, lance EngineCoreProc, enregistre SIGTERM/SIGINT, appellerun_busy_loop.run_busy_loop:1259-1267—while self._handle_shutdown(): _process_input_queue(); _process_engine_step()._process_engine_step:1300-1317— appellestep_fn(), pousse les outputs dansoutput_queue, appellepost_step.LLMEngine.make_client:105-111— LLMEngine choisit Inproc/MP/AsyncMP viaEngineCoreClient.make_client.EngineCoreClient.make_client:83-105— trois choix : multi-processus synchrone / multi-processus asynchrone / in-process.
Flux de données
Une requête entre côté client, est transformée par InputProcessor dans LLMEngine en EngineCoreRequest, puis engine_core.add_request(...) la pousse. En mode multi-processus, cet appel est sérialisé par MPClient et envoyé via ZMQ au socket input du EngineCoreProc ; le thread process_input_sockets le décode et fait put_nowait dans input_queue (core.py:915-916). La boucle principale run_busy_loop s'éveille, retire l'élément de la file, le passe à _handle_client_request pour dispatch (core.py:1372-1405) ; le type ADD finit dans self.scheduler.add_request(request) (core.py:372-403).
# 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) prend les outputs renvoyés par step_fn() et fait output_queue.put_nowait(output) pour chacun. Le thread output les renvoie via ZMQ au frontal, où MPClient.get_output() les désérialise et les donne à l'appel engine_core.get_output() de LLMEngine.step() (llm_engine.py:302-314) ; enfin OutputProcessor.process_outputs réassemble le flux de tokens en RequestOutput.
Le corps de la fonction step se résume à ces trois blocs :
# 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_configs = self.scheduler.update_from_output(
scheduler_output, model_output
)non_block=True fait qu'execute_model renvoie immédiatement un future, pendant que le thread principal en profite pour calculer le grammar bitmask — c'est le geste clé qui permet en v1 de vraiment paralléliser l'ordonnancement et le worker.
Limites et échecs
- Crash du processus EngineCoreProc :
run_engine_core, dans sonexcept Exception, appelleengine_core._send_engine_dead()(core.py:1229-1235) qui pousse la bytestringENGINE_CORE_DEADdansoutput_queue(core.py:1470-1474) ; leMPClientfrontal lève alors une erreur au lieu d'attendre indéfiniment. - Callback d'échec d'Executor : à la construction,
executor_fail_callbackest enregistré auprès demodel_executor(core.py:124-125) ; quand le worker remonte une erreur, le callback pousse unEXECUTOR_FAILEDdansinput_queue, et la boucle principale, dans_handle_client_request, lèveraise RuntimeError("Executor failed.")(core.py:1400-1401). - Mode shutdown :
shutdown_timeout == 0est le mode abort, qui marque immédiatement toutes les requêtes en vol commeFINISHED_ABORTED; non nul, c'est le mode drain, qui attend la fin de toutes les requêtes en vol (core.py:1329-1360). Les ADD et UTILITY qui arrivent pendant le shutdown sont explicitement rejetés (core.py:1407-1432). - Couches d'attention non causales : certaines couches attention (Prefix LM par ex.) sont marquées
non_causal=True; détectées dans_initialize_kv_caches, elles forcent la désactivation du chunked prefill et du prefix caching (core.py:255-269), sinon le KV cache serait corrompu. - batch_queue rend la main : si un step ne lance pas de forward mais que le scheduler a encore des requêtes en cours (par ex. en attente d'un transfert KV distant), un
time.sleep(0.001)rend explicitement le GIL (core.py:1311-1315) pour éviter de faire mourir de faim les threads de transfert en arrière-plan. - Pas de busy loop en mode in-process :
InprocClient.get_outputappellestep_fnde façon synchrone directe (core_client.py:289-292) sans lancerrun_busy_loop; en mode in-process il n'y a donc ni thread input ni thread output, etadd_requestentre synchrone dans le scheduler.
Résumé
Le cœur du moteur (EngineCore) est le moteur v1 : il assemble l'ordonnanceur (scheduler), l'exécuteur (executor) et le KV cache (KV cache), et répète inlassablement schedule + forward + récolte via step(). La sous-classe EngineCoreProc le place dans un processus séparé et utilise ZMQ + deux queues pour relier front et back, permettant au forward GPU et à l'IO réseau de vraiment se chevaucher. Le choix du client frontal (InprocClient, MPClient, AsyncMPClient) décide du mode synchrone ou asynchrone, mais la couche cœur reste le même code pour tous. Pour aller plus loin : /engine/llm-engine pour voir ce que la coquille LLMEngine enveloppe, ou /engine/engine-core-proc pour plonger dans la couche ZMQ ; les trois implémentations client sont à /client/inproc-mp, et l'intérieur du scheduler à /scheduler/scheduler.
Voir la documentation officielle : Documentation vLLM · README