12 KiB
Async Diffusion Output
Table of Contents
Overview
The async diffusion output feature moves the D2H (Device→Host) copy and SHM packing from the Worker main thread to a background daemon thread, so the GPU default stream can immediately start the next request's forward pass. This eliminates the GPU bubble caused by synchronous D2H/packing.
Core benefit: the previous request's output D2H/packing overlaps with the next request's forward pass.
Automatically enabled when step_execution=False (default, request-mode). No extra configuration needed. When step_execution=True (step-mode), the original synchronous path is used — pump thread and Worker background thread are not started.
Performance
HunyuanImage-3.0 (TP4)
| Resolution | Async Off (QPS) | Async On (QPS) | Change |
|---|---|---|---|
| 1024×1024 | 0.4773 | 0.4802 | +0.60% |
| 768×768 | 0.8370 | 0.8533 | +1.95% |
Analysis: smaller resolution → larger QPS gain. D2H/packing is relatively fixed overhead; forward is variable (scales with resolution). Smaller images → shorter forward → D2H/packing occupies a larger fraction of total time → more benefit from overlapping it.
Architecture
Execution Timeline
Before (synchronous D2H):
[forward req1] [D2H+SHM pack req1] [forward req2] [D2H+SHM pack req2]
^^^^^^^^^^^^^^^^ GPU bubble
After (async D2H):
[forward req1] [forward req2]
[D2H+SHM pack req1 (side stream)] [D2H+SHM pack req2 (side stream)]
^^^^^^^^^^^^^^^^ bubble eliminated
Data Flow
┌─────────────────────────────────────────────────────┐
│ Worker Process │
│ │
execute_model ──> │ WorkerBusyLoop ──> forward() ──> DiffusionOutput │
│ │ │ (GPU tensor)│
│ │ ┌─────────┴──────────┐ │
│ │ │ async output path │ │
│ │ │ │ │
│ │ compute_done ──> result_mq │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ AsyncOutputThread │ │
│ │ (side CUDA stream) │ │
│ │ wait_event(gpu_event) │ │
│ │ pack_diffusion_output_shm() │ │
│ │ D2H + SHM write │ │
│ │ │ │ │
│ │ output_ready ──> result_mq │ │
│ │ │ │ │
│ ▼ ▼ │ │
│ (dequeue next request) │ │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Executor (main process) │
│ │
│ ResultPumpThread (sole reader of result_mq) │
│ │ │
│ ├── AsyncDiffusionOutput(COMPUTE_DONE) │
│ │ → resolve _rpc_futures[rpc_id] │
│ │ → RunnerOutput(async_output_id=...) │
│ │ │
│ ├── AsyncDiffusionOutput(OUTPUT_READY) │
│ │ → resolve _output_futures[async_output_id]│
│ │ → or batch split via _batch_split_map │
│ │ │
│ └── Non-async message │
│ → _sync_result_buffer (for other RPCs) │
│ │
│ wait_output_ready(async_output_id) → Future │
└─────────────────────────────────────────────────────┘
Key Components
-
AsyncDiffusionOutput (
data.py): Protocol envelope forresult_mq.kindfield routes messages:COMPUTE_DONE— forward finished, GPU can start next requestOUTPUT_READY— D2H/SHM packing finished, final output availableRPC_RESULT— ordinary RPC return (including error propagation)
-
Worker AsyncOutputThread (
diffusion_worker.py): Background daemon thread that:- Waits for
gpu_event(cross-stream sync ensuring forward wrote the tensor) - Runs
pack_diffusion_output_shm()on a side CUDA stream - Enqueues
OUTPUT_READYtoresult_mqwhen packing completes
- Waits for
-
ResultPumpThread (
multiproc_executor.py): Sole reader ofresult_mqin request-mode. Dispatches messages:COMPUTE_DONE/RPC_RESULT→ resolves_rpc_futures[rpc_id]OUTPUT_READY→ resolves_output_futures[async_output_id](with batch split via_batch_split_map)- Non-async messages →
_sync_result_bufferfor other RPCs
-
collective_rpc() two-path dispatch (
multiproc_executor.py):- Path 1:
execute_model/execute_model_batch→ generatesrpc_id, registers Future, waits for pump to delivercompute_done - Path 2: All other RPCs →
_sync_result_buffer(pump-fed) or_result_mq(step-mode, no pump)
- Path 1:
-
record_device_event (
platforms/): Cross-platform GPU event recording for side-stream synchronization. Implemented for CUDA, NPU, ROCm, XPU, MUSA; base class returnsNone(safe no-op). -
IPC side-stream D2H (
ipc.py):pack_diffusion_output_shm()and helpers acceptd2h_streamparameter; usepin_memory+copy_(non_blocking=True)on the side stream instead of synchronous.cpu().
Batch Split
When execute_model_batch returns a COMPUTE_DONE with a single async_output_id for the entire batch, the executor splits it into per-request async_output_ids (format {batch_id}/{request_id}) via _batch_split_map. When OUTPUT_READY arrives, the pump extracts per-request results from the batch output and resolves each request's output future independently. If OUTPUT_READY arrives before execute_batch registers the split map, execute_batch directly adopts the early output.
Reliability, Lifecycle & Timeout Behavior
-
Drain Before Memory Release (
drain_async_outputs/_ASYNC_OUTPUT_DRAIN_TIMEOUT_S): When workers transition into sleep states (handle_sleep_task) or invoke memory-releasing methods,WorkerProc.drain_async_outputs()blocks up to_ASYNC_OUTPUT_DRAIN_TIMEOUT_S(10.0s) until all in-flight background D2H/SHM packing tasks finish. This acts as a synchronization barrier preventing worker sleep cycles from releasing device tensors while the side CUDA stream is still actively reading them. Draining typically completes within 10–50 ms under normal operation. -
Timeout Diagnostics (
_ASYNC_OUTPUT_TIMEOUT): The engine waits forwait_output_ready()with a timeout of_ASYNC_OUTPUT_TIMEOUT(30.0s). If a timeout occurs, the executor logs detailed diagnostic bookkeeping (pending futures, cached outputs, and batch split maps) to identify whether the stall occurred in the worker, pump, or waiter. -
Queue Serialization & Atomic Future Resolution:
result_mqwrites are serialized with_result_mq_lockto protect the single-writerMessageQueueagainst concurrent writes from the worker main loop (COMPUTE_DONE) and background thread (OUTPUT_READY).ResultPumpThreadunpacks shared-memory payloads first, and then atomically resolves waiting futures or populates_completed_outputsunder_futures_lock, avoiding race conditions with concurrentwait_output_ready()invocations.
-
Storage-Aware IPC Packing:
pack_diffusion_output_shm()evaluatesmax(view_bytes, storage_bytes)against_SHM_TENSOR_THRESHOLD(1 MB) to ensure that per-request slices sharing a large batch storage are moved to POSIX shared memory rather than serialized through pickle over the MessageQueue inline buffer.
Model Coverage
Currently Supported (request-mode, step_execution=False)
All models running in request-mode (step_execution=False, the default) automatically use async output.
Verified models:
| Model | Type | supports_request_batch |
|---|---|---|
| HunyuanImage-3.0 | Image | False |
| Qwen-Image | Image | True |
| LTX-2.3 | Video / Audio | False |
Other models with step_execution=False are also supported but not yet verified.
Not Yet Supported (step-mode, step_execution=True)
When step_execution=True (or streaming_output=True, which auto-enables step-mode), models use execute_stepwise instead of execute_model, which is not in the async Path 1 whitelist. Async output is not applicable.
Verified: Helios (step_execution=True, does not benefit from this feature).
Other models with step_execution=True are also not applicable.
Adapting step-mode requires additional design because:
- Each step's
RunnerOutputmust be available synchronously for the scheduler to advance - Intermediate latent tensors may not need D2H (only the final step needs D2H + postprocess)
- Steps are serial (step N+1 depends on step N output), so the overlap benefit is limited to "final step's D2H overlaps with the next request's forward"
Planned approach: first cover only the final step's async D2H, keep intermediate steps synchronous. This matches the request-mode benefit pattern.
Configuration
No configuration needed. Async output is automatically enabled when step_execution=False (default).
step_execution |
Mode | Async Output | Pump Thread | Worker BG Thread |
|---|---|---|---|---|
False (default) |
request-mode | ✅ enabled | ✅ started | ✅ started |
True |
step-mode | ❌ disabled | ❌ not started | ❌ not started |
Related Files
vllm_omni/diffusion/data.py:AsyncDiffusionOutput,AsyncOutputKind,DiffusionOutput.async_output_idvllm_omni/diffusion/worker/diffusion_worker.py:WorkerProc._return_result(),_async_output_loop(),_generate_async_output_id()vllm_omni/diffusion/executor/multiproc_executor.py:ResultPumpThread,collective_rpc()two-path dispatch,wait_output_ready(),_batch_split_mapvllm_omni/diffusion/diffusion_engine.py:step()/step_streaming()/add_req_and_wait_for_response()async output waitingvllm_omni/diffusion/sched/request_scheduler.py:update_from_output()—async_output_id→FINISHED_COMPLETEDvllm_omni/diffusion/ipc.py:pack_diffusion_output_shm()side-stream D2H pathvllm_omni/platforms/interface.py:OmniPlatform.record_device_event()base classvllm_omni/platforms/cuda/platform.py:CudaOmniPlatform.record_device_event()vllm_omni/platforms/npu/platform.py:NPUOmniPlatform.record_device_event()vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py:get_hunyuan_image3_post_process_func(), postprocess removalvllm_omni/diffusion/registry.py:_DIFFUSION_POST_PROCESS_FUNCSpostprocess registry