Skip to content

Commit e70d5c8

Browse files
authored
Merge pull request #21 from ausimian/m11-fast-kernels
M11: Fused MLX transformer kernels
2 parents 8f55aa4 + 496f291 commit e70d5c8

18 files changed

Lines changed: 2164 additions & 26 deletions

RELEASE.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,54 @@
22

33
## Added
44

5+
- M11 — MLX fused transformer kernels. Wires MLX's handwritten
6+
`mx::fast::*` fused kernels (RMSNorm, LayerNorm, RoPE, scaled-dot-
7+
product attention) into Emily as `defn`-callable helpers, and ships
8+
a Bumblebee shim that swaps these in for the stock composed-defn
9+
implementations when the Axon graph is rewritten with
10+
`Emily.Bumblebee.FastKernels.apply/1`.
11+
- **Native NIFs** (`c_src/ops/fast.cpp`) over `mx::fast::rms_norm`,
12+
`mx::fast::layer_norm`, `mx::fast::rope`, and
13+
`mx::fast::scaled_dot_product_attention`. Nullable weight / bias
14+
/ freqs arguments marshal via `std::optional`; the SDPA mask
15+
argument list marshals via `std::vector<fine::ResourcePtr<Tensor>>`.
16+
- **`Emily.Fast`** (`lib/emily/fast.ex`) — `rms_norm/3`,
17+
`layer_norm/4`, `rope/3`, `rope_with_freqs/4`,
18+
`scaled_dot_product_attention/4`,
19+
`scaled_dot_product_attention_with_mask/5`. Each helper emits a
20+
`Nx.Defn.Expr.optional/3` node whose op name matches a custom
21+
callback on `Emily.Backend`; the Evaluator dispatches to the
22+
fused kernel under Emily and falls back to a defn composition on
23+
any other backend. This makes the helpers safe to drop into
24+
Bumblebee inference paths without breaking BinaryBackend
25+
conformance runs.
26+
- **`Emily.Backend.fast_*`** — six custom callbacks (not part of the
27+
`Nx.Backend` behaviour) that Evaluator picks up when the input
28+
tensors carry Emily data. They unwrap refs and call the Native
29+
NIF directly.
30+
- **`Emily.Bumblebee.FastKernels`** (`test/support/`) — Axon graph
31+
rewriter, mirroring the M10.5 `Emily.Quantization.Transform`
32+
pattern. Rewrites `:rms_norm` and `:layer_norm` nodes via
33+
`Axon.map_nodes`, `Bumblebee.Layers.apply_rotary_embedding/5` by
34+
function-reference match, and coalesces
35+
`attention_weights_impl + attention_output_impl` into one fused
36+
SDPA layer via `Axon.rewrite_nodes`. RoPE handles all four
37+
Bumblebee scaling strategies (`:linear`, `:dynamic`, `:longrope`,
38+
`:llama3`) by precomputing the inverse-frequency table Elixir-
39+
side and passing it to MLX via the `freqs`-override overload.
40+
- **Tests**: per-kernel Native/defn/equivalence suites at
41+
`test/emily/fast/`; shim unit tests at
42+
`test/emily/bumblebee/fast_kernels_test.exs`; fused-kernel
43+
variants of every `*_full` conformance suite tagged
44+
`:fast_kernels_full` (excluded by default like the other
45+
`*_full` tags). Run explicitly:
46+
`mix test --only fast_kernels_full`.
47+
- **Bench**: `bench/qwen3_tokens_per_sec.exs` gains an
48+
`EMILY_BENCH_FAST_KERNELS=1` mode that benchmarks baseline vs
49+
fused side-by-side, plus an `EMILY_BENCH_PIN=<multiplier>` flag
50+
that fails with a non-zero exit when the fused mean throughput
51+
doesn't clear the multiplier × baseline threshold.
52+
553
- M10 (partial) — Quantized inference primitives. Exposes MLX's affine
654
int4/int8 group-wise quantization at the Native and Elixir levels, plus
755
a direct-call helper for eager use. Enough to quantize a dense weight,

