Sparse linalg: spectral routines#
mlx_sparse.linalg provides three native sparse spectral routines that
compute only the k extreme eigenpairs or singular triplets, not the full
decomposition. This is the main advantage over dense mx.linalg.eigh /
mx.linalg.svd, which always compute all values.
Routine |
Method |
Matrix |
Returns |
|---|---|---|---|
|
Native CSR Lanczos |
Hermitian/symmetric |
|
|
Native CSR Arnoldi |
Any square |
|
|
Normal-operator Lanczos |
Any rectangular |
|
The which parameter selects which extremal eigenvalues to compute:
"LM" (Largest Magnitude, default) or "SM" (Smallest Magnitude).
import mlx.core as mx
import numpy as np
import mlx_sparse as ms
from mlx_sparse import linalg
Test matrix: sparse graph Laplacian#
The graph Laplacian of a path graph on n nodes is a tridiagonal symmetric
positive semi-definite matrix. Its eigenvalues are
2 - 2*cos(π*k/n) for k = 0, …, n-1, giving a known reference.
import scipy.sparse
n = 20
# Path-graph Laplacian: tridiagonal with 2 on diagonal and -1 on off-diagonals
lap = scipy.sparse.diags(
[[-1.0]*(n-1), [2.0]*n, [-1.0]*(n-1)],
[-1, 0, 1], shape=(n, n), dtype=np.float32,
).tocsr()
# The smallest eigenvalue is 0 (constant eigenvector), add small shift to make it PD
lap_pd = lap + scipy.sparse.eye(n, dtype=np.float32) * 0.1
A = ms.csr_array(
(mx.array(lap_pd.data), mx.array(lap_pd.indices), mx.array(lap_pd.indptr)),
shape=(n, n), canonical=True,
)
print(f"shape={A.shape}, nnz={A.nnz}")
# Dense reference eigenvalues
ref_vals = np.linalg.eigvalsh(lap_pd.toarray())
print(f"True eigenvalues (sorted): {np.round(np.sort(ref_vals), 4)}")
shape=(20, 20), nnz=58
True eigenvalues (sorted): [0.1223 0.1889 0.2981 0.4475 0.6339 0.853 1.1 1.3693 1.655 1.9505
2.2495 2.545 2.8307 3.1 3.347 3.5661 3.7525 3.9019 4.0111 4.0777]
eigsh: largest eigenvalues (Lanczos)#
eigsh(A, k=6, which='LM') runs the native Lanczos algorithm and returns the
k eigenvalues of largest magnitude together with their eigenvectors. The
ncv parameter (Krylov subspace size) controls accuracy vs. cost.
k = 4
vals_lm, vecs_lm = linalg.eigsh(A, k=k, which="LM", ncv=30)
mx.eval(vals_lm, vecs_lm)
print(f"Largest {k} eigenvalues (Lanczos): {np.sort(np.array(vals_lm))[::-1]}")
print(f"True largest {k}: {np.sort(ref_vals)[::-1][:k]}")
Largest 4 eigenvalues (Lanczos): [4.0776653 4.011147 3.9019392 3.7524784]
True largest 4: [4.0776615 4.0111456 3.9019377 3.7524774]
# Verify eigenpairs: A @ v approx. λ * v for each returned pair
vals_np = np.array(vals_lm)
vecs_np = np.array(vecs_lm)
A_dense = lap_pd.toarray()
for i in range(k):
v = vecs_np[:, i]
lam = vals_np[i]
residual = np.linalg.norm(A_dense @ v - lam * v)
print(f" λ={lam:.4f} ||Av - λv||={residual:.2e}")
λ=4.0777 ||Av - λv||=3.85e-06
λ=4.0111 ||Av - λv||=1.72e-06
λ=3.9019 ||Av - λv||=1.98e-06
λ=3.7525 ||Av - λv||=1.45e-06
eigsh: smallest eigenvalues#
Pass which='SM' to request the smallest-magnitude eigenvalues. These
correspond to the lowest-frequency modes of the graph Laplacian.
vals_sm, _ = linalg.eigsh(A, k=k, which="SM", ncv=30)
mx.eval(vals_sm)
print(f"Smallest {k} eigenvalues (Lanczos): {np.sort(np.array(vals_sm))}")
print(f"True smallest {k}: {np.sort(ref_vals)[:k]}")
Smallest 4 eigenvalues (Lanczos): [0.1223383 0.1888545 0.2980625 0.44752267]
True smallest 4: [0.12233825 0.18885429 0.29806218 0.44752234]
eigs: general sparse Arnoldi#
eigs uses the Arnoldi factorization and works for non-symmetric matrices.
It returns complex eigenvalues in general; for symmetric matrices the imaginary
parts are negligible.
vals_arnoldi, vecs_arnoldi = linalg.eigs(A, k=k, which="LM", ncv=30)
mx.eval(vals_arnoldi)
vals_real = np.array(vals_arnoldi)
print(f"Arnoldi eigenvalues: {np.round(np.sort(np.abs(vals_real))[::-1], 4)}")
print(f"Lanczos eigenvalues: {np.round(np.sort(np.abs(np.array(vals_lm)))[::-1], 4)}")
Arnoldi eigenvalues: [4.0775 4.0113 3.9019 3.7525]
Lanczos eigenvalues: [4.0777 4.0111 3.9019 3.7525]
svds: sparse singular value decomposition#
svds applies Lanczos to the normal operator A.T @ A without
materializing the product. It accepts rectangular matrices and returns
(u, s, vh) where u (left singular vectors), s (singular values),
and vh (right singular vectors, transposed) satisfy u @ diag(s) @ vh ≈ A.
# Rectangular sparse matrix for svds
import scipy.sparse
m, nc = 30, 20
rng = np.random.default_rng(99)
rect = scipy.sparse.random(m, nc, density=0.15, format="csr", dtype=np.float32, random_state=rng)
B = ms.csr_array(
(mx.array(rect.data), mx.array(rect.indices), mx.array(rect.indptr)),
shape=(m, nc), canonical=True,
)
print(f"B shape={B.shape}, nnz={B.nnz}")
k_sv = 3
u, s, vh = linalg.svds(B, k=k_sv, which="LM", ncv=30)
mx.eval(u, s, vh)
s_ref = np.linalg.svd(rect.toarray(), compute_uv=False)
print(f"Sparse svds top-{k_sv}: {np.round(np.sort(np.array(s))[::-1], 4)}")
print(f"Dense SVD top-{k_sv}: {np.round(s_ref[:k_sv], 4)}")
B shape=(30, 20), nnz=90
Sparse svds top-3: [2.6716 2.0926 1.9927]
Dense SVD top-3: [2.6716 2.0926 1.9927]
# Verify singular triplets: B @ vh[i] approx. s[i] * u[i]
s_np = np.array(s)
u_np = np.array(u)
vh_np = np.array(vh)
B_dense = rect.toarray()
for i in range(k_sv):
err = np.linalg.norm(B_dense @ vh_np[i] - s_np[i] * u_np[:, i])
print(f" s={s_np[i]:.4f} ||B @ v_i - s_i * u_i||={err:.2e}")
s=2.6716 ||B @ v_i - s_i * u_i||=1.87e-07
s=2.0926 ||B @ v_i - s_i * u_i||=8.71e-08
s=1.9927 ||B @ v_i - s_i * u_i||=5.36e-08
Comparing with dense: speedup depends on k/n#
Sparse spectral routines are advantageous when k << n. For large n
and small k, the Lanczos/Arnoldi approach avoids the O(n³) cost of
full dense decomposition. The crossover point depends on matrix sparsity
and hardware; for k=4, n=20 the system is too small to see a speedup.
import time
# use CPU since MLX's eigh is not supported on GPU yet
ms.use_cpu()
def timer(fn, iters=10):
for _ in range(3):
mx.eval(fn())
t0 = time.perf_counter()
for _ in range(iters):
mx.eval(fn())
return 1000 * (time.perf_counter() - t0) / iters
A_dense_mx = mx.array(lap_pd.toarray())
sparse_ms = timer(lambda: linalg.eigsh(A, k=k, which="LM")[0])
dense_ms = timer(lambda: mx.linalg.eigh(A_dense_mx)[0])
print(f"eigsh (k={k}, n={n}): {sparse_ms:.3f} ms")
print(f"dense eigh (all n): {dense_ms:.3f} ms")
print(f"speedup: {dense_ms/sparse_ms:.1f}x")
eigsh (k=4, n=20): 0.016 ms
dense eigh (all n): 0.086 ms
speedup: 5.3x