Below you can run a simplified iterative solver (gradient descent on the residual) on a diagonal linear system whose condition number you control. Press Run without preconditioner to see how many steps the raw solver needs, then press Run with preconditioner to see it solve the same system in far fewer steps.
<!-- Sliders select condition number (kappa) and convergence tolerance -->
<div class="controls">
<label>
Condition number κ:
<input type="range" id="kappa" min="1" max="5" step="0.5" value="3">
<span id="kappa-val">1000</span>
</label>
<label>
Tolerance:
<input type="range" id="tol" min="1" max="4" step="1" value="2">
<span id="tol-val">0.01</span>
</label>
</div>
<div class="run-btns">
<button id="run-raw" type="button">Without preconditioner</button>
<button id="run-pre" type="button" class="accent">With preconditioner</button>
<button id="reset-btn" type="button" class="ghost">Reset</button>
</div>
<div id="status-line" class="status-line"></div>
<div class="chart-wrap">
<canvas id="chart" width="460" height="200"></canvas>
</div>
<div id="legend" class="legend"></div>
/* Demo styles — chart, controls, legend */
* { box-sizing: border-box; margin: 0; }
body { font-family: system-ui, sans-serif; color: #222; padding: .5rem .4rem; }
.controls { display: flex; flex-wrap: wrap; gap: .8rem; margin-bottom: .6rem; }
label { display: flex; align-items: center; gap: .4rem; font-size: .85rem; }
input[type=range] { width: 110px; }
span[id$="-val"] { font-weight: 700; min-width: 3.5rem; }
.run-btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.accent { background: #2a9d8f; border-color: #2a9d8f; }
button.ghost { background: #fff; color: #1d3557; }
.status-line { font-size: .9rem; font-weight: 600; min-height: 1.3em; margin-bottom: .3rem; }
.status-line.ok { color: #0a7d33; }
.status-line.bad { color: #c92f3c; }
.chart-wrap { border: 1px solid #cdd9e3; border-radius: 8px; overflow: hidden; background: #f8fafc; }
canvas { display: block; width: 100%; height: 200px; }
.legend { display: flex; gap: 1.2rem; font-size: .8rem; margin-top: .4rem; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: .3rem; }
.legend-dot { width: 12px; height: 4px; border-radius: 2px; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Build a diagonal linear system with a given condition number.
// A = diag(1, r, r^2, ..., r^(n-1)) where r = kappa^(1/(n-1))
// The Jacobi preconditioner M = diag(A)^{-1} exactly inverts the diagonal.
// M = diag(1/A_ii) — Jacobi preconditioner (exact inverse of diagonal A)
const N_DIM = 8; // system dimension (8 equations)
// Build diagonal A and right-hand side b
function buildSystem(kappa) {
const ratio = Math.pow(kappa, 1 / (N_DIM - 1));
const diag = [];
for (let i = 0; i < N_DIM; i++) diag.push(Math.pow(ratio, i));
// right-hand side: b_i = diag_i (so true solution x* = all-ones)
const b = diag.slice();
return { diag, b };
}
// Gradient descent for the linear system Ax = b
// Gradient descent for diag(A) x = b: x_{k+1} = x_k - step * A(Ax_k - b)
// Preconditioned variant: M*A has much lower condition number
// Preconditioned: replace A with M*A = I (since M=inv(diag(A)) and A=diag(A))
// so the preconditioned system has condition number 1 — converges in 1 step.
// Off-diagonal perturbation makes the Jacobi preconditioner approximate
// For demo realism we add a small off-diagonal perturbation so the Jacobi
// preconditioner is only approximate and we need a few iterations.
function gradientDescent(diag, b, precond, tol) {
const n = diag.length;
// start from the zero vector
let x = new Array(n).fill(0);
const residuals = [];
const maxIter = 5000;
// optimal step size for gradient descent: raw: 1/lambda_max (conservative, convergent); precond: 0.9 (near-unit spectrum)
const step = precond ? 0.9 : (1.0 / diag[n - 1]);
for (let iter = 0; iter < maxIter; iter++) {
// compute residual: r = A*x - b
let r = x.map((xi, i) => diag[i] * xi - b[i]);
// add small off-diagonal coupling for realism add tiny coupling: r[i] += 0.05 * (x[i-1] or x[i+1])
for (let i = 0; i < n; i++) {
if (i > 0) r[i] += 0.05 * x[i - 1];
if (i < n - 1) r[i] += 0.05 * x[i + 1];
}
const relErr = Math.sqrt(r.reduce((s, v) => s + v * v, 0)) /
Math.sqrt(b.reduce((s, v) => s + v * v, 0));
residuals.push(relErr);
if (relErr < tol) break;
// gradient step x = x - step * gradient
if (precond) {
// apply Jacobi preconditioner to residual M * r = r / diag (Jacobi preconditioner)
x = x.map((xi, i) => xi - step * (r[i] / diag[i]));
} else {
x = x.map((xi, i) => xi - step * r[i]);
}
}
return residuals;
}
// Canvas chart — log-scale residual curves
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const colors = { raw: '#e63946', pre: '#2a9d8f' };
let rawRes = null, preRes = null;
function getKappa() {
const v = parseFloat(document.getElementById('kappa').value);
return Math.round(Math.pow(10, v));
}
function getTol() {
const v = parseFloat(document.getElementById('tol').value);
return Math.pow(10, -v);
}
function drawChart() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// draw horizontal grid lines at each decade draw log-scale grid
ctx.strokeStyle = '#dde3ea'; ctx.lineWidth = 1;
for (let e = 0; e >= -4; e--) {
const y = H - ((-e) / 4) * (H - 20) - 10;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
const allRes = [rawRes, preRes].filter(Boolean);
if (!allRes.length) return;
const maxLen = Math.max(...allRes.map(r => r.length));
function drawLine(res, color) {
if (!res || !res.length) return;
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2;
res.forEach((v, i) => {
const x = (i / Math.max(maxLen - 1, 1)) * W;
const logV = Math.max(Math.log10(v), -4);
const y = H - ((logV + 4) / 4) * (H - 20) - 10;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
}
drawLine(rawRes, colors.raw);
drawLine(preRes, colors.pre);
}
function updateLegend() {
const leg = document.getElementById('legend');
leg.innerHTML = '';
const items = [];
if (rawRes) items.push({ color: colors.raw, label: i18nFmt('No preconditioner (%n% iter)', { n: rawRes.length }) });
if (preRes) items.push({ color: colors.pre, label: i18nFmt('Jacobi preconditioner (%n% iter)', { n: preRes.length }) });
items.forEach(({ color, label }) => {
const div = document.createElement('div');
div.className = 'legend-item';
div.innerHTML = `<div class="legend-dot" style="background:${color}"></div><span>${label}</span>`;
leg.appendChild(div);
});
}
function setStatus(msg, cls) {
const el = document.getElementById('status-line');
el.textContent = msg; el.className = 'status-line ' + (cls || '');
}
document.getElementById('kappa').addEventListener('input', function() {
document.getElementById('kappa-val').textContent = getKappa().toLocaleString();
});
document.getElementById('tol').addEventListener('input', function() {
document.getElementById('tol-val').textContent = getTol().toFixed(4).replace(/\.?0+$/, '') || '0.0001';
});
document.getElementById('run-raw').addEventListener('click', function() {
const kappa = getKappa(), tol = getTol();
const { diag, b } = buildSystem(kappa);
rawRes = gradientDescent(diag, b, false, tol);
const converged = rawRes[rawRes.length - 1] < tol;
if (converged) {
setStatus(i18nFmt('Without preconditioner: converged in %n% iterations.', { n: rawRes.length }), 'ok');
} else {
setStatus(i18nFmt('Without preconditioner: did not converge in %n% iterations.', { n: rawRes.length }), 'bad');
}
drawChart(); updateLegend();
});
document.getElementById('run-pre').addEventListener('click', function() {
const kappa = getKappa(), tol = getTol();
const { diag, b } = buildSystem(kappa);
preRes = gradientDescent(diag, b, true, tol);
const converged = preRes[preRes.length - 1] < tol;
if (converged) {
setStatus(i18nFmt('With preconditioner: converged in %n% iterations.', { n: preRes.length }), 'ok');
} else {
setStatus(i18nFmt('With preconditioner: did not converge in %n% iterations.', { n: preRes.length }), 'bad');
}
drawChart(); updateLegend();
});
document.getElementById('reset-btn').addEventListener('click', function() {
rawRes = null; preRes = null;
drawChart(); updateLegend();
setStatus('');
document.getElementById('kappa').value = 3;
document.getElementById('kappa-val').textContent = '1000';
document.getElementById('tol').value = 2;
document.getElementById('tol-val').textContent = '0.01';
});
// initialize slider display values on load initialize display
document.getElementById('kappa-val').textContent = getKappa().toLocaleString();
document.getElementById('tol-val').textContent = getTol().toFixed(4).replace(/\.?0+$/, '');
drawChart();
<!-- Sliders select condition number (kappa) and convergence tolerance -->
<div class="controls">
<label>
Condition number κ:
<input type="range" id="kappa" min="1" max="5" step="0.5" value="3">
<span id="kappa-val">1000</span>
</label>
<label>
Tolerance:
<input type="range" id="tol" min="1" max="4" step="1" value="2">
<span id="tol-val">0.01</span>
</label>
</div>
<div class="run-btns">
<button id="run-raw" type="button">Without preconditioner</button>
<button id="run-pre" type="button" class="accent">With preconditioner</button>
<button id="reset-btn" type="button" class="ghost">Reset</button>
</div>
<div id="status-line" class="status-line"></div>
<div class="chart-wrap">
<canvas id="chart" width="460" height="200"></canvas>
</div>
<div id="legend" class="legend"></div>
/* Demo styles — chart, controls, legend */
* { box-sizing: border-box; margin: 0; }
body { font-family: system-ui, sans-serif; color: #222; padding: .5rem .4rem; }
.controls { display: flex; flex-wrap: wrap; gap: .8rem; margin-bottom: .6rem; }
label { display: flex; align-items: center; gap: .4rem; font-size: .85rem; }
input[type=range] { width: 110px; }
span[id$="-val"] { font-weight: 700; min-width: 3.5rem; }
.run-btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.accent { background: #2a9d8f; border-color: #2a9d8f; }
button.ghost { background: #fff; color: #1d3557; }
.status-line { font-size: .9rem; font-weight: 600; min-height: 1.3em; margin-bottom: .3rem; }
.status-line.ok { color: #0a7d33; }
.status-line.bad { color: #c92f3c; }
.chart-wrap { border: 1px solid #cdd9e3; border-radius: 8px; overflow: hidden; background: #f8fafc; }
canvas { display: block; width: 100%; height: 200px; }
.legend { display: flex; gap: 1.2rem; font-size: .8rem; margin-top: .4rem; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: .3rem; }
.legend-dot { width: 12px; height: 4px; border-radius: 2px; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Build a diagonal linear system with a given condition number.
// A = diag(1, r, r^2, ..., r^(n-1)) where r = kappa^(1/(n-1))
// The Jacobi preconditioner M = diag(A)^{-1} exactly inverts the diagonal.
// M = diag(1/A_ii) — Jacobi preconditioner (exact inverse of diagonal A)
const N_DIM = 8; // system dimension (8 equations)
// Build diagonal A and right-hand side b
function buildSystem(kappa) {
const ratio = Math.pow(kappa, 1 / (N_DIM - 1));
const diag = [];
for (let i = 0; i < N_DIM; i++) diag.push(Math.pow(ratio, i));
// right-hand side: b_i = diag_i (so true solution x* = all-ones)
const b = diag.slice();
return { diag, b };
}
// Gradient descent for the linear system Ax = b
// Gradient descent for diag(A) x = b: x_{k+1} = x_k - step * A(Ax_k - b)
// Preconditioned variant: M*A has much lower condition number
// Preconditioned: replace A with M*A = I (since M=inv(diag(A)) and A=diag(A))
// so the preconditioned system has condition number 1 — converges in 1 step.
// Off-diagonal perturbation makes the Jacobi preconditioner approximate
// For demo realism we add a small off-diagonal perturbation so the Jacobi
// preconditioner is only approximate and we need a few iterations.
function gradientDescent(diag, b, precond, tol) {
const n = diag.length;
// start from the zero vector
let x = new Array(n).fill(0);
const residuals = [];
const maxIter = 5000;
// optimal step size for gradient descent: raw: 1/lambda_max (conservative, convergent); precond: 0.9 (near-unit spectrum)
const step = precond ? 0.9 : (1.0 / diag[n - 1]);
for (let iter = 0; iter < maxIter; iter++) {
// compute residual: r = A*x - b
let r = x.map((xi, i) => diag[i] * xi - b[i]);
// add small off-diagonal coupling for realism add tiny coupling: r[i] += 0.05 * (x[i-1] or x[i+1])
for (let i = 0; i < n; i++) {
if (i > 0) r[i] += 0.05 * x[i - 1];
if (i < n - 1) r[i] += 0.05 * x[i + 1];
}
const relErr = Math.sqrt(r.reduce((s, v) => s + v * v, 0)) /
Math.sqrt(b.reduce((s, v) => s + v * v, 0));
residuals.push(relErr);
if (relErr < tol) break;
// gradient step x = x - step * gradient
if (precond) {
// apply Jacobi preconditioner to residual M * r = r / diag (Jacobi preconditioner)
x = x.map((xi, i) => xi - step * (r[i] / diag[i]));
} else {
x = x.map((xi, i) => xi - step * r[i]);
}
}
return residuals;
}
// Canvas chart — log-scale residual curves
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const colors = { raw: '#e63946', pre: '#2a9d8f' };
let rawRes = null, preRes = null;
function getKappa() {
const v = parseFloat(document.getElementById('kappa').value);
return Math.round(Math.pow(10, v));
}
function getTol() {
const v = parseFloat(document.getElementById('tol').value);
return Math.pow(10, -v);
}
function drawChart() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// draw horizontal grid lines at each decade draw log-scale grid
ctx.strokeStyle = '#dde3ea'; ctx.lineWidth = 1;
for (let e = 0; e >= -4; e--) {
const y = H - ((-e) / 4) * (H - 20) - 10;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
const allRes = [rawRes, preRes].filter(Boolean);
if (!allRes.length) return;
const maxLen = Math.max(...allRes.map(r => r.length));
function drawLine(res, color) {
if (!res || !res.length) return;
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2;
res.forEach((v, i) => {
const x = (i / Math.max(maxLen - 1, 1)) * W;
const logV = Math.max(Math.log10(v), -4);
const y = H - ((logV + 4) / 4) * (H - 20) - 10;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
}
drawLine(rawRes, colors.raw);
drawLine(preRes, colors.pre);
}
function updateLegend() {
const leg = document.getElementById('legend');
leg.innerHTML = '';
const items = [];
if (rawRes) items.push({ color: colors.raw, label: i18nFmt('No preconditioner (%n% iter)', { n: rawRes.length }) });
if (preRes) items.push({ color: colors.pre, label: i18nFmt('Jacobi preconditioner (%n% iter)', { n: preRes.length }) });
items.forEach(({ color, label }) => {
const div = document.createElement('div');
div.className = 'legend-item';
div.innerHTML = `<div class="legend-dot" style="background:${color}"></div><span>${label}</span>`;
leg.appendChild(div);
});
}
function setStatus(msg, cls) {
const el = document.getElementById('status-line');
el.textContent = msg; el.className = 'status-line ' + (cls || '');
}
document.getElementById('kappa').addEventListener('input', function() {
document.getElementById('kappa-val').textContent = getKappa().toLocaleString();
});
document.getElementById('tol').addEventListener('input', function() {
document.getElementById('tol-val').textContent = getTol().toFixed(4).replace(/\.?0+$/, '') || '0.0001';
});
document.getElementById('run-raw').addEventListener('click', function() {
const kappa = getKappa(), tol = getTol();
const { diag, b } = buildSystem(kappa);
rawRes = gradientDescent(diag, b, false, tol);
const converged = rawRes[rawRes.length - 1] < tol;
if (converged) {
setStatus(i18nFmt('Without preconditioner: converged in %n% iterations.', { n: rawRes.length }), 'ok');
} else {
setStatus(i18nFmt('Without preconditioner: did not converge in %n% iterations.', { n: rawRes.length }), 'bad');
}
drawChart(); updateLegend();
});
document.getElementById('run-pre').addEventListener('click', function() {
const kappa = getKappa(), tol = getTol();
const { diag, b } = buildSystem(kappa);
preRes = gradientDescent(diag, b, true, tol);
const converged = preRes[preRes.length - 1] < tol;
if (converged) {
setStatus(i18nFmt('With preconditioner: converged in %n% iterations.', { n: preRes.length }), 'ok');
} else {
setStatus(i18nFmt('With preconditioner: did not converge in %n% iterations.', { n: preRes.length }), 'bad');
}
drawChart(); updateLegend();
});
document.getElementById('reset-btn').addEventListener('click', function() {
rawRes = null; preRes = null;
drawChart(); updateLegend();
setStatus('');
document.getElementById('kappa').value = 3;
document.getElementById('kappa-val').textContent = '1000';
document.getElementById('tol').value = 2;
document.getElementById('tol-val').textContent = '0.01';
});
// initialize slider display values on load initialize display
document.getElementById('kappa-val').textContent = getKappa().toLocaleString();
document.getElementById('tol-val').textContent = getTol().toFixed(4).replace(/\.?0+$/, '');
drawChart();
Comments
Loading comments...