Skip to content

Commit fa43331

Browse files
authored
Merge pull request #35 from ausimian/m18-telemetry
M18: observability & fallback telemetry
2 parents bf5a110 + 42cad68 commit fa43331

8 files changed

Lines changed: 444 additions & 32 deletions

File tree

PLAN.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,40 @@ ops rotate on/off `via_binary` — make it observable.
918918
**Exit:** events documented in `Emily.Telemetry` moduledoc; fallback
919919
warning behavior covered by tests.
920920

921+
**Shipped.**
922+
923+
- `Emily.Telemetry` (`lib/emily/telemetry.ex`) — moduledoc enumerates
924+
every event; `memory_stats/0` samples the MLX allocator and emits
925+
`[:emily, :memory, :stats]`. `maybe_warn_fallback/2` and
926+
`init_dedup_table/0` are the internal helpers; dedup state lives in
927+
a named `:public` ETS table owned by `Emily.Application`.
928+
- Span events via `:telemetry.span/3`: `[:emily, :eval, *]` on
929+
`Emily.eval/1`, `[:emily, :to_binary, *]` on both `Emily.to_binary/1`
930+
and `Emily.Backend.to_binary/2` (the Nx.to_binary path) with
931+
`:shape`/`:dtype`/`:byte_size` metadata, and
932+
`[:emily, :fallback, *]` on every `via_binary` entry with
933+
`:op`/`:input_shapes`/`:input_dtypes`.
934+
- One-shot `Logger.warning` per `{op, input_shapes}` pair. Opt-in via
935+
`config :emily, :warn_on_fallback, true`; off by default so library
936+
consumers and CI logs stay quiet. The telemetry event fires
937+
regardless — the log is a dev-time convenience on top of it.
938+
- **Scope interpretation.** PLAN's "each Native dispatch" was read as
939+
the evaluation boundary (`Native.eval` / `Native.to_binary`) rather
940+
than wrapping 300+ graph-construction call sites in
941+
`Emily.Backend`. Graph-construction NIFs are <10μs and do no work;
942+
the evaluation boundary is where MLX actually runs kernels, and
943+
it's the point every lazy tensor funnels through. If per-op
944+
graph-construction histograms are ever needed, the right answer is
945+
a centralised dispatch helper, not per-callback decoration.
946+
- **Op-name plumbing.** `via_binary/3``via_binary/4`;
947+
`via_binary_tuple/3``via_binary_tuple/4`; `apply_scatter/7`
948+
`/8`. The op name propagates into both the `:telemetry` metadata
949+
and the dedup key.
950+
- Tests: `test/emily/telemetry_test.exs` covers fallback start/stop
951+
events, 100-call dedup via `capture_log`, `warn_on_fallback=false`
952+
silence, `to_binary` span metadata, and `memory_stats/0` emission.
953+
`async: false` because the dedup table is global.
954+
921955
### M19 — Error surfacing
922956

923957
C++ exceptions propagate through `fine` and surface as

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ weight copy.
9292

9393
See `Emily.Stream` moduledoc for details.
9494

95+
## Observability
96+
97+
Emily emits `:telemetry` events at the evaluation boundary
98+
(`[:emily, :eval, *]`, `[:emily, :to_binary, *]`) and at every
99+
`Nx.BinaryBackend` fallback (`[:emily, :fallback, *]`). Attach a
100+
handler to graph hotspots or detect silent performance cliffs —
101+
see `Emily.Telemetry` for the full event catalogue.
102+
103+
When a backend callback has no native MLX path, Emily transparently
104+
falls back to `Nx.BinaryBackend`. The fallback is ~100× slower; to
105+
get a one-shot `Logger.warning` per `{op, input_shapes}` pair the
106+
first time each one fires (recommended during development):
107+
108+
```elixir
109+
# config/dev.exs
110+
config :emily, :warn_on_fallback, true
111+
```
112+
113+
The warning is off by default so library consumers and CI logs stay
114+
quiet. The telemetry event fires regardless.
115+
95116
## Milestones shipped
96117

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

RELEASE.md

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

99
## Added
1010

