Skip to content

Commit c0b9ff0

Browse files
authored
Merge pull request #153 from ausimian/feat/expr-compiler-cm6
CM6: opt-in mx::compile eval mode for the program replay
2 parents 1a81420 + 446c0e8 commit c0b9ff0

8 files changed

Lines changed: 521 additions & 55 deletions

File tree

bench/program_compile.exs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# CM6 mx::compile secondary-win microbench.
2+
#
3+
# The single-NIF replay (CM0) already collapses the ~750 per-op
4+
# BEAM<->worker round-trips into one. CM6 adds an *opt-in* `:compiled`
5+
# eval mode that wraps that replay in `mx::compile` (cached per stream,
6+
# keyed off the Program). `mx::compile` can fuse adjacent elementwise
7+
# kernels, so the question this bench answers is narrow: on a realistic
8+
# transformer block, does the compiled wrap buy a meaningful secondary
9+
# encode win over the already-collapsed sync replay?
10+
#
11+
# The prior (Emily's own M6 milestone) predicted ~1.04-1.11x: MLX does
12+
# not fuse matmul with adjacent elementwise. In practice this bench
13+
# measures ~1.6x at decode-shaped sizes -- because at small sequence
14+
# lengths kernel-launch and intermediate-memory overhead dominate, and
15+
# fusing the elementwise runs (rms-norm, softmax, SiLU gating, residuals)
16+
# removes them. The speedup is reported, not gated (only the correctness
17+
# guard halts) -- CM6 is additive, not load-bearing: CM0's single-NIF
18+
# replay already delivered the main dispatch collapse, and CM3 met the
19+
# tok/s target without this.
20+
#
21+
# mix run bench/program_compile.exs
22+
# mix run bench/program_compile.exs -- --seq 128 --iters 300
23+
#
24+
# Both paths run the SAME compiled Program; only the eval mode differs,
25+
# and a correctness guard asserts they agree within a tight f32 tolerance
26+
# before timing (mx::compile fuses elementwise runs, which reassociates
27+
# float arithmetic to within a few ULP -- correct, not bit-exact).
28+
29+
alias Emily.{IR, Native, Program}
30+
alias Nx.Defn.Composite
31+
32+
{opts, _, _} =
33+
OptionParser.parse(System.argv(),
34+
strict: [seq: :integer, dmodel: :integer, ffn: :integer, iters: :integer]
35+
)
36+
37+
seq = opts[:seq] || 64
38+
d = opts[:dmodel] || 512
39+
ffn = opts[:ffn] || 2048
40+
iters = opts[:iters] || 300
41+
42+
worker = Emily.MlxStream.default_worker()
43+
44+
# --- A realistic transformer block, in Nx primitives only ---
45+
# RMSNorm -> single-head attention (QKV matmuls, softmax, out proj) ->
46+
# residual -> RMSNorm -> SwiGLU FFN -> residual. The elementwise runs
47+
# (rms-norm, softmax, SiLU gating, residual adds) are mx::compile's
48+
# fusion surface; the matmuls are the floor it can't fuse through.
49+
eps = 1.0e-6
50+
scale = 1.0 / :math.sqrt(d)
51+
52+
rms = fn t, g ->
53+
ms = Nx.mean(Nx.multiply(t, t), axes: [-1], keep_axes: true)
54+
Nx.multiply(Nx.divide(t, Nx.sqrt(Nx.add(ms, eps))), g)
55+
end
56+
57+
silu = fn z -> Nx.multiply(z, Nx.divide(1.0, Nx.add(1.0, Nx.exp(Nx.negate(z))))) end
58+
59+
softmax = fn s ->
60+
m = Nx.reduce_max(s, axes: [-1], keep_axes: true)
61+
e = Nx.exp(Nx.subtract(s, m))
62+
Nx.divide(e, Nx.sum(e, axes: [-1], keep_axes: true))
63+
end
64+
65+
# Param order: x, g1, w_q, w_k, w_v, w_o, g2, w_gate, w_up, w_down.
66+
block = fn [x, g1, w_q, w_k, w_v, w_o, g2, w_gate, w_up, w_down] ->
67+
h = rms.(x, g1)
68+
q = Nx.dot(h, w_q)
69+
k = Nx.dot(h, w_k)
70+
v = Nx.dot(h, w_v)
71+
scores = Nx.multiply(Nx.dot(q, Nx.transpose(k)), scale)
72+
ctx = Nx.dot(softmax.(scores), v)
73+
x2 = Nx.add(x, Nx.dot(ctx, w_o))
74+
75+
f = rms.(x2, g2)
76+
gated = Nx.multiply(silu.(Nx.dot(f, w_gate)), Nx.dot(f, w_up))
77+
Nx.add(x2, Nx.dot(gated, w_down))
78+
end
79+
80+
# Concrete weights on Emily.Backend; the param exprs become {:input, i}.
81+
dev = fn shape ->
82+
dims = Tuple.to_list(shape)
83+
n = Enum.product(dims)
84+
bin = for i <- 1..n, into: <<>>, do: <<:math.sin(i * 0.013) * 0.1::float-32-native>>
85+
Native.from_binary(bin, dims, {:f, 32})
86+
end
87+
88+
shapes = [
89+
{seq, d},
90+
{d},
91+
{d, d},
92+
{d, d},
93+
{d, d},
94+
{d, d},
95+
{d},
96+
{d, ffn},
97+
{d, ffn},
98+
{ffn, d}
99+
]
100+
101+
input_refs = Enum.map(shapes, dev)
102+
103+
# Trace the block into an Expr (params at :root) and lower it once.
104+
vars =
105+
shapes
106+
|> Enum.with_index()
107+
|> Enum.map(fn {shape, i} ->
108+
Nx.Defn.Expr.parameter(Nx.template(shape, {:f, 32}), :root, i)
109+
end)
110+
111+
expr = block.(vars)
112+
{_template, leaves_rev} = Composite.traverse(expr, [], fn leaf, acc -> {leaf, [leaf | acc]} end)
113+
prog = leaves_rev |> Enum.reverse() |> IR.lower() |> Program.compile()
114+
115+
# Correctness guard: compiled wrap must match the plain replay. On a
116+
# shallow / integer-stable program the two are bit-identical, but
117+
# mx::compile fuses elementwise runs (rms-norm, the `* scale`, softmax,
118+
# SiLU gating), which reassociates f32 arithmetic. Through a deep block
119+
# that shows up as a last-few-ULP drift -- correct, just not bit-exact.
120+
# So assert a tight absolute tolerance and report the actual drift.
121+
tol = 1.0e-5
122+
[sync_out] = Program.eval(worker, prog, input_refs, mode: :sync)
123+
[compiled_out] = Program.eval(worker, prog, input_refs, mode: :compiled)
124+
125+
floats = fn ref ->
126+
for <<v::float-32-native <- Native.to_binary(worker, ref)>>, do: v
127+
end
128+
129+
sync_floats = floats.(sync_out)
130+
compiled_floats = floats.(compiled_out)
131+
132+
# Guard length first: Enum.zip/2 truncates to the shorter list, so a real
133+
# shape/length divergence between the two modes would otherwise be hidden.
134+
if length(sync_floats) != length(compiled_floats) do
135+
IO.puts(:stderr, "FAIL: compiled output length differs from sync output length")
136+
System.halt(1)
137+
end
138+
139+
max_drift =
140+
Enum.zip(sync_floats, compiled_floats)
141+
|> Enum.reduce(0.0, fn {a, b}, acc -> max(acc, abs(a - b)) end)
142+
143+
if max_drift > tol do
144+
IO.puts(:stderr, "FAIL: compiled vs sync max drift #{max_drift} exceeds tol #{tol}")
145+
System.halt(1)
146+
end
147+
148+
build = fn -> Program.eval(worker, prog, input_refs, mode: :build) end
149+
150+
sync = fn ->
151+
[out] = Program.eval(worker, prog, input_refs, mode: :sync)
152+
Native.eval(worker, out)
153+
end
154+
155+
compiled = fn ->
156+
[out] = Program.eval(worker, prog, input_refs, mode: :compiled)
157+
Native.eval(worker, out)
158+
end
159+
160+
warmup = fn f -> for _ <- 1..30, do: f.() end
161+
time = fn f ->
162+
{us, _} = :timer.tc(fn -> for _ <- 1..iters, do: f.() end)
163+
us / iters
164+
end
165+
166+
for f <- [build, sync, compiled], do: warmup.(f)
167+
168+
build_us = time.(build)
169+
sync_us = time.(sync)
170+
compiled_us = time.(compiled)
171+
172+
IO.puts("""
173+
174+
CM6 mx::compile secondary-win microbench
175+
transformer block : seq=#{seq} d_model=#{d} ffn=#{ffn}
176+
iterations : #{iters}
177+
178+
build only (dispatch, no GPU) : #{Float.round(build_us, 1)} us/iter
179+
sync replay (eval) : #{Float.round(sync_us, 1)} us/iter
180+
compiled replay (mx::compile) : #{Float.round(compiled_us, 1)} us/iter
181+
182+
compiled / sync speedup : #{Float.round(sync_us / compiled_us, 3)}x
183+
compiled vs sync max drift : #{max_drift} (f32 fusion reassociation, tol #{tol})
184+
""")
185+
186+
cond do
187+
sync_us / compiled_us >= 1.09 ->
188+
IO.puts("RESULT: meets the ~1.09x M6 prediction -- compiled mode earns its keep.")
189+
190+
sync_us / compiled_us >= 1.05 ->
191+
IO.puts("RESULT: marginal (1.05-1.09x). Opt-in is justified; stays off by default.")
192+
193+
true ->
194+
IO.puts(
195+
"RESULT: below 1.05x. As the plan anticipated, mx::compile is not load-bearing; " <>
196+
":compiled stays strictly opt-in and the single-NIF replay (CM0) is the real win."
197+
)
198+
end

