Skip to content

Commit c10a2d9

Browse files
committed
M3: Bumblebee DistilBERT end-to-end on Emily.Backend
Every Nx op on the transformer critical path now runs natively on MLX; a full DistilBERT forward pass matches HuggingFace Transformers (PyTorch) reference values within f32 tolerance across six architecture variants. Emily.Backend changes - Native batched dot/7 via permute + 3-D reshape + Native.matmul, replacing the BinaryBackend bounce. Hits 12x per DistilBERT forward (2x per attention block x 6 layers). - Cast binary-op operands to out.type at the backend boundary. MLX's cross-type promotion for mixed integer widths (e.g. u64+s32) falls back to float32 and then rejects bitwise ops; Nx.Random.key hits this transitively via Axon's dropout defn. - slice starts can now be scalar tensors (dynamic slicing under defn); materialised to concrete ints on the fly. - bitcast implemented via mx::view (zero-copy reinterpret between equal-width dtypes). Removes the hard raise; unblocks Nx.Random. - argmax/argmin derive keep-axis from out.shape vs input rank so behaviour is stable across Nx's :keep_axis / :keep_axes drift. Conformance suite - test/emily/conformance/distilbert_test.exs ports Bumblebee's own DistilBERT tests verbatim: :base, :for_masked_language_modeling, :for_sequence_classification, :for_token_classification, :for_question_answering, :for_multiple_choice, plus an Nx.Serving.batched_run smoke test exercising tokenizer -> model -> postprocess end to end. - @moduletag :conformance, excluded by default; run explicitly with mix test --only conformance. CI runs it as a separate step after mix precommit, with ~/Library/Caches/bumblebee cached across runs. - Batched-dot property tests added to backend_test.exs covering 1- and 2-axis batch cases plus scalar-output and multi-free-axis shapes. Deps - bumblebee ~> 0.6 and tokenizers ~> 0.5 added as test-only deps. - nx pinned ~> 0.10 (down from 0.11) to match Bumblebee's constraint; Emily's own API is unaffected. Out of scope for this milestone: native conv translation. PLAN.md lists it under M3 but DistilBERT and M4's Qwen3 don't use it; the BinaryBackend fallback remains until a CV model lands on Emily.
1 parent 39798e1 commit c10a2d9

11 files changed

Lines changed: 554 additions & 32 deletions

File tree

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ jobs:
5050
key: build-${{ runner.os }}-${{ hashFiles('mix.lock', 'c_src/**', 'Makefile', '.tool-versions') }}
5151
restore-keys: build-${{ runner.os }}-
5252

53+
# The Bumblebee cache holds tiny-random HuggingFace fixtures
54+
# downloaded by the conformance suite (~3 MB across 7 repos).
55+
# Cached by conformance test content so new fixtures invalidate;
56+
# restore-keys lets unrelated changes reuse the existing cache.
57+
- name: Cache Bumblebee fixtures
58+
uses: actions/cache@v4
59+
with:
60+
path: ~/Library/Caches/bumblebee
61+
key: bumblebee-${{ runner.os }}-${{ hashFiles('test/emily/conformance/**') }}
62+
restore-keys: bumblebee-${{ runner.os }}-
63+
5364
- run: mix deps.get
5465

5566
- run: mix precommit
67+
68+
# Conformance tests are excluded from the default suite because
69+
# they require network access on a cold cache (see
70+
# test/test_helper.exs). In CI we always want them green — a
71+
# DistilBERT forward pass is the canonical integration signal
72+
# that no Nx op on the transformer critical path has regressed.
73+
- run: mix test --only conformance

