Skip to content

Commit 3406fb0

Browse files
committed
feat: lower LogicalNot, AllClose and Phase blocks in the native Expr compiler
Closes the misc-block cluster on the Expr op-coverage checklist (#188). Three new lower_block clauses, no new opcodes — each routes through primitives the IR already lowers, bit-identical to the Evaluator. - `Nx.Block.LogicalNot` emits the existing `:logical_not` opcode directly with a trailing coerce, mirroring Emily.Backend.native_logical_not/2. - `Nx.Block.AllClose` composes five existing primitives end-to-end, matching Emily.Backend.native_all_close/4 op-for-op: cast both to the merged float type, compute `abs(a - b) <= atol + rtol * abs(b)`, optionally OR with `isnan(a) AND isnan(b)` for `equal_nan: true`, then reduce over every axis via `:all`. The trailing coerce produces the {:u, 8} predicate dtype. - `Nx.Block.Phase` falls through to the block's composed expansion — `atan2(imag(t), real(t))` — using the TopK-style block-parameter seeding (block-local `:parameter` nodes are fresh and would otherwise resolve to the outer function's input slots). All three primitives in the expansion (atan2/imag/real) are now lowered, so the result is bit-identical to the Evaluator. Probe drops from 12 → 9 misses. Tests cover logical_not (assertion on {:u, 8} dtype), all_close on close/far inputs, custom rtol/atol, and the equal_nan branch, and phase across the four complex quadrants plus the real/imaginary axes.
1 parent 23c90b4 commit 3406fb0

3 files changed

Lines changed: 152 additions & 0 deletions

File tree

RELEASE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@
7777
`mx::linalg::solve_triangular` to the CPU stream per call (it's
7878
CPU-only), matching the eager NIF.
7979

80+
- **`Nx.Block.LogicalNot`, `Nx.Block.AllClose`, `Nx.Block.Phase` lower
81+
natively** — closes the misc-block cluster on #188. No new opcodes:
82+
`LogicalNot` emits the existing `:logical_not` op directly (same as
83+
`Emily.Backend.native_logical_not/2`); `AllClose` composes five
84+
existing primitives — `astype``abs(a - b) <= atol + rtol * abs(b)`
85+
→ optional `isnan` OR for `equal_nan: true` → reduce-all — exactly
86+
matching `Emily.Backend.native_all_close/4`; `Phase` lowers the
87+
block's `atan2(imag(t), real(t))` expansion via TopK-style parameter
88+
seeding (every primitive in the expansion was already on the native
89+
path), bit-identical to the Evaluator.
90+
8091
- **`take_along_axis` lowers natively**`Nx.take_along_axis` (the
8192
`Nx.Block.TakeAlongAxis` block) now compiles under the native single-NIF
8293
path, mirroring `Emily.Backend.native_take_along_axis/4` (cast indices to

lib/emily/ir.ex

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,80 @@ defmodule Emily.IR do
13541354
{{:multi_refs, leaf_refs}, state}
13551355
end
13561356

1357+
# Nx.logical_not (Nx.Block.LogicalNot). Mirrors
1358+
# Emily.Backend.native_logical_not/2 — emit the :logical_not opcode
1359+
# directly (MLX returns bool), trailing coerce produces {:u, 8}.
1360+
defp lower_block(%Nx.Block.LogicalNot{}, [t], _expr, out, state) do
1361+
{rt, state} = lower_node(t, state)
1362+
emit_coerced(state, :logical_not, [rt], [], out.type)
1363+
end
1364+
1365+
# Nx.all_close (Nx.Block.AllClose). Mirrors
1366+
# Emily.Backend.native_all_close/4 op-for-op so the native and eager
1367+
# paths land on identical bits: cast both to the merged float type,
1368+
# compute `abs(a - b) <= atol + rtol * abs(b)`, optionally OR with the
1369+
# `equal_nan` mask (`isnan(a) AND isnan(b)`), then reduce over every
1370+
# axis via `:all`.
1371+
defp lower_block(
1372+
%Nx.Block.AllClose{equal_nan: equal_nan, rtol: rtol, atol: atol},
1373+
[a, b],
1374+
_expr,
1375+
out,
1376+
state
1377+
) do
1378+
{ra, state} = lower_node(a, state)
1379+
{rb, state} = lower_node(b, state)
1380+
1381+
merged = Nx.Type.merge(a.type, b.type) |> Nx.Type.to_floating()
1382+
code = dtype_code(merged)
1383+
{ra, state} = emit(state, :astype, [ra], [[code]])
1384+
{rb, state} = emit(state, :astype, [rb], [[code]])
1385+
1386+
{diff_raw, state} = emit(state, :subtract, [ra, rb])
1387+
{diff, state} = emit(state, :abs, [diff_raw])
1388+
1389+
{atol_ref, state} = scalar_const(atol, merged, state)
1390+
{rtol_ref, state} = scalar_const(rtol, merged, state)
1391+
{abs_b, state} = emit(state, :abs, [rb])
1392+
{rtol_x_abs_b, state} = emit(state, :multiply, [rtol_ref, abs_b])
1393+
{tol, state} = emit(state, :add, [atol_ref, rtol_x_abs_b])
1394+
1395+
{close, state} = emit(state, :less_equal, [diff, tol])
1396+
1397+
{close, state} =
1398+
if equal_nan do
1399+
{na, state} = emit(state, :isnan, [ra])
1400+
{nb, state} = emit(state, :isnan, [rb])
1401+
{both_nan, state} = emit(state, :logical_and, [na, nb])
1402+
emit(state, :logical_or, [close, both_nan])
1403+
else
1404+
{close, state}
1405+
end
1406+
1407+
axes = Enum.to_list(0..(tuple_size(a.shape) - 1)//1)
1408+
{result, state} = emit(state, :all, [close], [axes, [0]])
1409+
coerce(result, out.type, state)
1410+
end
1411+
1412+
# Nx.phase (Nx.Block.Phase) := atan2(imag(t), real(t)). Backend
1413+
# falls through to the composed expansion (no fused kernel); the IR
1414+
# does the same here, bound to the real in_args via the TopK-style
1415+
# parameter seeding (block-local :parameter nodes are FRESH and would
1416+
# otherwise resolve to the outer function's input slots). All three
1417+
# primitives in the expansion (atan2/imag/real) already lower
1418+
# natively, so the result is bit-identical to the Evaluator.
1419+
defp lower_block(%Nx.Block.Phase{}, [t], expr, _out, state) do
1420+
{arg_ref, state} = lower_node(t, state)
1421+
1422+
seed =
1423+
expr
1424+
|> collect_block_params(%{})
1425+
|> Map.new(fn {id, 0} -> {id, arg_ref} end)
1426+
1427+
state = %{state | cache: Map.merge(state.cache, seed)}
1428+
lower_node(expr, state)
1429+
end
1430+
13571431
# Any other block struct raises. Lowering the block's composed
13581432
# expansion would silently diverge from the Evaluator whenever
13591433
# Emily.Backend.block/4 dispatches that struct through a fused / native

test/emily/compiler_equivalence_test.exs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,73 @@ defmodule Emily.CompilerEquivalenceTest do
366366
end
367367
end
368368

369+
describe "misc Nx.Block lowerings" do
370+
# Nx.logical_not (Nx.Block.LogicalNot) — emits the IR :logical_not
371+
# opcode directly, mirroring Emily.Backend.native_logical_not/2. The
372+
# trailing coerce produces the {:u, 8} predicate dtype.
373+
test "logical_not matches the evaluator and produces u8" do
374+
out = assert_equiv(&Nx.logical_not/1, [et([0, 1, 2, 0, 3], type: :s32)])
375+
assert out.type == {:u, 8}
376+
377+
assert_equiv(&Nx.logical_not/1, [et([0.0, 1.0, -1.5, 0.0])])
378+
end
379+
380+
# Nx.all_close (Nx.Block.AllClose) — the IR composes the same five
381+
# primitive sequence as Emily.Backend.native_all_close/4, so the
382+
# output bit pattern is identical to the evaluator. Exercise the
383+
# close, far, default-tolerance, custom-tolerance, and equal_nan
384+
# branches.
385+
test "all_close on close inputs returns 1 (within default tolerance)" do
386+
a = et([1.0, 2.0, 3.0])
387+
b = et([1.0 + 1.0e-6, 2.0 + 1.0e-6, 3.0 - 1.0e-6])
388+
out = assert_equiv(&Nx.all_close/2, [a, b])
389+
assert out.type == {:u, 8}
390+
end
391+
392+
test "all_close on far inputs returns 0" do
393+
a = et([1.0, 2.0, 3.0])
394+
b = et([1.0, 2.0, 99.0])
395+
assert_equiv(&Nx.all_close/2, [a, b])
396+
end
397+
398+
test "all_close with custom rtol/atol matches" do
399+
a = et([1.0, 2.0, 3.0])
400+
b = et([1.1, 2.0, 3.0])
401+
assert_equiv(fn a, b -> Nx.all_close(a, b, atol: 0.2) end, [a, b])
402+
assert_equiv(fn a, b -> Nx.all_close(a, b, atol: 0.05) end, [a, b])
403+
end
404+
405+
test "all_close with equal_nan: true matches (NaN compares equal to NaN)" do
406+
a = et([1.0, :nan, 3.0])
407+
b = et([1.0, :nan, 3.0])
408+
assert_equiv(fn a, b -> Nx.all_close(a, b, equal_nan: true) end, [a, b])
409+
assert_equiv(fn a, b -> Nx.all_close(a, b, equal_nan: false) end, [a, b])
410+
end
411+
412+
# Nx.phase (Nx.Block.Phase) := atan2(imag(t), real(t)). Backend
413+
# falls through to the composed expansion (no fused kernel); the IR
414+
# lowers the same expansion via the TopK-style param-seeding, so the
415+
# result is bit-identical to the evaluator. Exercise the four
416+
# quadrants plus the real axis (phase == 0 for real, ±pi for
417+
# negative real) and the imaginary axis (±pi/2).
418+
test "phase matches the evaluator across the four complex quadrants" do
419+
x =
420+
et([
421+
Complex.new(1.0, 1.0),
422+
Complex.new(-1.0, 1.0),
423+
Complex.new(-1.0, -1.0),
424+
Complex.new(1.0, -1.0),
425+
Complex.new(2.0, 0.0),
426+
Complex.new(-2.0, 0.0),
427+
Complex.new(0.0, 3.0),
428+
Complex.new(0.0, -3.0)
429+
])
430+
431+
out = assert_equiv(&Nx.phase/1, [x])
432+
assert out.type == {:f, 32}
433+
end
434+
end
435+
369436
describe "pad / triangular_solve" do
370437
# pad with constant value matches Emily.Backend.pad/4 (mx::pad with
371438
# mode "constant", no interior dilation). Cover symmetric and

0 commit comments

Comments
 (0)