bench/qwen3_tokens_per_sec.exs

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,29 @@
66
#
77
# Optional environment variables:
88
#
9-
# EMILY_BENCH_MODEL HuggingFace repo id. Defaults to
10-
# "Qwen/Qwen3-0.6B".
11-
# EMILY_BENCH_NEW_TOKENS Number of tokens to greedy-decode per
12-
# run. Defaults to 64.
13-
# EMILY_BENCH_PROMPT Prompt text. Defaults to a fixed short
14-
# English sentence.
15-
# EMILY_BENCH_WARMUP Number of warm-up runs (not measured).
16-
# Defaults to 1.
17-
# EMILY_BENCH_RUNS Number of measured runs. Defaults to 3.
9+
# EMILY_BENCH_MODEL HuggingFace repo id. Defaults to
10+
# "Qwen/Qwen3-0.6B".
11+
# EMILY_BENCH_NEW_TOKENS Number of tokens to greedy-decode per
12+
# run. Defaults to 64.
13+
# EMILY_BENCH_PROMPT Prompt text. Defaults to a fixed short
14+
# English sentence.
15+
# EMILY_BENCH_WARMUP Number of warm-up runs (not measured).
16+
# Defaults to 1.
17+
# EMILY_BENCH_RUNS Number of measured runs. Defaults to 3.
18+
# EMILY_BENCH_FAST_KERNELS "1" → also benchmark the M11 fused
19+
# MLX kernels (RMSNorm, LayerNorm, RoPE,
20+
# SDPA) via `Emily.Bumblebee.FastKernels`.
21+
# Reports baseline vs fused side by side.
22+
# EMILY_BENCH_PIN "1.5" → fail with non-zero exit if the
23+
# fused mean tokens/sec doesn't beat
24+
# baseline mean by at least the given
25+
# multiplier. Implies
26+
# EMILY_BENCH_FAST_KERNELS=1.
1827
#
1928
# The first run downloads the model (~1.2 GB at f32, ~600 MB at f16).
2029
# We deliberately avoid `Benchee` — this benchmark has one workload and
21-
# one metric (tokens/sec). The whole script is standalone so a reader
22-
# can follow the generation flow without chasing macros.
30+
# one (or two) metrics. The whole script is standalone so a reader can
31+
# follow the generation flow without chasing macros.
2332

2433
defmodule Emily.Bench.Qwen3 do
2534
@default_model "Qwen/Qwen3-0.6B"
@@ -34,19 +43,27 @@ defmodule Emily.Bench.Qwen3 do
3443
model_repo = System.get_env("EMILY_BENCH_MODEL", @default_model)
3544
prompt = System.get_env("EMILY_BENCH_PROMPT", @default_prompt)
3645

37-
new_tokens =
38-
System.get_env("EMILY_BENCH_NEW_TOKENS")
39-
|> env_int(@default_new_tokens)
40-
46+
new_tokens = System.get_env("EMILY_BENCH_NEW_TOKENS") |> env_int(@default_new_tokens)
4147
warmup = System.get_env("EMILY_BENCH_WARMUP") |> env_int(@default_warmup)
4248
runs = System.get_env("EMILY_BENCH_RUNS") |> env_int(@default_runs)
4349

50+
pin_threshold =
51+
case System.get_env("EMILY_BENCH_PIN") do
52+
nil -> nil
53+
s -> elem(Float.parse(s), 0)
54+
end
55+
56+
fast_kernels? =
57+
System.get_env("EMILY_BENCH_FAST_KERNELS") == "1" or pin_threshold != nil
58+
4459
IO.puts("Emily / Qwen3 throughput benchmark")
45-
IO.puts(" model : #{model_repo}")
46-
IO.puts(" prompt : #{inspect(prompt)}")
47-
IO.puts(" new tokens : #{new_tokens}")
48-
IO.puts(" warmup : #{warmup}")
49-
IO.puts(" runs : #{runs}")
60+
IO.puts(" model : #{model_repo}")
61+
IO.puts(" prompt : #{inspect(prompt)}")
62+
IO.puts(" new tokens : #{new_tokens}")
63+
IO.puts(" warmup : #{warmup}")
64+
IO.puts(" runs : #{runs}")
65+
IO.puts(" fused kernels : #{fast_kernels?}")
66+
if pin_threshold, do: IO.puts(" pin threshold : #{pin_threshold}× baseline")
5067
IO.puts("")
5168

5269
{:ok, model_info} = Bumblebee.load_model({:hf, model_repo})
@@ -60,6 +77,34 @@ defmodule Emily.Bench.Qwen3 do
6077
strategy: %{type: :greedy_search}
6178
)
6279

