Skip to content

Avoid zero-initializing JAX grouped GEMM outputs - #592

Closed
mgoldfarb-nvidia wants to merge 1 commit into
NVIDIA:developfrom
mgoldfarb-nvidia:mgoldfarb/jax-grouped-gemm-output-init
Closed

Avoid zero-initializing JAX grouped GEMM outputs#592
mgoldfarb-nvidia wants to merge 1 commit into
NVIDIA:developfrom
mgoldfarb-nvidia:mgoldfarb/jax-grouped-gemm-output-init

Conversation

@mgoldfarb-nvidia

@mgoldfarb-nvidia mgoldfarb-nvidia commented Aug 14, 2026

Copy link
Copy Markdown

Before submitting

  • I agree to license this contribution under the terms of LICENSE.txt.
  • I ran pre-commit run and committed any formatting changes.
  • I added GitHub labels: one cat-*, one or more mod-*, and one orig-*.

Affected area

Python API or bindings

Summary

Stop zero-initializing the D, C, and workspace outputs of grouped_gemm_jax_sm100. The output specs and CuTeDSL kernel are unchanged. Document that rows at or beyond the final padded offset are unspecified.

Why

Passing initialized_outputs makes JAX zero-fill three output buffers before every grouped GEMM dispatch. The grouped GEMM writes the addressed D/C rows, and its helper writes the workspace. Requiring zero-filled padding adds large device operations to the JAX path without benefiting full-coverage calls, while the equivalent PyTorch API allocates outputs with empty_strided.

Same-node A/B results on an isolated ComputeLab B200 using
benchmark/grouped_gemm_dispatch/bench_dispatch.py from
MR 2332
at head 92b46b39099cec904d62aec0de074b8c0c712201:

M x N x K Parent (us) This PR (us) Latency reduction
2048 x 2048 x 2048 37.135 30.751 17.2%
4096 x 2048 x 2048 53.145 37.618 29.2%
8192 x 4096 x 2048 132.452 101.144 23.6%
16384 x 4096 x 4096 412.191 367.296 10.9%

Environment: one B200 (SM100), cuDNN 9.24.0, CUDA 13.3, JAX 0.11.0, and NVIDIA CUTLASS DSL 4.6.2. Each value is the median of five complete harness invocations, alternating parent/head run order. Each invocation uses five warmups followed by 30 calls and one final block_until_ready(). Median kernel and Torch-execute controls differed by at most 1.5% between variants.

Related issues

None.

API and compatibility impact

Rows before padded_offsets[-1] are unchanged. Rows at or beyond that offset are now explicitly unspecified instead of guaranteed to be zero. No kernel or output-layout changes are included.

Testing

  • uvx pre-commit run --files python/cudnn/gemm/cutedsl/grouped/unfused/jax_api.py (passed)
  • cd test/python && timeout 120 python -m pytest -q -m L0 fe_api/grouped_gemm/test_grouped_gemm_jax.py::test_grouped_gemm_jax_jit_matches_eager on ComputeLab B200 (1 passed)
  • Five alternating parent/head runs of timeout 900 python benchmark/grouped_gemm_dispatch/bench_dispatch.py --api unfused --json <output> on the same ComputeLab B200

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 959e92d2-62fd-4e01-88f9-a0af72d379c2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@hwanseoc

hwanseoc commented Aug 17, 2026

Copy link
Copy Markdown
Member

Closing in favor of #631, which removes zero init for more than just unfused

hwanseoc added a commit to hwanseoc/cudnn-frontend that referenced this pull request Aug 17, 2026
Folds in NVIDIA#592's change, which is being closed in favour of this PR so the
whole audit lands as one piece. d, c and the workspace are all written before
use, so nothing here needed the fill.

Adds the poisoned-allocator test for unfused as well: on fresh device memory
the untouched tail reads back as zeros whether or not the fill is present, so a
test that does not dirty the allocator first passes either way.
@hwanseoc

Copy link
Copy Markdown
Member

Closing in favour of #631, which folds this change in and extends the same audit to the rest of the JAX entry points. The mechanism, the measurement protocol and the framing here are yours — #631 credits this PR as its basis.

For the record, I reproduced this independently on a separate B200 before building on it. jax.jit overhead above kernel went from +26.7 / +21.1 / +29.0 / +52.3 µs to +17.1 / +5.4 / +2.9 / +2.6 across the four shapes. The diagnosis was right and the fix is right.

Two things #631 adds that are worth knowing about:

The tail change is not observable in a clean test. On fresh device memory the rows past padded_offsets[-1] read back as zeros whether or not the fill is present, so a test that does not dirty the allocator first passes either way. #631 adds test_grouped_gemm_jax_addressed_rows_survive_a_dirty_allocator, which poisons the allocator before dispatch — with poisoning, every tail element comes back holding the poison value.

It does not generalize. I tried removing initialized_outputs from all nine remaining sites; six had to be reverted — dense/amax, dense/srelu, grouped/dglu, grouped/dsrelu, discrete_grouped/swiglu, discrete_grouped/dswiglu. The comments there citing the bridge's leading-dim inference rejecting trailing-unit-dim buffers on pure results are still accurate, not stale. All six fail the jit-vs-eager test without the donation, and grouped/dglu returns results differing from torch by 1.125 where it matched exactly.

Happy to hand #631 back or split it if you would rather land this one yourself — no attachment to who carries it.