c_src/emily/program.hpp

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,23 @@
1414

1515
#include "opcodes.hpp"
1616
#include "tensor.hpp"
17+
#include "worker.hpp"
1718

1819
#include <fine.hpp>
20+
#include <mlx/mlx.h>
1921

2022
#include <cstdint>
23+
#include <functional>
24+
#include <map>
25+
#include <memory>
26+
#include <mutex>
27+
#include <utility>
2128
#include <vector>
2229

2330
namespace emily {
2431

32+
namespace mx = mlx::core;
33+
2534
// Operand references are packed into an int64 by the Elixir lowerer
2635
// (`Emily.IR.pack_ref/1`): the high bits carry the slot kind, the low
2736
// bits the index. Unpacked here during compile-time validation and
@@ -57,6 +66,55 @@ class Program {
5766
std::vector<fine::ResourcePtr<Tensor>> consts;
5867
std::vector<CompiledInstr> instrs;
5968
std::vector<int64_t> outputs; // packed refs
69+
70+
using CompiledFn =
71+
std::function<std::vector<mx::array>(const std::vector<mx::array> &)>;
72+
73+
// One `mx::compile`d replay callable, plus a weak handle to the worker
74+
// whose thread-local compiler cache holds its traced graph. The handle
75+
// lets `~Program` drop the callable back on that worker thread (see the
76+
// destructor) — `mx::compile`'s cache erase is thread-affine.
77+
struct CompiledEntry {
78+
std::weak_ptr<State> worker;
79+
CompiledFn fn;
80+
};
81+
82+
// CM6: opt-in mx::compile cache. One compiled replay callable per stream
83+
// index — the compiled graph bakes in the captured weights and the
84+
// stream, so it must be keyed by stream and rebuilt if used on a
85+
// different one. Built lazily on the first compiled eval (eval_mode 3).
86+
// This is the *secondary* encode win; the main dispatch-collapse win is
87+
// the single-NIF replay itself.
88+
std::mutex compile_mtx;
89+
std::map<int, CompiledEntry> compiled;
90+
91+
Program() = default;
92+
93+
// Drop each compiled callable on the worker thread that built it. MLX's
94+
// compiler cache is `thread_local` and the callable's deleter calls
95+
// `compile_erase` wherever it is destroyed; this resource is collected on
96+
// a BEAM/GC thread, so destroying the callable here would erase the wrong
97+
// thread's cache — leaking the worker's traced graph (and its refs to the
98+
// captured weight buffers), and risking a later `fun_id` (a recycled heap
99+
// address) colliding with the stale entry. Posting the drop to the worker
100+
// makes the erase land on the right cache. If the worker is already gone,
101+
// its thread exit destroyed the thread-local cache (and our entry) for us.
102+
~Program() {
103+
for (auto &kv : compiled) {
104+
CompiledEntry &entry = kv.second;
105+
if (!entry.fn) {
106+
continue;
107+
}
108+
if (auto st = entry.worker.lock()) {
109+
post_to_worker(*st, [fn = std::move(entry.fn)]() mutable { fn = nullptr; });
110+
}
111+
}
112+
}
113+
114+
// Movable/copyable would be wrong (std::mutex member), and the explicit
115+
// destructor suppresses the implicit moves anyway; spell it out.
116+
Program(const Program &) = delete;
117+
Program &operator=(const Program &) = delete;
60118
};
61119

62120
} // namespace emily

