Skip to content

Commit 1afb2d9

Browse files
committed
M22: compile-time debug flags
Two opt-in Application.compile_env flags re-enable runtime assertions on hot paths that GPU backends skip by default. Both default false with zero runtime cost when off — the gated branches are dead-code eliminated by the Elixir compiler and no Emily.Backend.DebugHelpers reference reaches the compiled BEAM. - :debug_bounds_check — raises on OOB / negative indices in gather, take, take_along_axis, indexed_add, indexed_put. Closes the silent- NaN-from-OOB-gather class of bug that surfaced in DistilBERT-QA conformance (vocab-30522 tokenizer vs. tiny-random embedding). - :debug_detect_nan_inf — scans matmul, fast_rms_norm, fast_layer_norm, and both fast_scaled_dot_product_attention variants for NaN/Inf so training-time numerics failures surface at the producing op. The Emily.Backend.DebugHelpers module holds the assertion bodies and is always compiled; Emily.Backend only references it from inside `if @flag` gates, so when the flags are off the entire module is unreachable from Emily.Backend's bytecode. A zero-cost verification test inspects :beam_disasm.file/1 output and asserts no reference survives under the default-off build. Emily's own config/test.exs keeps both prod flags false so mix test doesn't pay GPU-sync cost on every run; fixture-only flags drive the gate→helper composition tests in test/support/debug_fixture.ex. Standalone softmax has no Backend callback in Emily (it's Axon-composed from exp/sum/divide); the fused SDPA softmax is covered via the two SDPA callback paths. Closes #32.
1 parent 9c561b6 commit 1afb2d9

9 files changed

Lines changed: 423 additions & 22 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,33 @@ config :emily, :warn_on_fallback, true
113113
The warning is off by default so library consumers and CI logs stay
114114
quiet. The telemetry event fires regardless.
115115

116+
## Debug assertions
117+
118+
Two compile-time flags re-enable runtime checks that MLX (and every
119+
other GPU backend) skips by default. Both are off by default with
120+
zero runtime cost when off — the guarded branches are dead-code
121+
eliminated by the Elixir compiler.
122+
123+
```elixir
124+
# config/dev.exs
125+
config :emily,
126+
debug_bounds_check: true,
127+
debug_detect_nan_inf: true
128+
```
129+
130+
- `:debug_bounds_check` — raises on out-of-range / negative indices
131+
in `gather` / `take` / `take_along_axis` / `indexed_add` /
132+
`indexed_put`. Catches the silent-`NaN`-from-OOB-gather class of
133+
bug (e.g. a vocab-30522 tokenizer paired with a tiny-random model
134+
whose embedding table is smaller).
135+
- `:debug_detect_nan_inf` — scans results of `matmul`, the fused
136+
`layer_norm` / `rms_norm`, and both fused SDPA variants. Surfaces
137+
numerics failures at the producing op rather than downstream.
138+
139+
Each check is a per-op MLX reduction plus a scalar readback — a
140+
worker sync that breaks lazy-graph fusion. Leave off in release
141+
builds. See the `Emily` moduledoc for the full opt-in snippet.
142+
116143
## Milestones shipped
117144

118145
- **M0** — NIF scaffold, MLX prebuilt fetch, tensor round-trip.

RELEASE.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,57 @@
88

99
## Added
1010