@hwanseoc hwanseoc closed this Aug 17, 2026
hwanseoc added a commit that referenced this pull request Aug 17, 2026
* jax: stop zero-filling outputs the kernel already writes

Follows #592, which established this for grouped/unfused: passing
initialized_outputs makes XLA zero-fill the buffer before every dispatch, and
that fill scales with the output, so it is the JAX path's dominant host-visible
cost. Extends it to the entry points where the same reasoning holds and, more
importantly, establishes which ones it does not hold for.

Changed:
  grouped/glu    d, c and the workspace are all written before use
  dense/swiglu   ab12 and c are written in full over their extents
  grouped/wgrad  workspace only; the template keeps its zeros because the
                 kernel writes through wgrad_ptrs and never touches it

Deliberately unchanged, having tried and reverted each: dense/amax,
dense/srelu, grouped/dglu, grouped/dsrelu, discrete_grouped/swiglu and
discrete_grouped/dswiglu. Their comments cite the bridge's leading-dim
inference rejecting trailing-unit-dim buffers on pure results, and that is
still accurate -- dropping the donation there fails the JAX jit-vs-eager tests,
and grouped/dglu returns results differing from torch by 1.125. The zeroing is
load-bearing beyond the accumulators.

Rows at or past padded_offsets[-1] are now unspecified for grouped/glu, as they
already are for the torch wrapper's empty_strided outputs. Documented in the
docstring.

B200, bf16, 8 experts, grouped/glu jax.jit, median of 3 alternating runs (us):

  2048x2048x2048     50.6 -> 36.3
  4096x2048x2048     49.1 -> 38.6
  8192x4096x2048    122.7 -> 108.4
  16384x4096x4096   413.4 -> 385.8

At the largest shape that leaves +1.8 us over the kernel, against +29 before.

Adds a partial-coverage test that dirties the allocator before dispatch: on
fresh device memory the tail reads back as zeros either way, so a test that
does not poison it passes whether or not the fill is there.

* jax: stop zero-filling grouped/unfused outputs too

Folds in #592's change, which is being closed in favour of this PR so the
whole audit lands as one piece. d, c and the workspace are all written before
use, so nothing here needed the fill.

Adds the poisoned-allocator test for unfused as well: on fresh device memory
the untouched tail reads back as zeros whether or not the fill is present, so a
test that does not dirty the allocator first passes either way.

* test: gate the new JAX tests on CuTeDSL JAX availability

Both new tests called skip_unless_sm100() without first checking
cutlass.jax.is_available(), unlike every other JAX test in the same two files.
On an SM100 machine without the CuTeDSL JAX extensions they would have failed
rather than skipped. Caught in review by CodeRabbit.
hxbai pushed a commit to hxbai/cudnn-frontend that referenced this pull request Aug 18, 2026
…#631)

* jax: stop zero-filling outputs the kernel already writes

Follows NVIDIA#592, which established this for grouped/unfused: passing
initialized_outputs makes XLA zero-fill the buffer before every dispatch, and
that fill scales with the output, so it is the JAX path's dominant host-visible
cost. Extends it to the entry points where the same reasoning holds and, more
importantly, establishes which ones it does not hold for.

Changed:
  grouped/glu    d, c and the workspace are all written before use
  dense/swiglu   ab12 and c are written in full over their extents
  grouped/wgrad  workspace only; the template keeps its zeros because the
                 kernel writes through wgrad_ptrs and never touches it

Deliberately unchanged, having tried and reverted each: dense/amax,
dense/srelu, grouped/dglu, grouped/dsrelu, discrete_grouped/swiglu and
discrete_grouped/dswiglu. Their comments cite the bridge's leading-dim
inference rejecting trailing-unit-dim buffers on pure results, and that is
still accurate -- dropping the donation there fails the JAX jit-vs-eager tests,
and grouped/dglu returns results differing from torch by 1.125. The zeroing is
load-bearing beyond the accumulators.

Rows at or past padded_offsets[-1] are now unspecified for grouped/glu, as they
already are for the torch wrapper's empty_strided outputs. Documented in the
docstring.

B200, bf16, 8 experts, grouped/glu jax.jit, median of 3 alternating runs (us):

  2048x2048x2048     50.6 -> 36.3
  4096x2048x2048     49.1 -> 38.6
  8192x4096x2048    122.7 -> 108.4
  16384x4096x4096   413.4 -> 385.8

At the largest shape that leaves +1.8 us over the kernel, against +29 before.

Adds a partial-coverage test that dirties the allocator before dispatch: on
fresh device memory the tail reads back as zeros either way, so a test that
does not poison it passes whether or not the fill is there.

* jax: stop zero-filling grouped/unfused outputs too

Folds in NVIDIA#592's change, which is being closed in favour of this PR so the
whole audit lands as one piece. d, c and the workspace are all written before
use, so nothing here needed the fill.

Adds the poisoned-allocator test for unfused as well: on fresh device memory
the untouched tail reads back as zeros whether or not the fill is present, so a
test that does not dirty the allocator first passes either way.

* test: gate the new JAX tests on CuTeDSL JAX availability

Both new tests called skip_unless_sm100() without first checking
cutlass.jax.is_available(), unlike every other JAX test in the same two files.
On an SM100 machine without the CuTeDSL JAX extensions they would have failed
rather than skipped. Caught in review by CodeRabbit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants