Skip to content

Emily.Fast.* fused kernels fail when composed inside a caller's own defn (fallback functions aren't defnp) #205

Description

@lostbean

Summary

Every Emily.Fast fused kernel (rms_norm, layer_norm, rope,
rope_with_freqs, and all four scaled_dot_product_attention* variants)
raises RuntimeError: cannot invoke Emily.Fast.<kernel> inside defn because it was not defined with defn when called from inside a defn-defined
function nested one level below the top-level jit_apply/defn entry
point — even though the module's own moduledoc documents this exact usage
as supported ("Call these from inside a defn or Nx.Defn.jit-traced
function, alongside regular Nx ops").

Environment

  • emily 1.0.0 (Hex), MLX 0.32.0
  • macOS, Apple Silicon
  • elixir 1.18.4 / OTP 28
  • nx 0.12.1
  • Both Emily.Compiler with native: true and without (plain
    Nx.Defn.Evaluator) — same failure either way.

Repro

The moduledoc's own example, run two ways:

defmodule DebugFastDefn do
  import Nx.Defn

  defn block(x, w) do
    Emily.Fast.rms_norm(x, w, eps: 1.0e-5)
  end
end

x = Nx.tensor([[1.0, 2.0, 3.0, 4.0]], backend: Emily.Backend)
w = Nx.tensor([1.0, 1.0, 1.0, 1.0], backend: Emily.Backend)

Works — calling the moduledoc's Nx.Defn.jit_apply form directly, with
the anonymous function as the top-level jit'd unit:

Nx.Defn.jit_apply(fn x, w -> Emily.Fast.rms_norm(x, w, eps: 1.0e-5) end, [x, w])
#=> #Nx.Tensor<...> -- succeeds

Fails — the exact same call, one level deeper, wrapped in a
module-level defn (i.e. the moduledoc's own defn block(x, w) do ... end
example, called via DebugFastDefn.block(x, w) or via
Nx.Defn.jit_apply(&DebugFastDefn.block/2, [x, w], compiler: Emily.Compiler, native: true)):

** (RuntimeError) cannot invoke Emily.Fast.rms_norm/3 inside defn because it was not defined with defn
    (nx 0.12.1) lib/nx/defn/compiler.ex:190: Nx.Defn.Compiler.runtime_fun/3
    ...

Confirmed reproducible under both compiler: Emily.Compiler (with and
without native: true) and plain Nx.Defn.Evaluator (no Emily.Compiler
configured at all) — identical error either way.

Root cause (traced through lib/emily/fast.ex)

Every fused kernel wraps its Nx.block/4 fallback in a plain def,
not defnp:

# lib/emily/fast.ex
def rms_norm(x, weight, opts \\ []) do
  opts = Keyword.validate!(opts, eps: 1.0e-6)
  block = struct!(FB.RMSNorm, opts)

  Nx.block(block, [x, weight], output_like(x), fn ^block, x, weight ->
    rms_norm_fallback(x, weight, opts)   # <-- plain def, not defnp
  end)
end

defp rms_norm_fallback(x, weight, opts) do
  ...
end

Nx.block/4's fallback function argument must itself be defn-traceable.
When the outer call is the literal top-level unit handed to
Nx.Defn.jit_apply/defn, tracing apparently short-circuits before this
matters. Once Emily.Fast.rms_norm/3 is called from inside an
already-defn-traced function (i.e. genuine composition — the moduledoc's
own stated use case), the trace has to actually enter Nx.block's fallback
path, and hits the plain-def rms_norm_fallback/2 — hence "was not
defined with defn."

This isn't rms_norm-specific — the identical pattern (Nx.block(..., fn ... -> some_plain_def_fallback(...) end)) appears in every fused kernel in
this module: layer_norm/layer_norm_fallback,
rope/rope_fallback, rope_with_freqs/rope_freqs_fallback,
scaled_dot_product_attention/sdpa_fallback and sdpa_sinks_fallback,
scaled_dot_product_attention_with_mask/sdpa_masked_fallback and
sdpa_masked_sinks_fallback.

Impact

This blocks a real use case: composing multiple fused kernels (e.g. several
transformer layers' worth of rms_norm + rope + SDPA) into one larger
defn-traced graph, to get Emily.Compiler's native single-NIF
whole-graph replay speedup (per #163's own numbers, ~5x on a decode loop)
across the composed graph rather than dispatching each fused kernel as its
own separate top-level call. Right now, the only way to call any
Emily.Fast kernel successfully is as the literal top-level jit'd unit —
never nested inside a caller's own defn.

Suggested fix

Change each *_fallback function from defp to defnp (and correspondingly
any helper functions those fallbacks call that aren't already defn-safe).
Given the fallback bodies already appear to be pure Nx composition (matmuls,
elementwise ops, reductions — nothing outside defn's supported op set, based
on reading rms_norm_fallback/layer_norm_fallback), this looks like it
should be a mechanical, low-risk change, but I haven't attempted the fix
myself or checked whether it's contained to this file.

Acceptance criteria (suggested)

  • The moduledoc's own defn block(x, w) do Emily.Fast.layer_norm(w, b, ...) end
    example succeeds when called through a caller's defn, not just as the
    top-level jit'd unit.
  • Same for rope, rope_with_freqs, and both SDPA variants.
  • A regression test nesting at least one Emily.Fast call two levels deep
    (a defn calling a defnp calling Emily.Fast.*) so this doesn't
    regress silently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions