Skip to content

Commit 9bebe28

Browse files
committed
M13: EXLA gradient conformance
Add a third gradient oracle — EXLA (XLA CPU backend) — to catch bugs where Emily and BinaryBackend agree on the wrong gradient (they share the same Nx.Defn.grad lowering). Eight zoo functions plus a full transformer-block training step are tested against checked-in EXLA golden values with per-function tolerance tables. - Extract shared defn functions into Emily.GradZoo - Generate golden values via bench/exla_golden_gen.exs (EXLA 0.11.0 CPU) - Test harness at test/emily/grad/exla_oracle_test.exs - CUDA conformance deferred to post-1.0
1 parent 8138c05 commit 9bebe28

9 files changed

Lines changed: 3032 additions & 59 deletions

File tree

PLAN.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,39 @@ tolerance tables — not a global epsilon.
608608
**Exit:** `:grad_conformance` green on default CI; tolerance tables
609609
checked in alongside the goldens.
610610

611+
**Shipped**:
612+
613+
- **Scope change**: EXLA CPU backend on macOS instead of Linux+CUDA.
614+
XLA-CPU is still a fully independent oracle (different compiler,
615+
different kernels from both BinaryBackend and MLX). CUDA conformance
616+
deferred to post-1.0.
617+
- **`Emily.GradZoo`** (`test/support/grad_zoo.ex`) — extracted the 8
618+
`defn` grad functions and `softmax_last/1` from
619+
`grad_equivalence_test.exs` into a shared module. Added `fixed_inputs/1`
620+
(deterministic BinaryBackend tensors per function) and
621+
`grad_function/1` (function captures). Both existing grad test files
622+
updated to import from GradZoo.
623+
- **`Emily.ExlaGoldenData`** (`test/support/exla_golden_data.ex`) —
624+
EXLA 0.11.0 CPU-generated golden gradient values for all 8 zoo
625+
functions plus a 1-step transformer-block training step (forward +
626+
grad + SGD update of all 8 parameters). Inline Elixir float lists,
627+
consistent with the existing conformance golden pattern.
628+
- **`Emily.Grad.ExlaOracleTest`** (`test/emily/grad/exla_oracle_test.exs`)
629+
`@moduletag :grad_conformance`. Per-function tolerance table
630+
(tighter than BinaryBackend's 1e-3: linear ops at 1e-6/1e-5,
631+
compositions at 1e-4/1e-3). Runs in the default test suite.
632+
`grad_dropout` excluded (PRNG divergence across backends).
633+
- **Golden generator** (`bench/exla_golden_gen.exs`) — standalone
634+
Elixir script using `Mix.install` for `{:exla, "~> 0.10"}`. Runs on
635+
macOS (CPU) or Linux+CUDA. Emits a complete `ExlaGoldenData` module:
636+
`elixir bench/exla_golden_gen.exs`.
637+
638+
**Known issue (pre-existing, not M13):**
639+
`test/emily/quantization/transform_test.exs:105` ("round-trip quantized
640+
2-layer MLP predicts close to dense") is seed-dependent flaky — the
641+
relative-error threshold (15 %) sits right at the boundary for some
642+
random inputs. Needs either a wider tolerance or a fixed seed.
643+
611644
### M14 — Serving concurrency cookbook + pooled-serving helper
612645

613646
`Emily.Compiler.__partitions_options__/1` raises on

RELEASE.md

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

33
## Added
44

5+
- M13 — EXLA gradient conformance. Adds a third gradient oracle —
6+
EXLA (XLA CPU backend) — to catch bugs where Emily and BinaryBackend
7+
agree on the wrong gradient (they share the same `Nx.Defn.grad`
8+
lowering). Eight zoo functions plus a full transformer-block training
9+
step (forward + grad + SGD update) are tested against checked-in EXLA
10+
golden values. Per-function tolerance tables (linear ops at 1e-6,
11+
compositions at 1e-4) are calibrated against EXLA 0.11.0 CPU output.
12+
- **New files**: `test/support/grad_zoo.ex` (shared defn functions),
13+
`test/support/exla_golden_data.ex` (golden values),
14+
`test/emily/grad/exla_oracle_test.exs` (test harness),
15+
`bench/exla_golden_gen.exs` (standalone golden generator script).
16+
- **Refactored**: `grad_equivalence_test.exs` and
17+
`finite_diff_test.exs` now import shared functions from
18+
`Emily.GradZoo` instead of defining inline copies.
19+
- CUDA conformance deferred to post-1.0.
20+
521
- M12 — Zero-copy `to_binary`. `Emily.to_binary/1` (and everything
622
that routes through `Nx.to_binary` on the Emily backend) now
723
returns a BEAM resource binary that aliases the MLX buffer

bench/exla_golden_gen.exs

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
# Generates EXLA golden gradient values for Emily's M13 conformance suite.
2+
#
3+
# Usage:
4+
# elixir bench/exla_golden_gen.exs
5+
#
6+
# Writes test/support/exla_golden_data.ex directly (path relative to the
7+
# script). On Linux+CUDA, set EXLA_TARGET=cuda before running.
8+
#
9+
# Regenerate when any of these change:
10+
# - A defn function in Emily.GradZoo (test/support/grad_zoo.ex)
11+
# - The fixed_inputs/1 builders in GradZoo
12+
# - The training step functions in Emily.TrainingHelper
13+
# - The EXLA or Nx version
14+
15+
Mix.install([{:nx, "~> 0.10"}, {:exla, "~> 0.10"}])
16+
17+
defmodule GoldenGen do
18+
import Nx.Defn
19+
20+
# -------------------- Zoo functions --------------------
21+
# Verbatim copies from Emily.GradZoo.
22+
23+
defn(grad_sum_op(x), do: grad(x, fn z -> Nx.sum(z) end))
24+
25+
defn(grad_dot_left(x, b), do: grad(x, fn z -> z |> Nx.dot(b) |> Nx.sum() end))
26+
27+
defn grad_reshape_transpose(x) do
28+
grad(x, fn z ->
29+
z |> Nx.transpose(axes: [1, 0]) |> Nx.reshape({12}) |> Nx.sum()
30+
end)
31+
end
32+
33+
defn grad_broadcast(x) do
34+
grad(x, fn z -> z |> Nx.broadcast({4, 3}) |> Nx.sum() end)
35+
end
36+
37+
defn grad_gather(x, idx) do
38+
grad(x, fn z -> z |> Nx.gather(idx, axes: [0, 1]) |> Nx.sum() end)
39+
end
40+
41+
defn grad_indexed_add(x, idx, upd) do
42+
grad(x, fn z -> z |> Nx.indexed_add(idx, upd) |> Nx.sum() end)
43+
end
44+
45+
defn grad_gather_dot_softmax(x, idx, w) do
46+
grad(x, fn z ->
47+
z
48+
|> Nx.gather(idx, axes: [0])
49+
|> Nx.reshape({3, 6})
50+
|> Nx.dot(w)
51+
|> softmax_last()
52+
|> Nx.sum()
53+
end)
54+
end
55+
56+
defn grad_attention(x, wq, wk, wv, scale) do
57+
grad(x, fn z ->
58+
q = Nx.dot(z, wq)
59+
k = Nx.dot(z, wk)
60+
v = Nx.dot(z, wv)
61+
logits = Nx.dot(q, Nx.transpose(k)) * scale
62+
attn = softmax_last(logits)
63+
attn |> Nx.dot(v) |> Nx.sum()
64+
end)
65+
end
66+
67+
defn softmax_last(t) do
68+
m = Nx.reduce_max(t, axes: [-1], keep_axes: true)
69+
e = Nx.exp(t - m)
70+
e / Nx.sum(e, axes: [-1], keep_axes: true)
71+
end
72+
73+
# -------------------- Training step --------------------
74+
# Verbatim copies from Emily.TrainingHelper.
75+
76+
defn block_forward(params, x, scale) do
77+
q = Nx.dot(x, params.wq)
78+
k = Nx.dot(x, params.wk)
79+
v = Nx.dot(x, params.wv)
80+
logits = Nx.dot(q, Nx.transpose(k)) * scale
81+
attn = softmax_last(logits)
82+
attended = Nx.dot(attn, v) |> Nx.dot(params.wo)
83+
h = x + attended
84+
85+
ff = Nx.max(Nx.dot(h, params.w_ff1) + params.b_ff1, 0.0)
86+
out = Nx.dot(ff, params.w_ff2) + params.b_ff2
87+
h + out
88+
end
89+
90+
defn block_loss(params, x, y, scale) do
91+
out = block_forward(params, x, scale)
92+
diff = out - y
93+
Nx.mean(diff * diff)
94+
end
95+
96+
defn block_step_with_loss(params, x, y, lr, scale) do
97+
loss = block_loss(params, x, y, scale)
98+
grads = grad(params, fn p -> block_loss(p, x, y, scale) end)
99+
100+
new_params = %{
101+
wq: params.wq - lr * grads.wq,
102+
wk: params.wk - lr * grads.wk,
103+
wv: params.wv - lr * grads.wv,
104+
wo: params.wo - lr * grads.wo,
105+
w_ff1: params.w_ff1 - lr * grads.w_ff1,
106+
b_ff1: params.b_ff1 - lr * grads.b_ff1,
107+
w_ff2: params.w_ff2 - lr * grads.w_ff2,
108+
b_ff2: params.b_ff2 - lr * grads.b_ff2
109+
}
110+
111+
{new_params, loss}
112+
end
113+
114+
# -------------------- Input builders --------------------
115+
116+
defp det_weights(shape, seed) do
117+
size = shape |> Tuple.to_list() |> Enum.reduce(1, &(&1 * &2))
118+
119+
Nx.iota({size}, type: {:f, 32}, backend: Nx.BinaryBackend)
120+
|> Nx.multiply(0.7)
121+
|> Nx.add(seed * 7.1)
122+
|> Nx.sin()
123+
|> Nx.multiply(0.3)
124+
|> Nx.reshape(shape)
125+
end
126+
127+
defp fixed_inputs(:grad_sum_op), do: [det_weights({3, 4}, 1)]
128+
defp fixed_inputs(:grad_dot_left), do: [det_weights({3, 4}, 2), det_weights({4, 5}, 3)]
129+
defp fixed_inputs(:grad_reshape_transpose), do: [det_weights({3, 4}, 4)]
130+
defp fixed_inputs(:grad_broadcast), do: [det_weights({3}, 5)]
131+
132+
defp fixed_inputs(:grad_gather) do
133+
[
134+
det_weights({4, 5}, 6),
135+
Nx.tensor([[0, 1], [2, 3], [1, 0]], type: {:s, 32}, backend: Nx.BinaryBackend)
136+
]
137+
end
138+
139+
defp fixed_inputs(:grad_indexed_add) do
140+
[
141+
det_weights({3, 4}, 7),
142+
Nx.tensor([[0, 1], [2, 3], [1, 0]], type: {:s, 32}, backend: Nx.BinaryBackend),
143+
Nx.iota({3}, type: {:f, 32}, backend: Nx.BinaryBackend) |> Nx.add(1.0)
144+
]
145+
end
146+
147+
defp fixed_inputs(:grad_gather_dot_softmax) do
148+
[
149+
det_weights({4, 6}, 8),
150+
Nx.tensor([[0], [2], [1]], backend: Nx.BinaryBackend),
151+
Nx.iota({6, 5}, type: {:f, 32}, backend: Nx.BinaryBackend) |> Nx.divide(30.0)
152+
]
153+
end
154+
155+
defp fixed_inputs(:grad_attention) do
156+
[
157+
det_weights({3, 4}, 9),
158+
Nx.iota({4, 4}, type: {:f, 32}, backend: Nx.BinaryBackend) |> Nx.divide(16.0),
159+
Nx.iota({4, 4}, type: {:f, 32}, backend: Nx.BinaryBackend) |> Nx.divide(16.0),
160+
Nx.iota({4, 4}, type: {:f, 32}, backend: Nx.BinaryBackend) |> Nx.divide(16.0),
161+
Nx.tensor(0.5, type: {:f, 32}, backend: Nx.BinaryBackend)
162+
]
163+
end
164+
165+
defp grad_function(:grad_sum_op), do: &grad_sum_op/1
166+
defp grad_function(:grad_dot_left), do: &grad_dot_left/2
167+
defp grad_function(:grad_reshape_transpose), do: &grad_reshape_transpose/1
168+
defp grad_function(:grad_broadcast), do: &grad_broadcast/1
169+
defp grad_function(:grad_gather), do: &grad_gather/2
170+
defp grad_function(:grad_indexed_add), do: &grad_indexed_add/3
171+
defp grad_function(:grad_gather_dot_softmax), do: &grad_gather_dot_softmax/3
172+
defp grad_function(:grad_attention), do: &grad_attention/5
173+
174+
@zoo [
175+
:grad_sum_op,
176+
:grad_dot_left,
177+
:grad_reshape_transpose,
178+
:grad_broadcast,
179+
:grad_gather,
180+
:grad_indexed_add,
181+
:grad_gather_dot_softmax,
182+
:grad_attention
183+
]
184+
185+
# -------------------- Generator --------------------
186+
187+
def generate do
188+
Nx.default_backend(EXLA.Backend)
189+
190+
goldens = generate_zoo()
191+
block = generate_block_step()
192+
193+
# Resolve relative to the script's own location.
194+
script_dir = Path.dirname(Path.expand(__ENV__.file))
195+
out = Path.join(script_dir, "../test/support/exla_golden_data.ex") |> Path.expand()
196+
write_module(goldens, block, out)
197+
end
198+
199+
defp generate_zoo do
200+
for name <- @zoo, into: %{} do
201+
inputs = fixed_inputs(name)
202+
fun = grad_function(name)
203+
result = Nx.Defn.jit_apply(fun, inputs, compiler: EXLA)
204+
result_bin = Nx.backend_transfer(result, Nx.BinaryBackend)
205+
206+
{name, %{
207+
expected: Nx.to_flat_list(result_bin),
208+
shape: result_bin.shape,
209+
type: result_bin.type
210+
}}
211+
end
212+
end
213+
214+
defp generate_block_step do
215+
embed = 16
216+
ff = 32
217+
seq = 8
218+
lr_val = 0.1
219+
scale_val = 1.0 / :math.sqrt(embed)
220+
221+
params = init_block({embed, ff}, 0)
222+
{x, y} = block_batch({seq, embed})
223+
lr = Nx.tensor(lr_val, type: {:f, 32}, backend: Nx.BinaryBackend)
224+
scale = Nx.tensor(scale_val, type: {:f, 32}, backend: Nx.BinaryBackend)
225+
226+
{new_params, loss} =
227+
Nx.Defn.jit_apply(
228+
&block_step_with_loss/5,
229+
[params, x, y, lr, scale],
230+
compiler: EXLA
231+
)
232+
233+
loss_f = loss |> Nx.backend_transfer(Nx.BinaryBackend) |> Nx.to_number()
234+
235+
param_goldens =
236+
for key <- [:wq, :wk, :wv, :wo, :w_ff1, :b_ff1, :w_ff2, :b_ff2], into: %{} do
237+
t = Nx.backend_transfer(new_params[key], Nx.BinaryBackend)
238+
{key, %{expected: Nx.to_flat_list(t), shape: t.shape, type: t.type}}
239+
end
240+
241+
%{loss: loss_f, params: param_goldens}
242+
end
243+
244+
defp write_module(goldens, block, path) do
245+
i = fn list -> inspect(list, limit: :infinity) end
246+
247+
lines = [
248+
~s|defmodule Emily.ExlaGoldenData do|,
249+
~s| @moduledoc \"\"\"|,
250+
~s| EXLA-produced golden gradient values for M13 grad conformance.|,
251+
~s||,
252+
~s| Generated by `elixir bench/exla_golden_gen.exs` with EXLA #{Application.spec(:exla, :vsn)}|,
253+
~s| (CPU backend). Regenerate when the grad zoo or its fixed inputs change.|,
254+
~s||,
255+
~s| Generated: #{Date.utc_today()}|,
256+
~s| EXLA version: #{Application.spec(:exla, :vsn)}|,
257+
~s| Backend: EXLA (CPU)|,
258+
~s| \"\"\"|,
259+
~s||
260+
]
261+
262+
zoo_lines =
263+
Enum.flat_map(goldens, fn {name, data} ->
264+
[
265+
~s| def golden(#{inspect(name)}) do|,
266+
~s| %{|,
267+
~s| expected: #{i.(data.expected)},|,
268+
~s| shape: #{inspect(data.shape)},|,
269+
~s| type: #{inspect(data.type)}|,
270+
~s| }|,
271+
~s| end|,
272+
~s||
273+
]
274+
end)
275+
276+
block_param_lines =
277+
Enum.flat_map(block.params, fn {key, data} ->
278+
[
279+
~s| #{key}: %{|,
280+
~s| expected: #{i.(data.expected)},|,
281+
~s| shape: #{inspect(data.shape)},|,
282+
~s| type: #{inspect(data.type)}|,
283+
~s| },|
284+
]
285+
end)
286+
287+
block_lines = [
288+
~s| def block_step_golden do|,
289+
~s| %{|,
290+
~s| loss: #{inspect(block.loss)},|,
291+
~s| params: %{|
292+
] ++ block_param_lines ++ [
293+
~s| }|,
294+
~s| }|,
295+
~s| end|,
296+
~s|end|
297+
]
298+
299+
content = Enum.join(lines ++ zoo_lines ++ block_lines, "\n") <> "\n"
300+
File.write!(path, content)
301+
IO.puts(:stderr, "Wrote #{byte_size(content)} bytes to #{path}")
302+
end
303+
304+
defp init_block({embed, ff}, seed) do
305+
%{
306+
wq: det_weights({embed, embed}, seed * 31 + 1),
307+
wk: det_weights({embed, embed}, seed * 31 + 2),
308+
wv: det_weights({embed, embed}, seed * 31 + 3),
309+
wo: det_weights({embed, embed}, seed * 31 + 4),
310+
w_ff1: det_weights({embed, ff}, seed * 31 + 5),
311+
b_ff1: Nx.broadcast(Nx.tensor(0.0, type: {:f, 32}, backend: Nx.BinaryBackend), {ff}),
312+
w_ff2: det_weights({ff, embed}, seed * 31 + 6),
313+
b_ff2: Nx.broadcast(Nx.tensor(0.0, type: {:f, 32}, backend: Nx.BinaryBackend), {embed})
314+
}
315+
end
316+
317+
defp block_batch({seq, embed}) do
318+
x = det_weights({seq, embed}, 777)
319+
y = det_weights({seq, embed}, 888)
320+
{x, y}
321+
end
322+
end
323+
324+
GoldenGen.generate()

0 commit comments

Comments
 (0)