11+
- M22 — Compile-time debug flags. Two opt-in `Application.compile_env`
12+
flags re-enable runtime assertions on hot paths that GPU backends
13+
skip by default. Both default to `false` with zero runtime cost
14+
when off — the gated branches are dead-code eliminated and no
15+
`Emily.Backend.DebugHelpers` reference reaches the compiled BEAM.
16+
- **`:debug_bounds_check`** — raises on out-of-range / negative
17+
indices in `gather`, `take`, `take_along_axis`, `indexed_add`,
18+
`indexed_put`. Catches the class of bug where a vocab-N
19+
tokenizer paired with a smaller embedding table silently emits
20+
garbage (sometimes NaN) through `mx::take` / `mx::gather`. One
21+
gate each in `gather/4` (both branches), `take/3`,
22+
`take_along_axis/3`, and `apply_scatter/8` — the shared
23+
indexed_add/indexed_put helper.
24+
- **`:debug_detect_nan_inf`** — scans results of `matmul`,
25+
`fast_rms_norm`, `fast_layer_norm`, and both
26+
`fast_scaled_dot_product_attention` variants for NaN / Inf.
27+
Surfaces numerics failures at the producing op rather than
28+
downstream as `loss = NaN`. Standalone softmax has no Backend
29+
callback in Emily (Axon-composed); only fused SDPA softmax is
30+
scanned. Documented alongside the flag.
31+
- **Mechanism.** Module attributes bound via
32+
`Application.compile_env/3` default to `false`; the `if @flag`
33+
gates fold at compile time, and
34+
`Emily.Backend.DebugHelpers.check_bounds!` /
35+
`check_nan_inf!` are never referenced from
36+
`Emily.Backend.beam`. A zero-cost verification test inspects
37+
the disassembled BEAM (`:beam_disasm.file/1`) and asserts no
38+
reference survives under the default-off build — guards
39+
against a future refactor accidentally unconditionalising the
40+
gate.
41+
- **Each assertion is a GPU sync** (one reduction + scalar
42+
readback per gated op). Breaks lazy-graph fusion; only enable
43+
in dev / CI, never in release.
44+
- **New files**: `lib/emily/backend/debug_helpers.ex` (assertion
45+
bodies), `config/config.exs` + `config/test.exs` (first config
46+
files in the project — previously no `config/` directory),
47+
`test/support/debug_fixture.ex` (exercises the gate→helper
48+
composition with its own fixture-only `compile_env` flags),
49+
`test/emily/debug_flags_test.exs` (15 tests: direct helper,
50+
production negative control, fixture composition, zero-cost
51+
verification).
52+
- **Emily's own `config/test.exs` keeps prod flags default-false**
53+
so `mix test` doesn't pay the GPU-sync cost on every run and we
54+
don't mask perf regressions. Fixture-only flags
55+
(`test_fixture_debug_bounds_check`,
56+
`test_fixture_debug_detect_nan_inf`) are set to `true` to drive
57+
the composition tests.
58+
- **Docs**: `Emily` moduledoc gains a "Debug assertions" section
59+
with the worked opt-in snippet; README gets a matching brief
60+
after `## Observability`.
61+
1162
- M18 — Observability & fallback telemetry. Makes silent
1263
`via_binary` round-trips and long-running memory drift observable
1364
without changing any op semantics.

config/config.exs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Config
2+
3+
if File.exists?(Path.join(__DIR__, "#{config_env()}.exs")) do
4+
import_config "#{config_env()}.exs"
5+
end

config/test.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Config
2+
3+
# Production M22 flags stay default-false even in Emily's own tests so
4+
# `mix test` doesn't pay GPU-sync cost on every run and we don't mask
5+
# perf regressions. Fixture-only flags (set to `true`) drive the
6+
# gate→helper composition tests in test/support/debug_fixture.ex.
7+
config :emily,
8+
debug_bounds_check: false,
9+
debug_detect_nan_inf: false,
10+
test_fixture_debug_bounds_check: true,
11+
test_fixture_debug_detect_nan_inf: true