c_src/emily/worker.hpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ inline void signal_stop(State &st) {
6868
st.cv.notify_all();
6969
}
7070

71+
// Best-effort: enqueue `fn` to run (and then be destroyed) on the worker
72+
// thread. Used to drop thread-affine resources — e.g. an `mx::compile`
73+
// cache entry, whose erase must happen on the thread whose thread-local
74+
// compiler cache holds it — on the thread that created them. Bypasses the
75+
// queue cap (teardown is tiny and must not be back-pressured). If the
76+
// worker is already stopping, does nothing: the thread's exit destroys its
77+
// thread-local state anyway, so the resource is reclaimed regardless.
78+
inline void post_to_worker(State &st, std::function<void()> fn) {
79+
{
80+
std::lock_guard<std::mutex> lock(st.mtx);
81+
if (st.stop) {
82+
return;
83+
}
84+
st.queue.push([fn = std::move(fn)](mx::Stream &, bool) mutable { fn(); });
85+
}
86+
st.cv.notify_one();
87+
}
88+
7189
// Joins worker threads off the BEAM schedulers. Singleton; its thread is
7290
// created lazily on first use and joined in the NIF unload callback.
7391
class Reaper {
@@ -204,6 +222,12 @@ class WorkerThread {
204222
return state_->queue.size();
205223
}
206224

225+
// A non-owning handle to this worker's state, for posting thread-affine
226+
// teardown (see `post_to_worker`) from an object that outlives — or is
227+
// collected independently of — the worker resource. Weak so the worker
228+
// can be reclaimed while the holder lives; `lock()` fails once it is.
229+
std::weak_ptr<State> weak_state() const { return state_; }
230+
207231
private:
208232
static void run(std::shared_ptr<State> st) {
209233
st->stream = mx::new_stream(mx::Device(mx::Device::DeviceType::gpu));

0 commit comments

Comments
 (0)