Repository navigation
[Perf] diffusion: Remove per-step host syncs and uploads in image models - #46046
Merged
Merged
Conversation
A denoising step that reads a device value on the host (item(), float(t), if t:, nonzero) or uploads a constant with a pageable copy blocks the host until the stream drains, so it cannot queue the next step while the GPU runs this one. Regressions of this kind pass every numeric test. test/unit/host_sync_guard.py gives unit tests three guards: - no_cuda_sync(allow=...) / assert_no_cuda_sync(fn, ...): runs the code under torch.cuda.set_sync_debug_mode plus explicit synchronize() calls, and fails with the call site of every sync whose issuing function is not allow-listed. Skips without CUDA; restores the debug mode. - SyncOnRead: a CPU stand-in for device tensors whose host reads raise, so the same check runs in CPU-only tests. - forbid_host_syncs: a dispatch mode that fails on the ops that sync on CUDA (scalar reads, nonzero, boolean-mask indexing, data-dependent shapes), for CPU tests of code that builds its own tensors. test_denoising_host_sync.py applies both to one CFG request through the shared DenoisingStage loop with the flow-match Euler scheduler. The contributing guide documents the pattern for model tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1. NDRotaryEmbedding: each axis's 1D generator keyed an LRU cache on tuple(pos.tolist()). That reads the positions back to the host (a sync per axis), hashes thousands of floats, and on a miss rebuilds the tensor with a pageable upload. FLUX.1/Kontext/FLUX.2 pay it per image, text and condition axis on every request, as do JoyImage, LingBot and the Wan sequence-shard path. OneDRotaryEmbedding.forward now computes from the device positions directly, with the same ops on the same values; the outputs are bitwise identical (dtype and strides too). 2. apply_qk_norm_rope, apply_flashinfer_rope_qk_inplace and both RotaryEmbedding paths rebuilt the [offset, offset + seq) positions with an arange (and a repeat for batch > 1) in every block when the caller passes none: FLUX.2, Z-Image, Qwen-Image, Wan and others, 64-120 launches per step in eager mode. rope_positions() shares one read-only tensor per shape; compile and graph capture still build their own, so a capture never fills the cache with an unexecuted arange. Tests (test_rope_host_sync.py, fail on main): FluxPosEmbed with SyncOnRead positions matches the plain run bitwise; positions are built once per shape, rebuilt under compile and capture, and apply_qk_norm_rope takes them from rope_positions with the same output as explicit positions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edulers 1. PiflowScheduler (Kandinsky-6 distilled): the policy rollout looped range(substeps.max().item()), a device read right after the DiT forward on every step, for the video and the audio scheduler. The host then launched ~1k substep ops with the GPU idle. set_timesteps now derives each step's substep count from a host copy of the same fp32 math; the device substep sizes and masks are unchanged. 2. HeliosScheduler UniPC: torch.tensor() over lists of 0-dim device tensors read every rk and b back to the host, and the 0.5 constants were pageable uploads, about a dozen syncs per step. They are now stacked and filled on the device (same dtypes and values), and linalg.solve becomes linalg.solve_ex, which skips the host-side singularity check over the same kernels. 3. HeliosScheduler DMD: torch.full() with a device timestep read it on the host twice per step; the timestep is now cast and expanded on the device (same truncation to int64). Outputs are bitwise identical to main on CPU, NaN bits included. test_scheduler_host_sync.py runs both schedulers with SyncOnRead schedules and latents against plain runs; it fails on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DmdDenoisingStage (Wan DMD / FastWan) synced twice per step: 1. t_int = int(t.item()) on the device timestep, although only the two-expert branch uses it. It now comes from a host copy of the same dmd_denoising_steps list. 2. assert not torch.isnan(latent_model_input).any() read a device flag every step. The flags are OR-ed on the device and checked once after the loop, with the same message; outputs are unchanged, a NaN request still fails. test_denoising_host_sync.py runs one DMD request with SyncOnRead device timesteps against a plain run (bitwise equal; fails on main) and checks that NaN latents still raise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FlashAttentionImpl.forward_ring_kv_chunk built cu_seqlens_q/k with torch.tensor(..., device=...) on every ring hop of every block. Each is a pageable host-to-device copy that blocks the host until the stream drains, which also stalls the hop's P2P/compute overlap. The ring tail-pad path (ring > 1 on the FA backend) hit it twice per hop. The bounds depend only on the chunk lengths, so the hop now takes them from the cached cu_seqlens_on_device, moved from layer.py into the FA backend so both the local varlen path and the ring path share it. The values and dtype are unchanged. test_attention_host_sync.py checks two ring hops get [0, n] int32 bounds and reuse the same device tensors; it fails on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in Qwen-Image - Layered built `additional_t_cond` with a pageable `torch.tensor([0])` upload per branch. The two branches got different objects, so the DiT's modulation cache never hit on the negative branch: every step recomputed 60 blocks x 2 modulation GEMMs. Both branches now share one on-device zero tensor. - Batched prompts of different lengths rebuilt their varlen metadata every forward, uploading the lengths from pageable memory: a host sync per step and CFG branch. The metadata is cached by its lengths; each forward gets a fresh dict because attention may memoize into it. - Layered's condition-image encode, the shared channel-wise VAE stats helper (Qwen-Image edit encode/decode, Krea2, JoyImage) and Qwen-Image-2.1 decode uploaded latent mean/std per request. Each pageable upload stalls the host until the queued VAE encode or denoising loop drains. They are now uploaded once per (device, dtype). All values are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ost once `cosmos_unipc` iterated the scheduler's timesteps, which live on the device, and called `float(tick)` on each one. Every step therefore blocked the host until the previous step's kernels drained. The loop now copies the ticks to the host once per request and still hands each step the same device tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… per request - Every DiT forward cast the (B, T+I, 53248) fp32 LLM features to bf16, masked them, ran the RMSNorm and the 53248 -> 4608 projection (plus its TP all-gather), all on inputs that never change within a request. The denoising stage now projects each branch's conditioning on its first step, inside that step's forward context, and passes it to later steps as `llm_cond`. BCG replays keep passing the raw features, which their prompt padder pads, and the progressive stage drops the projection when the sequence grows. - Decode uploaded the latent scale/shift per request; that pageable upload stalled the host until the denoising loop drained. They are uploaded once per (device, dtype). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t sync - The eager forward dropped the unconditional branch's prior tokens with `prior_embedding[prior_token_drop] *= 0.0`. The boolean-mask read and write each run a nonzero, so every forward (every step x CFG branch) blocked the host twice. It now selects `prior_embedding * 0.0` with `torch.where`, which keeps the exact values, signed zeros included. - Decode uploaded the latent mean/std per request, stalling the host until the denoising loop drained; they are uploaded once per (device, dtype). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without sequence parallelism the pipeline config always handed the DiT a key mask, even for a single prompt with no padding. Every forward then ran `bool(mask.all())` to find out the mask was all valid, which blocked the host once per step and CFG branch. The config now makes that decision once per request and passes `mask=None` for unpadded prompts, the same maskless path the DiT took before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…off the host Each step blocked the host on the device several times: - `t.repeat(bs).to(device)` uploaded the CPU timestep from pageable memory. - `timestep_embedding` built its frequencies on the host and uploaded them on every call, and a CFG step embeds timesteps five times. - The image-token scatter and the prediction gather ran `masked_select` on the request-constant image mask (and the legacy boolean timestep mask), whose output size is only known on the host. - The edit path uploaded the condition timesteps every step. The loop now takes the mask positions, the device timesteps and the condition timesteps once per request, gathers the predictions with those positions, and uploads the host-built frequencies once per device. Values are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p syncs - `t2i_generate` checked the CFG window with `t >= lo and t <= hi` on the device timestep, two host syncs per step. The window is now computed for the whole schedule with the same float32 comparisons and read once per request. - The timestep embedder built its frequencies on the host and uploaded them on every call, which blocked the host each step; they are uploaded once per device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every block's cross-attention cast the int64 tokenizer mask to bool, one launch per block for a mask that is the same for the whole forward (20 blocks x 2 CFG branches per step on SANA 1.6B, which its own docstring notes is launch-bound in eager mode). The forward now casts it once; the blocks' cast becomes a no-op on the bool mask. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
mickqian
requested review from
AgainstEntropy,
BBuf,
HaiShaw,
JustinTong0323,
kevin-mii,
niehen6174,
ping1jing2,
sogalin,
wisclmy0611,
yichiche and
zijiexia
as code owners
October 23, 2026 11:56
Collaborator
Author
|
/tag-and-rerun-ci |
Fold the guard's own tests and the shared denoising, scheduler, RoPE and attention host-sync tests into test_host_sync.py, and compare outputs with torch.testing.assert_close(rtol=0, atol=0). Add the helpers the model host-overhead tests share to host_sync_guard.py: RecordOps (aten op recorder counting casts and host-data uploads), CountedUploads (host tensor counting its copies to the device), and tiny_dit_runtime / reference_custom_ops (SDPA attention and reference CustomOps for tiny single-process DiTs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ffusion-image-models-host-overhead Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… model files GLM-Image, HunyuanImage-3, Krea-2 and SANA tests join the models' existing test files (test_glm_image_multi_output.py, test_hunyuan_image3_tp_broadcast.py, test_krea2_fp8.py and the SANA family's test_sana_video.py), and SANA counts its bool casts with the shared RecordOps. No test is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 24, 2026
5 tasks done
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on #46045 (host-sync guards in
test/unit/host_sync_guard.py); this branch is stacked on it.Motivation
Several image pipelines read device values on the host or upload constants with pageable copies inside the denoising loop, or redo request-constant work in every forward. Each such read blocks the host until the stream drains, so it cannot queue the next step while the GPU runs this one. This PR removes them model by model. All values are unchanged.
Modifications
dits/qwen_image.py,pipeline_configs/qwen_image.py,qwen_image21.py,vaes/base.py,qwen_image_layered.py):additional_t_condwith a pageabletorch.tensor([0])per CFG branch. The branches got different objects, so the DiT's modulation cache never hit on the negative branch and every step recomputed 60 blocks x 2 modulation GEMMs. Both branches now share one device tensor.flux3_action.py):cosmos_unipccalledfloat(tick)on each device tick, a sync per step. The ticks are copied to the host once per request; each step still gets the same device tick.dits/ideogram.py,model_specific_stages/ideogram.py,progressive_resolution/ideogram.py): every forward cast the (B, T+I, 53248) fp32 LLM features to bf16, masked them and ran the RMSNorm and the 53248 -> 4608 projection (plus its TP all-gather) on inputs that never change within a request. The stage projects each branch's conditioning on its first step, inside that step's forward context, and passes it to later steps asllm_cond. BCG replays still pass the raw features, which their prompt padder pads; the progressive stage drops the projection when the sequence grows. Decode uploads its latent scale/shift once per (device, dtype).dits/glm_image.py,pipeline_configs/glm_image.py): the eager forward dropped the unconditional branch's prior tokens withprior_embedding[prior_token_drop] *= 0.0, two nonzero syncs per forward. It now selectsprior_embedding * 0.0withtorch.where(same values, signed zeros included). Decode stats are uploaded once.pipeline_configs/krea2.py): without SP the DiT always got a key mask and ranbool(mask.all())every forward. The config decides once per request and passesmask=Nonefor unpadded prompts, the maskless path the DiT took before.hunyuan_image3_utils.py,hunyuan_image3/ar_stage.py): per step, a pageable timestep upload, host-builttimestep_embeddingfrequencies uploaded on every call (five calls per CFG step),masked_selecton the request-constant image mask for the scatter and the gather, and per-step condition-timestep uploads on the edit path. Positions, device timesteps and condition timesteps are taken once per request, predictions are gathered by position, and the frequencies are uploaded once per device.modeling_neo_chat.py,modeling_fm_modules.py): the CFG-window check compared the device timestep in Python (two syncs per step); the window is computed for the whole schedule with the same fp32 comparisons and read once. The timestep embedder's frequencies are uploaded once per device.dits/sana.py): every block cast the int64 prompt mask to bool; the forward casts it once.Tests:
test_qwen_image_rope.py: batched prompts of different lengths build their varlen metadata once, and the second forward matches the first bitwise.test_qwen_image_layered.py: both CFG branches shareadditional_t_cond; VAE and condition-latent stats are uploaded once.test_qwen_image21.py,test_glm_image_dit.py,test_ideogram4.py: decode stats/constants are uploaded once and reused bitwise.test_flux3_action.py:cosmos_unipcwithSyncOnReadticks matches the plain run bitwise.test_ideogram4.py: the LLM projection runs once per branch and every step matches the unprojected forward bitwise; BCG replays get the raw features.progressive_resolution/test_progressive.py: a resolution change drops the projection.test_glm_image_dit.py: the prior-token drop runs underforbid_host_syncsand equals the graph path'smasked_fill.test_krea2_pipeline_config.py: only ragged prompts carry a key mask.test_hunyuan_image3_denoise_step.py: timestep frequencies are built once; the image-token scatter/gather runs underforbid_host_syncsthrough the precomputed positions.test_sensenova_u1.py: timestep frequencies are built once per device.test_sana_dit.py: the prompt mask is cast once per forward, output unchanged.The model tests run their tiny DiTs on CUDA when the runner has one (the diffusion unit suite runs on an H100).
No hunk overlaps #45942, #45943, #45944, #45948, #45965, #45966, #45967, #45977, #45978, #45979 or #45987; each of them still applies on top of this branch. #45967 also uploads Qwen-Image-2.1 latent stats once, in the encoding stage; this PR covers decode, a different function.
Accuracy Tests
2xH200 (1 GPU for Qwen-Image-2.1), eager profiles, two requests per model with different inputs. Decoded pixels are bitwise identical to main:
Speed Tests and Profiling
Host syncs inside the denoising stage per step (rank 0,
torch.cuda.set_sync_debug_mode("warn")around the stage, real server requests):Client latency, interleaved A/B (main, PR, PR, main), fresh server per run, median of 5 measured requests per run:
Checklist
🤖 Generated with Claude Code
CI States
Latest PR Test (Base): ✅ Run #40059865618
Latest PR Test (Extra): ❌ Run #40059865542
Latest PR Test (AMD ROCm 10): ❌ Run #40059865627