Skip to content

compute/xla: cuDNN flash as typed FusedSDPA + VJP#37

Merged
janpfeifer merged 23 commits into
gomlx:mainfrom
guygrigsby:flash-attention
Jul 3, 2026
Merged

compute/xla: cuDNN flash as typed FusedSDPA + VJP#37
janpfeifer merged 23 commits into
gomlx:mainfrom
guygrigsby:flash-attention

Conversation

@guygrigsby

@guygrigsby guygrigsby commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Implements the typed flash ops from gomlx/compute#13 for the XLA backend, so the cuDNN custom-call mapping stays inside go-xla (per gomlx/gomlx#422):

  • FusedScaledDotProductAttention (forward) and FusedScaledDotProductAttentionVJP (backward) in a new flash.go, via the __cudnn$fmhaSoftmax / __cudnn$fmhaSoftmaxBackward custom-calls. The forward returns the f32 softmax stats (log-sum-exp); the VJP threads them into the backward, so neither pass materializes the [B,H,S,S] scores.
  • The backend_config and operand/result layouts (and their JAX/StableHLO provenance) live here, not in gomlx.
  • Capability gated on the cuda plugin (IsCUDA). Off-cuda and unsupported combos (non-causal, explicit mask, non-BSHD, unequal heads) return ErrNotImplemented for a clean fallback to decomposed attention.
  • CustomCall is now a private customCall helper rather than a method on the Function interface. The low-level stablehlo.CustomCall emitter and its tests are unchanged.

Depends on gomlx/compute#13.

Context: gomlx/gomlx#422.

