Skip to content

Commit 981bac2

Browse files
authored
Merge pull request #190 from ausimian/feat/expr-compiler-binary-ops
feat: lower atan2, quotient and logical_xor in the native Expr compiler
2 parents 5747a68 + 7433600 commit 981bac2

4 files changed

Lines changed: 94 additions & 2 deletions

File tree

RELEASE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@
5858
`Emily.Backend`'s eager composition. Closes the largest cluster on the
5959
Expr op-coverage checklist (#188).
6060

61+
- **`atan2`, `quotient`, `logical_xor` lower natively** — closes the
62+
binary/compare cluster on the Expr op-coverage checklist (#188).
63+
`atan2` slots into the @arith_binary mapping (cast both to out.type,
64+
then `mx::arctan2`); `quotient` routes through `mx::floor_divide` the
65+
same way `Emily.Backend.quotient/3` does; `logical_xor` (no MLX
66+
primitive) lowers to `(a != 0) != (b != 0)`, mirroring the eager
67+
Backend composition. All three are bit-identical to the Evaluator.
68+
6169
- **`take_along_axis` lowers natively**`Nx.take_along_axis` (the
6270
`Nx.Block.TakeAlongAxis` block) now compiles under the native single-NIF
6371
path, mirroring `Emily.Backend.native_take_along_axis/4` (cast indices to

c_src/emily/opcodes.hpp

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,15 @@ enum class Opcode : int64_t {
201201
Conjugate = 108,
202202
Real = 109,
203203
Imag = 110,
204+
// Binary arithmetic peers of the @arith_binary cluster. arctan2 is the
205+
// direct Backend mapping (atan2: arctan2); floor_divide is the integer
206+
// engine behind Nx.quotient (the lowerer routes quotient -> floor_divide
207+
// matching Emily.Backend.quotient/3).
208+
Arctan2 = 111,
209+
FloorDivide = 112,
204210
};
205211

206-
inline constexpr int64_t kOpcodeCount = 111;
212+
inline constexpr int64_t kOpcodeCount = 113;
207213

208214
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
209215
inline std::string qmode_from_code(int64_t code) {
@@ -752,6 +758,13 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
752758
return mx::real(arg1(in, "real"), s);
753759
case Opcode::Imag:
754760
return mx::imag(arg1(in, "imag"), s);
761+
// --- Binary arithmetic peers ---
762+
case Opcode::Arctan2:
763+
need2(in, "arctan2");
764+
return mx::arctan2(in[0], in[1], s);
765+
case Opcode::FloorDivide:
766+
need2(in, "floor_divide");
767+
return mx::floor_divide(in[0], in[1], s);
755768
// --- Scatter (shares the eager index.cpp entry points) ---
756769
case Opcode::Scatter:
757770
case Opcode::ScatterAdd: {

lib/emily/ir.ex

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,13 @@ defmodule Emily.IR do
182182
isinf: 107,
183183
conjugate: 108,
184184
real: 109,
185-
imag: 110
185+
imag: 110,
186+
# Binary arithmetic peers. arctan2 is the direct atan2 lowering;
187+
# floor_divide is the integer engine Emily.Backend.quotient/3 calls,
188+
# so the IR routes Nx :quotient through it (cast both to out.type,
189+
# floor_divide, same bits as the eager Backend).
190+
arctan2: 111,
191+
floor_divide: 112
186192
}
187193

188194
# Quant mode string -> code; decoded by qmode_from_code in
@@ -281,15 +287,20 @@ defmodule Emily.IR do
281287

282288
# Nx Expr op -> IR opcode. Arithmetic/bitwise cast both operands to the
283289
# node's out.type before the op (see backend.ex @renamed_arith_binary).
290+
# Nx :quotient routes here too — Emily.Backend.quotient/3 is
291+
# `floor_divide(astype(a, out.type), astype(b, out.type))`, which is
292+
# exactly this clause with the floor_divide opcode.
284293
@arith_binary %{
285294
add: :add,
286295
subtract: :subtract,
287296
multiply: :multiply,
288297
divide: :divide,
289298
pow: :power,
290299
remainder: :remainder,
300+
atan2: :arctan2,
291301
min: :minimum,
292302
max: :maximum,
303+
quotient: :floor_divide,
293304
bitwise_and: :bitwise_and,
294305
bitwise_or: :bitwise_or,
295306
bitwise_xor: :bitwise_xor,
@@ -434,6 +445,21 @@ defmodule Emily.IR do
434445
emit(state, :astype, [r], [[dtype_code(t.type)]])
435446
end
436447

448+
# logical_xor(a, b) := (a != 0) != (b != 0). MLX has no logical_xor
449+
# primitive; mirrors Emily.Backend.logical_xor/3 — three not_equal
450+
# calls (each operand vs a per-dtype zero, then the two booleans), with
451+
# the final coerce to out.type ({:u, 8}) matching the eager wrap.
452+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :logical_xor, args: [a, b]}} = t, state) do
453+
{ra, state} = lower_node(a, state)
454+
{rb, state} = lower_node(b, state)
455+
{za, state} = scalar_const(0, a.type, state)
456+
{zb, state} = scalar_const(0, b.type, state)
457+
{ma, state} = emit(state, :not_equal, [ra, za])
458+
{mb, state} = emit(state, :not_equal, [rb, zb])
459+
{r, state} = emit(state, :not_equal, [ma, mb])
460+
coerce(r, t.type, state)
461+
end
462+
437463
defp lower_op(%T{data: %Nx.Defn.Expr{op: op, args: [a]}} = t, state)
438464
when is_map_key(@unary_ops, op) do
439465
{ra, state} = lower_node(a, state)

test/emily/compiler_equivalence_test.exs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,35 @@ defmodule Emily.CompilerEquivalenceTest do
170170
assert_equiv(&Nx.add/2, [a, b])
171171
end
172172

173+
# Binary arithmetic peers added with the Expr op-coverage sweep (#188).
174+
# atan2 is a direct @arith_binary mapping (atan2: :arctan2); quotient
175+
# routes through floor_divide the same way Emily.Backend.quotient/3 does.
176+
test "atan2 matches the evaluator across the four quadrants" do
177+
# Quadrant-covering pairs (y, x): include the axes so the result
178+
# lands on the ±0, ±pi, ±pi/2 boundaries the eager Backend produces.
179+
y = et([1.0, 1.0, -1.0, -1.0, 0.0, 0.0, 2.0, -2.0])
180+
x = et([1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 0.0, 0.0])
181+
assert_equiv(&Nx.atan2/2, [y, x])
182+
end
183+
184+
test "quotient matches the evaluator for signed and unsigned integer dtypes" do
185+
# Backend casts both operands to out.type then calls floor_divide.
186+
# Quotient is integer-only in Nx, so we exercise the s32/s64/u8/u32
187+
# paths; the native lane matches the evaluator bit-for-bit because
188+
# both end up at the same mx::floor_divide kernel.
189+
for type <- [:s32, :s64] do
190+
a = et([7, -7, 10, -10, 4, -4], type: type)
191+
b = et([2, 2, 3, 3, 5, 5], type: type)
192+
assert_equiv(&Nx.quotient/2, [a, b])
193+
end
194+
195+
for type <- [:u8, :u32] do
196+
a = et([7, 10, 4, 255], type: type)
197+
b = et([2, 3, 5, 2], type: type)
198+
assert_equiv(&Nx.quotient/2, [a, b])
199+
end
200+
end
201+
173202
test "scalar constant operand (materialized capture)" do
174203
x = et([1.0, 2.0, 3.0])
175204
assert_equiv(fn t -> Nx.add(t, 1.5) end, [x])
@@ -192,6 +221,22 @@ defmodule Emily.CompilerEquivalenceTest do
192221
assert out.type == {:u, 8}
193222
end
194223
end
224+
225+
# logical_xor: MLX has no primitive, so both paths run
226+
# `(a != 0) != (b != 0)` — Emily.Backend.logical_xor/3 eagerly, the
227+
# IR via a dedicated composite clause that emits the same three
228+
# not_equal ops. Inputs span the four truth-table corners across
229+
# float and integer dtypes; the trailing coerce produces {:u, 8}.
230+
test "logical_xor matches across float and integer dtypes" do
231+
a = et([1.0, 0.0, 1.0, 0.0])
232+
b = et([1.0, 1.0, 0.0, 0.0])
233+
out = assert_equiv(&Nx.logical_xor/2, [a, b])
234+
assert out.type == {:u, 8}
235+
236+
ai = et([1, 0, 1, 0], type: :s32)
237+
bi = et([1, 1, 0, 0], type: :s32)
238+
assert_equiv(&Nx.logical_xor/2, [ai, bi])
239+
end
195240
end
196241

197242
describe "cast / shape" do

0 commit comments

Comments
 (0)