11+
- M18 — Observability & fallback telemetry. Makes silent
12+
`via_binary` round-trips and long-running memory drift observable
13+
without changing any op semantics.
14+
- **`Emily.Telemetry`** (`lib/emily/telemetry.ex`) — moduledoc
15+
enumerates every event, `memory_stats/0` samples the MLX
16+
allocator and emits `[:emily, :memory, :stats]`.
17+
- **Span events** via `:telemetry.span/3`:
18+
`[:emily, :eval, *]` wraps `Emily.eval/1`;
19+
`[:emily, :to_binary, *]` wraps both `Emily.to_binary/1` and
20+
`Emily.Backend.to_binary/2` (the Nx.to_binary path) with
21+
`:shape`, `:dtype`, `:byte_size` metadata;
22+
`[:emily, :fallback, *]` wraps every `via_binary` / `via_binary_tuple`
23+
entry with `:op`, `:input_shapes`, `:input_dtypes` metadata.
24+
- **One-shot fallback warning** per `{op, input_shapes}` pair via
25+
a `:public, :named_table` ETS dedup set owned by
26+
`Emily.Application`. **Off by default** — library consumers and
27+
CI logs stay quiet; opt in with
28+
`config :emily, :warn_on_fallback, true` (typically in
29+
`config/dev.exs`) when chasing the Whisper-before-M8 class of
30+
bug where forward-pass time silently lands on BinaryBackend.
31+
The telemetry event fires regardless of the config.
32+
- **Op-name plumbing**: `via_binary/3``via_binary/4` and
33+
`via_binary_tuple/3``via_binary_tuple/4` take a leading
34+
op-name atom; `apply_scatter/7``/8` threads it through for
35+
`indexed_add` / `indexed_put`. All call sites updated.
36+
- **Tests** (`test/emily/telemetry_test.exs`): fallback start/stop
37+
event with op+shape metadata (via `Nx.reduce`), 100-call dedup
38+
capture_log assertion, `warn_on_fallback=false` silence path,
39+
`to_binary` span metadata with byte_size, and `memory_stats/0`
40+
emission. `async: false` because the ETS dedup is global.
41+
- **No `mix.exs` change**: `:telemetry` is already a transitive dep
42+
via Nx.
43+
1144
- M17 — Conv-pool training (native window ops). Lifted `window_sum`,
1245
`window_max`, `window_min`, `window_product`, `window_scatter_max`,
1346
and `window_scatter_min` off the `via_binary` fallback onto native

lib/emily.ex

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ defmodule Emily do
4646
"""
4747
@spec to_binary(t()) :: binary()
4848
def to_binary(tensor) do
49-
Native.to_binary(Emily.MlxStream.default_worker(), tensor)
49+
metadata = %{shape: Native.shape(tensor), dtype: Native.dtype(tensor)}
50+
51+
:telemetry.span([:emily, :to_binary], metadata, fn ->
52+
bytes = Native.to_binary(Emily.MlxStream.default_worker(), tensor)
53+
{bytes, Map.put(metadata, :byte_size, byte_size(bytes))}
54+
end)
5055
end
5156

5257
@doc "Return the tensor's shape as a list of non-negative ints."
@@ -65,6 +70,8 @@ defmodule Emily do
6570
"""
6671
@spec eval(t()) :: :ok
6772
def eval(tensor) do
68-
Native.eval(Emily.MlxStream.default_worker(), tensor)
73+
:telemetry.span([:emily, :eval], %{}, fn ->
74+
{Native.eval(Emily.MlxStream.default_worker(), tensor), %{}}
75+
end)
6976
end
7077
end

lib/emily/application.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ defmodule Emily.Application do
44

55
@impl true
66
def start(_type, _args) do
7+
Emily.Telemetry.init_dedup_table()
8+
79
children = [
810
{Emily.MlxStream, name: Emily.MlxStream.Default}
911
]

lib/emily/backend.ex

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,16 @@ defmodule Emily.Backend do
122122
defp ensure_binary(bs) when is_bitstring(bs), do: :erlang.list_to_bitstring([bs])
123123

124124
@impl true
125-
def to_binary(%T{data: %B{ref: r}, type: {_, bits}} = tensor, limit) do
126-
bin = Native.to_binary(worker(), r)
125+
def to_binary(%T{data: %B{ref: r}, shape: shape, type: type} = tensor, limit) do
126+
metadata = %{shape: shape, dtype: type}
127+
128+
bin =
129+
:telemetry.span([:emily, :to_binary], metadata, fn ->
130+
bytes = Native.to_binary(worker(), r)
131+
{bytes, Map.put(metadata, :byte_size, byte_size(bytes))}
132+
end)
133+
134+
{_, bits} = type
127135
elem_bits = effective_elem_bits(bits)
128136
size = Nx.size(tensor)
129137

