Skip to content

Commit aebbdae

Browse files
authored
Merge pull request #194 from ausimian/feat/expr-compiler-sdpa-sinks
feat: lower SDPA-with-sinks blocks in the native Expr compiler
2 parents 3d2adf9 + f106ffb commit aebbdae

4 files changed

Lines changed: 160 additions & 2 deletions

File tree

RELEASE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,16 @@
8888
seeding (every primitive in the expansion was already on the native
8989
path), bit-identical to the Evaluator.
9090

91+
- **`Emily.Fast.Block.SDPAWithSinks` and `SDPAWithMaskAndSinks` lower
92+
natively** — finishes the Expr op-coverage checklist (#188). Two new
93+
opcodes (`fast_sdpa_sinks` / `fast_sdpa_mask_sinks`) call the same
94+
`mx::fast::scaled_dot_product_attention` kernel the existing
95+
`fast_sdpa{,_mask}` opcodes use, with the extra `sinks` operand
96+
wired into the kernel's `sinks` arg — same call shape as
97+
`Emily.Backend.fast_scaled_dot_product_attention_with{,_mask_and}_sinks`.
98+
gpt-oss attention now compiles fully native (with or without an
99+
additive mask), bit-identical to the Evaluator.
100+
91101
- **`Nx.LinAlg.cholesky` / `solve` / `qr` / `eigh` / `lu` / `svd` /
92102
`determinant` lower natively** — closes the LinAlg-block cluster on
93103
#188. Two new infrastructure pieces enable this: a small extension to

c_src/emily/opcodes.hpp

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,20 @@ enum class Opcode : int64_t {
241241
// operands [a]; arity 3 (U, S, V); `full_matrices` hard-coded `true` —
242242
// the IR slices for the thin case, matching Emily.Backend.native_svd/3.
243243
LinalgSVD = 120,
244+
// SDPA with attention sinks (gpt-oss). operands [q, k, v, sinks];
245+
// iattrs [[scale_bits], [causal]]. Mirrors
246+
// Emily.Backend.fast_scaled_dot_product_attention_with_sinks/6 — same
247+
// mx::fast::scaled_dot_product_attention entry point as FastSDPA, with
248+
// the sinks operand wired into the kernel's `sinks` arg.
249+
FastSDPASinks = 121,
250+
// SDPA with both an explicit additive mask and attention sinks
251+
// (gpt-oss). operands [q, k, v, mask, sinks]; iattrs [[scale_bits]];
252+
// mask_mode = "array". Mirrors
253+
// Emily.Backend.fast_scaled_dot_product_attention_with_mask_and_sinks/7.
254+
FastSDPAMaskSinks = 122,
244255
};
245256

246-
inline constexpr int64_t kOpcodeCount = 121;
257+
inline constexpr int64_t kOpcodeCount = 123;
247258

248259
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
249260
inline std::string qmode_from_code(int64_t code) {
@@ -562,6 +573,31 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
562573
in[0], in[1], in[2], scale, "array", std::optional<mx::array>(in[3]),
563574
std::nullopt, s);
564575
}
576+
case Opcode::FastSDPASinks: {
577+
if (in.size() != 4) {
578+
throw std::invalid_argument("fast_sdpa_sinks expects 4 operands, got " +
579+
std::to_string(in.size()));
580+
}
581+
auto scale = static_cast<float>(
582+
emily::f64_from_bits(scalar_at(iattrs, 0, "fast_sdpa_sinks")));
583+
std::string mask_mode =
584+
scalar_at(iattrs, 1, "fast_sdpa_sinks") != 0 ? "causal" : "";
585+
return mx::fast::scaled_dot_product_attention(
586+
in[0], in[1], in[2], scale, mask_mode, std::nullopt,
587+
std::optional<mx::array>(in[3]), s);
588+
}
589+
case Opcode::FastSDPAMaskSinks: {
590+
if (in.size() != 5) {
591+
throw std::invalid_argument(
592+
"fast_sdpa_mask_sinks expects 5 operands, got " +
593+
std::to_string(in.size()));
594+
}
595+
auto scale = static_cast<float>(
596+
emily::f64_from_bits(scalar_at(iattrs, 0, "fast_sdpa_mask_sinks")));
597+
return mx::fast::scaled_dot_product_attention(
598+
in[0], in[1], in[2], scale, "array", std::optional<mx::array>(in[3]),
599+
std::optional<mx::array>(in[4]), s);
600+
}
565601
case Opcode::QuantizedMatmul: {
566602
if (in.size() != 4) {
567603
throw std::invalid_argument("quantized_matmul expects 4 operands, got " +

lib/emily/ir.ex

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,15 @@ defmodule Emily.IR do
208208
linalg_qr: 117,
209209
linalg_eigh: 118,
210210
linalg_lu: 119,
211-
linalg_svd: 120
211+
linalg_svd: 120,
212+
# SDPA with attention sinks (gpt-oss). The mask-only and sinks-only
213+
# variants of the existing `fast_sdpa{,_mask}` family — same
214+
# mx::fast::scaled_dot_product_attention entry point, with the
215+
# extra `sinks` operand wired through.
216+
# operands [q, k, v, sinks]; iattrs [[scale_bits], [causal]]
217+
fast_sdpa_sinks: 121,
218+
# operands [q, k, v, mask, sinks]; iattrs [[scale_bits]]
219+
fast_sdpa_mask_sinks: 122
212220
}
213221

214222
# Quant mode string -> code; decoded by qmode_from_code in
@@ -1261,6 +1269,59 @@ defmodule Emily.IR do
12611269
emit_coerced(state, :fast_sdpa_mask, [rq, rk, rv, rm], [[float_bits(scale)]], t.type)
12621270
end
12631271

1272+
# SDPA with attention sinks (gpt-oss). Mirrors
1273+
# Emily.Backend.fast_scaled_dot_product_attention_with_sinks/6 —
1274+
# routes through the same mx::fast::scaled_dot_product_attention
1275+
# entry point as SDPA, with the sinks tensor as the kernel's `sinks`
1276+
# arg and the causal flag as the mask_mode discriminator
1277+
# ("causal" / "").
1278+
defp lower_block(
1279+
%FB.SDPAWithSinks{scale: scale, causal: causal},
1280+
[q, k, v, sinks],
1281+
_expr,
1282+
t,
1283+
state
1284+
) do
1285+
{rq, state} = lower_node(q, state)
1286+
{rk, state} = lower_node(k, state)
1287+
{rv, state} = lower_node(v, state)
1288+
{rs, state} = lower_node(sinks, state)
1289+
1290+
emit_coerced(
1291+
state,
1292+
:fast_sdpa_sinks,
1293+
[rq, rk, rv, rs],
1294+
[[float_bits(scale)], [bool_int(causal)]],
1295+
t.type
1296+
)
1297+
end
1298+
1299+
# SDPA with an additive mask *and* attention sinks (gpt-oss). Mirrors
1300+
# Emily.Backend.fast_scaled_dot_product_attention_with_mask_and_sinks/7 —
1301+
# mask_mode is fixed "array" (the dispatcher hard-codes it), so only the
1302+
# scale crosses as an iattr.
1303+
defp lower_block(
1304+
%FB.SDPAWithMaskAndSinks{scale: scale},
1305+
[q, k, v, mask, sinks],
1306+
_expr,
1307+
t,
1308+
state
1309+
) do
1310+
{rq, state} = lower_node(q, state)
1311+
{rk, state} = lower_node(k, state)
1312+
{rv, state} = lower_node(v, state)
1313+
{rm, state} = lower_node(mask, state)
1314+
{rs, state} = lower_node(sinks, state)
1315+
1316+
emit_coerced(
1317+
state,
1318+
:fast_sdpa_mask_sinks,
1319+
[rq, rk, rv, rm, rs],
1320+
[[float_bits(scale)]],
1321+
t.type
1322+
)
1323+
end
1324+
12641325
defp lower_block(%QB.QuantizedMatmul{} = qb, [x, q, s, b], _expr, t, state) do
12651326
{rx, state} = lower_node(x, state)
12661327
{rq, state} = lower_node(q, state)

test/emily/compiler_equivalence_test.exs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,57 @@ defmodule Emily.CompilerEquivalenceTest do
682682
[q, k, v, mask]
683683
)
684684
end
685+
686+
# SDPA with attention sinks (gpt-oss). Backend dispatches both the
687+
# plain sinks variant and the masked sinks variant through the same
688+
# mx::fast::scaled_dot_product_attention kernel (with the `sinks` arg
689+
# populated). The native IR routes through new fast_sdpa_sinks /
690+
# fast_sdpa_mask_sinks opcodes that call the same kernel — so the two
691+
# paths are bit-identical to the evaluator.
692+
test "sdpa with sinks (non-causal) matches the fused kernel" do
693+
shape = {1, 2, 4, 8}
694+
q = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(64.0)
695+
k = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(48.0)
696+
v = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(32.0)
697+
# Sinks is a per-head 1-D tensor (one extra logit per head).
698+
sinks = et([0.5, -0.5])
699+
700+
assert_equiv(
701+
fn q, k, v, s -> Emily.Fast.scaled_dot_product_attention(q, k, v, sinks: s) end,
702+
[q, k, v, sinks]
703+
)
704+
end
705+
706+
test "sdpa with sinks (causal) matches the fused kernel" do
707+
shape = {1, 2, 4, 8}
708+
q = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(64.0)
709+
k = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(48.0)
710+
v = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(32.0)
711+
sinks = et([0.0, 1.0])
712+
713+
assert_equiv(
714+
fn q, k, v, s ->
715+
Emily.Fast.scaled_dot_product_attention(q, k, v, sinks: s, causal: true)
716+
end,
717+
[q, k, v, sinks]
718+
)
719+
end
720+
721+
test "sdpa with an additive mask AND sinks matches the fused kernel" do
722+
shape = {1, 2, 4, 8}
723+
q = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(64.0)
724+
k = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(48.0)
725+
v = Nx.iota(shape, type: :f32, backend: Emily.Backend) |> Nx.divide(32.0)
726+
mask = Nx.broadcast(Nx.tensor(0.0, backend: Emily.Backend), {1, 1, 4, 4})
727+
sinks = et([-1.0, 1.0])
728+
729+
assert_equiv(
730+
fn q, k, v, m, s ->
731+
Emily.Fast.scaled_dot_product_attention_with_mask(q, k, v, m, sinks: s)
732+
end,
733+
[q, k, v, mask, sinks]
734+
)
735+
end
685736
end
686737

687738
describe "quantized matmul block" do

0 commit comments

Comments
 (0)