AsyncMPClient: asyncio-Event-Loop um ZMQ
Verantwortung
AsyncMPClient ist die asynchrone Variante von MPClient und wird von AsyncLLM und den Serving-Einstiegen verwendet. Wie SyncMPClient verlagert es EngineCore in einen Unterprozess und sendet Anfragen über ZMQ ROUTER und empfängt Outputs über PULL. Der Unterschied: Das Output-Socket wird nicht mehr von einem Daemon-Thread polliert, sondern hängt direkt an der asyncio-Event-Loop; ein dauerhafter Task await output_socket.recv_multipart(copy=False) zieht die Frames. So läuft das Frontend mit await, der Unterprozess rechnet und schickt zurück, der Task dekodiert und steckt die Daten in eine asyncio.Queue — die gesamte Kette läuft in einer Event-Loop, ohne Thread-Wechsel, ohne threadübergreifendes Umkopieren aus queue.Queue.
AsyncMPClient selbst ist sparsam implementiert: Es überschreibt nur _send_input (gibt ein Awaitable statt blockierend zurück), eine Gruppe async-Methoden wie call_utility_async/add_request_async sowie den zentralen _ensure_output_queue_task. Die meisten Initialisierungen in __init__ verwenden die ZMQ-Handshake- und Engine-Monitor-Logik von MPClient.__init__; nur asyncio_mode=True sorgt dafür, dass der Context zu zmq.asyncio.Context und der Socket zu zmq.asyncio.Socket wird. Datenparallel (DP) und elastisches EP werden von den beiden Unterklassen DPAsyncMPClient und DPLBAsyncMPClient übernommen; diese Seite behandelt sie nur am Rande.
Entwurfsmotivation
- Async-Loop ist single-Threaded, keine Daemon-Threads: Der
process_outputs_socket-Daemon-Thread vonSyncMPClientist im asynchronen Modus unnötig —zmq.asyncio.Socket.recv_multipartist selbst eine Coroutine und kann direktawaited werden; inasyncio.create_taskgesteckt, ist die Sache erledigt. Der Thread wird durch einen Task ersetzt; die Queue wird vonqueue.Queuezuasyncio.Queue. - Bei Konstruktion ist die Loop vielleicht noch nicht oben: In
AsyncMPClient.__init__fängtasyncio.get_running_loop()einen Fehler ab undpasst; der Start des Output-Queue-Tasks wird auf den ersten Aufruf von_ensure_output_queue_taskverschoben (lazy task start:974-982). So kann der Client außerhalb der Event-Loop konstruiert werden (z. B. in Test oder Init); beim echten Start wird lazy angehängt. Das hat einen Preis — zwischen Konstruktion und dem erstenadd_request_asyncgeht möglicherweise einENGINE_CORE_DEADverloren, weil der Task noch nicht läuft; der Kommentar weist explizit auf diesen Trade-off hin. - Task darf den Client nicht direkt halten: In
_ensure_output_queue_taskwerdendecoder,utility_results,outputs_queue,output_socketals Closure-lokale Variablen herausgezogen; der Client selbst wird überweakref.ref(self)(_self_ref) referenziert. So wird der Client, wenn das Frontend ihn fallen lässt, nicht durch eine Closure-Referenz am Leben erhalten (no direct ref:989-998)._self = _self_ref()liefertNone, soreturnt der Task und beendet sich selbst. - Utility-Future als asyncio.Future:
_call_utility_asyncverwendetasyncio.get_running_loop().create_future()und nichtconcurrent.futures.Future, damitawait futuredirekt von der Event-Loop angetrieben wird. Das Füllen des Futures erfolgt weiterhin über die geteilte Funktion_process_utility_output, die mit beiden Future-Typen kompatibel ist (_call_utility_async:1104-1116). - EEP-Benachrichtigungen mit spezieller call_id: Wenn in
process_outputs_socketutility_output.call_id == EEP_NOTIFICATION_CALL_IDist, wird das Future-Registry umgangen und stattdessen perasyncio.create_taskder Callbackeep_process_engine_core_notificationgetriggert (EEP notification path:1011-1027). Rank-Änderungen beim elastischen EP sind ein "Ereignis" und keine "Anfrage-Antwort" und können daher nicht über das Utility-Future abgebildet werden. output_handlerist ein optionaler Hook:getattr(self.__class__, "process_engine_outputs", None)(output_handler:994-996). DP-Unterklassen (z. B.DPLBAsyncMPClient) überschreiben diese Methode, um Lastverteilungs-Statistiken zu erfassen; die BasisklasseAsyncMPClienthat diese Methode nicht und fängt nichts ab — aberoutputs.outputs or outputs.scheduler_statsgeht dennoch in die Queue (enqueue condition:1042-1043).
Schlüsseldateien
AsyncMPClient.__init__:950-982— reichtasyncio_mode=TrueanMPClient.__init__weiter; bautasyncio.Queueund versucht lazy, in einer bestehenden Loop einen Task zu starten._ensure_output_queue_task:984-1051— zieht decoder/queue/utility_results/socket als Closure-locals;asyncio.create_task(process_outputs_socket()); idempotent.process_outputs_socket:1005-1047— Hauptschleifeawait output_socket.recv_multipart(copy=False); unterscheidet drei Frame-Typen: Utility / EEP-Benachrichtigung / normale Outputs.CancelledError handling:1042-1047— wenn der Task abgebrochen wird, wirdEngineDeadErrorin die Queue gelegt, sodass die Seiteget_output_asynces ebenfalls merkt.get_output_async:1053-1062—await outputs_queue.get(); enthält der Wert eine Exception, wird sie per_format_exceptioninEngineDeadErrorverpackt._send_input / _send_input_message:1064-1099— gibtAwaitablezurück; ohne Buffer direkt diesend_multipart-Coroutine; mit Buffertrack=True+add_done_callback, um asynchron denMessageTrackerzu holen.call_utility_async / _call_utility_async:1101-1116— erzeugtcall_id, registriertasyncio.Future,await self._send_input_message+await future.add_request_async / abort_requests_async:1121-1128— stempeltclient_indexauf die Anfrage und ruft nach dem Senden_ensure_output_queue_taskauf, um den Task zu sichern.collective_rpc_async:1188-1197— reicht ancall_utility_async("collective_rpc", ...)weiter; Parameter stimmen mit der sync-Version überein.DPAsyncMPClient.__init__:1200-1240— zusätzlichcurrent_wave,lb_engines,first_req_send_socket,eep_scaling_cache; bei laufender Loop lazy ein Stats-Update-Task._ensure_stats_update_task:1242-1249— startet den DP-Stats-Subscribe-Task; in der BasisklasseAsyncMPClientnicht vorhanden.DPLBAsyncMPClient:1380— interne Load-Balancer-Unterklasse; überschreibtcall_utility_async, um Anfragen gemäßlb_enginesan den am wenigsten ausgelasteten Rank zu senden.
Datenfluss
Die gesamte Kette läuft in einer asyncio-Loop. Frontend await client.add_request_async(req) → _send_input sendet die Frames per send_multipart an ZMQ ROUTER (ohne Buffer direkt als Awaitable von send_multipart; mit Buffer mit track=True + Done-Callback verpackt). In EngineCoreProc.run_busy_loop berechnet der Unterprozess und sendet EngineCoreOutputs an den PULL-Socket zurück. Der dauerhafte Task process_outputs_socket holt die Frames an await output_socket.recv_multipart(copy=False), dekodiert und leitet sie nach Typ weiter:
async def process_outputs_socket():
try:
while True:
frames = await output_socket.recv_multipart(copy=False)
resources.validate_alive(frames)
outputs: EngineCoreOutputs = decoder.decode(frames)
if outputs.utility_output:
if (
outputs.utility_output.call_id == EEP_NOTIFICATION_CALL_ID
and notification_callback_handler is not None
):
assert _self_ref is not None
_self = _self_ref()
if not _self:
return
if outputs.utility_output.result is None:
continue
notification_data = outputs.utility_output.result.result
assert isinstance(notification_data, Sequence)
assert len(notification_data) == 2
asyncio.create_task(
notification_callback_handler(_self, notification_data)
)
else:
_process_utility_output(
outputs.utility_output, utility_results
)
continue
if output_handler is not None:
assert _self_ref is not None
_self = _self_ref()
if not _self:
# Client has been garbage collected, abort.
return
await output_handler(_self, outputs)
if outputs.outputs or outputs.scheduler_stats:
outputs_queue.put_nowait(outputs)
except Exception as e:
outputs_queue.put_nowait(e)
except asyncio.CancelledError:
outputs_queue.put_nowait(EngineDeadError())(process_outputs_socket:1005-1047). Drei Wege: Utility-Antwort geht in die Future-Registry; EEP-Benachrichtigung startet einen neuen Task; normale Outputs laufen über den output_handler-Hook (falls vorhanden) und dann in die Queue. output_handler ist für DP-Unterklassen zum Einklinken von Statistiken gedacht — die Basisklasse hat diese Methode nicht; Frames gehen direkt in die outputs_queue, und das Frontend await get_output_async() erhält diese.
Der Future-Pfad für Utility-Aufrufe muss separat betrachtet werden, weil er sync und async Future-Typen vereinheitlicht:
async def _call_utility_async(
self, method: str, *args, engine: EngineIdentity
) -> Any:
call_id = uuid.uuid1().int >> 64
future = asyncio.get_running_loop().create_future()
self.utility_results[call_id] = future
message = (
EngineCoreRequestType.UTILITY.value,
*self.encoder.encode((self.client_index, call_id, method, args)),
)
await self._send_input_message(message, engine, args)
self._ensure_output_queue_task()
return await future(_call_utility_async:1104-1116). call_id nimmt uuid.uuid1().int >> 64 und die oberen 64 Bit — groß und dünn genug verteilt; das Future wird registriert, bevor die Anfrage gesendet wird, damit das Antwort nicht vor der Registrierung ankommt; _ensure_output_queue_task() wird noch einmal aufgerufen, um den Task zu sichern — sonst würde await future nie gefüllt. Das Füllen erfolgt in process_outputs_socket im Zweig _process_utility_output(outputs.utility_output, utility_results).
Grenzen und Fehler
- Bei Konstruktion ohne Loop gehen frühe Todsignale verloren: In
__init__schlägtasyncio.get_running_loop()fehl und der Task wird lazy gestartet (lazy:974-982); stirbt der Unterprozess vor dem ersten_ensure_output_queue_task, wird derENGINE_CORE_DEAD-Frame nicht konsumiert. Der Kommentar weist explizit auf diesen Trade-off hin. - Bei Task-Abbruch wird EngineDeadError in die Queue gelegt:
except asyncio.CancelledErrorstecktEngineDeadErrorin die Queue (CancelledError:1046-1047), sodass auf derget_output_async-Seite beimawaitdirekt ein Fehler geworfen wird und nicht stumm hängen bleibt. - Bei GC des Clients beendet sich der Task selbst:
_self = _self_ref()liefertNone, dannreturn(GC suicide:1037-1039), damit der Task keine bereits freigegebenen Ressourcen weiterhält. - Mit Tensor-Buffer wird ein Future statt einer Coroutine zurückgegeben: In
_send_input_messagegilt: wennlen(msg) > 3, gibtsend_multipart(track=True)einasyncio.Future[zmq.MessageTracker]zurück;add_done_callback(add_pending)hängt den Tracker asynchron anpending_messages(track path:1091-1099). Der Aufrufer erhält ein Awaitable; erst nach demawaitist das Senden wirklich abgeschlossen. - EEP-Benachrichtigung hat eigene call_id:
EEP_NOTIFICATION_CALL_IDgeht nicht überutility_results(EEP branch:1011-1027), sondern spawnt einen neuen Task, dereep_process_engine_core_notificationaufruft, weil Benachrichtigungen einseitige Ereignisse sind. _ensure_output_queue_taskist idempotent:if resources.output_queue_task is not None: return(idempotent:986-987).add_request_asyncundcall_utility_asyncrufen es jedes Mal auf, um den Task zu sichern; läuft er bereits, ist es ein No-Op.- DP-Unterklassen haben klare Erweiterungspunkte:
DPAsyncMPClientfügtcurrent_wave/lb_engines/eep_scaling_cacheund_ensure_stats_update_taskhinzu;DPLBAsyncMPClientüberschreibtcall_utility_asyncfür das interne LB (DP init:1200-1240). Die Basisklasse trägt diese DP-Logik nicht. output_handler-Hook ist ein classmethod-artiger Lookup:getattr(self.__class__, "process_engine_outputs", None)(output_handler lookup:994-996). Definiert die Unterklasse diese Methode, wird sie aufgerufen; definiert die Basisklasse sie nicht, wird übersprungen. Achtung: Sie kann nur aufgerufen werden, wenn_self_refden Client liefert;selfdarf nicht direkt geschlossen werden.
Zusammenfassung
Der Kern von AsyncMPClient ist, den Daemon-Thread + queue.Queue von SyncMPClient gegen asyncio-Task + asyncio.Queue zu tauschen; auch der ZMQ-Context wechselt zu zmq.asyncio.Context. Alles andere — Ready-Handshake, Engine-Monitor, Utility-Future-Registry, MessageTracker gegen vorzeitige GC des Tensors, ENGINE_CORE_DEAD-Ausbreitung — wird von der MPClient-Basisklasse geerbt. DP- und elastisches EP-Erweiterungen leben in DPAsyncMPClient / DPLBAsyncMPClient und binden über den output_handler-Hook und das Überschreiben von call_utility_async ein. Die synchrone Variante steht in /client/inproc-mp; EngineCore und EngineCoreProc selbst in /engine/engine-core und /engine/engine-core-proc.