lib/emily.ex

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,43 @@ defmodule Emily do
99
The M0 surface is intentionally tiny: build a tensor from a binary,
1010
round-trip it back, and inspect shape/dtype. This is the narrowest
1111
slice that proves the NIF + MLX linking is healthy.
12+
13+
## Debug assertions
14+
15+
Emily supports two compile-time flags that enable runtime assertions
16+
on hot paths. Both default to `false` with zero runtime cost — the
17+
guarded branches are dead-code-eliminated by the Elixir compiler
18+
when the flag is `false`.
19+
20+
# config/dev.exs (opt in only in development / CI)
21+
import Config
22+
config :emily,
23+
debug_bounds_check: true,
24+
debug_detect_nan_inf: true
25+
26+
* `:debug_bounds_check` — assert indices are in range for
27+
`gather`, `take`, `take_along_axis`, `indexed_add`, and
28+
`indexed_put`. Raises `ArgumentError` on out-of-range or
29+
negative indices. GPU backends (Emily, EXLA, Torch-CUDA,
30+
JAX-GPU) don't bounds-check by default — an OOB index gets
31+
whatever bytes happen to live at that memory address, which
32+
can silently produce `NaN` scores that propagate through
33+
softmax. Turning this on in CI catches the bug class at the
34+
offending op.
35+
36+
* `:debug_detect_nan_inf` — scan results of `matmul`,
37+
`fast_rms_norm`, `fast_layer_norm`, and the two
38+
`fast_scaled_dot_product_attention` variants for NaN/Inf.
39+
Raises `ArgumentError` on detection. Useful during training
40+
so numerics failures surface at the op that produced them
41+
rather than downstream as `loss = NaN`. Standalone softmax
42+
has no backend callback in Emily (it's Axon-composed from
43+
`exp` / `sum` / `divide`); only the fused SDPA softmax is
44+
scanned.
45+
46+
Each assertion forces a small MLX reduction plus a scalar readback
47+
on the worker — a sync point that breaks lazy-graph fusion and
48+
adds noticeable overhead. Leave the flags off in release builds.
1249
"""
1350

1451
alias Emily.Native

lib/emily/backend.ex

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,17 @@ defmodule Emily.Backend do
3131
defstruct [:ref]
3232

3333
alias Emily.Backend, as: B
34+
alias Emily.Backend.DebugHelpers
3435
alias Emily.Native
3536
alias Nx.Tensor, as: T
3637

38+
# Compile-time debug flags (M22). Default `false` so the `if @flag`
39+
# gates below fold at compile time — the `DebugHelpers.*` references
40+
# never appear in this module's BEAM when the flags are off. See
41+
# `Emily`'s moduledoc and `test/emily/debug_flags_test.exs`.
42+
@debug_bounds_check Application.compile_env(:emily, :debug_bounds_check, false)
43+
@debug_detect_nan_inf Application.compile_env(:emily, :debug_detect_nan_inf, false)
44+
3745
@typep tensor :: T.t()
3846
@typep ref :: reference()
3947

@@ -665,11 +673,19 @@ defmodule Emily.Backend do
665673
match?([_], axes) ->
666674
[axis] = axes
667675
idx_ref = Native.astype(w, ref(indices), {:s, 32})
676+
677+
if @debug_bounds_check,
678+
do: DebugHelpers.check_bounds!(:gather, input.shape, [idx_ref], [axis], w)
679+
668680
r = Native.take(w, ref(input), idx_ref, axis)
669681
Native.reshape(w, r, Tuple.to_list(out.shape)) |> wrap(out, w)
670682

671683
scatter_gather_compatible?(indices_shape, axes) ->
672684
idx_refs = split_indices_per_axis(ref(indices), indices_shape, length(axes), w)
685+
686+
if @debug_bounds_check,
687+
do: DebugHelpers.check_bounds!(:gather, input.shape, idx_refs, axes, w)
688+
673689
slice_sizes = slice_sizes_for_gather(input.shape, axes)
674690

675691
r = Native.gather(w, ref(input), idx_refs, axes, slice_sizes)
@@ -833,6 +849,7 @@ defmodule Emily.Backend do
833849
rb = Native.reshape(w, rb, [b_prod, k_prod, n])
834850

835851
r = Native.matmul(w, ra, rb)
852+
if @debug_detect_nan_inf, do: DebugHelpers.check_nan_inf!(:matmul, r, w)
836853
Native.reshape(w, r, shape_list(out_shape)) |> wrap(out, w)
837854
end
838855

@@ -932,6 +949,10 @@ defmodule Emily.Backend do
932949
w = worker()
933950
axis = opts[:axis] || 0
934951
idx_ref = Native.astype(w, ref(indices), {:s, 32})
952+
953+
if @debug_bounds_check,
954+
do: DebugHelpers.check_bounds!(:take, input.shape, [idx_ref], [axis], w)
955+
935956
Native.take(w, ref(input), idx_ref, axis) |> wrap(out, w)
936957
end
937958

@@ -940,6 +961,10 @@ defmodule Emily.Backend do
940961
w = worker()
941962
axis = opts[:axis] || 0
942963
idx_ref = Native.astype(w, ref(indices), {:s, 32})
964+
965+
if @debug_bounds_check,
966+
do: DebugHelpers.check_bounds!(:take_along_axis, input.shape, [idx_ref], [axis], w)
967+
943968
Native.take_along_axis(w, ref(input), idx_ref, axis) |> wrap(out, w)
944969
end
945970

@@ -1303,6 +1328,7 @@ defmodule Emily.Backend do
13031328
if scatter_gather_compatible?(indices_shape, axes) do
13041329
w = worker()
13051330
idx_refs = split_indices_per_axis(ref(indices), indices_shape, length(axes), w)
1331+
if @debug_bounds_check, do: DebugHelpers.check_bounds!(op, t.shape, idx_refs, axes, w)
13061332
updates_shape = updates_shape_for_scatter(indices_shape, t.shape, axes)
13071333
updates_ref = Native.reshape(w, ref(updates), updates_shape)
13081334

@@ -1482,13 +1508,17 @@ defmodule Emily.Backend do
14821508
@doc false
14831509
def fast_rms_norm(%T{} = out, x, weight, opts) do
14841510
w = worker()
1485-
Native.fast_rms_norm(w, ref(x), ref(weight), opts[:eps] * 1.0) |> wrap(out, w)
1511+
r = Native.fast_rms_norm(w, ref(x), ref(weight), opts[:eps] * 1.0)
1512+
if @debug_detect_nan_inf, do: DebugHelpers.check_nan_inf!(:fast_rms_norm, r, w)
1513+
wrap(r, out, w)
14861514
end
14871515

14881516
@doc false
14891517
def fast_layer_norm(%T{} = out, x, weight, bias, opts) do
14901518
w = worker()
1491-
Native.fast_layer_norm(w, ref(x), ref(weight), ref(bias), opts[:eps] * 1.0) |> wrap(out, w)
1519+
r = Native.fast_layer_norm(w, ref(x), ref(weight), ref(bias), opts[:eps] * 1.0)
1520+
if @debug_detect_nan_inf, do: DebugHelpers.check_nan_inf!(:fast_layer_norm, r, w)
1521+
wrap(r, out, w)
14921522
end
14931523

14941524
@doc false
@@ -1534,31 +1564,41 @@ defmodule Emily.Backend do
15341564
w = worker()
15351565
mask_mode = if opts[:causal], do: "causal", else: ""
15361566

1537-
Native.fast_scaled_dot_product_attention(
1538-
w,
1539-
ref(q),
1540-
ref(k),
1541-
ref(v),
1542-
opts[:scale] * 1.0,
1543-
mask_mode,
1544-
[]
1545-
)
1546-
|> wrap(out, w)
1567+
r =
1568+
Native.fast_scaled_dot_product_attention(
1569+
w,
1570+
ref(q),
1571+
ref(k),
1572+
ref(v),
1573+
opts[:scale] * 1.0,
1574+
mask_mode,
1575+
[]
1576+
)
1577+
1578+
if @debug_detect_nan_inf,
1579+
do: DebugHelpers.check_nan_inf!(:fast_scaled_dot_product_attention, r, w)
1580+
1581+
wrap(r, out, w)
15471582
end
15481583

15491584
@doc false
15501585
def fast_scaled_dot_product_attention_with_mask(%T{} = out, q, k, v, mask, opts) do
15511586
w = worker()
15521587

1553-
Native.fast_scaled_dot_product_attention(
1554-
w,
1555-
ref(q),
1556-
ref(k),
1557-
ref(v),
1558-
opts[:scale] * 1.0,
1559-
"array",
1560-
[ref(mask)]
1561-
)
1562-
|> wrap(out, w)
1588+
r =
1589+
Native.fast_scaled_dot_product_attention(
1590+
w,
1591+
ref(q),
1592+
ref(k),
1593+
ref(v),
1594+
opts[:scale] * 1.0,
1595+
"array",
1596+
[ref(mask)]
1597+
)
1598+
1599+
if @debug_detect_nan_inf,
1600+
do: DebugHelpers.check_nan_inf!(:fast_scaled_dot_product_attention_with_mask, r, w)
1601+
1602+
wrap(r, out, w)
15631603
end
15641604
end

lib/emily/backend/debug_helpers.ex

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
defmodule Emily.Backend.DebugHelpers do
2+
@moduledoc false
3+
# Assertion bodies for the M22 compile-time debug flags. Always
4+
# compiled; `Emily.Backend` only calls into here from `if @flag` gates
5+
# that fold to nothing when the attribute is the literal `false`, so
6+
# the reference to this module never lands in `Emily.Backend.beam`
7+
# under the default-off build.
8+
9+
alias Emily.Native
10+
11+
@doc false
12+
@spec check_bounds!(atom(), tuple(), [reference()], [integer()], reference()) :: :ok
13+
def check_bounds!(op, input_shape, idx_refs, axes, w) do
14+
axes
15+
|> Enum.zip(idx_refs)
16+
|> Enum.each(fn {axis, idx_ref} ->
17+
dim = elem(input_shape, axis)
18+
red_axes = Enum.to_list(0..(length(Native.shape(idx_ref)) - 1))
19+
20+
max_ref = Native.astype(w, Native.max(w, idx_ref, red_axes, false), {:s, 32})
21+
min_ref = Native.astype(w, Native.min(w, idx_ref, red_axes, false), {:s, 32})
22+
<<max_i::signed-integer-32-native>> = Native.to_binary(w, max_ref)
23+
<<min_i::signed-integer-32-native>> = Native.to_binary(w, min_ref)
24+
25+
cond do
26+
min_i < 0 ->
27+
raise ArgumentError,
28+
"#{op}: index #{min_i} on axis #{axis} is negative (dim=#{dim})"
29+
30+
max_i >= dim ->
31+
raise ArgumentError,
32+
"#{op}: index #{max_i} on axis #{axis} out of range (dim=#{dim})"
33+
34+
true ->
35+
:ok
36+
end
37+
end)
38+
end
39+
40+
@doc false
41+
@spec check_nan_inf!(atom(), reference(), reference()) :: :ok
42+
def check_nan_inf!(op, result_ref, w) do
43+
bad = Native.logical_or(w, Native.isnan(w, result_ref), Native.isinf(w, result_ref))
44+
axes = Enum.to_list(0..(length(Native.shape(bad)) - 1))
45+
scalar = Native.any(w, bad, axes, false)
46+
<<flag::unsigned-integer-8>> = Native.to_binary(w, scalar)
47+
if flag == 1, do: raise(ArgumentError, "#{op}: produced NaN or Inf")
48+
:ok
49+
end
50+
end

0 commit comments

Comments
 (0)