Skip to content

Commit cf1e447

Browse files
authored
Merge pull request #37 from guygrigsby/flash-attention
compute/xla: cuDNN flash as typed FusedSDPA + VJP
2 parents 9f76d27 + c88060b commit cf1e447

9 files changed

Lines changed: 1296 additions & 17 deletions

File tree

compute/xla/flash.go

Lines changed: 470 additions & 0 deletions
Large diffs are not rendered by default.

compute/xla/flash_dispatch_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2023-2026 The GoMLX Authors. SPDX-License-Identifier: Apache-2.0
2+
3+
package xla
4+
5+
import (
6+
"errors"
7+
"strings"
8+
"testing"
9+
10+
"github.com/gomlx/compute"
11+
"github.com/gomlx/compute/dtypes"
12+
"github.com/gomlx/compute/shapes"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestSelectFMHAVariant_StandardCausal(t *testing.T) {
17+
v, err := selectFMHAVariant("op", dtypes.BFloat16, true, nil)
18+
if err != nil {
19+
t.Fatalf("unexpected err: %v", err)
20+
}
21+
if v.fwdTarget != "__cudnn$fmhaSoftmax" || v.bwdTarget != "__cudnn$fmhaSoftmaxBackward" {
22+
t.Errorf("targets = %q / %q", v.fwdTarget, v.bwdTarget)
23+
}
24+
if v.maskType != "CAUSAL" {
25+
t.Errorf("maskType = %q, want CAUSAL", v.maskType)
26+
}
27+
}
28+
29+
func TestSelectFMHAVariant_NoMaskWhenNotCausal(t *testing.T) {
30+
v, err := selectFMHAVariant("op", dtypes.Float16, false, nil)
31+
if err != nil {
32+
t.Fatalf("unexpected err: %v", err)
33+
}
34+
if v.maskType != "NO_MASK" {
35+
t.Errorf("maskType = %q, want NO_MASK", v.maskType)
36+
}
37+
}
38+
39+
func TestSelectFMHAVariant_RejectsF32(t *testing.T) {
40+
_, err := selectFMHAVariant("op", dtypes.Float32, true, nil)
41+
if !errors.Is(err, compute.ErrNotImplemented) {
42+
t.Errorf("err = %v, want ErrNotImplemented", err)
43+
}
44+
}
45+
46+
// TestSelectFMHAVariant_FP8NotImplemented pins the fp8-paused seam: fp8 dtypes must return
47+
// ErrNotImplemented so the caller falls back to the decomposed path. CPU, Mac-runnable.
48+
func TestSelectFMHAVariant_FP8NotImplemented(t *testing.T) {
49+
_, err := selectFMHAVariant("fmha", dtypes.F8E4M3FN, true, nil)
50+
require.True(t, compute.IsNotImplemented(err), "fp8 must be NotImplemented (paused), got %v", err)
51+
_, err = selectFMHAVariant("fmha", dtypes.F8E5M2, true, nil)
52+
require.True(t, compute.IsNotImplemented(err), "fp8 e5m2 must be NotImplemented (paused), got %v", err)
53+
}
54+
55+
// TestSelectFMHAVariant_SeqLenPadding confirms that both-seqlens routes to PADDING (non-causal)
56+
// or PADDING_CAUSAL (causal), and nil cfg still routes to CAUSAL. CPU-runnable.
57+
func TestSelectFMHAVariant_SeqLenPadding(t *testing.T) {
58+
// sentinel is a non-nil compute.Value (any). selectFMHAVariant only checks != nil.
59+
var sent compute.Value = struct{}{}
60+
61+
cfgBoth := &compute.ScaledDotProductAttentionConfig{
62+
QuerySeqLen: sent,
63+
KeyValueSeqLen: sent,
64+
}
65+
66+
v, err := selectFMHAVariant("op", dtypes.BFloat16, false, cfgBoth)
67+
if err != nil {
68+
t.Fatalf("unexpected err: %v", err)
69+
}
70+
if v.maskType != "PADDING" {
71+
t.Errorf("maskType = %q, want PADDING", v.maskType)
72+
}
73+
if !v.hasSeqLens {
74+
t.Errorf("hasSeqLens = false, want true")
75+
}
76+
77+
v, err = selectFMHAVariant("op", dtypes.BFloat16, true, cfgBoth)
78+
if err != nil {
79+
t.Fatalf("unexpected err: %v", err)
80+
}
81+
if v.maskType != "PADDING_CAUSAL" {
82+
t.Errorf("maskType = %q, want PADDING_CAUSAL", v.maskType)
83+
}
84+
85+
// nil cfg still routes causal -> CAUSAL (no regression).
86+
v, err = selectFMHAVariant("op", dtypes.BFloat16, true, nil)
87+
if err != nil {
88+
t.Fatalf("unexpected err: %v", err)
89+
}
90+
if v.maskType != "CAUSAL" {
91+
t.Errorf("maskType = %q, want CAUSAL", v.maskType)
92+
}
93+
}
94+
95+
// nodeWithShape builds a minimal *Node with the given shape. value/builder are nil because
96+
// validateSeqLen only reads n.shape -- no backend call is made.
97+
func nodeWithShape(sh shapes.Shape) *Node { return &Node{shape: sh} }
98+
99+
// TestValidateSeqLen covers the CPU-runnable validation logic: wrong dtype, wrong rank,
100+
// wrong length, and the happy path. No cuda or backend required.
101+
func TestValidateSeqLen(t *testing.T) {
102+
const batch = 4
103+
104+
t.Run("wrong dtype (bf16)", func(t *testing.T) {
105+
v := nodeWithShape(shapes.Make(dtypes.BFloat16, batch))
106+
err := validateSeqLen("QuerySeqLen", v, batch)
107+
require.Error(t, err)
108+
require.Contains(t, err.Error(), "must be int32")
109+
})
110+
111+
t.Run("wrong rank (rank-2)", func(t *testing.T) {
112+
v := nodeWithShape(shapes.Make(dtypes.Int32, batch, 1))
113+
err := validateSeqLen("KeyValueSeqLen", v, batch)
114+
require.Error(t, err)
115+
require.Contains(t, err.Error(), "rank-1")
116+
})
117+
118+
t.Run("wrong length", func(t *testing.T) {
119+
v := nodeWithShape(shapes.Make(dtypes.Int32, batch+1))
120+
err := validateSeqLen("QuerySeqLen", v, batch)
121+
require.Error(t, err)
122+
require.Contains(t, err.Error(), "!= batch size")
123+
})
124+
125+
t.Run("not a *Node", func(t *testing.T) {
126+
var v compute.Value = struct{}{}
127+
err := validateSeqLen("QuerySeqLen", v, batch)
128+
require.Error(t, err)
129+
require.Contains(t, err.Error(), "*Node")
130+
})
131+
132+
t.Run("valid int32 [B]", func(t *testing.T) {
133+
v := nodeWithShape(shapes.Make(dtypes.Int32, batch))
134+
require.NoError(t, validateSeqLen("QuerySeqLen", v, batch))
135+
})
136+
}
137+
138+
func TestFlashBackendConfigV_MaskTypeFromVariant(t *testing.T) {
139+
v := fmhaVariant{maskType: "NO_MASK"}
140+
cfg := flashBackendConfigV(2, 12, 2048, 0.125, map[string]any{"x": 1}, v)
141+
cfg = strings.ReplaceAll(cfg, " ", "") // Remove spaces, to normalize.
142+
if !strings.Contains(cfg, `"mask_type":"NO_MASK"`) {
143+
t.Errorf("backend_config missing NO_MASK mask_type:\n%s", cfg)
144+
}
145+
if !strings.Contains(cfg, `"dropout_rate":0`) {
146+
t.Errorf("backend_config missing dropout_rate:\n%s", cfg)
147+
}
148+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright 2023-2026 The GoMLX Authors. SPDX-License-Identifier: Apache-2.0
2+
3+
package xla
4+
5+
import (
6+
"math"
7+
"testing"
8+
9+
"github.com/gomlx/compute"
10+
"github.com/gomlx/compute/dtypes"
11+
"github.com/gomlx/compute/dtypes/bfloat16"
12+
"github.com/gomlx/compute/shapes"
13+
)
14+
15+
// getFusionBackend creates a cuda backend and probes FusedScaledDotProductAttention on a tiny
16+
// causal bf16 input. Skips if the cuda plugin is unavailable or if cuDNN fMHA is not supported.
17+
func getFusionBackend(t *testing.T) *Backend {
18+
t.Helper()
19+
be, err := NewWithOptions("cuda", nil)
20+
if err != nil {
21+
t.Skipf("[cuda] plugin unavailable: %v", err)
22+
}
23+
t.Cleanup(func() { be.Finalize() })
24+
25+
// Probe: tiny causal bf16 forward to confirm cuDNN fMHA actually works on this host.
26+
const B, S, H, D = 1, 4, 2, 8
27+
bld := be.Builder("fmha_probe").(*Builder)
28+
fn := bld.Main().(*Function)
29+
qkvShape := shapes.Make(dtypes.BFloat16, B, S, H, D)
30+
q, err := fn.Parameter("q", qkvShape, nil)
31+
if err != nil {
32+
t.Fatalf("probe: parameter q: %v", err)
33+
}
34+
k, err := fn.Parameter("k", qkvShape, nil)
35+
if err != nil {
36+
t.Fatalf("probe: parameter k: %v", err)
37+
}
38+
v, err := fn.Parameter("v", qkvShape, nil)
39+
if err != nil {
40+
t.Fatalf("probe: parameter v: %v", err)
41+
}
42+
out, _, err := fn.FusedScaledDotProductAttention(q, k, v, nil, H, H,
43+
compute.AxesLayoutBSHD, 1.0/math.Sqrt(float64(D)), true, nil)
44+
if compute.IsNotImplemented(err) {
45+
t.Skipf("[cuda] cuDNN fMHA not supported on this host: %v", err)
46+
}
47+
if err != nil {
48+
t.Fatalf("probe: FusedScaledDotProductAttention: %v", err)
49+
}
50+
if err = fn.Return([]compute.Value{out}, nil); err != nil {
51+
t.Fatalf("probe: Return: %v", err)
52+
}
53+
if _, err = bld.Compile(); err != nil {
54+
t.Skipf("[cuda] cuDNN fMHA compile failed (unsupported cuDNN): %v", err)
55+
}
56+
return be
57+
}
58+
59+
// assertFiniteBSHD reads buf (bf16) and fails if any element is NaN or Inf.
60+
func assertFiniteBSHD(t *testing.T, buf compute.Buffer) {
61+
t.Helper()
62+
sh, err := buf.Shape()
63+
if err != nil {
64+
t.Fatalf("assertFiniteBSHD Shape: %v", err)
65+
}
66+
flat := make([]bfloat16.BFloat16, sh.Size())
67+
if err = buf.ToFlatData(flat); err != nil {
68+
t.Fatalf("assertFiniteBSHD ToFlatData: %v", err)
69+
}
70+
bad := 0
71+
for _, x := range flat {
72+
f := x.Float32()
73+
if math.IsNaN(float64(f)) || math.IsInf(float64(f), 0) {
74+
bad++
75+
}
76+
}
77+
if bad > 0 {
78+
t.Errorf("assertFiniteBSHD: %d/%d non-finite values", bad, len(flat))
79+
} else {
80+
t.Logf("assertFiniteBSHD: all %d outputs finite", len(flat))
81+
}
82+
}
83+
84+
// readFlatBF16 extracts the flat bf16 slice from buf, failing the test on error.
85+
func readFlatBF16(t *testing.T, buf compute.Buffer) ([]bfloat16.BFloat16, shapes.Shape) {
86+
t.Helper()
87+
sh, err := buf.Shape()
88+
if err != nil {
89+
t.Fatalf("readFlatBF16 Shape: %v", err)
90+
}
91+
flat := make([]bfloat16.BFloat16, sh.Size())
92+
if err = buf.ToFlatData(flat); err != nil {
93+
t.Fatalf("readFlatBF16 ToFlatData: %v", err)
94+
}
95+
return flat, sh
96+
}
97+
98+
// assertSeqLenMasksOutput verifies that seqlen masking actually changed the output. It compares
99+
// the padding positions (seqIdx >= shortLen) of batch element 1 (masked to shortLen) against the
100+
// same positions of batch element 0 (full seqLen). With q=k=v=ones and PADDING_CAUSAL, cuDNN
101+
// zeros/masks the padding-query outputs for the short batch element, producing values that differ
102+
// from the active (non-zero) outputs of the full batch element. If seqlens were ignored, all
103+
// positions across both elements would be identical and the assertion would fail.
104+
//
105+
// Layout: flat [B, S, H, D] row-major. shortLen < seqLen required.
106+
func assertSeqLenMasksOutput(t *testing.T, flat []bfloat16.BFloat16, b, seqLen, h, d, shortLen int) {
107+
t.Helper()
108+
if shortLen >= seqLen {
109+
t.Fatalf("assertSeqLenMasksOutput: shortLen %d must be < seqLen %d", shortLen, seqLen)
110+
}
111+
stride := func(batch, seq int) int { return (batch*seqLen+seq)*h*d }
112+
// Count positions in [batch=1, s=shortLen..seqLen-1] that differ from the corresponding
113+
// position in [batch=0] (which has full seqlen and should be active/non-zero).
114+
diffCount := 0
115+
totalPad := (seqLen - shortLen) * h * d
116+
for s := shortLen; s < seqLen; s++ {
117+
base0 := stride(0, s)
118+
base1 := stride(1, s)
119+
for i := 0; i < h*d; i++ {
120+
if flat[base0+i] != flat[base1+i] {
121+
diffCount++
122+
}
123+
}
124+
}
125+
if diffCount == 0 {
126+
t.Errorf("assertSeqLenMasksOutput: padding positions (s>=%d) in batch 1 are identical to batch 0 (%d elements) -- seqlen masking had no effect", shortLen, totalPad)
127+
} else {
128+
t.Logf("assertSeqLenMasksOutput: %d/%d padding-position elements differ (masking active)", diffCount, totalPad)
129+
}
130+
}
131+
132+
// [cuda] PADDING_CAUSAL: per-batch lengths shorter than S must mask the padding rows. With
133+
// q=k=v=ones, masking changes which keys contribute, so the masked output differs from the
134+
// unmasked all-ones output for the shortened batch element. Runs under xla:cuda.
135+
func TestFMHA_SeqLenPaddingCausal_cuda(t *testing.T) {
136+
be := getFusionBackend(t)
137+
const B, S, H, D = 2, 8, 12, 64
138+
scale := 1.0 / math.Sqrt(float64(D))
139+
qkvShape := shapes.Make(dtypes.BFloat16, B, S, H, D)
140+
141+
bld := be.Builder("fmha_seqlen").(*Builder)
142+
fn := bld.Main().(*Function)
143+
144+
q, err := fn.Parameter("q", qkvShape, nil)
145+
if err != nil {
146+
t.Fatalf("parameter q: %v", err)
147+
}
148+
k, err := fn.Parameter("k", qkvShape, nil)
149+
if err != nil {
150+
t.Fatalf("parameter k: %v", err)
151+
}
152+
v, err := fn.Parameter("v", qkvShape, nil)
153+
if err != nil {
154+
t.Fatalf("parameter v: %v", err)
155+
}
156+
// Seqlen constants embedded in the graph: batch 0 uses full S, batch 1 is padded to S/2.
157+
qSeq, err := fn.Constant([]int32{S, S / 2}, B)
158+
if err != nil {
159+
t.Fatalf("constant qSeqLen: %v", err)
160+
}
161+
kvSeq, err := fn.Constant([]int32{S, S / 2}, B)
162+
if err != nil {
163+
t.Fatalf("constant kvSeqLen: %v", err)
164+
}
165+
cfg := &compute.ScaledDotProductAttentionConfig{
166+
QuerySeqLen: qSeq,
167+
KeyValueSeqLen: kvSeq,
168+
}
169+
out, _, err := fn.FusedScaledDotProductAttention(q, k, v, nil, H, H,
170+
compute.AxesLayoutBSHD, scale, true, cfg)
171+
if err != nil {
172+
t.Fatalf("FusedScaledDotProductAttention: %v", err)
173+
}
174+
if err = fn.Return([]compute.Value{out}, nil); err != nil {
175+
t.Fatalf("Return: %v", err)
176+
}
177+
exec, err := bld.Compile()
178+
if err != nil {
179+
t.Fatalf("Compile: %v", err)
180+
}
181+
182+
nElems := B * S * H * D
183+
ones := make([]bfloat16.BFloat16, nElems)
184+
one := bfloat16.FromFloat32(1)
185+
for i := range ones {
186+
ones[i] = one
187+
}
188+
mkBuf := func() compute.Buffer {
189+
buf, e := be.BufferFromFlatData(0, ones, qkvShape)
190+
if e != nil {
191+
t.Fatalf("BufferFromFlatData: %v", e)
192+
}
193+
return buf
194+
}
195+
qb, kb, vb := mkBuf(), mkBuf(), mkBuf()
196+
defer func() {
197+
_ = qb.Finalize()
198+
_ = kb.Finalize()
199+
_ = vb.Finalize()
200+
}()
201+
202+
outs, err := exec.Execute([]compute.Buffer{qb, kb, vb}, nil, 0)
203+
if err != nil {
204+
t.Fatalf("Execute: %v", err)
205+
}
206+
defer func() {
207+
for _, o := range outs {
208+
_ = o.Finalize()
209+
}
210+
}()
211+
212+
// Original finiteness check: no NaN/Inf anywhere in the output.
213+
assertFiniteBSHD(t, outs[0])
214+
215+
// Masking-effect check: seqlen masking must change the output for the short batch element.
216+
// Batch 0 has qSeqLen=S (all active); batch 1 has qSeqLen=S/2 (padding at s>=S/2).
217+
// cuDNN PADDING_CAUSAL zeros/masks query positions >= qSeqLen, so batch 1's padding
218+
// positions must differ from batch 0's (which are fully active and non-zero). If cuDNN
219+
// ignored the seqlens, both elements would produce identical all-ones outputs and this
220+
// assertion would fail.
221+
flat, _ := readFlatBF16(t, outs[0])
222+
assertSeqLenMasksOutput(t, flat, B, S, H, D, S/2)
223+
}

compute/xla/fused_ops.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,8 @@ func (f *Function) FusedDense(x, weight, bias compute.Value, activation compute.
2626
return nil, errors.Wrapf(compute.ErrNotImplemented, "FusedDense not implemented in XLA backend")
2727
}
2828

29-
func (f *Function) FusedScaledDotProductAttention(query, key, value, mask compute.Value, numHeads, numKVHeads int, axesLayout compute.AxesLayout, scale float64, causal bool, options *compute.ScaledDotProductAttentionConfig) (compute.Value, error) {
30-
return nil, errors.Wrapf(compute.ErrNotImplemented, "FusedScaledDotProductAttention not implemented in XLA backend")
31-
}
29+
// FusedScaledDotProductAttention and FusedScaledDotProductAttentionVJP are the cuDNN flash
30+
// forward and backward; see flash.go.
3231

3332
func (f *Function) FusedAttentionQKVProjection(x, wQKV, biasQ, biasK, biasV compute.Value, queryDim, keyValueDim int) (query, key, value compute.Value, err error) {
3433
err = errors.Wrapf(compute.ErrNotImplemented, "FusedAttentionQKVProjection not implemented in XLA backend")

0 commit comments

Comments
 (0)