Skip to content

Commit 381ea74

Browse files
authored
Merge pull request #27 from ausimian/m15-native-linalg
M15: native linalg via mx::linalg::*
2 parents c0f14ca + 89295e4 commit 381ea74

10 files changed

Lines changed: 669 additions & 15 deletions

File tree

PLAN.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,28 @@ BinaryBackend-slow. MLX exposes most natively under `mx::linalg::*`.
754754
**Exit:** all `mx::linalg::*`-backed callbacks pass property suite;
755755
remaining `via_binary` linalg paths documented with rationale.
756756

757+
**Post-M15 note — intermittent test crashes:**
758+
Two crash modes were observed during M15 development.
759+
760+
1. **SIGABRT (exit 134) — fixed:** LAPACK errors (SVD convergence, LU
761+
singular matrix) abort the VM because MLX's `StreamThread::thread_fn`
762+
has no catch frame — any C++ exception from an `eval_cpu` primitive
763+
hits `std::terminate`. This is an MLX design constraint, not
764+
something the NIF layer can catch. Fixed by strengthening test
765+
inputs:
766+
- SVD property test now applies `make_well_conditioned/1` (was the
767+
only linalg test without it).
768+
- `make_well_conditioned/1` multiplier raised from `n*10` to
769+
`n*10+20` — the old value landed on the diagonal-dominance
770+
boundary for n=2, allowing f32 rounding to produce singular
771+
pivots in LAPACK.
772+
2. **SIGSEGV (exit 139) — pre-existing, not M15-related:** Reproduces
773+
at ~4/10 on main with no linalg tests. Likely an MLX Metal driver
774+
issue (see `test_helper.exs` commentary). The linalg NIFs add
775+
`mx::eval()` on inputs before the cross-stream `cpu_stream()`
776+
handoff as a defensive measure, but this does not fix the underlying
777+
SIGSEGV. Needs separate investigation outside the M15 scope.
778+
757779
### M16 — Mixed-precision training
758780

759781
bf16 activations + f32 master weights + loss scaling is the standard

RELEASE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@
88

99
## Added
1010

