Repository navigation
[Perf] diffusion: Keep shared denoising paths off the host and add host-sync test guards - #46045
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>
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
Contributor
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
4 of 5 tasks
4 of 5 tasks
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>
This was referenced Oct 27, 2026
This was referenced Nov 4, 2026
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.
Motivation
A denoising step that reads a device value on the host (
item(),float(t),if t:,nonzero, boolean-mask indexing) or uploads a constant with a pageable copy blocks the host until the stream drains. The host then cannot queue the next step while the GPU runs this one. These regressions pass every numeric test, so they come back. This PR adds unit-test guards for them and removes the ones in shared code: RoPE position setup, two schedulers, the DMD stage and the ring-attention hop.The model-specific fixes are in two follow-ups that use these guards: image models (#) and video models (#).
Modifications
Guards (
test/unit/host_sync_guard.py):no_cuda_sync(allow=...)/assert_no_cuda_sync(fn, ...)run the code undertorch.cuda.set_sync_debug_mode("warn")plus explicitsynchronize()calls, and fail with the call site of every sync whose issuing function is not allow-listed. They skip without CUDA and restore the debug mode.SyncOnReadis a CPU stand-in for device tensors (timesteps, sigmas, masks): host reads of its values raise, and.cpu()is the explicit copy that is allowed.forbid_host_syncs()is aTorchDispatchModethat fails on the ops that sync on CUDA whatever tensor they read: scalar reads,nonzero, boolean-mask indexing, data-dependent output shapes.Shared hot paths:
rotary_embedding/mrope.py:OneDRotaryEmbeddingkeyed an LRU cache ontuple(pos.tolist()), a device read per axis plus a hash over thousands of floats, and a pageable upload on a miss. FLUX.1/Kontext/FLUX.2, JoyImage, LingBot and the Wan sequence-shard path paid it per image, text and condition axis on every request. It now computes from the device positions directly (same ops, same values).rotary_embedding/{utils,base}.py,layernorm.py:apply_qk_norm_rope,apply_flashinfer_rope_qk_inplaceand bothRotaryEmbeddingpaths rebuilt[offset, offset + seq)with anarange(and arepeatfor batch > 1) in every block when the caller passes no positions: 64-120 launches per step in eager mode for FLUX.2, Z-Image, Qwen-Image, Wan and others.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 arange it did not execute.schedulers/kandinsky6_piflow.py: the policy rollout loopedrange(substeps.max().item()), a device read right after the DiT forward on every step (video and audio scheduler). The substep counts now come from a host copy of the same fp32 math.schedulers/scheduling_helios.py: UniPC builttorch.tensor()over lists of 0-dim device tensors and uploaded0.5constants, about a dozen syncs per step; they are stacked and filled on the device, andlinalg.solvebecomeslinalg.solve_ex(same kernels, no host-side singularity check). The DMD path read the timestep twice per step throughtorch.full(..., tensor); it is now cast and expanded on the device.stages/denoising_dmd.py(Wan DMD / FastWan):int(t.item())per step now comes from a host copy of the same schedule, and the per-step NaN assert is OR-ed on the device and checked once after the loop (same message; a NaN request still fails).attention/backends/flash_attn.py,attention/layer.py: the ring hop builtcu_seqlens_q/kwithtorch.tensor(..., device=...)on every hop of every block, twice per hop for the tail-pad path. The hop now uses the cachedcu_seqlens_on_device, moved next to the FA backend so the varlen and ring paths share it.Tests (the fix tests fail on main):
test_host_sync_guard.py: the three guards catch scalar reads, explicit synchronizes, boolean-mask indexing and data-dependent shapes, let device-side selects through, honor the allow list and restore the debug mode.test_denoising_host_sync.py: one CFG request through the sharedDenoisingStageloop withSyncOnReadtimesteps matches a plain run, and runs underno_cuda_syncon CUDA; one DMD request withSyncOnReadtimesteps matches a plain run bitwise, and NaN latents still raise.test_rope_host_sync.py:FluxPosEmbedwithSyncOnReadpositions matches bitwise; positions are built once per shape, rebuilt under compile and capture.test_scheduler_host_sync.py: PiFlow and Helios UniPC/DMD steps withSyncOnReadschedules match plain runs bitwise, NaN bits included.test_attention_host_sync.py: two ring hops get[0, n]int32 bounds from the same device tensors.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.
Accuracy Tests
2xH200, eager. Decoded outputs are bitwise identical to main for every model below, two requests each with different inputs (pixels for images, decoded frames and audio for video), with this PR and both follow-ups applied:
Speed Tests and Profiling
Host syncs inside the denoising stage, counted with
torch.cuda.set_sync_debug_mode("warn")around the stage's forward on a real server request (rank 0, second request, per denoising step):None of the twelve models above runs the code this PR changes in a way that syncs per step: their counts move only through the follow-ups (#, #). The paths changed here are FLUX.1/FLUX.2/JoyImage/LingBot RoPE setup, Kandinsky-6 PiFlow, Helios, Wan DMD / FastWan and ring attention on the FA backend. A GPU sync count and A/B for those (FastWan2.2-TI2V DMD, Wan2.1 with ring degree 2, FLUX.2 klein) is still to be run.
The CUDA guard tests (
no_cuda_sync) ran on an H200 and pass.Keep this PR in draft until the A/B above exists; the RoPE-position change removes 64-120 small launches per eager step, which should show on launch-bound models (FLUX.2, Z-Image), but it has not been measured in isolation.
Checklist
🤖 Generated with Claude Code
CI States
Latest PR Test (Base): ✅ Run #40059851951
Latest PR Test (Extra): ❌ Run #40059851924
Latest PR Test (AMD ROCm 10): ❌ Run #40059851923