Skip to content

[Perf] diffusion: Keep shared denoising paths off the host and add host-sync test guards - #46045

Merged
mickqian merged 6 commits into
mainfrom
mick/diffusion-host-sync-guard-and-shared
Oct 24, 2026
Merged

mickqian merged 6 commits into
mainfrom
mick/diffusion-host-sync-guard-and-shared

Conversation

@mickqian

@mickqian mickqian commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

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 under torch.cuda.set_sync_debug_mode("warn") plus explicit synchronize() 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.
  • SyncOnRead is 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 a TorchDispatchMode that fails on the ops that sync on CUDA whatever tensor they read: scalar reads, nonzero, boolean-mask indexing, data-dependent output shapes.
  • The contributing guide documents the three.

Shared hot paths:

  • rotary_embedding/mrope.py: OneDRotaryEmbedding keyed an LRU cache on tuple(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_inplace and both RotaryEmbedding paths rebuilt [offset, offset + seq) with an arange (and a repeat for 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 looped range(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 built torch.tensor() over lists of 0-dim device tensors and uploaded 0.5 constants, about a dozen syncs per step; they are stacked and filled on the device, and linalg.solve becomes linalg.solve_ex (same kernels, no host-side singularity check). The DMD path read the timestep twice per step through torch.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 built cu_seqlens_q/k with torch.tensor(..., device=...) on every hop of every block, twice per hop for the tail-pad path. The hop now uses the cached cu_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 shared DenoisingStage loop with SyncOnRead timesteps matches a plain run, and runs under no_cuda_sync on CUDA; one DMD request with SyncOnRead timesteps matches a plain run bitwise, and NaN latents still raise.
  • test_rope_host_sync.py: FluxPosEmbed with SyncOnRead positions matches bitwise; positions are built once per shape, rebuilt under compile and capture.
  • test_scheduler_host_sync.py: PiFlow and Helios UniPC/DMD steps with SyncOnRead schedules 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:

model (request) GPUs outputs compared main vs PRs
Qwen-Image-2.1 T2I 1024x1024, 40 steps 1 pixels identical (2/2)
Qwen-Image-2.1 edit 1024x1024, 40 steps 1 pixels identical (2/2)
Z-Image-Turbo 1024x1024, 9 steps, TP2 2 pixels identical (2/2)
FLUX.3 Klein Base 1024x1024, 50 steps, CFG parallel 2 pixels identical (2/2)
Ideogram-4 FP8 1024x1024, 20 steps, TP2 2 pixels identical (2/2)
Cosmos3-Nano T2I 1280x720, 35 steps, CFG parallel 2 pixels identical (2/2)
Cosmos3-Nano T2V 1280x720x61, 35 steps, CFG parallel 2 frames identical (2/2)
HunyuanVideo 848x480x61, 30 steps, Ulysses 2 2 frames identical (2/2)
LTX-2 two-stage 768x512x121, 40 steps, CFG parallel 2 frames + audio identical (2/2)
Wan2.2-T2V-A14B 1280x720x33, 40 steps, CFG parallel 2 frames identical (2/2)
MiniMax-H3 T2VA 1344x768 4 s, 12 steps, Ulysses 2 2 frames + audio identical (2/2)
MOVA-360p I2VA 640x352x97, 20 steps, TP2 2 frames + audio identical (2/2)

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

mickqian and others added 5 commits October 23, 2026 16:17
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>
@mintlify

mintlify Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
sglang-doc 🟢 Ready View Preview Oct 24, 2026, 3:49 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions github-actions Bot added documentation Improvements or additions to documentation diffusion SGLang Diffusion labels Oct 23, 2026
@mickqian

Copy link
Copy Markdown
Collaborator Author

/tag-and-rerun-ci

@github-actions github-actions Bot added the run-ci CI: run the baseline test suite on this PR label Oct 23, 2026
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>
@mickqian
mickqian merged commit 19212d1 into main Oct 24, 2026
143 of 152 checks passed
@mickqian
mickqian deleted the mick/diffusion-host-sync-guard-and-shared branch October 24, 2026 07:40

This branch was successfully deployed

1 active deployment
staging - docs — 9b75f013 Deployed Oct 24, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

diffusion SGLang Diffusion documentation Improvements or additions to documentation run-ci CI: run the baseline test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant