Repository metrics
- Stars
- (4,990 stars)
- PR merge metrics
- (PR metrics pending)
Description
Motivation.
This feature offloads the D2H (Device-to-Host) copy and SHM packing—performed after the Worker GPU forward pass—from the Worker's main thread to a background daemon thread. This allows the GPU's default stream to immediately start the forward pass for the next request, thereby eliminating GPU bubbles.
Proposed Change.
Async Diffusion Output
1. Overview
After the Worker completes GPU forward, it must synchronously execute D2H (Device→Host) copy and SHM packing on the main thread, during which the GPU default stream sits idle, creating a GPU bubble. In multi-request concurrent scenarios, the D2H/packing of the previous request blocks the forward launch of the next request, reducing overall throughput.
This feature moves the D2H copy and SHM packing from the Worker main thread to a background daemon thread (side CUDA stream), so the GPU default stream can immediately start the next request's forward, achieving overlap between the previous request's output D2H/packing and the next request's forward execution, eliminating the GPU bubble.
2. Scope & Objectives
Goals
- Eliminate GPU bubble in request-mode: After Worker forward completes, immediately return
compute_done; the GPU default stream can start the next forward, while D2H/packing executes asynchronously on the side stream. - Automatically enabled, zero configuration: When
step_execution=False(default, request-mode), the async output path is automatically enabled; whenstep_execution=True(step-mode), the original synchronous path is used. No extra configuration switch needed. - Performance improvement: On HunyuanImage-3.0 (TP4), 768×768 resolution QPS improved by +1.95%, 1024×1024 resolution QPS improved by +0.60%.
- Compatibility: Transparent to request-mode models — existing models benefit without modification; step-mode behavior is completely unchanged.
Feature Goals
- Step-mode async output adaptation: Currently not supported for
step_execution=Truemodels. Step-mode requires synchronous intermediate results per step; adaptation is more complex and deferred to a future phase. - Intermediate step async D2H: Even when adapting step-mode in the future, only the final step's async D2H is planned; intermediate steps will remain synchronous.
3. Design
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 │
└─────────────────────────────────────────────────────┘
Sequence Diagram (request-mode, async path)
sequenceDiagram
autonumber
participant Client as Online Client
participant StageA as Stage thread A
participant StageB as Stage thread B
participant Engine as DiffusionEngine
participant Executor as MultiprocDiffusionExecutor
participant Pump as result_pump
participant WorkerLoop as WorkerProc.worker_busy_loop
participant Worker as DiffusionWorker
participant PackThread as async output thread
participant MQ as result_mq
Client->>StageA: request 1
StageA->>Engine: step(req1)
Engine->>Engine: acquire _rpc_lock
Engine->>Executor: execute_request(req1)
Executor->>Executor: _next_rpc_id() → rpc_id
Executor->>WorkerLoop: broadcast rpc execute_model(req1, rpc_id)
WorkerLoop->>Worker: execute_model(req1)
Worker-->>WorkerLoop: DiffusionOutput(device tensor)
WorkerLoop->>PackThread: submit_async_output(async_output_id, output)
WorkerLoop->>MQ: enqueue(AsyncDiffusionOutput(kind=COMPUTE_DONE, rpc_id, async_output_id))
WorkerLoop-->>WorkerLoop: dequeue next request
MQ-->>Pump: dequeue()
Pump-->>Executor: resolve rpc_futures[rpc_id]
Executor-->>Engine: RunnerOutput(result=None, async_output_id=...)
Engine->>Engine: scheduler.update_from_output()<br/>release running slot
Engine->>Engine: release _rpc_lock
par req1 output packing
PackThread->>PackThread: wait gpu_event
PackThread->>PackThread: pack_diffusion_output_shm()<br/>D2H + SHM write on side stream
PackThread->>MQ: enqueue(AsyncDiffusionOutput(kind=OUTPUT_READY, async_output_id))
MQ-->>Pump: dequeue()
Pump-->>Executor: resolve output_futures[async_output_id]
StageA->>Executor: wait_output_ready(async_output_id)
Executor-->>StageA: DiffusionOutput
StageA->>StageA: post_process_func(output)
and req2 forward can start
Client->>StageB: request 2
StageB->>Engine: step(req2)
Engine->>Engine: acquire _rpc_lock
Engine->>Executor: execute_request(req2)
Executor->>WorkerLoop: broadcast rpc execute_model(req2)
WorkerLoop->>Worker: execute_model(req2)
end
StageA-->>Client: final result for req1
Note over WorkerLoop,PackThread: D2H/SHM packing leaves worker main loop.<br/>req2 forward can overlap with req1 output packing.
Optional API & Interface Changes
New Classes
| Class | File | Description |
|---|---|---|
AsyncOutputKind |
vllm_omni/diffusion/data.py |
Enum: RPC_RESULT, COMPUTE_DONE, OUTPUT_READY |
AsyncDiffusionOutput |
vllm_omni/diffusion/data.py |
Protocol envelope for result_mq; kind field routes messages |
class AsyncOutputKind(Enum):
RPC_RESULT = "rpc_result" # Ordinary RPC return (including error propagation)
COMPUTE_DONE = "compute_done" # Forward finished, GPU can start next request
OUTPUT_READY = "output_ready" # D2H/SHM packing finished, final output available
@dataclass
class AsyncDiffusionOutput:
kind: AsyncOutputKind
rpc_id: str | None = None
async_output_id: str | None = None
result: Any | None = None
output: DiffusionOutput | None = None
error: str | None = None
Modified Classes
| Class | File | Change |
|---|---|---|
DiffusionOutput |
vllm_omni/diffusion/data.py |
New field async_output_id: str | None = None — async output claim token |
RunnerOutput |
vllm_omni/diffusion/data.py |
New field async_output_id: str | None = None — marks "compute done, output pending" |
New Methods
| Method | File | Description |
|---|---|---|
_generate_async_output_id() |
vllm_omni/diffusion/worker/diffusion_worker.py |
Static method; generates UUID claim token |
_async_output_loop() |
vllm_omni/diffusion/worker/diffusion_worker.py |
Daemon thread main loop: dequeue from _async_output_queue → wait_event → side-stream D2H+SHM packing → enqueue output_ready |
record_device_event() |
vllm_omni/platforms/interface.py |
Base class returns None (safe no-op); subclasses override for CUDA/NPU/ROCm/XPU/MUSA |
get_hunyuan_image3_post_process_func() |
vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py |
Postprocess function moved from Worker to Engine side |
New Attributes
| Attribute | File | Description |
|---|---|---|
_async_output_queue |
diffusion_worker.py |
queue.Queue; main thread puts (output, async_output_id, gpu_event), bg thread consumes |
_async_output_thread |
diffusion_worker.py |
Daemon thread object |
_rpc_id_counter + _rpc_id_lock |
multiproc_executor.py |
RPC ID counter + lock, Path 1 only |
_rpc_futures |
multiproc_executor.py |
dict[str, Future[AsyncDiffusionOutput]]; wait compute_done by rpc_id |
_output_futures |
multiproc_executor.py |
dict[str, Future[DiffusionOutput]]; wait final output by async_output_id |
_completed_outputs |
multiproc_executor.py |
dict[str, Future[DiffusionOutput]]; cache for output_ready arriving before wait |
_batch_split_map |
multiproc_executor.py |
dict[str, dict[str, str]]; batch_id → {per_req_id: request_id} |
_futures_lock |
multiproc_executor.py |
RLock; protects above four dicts |
_pump_stop |
multiproc_executor.py |
threading.Event; shutdown signal for pump thread |
_sync_result_buffer |
multiproc_executor.py |
queue.Queue; pump puts non-async messages here for Path 2 |
Modified Methods
| Method | File | Change |
|---|---|---|
_return_result() |
diffusion_worker.py |
New async branch: when step_execution=False and output is DiffusionOutput/BatchRunnerOutput, generate async_output_id + gpu_event, put to _async_output_queue, enqueue compute_done, return immediately |
worker_busy_loop() |
diffusion_worker.py |
Extract rpc_id and pass to _return_result(); add async RPC error propagation via AsyncDiffusionOutput(kind=RPC_RESULT, error=...) |
_dequeue_one_with_failure_polling() |
multiproc_executor.py |
New dispatch: request-mode reads from _sync_result_buffer; step-mode reads from _result_mq (original logic) |
execute_request() |
multiproc_executor.py |
New compute_done branch: construct RunnerOutput(finished=True, result=None, async_output_id=...) |
execute_batch() |
multiproc_executor.py |
New compute_done branch: split batch-level async_output_id into per-request via _batch_split_map |
_result_pump() |
multiproc_executor.py |
Handle OUTPUT_READY with batch split logic via _batch_split_map |
wait_output_ready() |
multiproc_executor.py |
Return cached completed Future directly |
shutdown() |
multiproc_executor.py |
Add _batch_split_map.clear() |
step() |
diffusion_engine.py |
Check async_output_id → await asyncio.wait_for(asyncio.wrap_future(fut), timeout=_ASYNC_OUTPUT_TIMEOUT) |
step_streaming() |
diffusion_engine.py |
Same async wait inside async for loop |
add_req_and_wait_for_response() |
diffusion_engine.py |
fut.result(timeout=_ASYNC_OUTPUT_TIMEOUT) blocking wait |
_finalize_finished_request() |
diffusion_engine.py |
New branch: result=None but async_output_id present → return DiffusionOutput(async_output_id=...) placeholder |
update_from_output() |
request_scheduler.py |
result=None + async_output_id present → FINISHED_COMPLETED (not FINISHED_ERROR) |
_tensor_to_shm() |
ipc.py |
New d2h_stream param: side-stream path uses pin_memory + copy_(non_blocking=True); original .cpu() when absent |
_pack_tensor_if_large() |
ipc.py |
Pass through d2h_stream |
_pack_value_if_large() |
ipc.py |
Pass through d2h_stream |
_pack_diffusion_fields() |
ipc.py |
Pass through d2h_stream |
pack_diffusion_output_shm() |
ipc.py |
Pass through d2h_stream (entry function) |
Modified Pipeline Files (postprocess removal)
| File | Change |
|---|---|
pipeline_hunyuan_image3.py |
HunyuanImage3Pipeline.__call__: postprocess commented out, output=image (return raw GPU tensor) |
hunyuan_image3_transformer.py |
HunyuanImage3Text2ImagePipeline.__call__: postprocess commented out |
registry.py |
_DIFFUSION_POST_PROCESS_FUNCS registers HunyuanImage3Pipeline and HunyuanImage3ForCausalMM → get_hunyuan_image3_post_process_func |
Key Technical Decisions
Decision 1: Two-phase message protocol (COMPUTE_DONE + OUTPUT_READY)
- Decision: Split one request's completion into
compute_doneandoutput_readytwo phases viaAsyncDiffusionOutput.kind, using the same physicalresult_mq. - Alternatives: (A) Use two separate queues for compute completion and output delivery; (B) Use a single message type with a flag field.
- Rationale: Single queue preserves the existing
result_mqinfrastructure and ordering guarantees. Thekindfield provides clean dispatch without requiring the pump thread to manage two consumer loops. Two separate queues would complicate ordering and error handling.
Decision 2: Pump thread as sole reader of result_mq
- Decision: In request-mode, the pump thread is the sole reader of
result_mq.collective_rpc()cannot directly dequeue; non-async messages are buffered in_sync_result_buffer. - Alternatives: (A) Multiple readers with locks on
result_mq; (B) Letcollective_rpc()directly readresult_mqwhen expecting async messages. - Rationale: Single reader eliminates race conditions on
result_mqand ensures message ordering. The_sync_result_bufferindirection is minimal overhead for Path 2 RPCs while keeping the pump's dispatch logic simple.
Decision 3: Two-path dispatch in collective_rpc()
- Decision: Path 1 (
execute_model/execute_model_batch) generatesrpc_idand waits for pump-deliveredcompute_donevia Future; Path 2 (other RPCs) reads from_sync_result_bufferor_result_mq. - Alternatives: (A) All RPCs go through the same async path; (B) Per-method configuration.
- Rationale: Only
execute_model/execute_model_batchbenefit from the compute_done/output_ready split (they produce GPU tensors requiring D2H). Other RPCs (sleep/wake/profile) return small results with no D2H overhead; forcing them through the async path adds unnecessary latency and complexity. The whitelist approach is simple and explicit.
Decision 4: Postprocess moved from Worker to Engine
- Decision: Move denormalize→PIL conversion from Worker pipeline to Engine side via registered
post_process_func. - Alternatives: (A) Keep postprocess in Worker bg thread after D2H; (B) Skip postprocess entirely.
- Rationale: Postprocess is CPU-intensive. If kept in Worker bg thread, it increases packing time and compresses the overlap benefit. Moving it to Engine side reduces bg thread work to pure D2H+SHM packing, maximizing overlap with the next forward. The postprocess runs on Engine side after
wait_output_ready()returns, which is already outside the critical GPU path.
Decision 5: record_device_event() base class returns None
- Decision:
OmniPlatform.record_device_event()base class returnsNone(safe no-op) instead of raisingNotImplementedError. - Alternatives: (A) Raise
NotImplementedErrorto force subclasses to implement; (B) Use a separate capability flag. - Rationale: Safe degradation is preferred over hard failures. If a platform doesn't implement
record_device_event(), the bg thread will skip the event wait and proceed with D2H immediately — this may cause data races but won't crash. This allows incremental platform support without breaking existing deployments.
Dependencies & Risks
- Memory pressure from pending outputs: Background D2H holds output tensor references. If pending outputs accumulate (e.g., Engine side is slow to consume), GPU memory and host memory pressure will increase. Mitigation:
_ASYNC_OUTPUT_TIMEOUTprovides a hard timeout; if exceeded, the request fails rather than blocking indefinitely. - Batch split correctness:
execute_batchpath uses_batch_split_mapto split batchOUTPUT_READYinto per-request results. The split logic depends onDiffusionOutput.get_request_output()method. If the batch output structure changes, the split logic must be updated accordingly. - RPC error propagation: Worker RPC exceptions must be propagated via
AsyncDiffusionOutput(kind=RPC_RESULT, error=...)to avoidcollective_rpc()pending Future hanging forever. This is handled inworker_busy_loop()exception handling. _completed_outputsstores Future, not bare DiffusionOutput:wait_output_ready()returns the cached completed Future directly. Callers must not assume they receive aDiffusionOutput— they must call.result()on the Future.- Pump thread lifecycle: Pump thread is started in request-mode and stopped via
_pump_stopevent duringshutdown(). If pump crashes, allcollective_rpc()calls will hang. Mitigation:_dequeue_one_with_failure_polling()checksis_failedand_closedon timeout. - Step-mode compatibility: Step-mode is completely unaffected — pump and Worker bg thread are not started. No risk of regression for step-mode models.
Future Work: Step-Mode Async Output Adaptation
Does Step-Mode Need Async Output?
- Intermediate steps return
result=None(no D2H): no GPU bubble. Only the final step'spost_decode()+ D2H/packing causes a bubble. - Overlap opportunity: in multi-request scenarios, the final step's D2H/packing of request N can overlap with the first denoise step of request N+1. Single-request scenarios see no benefit.
- Conclusion: step-mode benefits from async output only in multi-request concurrency, limited to the final step's D2H overlap with the next request's forward.
Key Changes Needed
execute_stepwise→ Path 1: Add"execute_stepwise"to thecollective_rpc()Path 1 whitelist (or replace whitelist withstep_execution-based check).- Start pump thread in step-mode: Enable
_result_pumpregardless ofstep_executionsoCOMPUTE_DONE/OUTPUT_READYdispatch works. _return_result()for step-mode: Only the final step (result=DiffusionOutput) needs async path; intermediate steps (result=None) remain synchronous._busy_loopasync wait: Whenrunner_outputcontainsasync_output_id, let_busy_loopwait foroutput_readybefore_emit_outputs(), whileupdate_from_output()advances immediately.post_decode()externalization: Movepost_decode()from Worker to Engine (same pattern as request-mode postprocess externalization), so bg thread only handles D2H+SHM packing.
Open Questions (Verification needed)
- Streaming output (
streaming_output=True): Eachchunk_denoise_completedstep also needs D2H. The D2H of step N can overlap with step N+1's forward (same pattern as request-mode), but each chunk output is smaller than the full output, so the absolute time saved is less.
Feedback Period.
Currently, the benefits of this feature have been verified on the HunyuanImage3 and QwenImage models. In theory, any model configured with step_execution=False (i.e., in request-mode) should see similar improvements. We'd appreciate it if everyone could help verify the performance gains on other models.
CC List.
No response
Any Other Things.
No response
Before submitting a new issue...
- Make sure you already searched for relevant issues, and asked the chatbot living at the bottom right corner of the documentation page, which can answer lots of frequently asked questions.