Builds and applies the smoothed-aggregation multigrid cycle that speeds up the conjugate-gradient solve of the tetrahedral stress model.
How the precision is kept
All matrices and vectors are Float64Array in compressed sparse row form; indexes are Int32Array. The hierarchy is built with fixed thresholds (strength 0.08, 15 power iterations with a 5 % margin, a column-drop tolerance in the per-aggregate QR, a shifted pivot in the coarse Cholesky), so the cycle is an approximate inverse by design. The file's header says it is used as a preconditioner for conjugate gradients in tetfea.js; the solve's own stopping rule is not in this file.
Calculations under this policy
8 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.
amg.jsPartCraft 0.8.164-bit8 calculationsReviewed
Tests tools/partcraft-test/test-tet.mjs
Strength of connection between nodes
64-bit
Decides which neighboring nodes are strongly coupled, from the Frobenius norms of their stiffness blocks.
Squares of the block entries are summed in a Float64Array in row order and the square root is taken once per block. The threshold is relative to the two diagonal blocks, so it scales with the stiffness units.
amg.js
lines 92 to 100
PartCraft 0.8.1
// group strength graph
const gd = new Float64Array(ng), nbr = Array.from({ length: ng }, () => []);
const acc = new Float64Array(ng), mark = new Int32Array(ng).fill(-1), lst = [];
for (let g = 0; g < ng; g++) {
lst.length = 0;
for (let r = grp[g]; r < grp[g + 1]; r++) for (let k = cur.ptr[r]; k < cur.ptr[r + 1]; k++) { const h = gof[cur.col[k]]; if (mark[h] !== g) { mark[h] = g; acc[h] = 0; lst.push(h); } acc[h] += cur.val[k] * cur.val[k]; }
for (const h of lst) { if (h === g) gd[g] = Math.sqrt(acc[h]); else nbr[g].push(h, Math.sqrt(acc[h])); }
}
const strong = nbr.map((l, g) => { const out = []; for (let i = 0; i < l.length; i += 2) if (l[i + 1] > theta * Math.sqrt(gd[g] * gd[l[i]])) out.push(l[i]); return out; });
Tentative prolongator by QR per aggregate
64-bit
Orthonormalizes the near-null vectors (the rigid motions) restricted to each aggregate to form the columns of the prolongator.
Gram–Schmidt with the projection pass run twice per column, on Float64Array copies. A column is dropped when its remaining norm is at most 1e-10 times the largest absolute entry of the aggregate times √(rows), so the test scales with the data. Entries of Q that are exactly zero are not stored.
amg.js
lines 119 to 132
PartCraft 0.8.1
const m = dofs.length, Q = Bc.map((b) => Float64Array.from(dofs, (d) => b[d])), R = [];
let scale = 0; for (const q of Q) for (const v of q) scale = Math.max(scale, Math.abs(v));
const kept = [];
for (let j = 0; j < nb; j++) {
const q = Q[j], r = new Float64Array(nb);
for (let t = 0; t < 2; t++) for (const i of kept) { let s = 0; for (let k = 0; k < m; k++) s += Q[i][k] * q[k]; r[i] += s; for (let k = 0; k < m; k++) q[k] -= s * Q[i][k]; }
let l = 0; for (let k = 0; k < m; k++) l += q[k] * q[k]; l = Math.sqrt(l);
if (l > 1e-10 * Math.max(scale, 1e-300) * Math.sqrt(m)) { for (let k = 0; k < m; k++) q[k] /= l; r[j] = l; kept.push(j); }
R.push(r);
}
// coarse dofs = kept columns; B_c rows = R rows of the kept columns (R[j][i] = component of B_j on q_i)
kept.forEach((i, ci) => { const c = nc + ci; dofs.forEach((d, k) => { if (Q[i][k] !== 0) rowsP[d].push(c, Q[i][k]); }); });
for (const i of kept) { const row = new Float64Array(nb); for (let j = 0; j < nb; j++) row[j] = R[j][i]; newB.push(row); }
nc += kept.length; cgrp.push(nc);
Largest eigenvalue of D⁻¹A
64-bit
Estimates the largest eigenvalue used to set the prolongator smoothing weight.
λ ≈ 1.05 × ‖D⁻¹A x‖ ÷ ‖x‖ after 15 power iterations
A fixed 15 iterations with no convergence test; the result is multiplied by 1.05. The start vector comes from a multiplicative generator (16807 mod 2147483647) whose products stay below 2⁵³, so it is the same on every run. A zero diagonal entry gives a zero row instead of a division by zero.
amg.js
lines 224 to 234
PartCraft 0.8.1
/** λmax of D⁻¹A by power iteration (15 steps, +5 % margin) */
function powerMax(A, diag) {
let x = new Float64Array(A.n), y = new Float64Array(A.n), lam = 1;
let s = 7; for (let i = 0; i < A.n; i++) { s = (s * 16807) % 2147483647; x[i] = s / 2147483647 - 0.5; }
for (let it = 0; it < 15; it++) {
csrMul(A, x, y); for (let i = 0; i < A.n; i++) y[i] = diag[i] ? y[i] / diag[i] : 0;
let nx = 0, ny = 0; for (let i = 0; i < A.n; i++) { nx += x[i] * x[i]; ny += y[i] * y[i]; }
lam = Math.sqrt(ny / (nx || 1)); const l = Math.sqrt(ny) || 1; for (let i = 0; i < A.n; i++) x[i] = y[i] / l;
}
return 1.05 * lam;
}
Prolongator smoothing
64-bit
Applies one damped Jacobi step to the tentative prolongator.
P = P̂ − ω D⁻¹ A P̂, ω = 4 ÷ (3 λmax)
ω comes from the power-iteration estimate. Rows whose diagonal is not positive are left unsmoothed (factor 0). The sparse product and the sum are 64-bit.
amg.js
lines 136 to 146
PartCraft 0.8.1
// smoothing: P = (I − ω D⁻¹ A) P̂
let P = Pt;
if (smooth[levels.length - 1] ?? smooth[smooth.length - 1]) {
const lam = powerMax(cur, lev.diag), w = 4 / (3 * lam);
const AP = spgemm(cur, Pt, nc);
for (let r = 0; r < cur.n; r++) { const s = lev.diag[r] > 0 ? w / lev.diag[r] : 0; for (let k = AP.ptr[r]; k < AP.ptr[r + 1]; k++) AP.val[k] *= -s; }
P = addCsr(Pt, AP);
}
const PT = transpose(P, nc);
lev.P = P; lev.PT = PT;
cur = spgemm(PT, spgemm(cur, P, nc), nc);
Sparse matrix product
64-bit
Multiplies two sparse matrices, used for A·P and Pᵀ·(A·P).
C_ij = Σ_r A_ir × B_rj
Each output entry is accumulated in a Float64Array in the order the terms are met along the row; there is no compensated summation. Entries that sum to zero are kept.
amg.js
lines 38 to 53
PartCraft 0.8.1
/** C = A·B (CSR, B with ncols columns) */
export function spgemm(A, B, ncols) {
const ptr = new Int32Array(A.n + 1), mark = new Int32Array(ncols).fill(-1), acc = new Float64Array(ncols), g = new Grow(A.col.length * 2);
const list = new Int32Array(ncols);
for (let i = 0; i < A.n; i++) {
let m = 0;
for (let k = A.ptr[i]; k < A.ptr[i + 1]; k++) {
const a = A.val[k], r = A.col[k];
for (let q = B.ptr[r]; q < B.ptr[r + 1]; q++) { const j = B.col[q]; if (mark[j] !== i) { mark[j] = i; acc[j] = 0; list[m++] = j; } acc[j] += a * B.val[q]; }
}
const l = list.subarray(0, m).sort();
for (let t = 0; t < m; t++) g.push(l[t], acc[l[t]]);
ptr[i + 1] = g.n;
}
return { n: A.n, ptr, col: g.c.slice(0, g.n), val: g.v.slice(0, g.n) };
}
Dense Cholesky on the coarsest level
64-bit
Factors the coarsest matrix and solves with it by forward and back substitution.
L Lᵀ = A_c; pivot = √s when s > 1e-12 × max diag, else √(1e-12 × max diag + |s|)
A row-major Float64Array of n × n. A pivot that is not above 1e-12 times the largest diagonal entry is replaced by a small positive value, so a singular or indefinite coarse matrix is factored without an error being raised.
amg.js
lines 150 to 165
PartCraft 0.8.1
// coarsest: dense Cholesky (with a tiny shift if singular)
const last = levels[levels.length - 1], n = last.n, Dm = new Float64Array(n * n);
for (let r = 0; r < n; r++) for (let k = last.A.ptr[r]; k < last.A.ptr[r + 1]; k++) Dm[r * n + last.A.col[k]] = last.A.val[k];
let dmax = 0; for (let i = 0; i < n; i++) dmax = Math.max(dmax, Dm[i * n + i]);
for (let i = 0; i < n; i++) {
for (let j = 0; j <= i; j++) {
let s = Dm[i * n + j]; for (let k = 0; k < j; k++) s -= Dm[i * n + k] * Dm[j * n + k];
if (i === j) Dm[i * n + i] = Math.sqrt(s > 1e-12 * dmax ? s : 1e-12 * dmax + Math.abs(s)); else Dm[i * n + j] = s / Dm[j * n + j];
}
}
const coarseSolve = (b, x) => {
for (let i = 0; i < n; i++) { let s = b[i]; for (let k = 0; k < i; k++) s -= Dm[i * n + k] * x[k]; x[i] = s / Dm[i * n + i]; }
for (let i = n - 1; i >= 0; i--) { let s = x[i]; for (let k = i + 1; k < n; k++) s -= Dm[k * n + i] * x[k]; x[i] = s / Dm[i * n + i]; }
return x;
};
const work = levels.map((l) => ({ r: new Float64Array(l.n), x: new Float64Array(l.n), b: new Float64Array(l.n) }));
Gauss–Seidel sweep
64-bit
One forward or backward relaxation sweep, the smoother of the V-cycle.
x_r = (b_r − Σ_{c≠r} A_rc x_c) ÷ A_rr
The row sum is subtracted from b term by term in column order in 64-bit. A row with a zero diagonal gets x = 0.
amg.js
lines 63 to 70
PartCraft 0.8.1
/** One Gauss–Seidel sweep on full CSR M (diagonal diag): forward (rows up) or backward (rows down); x updated in place.
* The smoother of the V-cycle (with csrMul, the only kernels the cycle runs besides the coarse Cholesky). */
export function gaussSeidel(M, diag, b, x, forward) {
const nn = M.n;
if (forward) for (let r = 0; r < nn; r++) { let s = b[r]; for (let k = M.ptr[r]; k < M.ptr[r + 1]; k++) { const c = M.col[k]; if (c !== r) s -= M.val[k] * x[c]; } x[r] = diag[r] ? s / diag[r] : 0; }
else for (let r = nn - 1; r >= 0; r--) { let s = b[r]; for (let k = M.ptr[r]; k < M.ptr[r + 1]; k++) { const c = M.col[k]; if (c !== r) s -= M.val[k] * x[c]; } x[r] = diag[r] ? s / diag[r] : 0; }
return x;
}
V-cycle
64-bit
Applies the preconditioner: smooth, restrict the residual, solve the coarse problem, add the correction, smooth again.
x ← S(b); r = b − A x; x ← x + P × cycle(Pᵀ r); x ← Sᵀ(b)
Work vectors are Float64Array per level. One forward sweep before and one backward sweep after the coarse correction, which keeps the operator symmetric as the header comment states.
amg.js
lines 167 to 179
PartCraft 0.8.1
const cycle = (l, b, x) => {
const lev = levels[l];
if (l === levels.length - 1) return coarseSolve(b, x);
x.fill(0);
gs(lev, b, x, true);
const r = work[l].r; csrMul(lev.A, x, r); for (let i = 0; i < lev.n; i++) r[i] = b[i] - r[i];
const bc = work[l + 1].b, xc = work[l + 1].x;
csrMul(lev.PT, r, bc);
cycle(l + 1, bc, xc);
const e = work[l].r; csrMul(lev.P, xc, e); for (let i = 0; i < lev.n; i++) x[i] += e[i];
gs(lev, b, x, false);
return x;
};
In detail
Method
Smoothed-aggregation algebraic multigrid; the file's header cites Vaněk, Mandel and Brezina 1996. Strength of connection by block Frobenius norm, greedy aggregation, tentative prolongator by QR of the near-null vectors per aggregate, prolongator smoothing P = (I − ω D⁻¹A) P̂ with ω = 4 ÷ (3 λmax), Galerkin coarse matrix Pᵀ A P, dense Cholesky on the coarsest level, V-cycle with one forward and one backward Gauss–Seidel sweep.
Number types
64-bit numbers and Float64Array for all values; Int32Array for row pointers, columns and aggregate numbers.
Tolerances
Strength threshold θ = 0.08 relative to √(‖A_II‖ ‖A_JJ‖). QR column kept when its norm exceeds 1e-10 × (largest absolute entry in the aggregate, at least 1e-300) × √(rows). Coarse Cholesky pivot replaced when it is not above 1e-12 × the largest diagonal entry. Coarsening stops at 600 unknowns or 12 levels.
Limits
λmax is an estimate from 15 power iterations multiplied by 1.05. The prolongator is smoothed on the finest level only by default. A singular coarse matrix is shifted rather than reported.
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.