Skip to content

[Bugfix][Model] Fix DiffusionGemma silently freezing attention mask under CUDA graph replay - #51994

Merged
LucasWilkinson merged 11 commits into
vllm-project:mainfrom
fjosw:fix/dgemma-causal-cudagraph-stale-ptr
Sep 30, 2026
Merged

LucasWilkinson merged 11 commits into
vllm-project:mainfrom
fjosw:fix/dgemma-causal-cudagraph-stale-ptr

Conversation

@fjosw

@fjosw fjosw commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Purpose

While looking into whether FULL CUDA graphs are worth the added complexity for DiffusionGemma, I found that DiffusionGemmaModelState._causal_buf (the per-request flag that switches each request between causal/encoder and bidirectional/denoise attention) stops being updated once CUDAGraphMode.FULL graphs are captured. It's bool, but FlashAttentionMetadataBuilder.build() casts non-int32 causal tensors via causal.to(torch.int32), allocating a new tensor every call. A FULL-mode graph binds to the address it saw at capture time, so once captured it keeps reading that orphaned tensor forever. Later updates to _causal_buf never reach it. Any request served through an already-captured FULL graph gets whatever causal/bidirectional pattern existed at capture (warmup) time for its slot, for the graph's whole lifetime, silently.

This isn't opt-in: vLLM's default optimization level (O2) resolves cudagraph_mode to FULL_AND_PIECEWISE, whose decode_mode() is FULL, so vllm serve google/diffusiongemma-26B-A4B-it with no flags already runs every denoise/decode step under FULL cudagraphs.

I couldn't pin observed output corruption on this specifically: an E2E FULL-vs-PIECEWISE comparison was inconclusive, since DiffusionGemma's denoising has its own run-to-run variance of similar magnitude. The capture/replay test below reproduces the mechanism directly instead. So overall, this is a silent bug that likely has some unpredictable impact on output quality.

Fix: make _causal_buf int32 from construction and update it in place (.copy_() / direct assignment) instead of = from a bool source, so build() takes its dtype-matched fast path and never reallocates.

Tagging @LucasWilkinson, who authored the original FULL-cudagraph path in #45163.

AI-assisted, every line reviewed and tested by the submitter.

Test Plan

pytest tests/models/test_diffusion_gemma_cudagraph.py -v

Test Result

New regression test constructs a real DiffusionGemmaModelState and drives _causal_buf through an actual torch.cuda.CUDAGraph capture/replay cycle mirroring FlashAttention's cast. Fails on main: replay returns the stale [0, 0, 0, 0] instead of the updated [1, 1, 1, 1]. Passes with this patch. Run on H100 (SM90).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added nvidia bug Something isn't working labels Aug 12, 2026

@LucasWilkinson LucasWilkinson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! thanks; can we remove the .to(...) call too inorder to avoid this footgun?

@fjosw
fjosw requested a review from MatthewBonanni as a code owner August 12, 2026 18:38
@fjosw

fjosw commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @LucasWilkinson , I just pushed a new commit that removes the .to(...) call. build() now raises on non-int32 causal tensors instead, since the cast was out-of-place anyway and would silently detach graph replay from the caller's buffer. As far as I can tell, DiffusionGemma was the only in-tree model passing a tensor here.

@fjosw

fjosw commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Given that this is a bug on the standard path in the official recipe, would you consider merging this @LucasWilkinson @DarkLight1337 @ywang96 @AndreasKaratzas @MatthewBonanni ?

@fjosw
fjosw force-pushed the fix/dgemma-causal-cudagraph-stale-ptr branch from 710b110 to a3b03f5 Compare September 23, 2026 12:50
@fjosw

fjosw commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Hi @mmastrac, @LucasWilkinson, @njhill, @NickLucche I saw that you merged #57250 to add support for Jev-like structured outputs. Quick reminder that this PR fixes an issue where DiffusionGemma can use an incorrect attention mask during FULL CUDA graph replay, switching between causal and bidirectional attention incorrectly.

To my understanding, the example in #57250 uses TRITON_ATTN to which this bug does not apply, the default path should, however, still run into this bug.

The actual impact of a wrong attention mask is actually not super clear to me but the bug can impose a training-inference mismatch. I rebased the branch onto main and would recommend merging this simple fix.

@mmastrac mmastrac left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks correct to me - will need a rebase but otherwise makes sense.

…uffer under CUDA graphs

DiffusionGemmaModelState._causal_buf is bool. FlashAttention's metadata
builder casts non-int32 causal tensors via `causal.to(torch.int32)`,
which allocates a new tensor on every call instead of mutating in place.
Under CUDAGraphMode.FULL, the address returned at capture time is what
the replayed kernel reads for the graph's entire lifetime, so a bool
_causal_buf means later updates never reach an already-captured graph.