Exposes stablehlo.custom_call (the op was in the optype enum but had no builder).
Sets call_target_name, api_version, raw backend_config, and pre-rendered
operand/result layout attributes; multi-output for the result + workspace tuple.
This is the primitive needed to emit the cuDNN fused-attention custom-calls
(__cudnn$fmhaSoftmax / -Backward). Test emits the flash forward and checks the
rendered StableHLO matches what JAX emits.
Emits __cudnn$fmhaSoftmax via stablehlo.CustomCall, compiles through PJRT, and
runs it. Follows the existing convention: a normal test that skips unless
-plugin cuda (so `go test ./...` on the cpu plugin skips it). With q=k=v=ones the
uniform-softmax output must be all 1.0; verified on an RTX 3070 Ti (sm_86, cuDNN
9.23). Confirms XLA lowers our emitted custom_call to cuDNN flash.
Run: go test -plugin cuda -run TestFMHA ./pjrt/
Chains the training forward (which also returns the softmax stats f32[B,H,S])
through a transpose into __cudnn$fmhaSoftmaxBackward, all in one compiled graph,
and runs it: dQ/dK/dV come back finite. Confirms XLA lowers the flash backward we
emit. Plus buffer/exec cleanup so the forward and backward tests don't starve
each other's device memory when run in the same process. Both skip unless
-plugin cuda.
Wires the backend Function.CustomCall through to stablehlo.CustomCall, converting
the compute output shapes to the StableHLO shape type. Multi-output: one result
Value per spec.OutputShapes entry. This is what lets the gomlx graph layer emit
cuDNN flash-attention custom_calls on the XLA backend.
A __cudnn$ custom_call only lowers on the cuda plugin; on cpu/rocm it built fine and
then failed at compile time with an unknown-target error. Return ErrNotImplemented at
build time instead, so callers (flash attention) fall back to a decomposed path rather
than hitting an uncatchable compile failure.
Comment thread stablehlo/customcall.go Outdated
// output plus a scratch workspace buffer).
//
// Returns one output Value per outputShape, in order.
func CustomCall(target string, apiVersion int, backendConfig, operandLayouts, resultLayouts string,

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.

What about the following API instead:

func CustomCallV2(
	target string,
	backendConfig string,
	operands []*Value,        // Grouped inputs
	operandLayouts [][]int, 
	outputShapes []shapes.Shape, // Grouped outputs
	outputLayouts [][]int,
) ([]*Value, error) {
    // ...
}
  • API version hard-coded in the signature: the current V4 has a different signature, it cannot easily be the same function (https://openxla.org/xla/custom_call).
  • Operands come before outputs.
  • Layouts inlined with paired operand/result -- they are coupled by the length if defined.
  • Layout as a list of int: if they are nil, they default to row-major, that is, a decreasing list of ints with the length of the corresponding parameter's rank.

Example call:

out, err := CustomCallV2(
    "__cudnn$fmhaSoftmax", cfg, 
    []*Value{q, k, v}, nil, // inputs and their layouts
    outShapes, nil,         // outputs and their layouts
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted this shape in 0015b77. CustomCallV2(target, backendConfig, operands []*Value, operandLayouts [][]int, outputShapes []shapes.Shape, outputLayouts [][]int) ([]*Value, error) — operands before outputs, layouts paired by position, nil layout defaults to row-major. Version stays out of the signature (V2, not a hard-coded V4). The nil-slice vs nil-entry default is documented in 8bd845e.

Comment thread pjrt/fmha_test.go Outdated
// 1.0 (the weighted sum of all-ones values), independent of the causal mask. Needs
// the cuda plugin (cuDNN); skipped otherwise.
func TestFMHAForwardExecute(t *testing.T) {
if *FlagPluginName != "cuda" {

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.

Instead, use client.Plugin().IsCUDA()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Switched to client.Plugin().IsCUDA() in 9350689 (fmha tests also moved onto CustomCallV2 there).

@janpfeifer

janpfeifer commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

From digging (the AI did the digging) into the Jax code I gathered there are the following custom calls:

Feature / Variant Forward Custom Call Target Backward Custom Call Target Supported Input Data Types (Dtypes)
Standard (No Bias, No Dropout) __cudnn$fmhaSoftmax __cudnn$fmhaSoftmaxBackward Float16, Bfloat16
With Bias (No Dropout) __cudnn$fmhaScaleBiasSoftmax __cudnn$fmhaScaleBiasSoftmaxBackward Float16, Bfloat16
With Dropout (No Bias) __cudnn$fmhaSoftmaxDropout __cudnn$fmhaSoftmaxDropoutBackward Float16, Bfloat16
With Bias & Dropout __cudnn$fmhaScaleBiasSoftmaxDropout __cudnn$fmhaScaleBiasSoftmaxDropoutBackward Float16, Bfloat16
FP8 (Floating Point 8) __cudnn$fmhaSoftmaxF8 __cudnn$fmhaSoftmaxBackwardF8 FP8 E4M3 (float8_e4m3fn), FP8 E5M2 (float8_e5m2)

A FusedSDPA implementation would have to return a NotImplemented error if the dtype is different, which the attention.Core automatically handles by falling back to the decomposed version.

@janpfeifer

Copy link
Copy Markdown
Contributor

Also of interest, some performance metrics: jax-ml/jax#24934

…mCall

Implement FusedScaledDotProductAttention (forward) and FusedScaledDotProductAttentionVJP
(backward) in flash.go via the cuDNN __cudnn$fmhaSoftmax / __cudnn$fmhaSoftmaxBackward
custom-calls. The backend_config + layout knowledge moves here from gomlx; it never leaves
go-xla. The forward returns the f32 softmax stats (log-sum-exp) the VJP threads into the
backward. Both return ErrNotImplemented off-cuda or for unsupported combos (non-causal,
explicit mask, non-BSHD, unequal heads) so the graph layer differentiates decomposed.

CustomCall drops from the public Function surface (interface no longer has it) and becomes a
private customCall helper. FusedSDPA capability is gated on the cuda plugin at construction.
The low-level stablehlo.CustomCall primitive and its tests are unchanged.
@guygrigsby guygrigsby changed the title compute/xla: CustomCall and the cuDNN flash attention custom-calls compute/xla: cuDNN flash as typed FusedSDPA + VJP Jun 24, 2026
@janpfeifer

Copy link
Copy Markdown
Contributor

Thanks for the changes @guygrigsby !

A few initial comments:

  1. My suggestion of changing the CustomCall API signature to use []int for layouts and a signature per version, even if now these calls are internal to go-xla are still are valid I think. I suspect more custom calls (and for different API versions) will be used in the future, and that would improve the API.
  2. In XLA, because of fixed shapes and the need for padding, masking is almost always needed, which you are currently not supporting. The AI tells me that the cuDNN calls accepts masking, but a faster way of doing it (both are supported) is passing a keyValueSeqLen and querySeqLen, so a full mask matrix is not consuming HBM memory. Unfortunately, because I didn't think about this earlier, this alternative masking (with the sequence lengths) need to propagate all the way to GoMLX's layers attention API, as an alternative. Even go-huggingface's transformer library needs change to make use of it -- but I can do it later.
  3. I noticed there are different custom calls for different set of parameters (dropout/non-dropout, etc.) Ideally, we should add support for as many variations as possible (at least the ones we support in the upstream libraries), since we are at it.

Ugh ... this is a long list of asks, I apologize. Please, let me know if it's beyond what you have time to contribute, I can work on this next week if that is the case.

Btw, I attempted to run a benchmark on other examples of sentence embedders and generators, but because of masking they never triggered the cuDNN custom calls. See last entries in the Sentence Embed benchmarks tab in this spreadsheet.

@janpfeifer

Copy link
Copy Markdown
Contributor

Btw, I merged some changes I did in main into your branch -- I hope it's ok. You probably will need to pull those changes into your local clone.

It includes adding more (and better) options support for the XLA backend, including the options preallocate and memory_fraction that you can use to control the CUDA preallocation and memory fraction in a friendlier way. Example, from the github.com/gomlx/gomlx root, to train a mnist model without GPU preallocation:

$ GOMLX_BACKEND="xla:cuda,preallocate=false" go run ./examples/mnist/demo/ -set='train_steps=100_000'

Or, to enable internal XLA loggign (it uses an old tensorflow logging framework):

$ TF_CPP_MIN_LOG_LEVEL=0 GOMLX_BACKEND="xla:cuda,preallocate=false" go run ./examples/mnist/demo/ -set='train_steps=100_000'

@guygrigsby

Copy link
Copy Markdown
Contributor Author

Ugh ... this is a long list of asks, I apologize. Please, let me know if it's beyond what you have time to contribute, I can work on this next week if that is the case.

I will review the list closely tonight or tomorrow. I don't think it will be too much. I don't mind. ❤️

flashSupported no longer rejects non-causal or missing seqlens: any
combination of causal/seqlens is valid; selectFMHAVariant picks the
mask_type (NO_MASK, CAUSAL, PADDING, PADDING_CAUSAL). Materialized mask
and mismatched seqlens (one without the other) remain rejected.

Add TestSelectFMHAVariant_SeqLenPadding (CPU, confirms PADDING /
PADDING_CAUSAL routing and causal regression).
Add TestFMHA_SeqLenPaddingCausal_cuda ([cuda], self-skips on Mac).
Replace 3 stablehlo.CustomCall sites with CustomCallV2 ([][]int layouts).
Switch cuda guard from FlagPluginName string compare to client.Plugin().IsCUDA();
move skip after getPJRTClient so the plugin is available. Both tests compile
and SKIP cleanly on CPU/Mac; cuDNN execution path unchanged for cuda host.
Add readFlatBF16 and assertSeqLenMasksOutput helpers. The latter compares
the padding positions (s >= S/2) of the short batch element against the
same positions of the full-length element: with q=k=v=ones, cuDNN
PADDING_CAUSAL zeros padding-query outputs, so they must differ from the
active (non-zero) outputs of batch 0. The test now fails if cuDNN ignores
the seqlens. Finiteness assertion retained.
@guygrigsby

guygrigsby commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed the list and it'll take me a couple days between other things, but it's no problem. All your suggestions are good. I can handle the API changes, padding/masking, most variant work and go-huggingface. The only thing I can't do is the FP8 variant because I have no way to test that right now. RTX 3070 (Ampere) doesn't support it and that's all I have to test.

…istinction

compute/xla: validate seqlen operand dtype and shape before cuDNN custom-call

validateSeqLen helper checks int32 rank-1 [B] for QuerySeqLen/KeyValueSeqLen
in both the forward and VJP paths; wrong dtype or length now returns a clear
error before the operand reaches XLA lowering. Add CPU-runnable unit tests
covering wrong dtype, wrong rank, wrong length, non-Node value, and the
happy path (TestValidateSeqLen).
@janpfeifer

Copy link
Copy Markdown
Contributor

Reviewed the list and it'll take me a couple days between other things, but it's no problem. All your suggestions are good. I can handle the API changes, padding/masking, most variant work and go-huggingface. The only thing I can't do is the FP8 variant because I have no way to test that right now. RTX 3070 (Ampere) doesn't support it and that's all I have to test.

Thanks!! No worry about the FP8, I can add it later.

Comment thread compute/xla/flash.go Outdated
if !f.builder.backend.plugin.IsCUDA() {
return errors.Wrapf(compute.ErrNotImplemented, "%s: cuDNN flash needs the cuda plugin, have %q", op, f.builder.backend.pluginName)
}
if !causal || mask != nil || axesLayout != compute.AxesLayoutBSHD || numKVHeads != numHeads {

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: What about adding to the error exactly which things were not supported, so one can easily track down why some model can't use flash attention ? With the logging output I added earlier, one can output the failure to use a fusion op, and easily investigate whether/why their model is not using fusion (flash attention).

Also I'm missing the check for the dtypes supported (I think only dtypes.Float16 and dtype.BFloat16, which can be checked with dtype.IsHalfPrecision()).

@guygrigsby guygrigsby Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in the incoming update: flashSupported now returns a distinct ErrNotImplemented per failing condition (cuda plugin, dtype, materialized mask, non-BSHD/unequal heads, mismatched seqlens), and gates dtype via IsHalfPrecision (f16/bf16 only). Pairs with your V(2) fusion logging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Landed in 7ebc65e: flashSupported returns a distinct ErrNotImplemented reason per failing condition (non-cuda plugin, unsupported dtype, materialized mask, non-BSHD/unequal heads, mismatched seqlens), and gates dtype via IsHalfPrecision (f16/bf16 only). Pairs with your V(2) fusion logging for tracing why a model isn't fusing.

@janpfeifer

Copy link
Copy Markdown
Contributor

After a bit more investigation, the Jax cuDNN version of SDPA with dropout is not working -- it takes the random seed as a static parameter (part of the backend_config), and would always generate the same mask.

But also, dropout in attention models is not usually used -- it doesn't help and it's pretty expensive (it regenerates the random mask all the time).

In other words, probably we don't want to support dropout.

In some future we may want to integrate NVIDIA Transformer Engine into GoMLX, also for adding supprot for Blackwells (>= RTX 5090) FP4 format, but it's lots of work, and I would expect eventually that support to be added to the standard JAX path.

… clearer reason

Per review: consolidate the 'why no fusion' reasons in flashSupported and add the
missing dtype check (float16/bfloat16 via IsHalfPrecision) with an explicit message.
@guygrigsby

guygrigsby commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

On dropout: agreed, and thanks for confirming from the JAX side. Cutting dropout (and bias+dropout) from scope for exactly that reason. fp8 is likewise paused (no local sm_8.9+ hardware). Both are left as NotImplemented seams that fall back to the decomposed path.

Stage 1: CustomCallV2, fmha target seam, seqlen padding masks
@guygrigsby

guygrigsby commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Stage 1 of the fused-attention work just landed here. Terminology: Stage 1 is CustomCallV2, the config-driven fmha variant seams, and sequence-length padding masks, which is this PR. Stage 2 is additive attention bias via __cudnn$fmhaScaleBiasSoftmax, a separate cumulative PR: #39.

What the review asked for:

  • CustomCallV2 with the shape you suggested: CustomCallV2(target, backendConfig string, operands []*Value, operandLayouts [][]int, outputShapes []shapes.Shape, outputLayouts [][]int). Version pinned in the name, operands before outputs, typed [][]int layouts (nil slice omits the attribute, a nil entry defaults to row-major). The old string-layout CustomCall is removed and all callers migrated.
  • client.Plugin().IsCUDA() in the pjrt fmha tests.
  • Sequence-length masking: seqlen operands appended, mask_type derives from (causal, seqlens) into CAUSAL / PADDING / PADDING_CAUSAL / NO_MASK. flashSupported is relaxed to accept seqlens, returns a distinct ErrNotImplemented per failing condition so you can see why a model did not fuse, and gates dtype via IsHalfPrecision. Validated on an RTX 3070 Ti.
  • More variants: bias is Stage 2 (Stage 2: cuDNN fmhaScaleBias variant (additive attention bias, cumulative on #37) #39), validated forward and backward against a JAX-lowered ScaleBias reference. Dropout: agreed with your finding and cut it (JAX cuDNN dropout takes a static seed so it always generates the same mask, and attention dropout is not usually used); left as a NotImplemented seam. fp8 is paused, no local sm_8.9+ hardware, falls through to ErrNotImplemented and decomposes.

guygrigsby and others added 6 commits July 2, 2026 10:54
…mentation to the PJRT plugin.

Properly set the `FusedScaledDotProductAttentionVJP` capability.
…use json encoding to generate the backend config,

to facilitate reconfigurability.
BackendConfig now generated from arbitrary `map[string]any`, easier to manipulate and configure.
@janpfeifer

Copy link
Copy Markdown
Contributor

Following the updates to tests done in gomlx/gomlx/#427, I updated the implementation to match:

  • Added Backend.IsCUDA and Backend.IsCPU that uses the underlying PJRT plugin.
  • Check for featureDim to be multiple of 8, and if not report accordingly.
  • Refactored flashBackendConfigV to take as input map[string]any parameters, and then encode into json. To facilitate configuration.
  • Added support for BHSD layout.
  • Added Float16 layout.
  • Removed automatic conversion to BFloat16: at the Backend level we don't want any automatic conversion, and instead have everything explicit. Upper levels may want to decide to do this automatically to the user -- although I would rather simply fail and report why, so the user can decide and be aware of the loss of precision if they want.

I'll merge this as is, because this frees you to move to the stage 2. I apologize for the main this may be merging into the other PR... But hopefully the refactored flashBackendConfigV may make it easier to configure?

janpfeifer added a commit to gomlx/compute that referenced this pull request Jul 3, 2026
…omCall (#13)

Per the design discussion in gomlx/gomlx#422: keep the cuDNN target
mapping off the cross-backend surface and express flash attention as
typed fused ops instead of a generic escape hatch.

- `FusedScaledDotProductAttention` now returns `(output, softmaxStats,
err)`. `softmaxStats` is optional (may be nil); a backend with a fused
backward returns the log-sum-exp the backward needs, others return nil.
- New `FusedScaledDotProductAttentionVJP(query, key, value, mask, ...,
output, softmaxStats, dOutput) (dQuery, dKey, dValue, err)`. Returns
`ErrNotImplemented` when the backend has no fused backward, so the graph
layer differentiates the decomposed attention instead.
- Removed the generic `CustomCall` / `CustomCallSpec` from the
`Function` interface. The cuDNN custom-call mapping now lives in the xla
backend (gomlx/go-xla#37), not on the shared interface.

Regenerated opsregistration; the two multi-return methods are
hand-stubbed in `notimplemented` (the generator only templates `(Value,
error)`). The Go backend returns nil stats, so its attention gradient
stays decomposed.

Base of the flash stack: gomlx/go-xla#37 and gomlx/gomlx#427 build on
it.

Context: gomlx/gomlx#422.

---------

Co-authored-by: Jan Pfeifer <pfeifer@gmail.com>
@janpfeifer
janpfeifer merged commit cf1e447 into gomlx:main Jul 3, 2026
@guygrigsby

Copy link
Copy Markdown
Contributor Author

Thanks for the help. No worries about the other merges. I'll sort it out.

janpfeifer added a commit that referenced this pull request Jul 8, 2026
Stage 2: cuDNN fmhaScaleBias variant (additive attention bias, cumulative on #37)
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