11+
- M15 — Native linalg. `lu`, `svd`, `qr` (reduced), `cholesky`, `eigh`,
12+
`solve`, and `triangular_solve` now dispatch directly to
13+
`mx::linalg::*` instead of round-tripping through `Nx.BinaryBackend`.
14+
MLX's linalg primitives are CPU-only; the NIFs use a CPU stream
15+
inside the worker's `run_sync` callback.
16+
- **`qr` with `mode: :complete`** falls back to `via_binary` (MLX only
17+
supports reduced QR). **`determinant`** uses Nx's default
18+
implementation, which calls the now-native `lu`.
19+
- `triangular_solve` handles `left_side: false` and
20+
`transform_a: :transpose` by composing native transpose + native
21+
solve (no BinaryBackend fallback).
22+
- SVD reduced mode (`full_matrices?: false`) slices the full MLX
23+
result to the target shape.
24+
- **New NIF stubs** in `Emily.Native`: `linalg_lu/2`, `linalg_svd/2`,
25+
`linalg_qr/2`, `linalg_cholesky/3`, `linalg_eigh/3`,
26+
`linalg_solve/3`, `linalg_solve_triangular/4`.
27+
- Property tests compare all native linalg ops against
28+
`Nx.BinaryBackend` with well-conditioned random inputs.
29+
1130
- M14.5 — Worker-thread dispatch for vendored MLX. Replaces the
1231
stream-index NIF convention (M14) and the `safe_eval` mutex with a
1332
proper per-stream dedicated OS thread. Each `WorkerThread` (C++ class

c_src/ops/linalg.cpp

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
// Linear algebra: matmul, tensordot, outer, inner, and affine int4/int8
2-
// quantization primitives (quantize / dequantize / quantized_matmul).
1+
// Linear algebra: matmul, tensordot, outer, inner, decompositions
2+
// (lu, svd, qr, cholesky, eigh), solvers (solve, solve_triangular),
3+
// and affine int4/int8 quantization primitives
4+
// (quantize / dequantize / quantized_matmul).
35

46
#include "../emily/tensor.hpp"
57
#include "../emily/worker.hpp"
@@ -122,4 +124,120 @@ fine::ResourcePtr<Tensor> quantized_matmul(
122124
}
123125
FINE_NIF(quantized_matmul, 0);
124126

127+
// ---- Decompositions / solvers (mx::linalg::*) ------------------
128+
//
129+
// MLX's linalg primitives are CPU-only — they throw on a GPU stream.
130+
// Each NIF dispatches via the worker's run_sync (serialisation) but
131+
// uses the CPU default stream for the actual linalg call. MLX handles
132+
// cross-stream data dependencies internally via its lazy eval graph.
133+
134+
// LU decomposition. Returns {P, L, U}.
135+
std::tuple<fine::ResourcePtr<Tensor>,
136+
fine::ResourcePtr<Tensor>,
137+
fine::ResourcePtr<Tensor>>
138+
linalg_lu(
139+
ErlNifEnv *,
140+
fine::ResourcePtr<WorkerThread> w,
141+
fine::ResourcePtr<Tensor> a) {
142+
return w->run_sync([&](mx::Stream & /*s*/) {
143+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
144+
auto result = mx::linalg::lu(a->array, cpu);
145+
return std::make_tuple(
146+
wrap(std::move(result[0])),
147+
wrap(std::move(result[1])),
148+
wrap(std::move(result[2])));
149+
});
150+
}
151+
FINE_NIF(linalg_lu, 0);
152+
153+
// Singular value decomposition. Returns {U, S, Vt}.
154+
std::tuple<fine::ResourcePtr<Tensor>,
155+
fine::ResourcePtr<Tensor>,
156+
fine::ResourcePtr<Tensor>>
157+
linalg_svd(
158+
ErlNifEnv *,
159+
fine::ResourcePtr<WorkerThread> w,
160+
fine::ResourcePtr<Tensor> a) {
161+
return w->run_sync([&](mx::Stream & /*s*/) {
162+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
163+
auto result = mx::linalg::svd(a->array, true, cpu);
164+
return std::make_tuple(
165+
wrap(std::move(result[0])),
166+
wrap(std::move(result[1])),
167+
wrap(std::move(result[2])));
168+
});
169+
}
170+
FINE_NIF(linalg_svd, 0);
171+
172+
// QR decomposition (reduced). Returns {Q, R}.
173+
std::tuple<fine::ResourcePtr<Tensor>,
174+
fine::ResourcePtr<Tensor>>
175+
linalg_qr(
176+
ErlNifEnv *,
177+
fine::ResourcePtr<WorkerThread> w,
178+
fine::ResourcePtr<Tensor> a) {
179+
return w->run_sync([&](mx::Stream & /*s*/) {
180+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
181+
auto [q, r] = mx::linalg::qr(a->array, cpu);
182+
return std::make_tuple(wrap(std::move(q)), wrap(std::move(r)));
183+
});
184+
}
185+
FINE_NIF(linalg_qr, 0);
186+
187+
// Cholesky decomposition. `upper` selects upper- vs lower-triangular.
188+
fine::ResourcePtr<Tensor> linalg_cholesky(
189+
ErlNifEnv *,
190+
fine::ResourcePtr<WorkerThread> w,
191+
fine::ResourcePtr<Tensor> a,
192+
bool upper) {
193+
return w->run_sync([&](mx::Stream & /*s*/) {
194+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
195+
return wrap(mx::linalg::cholesky(a->array, upper, cpu));
196+
});
197+
}
198+
FINE_NIF(linalg_cholesky, 0);
199+
200+
// Symmetric eigendecomposition. Returns {eigenvalues, eigenvectors}.
201+
std::tuple<fine::ResourcePtr<Tensor>,
202+
fine::ResourcePtr<Tensor>>
203+
linalg_eigh(
204+
ErlNifEnv *,
205+
fine::ResourcePtr<WorkerThread> w,
206+
fine::ResourcePtr<Tensor> a,
207+
std::string uplo) {
208+
return w->run_sync([&](mx::Stream & /*s*/) {
209+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
210+
auto [vals, vecs] = mx::linalg::eigh(a->array, uplo, cpu);
211+
return std::make_tuple(wrap(std::move(vals)), wrap(std::move(vecs)));
212+
});
213+
}
214+
FINE_NIF(linalg_eigh, 0);
215+
216+
// General linear solve: A X = B.
217+
fine::ResourcePtr<Tensor> linalg_solve(
218+
ErlNifEnv *,
219+
fine::ResourcePtr<WorkerThread> w,
220+
fine::ResourcePtr<Tensor> a,
221+
fine::ResourcePtr<Tensor> b) {
222+
return w->run_sync([&](mx::Stream & /*s*/) {
223+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
224+
return wrap(mx::linalg::solve(a->array, b->array, cpu));
225+
});
226+
}
227+
FINE_NIF(linalg_solve, 0);
228+
229+
// Triangular solve: A X = B where A is upper- or lower-triangular.
230+
fine::ResourcePtr<Tensor> linalg_solve_triangular(
231+
ErlNifEnv *,
232+
fine::ResourcePtr<WorkerThread> w,
233+
fine::ResourcePtr<Tensor> a,
234+
fine::ResourcePtr<Tensor> b,
235+
bool upper) {
236+
return w->run_sync([&](mx::Stream & /*s*/) {
237+
auto cpu = mx::default_stream(mx::Device(mx::Device::DeviceType::cpu));
238+
return wrap(mx::linalg::solve_triangular(a->array, b->array, upper, cpu));
239+
});
240+
}
241+
FINE_NIF(linalg_solve_triangular, 0);
242+
125243
} // namespace