80+
IO.puts("=== baseline (composed defn kernels) ===")
81+
baseline = bench(model_info, tokenizer, generation_config, prompt, new_tokens, warmup, runs)
82+
{baseline_mean, _, _, _} = baseline
83+
84+
if fast_kernels? do
85+
IO.puts("\n=== fused (Emily.Bumblebee.FastKernels) ===")
86+
87+
fused_model_info =
88+
update_in(model_info.model, &Emily.Bumblebee.FastKernels.apply/1)
89+
90+
fused = bench(fused_model_info, tokenizer, generation_config, prompt, new_tokens, warmup, runs)
91+
{fused_mean, _, _, _} = fused
92+
93+
speedup = fused_mean / baseline_mean
94+
IO.puts("\nspeedup : #{Float.round(speedup, 2)}× (fused mean / baseline mean)")
95+
96+
if pin_threshold do
97+
if speedup >= pin_threshold do
98+
IO.puts("PIN OK : #{Float.round(speedup, 2)}× ≥ #{pin_threshold}×")
99+
else
100+
IO.puts("PIN FAIL : #{Float.round(speedup, 2)}× < #{pin_threshold}×")
101+
System.halt(1)
102+
end
103+
end
104+
end
105+
end
106+
107+
defp bench(model_info, tokenizer, generation_config, prompt, new_tokens, warmup, runs) do
63108
serving =
64109
Bumblebee.Text.generation(model_info, tokenizer, generation_config,
65110
defn_options: [compiler: Nx.Defn.Evaluator]
@@ -86,14 +131,16 @@ defmodule Emily.Bench.Qwen3 do
86131
mean = Enum.sum(tps_list) / length(tps_list)
87132
{min_tps, max_tps} = Enum.min_max(tps_list)
88133

89-
IO.puts("")
90-
IO.puts("tokens/sec mean=#{Float.round(mean, 2)} min=#{Float.round(min_tps, 2)} max=#{Float.round(max_tps, 2)}")
91-
IO.puts("")
92-
IO.puts("first completion:")
93-
IO.puts(String.slice(sample, 0, 500))
134+
IO.puts(
135+
"tokens/sec : mean=#{Float.round(mean, 2)} min=#{Float.round(min_tps, 2)} max=#{Float.round(max_tps, 2)}"
136+
)
137+
138+
IO.puts("first completion:\n #{String.slice(sample, 0, 500)}")
139+
{mean, min_tps, max_tps, sample}
94140
end
95141

96142
defp env_int(nil, default), do: default
143+
97144
defp env_int(s, default) do
98145
case Integer.parse(s) do
99146
{n, ""} -> n

c_src/ops/fast.cpp

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Fused transformer kernels from mlx::core::fast.
2+
//
3+
// These handwritten kernels beat the defn-composed equivalents on the
4+
// transformer hot paths: RMSNorm (one kernel vs rsqrt+mean+multiply
5+
// chain), LayerNorm (same for Welford+affine), RoPE (fused trig +
6+
// interleave), and Scaled-Dot-Product Attention (QK^T → scale → mask →
7+
// softmax → V as one dispatch instead of ~5). Elixir-side they're
8+
// surfaced as `Emily.Fast.*` helpers callable from inside `defn`.
9+
//
10+
// Nullable inputs (weight/bias, the RoPE `base` override, the precomp
11+
// `freqs`, per-tensor `offset`) marshal via `std::optional` — the same
12+
// pattern `ops/random.cpp` uses for PRNG keys.
13+
14+
#include "../emily/tensor.hpp"
15+
16+
#include <fine.hpp>
17+
#include <mlx/fast.h>
18+
#include <mlx/mlx.h>
19+
20+
#include <cstdint>
21+
#include <optional>
22+
#include <string>
23+
#include <variant>
24+
#include <vector>
25+
26+
namespace mx = mlx::core;
27+
using emily::Tensor;
28+
using emily::unwrap_all;
29+
using emily::wrap;
30+
31+
namespace {
32+
33+
// -----------------------------------------------------------------
34+
// Nullable tensor helper
35+
// -----------------------------------------------------------------
36+
37+
std::optional<mx::array> opt_array(
38+
const std::optional<fine::ResourcePtr<Tensor>> &opt) {
39+
if (opt) return (*opt)->array;
40+
return std::nullopt;
41+
}
42+
43+
// -----------------------------------------------------------------
44+
// fast_rms_norm/3 — mx::fast::rms_norm(x, weight?, eps)
45+
// -----------------------------------------------------------------
46+
//
47+
// Normalises the last axis of `x` by `rsqrt(mean(x^2) + eps)` and
48+
// optionally multiplies by `weight` (vector of size last-axis). The
49+
// weight is nil-able because some models (e.g. `pre_norm=False`
50+
// variants) use unit-scale RMSNorm.
51+
fine::ResourcePtr<Tensor> fast_rms_norm(
52+
ErlNifEnv *,
53+
fine::ResourcePtr<Tensor> x,
54+
std::optional<fine::ResourcePtr<Tensor>> weight,
55+
double eps) {
56+
return wrap(mx::fast::rms_norm(
57+
x->array, opt_array(weight), static_cast<float>(eps)));
58+
}
59+
FINE_NIF(fast_rms_norm, 0);
60+
61+
// -----------------------------------------------------------------
62+
// fast_layer_norm/4 — mx::fast::layer_norm(x, weight?, bias?, eps)
63+
// -----------------------------------------------------------------
64+
//
65+
// Welford-style LayerNorm over the last axis with optional affine
66+
// (weight + bias). `weight` and `bias` are independently nullable to
67+
// match MLX — e.g. `elementwise_affine=False` PyTorch modules map to
68+
// both-nil.
69+
fine::ResourcePtr<Tensor> fast_layer_norm(
70+
ErlNifEnv *,
71+
fine::ResourcePtr<Tensor> x,
72+
std::optional<fine::ResourcePtr<Tensor>> weight,
73+
std::optional<fine::ResourcePtr<Tensor>> bias,
74+
double eps) {
75+
return wrap(mx::fast::layer_norm(
76+
x->array,
77+
opt_array(weight),
78+
opt_array(bias),
79+
static_cast<float>(eps)));
80+
}
81+
FINE_NIF(fast_layer_norm, 0);
82+
83+
// -----------------------------------------------------------------
84+
// fast_rope/7 — mx::fast::rope(x, dims, traditional, base?, scale, offset, freqs?)
85+
// -----------------------------------------------------------------
86+
//
87+
// Fused rotary positional embedding. `dims` is the count of trailing
88+
// dimensions that carry rotated components (typically head_dim; the
89+
// remaining trailing dims, if any, are passed through). `traditional`
90+
// selects the paired-interleave layout (`true`, per Meta / MLX) vs
91+
// the split-half layout (`false`, per HuggingFace). `base` is the
92+
// theta override (nil → use the `freqs` argument instead); `freqs` is
93+
// a pre-computed 1-D tensor of inverse frequencies to support the
94+
// Llama-3 / LongRoPE / linear / dynamic scaling strategies that
95+
// Bumblebee implements outside of MLX.
96+
//
97+
// `offset` is a scalar integer tensor (Nx's canonical rep — Bumblebee
98+
// tracks the cumulative position offset as an %Nx.Tensor{} through
99+
// iterative decode), which matches the `array`-offset overload of
100+
// MLX's `rope`. The NIF always takes a tensor here and uses the
101+
// overload with `const array&` — users pass `Nx.tensor(0)` when
102+
// there's no KV-cache offset.
103+
fine::ResourcePtr<Tensor> fast_rope(
104+
ErlNifEnv *,
105+
fine::ResourcePtr<Tensor> x,
106+
int64_t dims,
107+
bool traditional,
108+
std::optional<double> base,
109+
double scale,
110+
fine::ResourcePtr<Tensor> offset,
111+
std::optional<fine::ResourcePtr<Tensor>> freqs) {
112+
std::optional<float> base_f;
113+
if (base) base_f = static_cast<float>(*base);
114+
115+
return wrap(mx::fast::rope(
116+
x->array,
117+
static_cast<int>(dims),
118+
traditional,
119+
base_f,
120+
static_cast<float>(scale),
121+
offset->array,
122+
opt_array(freqs)));
123+
}
124+
FINE_NIF(fast_rope, 0);
125+
126+
// -----------------------------------------------------------------
127+
// fast_scaled_dot_product_attention/6 —
128+
// mx::fast::scaled_dot_product_attention(Q, K, V, scale, mask_mode, mask_arrs)
129+
// -----------------------------------------------------------------
130+
//
131+
// Computes `softmax((Q @ Kᵀ) * scale + mask) @ V` as a single fused
132+
// kernel over `[B, H, S, D]` inputs.
133+
//
134+
// `mask_mode` is the empty string, `"causal"`, or `"array"`:
135+
// - `""` — no mask.
136+
// - `"causal"` — upper-triangular -inf mask (no additional arrays).
137+
// - `"array"` — `mask_arrs` holds one broadcastable additive bias
138+
// tensor (Bumblebee's `bias = select(mask, 0, -inf)`
139+
// materialises this).
140+
//
141+
// MLX supports a handful of other modes (block-sparse etc.) — out of
142+
// scope for M11; add them when a model asks.
143+
fine::ResourcePtr<Tensor> fast_scaled_dot_product_attention(
144+
ErlNifEnv *,
145+
fine::ResourcePtr<Tensor> q,
146+
fine::ResourcePtr<Tensor> k,
147+
fine::ResourcePtr<Tensor> v,
148+
double scale,
149+
std::string mask_mode,
150+
std::vector<fine::ResourcePtr<Tensor>> mask_arrs) {
151+
return wrap(mx::fast::scaled_dot_product_attention(
152+
q->array,
153+
k->array,
154+
v->array,
155+
static_cast<float>(scale),
156+
mask_mode,
157+
unwrap_all(mask_arrs)));
158+
}
159+
FINE_NIF(fast_scaled_dot_product_attention, 0);
160+
161+
} // namespace

0 commit comments

Comments
 (0)