|
| 1 | +defmodule Emily.Compiler do |
| 2 | + @moduledoc """ |
| 3 | + `Nx.Defn.Compiler` implementation that runs `defn` computations on |
| 4 | + `Emily.Backend`. |
| 5 | +
|
| 6 | + The compiler walks `Nx.Defn.Expr` in Elixir and dispatches each node |
| 7 | + through the active backend — exactly what `Nx.Defn.Evaluator` already |
| 8 | + does — with two adjustments specific to Emily: |
| 9 | +
|
| 10 | + * `c:__to_backend__/1` returns `{Emily.Backend, [device: …]}` so |
| 11 | + `Nx.Defn.to_backend/1` (and the callers that consult it, including |
| 12 | + `Nx.Serving`) allocate inputs and outputs on Emily rather than the |
| 13 | + process-default backend. |
| 14 | + * `c:__partitions_options__/1` always returns a single partition. |
| 15 | + MLX's Metal runtime is not safe for concurrent kernel dispatch |
| 16 | + from multiple OS threads (see `test/soak/backend_concurrency_test.exs` |
| 17 | + for the SIGSEGV story); a multi-partition serving would race the |
| 18 | + driver. `:max_concurrency` is accepted for API compatibility with |
| 19 | + `Nx.Serving` but capped at 1. |
| 20 | +
|
| 21 | + ## Why this is so thin |
| 22 | +
|
| 23 | + M5 deliberately avoids two pieces of complexity: |
| 24 | +
|
| 25 | + 1. **No external cache.** `__compile__/4` walks the expression once |
| 26 | + and returns a closure that captures the walked plan; the closure |
| 27 | + *is* the cache. Callers that want reuse across invocations use |
| 28 | + `Nx.Defn.compile/3` and hold the returned function (Bumblebee / |
| 29 | + `Nx.Serving` already do this on warmup). The PLAN.md note about |
| 30 | + caching the walk in ETS was rejected once we accounted for the |
| 31 | + per-call ETS deep-copy cost on a Qwen3-sized expression tree. |
| 32 | +
|
| 33 | + 2. **No `mlx::core::compile` wrapping.** Lazy evaluation at the |
| 34 | + Backend layer suffices for correctness; kernel-fusion via |
| 35 | + `mlx::core::compile` is M6. |
| 36 | +
|
| 37 | + Concretely, `__jit__/5` and `__compile__/4` delegate to |
| 38 | + `Nx.Defn.Evaluator` after option validation. The Evaluator dispatches |
| 39 | + every op via `Nx.Shared.list_impl!/1`, which finds `Emily.Backend` |
| 40 | + whenever the operands carry it — and `c:__to_backend__/1` ensures the |
| 41 | + operands do. |
| 42 | +
|
| 43 | + ## Options |
| 44 | +
|
| 45 | + * `:device` — `:gpu` (default) or `:cpu`. Forwarded to `Emily.Backend` |
| 46 | + via the `c:__to_backend__/1` callback. |
| 47 | + * `:hooks`, `:debug_options`, `:garbage_collect` — passed through to |
| 48 | + `Nx.Defn.Evaluator` unchanged. See its moduledoc. |
| 49 | + * `:max_concurrency` — accepted for `Nx.Serving` compatibility, but |
| 50 | + multi-partition serving is rejected because MLX kernel dispatch |
| 51 | + isn't thread-safe. Pass `1` (the default) to silence. |
| 52 | + """ |
| 53 | + |
| 54 | + @behaviour Nx.Defn.Compiler |
| 55 | + |
| 56 | + alias Nx.Defn.Evaluator |
| 57 | + |
| 58 | + @valid_opts [:device, :hooks, :debug_options, :garbage_collect, :max_concurrency] |
| 59 | + |
| 60 | + @impl true |
| 61 | + def __jit__(key, vars, fun, args_list, opts) do |
| 62 | + opts = validate_opts!(opts) |
| 63 | + Evaluator.__jit__(key, vars, fun, args_list, opts) |
| 64 | + end |
| 65 | + |
| 66 | + @impl true |
| 67 | + def __compile__(key, vars, fun, opts) do |
| 68 | + opts = validate_opts!(opts) |
| 69 | + Evaluator.__compile__(key, vars, fun, opts) |
| 70 | + end |
| 71 | + |
| 72 | + @impl true |
| 73 | + def __partitions_options__(opts) do |
| 74 | + opts = validate_opts!(opts) |
| 75 | + |
| 76 | + case Keyword.get(opts, :max_concurrency, 1) do |
| 77 | + n when n in [nil, 1] -> |
| 78 | + [opts] |
| 79 | + |
| 80 | + n when is_integer(n) and n > 1 -> |
| 81 | + raise ArgumentError, |
| 82 | + "Emily.Compiler does not support :max_concurrency > 1 — MLX's Metal " <> |
| 83 | + "runtime isn't safe for concurrent kernel dispatch from multiple OS " <> |
| 84 | + "threads. Got: #{n}" |
| 85 | + end |
| 86 | + end |
| 87 | + |
| 88 | + @impl true |
| 89 | + def __to_backend__(opts) do |
| 90 | + opts = validate_opts!(opts) |
| 91 | + {Emily.Backend, [device: Keyword.get(opts, :device, :gpu)]} |
| 92 | + end |
| 93 | + |
| 94 | + defp validate_opts!(opts) do |
| 95 | + case Enum.reject(Keyword.keys(opts), &(&1 in @valid_opts)) do |
| 96 | + [] -> |
| 97 | + opts |
| 98 | + |
| 99 | + unknown -> |
| 100 | + raise ArgumentError, |
| 101 | + "Emily.Compiler received unknown option(s): #{inspect(unknown)}. " <> |
| 102 | + "Valid options: #{inspect(@valid_opts)}" |
| 103 | + end |
| 104 | + end |
| 105 | +end |
0 commit comments