Skip to content

Commit a4a5d9e

Browse files
authored
Merge pull request #10 from ausimian/feat/m3-coverage-tests
Cover every via_binary fallback and raise path
2 parents 8e63d84 + 1218968 commit a4a5d9e

4 files changed

Lines changed: 358 additions & 2 deletions

File tree

RELEASE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,36 @@
162162
to match Bumblebee's current constraint; emily's own API is
163163
unaffected.
164164

165+
## Fixed
166+
167+
- `Emily.Backend.put_slice/4` — swapped `slice` and `start_indices`
168+
parameters. Latent since M2 because the callback routes through
169+
the BinaryBackend fallback and had no direct test. Surfaced by
170+
the new fallback-coverage suite.
171+
172+
## Tests
173+
174+
- `test/emily/backend_fallbacks_test.exs` — smoke coverage for every
175+
`via_binary` branch (`put_slice`, multi-axis `gather`, `conv`,
176+
`reduce`, `window_reduce`, `window_sum`/`_product`/`_max`/`_min`,
177+
`window_scatter_max`/`_min`, `indexed_add`/`_put`, `lu`,
178+
`triangular_solve`, `svd`) plus the forced-fallback branches
179+
(integer batched `dot`, interior-axis `cumulative_*`). The
180+
fallback dispatches to BinaryBackend, so comparing against
181+
BinaryBackend is tautological — these tests verify the transfer /
182+
compute / rewrap round-trip runs clean, not numerical correctness.
183+
- Extended `test/emily/backend_lifecycle_test.exs` with the three
184+
raise-only callbacks (`count_leading_zeros`, `population_count`,
185+
`pad` with interior padding), the `backend_transfer(t, Nx.Tensor)`
186+
identity case, the `from_binary` iodata path, and the
187+
`inspect` `:infinity` limit branch.
188+
- Aggregate coverage with `mix test --cover --include conformance`:
189+
74.7% → 81.9% total; `Emily.Backend` 73.5% → 82.3%. Remaining
190+
uncovered in `Emily.Backend` is a handful of functional ops not
191+
yet in the property suite (`fft`/`ifft`/`fft2`/`ifft2`, `argsort`,
192+
`top_k`, `erfc`, `cbrt`, `all_close` with `equal_nan: true`) plus
193+
unreachable defensive branches.
194+
165195
## Notes
166196

167197
- Ops files use anonymous namespaces to prevent NIF function names

lib/emily/backend.ex

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,9 +593,12 @@ defmodule Emily.Backend do
593593
defp slice_start(%T{} = t), do: t |> Nx.backend_copy(Nx.BinaryBackend) |> Nx.to_number()
594594

595595
# put_slice: MLX has no direct primitive; route via BinaryBackend.
596+
# Nx's Backend contract order is (out, tensor, start_indices, slice).
597+
# start_indices arrive as scalar tensors on Emily.Backend; Nx auto-
598+
# transfers them when the BinaryBackend call goes through `to_indices`.
596599
@impl true
597-
def put_slice(out, t, slice, starts),
598-
do: via_binary(out, [t, slice], &Nx.put_slice(&1, starts, &2))
600+
def put_slice(out, t, start_indices, slice),
601+
do: via_binary(out, [t, slice], &Nx.put_slice(&1, start_indices, &2))
599602

