CraftScript iterative solvers and sparse eigenvalues
Solves large sparse systems by iteration in a script and finds the smallest eigenvalues of a sparse problem.
How the precision is kept
Arithmetic is 64-bit throughout, but the answer is only as close as the stopping tolerance asks. BiCGSTAB stops on the recurrence residual relative to the right-hand side. The eigenvalue routine stops when successive eigenvalue estimates agree to a relative tolerance, and returns the last estimate when the iteration cap is reached.
Open item. Make eigs report whether it converged, and check the convergence of its inner PCG solves.
Calculations under this policy
6 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
Solves a symmetric positive definite sparse system with the pcg builtin.
This function builds the preconditioner and passes the tolerance (1e-8 by default) to csrPcg in thermal.js, which does the iteration and reports the residual and whether it converged. The iteration itself is not in this file.
Scales a residual by the inverse of the matrix diagonal.
zᵢ = rᵢ ÷ A_ii
A diagonal entry that is exactly zero is replaced by 1, so there is no division by zero. A preconditioner changes the number of iterations, not the stopping tolerance.
cs-lib-linalg.js
lines 337 to 341
PartCraft 0.8.1
// ─────────────────────────────────────────────────────────────── iterative solvers and preconditioners
function jacobiPrec(S) {
const d = spDiag(S), inv = d.map((v) => (v ? 1 / v : 1));
return { kind: 'jacobi', apply(r, z) { for (let i = 0; i < r.length; i++) z[i] = r[i] * inv[i]; return z; } };
}
Incomplete LU factorization
64-bit
The ILU(0) preconditioner used by BiCGSTAB.
Factors on the matrix's own pattern in a Float64Array copy. A missing diagonal entry or a pivot that is exactly zero raises an error; a very small non-zero pivot is used as it is.
cs-lib-linalg.js
lines 379 to 403
PartCraft 0.8.1
/** ILU(0) of a square CSR with sorted columns → {apply}. */
export function ilu0(S) {
const { n, rowPtr, col } = S, LU = Float64Array.from(S.val), diag = new Int32Array(n).fill(-1);
for (let r = 0; r < n; r++) for (let k = rowPtr[r]; k < rowPtr[r + 1]; k++) if (col[k] === r) diag[r] = k;
for (let r = 0; r < n; r++) if (diag[r] < 0) throw err(t('script.linalg.ilu0_diag'));
const pos = new Int32Array(n).fill(-1);
for (let i = 0; i < n; i++) {
for (let k = rowPtr[i]; k < rowPtr[i + 1]; k++) pos[col[k]] = k;
for (let k = rowPtr[i]; k < rowPtr[i + 1] && col[k] < i; k++) {
const j = col[k], piv = LU[diag[j]];
if (piv === 0) throw err(t('script.linalg.ilu0_pivot'));
const f = (LU[k] /= piv);
for (let q = diag[j] + 1; q < rowPtr[j + 1]; q++) { const p = pos[col[q]]; if (p >= 0) LU[p] -= f * LU[q]; }
}
for (let k = rowPtr[i]; k < rowPtr[i + 1]; k++) pos[col[k]] = -1;
}
return {
kind: 'ilu0',
apply(r, z) {
for (let i = 0; i < n; i++) { let s = r[i]; for (let k = rowPtr[i]; k < diag[i]; k++) s -= LU[k] * z[col[k]]; z[i] = s; }
for (let i = n - 1; i >= 0; i--) { let s = z[i]; for (let k = diag[i] + 1; k < rowPtr[i + 1]; k++) s -= LU[k] * z[col[k]]; z[i] = s / LU[diag[i]]; }
return z;
},
};
}
BiCGSTAB
64-bit
Solves a general sparse system by iteration.
stop when ‖r‖ ÷ ‖b‖ < tol
The residual is the recurrence vector, measured relative to the norm of b (1 when b is zero); it is not recomputed from b − A x at the end. The loop ends at the tolerance, at the iteration cap, or when ρ or ω is exactly zero; the divisions by (r̂·v) and (t·t) are not guarded. The result carries a converged flag.
cs-lib-linalg.js
lines 404 to 433
PartCraft 0.8.1
/** Right-preconditioned BiCGSTAB (van der Vorst 1992). */
export function bicgstabSolve(S, b, { tol = 1e-8, maxit = 0, prec = 'ilu0', x0 = null, check = () => {} } = {}) {
const n = S.n, t0 = Date.now();
const M = prec === 'ilu0' ? ilu0(S) : prec === 'jacobi' ? jacobiPrec(S) : { kind: 'none', apply(r, z) { z.set(r); return z; } };
const x = x0 ? Float64Array.from(x0) : new Float64Array(n), r = new Float64Array(n), rh = new Float64Array(n), p = new Float64Array(n), v = new Float64Array(n);
const s = new Float64Array(n), t = new Float64Array(n), ph = new Float64Array(n), sh = new Float64Array(n);
const dot = (a, c) => { let q = 0; for (let i = 0; i < n; i++) q += a[i] * c[i]; return q; };
spMulVec(S, x, r); for (let i = 0; i < n; i++) r[i] = b[i] - r[i];
rh.set(r);
const bn = Math.sqrt(dot(b, b)) || 1;
let rho = 1, alpha = 1, omega = 1, it = 0, res = Math.sqrt(dot(r, r)) / bn;
const cap = maxit || Math.max(1000, 2 * n);
for (; it < cap && res >= tol; it++) {
if ((it & 31) === 0) check();
const rho1 = dot(rh, r);
if (rho1 === 0) break;
const beta = (rho1 / rho) * (alpha / omega); rho = rho1;
for (let i = 0; i < n; i++) p[i] = r[i] + beta * (p[i] - omega * v[i]);
M.apply(p, ph); spMulVec(S, ph, v);
alpha = rho / dot(rh, v);
for (let i = 0; i < n; i++) s[i] = r[i] - alpha * v[i];
if (Math.sqrt(dot(s, s)) / bn < tol) { for (let i = 0; i < n; i++) x[i] += alpha * ph[i]; res = Math.sqrt(dot(s, s)) / bn; it++; break; }
M.apply(s, sh); spMulVec(S, sh, t);
omega = dot(t, s) / dot(t, t);
for (let i = 0; i < n; i++) { x[i] += alpha * ph[i] + omega * sh[i]; r[i] = s[i] - omega * t[i]; }
res = Math.sqrt(dot(r, r)) / bn;
if (omega === 0) break;
}
return { x, iterations: it, residual: res, converged: res < tol, prec: M.kind, ms: Date.now() - t0 };
}
Rigid-body vectors for AMG
64-bit
Builds the translation and rotation vectors the multigrid preconditioner uses.
rotation about the centroid: (−(y − c_y), x − c_x)
The centroid is a 64-bit running sum of coordinate ÷ node count. These vectors shape the preconditioner only and do not enter the solution's stopping test.
cs-lib-linalg.js
lines 342 to 354
PartCraft 0.8.1
/** Near-null space for AMG: constants per dof direction, plus rotations when node coordinates are given. */
function nearNull(n, dpn, coords) {
const B = [];
for (let d = 0; d < dpn; d++) { const v = new Float64Array(n); for (let i = d; i < n; i += dpn) v[i] = 1; B.push(v); }
if (coords && (dpn === 2 || dpn === 3)) {
const nn = n / dpn, X = coords;
if (X.length < nn * dpn) throw err(t('script.linalg.pcg_coords', { dpn, nn }));
let c = [0, 0, 0]; for (let i = 0; i < nn; i++) for (let a = 0; a < dpn; a++) c[a] += X[i * dpn + a] / nn;
if (dpn === 2) { const v = new Float64Array(n); for (let i = 0; i < nn; i++) { v[2 * i] = -(X[2 * i + 1] - c[1]); v[2 * i + 1] = X[2 * i] - c[0]; } B.push(v); }
else for (const [a, b] of [[0, 1], [1, 2], [2, 0]]) { const v = new Float64Array(n); for (let i = 0; i < nn; i++) { v[3 * i + a] = -(X[3 * i + b] - c[b]); v[3 * i + b] = X[3 * i + a] - c[a]; } B.push(v); }
}
return B;
}
Smallest eigenvalues of a sparse problem
64-bit
The k smallest eigenvalues and vectors of K v = λ M v.
stop when |λⱼ − λⱼ(previous)| ≤ tol × |λⱼ| for every j ≤ k
Up to 400 unknowns the dense routine is used. Above that, subspace iteration with max(2k, k + 8) vectors starts from a fixed pseudo-random sequence and stops on the relative change of the eigenvalues (1e-10 by default). If 300 iterations pass without meeting the tolerance the last estimates are returned without an error or a flag; when the factorization does not fit, the inner PCG solves use tolerance 1e-12 and their convergence is not checked.
cs-lib-linalg.js
lines 684 to 719
PartCraft 0.8.1
// ─────────────────────────────────────────────────────────────── eigs (small symmetric generalized)
export function eigsSolve(K, M, k, { sigma = 0, tol = 1e-10, maxit = 300, check = () => {} } = {}) {
const n = isSparse(K) ? K.n : size2(K)[0];
if (!Number.isInteger(k) || k < 1 || k > n) throw err(t('script.linalg.eigs_k', { n }));
if (M && (isSparse(M) ? M.n : size2(M)[0]) !== n) throw err(t('script.linalg.eigs_size'));
if (n <= DENSE_EIG) {
const Kd = isSparse(K) ? toDense(K) : asTensor(K), Md = M ? (isSparse(M) ? toDense(M) : asTensor(M)) : null;
const { d, V } = Md ? symGenEigen(Kd, Md) : symEigen(Kd);
return { d: d.slice(0, k), V: V.slice(0, n * k), iterations: 0, method: 'dense' };
}
const Ks = isSparse(K) ? K : fromDense(asTensor(K)), Ms = M ? (isSparse(M) ? M : fromDense(asTensor(M))) : null;
const shifted = sigma ? spAdd(Ks, Ms || speyeS(n), 1, -sigma) : Ks;
let F = envelopeCholesky(shifted, { check });
const solveK = F ? (b, x) => F.solve(b, x) : (() => { const P = makePrec(shifted, 'auto'); return (b, x) => { const r = pcgSolve(shifted, b, { tol: 1e-12, prec: P, check }); x.set(r.x); return x; }; })();
const Mmul = Ms ? (x, y) => spMulVec(Ms, x, y) : (x, y) => { y.set(x); return y; };
const p = Math.min(n, Math.max(2 * k, k + 8));
let X = new Float64Array(n * p);
let s = 12345; for (let i = 0; i < X.length; i++) { s = (s * 16807) % 2147483647; X[i] = s / 2147483647 - 0.5; }
let lam = new Float64Array(k).fill(Infinity), it = 0;
const Y = new Float64Array(n * p), tw = new Float64Array(n), u = new Float64Array(n);
for (; it < maxit; it++) {
check();
for (let j = 0; j < p; j++) { Mmul(X.subarray(j * n, (j + 1) * n), tw); solveK(tw, Y.subarray(j * n, (j + 1) * n)); }
// Rayleigh–Ritz: Kr = Yᵀ(K−σM)Y, Mr = YᵀMY
const Kr = new Float64Array(p * p), Mr = new Float64Array(p * p), KY = new Float64Array(n * p), MY = new Float64Array(n * p);
for (let j = 0; j < p; j++) { spMulVec(shifted, Y.subarray(j * n, (j + 1) * n), u); KY.set(u, j * n); Mmul(Y.subarray(j * n, (j + 1) * n), u); MY.set(u, j * n); }
for (let a = 0; a < p; a++) for (let b = a; b < p; b++) { let s1 = 0, s2 = 0; for (let i = 0; i < n; i++) { const ya = Y[a * n + i]; s1 += ya * KY[b * n + i]; s2 += ya * MY[b * n + i]; } Kr[a + b * p] = Kr[b + a * p] = s1; Mr[a + b * p] = Mr[b + a * p] = s2; }
const { d, V: Q } = symGenEigen(mat(p, p, Kr), mat(p, p, Mr));
X = new Float64Array(n * p);
for (let j = 0; j < p; j++) for (let a = 0; a < p; a++) { const q = Q[a + j * p]; if (q) for (let i = 0; i < n; i++) X[j * n + i] += Y[a * n + i] * q; }
let done = true;
for (let j = 0; j < k; j++) { if (!(Math.abs(d[j] - lam[j]) <= tol * Math.max(1e-300, Math.abs(d[j])))) done = false; lam[j] = d[j]; }
if (done) break;
}
return { d: Float64Array.from(lam, (v) => v + sigma), V: X.slice(0, n * k), iterations: it + 1, method: F ? 'subspace iteration (envelope Cholesky)' : 'subspace iteration (pcg)' }; // i18n-ignore: method names scripts read
}
In detail
Method
Right-preconditioned BiCGSTAB (the code cites van der Vorst 1992) with ILU(0) or Jacobi; preconditioned conjugate gradients from thermal.js with IC(0), smoothed-aggregation AMG from amg.js, Jacobi or none; subspace iteration with a Rayleigh-Ritz step for eigenvalues.
Number types
64-bit numbers and Float64Array.
Tolerances
pcg and bicgstab: relative residual 1e-8 by default (1e-10 when called by the backslash operator). BiCGSTAB iteration cap: max(1000, 2n) unless given. eigs: relative change 1e-10, at most 300 iterations; its inner PCG solves use 1e-12. AMG is chosen from 60000 unknowns.
Limits
BiCGSTAB reports the residual of its recurrence, not a recomputed b − A x. eigs has no convergence flag.
Approach: 64-bit, stated limit
The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.