@@ -668,7 +676,7 @@ defmodule Emily.Backend do
668676
Native.reshape(w, r, Tuple.to_list(out.shape)) |> wrap(out, w)
669677

670678
true ->
671-
via_binary(out, [input, indices], &Nx.gather(&1, &2, opts))
679+
via_binary(:gather, out, [input, indices], &Nx.gather(&1, &2, opts))
672680
end
673681
end
674682

@@ -753,7 +761,7 @@ defmodule Emily.Backend do
753761
Native.unquote(native_name)(w, ref(t), axis, reverse, true) |> wrap(out, w)
754762
else
755763
nx_fun = unquote(nx_name)
756-
via_binary(out, [t], &apply(Nx, nx_fun, [&1, opts]))
764+
via_binary(nx_fun, out, [t], &apply(Nx, nx_fun, [&1, opts]))
757765
end
758766
end
759767
end
@@ -779,7 +787,7 @@ defmodule Emily.Backend do
779787
if float_like?(type) do
780788
batched_matmul(out, a, contract_a, batch_a, b, contract_b, batch_b)
781789
else
782-
via_binary(out, [a, b], &Nx.dot(&1, contract_a, batch_a, &2, contract_b, batch_b))
790+
via_binary(:dot, out, [a, b], &Nx.dot(&1, contract_a, batch_a, &2, contract_b, batch_b))
783791
end
784792
end
785793

@@ -990,10 +998,10 @@ defmodule Emily.Backend do
990998
def conv(out, input, kernel, opts) do
991999
cond do
9921000
opts[:batch_group_size] > 1 ->
993-
via_binary(out, [input, kernel], &Nx.conv(&1, &2, opts))
1001+
via_binary(:conv, out, [input, kernel], &Nx.conv(&1, &2, opts))
9941002

9951003
match?({:c, _}, out.type) ->
996-
via_binary(out, [input, kernel], &Nx.conv(&1, &2, opts))
1004+
via_binary(:conv, out, [input, kernel], &Nx.conv(&1, &2, opts))
9971005

9981006
true ->
9991007
w = worker()
@@ -1062,40 +1070,68 @@ defmodule Emily.Backend do
10621070
# those scalars land on the current global default — which is
10631071
# `Emily.Backend` during conformance tests — and the resulting
10641072
# mixed-backend operand list crashes inside BinaryBackend's op.
1065-
defp via_binary(%T{} = out, tensors, fun) when is_list(tensors) do
1066-
result =
1067-
Nx.with_default_backend(Nx.BinaryBackend, fn ->
1068-
tensors |> transfer_all() |> then(&apply(fun, &1))
1069-
end)
1073+
defp via_binary(op, %T{} = out, tensors, fun) when is_atom(op) and is_list(tensors) do
1074+
metadata = fallback_metadata(op, tensors)
1075+
Emily.Telemetry.maybe_warn_fallback(op, metadata.input_shapes)
10701076

1071-
from_binary(out, Nx.to_binary(result), [])
1077+
:telemetry.span([:emily, :fallback], metadata, fn ->
1078+
result =
1079+
Nx.with_default_backend(Nx.BinaryBackend, fn ->
1080+
tensors |> transfer_all() |> then(&apply(fun, &1))
1081+
end)
1082+
1083+
{from_binary(out, Nx.to_binary(result), []), metadata}
1084+
end)
10721085
end
10731086

10741087
# Same pattern, but the op returns a tuple of tensors. `outs` is a
10751088
# tuple of output templates matching arity; positions are zipped.
1076-
defp via_binary_tuple(outs, tensors, fun) when is_tuple(outs) and is_list(tensors) do
1077-
result_tuple =
1078-
Nx.with_default_backend(Nx.BinaryBackend, fn ->
1079-
tensors |> transfer_all() |> then(&apply(fun, &1))
1080-
end)
1089+
defp via_binary_tuple(op, outs, tensors, fun)
1090+
when is_atom(op) and is_tuple(outs) and is_list(tensors) do
1091+
metadata = fallback_metadata(op, tensors)
1092+
Emily.Telemetry.maybe_warn_fallback(op, metadata.input_shapes)
1093+
1094+
:telemetry.span([:emily, :fallback], metadata, fn ->
1095+
result_tuple =
1096+
Nx.with_default_backend(Nx.BinaryBackend, fn ->
1097+
tensors |> transfer_all() |> then(&apply(fun, &1))
1098+
end)
1099+
1100+
result =
1101+
outs
1102+
|> Tuple.to_list()
1103+
|> Enum.zip(Tuple.to_list(result_tuple))
1104+
|> Enum.map(fn {out, r} -> from_binary(out, Nx.to_binary(r), []) end)
1105+
|> List.to_tuple()
1106+
1107+
{result, metadata}
1108+
end)
1109+
end
10811110

