EngineCore: el núcleo real del motor en v1
Responsabilidades
LLMEngine en la arquitectura v1 es solo una shell fina: toma un InputProcessor que convierte el prompt entrante en EngineCoreRequest, y un OutputProcessor que traduce EngineCoreOutputs de vuelta a RequestOutput; el trabajo real se lo deja a EngineCore. Esa línea self.engine_core = EngineCoreClient.make_client(...) en LLMEngine.__init__ (llm_engine.py:105-111) es donde entra al tablero el núcleo del motor (EngineCore).
EngineCore arma tres piezas: model_executor (el executor, es decir, la instancia de executor_class, encargada de correr el forward entre workers), scheduler (el planificador, encargado de las colas waiting/running, del batching continuo (continuous batching) y del preempt), y KVCacheManager (el gestor de caché KV, que a través de _initialize_kv_caches determina cuánta VRAM puede destinar cada GPU a la caché KV después del profiling). El constructor inicializa esas tres piezas (core.py:122-158), y luego fija el puntero de la función step a self.step o self.step_with_batch_queue (core.py:221-223); el loop después simplemente la llama una y otra vez.
step() hace una sola cosa: scheduler, forward, recuperar outputs (core.py:479-508). En concreto, scheduler.schedule() devuelve un SchedulerOutput, model_executor.execute_model(..., non_block=True) devuelve de inmediato un future, mientras tanto el scheduler calcula de paso el grammar bitmask; cuando el future hace result(), se lo pasa a scheduler.update_from_output() para escribir el token de vuelta en la request y producir los EngineCoreOutputs. Esa es una iteración de v1: todas las APIs de arriba (LLM.generate, AsyncLLMEngine.generate) terminan cayendo en esas decenas de líneas.
Motivación de diseño
¿Por qué separar LLMEngine / EngineCore / EngineCoreProc / EngineCoreClient en cuatro capas?
- Aislamiento de procesos:
EngineCoreProces una subclase deEngineCore(core.py:896-897); al meterlo en un proceso en segundo plano independiente, si el Python del frontend se cae no arrastra a los workers que ya están corriendo forward, y un OOM de un worker no se lleva por delante al proceso de API. - Desacople con ZMQ: frontend y backend se puentean con sockets ZMQ + dos
queue.Queue(core.py:915-916); ZMQ libera el GIL al hacer IO del socket, así que puede solaparse de verdad con el forward en GPU; input y output tienen hilos independientes (core.py:980-1001), y la serialización/deserialización también queda escondida detrás del forward. - Fallback en el mismo proceso: cuando no se usa multiproceso,
InprocClienthace directamenteself.engine_core = EngineCore(...)(core_client.py:286-287);get_output()llama in situ astep_fn(), preservando el uso sincrónico estilo v0LLMEngine.add_request()/.step(). - Entrada async:
AsyncMPClientcombina multiproceso + asyncio; en escenarios de servidor,AsyncLLMlo usa para envolver EngineCore en un objetoawaitable, sin bloquear el frontend. - Extensión DP: cuando hay varios DP rank,
run_engine_corelanza unEngineCoreProcpor rank (core.py:1192-1200); MoE va porDPEngineCoreProc, y lo que no es MoE se trata como DP=1 independiente; el frontend entonces hace balance de carga conDPLBAsyncMPClient.
Archivos clave
EngineCore class:96-97— definición declass EngineCore:, el docstring diceInner loop of vLLM's Engine.EngineCore.__init__:99-237— ensambla model_executor,_initialize_kv_caches, Scheduler, batch_queue, prefix caching hasher._initialize_kv_caches:240-295— obtieneget_kv_cache_specs, hace profiling de VRAM, arma elKVCacheConfig.EngineCore.step:479-508— las 30 líneas centrales: schedule + execute_model + update_from_output.step_with_batch_queue:519-535— en escenarios PP usa un batch queue para solapar de forma async varios lotes, priorizando llenar la cola.EngineCoreProc class:896-897—ZMQ-wrapper for running EngineCore in background process.run_engine_core:1154-1224— entrada del proceso, arranca EngineCoreProc, registra SIGTERM/SIGINT, llamarun_busy_loop.run_busy_loop:1259-1267—while self._handle_shutdown(): _process_input_queue(); _process_engine_step()._process_engine_step:1300-1317— llamastep_fn(), mete outputs enoutput_queue, llamapost_step.LLMEngine.make_client:105-111— LLMEngine elige Inproc/MP/AsyncMP a través deEngineCoreClient.make_client.EngineCoreClient.make_client:83-105— elige entre multiproceso sincrónico / multiproceso async / en el mismo proceso.
Flujo de datos
Una request entra desde el cliente, primero es procesada por InputProcessor en LLMEngine hasta convertirse en EngineCoreRequest, y luego se envía con engine_core.add_request(...). En modo multiproceso, esta llamada termina serializada por MPClient y enviada por ZMQ al socket de input de EngineCoreProc; el hilo process_input_sockets la decodifica y hace put_nowait en input_queue (core.py:915-916). El loop principal run_busy_loop al despertar la saca de la cola y la pasa a _handle_client_request para despacharla (core.py:1372-1405); el tipo ADD finalmente cae en 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) toma los outputs que devuelve step_fn() y hace output_queue.put_nowait(output) uno por uno. El hilo de output los envía de vuelta por ZMQ al frontend, MPClient.get_output() los deserializa y se los entrega a la llamada engine_core.get_output() que hizo LLMEngine.step() (llm_engine.py:302-314); finalmente OutputProcessor.process_outputs recompone el stream de tokens en un RequestOutput.
El cuerpo de la función step es tres bloques:
# 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 hace que execute_model devuelva de inmediato un future; el hilo principal aprovecha ese hueco para calcular el grammar bitmask — esta es la acción clave que en v1 vuelve realmente concurrentes al scheduler y al worker.
Límites y fallos
- Caída del proceso EngineCoreProc:
run_engine_coreenexcept Exceptionllamaengine_core._send_engine_dead()(core.py:1229-1235), metiendo enoutput_queuela cadena de bytesENGINE_CORE_DEAD(core.py:1470-1474); elMPClientdel frontend al recibirla lanza un error, no se queda esperando para siempre. - Callback de fallo del executor: al construir se registra
executor_fail_callbackenmodel_executor(core.py:124-125); cuando un worker falla internamente, el callback meteEXECUTOR_FAILEDeninput_queue, y el loop principal al recibirlo en_handle_client_requestlanzaraise RuntimeError("Executor failed.")(core.py:1400-1401). - Modo shutdown:
shutdown_timeout == 0es modo abort, marca todas las requests en vuelo comoFINISHED_ABORTEDde inmediato; si no es 0 es modo drain, espera a que terminen todas (core.py:1329-1360). Durante el shutdown, las ADD y UTILITY nuevas se rechazan explícitamente (core.py:1407-1432). - Capas de atención no causales: algunas capas de attention (p. ej. Prefix LM) marcan
non_causal=True; al detectarlas en_initialize_kv_cachesse desactivan forzosamente chunked prefill y prefix caching (core.py:255-269), porque si no la caché KV se ensucia. - batch_queue cede la CPU: cuando algún step no ejecuta forward pero el scheduler aún tiene requests sin terminar (por ejemplo, esperando transferencia remota de KV), un
time.sleep(0.001)cede el GIL de forma proactiva (core.py:1311-1315), para no dejar morir de hambre a los hilos de transferencia en segundo plano. - El modo en el mismo proceso no tiene busy loop:
InprocClient.get_outputllama directamente y de forma sincrónica astep_fn(core_client.py:289-292), sin correrrun_busy_loop; eso significa que en modo en el mismo proceso no hay hilos de input/output, y add_request entra al scheduler de forma sincrónica.
Resumen
El núcleo del motor (EngineCore) es el motor de v1: ensambla las tres piezas —planificador (scheduler), executor, caché KV (KV cache)— y con step() repite una y otra vez schedule + forward + recuperación. La subclase EngineCoreProc lo mete en un proceso independiente y puentea frontend y backend con ZMQ + dos queues, para que el forward en GPU y el IO de red se solapen de verdad. Qué cliente se use en el frontend (InprocClient, MPClient, AsyncMPClient) decide si corre sincrónico o asincrónico, pero la capa del núcleo del motor es la misma para todos los frontends. Para ver qué envuelve exactamente la shell de LLMEngine continúa en /engine/llm-engine, o en /engine/engine-core-proc para entrar en la capa ZMQ; las tres implementaciones de cliente están en /client/inproc-mp, y el interior del scheduler en /scheduler/scheduler.