EngineCoreClient: In-Process- und Multiprozess-Client-Pfade
Verantwortung
Das vLLM-Frontend (LLM / AsyncLLM / Serving-Einstieg) greift nicht direkt auf EngineCore zu, sondern über eine Client-Schicht, die Anfragen hineinpusht und Outputs herauszieht. Diese Schicht ist EngineCoreClient; sie vereint die beiden orthogonalen Dimensionen "synchron vs. asynchron" und "In-Process vs. Unterprozess" auf denselben abstrakten Methoden. In vllm/v1/engine/core_client.py liegen alle Implementierungen.
Die zwei wichtigsten Implementierungen sind InprocClient und MPClient. Ersteres konstruiert EngineCore direkt im eigenen Prozess: ein Schritt pro Aufruf, kein Busy Loop, kein ZMQ — im Wesentlichen eine dünne Schale für das v0-artige LLMEngine.add_request()/step(). Letzteres verpackt EngineCore als EngineCoreProc-Unterprozess und sendet/empfängt Anfragen und Outputs über ein Paar ZMQ ROUTER/PULL-Sockets; der aufrufende Frontend-Thread interagiert nur mit den Sockets. MPClient ist die Basisklasse; konkret落地 gibt es SyncMPClient (synchron, mit Hintergrund-Thread, der Frames vom Output-Socket liest) und AsyncMPClient (asyncio, mit Hintergrund-Task, der denselben Socket betreibt); letzteres wird auf einer separaten Seite behandelt.
EngineCoreClient.make_client ist eine statische Factory, die anhand der beiden Booleans multiprocess_mode und asyncio_mode die Implementierung wählt. Die Kombination (asyncio=True, multiprocess=False) wirft direkt NotImplementedError — EngineCore selbst ist nicht asyncio-freundlich; Async muss immer zusammen mit Multiprozess verwendet werden.
Entwurfsmotivation
- Lebenszyklus von EngineCore entkoppeln: Im In-Process ist der Aufruf von EngineCore einfach; will das Frontend aber mehrere EngineCore parallel betreiben (Datenparallel) oder elastisches EP (Rank dynamisch hinzufügen/entfernen), muss EngineCore in einen eigenen Prozess verlagert werden, und das Frontend hält nur noch den Socket.
weakref.finalize(self, self.resources)inMPClient.__init__ist der Kern: Selbst wenn während der Konstruktion eine Exception auftritt, werden der Hintergrund-Unterprozess und der ZMQ-Context durchBackgroundResources.__call__als Fallback eingesammelt, ohne zu leaken. - Synchrone und asynchrone Schnittstellen teilen dasselbe ZMQ-Protokoll:
SyncMPClientnutztqueue.Queue, um Frames vom Output-Socket in den Haupt-Thread zu tragen;AsyncMPClientnutztasyncio.Queue.MPClientteilt sich jedoch Encoder/Decoder, Ready-Handshake, Engine-Monitor und die Future-Registry für Utility. Die Unterschiede stecken in den beiden Unterklassen. sleep(mode="wait")inInprocClientdirekt verboten (InprocClient.sleepL324-L328), weil es im In-Process-Modus keinen Scheduler gibt, der "warten kann, bis Leerlauf, dann sleep"; es geht nurabort. Diese Grenze wird im Unterprozess-Modus unterstützt — der Unterprozess hat seinen eigenen Busy Loop und einen Scheduler-Zustandsautomat.- Utility-Aufrufe über Future-Registry: RPCs wie
collective_rpcodersave_sharded_state, die einen Rückgabewert brauchen, können nicht wieadd_requestals Fire-and-Forget arbeiten.call_utilityerzeugt einecall_id, steckt das Future inself.utility_resultsund wartet, bis das zugehörigeUtilityOutputvom Output-Socket zurückkommt; dann holt_process_utility_outputpercall_iddas Future und ruftset_result. Dieser Mechanismus ist auf dem sync und async Client identisch; nur der Future-Typ unterscheidet sich (concurrent.futures.Futurevsasyncio.Future). - Ausbreitung von Engine-Dead: Wenn der Unterprozess unerwartet stirbt, setzt der Monitor-Thread
BackgroundResources.engine_deadauf True; jeder nachfolgende_send_inputwird vonensure_aliveblockiert und wirftEngineDeadError. Wenn auf der Output-Socket-Seite einENGINE_CORE_DEAD-Einzelframe ankommt, ziehtvalidate_alivedasselbe Flag hoch — Anfrage- und Empfangsseite sehen beide den Tod.
Schlüsseldateien
EngineCoreClient + make_client:71-105— abstrakte Basisklasse und statische Factory; wähltInprocClient/SyncMPClient/AsyncMPClientanhand vonmultiprocess_mode/asyncio_mode.make_async_mp_client:107-132— asynchrone Factory; wählt anhand vondata_parallel_sizeund externem LB zwischenAsyncMPClient/DPAsyncMPClient/DPLBAsyncMPClient.InprocClient.__init__ / get_output:276-292— konstruiert direktEngineCore;get_outputist einfach synchroner Aufruf vonstep_fn()+post_step().InprocClient.add_request / abort / shutdown:297-306— direkte Weiterleitung anself.engine_core, ohne Serialisierung, ohne Socket.InprocClient.sleep lehnt wait ab:324-328— In-Process-Modus unterstützt keinewait-Pause, nurabort.BackgroundResources:370-458— Dataclass +__call__alsweakref.finalize-Callback; schließt einheitlich Sockets / stoppt Tasks / fährt Engine-Manager herunter.validate_alive:454-457— bei Empfang eines einzelnen FramesENGINE_CORE_DEADwirdengine_deadgesetzt undEngineDeadErrorgeworfen.MPClient.__init__:480-649— baut ZMQ-Context/Sockets, optionallaunch_core_engines, führt Ready-Handshake, startet Monitor; rollt bei Fehler zurück.MPClient.shutdown:651-662— nachdetach()des Finalizers Engine-Manager stoppen undBackgroundResourceszur Cleanup ausführen.start_engine_core_monitor:685-712— Daemon-Threadmonitor_engine_liveness; setztengine_dead, wenn die Engine stirbt, und löstself.shutdown()aus._apply_ready_response:714-754— dekodiertEngineCoreReadyResponseund schreibtmax_model_len/num_gpu_blocks/block_sizevom Unterprozess ins Frontend-vllm_configzurück.SyncMPClient.__init__:779-847— bautqueue.Queueund startet den Daemon-Threadprocess_outputs_socket, das Output-Socket + Shutdown-Socket pollt.SyncMPClient.get_output / _send_input / call_utility:849-881— blockierendesoutputs_queue.get(); beim Senden ggf.trackfür MessageTracker, um vorzeitige GC des Tensors zu verhindern.
Datenfluss
SyncMPClient holt Outputs über einen Daemon-Thread, der auf dem ZMQ-output_socket pollt, dekodiert und in eine queue.Queue steckt. Der Frontend-Thread ruft get_output() auf und führt einfach queue.get() aus — der nicht-blockierende ZMQ-Socket wird an eine Python-Synchron-Schnittstelle adaptiert. Dieser process_outputs_socket ist der Kern des ganzen Sync-Pfads:
def process_outputs_socket():
assert isinstance(out_socket, zmq.Socket)
shutdown_socket = ctx.socket(zmq.PAIR)
try:
shutdown_socket.bind(shutdown_path)
poller = zmq.Poller()
poller.register(shutdown_socket, zmq.POLLIN)
poller.register(out_socket, zmq.POLLIN)
while True:
socks = poller.poll()
if not socks:
continue
if len(socks) == 2 or socks[0][0] == shutdown_socket:
# shutdown signal, exit thread.
break
frames = out_socket.recv_multipart(copy=False)
resources.validate_alive(frames)
outputs: EngineCoreOutputs = decoder.decode(frames)
if outputs.utility_output:
_process_utility_output(outputs.utility_output, utility_results)
else:
outputs_queue.put_nowait(outputs)
except Exception as e:
outputs_queue.put_nowait(e)
finally:
# Close sockets.
shutdown_socket.close(linger=0)
out_socket.close(linger=0)(SyncMPClient.process_outputs_socket:808-836). Drei Details: Erstens läuft Shutdown über einen separaten zmq.PAIR-Inproc-Socket, der an shutdown_path gebunden ist; wenn BackgroundResources.__call__ schließt, sendet es ein leeres Byte an diesen Socket, um den Thread zum Beenden zu bringen, ohne dass er auf out_socket wartet. Zweitens wandelt validate_alive einen ENGINE_CORE_DEAD-Einzelframe in EngineDeadError um. Drittens laufen Utility-Antworten (z. B. Rückgabe von collective_rpc) und normale Outputs über dasselbe Frame; erstere fließen direkt in das Future von utility_results[call_id], letztere in die Queue.
Die Anfragerichtung ist einfacher: SyncMPClient._send_input sendet die Multi-Frames (identity, request_type, *encoder.encode(request)) direkt an ROUTER; bei vorhandenem Tensor-Buffer wird track=True verwendet, um einen MessageTracker zu erhalten, der in pending_messages eingetragen wird und verhindert, dass die Python-Seite den Backing-Tensor vorzeitig per GC einsammelt (_send_input:861-873).
Der Handshake-Phase läuft in MPClient.__init__ ab (wait ready:615-634): Für jeden engine_rank wird eine 2-Byte-Little-Endian-Identität erzeugt und auf dem input_socket per poll auf die EngineCoreReadyResponse des Unterprozesses gewartet; ein Timeout wird durch VLLM_ENGINE_READY_TIMEOUT_S gesichert. _apply_ready_response schreibt die im Unterprozess berechneten num_gpu_blocks, das ausgerichtete block_size und das automatisch angepasste max_model_len ins Frontend-vllm_config zurück — das ist die echte Kapazität, die das Frontend für Schedule und Throttling verwenden kann.
Grenzen und Fehler
asyncio_mode=True, multiprocess_mode=Falsedirekt abgelehnt (make_client:91-95). EngineCore selbst ist nicht async-freundlich; diese Kombination ist nicht implementiert.- Konstruktionsfehler müssen den Unterprozess einsammeln:
MPClient.__init__verwendettry/finally+ einsuccess-Flag; bei Fehler wirdself._finalizer()aufgerufen, wasBackgroundResources.__call__auslöst und auch die bereits perlaunch_core_enginesgestarteten Unterprozesse stoppt (__init__ finally:499-649). - InprocClient unterstützt
wait-Pause nicht (InprocClient.sleep:324-328). Im In-Process-Modus gibt es niemanden, der auf Leerlauf wartet; es geht nur, die aktuelle Anfrage abzubrechen und dann zu schlafen. - Der Tod des Engine-Core-Unterprozesses wird in beide Richtungen sichtbar: Auf der Sendeseite über
ensure_alive(ensure_alive:670-672); auf der Empfangsseite übervalidate_alivebeimENGINE_CORE_DEAD-Einzelframe (validate_alive:454-457). Beide Wege ziehenBackgroundResources.engine_deadhoch. - Toleranz von InvalidStateError bei Utility-Futures:
_process_utility_outputfängtasyncio.InvalidStateErrorab (_process_utility_output:769-776) und deckt damit den Fall, dass die aufrufende Task bereits abgebrochen wurde und das Future bereits cancelled ist. - Output-Socket muss vor
context.termgeschlossen werden:BackgroundResources.__call__macht zuerst explizitclose_sockets(sync case:437-452); andernfalls hängtcontext.termfest. output_socketvon SyncMPClient wird vom Thread geschlossen: Am Ende der Konstruktion wirdself.resources.output_socket = Nonegesetzt (socket ownership transfer:846-847), damitBackgroundResourcesund der Daemon-Thread nicht gleichzeitig schließen.- Ready-Handshake-Timeout mit lesbarem Fehler:
Timed out waiting for engine core processes to start. This is often caused by slow weight loading for large models.(ready timeout:622-630) — weist den Nutzer direkt hin,VLLM_ENGINE_READY_TIMEOUT_Sanzupassen.
Zusammenfassung
Die beiden Pfade InprocClient und MPClient wirken sehr unterschiedlich, sehen aber außen wie EngineCoreClient aus — ein add_request hier, ein get_output dort; der Frontend-Code muss nicht wissen, ob EngineCore ein In-Process-Objekt oder ein Unterprozess + ZMQ ist. Der In-Process-Pfad ist so einfach wie möglich: eine Objektreferenz. Der Unterprozess-Pfad kapselt ZMQ ROUTER/PULL, Handshake, Monitor, Future-Tabelle und Socket-Ownership vollständig. Die asynchrone Variante AsyncMPClient ist auf /client/async-mp einzeln aufgeführt; EngineCore selbst in /engine/engine-core; Busy Loop und Shutdown-Signal von EngineCoreProc in /engine/engine-core-proc.