600603
@impl true
601604
def select(%T{} = out, pred, on_true, on_false) do
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
defmodule Emily.Backend.FallbacksTest do
2+
@moduledoc """
3+
Tests that exercise every `via_binary` fallback path in
4+
`Emily.Backend`. These are backend callbacks with no single MLX
5+
primitive — we transfer inputs to `Nx.BinaryBackend`, run the
6+
reference op there, and transfer the result back.
7+
8+
Because the fallback dispatches *to* BinaryBackend, comparing its
9+
output against a direct BinaryBackend call is tautological. These
10+
aren't correctness tests — they're a coverage harness to make sure
11+
every via_binary branch compiles, transfers, and rewraps without
12+
error, and that the dtype/shape of the result matches the Nx
13+
contract for the op.
14+
15+
The cost of these fallbacks (a full round-trip to CPU per call) is
16+
why the ops they guard are on the roadmap for native translation;
17+
keeping the smoke coverage here lets us delete each test as the
18+
corresponding native path lands.
19+
"""
20+
21+
use ExUnit.Case, async: true
22+
23+
defp emily(list, type \\ {:f, 32}) do
24+
Nx.tensor(list, type: type, backend: Emily.Backend)
25+
end
26+
27+
defp flat(t), do: Nx.to_flat_list(t)
28+
29+
describe "indexing fallbacks" do
30+
test "put_slice routes through BinaryBackend" do
31+
t = emily([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
32+
u = emily([[10.0, 20.0]])
33+
34+
result = Nx.put_slice(t, [0, 1], u)
35+
36+
assert Nx.shape(result) == {2, 3}
37+
assert flat(result) == [1.0, 10.0, 20.0, 4.0, 5.0, 6.0]
38+
end
39+
40+
test "gather with multi-axis indices routes through BinaryBackend" do
41+
t = emily([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
42+
# Multi-axis index: each index selects across axes [0, 1].
43+
idx = Nx.tensor([[0, 0], [1, 1], [2, 0]], backend: Emily.Backend)
44+
45+
result = Nx.gather(t, idx, axes: [0, 1])
46+
47+
assert flat(result) == [1.0, 4.0, 5.0]
48+
end
49+
50+
test "indexed_add routes through BinaryBackend" do
51+
t = emily([1.0, 2.0, 3.0, 4.0])
52+
idx = Nx.tensor([[0], [2]], backend: Emily.Backend)
53+
upd = emily([10.0, 100.0])
54+
55+
result = Nx.indexed_add(t, idx, upd)
56+
57+
assert flat(result) == [11.0, 2.0, 103.0, 4.0]
58+
end
59+
60+
test "indexed_put routes through BinaryBackend" do
61+
t = emily([1.0, 2.0, 3.0, 4.0])
62+
idx = Nx.tensor([[0], [2]], backend: Emily.Backend)
63+
upd = emily([99.0, 77.0])
64+
65+
result = Nx.indexed_put(t, idx, upd)
66+
67+
assert flat(result) == [99.0, 2.0, 77.0, 4.0]
68+
end
69+
end
70+
71+
describe "convolution fallback" do
72+
test "conv routes through BinaryBackend" do
73+
# {batch=1, channels=1, height=3, width=3} input, {out=1, in=1, 2, 2} kernel.
74+
input = Nx.iota({1, 1, 3, 3}, type: {:f, 32}, backend: Emily.Backend)
75+
kernel = emily([[[[1.0, 0.0], [0.0, 1.0]]]])
76+
77+
result = Nx.conv(input, kernel)
78+
79+
assert Nx.shape(result) == {1, 1, 2, 2}
80+
# Diagonal kernel: 0+4=4, 1+5=6, 3+7=10, 4+8=12.
81+
assert flat(result) == [4.0, 6.0, 10.0, 12.0]
82+
end
83+
end
84+
85+
describe "reduce fallbacks" do
86+
test "reduce with a custom accumulator function" do
87+
t = emily([1.0, 2.0, 3.0, 4.0])
88+
acc = Nx.tensor(0.0, backend: Emily.Backend)
89+
90+
result = Nx.reduce(t, acc, fn x, a -> Nx.add(x, a) end)
91+
92+
assert Nx.to_number(result) == 10.0
93+
end
94+
95+
test "window_reduce with a custom accumulator function" do
96+
t = emily([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
97+
acc = Nx.tensor(0.0, backend: Emily.Backend)
98+
99+
result =
100+
Nx.window_reduce(t, acc, {1, 2}, [strides: [1, 1]], fn x, a -> Nx.max(x, a) end)
101+
102+
assert Nx.shape(result) == {2, 2}
103+
assert flat(result) == [2.0, 3.0, 5.0, 6.0]
104+
end
105+
end
106+
107+
describe "window reductions" do
108+
setup do
109+
%{tensor: emily([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]])}
110+
end
111+
112+
test "window_sum", %{tensor: t} do
113+
result = Nx.window_sum(t, {1, 2})
114+
assert flat(result) == [3.0, 5.0, 7.0, 11.0, 13.0, 15.0]
115+
end
116+
117+
test "window_product", %{tensor: t} do
118+
result = Nx.window_product(t, {1, 2})
119+
assert flat(result) == [2.0, 6.0, 12.0, 30.0, 42.0, 56.0]
120+
end
121+
122+
test "window_max", %{tensor: t} do
123+
result = Nx.window_max(t, {1, 2})
124+
assert flat(result) == [2.0, 3.0, 4.0, 6.0, 7.0, 8.0]
125+
end
126+
127+
test "window_min", %{tensor: t} do
128+
result = Nx.window_min(t, {1, 2})
129+
assert flat(result) == [1.0, 2.0, 3.0, 5.0, 6.0, 7.0]
130+
end
131+
end
132+
133+
describe "window scatter" do
134+
# Scatter selects the argmax/argmin within each window and scatters
135+
# the corresponding `source` entry into the output. Small inputs;
136+
# we're only checking the via_binary path runs clean.
137+
test "window_scatter_max" do
138+
t = emily([[1.0, 2.0], [3.0, 4.0]])
139+
source = emily([[5.0]])
140+
init = Nx.tensor(0.0, backend: Emily.Backend)
141+
142+
result = Nx.window_scatter_max(t, source, init, {2, 2}, strides: [1, 1])
143+
144+
assert Nx.shape(result) == {2, 2}
145+
# Max element is 4.0 (bottom-right); that position receives 5.0.
146+
assert flat(result) == [0.0, 0.0, 0.0, 5.0]
147+
end
148+
149+
test "window_scatter_min" do
150+
t = emily([[1.0, 2.0], [3.0, 4.0]])
151+
source = emily([[5.0]])
152+
init = Nx.tensor(0.0, backend: Emily.Backend)
153+
154+
result = Nx.window_scatter_min(t, source, init, {2, 2}, strides: [1, 1])
155+
156+
# Min element is 1.0 (top-left).
157+
assert flat(result) == [5.0, 0.0, 0.0, 0.0]
158+
end
159+
end
160+
161+
describe "linear algebra fallbacks" do
162+
test "lu returns (p, l, u) tuple via BinaryBackend" do
163+
t = emily([[2.0, 1.0], [1.0, 3.0]])
164+
{p, l, u} = Nx.LinAlg.lu(t)
165+
166+
assert Nx.shape(p) == {2, 2}
167+
assert Nx.shape(l) == {2, 2}
168+
assert Nx.shape(u) == {2, 2}
169+
170+
# Round-trip check: P * L * U ≈ original (within f32 tolerance).
171+
reconstructed = p |> Nx.dot(l) |> Nx.dot(u)
172+
assert_in_delta Nx.to_number(reconstructed[0][0]), 2.0, 1.0e-4
173+
assert_in_delta Nx.to_number(reconstructed[1][1]), 3.0, 1.0e-4
174+
end
175+
176+
test "svd returns (u, s, vt) tuple via BinaryBackend" do
177+
t = emily([[3.0, 0.0], [0.0, 4.0]])
178+
{u, s, vt} = Nx.LinAlg.svd(t)
179+
180+
assert Nx.shape(u) == {2, 2}
181+
assert Nx.shape(s) == {2}
182+
assert Nx.shape(vt) == {2, 2}
183+
184+
# Singular values of a positive diagonal are its entries, sorted.
185+
[s0, s1] = flat(s)
186+
assert_in_delta max(s0, s1), 4.0, 1.0e-4
187+
assert_in_delta min(s0, s1), 3.0, 1.0e-4
188+
end
189+
190+
test "triangular_solve routes through BinaryBackend" do
191+
# L x = b with L lower-triangular; x should be [1, 1].
192+
l = emily([[2.0, 0.0], [1.0, 3.0]])
193+
b = emily([2.0, 4.0])
194+
195+
x = Nx.LinAlg.triangular_solve(l, b)
196+
197+
assert_in_delta Nx.to_number(x[0]), 1.0, 1.0e-4
198+
assert_in_delta Nx.to_number(x[1]), 1.0, 1.0e-4
199+
end
200+
end
201+
202+
describe "forced fallback branches" do
203+
# Batched dot with integer operands: MLX matmul is float-only, so
204+
# `Emily.Backend.dot/7` routes integer-batched calls through
205+
# BinaryBackend. Float-batched path is covered by the property
206+
# tests in backend_test.exs.
207+
test "batched dot with s32 operands falls back" do
208+
a = Nx.iota({2, 3, 4}, type: {:s, 32}, backend: Emily.Backend)
209+
b = Nx.iota({2, 4, 5}, type: {:s, 32}, backend: Emily.Backend)
210+
211+
emily = Nx.dot(a, [2], [0], b, [1], [0])
212+
213+
ref_a = Nx.iota({2, 3, 4}, type: {:s, 32}, backend: Nx.BinaryBackend)
214+
ref_b = Nx.iota({2, 4, 5}, type: {:s, 32}, backend: Nx.BinaryBackend)
215+
ref = Nx.dot(ref_a, [2], [0], ref_b, [1], [0])
216+
217+
assert Nx.shape(emily) == {2, 3, 5}
218+
assert flat(emily) == Nx.to_flat_list(ref)
219+
end
220+
221+
# Interior-axis cumulative: MLX's cumulative kernels raise on
222+
# some 4-D+ shape factorings, so the backend routes interior-axis
223+
# cumulation through BinaryBackend. Last-axis stays native.
224+
test "cumulative_sum on an interior axis falls back" do
225+
t = Nx.iota({2, 3, 4}, type: {:f, 32}, backend: Emily.Backend)
226+
result = Nx.cumulative_sum(t, axis: 1)
227+
228+
ref = Nx.iota({2, 3, 4}, type: {:f, 32}, backend: Nx.BinaryBackend)
229+
ref_result = Nx.cumulative_sum(ref, axis: 1)
230+
231+
assert flat(result) == Nx.to_flat_list(ref_result)
232+
end
233+
234+
test "cumulative_product on an interior axis falls back" do
235+
t = emily([[[1.0, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]])
236+
result = Nx.cumulative_product(t, axis: 1)
237+
assert Nx.shape(result) == {2, 2, 2}
238+
end
239+
240+
test "cumulative_max on an interior axis falls back" do
241+
t = emily([[[1.0, 4.0], [3.0, 2.0]], [[5.0, 8.0], [7.0, 6.0]]])
242+
result = Nx.cumulative_max(t, axis: 1)
243+
assert Nx.shape(result) == {2, 2, 2}
244+
end
245+
246+
test "cumulative_min on an interior axis falls back" do
247+
t = emily([[[1.0, 4.0], [3.0, 2.0]], [[5.0, 8.0], [7.0, 6.0]]])
248+
result = Nx.cumulative_min(t, axis: 1)
249+
assert Nx.shape(result) == {2, 2, 2}
250+
end
251+
end
252+
end

test/emily/backend_lifecycle_test.exs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,77 @@ defmodule Emily.Backend.LifecycleTest do
146146
end
147147
end
148148

149+
# MLX has no instruction for either. Nx's population_count /
150+
# count_leading_zeros apply to small int tensors, and a workaround
151+
# would be pure CPU — not worth carrying until a caller needs it.
152+
describe "unsupported bitwise ops" do
153+
test "count_leading_zeros raises" do
154+
t = Nx.tensor([1, 2, 3], type: {:s, 32}, backend: Emily.Backend)
155+
156+
assert_raise ArgumentError, ~r/count_leading_zeros/, fn ->
157+
Nx.count_leading_zeros(t)
158+
end
159+
end
160+
161+
test "population_count raises" do
162+
t = Nx.tensor([1, 2, 3], type: {:s, 32}, backend: Emily.Backend)
163+
164+
assert_raise ArgumentError, ~r/population_count/, fn ->
165+
Nx.population_count(t)
166+
end
167+
end
168+
end
169+
170+
describe "pad" do
171+
# MLX pad supports low/high padding but no interior dilation. Reject
172+
# cleanly rather than silently dropping the interior spec.
173+
test "raises on interior padding" do
174+
t = Nx.tensor([[1.0, 2.0], [3.0, 4.0]], backend: Emily.Backend)
175+
pad_value = Nx.tensor(0.0, backend: Emily.Backend)
176+
177+
assert_raise ArgumentError, ~r/interior padding/, fn ->
178+
Emily.Backend.pad(t, t, pad_value, [{0, 0, 1}, {0, 0, 0}])
179+
end
180+
end
181+
end
182+
183+
describe "backend_transfer edge cases" do
184+
# Transferring to the meta-module `Nx.Tensor` is a no-op —
185+
# Nx.BinaryBackend treats it the same way. This keeps code that
186+
# calls `backend_transfer(t, Nx.Tensor)` portable across backends.
187+
test "to Nx.Tensor is identity" do
188+
t = Nx.tensor([1.0, 2.0], backend: Emily.Backend)
189+
same = Emily.Backend.backend_transfer(t, Nx.Tensor, [])
190+
assert same.data.__struct__ == Emily.Backend
191+
end
192+
end
193+
194+
describe "from_binary iodata fallback" do
195+
# `Nx.from_binary`'s public API guards for `is_binary(binary)`, but
196+
# the backend callback also handles iodata. Call the backend
197+
# directly to exercise the iolist branch of `ensure_binary/1`.
198+
test "flattens an iolist of binaries" do
199+
bin1 = <<1.0::float-32-native, 2.0::float-32-native>>
200+
bin2 = <<3.0::float-32-native, 4.0::float-32-native>>
201+
202+
out_template = %Nx.Tensor{shape: {4}, type: {:f, 32}, names: [nil], data: nil}
203+
t = Emily.Backend.from_binary(out_template, [bin1, bin2], [])
204+
assert Nx.to_flat_list(t) == [1.0, 2.0, 3.0, 4.0]
205+
end
206+
end
207+
208+
describe "inspect/2 limit" do
209+
# Nx passes `limit: :infinity` when the caller wants the full
210+
# tensor. Exercises the :infinity branch of Emily.Backend.inspect.
211+
test "renders full tensor under :infinity" do
212+
t = Nx.tensor([1.0, 2.0, 3.0], backend: Emily.Backend)
213+
doc = Emily.Backend.inspect(t, %Inspect.Opts{limit: :infinity, custom_options: []})
214+
rendered = doc |> Inspect.Algebra.format(80) |> IO.iodata_to_binary()
215+
assert rendered =~ "1.0"
216+
assert rendered =~ "3.0"
217+
end
218+
end
219+
149220
describe "bitcast" do
150221
# MLX exposes `mx::view(array, dtype)` — a zero-copy reinterpret
151222
# cast between equal-width dtypes. Nx.Random uses this to move

0 commit comments

Comments
 (0)