Verified on H100 (SM90, FlashAttention v4): constructing a real
DiffusionGemmaModelState and driving its _causal_buf through an actual
torch.cuda.CUDAGraph capture/replay cycle shows the replayed output
frozen at the capture-time value, not the live one.

test_causal_buf_survives_cudagraph_replay fails on this commit,
demonstrating the bug.

Signed-off-by: Fabian Joswig <fjosw@users.noreply.github.com>
…DA graph replay

_causal_buf was bool, but FlashAttentionMetadataBuilder.build() casts
non-int32 causal tensors via causal.to(torch.int32), an out-of-place op
that allocates a new tensor every call. Under CUDAGraphMode.FULL, the
captured graph binds to the address returned at capture time, so later
in-place updates to a bool _causal_buf never reach an already-captured
graph -- replay keeps using whatever causal/bidirectional flags existed
at capture.

Making the buffer int32 up front means build() takes the dtype-matched
fast path and never re-allocates, so in-place updates (.copy_() /
direct assignment) stay visible to the graph across every replay.

Fixes test_causal_buf_survives_cudagraph_replay added in the prior
commit.

Signed-off-by: Fabian Joswig <fjosw@users.noreply.github.com>
Trim redundant docstrings/comments and inline the single-use helper;
no behavior change. The FlashAttention-cast still runs once before
graph capture, matching how build() behaves in production, so the
test still fails against a bool _causal_buf and passes against int32.

Verified on H100.

Signed-off-by: Fabian Joswig <fjosw@users.noreply.github.com>
Per review: drop the silent causal.to(torch.int32) fallback. The cast is
out-of-place by construction (the builder has no persistent buffer to
cast into), so any non-int32 causal tensor silently detaches FULL CUDA
graph replay from the caller's buffer. Raise instead so the next caller
hits a loud error at the first build() rather than stale attention modes.

DiffusionGemma is the only tensor-causal producer and already allocates
int32; comment and regression test updated to state the contract instead
of mirroring the removed cast.

Signed-off-by: Fabian Joswig <fjosw@users.noreply.github.com>
@fjosw
fjosw force-pushed the fix/dgemma-causal-cudagraph-stale-ptr branch from a3b03f5 to 8c67d68 Compare September 23, 2026 13:09
@mmastrac

Copy link
Copy Markdown
Contributor

@fjosw LGTM. If you want to fix a related nit: [num_seqs] bool in triton_unified_attention.py should be int32 (it was wrong before).

@LucasWilkinson FWIW I think this should land as-is.

@LucasWilkinson LucasWilkinson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM thanks for the fix! sorry this slipped under my radar for so long! thanks @mmastrac for flagging

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this test is a bit overly specific, not sure how much value it brings. I think we should probably just remove it

@github-project-automation github-project-automation Bot moved this to Ready in NVIDIA Sep 23, 2026
@LucasWilkinson LucasWilkinson added the ready ONLY add when PR is ready to merge/full CI is needed label Sep 23, 2026
@github-actions

Copy link
Copy Markdown

✅ @fjosw, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Your branch must contain every commit currently on its upstream target branch. Merge or rebase onto the latest target branch, then rerun the command. Append --allow-stale to a run command to test an outdated branch at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@mergify

mergify Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Hi @fjosw, the pre-commit checks have failed. Please run:

uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-files

Then, commit the changes and push to your branch.

For future commits, pre-commit will run automatically on changed files before each commit.

Update the Triton per-sequence causal buffer documentation to match the int32 buffer used by DiffusionGemma.

Co-authored-by: OpenAI Codex <noreply@openai.com>

Signed-off-by: Fabian Joswig <fjosw@users.noreply.github.com>
@fjosw
fjosw requested a review from tdoublep as a code owner September 25, 2026 07:51
@fjosw

fjosw commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for your comments @mmastrac and @LucasWilkinson, I followed your suggestions and removed the test and fixed the comment in triton_unified_attention.py 👍

@mmastrac mmastrac left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LucasWilkinson

Copy link
Copy Markdown
Contributor

/ci run

@LucasWilkinson
LucasWilkinson enabled auto-merge (squash) September 30, 2026 03:26
@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #92028 for commit d4d7cd18c735.

@LucasWilkinson
LucasWilkinson merged commit 1117140 into vllm-project:main Sep 30, 2026
205 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working nvidia ready ONLY add when PR is ready to merge/full CI is needed

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants