Resources Numerical accuracy Calculation policy

Multigrid preconditioner for stress analysis

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.js PartCraft 0.8.1 64-bit 8 calculations Reviewed

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.

strong when ‖A_IJ‖ > θ × √(‖A_II‖ × ‖A_JJ‖), θ = 0.08

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.

q_j ← b_j − Σᵢ (qᵢ·b_j) qᵢ (twice); keep when ‖q_j‖ > 1e-10 × scale × √m

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.

Every policy that takes this approach