RELEASE.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,58 @@
110110
last-axis fast path stays on MLX; interior-axis usage is rare on
111111
our M3/M4 critical path (transformer inference doesn't need it).
112112

113+
- M3 — DistilBERT end-to-end on Bumblebee. Every Nx op on the
114+
transformer critical path now runs natively on MLX; the full
115+
forward pass matches HuggingFace Transformers (PyTorch) reference
116+
values within f32 tolerance.
117+
- **Native batched `dot/7`** in `Emily.Backend`, replacing the
118+
BinaryBackend bounce. Permutes operands to
119+
`[batch… , free… , contract…]`/`[batch…, contract…, free…]`,
120+
collapses to 3-D, dispatches to `Native.matmul` (which treats
121+
leading dims as batch), reshapes to Nx's canonical
122+
`batch ++ free_a ++ free_b` layout. Hits 12× per DistilBERT
123+
forward pass (2× per attention layer × 6 layers). Falls back to
124+
BinaryBackend for non-float dtypes — MLX matmul is float-only.
125+
- **Binary op type promotion** fixed at the Backend boundary.
126+
MLX's cross-type promotion for mixed integer widths (e.g.
127+
`right_shift(u64, s32)`) falls to float32 and then rejects the
128+
op. `Emily.Backend` now casts both operands to the Nx-computed
129+
output type (for arithmetic/bitwise) or merged input type (for
130+
compare/logical) before dispatching to MLX. Unblocks
131+
`Nx.Random.key`, which is pulled in transitively even in
132+
inference-only models via Axon's dropout defn.
133+
- **Dynamic `slice` starts.** Nx passes scalar-tensor starts under
134+
`defn` evaluation; `Emily.Backend.slice` now materialises them
135+
to their concrete values on the fly.
136+
- **`bitcast`** implemented via `mx::view` (zero-copy reinterpret
137+
cast between equal-width dtypes). Required by `Nx.Random` to
138+
move between f32 and u32 bit patterns.
139+
- **`argmax`/`argmin` keep-axis robustness.** Derive the keep-axis
140+
flag from `out.shape` vs input rank instead of trusting the raw
141+
opts key (Nx's user-facing API uses `:keep_axis`, singular, while
142+
some callers pass `:keep_axes`).
143+
- **`test/emily/conformance/distilbert_test.exs`**
144+
(`@moduletag :conformance`, excluded by default; run with
145+
`mix test --only conformance`) — ports Bumblebee's own DistilBERT
146+
tests verbatim. Six architecture variants (`:base`,
147+
`:for_masked_language_modeling`,
148+
`:for_sequence_classification`, `:for_token_classification`,
149+
`:for_question_answering`, `:for_multiple_choice`) plus an
150+
`Nx.Serving.batched_run` smoke test exercising the QA pipeline
151+
end-to-end (tokenizer → model → postprocess).
152+
- **CI runs the conformance suite** on every push/PR as a separate
153+
step after `mix precommit`. `~/Library/Caches/bumblebee` is
154+
cached across runs so the ~3 MB HF fixture download happens
155+
once. Local `mix test` remains opt-in via `--only conformance`
156+
so a fresh-clone/offline contributor isn't blocked by network.
157+
- **Batched-dot property tests** added to
158+
`test/emily/backend_test.exs` — 1- and 2-axis batch cases plus
159+
edge shapes (scalar output, multi-free-axis both sides).
160+
- **Test-only deps:** `bumblebee ~> 0.6`, `tokenizers ~> 0.5`
161+
(both `only: :test`). Nx pinned to `~> 0.10` (down from 0.11)
162+
to match Bumblebee's current constraint; emily's own API is
163+
unaffected.
164+
113165
## Notes
114166

115167
- Ops files use anonymous namespaces to prevent NIF function names
@@ -120,3 +172,6 @@
120172
`hadamard_transform`, quantized matmul, `linalg.*` decompositions
121173
(LU, QR, Cholesky, SVD). These will be added opportunistically when
122174
M2/M3 callers need them.
175+
- Deferred beyond M3: native `conv` translation (PLAN lists it under
176+
M3, but DistilBERT and M4's Qwen3 don't use it; the BinaryBackend
177+
fallback remains until a CV model lands on Emily).

c_src/ops/cast.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,14 @@ fine::ResourcePtr<Tensor> astype(
2323
}
2424
FINE_NIF(astype, 0);
2525

26+
// bitcast: reinterpret the bits as a different dtype of the same
27+
// element size. MLX exposes this as `mx::view`.
28+
fine::ResourcePtr<Tensor> bitcast(
29+
ErlNifEnv *,
30+
fine::ResourcePtr<Tensor> a,
31+
std::tuple<fine::Atom, int64_t> dtype) {
32+
return wrap(mx::view(a->array, to_mlx_dtype(dtype)));
33+
}
34+
FINE_NIF(bitcast, 0);
35+
2636
} // namespace