1082-
outs
1083-
|> Tuple.to_list()
1084-
|> Enum.zip(Tuple.to_list(result_tuple))
1085-
|> Enum.map(fn {out, r} -> from_binary(out, Nx.to_binary(r), []) end)
1086-
|> List.to_tuple()
1111+
defp fallback_metadata(op, tensors) do
1112+
%{
1113+
op: op,
1114+
input_shapes: Enum.map(tensors, & &1.shape),
1115+
input_dtypes: Enum.map(tensors, & &1.type)
1116+
}
10871117
end
10881118

10891119
defp transfer_all(tensors),
10901120
do: Enum.map(tensors, &Nx.backend_transfer(&1, Nx.BinaryBackend))
10911121

10921122
@impl true
10931123
def reduce(out, t, acc, opts, fun),
1094-
do: via_binary(out, [t, acc], &Nx.reduce(&1, &2, opts, fun))
1124+
do: via_binary(:reduce, out, [t, acc], &Nx.reduce(&1, &2, opts, fun))
10951125

10961126
@impl true
10971127
def window_reduce(out, t, acc, window_shape, opts, fun),
1098-
do: via_binary(out, [t, acc], &Nx.window_reduce(&1, &2, window_shape, opts, fun))
1128+
do:
1129+
via_binary(
1130+
:window_reduce,
1131+
out,
1132+
[t, acc],
1133+
&Nx.window_reduce(&1, &2, window_shape, opts, fun)
1134+
)
10991135

11001136
# M17: window reductions lifted off via_binary. MLX has no native
11011137
# window_* primitive — each op is composed as pad → as_strided
@@ -1234,15 +1270,33 @@ defmodule Emily.Backend do
12341270
# indices; correctness on duplicates with indexed_put is best-effort.
12351271
@impl true
12361272
def indexed_add(out, t, indices, updates, opts) do
1237-
apply_scatter(out, t, indices, updates, opts, :scatter_add, &Nx.indexed_add(&1, &2, &3, opts))
1273+
apply_scatter(
1274+
:indexed_add,
1275+
out,
1276+
t,
1277+
indices,
1278+
updates,
1279+
opts,
1280+
:scatter_add,
1281+
&Nx.indexed_add(&1, &2, &3, opts)
1282+
)
12381283
end
12391284

12401285
@impl true
12411286
def indexed_put(out, t, indices, updates, opts) do
1242-
apply_scatter(out, t, indices, updates, opts, :scatter, &Nx.indexed_put(&1, &2, &3, opts))
1287+
apply_scatter(
1288+
:indexed_put,
1289+
out,
1290+
t,
1291+
indices,
1292+
updates,
1293+
opts,
1294+
:scatter,
1295+
&Nx.indexed_put(&1, &2, &3, opts)
1296+
)
12431297
end
12441298

1245-
defp apply_scatter(out, t, indices, updates, opts, native_fun, fallback) do
1299+
defp apply_scatter(op, out, t, indices, updates, opts, native_fun, fallback) do
12461300
axes = opts[:axes] || Enum.to_list(0..(tuple_size(t.shape) - 1))
12471301
indices_shape = Tuple.to_list(indices.shape)
12481302

@@ -1255,7 +1309,7 @@ defmodule Emily.Backend do
12551309
apply(Native, native_fun, [w, ref(t), idx_refs, updates_ref, axes])
12561310
|> wrap(out, w)
12571311
else
1258-
via_binary(out, [t, indices, updates], fallback)
1312+
via_binary(op, out, [t, indices, updates], fallback)
12591313
end
12601314
end
12611315

@@ -1388,7 +1442,7 @@ defmodule Emily.Backend do
13881442
{wrap(q_ref, q_out, w), wrap(r_ref, r_out, w)}
13891443

13901444
:complete ->
1391-
via_binary_tuple({q_out, r_out}, [t], &Nx.LinAlg.qr(&1, opts))
1445+
via_binary_tuple(:qr, {q_out, r_out}, [t], &Nx.LinAlg.qr(&1, opts))
13921446
end
13931447
end
13941448

0 commit comments

Comments
 (0)