Skip to content

Commit 5747a68

Browse files
authored
Merge pull request #189 from ausimian/feat/expr-compiler-unary-ops
feat: lower 19 more unary ops in the native Expr compiler
2 parents 0613ed5 + b931620 commit 5747a68

5 files changed

Lines changed: 529 additions & 3 deletions

File tree

RELEASE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@
5050
selection on the native path. Remaining gaps (`gather`/scatter,
5151
pooling/`window_*`, cumulative) continue to work via the graceful fallback.
5252

53+
- **19 more unary ops lower natively**`expm1`, `tan`, `sinh`, `cosh`,
54+
`acos`/`asin`/`atan`, `acosh`/`asinh`/`atanh`, `round`, `bitwise_not`,
55+
`is_nan`/`is_infinity`, `conjugate`, `real`/`imag` route to the same
56+
`mx::*` primitive as the eager unary NIF (bit-identical to the
57+
Evaluator); `erfc` and `cbrt` compose from existing ops, mirroring
58+
`Emily.Backend`'s eager composition. Closes the largest cluster on the
59+
Expr op-coverage checklist (#188).
60+
5361
- **`take_along_axis` lowers natively**`Nx.take_along_axis` (the
5462
`Nx.Block.TakeAlongAxis` block) now compiles under the native single-NIF
5563
path, mirroring `Emily.Backend.native_take_along_axis/4` (cast indices to

c_src/emily/opcodes.hpp

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,31 @@ enum class Opcode : int64_t {
179179
// idx0, ...] (one s32 index array per scattered axis); iattrs [[axes...]].
180180
Scatter = 92, // overwrite (last write wins on duplicates)
181181
ScatterAdd = 93, // accumulate
182+
// Unary elementwise (round 2 — added alongside the @unary_ops expansion
183+
// for the missing Nx ops; map to the same mx::* primitives the eager
184+
// unary NIFs use, see c_src/ops/unary.cpp).
185+
Expm1 = 94,
186+
Tan = 95,
187+
Sinh = 96,
188+
Cosh = 97,
189+
Arccos = 98,
190+
Arcsin = 99,
191+
Arctan = 100,
192+
Arccosh = 101,
193+
Arcsinh = 102,
194+
Arctanh = 103,
195+
// Round-half-away-from-zero. Backend hard-codes decimals=0
196+
// (Nx.round/1 takes no decimals arg); the dispatcher does too.
197+
Round = 104,
198+
BitwiseInvert = 105,
199+
Isnan = 106,
200+
Isinf = 107,
201+
Conjugate = 108,
202+
Real = 109,
203+
Imag = 110,
182204
};
183205

184-
inline constexpr int64_t kOpcodeCount = 94;
206+
inline constexpr int64_t kOpcodeCount = 111;
185207

186208
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
187209
inline std::string qmode_from_code(int64_t code) {
@@ -695,6 +717,41 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
695717
emily::to_mlx_shape(attr_at(iattrs, 0, "irfftn")),
696718
emily::to_int_vec(attr_at(iattrs, 1, "irfftn")),
697719
mx::fft::FFTNorm::Backward, s);
720+
// --- Unary elementwise (round 2) ---
721+
case Opcode::Expm1:
722+
return mx::expm1(arg1(in, "expm1"), s);
723+
case Opcode::Tan:
724+
return mx::tan(arg1(in, "tan"), s);
725+
case Opcode::Sinh:
726+
return mx::sinh(arg1(in, "sinh"), s);
727+
case Opcode::Cosh:
728+
return mx::cosh(arg1(in, "cosh"), s);
729+
case Opcode::Arccos:
730+
return mx::arccos(arg1(in, "arccos"), s);
731+
case Opcode::Arcsin:
732+
return mx::arcsin(arg1(in, "arcsin"), s);
733+
case Opcode::Arctan:
734+
return mx::arctan(arg1(in, "arctan"), s);
735+
case Opcode::Arccosh:
736+
return mx::arccosh(arg1(in, "arccosh"), s);
737+
case Opcode::Arcsinh:
738+
return mx::arcsinh(arg1(in, "arcsinh"), s);
739+
case Opcode::Arctanh:
740+
return mx::arctanh(arg1(in, "arctanh"), s);
741+
case Opcode::Round:
742+
return mx::round(arg1(in, "round"), /*decimals=*/0, s);
743+
case Opcode::BitwiseInvert:
744+
return mx::bitwise_invert(arg1(in, "bitwise_invert"), s);
745+
case Opcode::Isnan:
746+
return mx::isnan(arg1(in, "isnan"), s);
747+
case Opcode::Isinf:
748+
return mx::isinf(arg1(in, "isinf"), s);
749+
case Opcode::Conjugate:
750+
return mx::conjugate(arg1(in, "conjugate"), s);
751+
case Opcode::Real:
752+
return mx::real(arg1(in, "real"), s);
753+
case Opcode::Imag:
754+
return mx::imag(arg1(in, "imag"), s);
698755
// --- Scatter (shares the eager index.cpp entry points) ---
699756
case Opcode::Scatter:
700757
case Opcode::ScatterAdd: {

lib/emily/ir.ex

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,31 @@ defmodule Emily.IR do
158158
# idx0, ...] (one s32 index array per scattered axis); iattrs [[axes...]].
159159
# scatter overwrites (last-write on duplicates); scatter_add accumulates.
160160
scatter: 92,
161-
scatter_add: 93
161+
scatter_add: 93,
162+
# Unary elementwise (round 2 — the missing Nx ops alongside the
163+
# original @unary_ops set). Names mirror the eager unary NIF names
164+
# (c_src/ops/unary.cpp) so the IR opcode atom matches the MLX entry
165+
# point — Nx renaming (e.g. acos -> arccos, is_nan -> isnan) happens
166+
# in @unary_ops below, just like the eager Backend's @renamed_unary.
167+
expm1: 94,
168+
tan: 95,
169+
sinh: 96,
170+
cosh: 97,
171+
arccos: 98,
172+
arcsin: 99,
173+
arctan: 100,
174+
arccosh: 101,
175+
arcsinh: 102,
176+
arctanh: 103,
177+
# round/0 — Backend hard-codes mx::round's decimals to 0
178+
# (Nx.round/1 takes no decimals arg). Dispatcher does the same.
179+
round: 104,
180+
bitwise_invert: 105,
181+
isnan: 106,
182+
isinf: 107,
183+
conjugate: 108,
184+
real: 109,
185+
imag: 110
162186
}
163187

164188
# Quant mode string -> code; decoded by qmode_from_code in
@@ -287,23 +311,44 @@ defmodule Emily.IR do
287311
}
288312

289313
# Unary elementwise: no coercion (MLX preserves the dtype Nx expects).
314+
# The post-emit `coerce/3` then astype-casts to out.type so MLX ops
315+
# whose dtype rule differs from Nx (e.g. `is_nan`/`is_infinity` returning
316+
# a bool that Nx wants as {:u, 8}, or `real`/`imag` whose Nx out.type is
317+
# the real component) line up — same machinery as the original 16 ops.
290318
@unary_ops %{
291319
negate: :negative,
292320
abs: :abs,
293321
sign: :sign,
294322
sqrt: :sqrt,
295323
rsqrt: :rsqrt,
296324
exp: :exp,
325+
expm1: :expm1,
297326
log: :log,
298327
log1p: :log1p,
299328
sin: :sin,
300329
cos: :cos,
330+
tan: :tan,
301331
tanh: :tanh,
332+
sinh: :sinh,
333+
cosh: :cosh,
334+
acos: :arccos,
335+
asin: :arcsin,
336+
atan: :arctan,
337+
acosh: :arccosh,
338+
asinh: :arcsinh,
339+
atanh: :arctanh,
302340
sigmoid: :sigmoid,
303341
floor: :floor,
304342
ceil: :ceil,
343+
round: :round,
305344
erf: :erf,
306-
erf_inv: :erf_inv
345+
erf_inv: :erf_inv,
346+
bitwise_not: :bitwise_invert,
347+
is_nan: :isnan,
348+
is_infinity: :isinf,
349+
conjugate: :conjugate,
350+
real: :real,
351+
imag: :imag
307352
}
308353

309354
@doc """
@@ -405,6 +450,30 @@ defmodule Emily.IR do
405450
emit(state, :astype, [ra], [[dtype_code(t.type)]])
406451
end
407452

453+
# erfc(x) := 1 - erf(x). Mirrors Emily.Backend.erfc/2 — MLX has no
454+
# erfc primitive, so the eager path also composes from erf + subtract.
455+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :erfc, args: [a]}} = t, state) do
456+
{ra, state} = lower_node(a, state)
457+
{erf_r, state} = emit(state, :erf, [ra])
458+
{one_ref, state} = scalar_const(1.0, t.type, state)
459+
{r, state} = emit(state, :subtract, [one_ref, erf_r])
460+
coerce(r, t.type, state)
461+
end
462+
463+
# cbrt(x) := sign(x) * abs(x)^(1/3). Mirrors Emily.Backend.cbrt/2 —
464+
# MLX has no cbrt primitive. Splitting via sign+abs keeps the negative
465+
# branch correct (`x^(1/3)` over negatives lands in complex), matching
466+
# the eager path's bit pattern.
467+
defp lower_op(%T{data: %Nx.Defn.Expr{op: :cbrt, args: [a]}} = t, state) do
468+
{ra, state} = lower_node(a, state)
469+
{sign_r, state} = emit(state, :sign, [ra])
470+
{abs_r, state} = emit(state, :abs, [ra])
471+
{third, state} = scalar_const(1.0 / 3.0, t.type, state)
472+
{pow_r, state} = emit(state, :power, [abs_r, third])
473+
{r, state} = emit(state, :multiply, [sign_r, pow_r])
474+
coerce(r, t.type, state)
475+
end
476+
408477
# bitcast: reinterpret the bytes as out.type (mirrors Emily.Backend.bitcast/2,
409478
# which calls mx::view). Used by the RNG path to turn random bits into floats.
410479
defp lower_op(%T{data: %Nx.Defn.Expr{op: :bitcast, args: [a]}} = t, state) do
@@ -1343,6 +1412,13 @@ defmodule Emily.IR do
13431412
{{:const, idx}, %{state | consts: [ref | state.consts], n_consts: idx + 1}}
13441413
end
13451414

1415+
# Bake a `{}` scalar of `type` as a captured const operand. Used by the
1416+
# composite lowerers (erfc, cbrt) whose Backend mirrors build the same
1417+
# scalar through `scalar_ref/2`.
1418+
defp scalar_const(value, type, state) do
1419+
materialize_const(Nx.tensor(value, type: type, backend: Nx.BinaryBackend), {}, type, state)
1420+
end
1421+
13461422
defp materialize_capture(tensor, shape, type, state) do
13471423
ref = Emily.Native.from_binary(Nx.to_binary(tensor), Tuple.to_list(shape), type)
13481424
idx = state.n_captures

0 commit comments

Comments
 (0)