Skip to content

Commit 1a81420

Browse files
authored
Merge pull request #152 from ausimian/feat/expr-compiler-cm5
feat: no-fallback conformance — DistilBERT + ViT under the compiler (CM5)
2 parents e2bf3f8 + fa7fc45 commit 1a81420

4 files changed

Lines changed: 240 additions & 2 deletions

File tree

c_src/emily/opcodes.hpp

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,12 @@ enum class Opcode : int64_t {
113113
// operands [src, update, start(s32 [naxes])]; iattrs [[axes...]] —
114114
// dynamic put_slice (runtime start indices) via mx::slice_update.
115115
DynSliceUpdate = 63,
116+
// operands [input(NHWC), kernel(OHWI)]; iattrs [[stride],[pad_lo],
117+
// [pad_hi],[kernel_dilation],[input_dilation],[groups],[flip]]
118+
ConvGeneral = 64,
116119
};
117120

118-
inline constexpr int64_t kOpcodeCount = 64;
121+
inline constexpr int64_t kOpcodeCount = 65;
119122

120123
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
121124
inline std::string qmode_from_code(int64_t code) {
@@ -479,6 +482,18 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
479482
emily::to_int_vec(attr0(iattrs, "dyn_slice_update")),
480483
s);
481484
}
485+
case Opcode::ConvGeneral: {
486+
need2(in, "conv_general");
487+
int groups =
488+
emily::checked_int(scalar_at(iattrs, 5, "conv_general"), "groups");
489+
bool flip = scalar_at(iattrs, 6, "conv_general") != 0;
490+
return mx::conv_general(
491+
in[0], in[1], emily::to_int_vec(attr_at(iattrs, 0, "conv_general")),
492+
emily::to_int_vec(attr_at(iattrs, 1, "conv_general")),
493+
emily::to_int_vec(attr_at(iattrs, 2, "conv_general")),
494+
emily::to_int_vec(attr_at(iattrs, 3, "conv_general")),
495+
emily::to_int_vec(attr_at(iattrs, 4, "conv_general")), groups, flip, s);
496+
}
482497
}
483498
throw std::invalid_argument("unknown opcode " +
484499
std::to_string(static_cast<int64_t>(op)));

lib/emily/ir.ex

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ defmodule Emily.IR do
109109
quantized_matmul: 60,
110110
take: 61,
111111
concatenate: 62,
112-
dyn_slice_update: 63
112+
dyn_slice_update: 63,
113+
conv_general: 64
113114
}
114115

115116
# Quant mode string -> code; decoded by qmode_from_code in
@@ -523,6 +524,61 @@ defmodule Emily.IR do
523524
lower_block(struct, in_args, expr, t, state)
524525
end
525526

