Skip to content

Commit deb1275

Browse files
committed
feat: lower the Nx.Block.LinAlg.* family in the native Expr compiler
Closes the LinAlg-block cluster on the Expr op-coverage checklist (#188) — seven new lowerings: Cholesky, Solve, QR, Eigh, LU, SVD, and Determinant. Two new infrastructure pieces unlock multi-output and nested-block lowering: - replay_program in c_src/program.cpp grows a switch for the four multi-output linalg opcodes (LinalgQR / LinalgEigh / LinalgLU / LinalgSVD) that pushes N values per instruction, the same shape `while` already uses. compile_program's per-instruction arity calculation extends to match. - The block-parameter collector goes "shallow": when it encounters a nested `:block` Expr node it walks only the block's `in_args` (which reference outer-scope tensors), not the inner `expr` whose fresh inner block-params live in a different scope. Determinant's expansion calls Nx.LinAlg.lu internally, so without this fix the outer block's parameter seeding would leak into LU's inner parameters and the binding would mis-resolve. Single-output ops (LinalgCholesky, LinalgSolve) dispatch through the standard `dispatch_op` path; multi-output ops use emit_multi/6 with arity 2 (QR, Eigh) or 3 (LU, SVD), returning {:multi_refs, ...} so the existing `:elem` projection works unchanged. LU's s32 perm vector is post-processed into a permutation matrix via `take(eye(n), perm, 0)`, mirroring Emily.Backend.native_lu/3. SVD in `full_matrices?: false` mode is sliced from the full output to the requested shape via the same `maybe_slice_svd/4` shape comparison as Emily.Backend. Determinant has no fused kernel — both paths run the block's composed 2x2 / 3x3 / NxN expansion via TopK-style parameter seeding, bit-identical to the Evaluator. All seven dispatch mx::linalg::* on the CPU stream per call (MLX's linalg is CPU-only), matching c_src/ops/linalg.cpp's eager NIFs. Tests in compiler_equivalence_test.exs cover each block on small inputs (SPD for Cholesky, well-conditioned for Solve, mixed shapes for QR/SVD, symmetric for Eigh, square for LU/Determinant). SVD's sign convention is mathematically non-unique and MLX's S also drifts by ~1 ULP across separate calls (the Backend property tests acknowledge this at backend_test.exs:771), so SVD tests assert shapes + singular values within tolerance instead of bit-exact; every other LinAlg block matches the evaluator bit-for-bit. Probe drops from 9 → 2 misses; only `count_leading_zeros` and `population_count` remain (no MLX primitive — by design).
1 parent 8c3b15f commit deb1275

5 files changed

Lines changed: 379 additions & 7 deletions

File tree

RELEASE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,28 @@
8888
seeding (every primitive in the expansion was already on the native
8989
path), bit-identical to the Evaluator.
9090

91+
- **`Nx.LinAlg.cholesky` / `solve` / `qr` / `eigh` / `lu` / `svd` /
92+
`determinant` lower natively** — closes the LinAlg-block cluster on
93+
#188. Two new infrastructure pieces enable this: a small extension to
94+
`replay_program` that pushes N values per instruction for multi-output
95+
linalg ops (mirroring how `while` already pushes one value per
96+
loop-carried state element), and a "shallow" parameter collector that
97+
stops at nested `:block` nodes so the outer block's seeding doesn't
98+
leak across scope boundaries (Determinant's expansion calls
99+
`Nx.LinAlg.lu` internally — without the fix the IR would try to bind
100+
LU's fresh inner block-params from the outer scope). Cholesky and
101+
Solve are single-output; QR/Eigh/LU/SVD are multi-output. LU's
102+
`s32` perm vector is post-processed into a permutation matrix via
103+
`take(eye(n), perm, 0)` exactly like `Emily.Backend.native_lu/3`; SVD
104+
in `full_matrices?: false` mode is sliced from the full output the
105+
same way `Emily.Backend.maybe_slice_svd/4` does (the Gram thin-tall
106+
workaround for Issue #84 stays on the eager path for now);
107+
Determinant has no fused kernel — both paths run the block's composed
108+
2x2 / 3x3 / NxN expansion, via the same TopK-style parameter seeding.
109+
All seven dispatch to the CPU stream per call (MLX's `linalg::*` is
110+
CPU-only); the C++ side overrides the replay stream just like the
111+
eager linalg NIFs.
112+
91113
- **`take_along_axis` lowers natively**`Nx.take_along_axis` (the
92114
`Nx.Block.TakeAlongAxis` block) now compiles under the native single-NIF
93115
path, mirroring `Emily.Backend.native_take_along_axis/4` (cast indices to

c_src/emily/opcodes.hpp

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,32 @@ enum class Opcode : int64_t {
218218
// Routed to MLX's CPU stream (`mx::linalg::solve_triangular` is CPU-only),
219219
// mirroring c_src/ops/linalg.cpp's eager NIF.
220220
LinalgSolveTriangular = 114,
221+
// CPU-only linalg decompositions / solvers (mx::linalg::*). Each
222+
// routes the call to the CPU stream like the eager NIFs in
223+
// c_src/ops/linalg.cpp. Single-output (Cholesky, Solve) dispatches
224+
// through the standard `dispatch_op` path; multi-output (QR/Eigh/LU/SVD)
225+
// is special-cased in replay_program — the dispatcher returns one
226+
// array; multi-output instructions push N values into the program
227+
// values vector, the same shape `while` uses.
228+
// operands [a]; iattrs [[upper]]
229+
LinalgCholesky = 115,
230+
// operands [a, b]; no iattrs
231+
LinalgSolve = 116,
232+
// operands [a]; arity 2 (Q, R); no iattrs
233+
LinalgQR = 117,
234+
// operands [a]; arity 2 (vals, vecs); uplo hard-coded "L" to match
235+
// Emily.Backend.native_eigh/3.
236+
LinalgEigh = 118,
237+
// operands [a]; arity 3 (perm[s32], L, U); the IR post-processes the
238+
// perm vector into a permutation matrix via take(eye, perm, 0),
239+
// mirroring Emily.Backend.native_lu/3.
240+
LinalgLU = 119,
241+
// operands [a]; arity 3 (U, S, V); `full_matrices` hard-coded `true` —
242+
// the IR slices for the thin case, matching Emily.Backend.native_svd/3.
243+
LinalgSVD = 120,
221244
};
222245

223-
inline constexpr int64_t kOpcodeCount = 115;
246+
inline constexpr int64_t kOpcodeCount = 121;
224247

225248
// Quant mode code (Emily.IR @quant_modes) -> MLX mode string.
226249
inline std::string qmode_from_code(int64_t code) {
@@ -801,6 +824,27 @@ inline mx::array dispatch_op(Opcode op, const std::vector<mx::array> &in,
801824
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
802825
return mx::linalg::solve_triangular(in[0], in[1], upper, cpu);
803826
}
827+
case Opcode::LinalgCholesky: {
828+
// CPU-only, mirrors c_src/ops/linalg.cpp's eager NIF.
829+
bool upper = scalar_at(iattrs, 0, "linalg_cholesky") != 0;
830+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
831+
return mx::linalg::cholesky(arg1(in, "linalg_cholesky"), upper, cpu);
832+
}
833+
case Opcode::LinalgSolve: {
834+
need2(in, "linalg_solve");
835+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
836+
return mx::linalg::solve(in[0], in[1], cpu);
837+
}
838+
// Multi-output linalg (QR/Eigh/LU/SVD) is special-cased in
839+
// replay_program (it pushes N values per instruction instead of one).
840+
// Reaching these here would be a compiler bug — surface it loudly
841+
// rather than silently fall through to "unknown opcode".
842+
case Opcode::LinalgQR:
843+
case Opcode::LinalgEigh:
844+
case Opcode::LinalgLU:
845+
case Opcode::LinalgSVD:
846+
throw std::invalid_argument(
847+
"multi-output linalg op is handled in replay_program");
804848
// --- Scatter (shares the eager index.cpp entry points) ---
805849
case Opcode::Scatter:
806850
case Opcode::ScatterAdd: {

c_src/program.cpp

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,51 @@ std::vector<mx::array> replay_program(const Program &prog,
242242
continue;
243243
}
244244

245+
// Multi-output linalg decompositions (QR/Eigh/LU/SVD) follow the
246+
// same shape as `while` above: one instruction, N values pushed.
247+
// mx::linalg::* are CPU-only, mirroring c_src/ops/linalg.cpp's
248+
// eager NIFs (the dispatcher's `s` arg is the replay stream, often
249+
// GPU — override per call).
250+
if (instr.opcode == Opcode::LinalgQR ||
251+
instr.opcode == Opcode::LinalgEigh ||
252+
instr.opcode == Opcode::LinalgLU ||
253+
instr.opcode == Opcode::LinalgSVD) {
254+
auto a = resolve(instr.operands.at(0));
255+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
256+
switch (instr.opcode) {
257+
case Opcode::LinalgQR: {
258+
auto [q, r] = mx::linalg::qr(a, cpu);
259+
values.push_back(std::move(q));
260+
values.push_back(std::move(r));
261+
break;
262+
}
263+
case Opcode::LinalgEigh: {
264+
auto [vals, vecs] = mx::linalg::eigh(a, "L", cpu);
265+
values.push_back(std::move(vals));
266+
values.push_back(std::move(vecs));
267+
break;
268+
}
269+
case Opcode::LinalgLU: {
270+
auto result = mx::linalg::lu(a, cpu);
271+
values.push_back(std::move(result[0]));
272+
values.push_back(std::move(result[1]));
273+
values.push_back(std::move(result[2]));
274+
break;
275+
}
276+
case Opcode::LinalgSVD: {
277+
auto result = mx::linalg::svd(a, /*compute_uv=*/true, cpu);
278+
values.push_back(std::move(result[0]));
279+
values.push_back(std::move(result[1]));
280+
values.push_back(std::move(result[2]));
281+
break;
282+
}
283+
default:
284+
// Unreachable — the outer `if` already filtered.
285+
throw std::logic_error("unreachable multi-output linalg switch");
286+
}
287+
continue;
288+
}
289+
245290
std::vector<mx::array> operands;
246291
operands.reserve(instr.operands.size());
247292
for (auto r : instr.operands) {
@@ -318,8 +363,15 @@ compile_program(ErlNifEnv *, int64_t n_inputs,
318363
}
319364

320365
Opcode op = static_cast<Opcode>(opcodes[i]);
366+
// Multi-output instructions: `while` mirrors its operand count
367+
// (one final-state value per loop-carried state element); the
368+
// linalg decompositions have fixed arity (Q,R / vals,vecs / P,L,U
369+
// / U,S,V). Everything else is single-output.
321370
int64_t out_count =
322-
(op == Opcode::While) ? static_cast<int64_t>(operands[i].size()) : 1;
371+
(op == Opcode::While) ? static_cast<int64_t>(operands[i].size())
372+
: (op == Opcode::LinalgQR || op == Opcode::LinalgEigh) ? 2
373+
: (op == Opcode::LinalgLU || op == Opcode::LinalgSVD) ? 3
374+
: 1;
323375

324376
std::vector<fine::ResourcePtr<Program>> sub;
325377
if (i < subprograms.size()) {

lib/emily/ir.ex

Lines changed: 156 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,19 @@ defmodule Emily.IR do
196196
# CPU-only triangular solve (operands [a, b]; iattrs [[upper]]); the
197197
# lowerer handles transform_a/left_side by transposing a/b/output
198198
# around this bare kernel call, mirroring Emily.Backend.triangular_solve/4.
199-
linalg_solve_triangular: 114
199+
linalg_solve_triangular: 114,
200+
# CPU-only linalg decompositions / solvers (mx::linalg::*). Each
201+
# routes through MLX's CPU stream like the eager NIFs in
202+
# c_src/ops/linalg.cpp. Single-output: Cholesky, Solve. Multi-output:
203+
# QR (Q,R), Eigh (vals,vecs), LU (perm[s32],L,U), SVD (U,S,V) — the
204+
# IR emits them via emit_multi (one instr, N reserved output slots)
205+
# the same way `while` does, and replay_program pushes N values.
206+
linalg_cholesky: 115,
207+
linalg_solve: 116,
208+
linalg_qr: 117,
209+
linalg_eigh: 118,
210+
linalg_lu: 119,
211+
linalg_svd: 120
200212
}
201213

202214
# Quant mode string -> code; decoded by qmode_from_code in
@@ -1428,6 +1440,119 @@ defmodule Emily.IR do
14281440
lower_node(expr, state)
14291441
end
14301442

1443+
# ---- LinAlg blocks (Nx.Block.LinAlg.*) ----
1444+
#
1445+
# Each mirrors the matching Emily.Backend.native_* helper. Single-output
1446+
# ops (Cholesky, Solve) emit a single instruction; multi-output ops
1447+
# (QR/Eigh/LU/SVD) use emit_multi/6 to reserve their output slots,
1448+
# same machinery as `:while`. The CPU stream is forced inside the C++
1449+
# dispatcher because mx::linalg::* is CPU-only.
1450+
1451+
# Nx.LinAlg.cholesky — Backend hard-codes upper: false (lower).
1452+
defp lower_block(%Nx.Block.LinAlg.Cholesky{}, [t], _expr, out, state) do
1453+
{rt, state} = lower_node(t, state)
1454+
emit_coerced(state, :linalg_cholesky, [rt], [[0]], out.type)
1455+
end
1456+
1457+
# Nx.LinAlg.solve — single-output, no opts.
1458+
defp lower_block(%Nx.Block.LinAlg.Solve{}, [a, b], _expr, out, state) do
1459+
{ra, state} = lower_node(a, state)
1460+
{rb, state} = lower_node(b, state)
1461+
emit_coerced(state, :linalg_solve, [ra, rb], [], out.type)
1462+
end
1463+
1464+
# Nx.LinAlg.qr (mode: :reduced). Backend's `:complete` mode falls back
1465+
# via_binary; here we don't lower `:complete` (the catch-all raises),
1466+
# matching the no-fallback contract.
1467+
defp lower_block(%Nx.Block.LinAlg.QR{mode: :reduced}, [t], expr, _out, state)
1468+
when is_tuple(expr) do
1469+
{rt, state} = lower_node(t, state)
1470+
{base, state} = emit_multi(state, :linalg_qr, [rt], [], [], 2)
1471+
[q_leaf, r_leaf] = Tuple.to_list(expr)
1472+
{q_ref, state} = coerce({:instr, base + 0}, q_leaf.type, state)
1473+
{r_ref, state} = coerce({:instr, base + 1}, r_leaf.type, state)
1474+
{{:multi_refs, [q_ref, r_ref]}, state}
1475+
end
1476+
1477+
# Nx.LinAlg.eigh — uplo hard-coded "L" (lower) inside the dispatcher,
1478+
# matching Emily.Backend.native_eigh/3.
1479+
defp lower_block(%Nx.Block.LinAlg.Eigh{}, [t], expr, _out, state) when is_tuple(expr) do
1480+
{rt, state} = lower_node(t, state)
1481+
{base, state} = emit_multi(state, :linalg_eigh, [rt], [], [], 2)
1482+
[vals_leaf, vecs_leaf] = Tuple.to_list(expr)
1483+
{vals_ref, state} = coerce({:instr, base + 0}, vals_leaf.type, state)
1484+
{vecs_ref, state} = coerce({:instr, base + 1}, vecs_leaf.type, state)
1485+
{{:multi_refs, [vals_ref, vecs_ref]}, state}
1486+
end
1487+
1488+
# Nx.LinAlg.lu — MLX returns (perm[s32], L, U); Backend post-processes
1489+
# perm into a permutation matrix via `take(eye(n), perm, 0)`. The IR
1490+
# mirrors that here.
1491+
defp lower_block(%Nx.Block.LinAlg.LU{}, [t], expr, _out, state) when is_tuple(expr) do
1492+
{rt, state} = lower_node(t, state)
1493+
{base, state} = emit_multi(state, :linalg_lu, [rt], [], [], 3)
1494+
[p_leaf, l_leaf, u_leaf] = Tuple.to_list(expr)
1495+
1496+
# perm is a 1-D s32 vector (or batched). Use the leaf p_leaf's
1497+
# shape to size the eye matrix used as the row-selection source.
1498+
p_rank = tuple_size(p_leaf.shape)
1499+
n = elem(p_leaf.shape, p_rank - 1)
1500+
1501+
{eye_ref, state} =
1502+
materialize_const(
1503+
Nx.eye({n, n}, type: p_leaf.type, backend: Nx.BinaryBackend),
1504+
{n, n},
1505+
p_leaf.type,
1506+
state
1507+
)
1508+
1509+
{perm_s32, state} = emit(state, :astype, [{:instr, base + 0}], [[dtype_code({:s, 32})]])
1510+
{p_ref, state} = emit(state, :take, [eye_ref, perm_s32], [[0]])
1511+
{p_ref, state} = coerce(p_ref, p_leaf.type, state)
1512+
{l_ref, state} = coerce({:instr, base + 1}, l_leaf.type, state)
1513+
{u_ref, state} = coerce({:instr, base + 2}, u_leaf.type, state)
1514+
{{:multi_refs, [p_ref, l_ref, u_ref]}, state}
1515+
end
1516+
1517+
# Nx.LinAlg.svd — MLX's full SVD (`compute_uv: true`, U is m×m, V is
1518+
# n×n, S is length min(m,n)). For thin SVD (`full_matrices?: false`),
1519+
# slice U and V down to the requested output shape, mirroring
1520+
# Emily.Backend.maybe_slice_svd/4. The Gram-matrix workaround for very
1521+
# tall 2-D matrices (Issue #84) is left to the eager path; the IR
1522+
# equivalent can land alongside it.
1523+
defp lower_block(%Nx.Block.LinAlg.SVD{}, [t], expr, _out, state) when is_tuple(expr) do
1524+
{rt, state} = lower_node(t, state)
1525+
{base, state} = emit_multi(state, :linalg_svd, [rt], [], [], 3)
1526+
[u_leaf, s_leaf, v_leaf] = Tuple.to_list(expr)
1527+
1528+
rank = tuple_size(t.shape)
1529+
m = elem(t.shape, rank - 2)
1530+
n = elem(t.shape, rank - 1)
1531+
1532+
{u_ref, state} = maybe_slice_svd_ir({:instr, base + 0}, u_leaf.shape, {m, m}, state)
1533+
{u_ref, state} = coerce(u_ref, u_leaf.type, state)
1534+
{s_ref, state} = coerce({:instr, base + 1}, s_leaf.type, state)
1535+
{v_ref, state} = maybe_slice_svd_ir({:instr, base + 2}, v_leaf.shape, {n, n}, state)
1536+
{v_ref, state} = coerce(v_ref, v_leaf.type, state)
1537+
{{:multi_refs, [u_ref, s_ref, v_ref]}, state}
1538+
end
1539+
1540+
# Nx.LinAlg.determinant — Backend has no fused kernel; the eager path
1541+
# runs the block's composed expansion (2x2 / 3x3 / NxN). The IR does
1542+
# the same here via TopK-style param seeding so the result is
1543+
# bit-identical to the evaluator.
1544+
defp lower_block(%Nx.Block.LinAlg.Determinant{}, [t], expr, _out, state) do
1545+
{arg_ref, state} = lower_node(t, state)
1546+
1547+
seed =
1548+
expr
1549+
|> collect_block_params(%{})
1550+
|> Map.new(fn {id, 0} -> {id, arg_ref} end)
1551+
1552+
state = %{state | cache: Map.merge(state.cache, seed)}
1553+
lower_node(expr, state)
1554+
end
1555+
14311556
# Any other block struct raises. Lowering the block's composed
14321557
# expansion would silently diverge from the Evaluator whenever
14331558
# Emily.Backend.block/4 dispatches that struct through a fused / native
@@ -1492,13 +1617,22 @@ defmodule Emily.IR do
14921617
defp tensors?(list), do: Enum.all?(list, &match?(%T{}, &1))
14931618

14941619
# Collect `{parameter_id => position}` for every block-local `:parameter`
1495-
# reachable from `node`. Used to bind a block expansion's fresh parameters
1496-
# to the real in_args (see the Nx.Block.TopK lowering). Walks only the small
1497-
# expansion graph — parameters are leaves, so it never descends into the
1498-
# in_args' own (possibly large) graph.
1620+
# reachable from `node` *in the current block scope*. Used to bind a
1621+
# block expansion's fresh parameters to the real in_args (see the
1622+
# Nx.Block.TopK / Nx.Block.LinAlg.Determinant lowerings). For nested
1623+
# `:block` nodes (e.g. determinant's expansion calls Nx.LinAlg.lu),
1624+
# walk only the in_args — which reference outer-scope tensors — and
1625+
# NOT the nested `expr`, whose fresh inner block params live in a
1626+
# different scope and would otherwise leak in as spurious positions.
14991627
defp collect_block_params(%T{data: %Nx.Defn.Expr{op: :parameter, id: id, args: [pos]}}, acc),
15001628
do: Map.put_new(acc, id, pos)
15011629

1630+
defp collect_block_params(
1631+
%T{data: %Nx.Defn.Expr{op: :block, args: [_struct, in_args, _expr, _cb]}},
1632+
acc
1633+
),
1634+
do: Enum.reduce(in_args, acc, &collect_block_params/2)
1635+
15021636
defp collect_block_params(%T{data: %Nx.Defn.Expr{args: args}}, acc),
15031637
do: Enum.reduce(args, acc, &collect_block_params/2)
15041638

@@ -1529,6 +1663,23 @@ defmodule Emily.IR do
15291663
Enum.to_list(0..(rank - 3)//1) ++ [rank - 1, rank - 2]
15301664
end
15311665

1666+
# Slice U/V from the SVD's full output down to the thin-SVD shape if the
1667+
# block requested it. Mirrors Emily.Backend.maybe_slice_svd/4 — when the
1668+
# trailing two dims match the full shape (`full_last2`), no-op; otherwise
1669+
# slice from origin to `out_shape` with stride 1.
1670+
defp maybe_slice_svd_ir(ref, out_shape, full_last2, state) do
1671+
rank = tuple_size(out_shape)
1672+
1673+
if {elem(out_shape, rank - 2), elem(out_shape, rank - 1)} == full_last2 do
1674+
{ref, state}
1675+
else
1676+
starts = List.duplicate(0, rank)
1677+
strides = List.duplicate(1, rank)
1678+
stops = Tuple.to_list(out_shape)
1679+
emit(state, :slice, [ref], [starts, stops, strides])
1680+
end
1681+
end
1682+
15321683
# Coerce a ref to `type` (emit an astype). MLX astype to the same dtype
15331684
# is a no-op, so this is safe to apply unconditionally — it mirrors
15341685
# Emily.Backend.wrap/3's coerce and keeps the node's dtype exact.

0 commit comments

Comments
 (0)