Skip to content

Commit e2bf3f8

Browse files
authored
Merge pull request #151 from ausimian/feat/expr-compiler-cm4
feat: control flow — :cond (where-chain) + honest deferrals (CM4)
2 parents e788789 + f98c401 commit e2bf3f8

2 files changed

Lines changed: 159 additions & 0 deletions

File tree

lib/emily/ir.ex

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ defmodule Emily.IR do
195195

196196
alias Emily.Fast.Block, as: FB
197197
alias Emily.Quantization.Block, as: QB
198+
alias Nx.Defn.Tree
198199
alias Nx.Tensor, as: T
199200

200201
# Nx Expr op -> IR opcode. Arithmetic/bitwise cast both operands to the
@@ -522,6 +523,76 @@ defmodule Emily.IR do
522523
lower_block(struct, in_args, expr, t, state)
523524
end
524525

526+
# cond: raw args [clauses, last], clauses = [{pred, body}, ...]. Lower to
527+
# a select chain `where(p1, b1, where(p2, b2, ... last))`. ALL branches
528+
# are evaluated (Nx branches are side-effect-free and shape-compatible);
529+
# the result value matches the Evaluator's chosen branch exactly — only
530+
# the cost differs (not-taken branches are computed and discarded by the
531+
# elementwise select). The predicate is a whole-tensor scalar bool, so
532+
# `where` selects a branch wholesale.
533+
#
534+
# Caveat: a not-taken branch is still computed. On MLX an out-of-bounds
535+
# gather/index there clamps rather than faults, so the discarded value
536+
# never changes the result; a hard-faulting op on a not-taken path would
537+
# diverge from the Evaluator's lazy single-branch eval.
538+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :cond, args: [clauses, last]}} = t, state) do
539+
{last_ref, state} = lower_node(last, state)
540+
541+
{result, state} =
542+
Enum.reduce(Enum.reverse(clauses), {last_ref, state}, fn {pred, body}, {else_ref, st} ->
543+
{pred_ref, st} = lower_node(pred, st)
544+
{body_ref, st} = lower_node(body, st)
545+
{pred_ref, st} = emit(st, :astype, [pred_ref], [[dtype_code({:pred, 1})]])
546+
emit(st, :where, [pred_ref, body_ref, else_ref])
547+
end)
548+
549+
coerce(result, t.type, state)
550+
end
551+
552+
# attach_token: sequences a token (hooks) before `expr`. With no active
553+
# hook the token is a no-op, so pass through to the inner expr. Hooks
554+
# would need a callback into Elixir mid-graph (program-split) — deferred.
555+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :attach_token, args: [token, expr]}}, state) do
556+
if Tree.has_hooks?(token, %{}) do
557+
raise ArgumentError,
558+
"Emily Expr compiler does not support hooks under native compilation " <>
559+
"(they require a mid-graph callback into Elixir)."
560+
end
561+
562+
lower_node(expr, state)
563+
end
564+
565+
# reduce / window_reduce with a user-supplied BEAM reducer cannot be
566+
# compiled — the reducer would have to run on the host mid-graph. The
567+
# fixed-identity aggregates (sum/product/max/min) are separate ops and
568+
# already lower natively; only an arbitrary reducer reaches here.
569+
defp lower_op(%T{data: %Nx.Defn.Expr{op: op}}, _state) when op in [:reduce, :window_reduce] do
570+
raise ArgumentError,
571+
"Emily Expr compiler cannot lower #{inspect(op)} with an arbitrary " <>
572+
"reducer function (it would require a host callback mid-graph; no " <>
573+
"fallback). Use the native aggregates (sum/product/reduce_max/" <>
574+
"reduce_min) where possible."
575+
end
576+
577+
# while is deferred to a follow-up: the single-NIF replay has no loop
578+
# construct, so a data-dependent while needs static-trip unrolling or a
579+
# worker-side synced loop. defn while is not used by the core transformer
580+
# forwards (decode/generation loops run in Elixir today).
581+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :while}}, _state) do
582+
raise ArgumentError,
583+
"Emily Expr compiler does not yet lower defn `while` loops (deferred — " <>
584+
"the single-NIF replay has no loop construct)."
585+
end
586+
587+
# :elem is a tuple projection, emitted for any tuple-returning expression
588+
# (defn `while`, multi-output ops). Deferred alongside the constructs
589+
# that produce surviving tuples.
590+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :elem}}, _state) do
591+
raise ArgumentError,
592+
"Emily Expr compiler does not yet lower :elem (tuple projection) — it " <>
593+
"arises from defn `while` and multi-output ops, which are deferred."
594+
end
595+
525596
defp lower_op(%T{data: %Nx.Defn.Expr{op: op}}, _state) do
526597
raise ArgumentError,
527598
"Emily Expr compiler does not yet lower op #{inspect(op)} " <>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
defmodule Emily.CompilerControlFlowTest do
2+
@moduledoc """
3+
CM4 — `Nx.Defn` control-flow constructs compile single-NIF, bit-identical
4+
to the Evaluator. `:cond` lowers to a `where`-chain (all branches
5+
evaluated; the value matches the chosen branch exactly).
6+
"""
7+
use ExUnit.Case, async: true
8+
import Nx.Defn
9+
10+
@native [compiler: Emily.Compiler, native: true]
11+
@eval [compiler: Emily.Compiler]
12+
13+
defn if_fn(x) do
14+
if Nx.greater(Nx.sum(x), 0) do
15+
Nx.multiply(x, 2)
16+
else
17+
Nx.negate(x)
18+
end
19+
end
20+
21+
defn cond3_fn(x) do
22+
s = Nx.sum(x)
23+
24+
cond do
25+
Nx.greater(s, 10) -> Nx.multiply(x, 10)
26+
Nx.greater(s, 0) -> Nx.multiply(x, 2)
27+
true -> Nx.negate(x)
28+
end
29+
end
30+
31+
defn nested_if_fn(x) do
32+
y = if Nx.greater(Nx.sum(x), 0), do: Nx.add(x, 1), else: Nx.subtract(x, 1)
33+
if Nx.greater(Nx.reduce_max(y), 5), do: Nx.divide(y, 2), else: y
34+
end
35+
36+
defn while_fn(x) do
37+
{_i, acc} =
38+
while {i = 0, acc = x}, i < 5 do
39+
{i + 1, Nx.multiply(acc, 2)}
40+
end
41+
42+
acc
43+
end
44+
45+
defp equiv(fun, x) do
46+
native = Nx.Defn.jit(fun, @native).(x)
47+
eval = Nx.Defn.jit(fun, @eval).(x)
48+
assert Nx.to_binary(native) == Nx.to_binary(eval)
49+
native
50+
end
51+
52+
describe ":cond / if" do
53+
test "two-branch if selects the right branch" do
54+
for data <- [[1.0, 2.0, 3.0], [-1.0, -2.0, -3.0], [0.0, 0.0, 0.0]] do
55+
equiv(&if_fn/1, Nx.tensor(data, backend: Emily.Backend))
56+
end
57+
end
58+
59+
test "multi-clause cond picks the first matching predicate" do
60+
# sums: 12 (>10), 3 (0..10), -6 (else) exercise each clause.
61+
for data <- [[5.0, 4.0, 3.0], [1.0, 1.0, 1.0], [-2.0, -2.0, -2.0]] do
62+
equiv(&cond3_fn/1, Nx.tensor(data, backend: Emily.Backend))
63+
end
64+
end
65+
66+
test "nested conds compose" do
67+
for data <- [[3.0, 3.0, 3.0], [-1.0, 0.0, 1.0], [10.0, 10.0, 10.0]] do
68+
equiv(&nested_if_fn/1, Nx.tensor(data, backend: Emily.Backend))
69+
end
70+
end
71+
end
72+
73+
describe "unsupported control flow raises (no silent fallback)" do
74+
test "arbitrary reduce/2 fn raises a clear error" do
75+
f = fn x -> Nx.reduce(x, 0.0, fn a, b -> Nx.add(a, b) end) end
76+
77+
assert_raise ArgumentError, ~r/arbitrary reducer/, fn ->
78+
Nx.Defn.jit(f, @native).(Nx.tensor([1.0, 2.0, 3.0], backend: Emily.Backend))
79+
end
80+
end
81+
82+
test "defn while raises (deferred)" do
83+
assert_raise ArgumentError, ~r/while/, fn ->
84+
Nx.Defn.jit(&while_fn/1, @native).(Nx.tensor([1.0, 2.0], backend: Emily.Backend))
85+
end
86+
end
87+
end
88+
end

0 commit comments

Comments
 (0)