527+
# concatenate(tensors, axis): join a list of tensors along `axis`.
528+
# Mirrors Emily.Backend.concatenate/3 (no input cast; the result is
529+
# coerced to out.type).
530+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :concatenate, args: [tensors, axis]}} = t, state) do
531+
{refs, state} = Enum.map_reduce(tensors, state, &lower_node/2)
532+
emit_coerced(state, :concatenate, refs, [[axis]], t.type)
533+
end
534+
535+
# conv: ports Emily.Backend.conv/4 — permute input -> NHWC and kernel ->
536+
# OHWI (casting both to out.type), mx::conv_general, then permute the
537+
# result NHWC -> NCHW -> the user's output layout. batch_group_size > 1
538+
# and complex types are unsupported (the backend falls back; we raise —
539+
# no fallback).
540+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :conv, args: [input, kernel, opts]}} = t, state) do
541+
if opts[:batch_group_size] > 1 or match?({:c, _}, t.type) do
542+
raise ArgumentError,
543+
"Emily Expr compiler: conv with batch_group_size > 1 or a complex " <>
544+
"output type is not supported (no fallback)."
545+
end
546+
547+
type = t.type
548+
ip = opts[:input_permutation]
549+
kp = opts[:kernel_permutation]
550+
{lows, highs} = opts[:padding] |> Enum.unzip()
551+
552+
input_to_nhwc = [hd(ip)] ++ Enum.drop(ip, 2) ++ [Enum.at(ip, 1)]
553+
kernel_to_ohwi = [hd(kp)] ++ Enum.drop(kp, 2) ++ [Enum.at(kp, 1)]
554+
rank = tuple_size(t.shape)
555+
nhwc_to_nchw = [0, rank - 1] ++ Enum.to_list(1..(rank - 2)//1)
556+
inv_op = invert_permutation(opts[:output_permutation])
557+
558+
{ir, state} = lower_node(input, state)
559+
{ir, state} = emit(state, :astype, [ir], [[dtype_code(type)]])
560+
{ir, state} = emit(state, :transpose, [ir], [input_to_nhwc])
561+
562+
{kr, state} = lower_node(kernel, state)
563+
{kr, state} = emit(state, :astype, [kr], [[dtype_code(type)]])
564+
{kr, state} = emit(state, :transpose, [kr], [kernel_to_ohwi])
565+
566+
conv_attrs = [
567+
opts[:strides],
568+
lows,
569+
highs,
570+
opts[:kernel_dilation],
571+
opts[:input_dilation],
572+
[opts[:feature_group_size]],
573+
[0]
574+
]
575+
576+
{r, state} = emit(state, :conv_general, [ir, kr], conv_attrs)
577+
{r, state} = emit(state, :transpose, [r], [nhwc_to_nchw])
578+
{r, state} = emit(state, :transpose, [r], [inv_op])
579+
coerce(r, type, state)
580+
end
581+
526582
# cond: raw args [clauses, last], clauses = [{pred, body}, ...]. Lower to
527583
# a select chain `where(p1, b1, where(p2, b2, ... last))`. ALL branches
528584
# are evaluated (Nx branches are side-effect-free and shape-compatible);
@@ -695,6 +751,13 @@ defmodule Emily.IR do
695751
defp float_like?({kind, _}) when kind in [:f, :bf, :c], do: true
696752
defp float_like?(_), do: false
697753

754+
# Invert a 0-based permutation (mirrors Emily.Backend.invert_permutation/1)
755+
# — reverse Nx's "user -> canonical" output_permutation to "canonical ->
756+
# user" for the final conv transpose.
757+
defp invert_permutation(perm) do
758+
perm |> Enum.with_index() |> Enum.sort() |> Enum.map(&elem(&1, 1))
759+
end
760+
698761
defp dim_product(axes, shape), do: Enum.reduce(axes, 1, &(elem(shape, &1) * &2))
699762

700763
# Coerce a ref to `type` (emit an astype). MLX astype to the same dtype

test/emily/compiler_equivalence_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,35 @@ defmodule Emily.CompilerEquivalenceTest do
325325
end
326326
end
327327

328+
describe "concatenate / conv" do
329+
test "concatenate along axes matches" do
330+
a = et([[1.0, 2.0], [3.0, 4.0]])
331+
b = et([[5.0, 6.0], [7.0, 8.0]])
332+
assert_equiv(fn a, b -> Nx.concatenate([a, b], axis: 0) end, [a, b])
333+
assert_equiv(fn a, b -> Nx.concatenate([a, b], axis: 1) end, [a, b])
334+
assert_equiv(fn a, b -> Nx.concatenate([a, b, a]) end, [a, b])
335+
end
336+
337+
test "2-D conv (patch-embed style) matches the evaluator" do
338+
# NCHW input {1, 3, 8, 8}; OIHW kernel {4, 3, 2, 2}, stride 2 (patches).
339+
x = Nx.iota({1, 3, 8, 8}, type: :f32, backend: Emily.Backend) |> Nx.divide(192.0)
340+
k = Nx.iota({4, 3, 2, 2}, type: :f32, backend: Emily.Backend) |> Nx.divide(48.0)
341+
assert_equiv(fn x, k -> Nx.conv(x, k, strides: [2, 2]) end, [x, k])
342+
end
343+
344+
test "conv with padding + feature groups matches" do
345+
x = Nx.iota({1, 4, 6, 6}, type: :f32, backend: Emily.Backend) |> Nx.divide(144.0)
346+
k = Nx.iota({4, 2, 3, 3}, type: :f32, backend: Emily.Backend) |> Nx.divide(72.0)
347+
348+
assert_equiv(
349+
fn x, k ->
350+
Nx.conv(x, k, strides: [1, 1], padding: [{1, 1}, {1, 1}], feature_group_size: 2)
351+
end,
352+
[x, k]
353+
)
354+
end
355+
end
356+
328357
describe "take (embedding lookup)" do
329358
test "take matches the Evaluator" do
330359
embed = et([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]])
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
defmodule Emily.Conformance.CompilerNativeTest do
2+
@moduledoc """
3+
CM5 — the **no-fallback** gate: real Bumblebee model forwards compile
4+
through the native single-NIF `Emily.Compiler` (`native: true`) and
5+
match the Evaluator-on-`Emily.Backend` path, with **zero** fallback to
6+
`Nx.BinaryBackend`.
7+
8+
The native compiler lowers the whole `Nx.Defn.Expr` to one program and
9+
**raises** on any op it can't lower (no silent fallback by design), so a
10+
forward that completes proves full native op coverage for that model.
11+
We additionally fail on any `[:emily, :fallback, *]` telemetry — the
12+
Backend-level BinaryBackend fallback — so an op that silently round-trips
13+
to the host is caught too.
14+
15+
Gated `:conformance` (downloads ~3 MB tiny-random HF fixtures); run with
16+
`mix test --only conformance`.
17+
"""
18+
use ExUnit.Case, async: false
19+
20+
@moduletag :conformance
21+
@moduletag timeout: 600_000
22+
23+
setup do
24+
prev = Nx.default_backend()
25+
Nx.global_default_backend(Emily.Backend)
26+
on_exit(fn -> Nx.global_default_backend(prev) end)
27+
:ok
28+
end
29+
30+
# Run `model`'s forward both ways and assert every output leaf agrees,
31+
# while asserting no Backend fallback fires on the native path.
32+
defp assert_native_matches(model, params, inputs) do
33+
{_init, native_predict} = Axon.build(model, compiler: Emily.Compiler, native: true)
34+
{_init, eval_predict} = Axon.build(model, compiler: Emily.Compiler)
35+
36+
{native, fallbacks} = with_fallback_count(fn -> native_predict.(params, inputs) end)
37+
eval = eval_predict.(params, inputs)
38+
39+
assert fallbacks == 0,
40+
"native compile path triggered #{fallbacks} Backend BinaryBackend fallback(s)"
41+
42+
compare_outputs(native, eval)
43+
end
44+
45+
# Count [:emily, :fallback, *] telemetry events during `fun`.
46+
defp with_fallback_count(fun) do
47+
ref = make_ref()
48+
me = self()
49+
id = {__MODULE__, ref}
50+
51+
:telemetry.attach_many(
52+
id,
53+
[[:emily, :fallback, :start], [:emily, :fallback, :stop], [:emily, :fallback, :exception]],
54+
&__MODULE__.handle_fallback/4,
55+
{me, ref}
56+
)
57+
58+
result = fun.()
59+
:telemetry.detach(id)
60+
61+
count = drain(ref, 0)
62+
{result, count}
63+
end
64+
65+
@doc false
66+
def handle_fallback(_event, _measure, _meta, {pid, ref}), do: send(pid, {ref, :fallback})
67+
68+
defp drain(ref, n) do
69+
receive do
70+
{^ref, :fallback} -> drain(ref, n + 1)
71+
after
72+
0 -> n
73+
end
74+
end
75+
76+
defp compare_outputs(%Nx.Tensor{} = native, %Nx.Tensor{} = eval) do
77+
assert native.shape == eval.shape
78+
assert native.type == eval.type
79+
# Same MLX kernels in the same order => exact; allow a tiny tolerance
80+
# against fp reassociation in the lazy-graph eval.
81+
n = Nx.to_flat_list(native)
82+
e = Nx.to_flat_list(eval)
83+
84+
assert Enum.zip(n, e) |> Enum.all?(fn {a, b} -> abs(a - b) <= 1.0e-4 + 1.0e-4 * abs(b) end),
85+
"native vs evaluator outputs diverge beyond tolerance"
86+
end
87+
88+
defp compare_outputs(native, eval) when is_map(native) and not is_struct(native) do
89+
for {k, nv} <- native, Map.has_key?(eval, k) do
90+
compare_outputs(nv, Map.fetch!(eval, k))
91+
end
92+
end
93+
94+
# Axon.None placeholders, tuples, and other non-tensor leaves: skip.
95+
defp compare_outputs(_native, _eval), do: :ok
96+
97+
test "tiny DistilBERT base forward: single-NIF native == evaluator, no fallback" do
98+
{:ok, %{model: model, params: params, spec: _spec}} =
99+
Bumblebee.load_model({:hf, "hf-internal-testing/tiny-random-DistilBertModel"})
100+
101+
inputs = %{
102+
"input_ids" => Nx.tensor([[1, 5, 7, 2, 3, 9]], backend: Emily.Backend),
103+
"attention_mask" => Nx.tensor([[1, 1, 1, 1, 1, 1]], backend: Emily.Backend)
104+
}
105+
106+
assert_native_matches(model, params, inputs)
107+
end
108+
109+
test "tiny DistilBERT for-masked-LM forward: native == evaluator, no fallback" do
110+
{:ok, %{model: model, params: params}} =
111+
Bumblebee.load_model({:hf, "hf-internal-testing/tiny-random-DistilBertForMaskedLM"})
112+
113+
inputs = %{
114+
"input_ids" => Nx.tensor([[1, 5, 7, 2]], backend: Emily.Backend),
115+
"attention_mask" => Nx.tensor([[1, 1, 1, 1]], backend: Emily.Backend)
116+
}
117+
118+
assert_native_matches(model, params, inputs)
119+
end
120+
121+
test "tiny ViT base forward (conv patch embed): native == evaluator, no fallback" do
122+
{:ok, %{model: model, params: params}} =
123+
Bumblebee.load_model({:hf, "hf-internal-testing/tiny-random-ViTModel"}, architecture: :base)
124+
125+
inputs = %{
126+
"pixel_values" => Nx.broadcast(Nx.tensor(0.1, backend: Emily.Backend), {1, 30, 30, 3})
127+
}
128+
129+
assert_native_matches(model, params, inputs)
130+
end
131+
end

0 commit comments

Comments
 (0)