lib/emily/backend.ex

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ defmodule Emily.Backend do
1414
message pointing to f32.
1515
* `from_pointer`, `to_pointer`, `population_count`, and
1616
`count_leading_zeros` raise `ArgumentError` — MLX has no primitive.
17-
* Window operations (`window_sum`, `window_scatter_max`, etc.) and
18-
advanced linalg (`lu`, `svd`, `qr`, `cholesky`, `eigh`, `solve`,
19-
`determinant`, `triangular_solve`) fall back to Nx's default
20-
`optional/3` implementation — correct but slow.
17+
* Window operations (`window_sum`, `window_scatter_max`, etc.) fall
18+
back to Nx's default `optional/3` implementation — correct but slow.
19+
`qr` with `mode: :complete` also falls back (MLX only supports
20+
reduced QR). `determinant` uses Nx's default implementation, which
21+
calls `lu` (native via MLX) for matrices larger than 3×3.
2122
* `quotient` uses MLX `floor_divide` semantics (floor toward -inf
2223
rather than Nx's truncate-toward-zero). For non-negative integer
2324
operands the results agree; mixed-sign inputs diverge by one. We
@@ -1194,15 +1195,114 @@ defmodule Emily.Backend do
11941195
batch ++ trailing
11951196
end
11961197

1198+
# =================================================================
1199+
# Native linalg — decompositions & solvers via mx::linalg::*
1200+
# =================================================================
1201+
11971202
@impl true
1198-
def lu(outs, t, opts), do: via_binary_tuple(outs, [t], &Nx.LinAlg.lu(&1, opts))
1203+
def lu({p_out, l_out, u_out}, t, _opts) do
1204+
w = worker()
1205+
{perm_ref, l_ref, u_ref} = Native.linalg_lu(w, ref(t))
1206+
n = elem(t.shape, tuple_size(t.shape) - 1)
1207+
eye_ref = Native.eye(w, n, n, 0, p_out.type)
1208+
p_ref = Native.take(w, eye_ref, perm_ref, 0)
1209+
{wrap(p_ref, p_out, w), wrap(l_ref, l_out, w), wrap(u_ref, u_out, w)}
1210+
end
11991211

12001212
@impl true
1201-
def triangular_solve(out, a, b, opts),
1202-
do: via_binary(out, [a, b], &Nx.LinAlg.triangular_solve(&1, &2, opts))
1213+
def svd({u_out, s_out, v_out}, t, _opts) do
1214+
w = worker()
1215+
{u_ref, s_ref, v_ref} = Native.linalg_svd(w, ref(t))
1216+
rank = tuple_size(t.shape)
1217+
m = elem(t.shape, rank - 2)
1218+
n = elem(t.shape, rank - 1)
1219+
u_ref = maybe_slice_svd(u_ref, u_out.shape, {m, m}, w)
1220+
v_ref = maybe_slice_svd(v_ref, v_out.shape, {n, n}, w)
1221+
{wrap(u_ref, u_out, w), wrap(s_ref, s_out, w), wrap(v_ref, v_out, w)}
1222+
end
1223+
1224+
defp maybe_slice_svd(ref, out_shape, full_last2, w) do
1225+
rank = tuple_size(out_shape)
1226+
1227+
if {elem(out_shape, rank - 2), elem(out_shape, rank - 1)} == full_last2 do
1228+
ref
1229+
else
1230+
starts = List.duplicate(0, rank)
1231+
strides = List.duplicate(1, rank)
1232+
Native.slice(w, ref, starts, Tuple.to_list(out_shape), strides)
1233+
end
1234+
end
12031235

12041236
@impl true
1205-
def svd(outs, t, opts), do: via_binary_tuple(outs, [t], &Nx.LinAlg.svd(&1, opts))
1237+
def triangular_solve(%T{} = out, a, b, opts) do
1238+
w = worker()
1239+
a_ref = ref(a)
1240+
b_ref = ref(b)
1241+
1242+
case {opts[:transform_a], opts[:left_side]} do
1243+
{:none, true} ->
1244+
Native.linalg_solve_triangular(w, a_ref, b_ref, not opts[:lower])
1245+
|> wrap(out, w)
1246+
1247+
{:transpose, true} ->
1248+
at = Native.transpose(w, a_ref, mat_transpose_axes(a.shape))
1249+
1250+
Native.linalg_solve_triangular(w, at, b_ref, opts[:lower])
1251+
|> wrap(out, w)
1252+
1253+
{:none, false} ->
1254+
at = Native.transpose(w, a_ref, mat_transpose_axes(a.shape))
1255+
bt = Native.transpose(w, b_ref, mat_transpose_axes(b.shape))
1256+
xt = Native.linalg_solve_triangular(w, at, bt, opts[:lower])
1257+
1258+
Native.transpose(w, xt, mat_transpose_axes(out.shape))
1259+
|> wrap(out, w)
1260+
1261+
{:transpose, false} ->
1262+
bt = Native.transpose(w, b_ref, mat_transpose_axes(b.shape))
1263+
xt = Native.linalg_solve_triangular(w, a_ref, bt, not opts[:lower])
1264+
1265+
Native.transpose(w, xt, mat_transpose_axes(out.shape))
1266+
|> wrap(out, w)
1267+
end
1268+
end
1269+
1270+
defp mat_transpose_axes(shape) do
1271+
rank = tuple_size(shape)
1272+
Enum.to_list(0..(rank - 3)//1) ++ [rank - 1, rank - 2]
1273+
end
1274+
1275+
@impl true
1276+
def qr({q_out, r_out}, t, opts) do
1277+
case opts[:mode] do
1278+
:reduced ->
1279+
w = worker()
1280+
{q_ref, r_ref} = Native.linalg_qr(w, ref(t))
1281+
{wrap(q_ref, q_out, w), wrap(r_ref, r_out, w)}
1282+
1283+
:complete ->
1284+
via_binary_tuple({q_out, r_out}, [t], &Nx.LinAlg.qr(&1, opts))
1285+
end
1286+
end
1287+
1288+
@impl true
1289+
def cholesky(%T{} = out, t) do
1290+
w = worker()
1291+
Native.linalg_cholesky(w, ref(t), false) |> wrap(out, w)
1292+
end
1293+
1294+
@impl true
1295+
def eigh({vals_out, vecs_out}, t, _opts) do
1296+
w = worker()
1297+
{vals_ref, vecs_ref} = Native.linalg_eigh(w, ref(t), "L")
1298+
{wrap(vals_ref, vals_out, w), wrap(vecs_ref, vecs_out, w)}
1299+
end
1300+
1301+
@impl true
1302+
def solve(%T{} = out, a, b) do
1303+
w = worker()
1304+
Native.linalg_solve(w, ref(a), ref(b)) |> wrap(out, w)
1305+
end
12061306

12071307
# =================================================================
12081308
# Custom fused-kernel callbacks for Emily.Fast

lib/emily/native.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,29 @@ defmodule Emily.Native do
255255
@spec inner(worker(), tensor(), tensor()) :: tensor()
256256
def inner(_w, _a, _b), do: nif()
257257

258+
# --- Linalg (decompositions / solvers) ---------------------------
259+
260+
@spec linalg_lu(worker(), tensor()) :: {tensor(), tensor(), tensor()}
261+
def linalg_lu(_w, _a), do: nif()
262+
263+
@spec linalg_svd(worker(), tensor()) :: {tensor(), tensor(), tensor()}
264+
def linalg_svd(_w, _a), do: nif()
265+
266+
@spec linalg_qr(worker(), tensor()) :: {tensor(), tensor()}
267+
def linalg_qr(_w, _a), do: nif()
268+
269+
@spec linalg_cholesky(worker(), tensor(), boolean()) :: tensor()
270+
def linalg_cholesky(_w, _a, _upper), do: nif()
271+
272+
@spec linalg_eigh(worker(), tensor(), String.t()) :: {tensor(), tensor()}
273+
def linalg_eigh(_w, _a, _uplo), do: nif()
274+
275+
@spec linalg_solve(worker(), tensor(), tensor()) :: tensor()
276+
def linalg_solve(_w, _a, _b), do: nif()
277+
278+
@spec linalg_solve_triangular(worker(), tensor(), tensor(), boolean()) :: tensor()
279+
def linalg_solve_triangular(_w, _a, _b, _upper), do: nif()
280+
258281
# --- Quantization ------------------------------------------------
259282

260283
@spec quantize(worker(), tensor(), integer(), integer()) ::

0 commit comments

Comments
 (0)