lib/emily/backend.ex

Lines changed: 132 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ defmodule Emily.Backend do
1212
* `{:f, 64}` is not supported — Metal cannot execute f64. Operations
1313
that would allocate an f64 tensor raise `ArgumentError` with a
1414
message pointing to f32.
15-
* `bitcast`, `from_pointer`, `to_pointer`, `population_count`, and
15+
* `from_pointer`, `to_pointer`, `population_count`, and
1616
`count_leading_zeros` raise `ArgumentError` — MLX has no primitive.
1717
* Window operations (`window_sum`, `window_scatter_max`, etc.) and
1818
advanced linalg (`lu`, `svd`, `qr`, `cholesky`, `eigh`, `solve`,
@@ -303,9 +303,13 @@ defmodule Emily.Backend do
303303
t |> ref() |> Native.astype(type) |> wrap(out)
304304
end
305305

306+
# bitcast: reinterpret the bits as the output dtype (same element
307+
# size). MLX exposes this as `mx::view`. Used by Nx.Random to move
308+
# between uint and float of matching width.
306309
@impl true
307-
def bitcast(_out, _t),
308-
do: raise(ArgumentError, "Emily.Backend does not implement bitcast (MLX has no primitive)")
310+
def bitcast(%T{type: type} = out, t) do
311+
t |> ref() |> Native.bitcast(type) |> wrap(out)
312+
end
309313

310314
# =================================================================
311315
# Unary ops
@@ -393,7 +397,15 @@ defmodule Emily.Backend do
393397
# Binary ops
394398
# =================================================================
395399

396-
@renamed_binary [
400+
# Arithmetic + bitwise: cast both operands to `out.type` before
401+
# handing to MLX. Two reasons:
402+
# 1. MLX's cross-type promotion for mixed integer widths (e.g.,
403+
# u64 + s32) falls back to float32 — which then fails on
404+
# integer-only ops like right_shift. `Nx.Random.key` hits this.
405+
# 2. `divide` has `out.type = float` even for integer operands
406+
# (`Nx.Type.to_floating/1`); casting to out.type first produces
407+
# the float division Nx promises.
408+
@renamed_arith_binary [
397409
subtract: :subtract,
398410
multiply: :multiply,
399411
divide: :divide,
@@ -402,36 +414,62 @@ defmodule Emily.Backend do
402414
atan2: :arctan2,
403415
min: :minimum,
404416
max: :maximum,
417+
bitwise_and: :bitwise_and,
418+
bitwise_or: :bitwise_or,
419+
bitwise_xor: :bitwise_xor,
420+
left_shift: :left_shift,
421+
right_shift: :right_shift
422+
]
423+
424+
for {nx_name, native_name} <- @renamed_arith_binary do
425+
@impl true
426+
def unquote(nx_name)(%T{type: type} = out, a, b) do
427+
ra = Native.astype(ref(a), type)
428+
rb = Native.astype(ref(b), type)
429+
Native.unquote(native_name)(ra, rb) |> wrap(out)
430+
end
431+
end
432+
433+
# Compare + logical: out.type is `{:u, 8}` (pred), but MLX still
434+
# needs the operands at a matched non-pred type to compare. Cast to
435+
# `Nx.Type.merge(a, b)` so MLX sees a consistent arithmetic type.
436+
@renamed_pred_binary [
405437
equal: :equal,
406438
not_equal: :not_equal,
407439
less: :less,
408440
less_equal: :less_equal,
409441
greater: :greater,
410442
greater_equal: :greater_equal,
411443
logical_and: :logical_and,
412-
logical_or: :logical_or,
413-
bitwise_and: :bitwise_and,
414-
bitwise_or: :bitwise_or,
415-
bitwise_xor: :bitwise_xor,
416-
left_shift: :left_shift,
417-
right_shift: :right_shift
444+
logical_or: :logical_or
418445
]
419446

420-
for {nx_name, native_name} <- @renamed_binary do
447+
for {nx_name, native_name} <- @renamed_pred_binary do
421448
@impl true
422449
def unquote(nx_name)(out, a, b) do
423-
Native.unquote(native_name)(ref(a), ref(b)) |> wrap(out)
450+
target = Nx.Type.merge(a.type, b.type)
451+
ra = Native.astype(ref(a), target)
452+
rb = Native.astype(ref(b), target)
453+
Native.unquote(native_name)(ra, rb) |> wrap(out)
424454
end
425455
end
426456

427457
@impl true
428-
def add(out, a, b), do: Native.add(ref(a), ref(b)) |> wrap(out)
458+
def add(%T{type: type} = out, a, b) do
459+
ra = Native.astype(ref(a), type)
460+
rb = Native.astype(ref(b), type)
461+
Native.add(ra, rb) |> wrap(out)
462+
end
429463

430464
# See moduledoc: we intentionally use MLX floor_divide for quotient,
431465
# matching its rounding (floor toward -inf) rather than Nx's
432466
# truncate-toward-zero. The two agree for non-negative operands.
433467
@impl true
434-
def quotient(out, a, b), do: Native.floor_divide(ref(a), ref(b)) |> wrap(out)
468+
def quotient(%T{type: type} = out, a, b) do
469+
ra = Native.astype(ref(a), type)
470+
rb = Native.astype(ref(b), type)
471+
Native.floor_divide(ra, rb) |> wrap(out)
472+
end
435473

436474
# logical_xor: MLX has no direct op. Compose via `not_equal` on
437475
# booleanised inputs. With u8 inputs, `not_equal(0)` yields truthy
@@ -543,10 +581,17 @@ defmodule Emily.Backend do
543581

544582
@impl true
545583
def slice(%T{} = out, t, starts, lengths, strides) do
584+
# Nx passes starts as either integers or scalar tensors (dynamic
585+
# slicing). MLX's slice takes integer bounds; under the evaluator
586+
# we materialise scalar-tensor starts to their concrete value.
587+
starts = Enum.map(starts, &slice_start/1)
546588
stops = Enum.zip_with(starts, lengths, fn s, l -> s + l end)
547589
Native.slice(ref(t), starts, stops, strides) |> wrap(out)
548590
end
549591

592+
defp slice_start(i) when is_integer(i), do: i
593+
defp slice_start(%T{} = t), do: t |> Nx.backend_copy(Nx.BinaryBackend) |> Nx.to_number()
594+
550595
# put_slice: MLX has no direct primitive; route via BinaryBackend.
551596
@impl true
552597
def put_slice(out, t, slice, starts),
@@ -609,10 +654,14 @@ defmodule Emily.Backend do
609654
end
610655
end
611656

657+
# Nx's argmax/argmin take `:keep_axis` (singular) on user-facing API
658+
# but the backend callback exposes raw opts whose spelling has drifted
659+
# across Nx versions. Derive `keep` from the shape invariant instead:
660+
# if `out.shape` has the same rank as the input, the axis was kept.
612661
@impl true
613662
def argmax(%T{} = out, t, opts) do
614663
axis = opts[:axis] || 0
615-
keep = opts[:keep_axes] || false
664+
keep = tuple_size(out.shape) == tuple_size(t.shape)
616665

617666
ref(t)
618667
|> Native.argmax(axis, keep)
@@ -623,7 +672,7 @@ defmodule Emily.Backend do
623672
@impl true
624673
def argmin(%T{} = out, t, opts) do
625674
axis = opts[:axis] || 0
626-
keep = opts[:keep_axes] || false
675+
keep = tuple_size(out.shape) == tuple_size(t.shape)
627676

628677
ref(t)
629678
|> Native.argmin(axis, keep)
@@ -666,20 +715,78 @@ defmodule Emily.Backend do
666715
# Dot product
667716
# =================================================================
668717

669-
# Non-batched tensor contraction: tensordot. Batched case: we
670-
# reshape-merge batch axes, matmul, reshape back. For now only
671-
# handle the non-batched case fully; batched falls back to
672-
# BinaryBackend.
718+
# Non-batched: tensordot. Batched: permute to [batch, free, contract]
719+
# on a and [batch, contract, free] on b, flatten to 3-D, hand to
720+
# MLX matmul (which treats leading dims as batch), reshape back to
721+
# Nx's canonical `batch ++ free_a ++ free_b` layout.
673722
@impl true
674723
def dot(%T{} = out, a, contract_a, [], b, contract_b, []) do
675724
Native.tensordot(ref(a), ref(b), contract_a, contract_b) |> wrap(out)
676725
end
677726

678-
# Batched dot is on the transformer-attention critical path. M3
679-
# replaces this BinaryBackend bounce with a native matmul-with-
680-
# transpose pattern before Bumblebee lands.
681-
def dot(out, a, contract_a, batch_a, b, contract_b, batch_b),
682-
do: via_binary(out, [a, b], &Nx.dot(&1, contract_a, batch_a, &2, contract_b, batch_b))
727+
def dot(%T{type: type} = out, a, contract_a, batch_a, b, contract_b, batch_b) do
728+
# MLX matmul is float-only; ints/preds fall through to BinaryBackend.
729+
# In practice every transformer-attention call is float, so this is
730+
# the hot path.
731+
if float_like?(type) do
732+
batched_matmul(out, a, contract_a, batch_a, b, contract_b, batch_b)
733+
else
734+
via_binary(out, [a, b], &Nx.dot(&1, contract_a, batch_a, &2, contract_b, batch_b))
735+
end
736+
end
737+
738+
defp float_like?({kind, _}) when kind in [:f, :bf, :c], do: true
739+
defp float_like?(_), do: false
740+
741+
# Nx guarantees batch axes on both tensors are [0, 1, ..., k-1] in
742+
# increasing order, so the permutation simplifies: batch dims stay
743+
# at the front, free axes sort in positional order, contract axes
744+
# in the Nx-given pairing order.
745+
defp batched_matmul(
746+
%T{shape: out_shape} = out,
747+
%T{shape: as} = a,
748+
contract_a,
749+
batch_a,
750+
%T{shape: bs} = b,
751+
contract_b,
752+
_batch_b
753+
) do
754+
a_rank = tuple_size(as)
755+
b_rank = tuple_size(bs)
756+
k = length(batch_a)
757+
758+
contract_set_a = MapSet.new(contract_a)
759+
contract_set_b = MapSet.new(contract_b)
760+
761+
free_a = for i <- k..(a_rank - 1)//1, not MapSet.member?(contract_set_a, i), do: i
762+
free_b = for i <- k..(b_rank - 1)//1, not MapSet.member?(contract_set_b, i), do: i
763+
764+
b_prod = dim_product(batch_a, as)
765+
m = dim_product(free_a, as)
766+
n = dim_product(free_b, bs)
767+
k_prod = dim_product(contract_a, as)
768+
769+
perm_a = batch_a ++ free_a ++ contract_a
770+
perm_b = batch_a ++ contract_b ++ free_b
771+
772+
ra =
773+
a
774+
|> ref()
775+
|> Native.transpose(perm_a)
776+
|> Native.reshape([b_prod, m, k_prod])
777+
778+
rb =
779+
b
780+
|> ref()
781+
|> Native.transpose(perm_b)
782+
|> Native.reshape([b_prod, k_prod, n])
783+
784+
Native.matmul(ra, rb)
785+
|> Native.reshape(shape_list(out_shape))
786+
|> wrap(out)
787+
end
788+
789+
defp dim_product(axes, shape), do: Enum.reduce(axes, 1, &(elem(shape, &1) * &2))
683790

684791
# =================================================================
685792
# Sort / argsort / top_k / all_close / take / take_along_axis

lib/emily/native.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ defmodule Emily.Native do
5757
@spec astype(tensor(), dtype()) :: tensor()
5858
def astype(_a, _dtype), do: nif()
5959

60+
@spec bitcast(tensor(), dtype()) :: tensor()
61+
def bitcast(_a, _dtype), do: nif()
62+
6063
# --- Unary -------------------------------------------------------
6164

6265
unary_ops = [

mix.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ defmodule Emily.MixProject do
5252
{:elixir_make, "~> 0.9"},
5353
{:fine, "~> 0.1"},
5454
{:nx, "~> 0.10"},
55+
{:bumblebee, "~> 0.6", only: :test},
56+
{:tokenizers, "~> 0.5", only: :test},
5557
{:stream_data, "~> 1.1", only: [:dev, :test]},
5658
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
5759
{:ex_doc, "~> 0.34", only: :docs, runtime: false}

0 commit comments

Comments
 (0)