Speed up rasterization ~40x and large solves ~2x

- Hybrid rasterizer: PIL scanline fill for the bulk, with cells in a
  ~2 px band around each ring edge re-tested exactly against the
  polygon - cell-for-cell identical to the old center-in-polygon pass
  (equivalence test added) but O(vertices + cells) instead of
  O(vertices x cells). Measured 4.5 s -> 0.11 s at 1.45M cells with
  8.8k polygon vertices.
- AMG-preconditioned CG (pyamg, new requirement) above 500k unknowns:
  measured 7.0 s vs 15.3 s spsolve at 1.4M unknowns at a fraction of
  the memory, R identical to 1e-6; the old Jacobi-CG (kept as fallback
  when pyamg is missing) needed tens of minutes there. spsolve stays
  the default below 500k where it is exact and fastest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-15 15:24:21 +07:00
parent 6e989fc5f8
commit b62e45a9b4
9 changed files with 124 additions and 23 deletions
+10 -8
View File
@@ -5,10 +5,9 @@ A future version may read overrides from <project>/fill_res_config.json.
# --- Grid sizing ---
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
# 150 um cells down to 50 um; 1.7M unknowns direct-solve in ~17 s
# (raster ~20 s). Accuracy is feature-limited (slots/necks narrower than
# one cell), not plane-limited - override CELL_UM_OVERRIDE for boards
# with sub-cell slots.
# 150 um cells down to 50 um. Accuracy is feature-limited (slots/necks
# narrower than one cell), not plane-limited - override CELL_UM_OVERRIDE
# for boards with sub-cell slots.
TARGET_CELLS = 2_000_000 # auto cell size aims for roughly this many cells
HARD_MAX_CELLS = 16_000_000 # abort above this (see GridSizeError message)
MIN_CELL_UM = 25.0 # clamp for auto cell size
@@ -51,10 +50,13 @@ CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
# (J ramps across the contact); "equipotential":
# ideal bonded lug (Dirichlet). The two bracket
# a real contact: R_equi <= R_real <= R_uniform.
SPSOLVE_MAX_UNKNOWNS = 2_500_000 # above this, use CG (Jacobi) instead of
# direct solve (measured: direct is ~14x
# faster at 1.7M unknowns, ~3 GB peak)
CG_TOL = 1e-8
SPSOLVE_MAX_UNKNOWNS = 500_000 # above this, AMG-preconditioned CG (pyamg;
# Jacobi-CG if pyamg is missing). Direct is
# exact and fastest for small grids; measured
# at 1.4M unknowns: spsolve 13 s / ~3 GB,
# AMG-CG 6 s at a fraction of the memory
AMG_TOL = 1e-10 # relative residual of the AMG-CG solve
CG_TOL = 1e-8 # Jacobi-CG fallback (no pyamg)
CG_MAXITER = 50_000 # CG iterations are cheap; large grids need many
# --- Geometry ---
+34 -8
View File
@@ -18,6 +18,7 @@ from dataclasses import dataclass
import numpy as np
from matplotlib.path import Path as MplPath
from PIL import Image, ImageDraw
from scipy import ndimage
from . import config
@@ -106,7 +107,10 @@ def choose_cell_size(bbox_nm: tuple[int, int, int, int], nlayers: int) -> float:
def _paint_ring(stack: RasterStack, ring: np.ndarray, value: bool,
target: np.ndarray) -> None:
"""Set target (2D) cells whose center lies inside ring to `value`,
testing only cells within the ring's bbox (cheap for small holes)."""
working only within the ring's bbox. Hybrid rasterizer: PIL scanline
fill for the bulk (fast, O(vertices + cells)), then the cells within
a ~2 px band around the ring edge are re-tested exactly against the
polygon, so the result is identical to a pure center-in-polygon pass."""
ny, nx = stack.shape2d
h = stack.h_nm
j0 = max(0, int((ring[:, 0].min() - stack.x0_nm) / h) - 1)
@@ -115,13 +119,35 @@ def _paint_ring(stack: RasterStack, ring: np.ndarray, value: bool,
i1 = min(ny, int((ring[:, 1].max() - stack.y0_nm) / h) + 2)
if i0 >= i1 or j0 >= j1:
return
xg, yg = stack.cell_centers(i0, i1, j0, j1)
pts = np.column_stack([xg.ravel(), yg.ravel()])
# Path(closed=True) treats the LAST vertex as the CLOSEPOLY dummy, so
# the first vertex must be appended or the ring loses its last corner
verts = np.vstack([ring, ring[:1]])
inside = MplPath(verts, closed=True).contains_points(pts)
inside = inside.reshape(i1 - i0, j1 - j0)
w, ht = j1 - j0, i1 - i0
# cell (i, j) center <-> pixel (j - j0, i - i0)
px = (ring[:, 0] - stack.x0_nm) / h - 0.5 - j0
py = (ring[:, 1] - stack.y0_nm) / h - 0.5 - i0
pts = list(zip(px.tolist(), py.tolist()))
inside = np.zeros((ht, w), dtype=bool)
band = np.ones((ht, w), dtype=bool)
if len(pts) >= 3:
fill_img = Image.new("1", (w, ht), 0)
ImageDraw.Draw(fill_img).polygon(pts, fill=1)
inside = np.array(fill_img, dtype=bool)
band_img = Image.new("1", (w, ht), 0)
ImageDraw.Draw(band_img).line(pts + pts[:1], fill=1, width=5,
joint="curve")
band = np.array(band_img, dtype=bool)
bi, bj = np.nonzero(band)
if len(bi):
xs = stack.x0_nm + (bj + j0 + 0.5) * h
ys = stack.y0_nm + (bi + i0 + 0.5) * h
# Path(closed=True) treats the LAST vertex as the CLOSEPOLY dummy,
# so the first vertex must be appended or the ring loses its last
# corner
verts = np.vstack([ring, ring[:1]])
inside[bi, bj] = MplPath(verts, closed=True).contains_points(
np.column_stack([xs, ys]))
sub = target[i0:i1, j0:j1]
sub[inside] = value
+30
View File
@@ -293,10 +293,40 @@ def solve_system(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, Solve
if n <= config.SPSOLVE_MAX_UNKNOWNS:
x = sla.spsolve(A.tocsc(), b)
return x, SolveInfo(method="spsolve", n_unknowns=n)
try:
return _solve_amg(A, b)
except ImportError:
print("note: pyamg not installed - falling back to Jacobi-CG "
"(much slower on large grids)")
return _solve_cg_jacobi(A, b)
def _solve_amg(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
"""CG preconditioned with smoothed-aggregation AMG: near-linear
scaling on these 2D Laplacians and a fraction of spsolve's memory."""
import pyamg
n = A.shape[0]
ml = pyamg.smoothed_aggregation_solver(A.tocsr(), max_coarse=500)
residuals: list[float] = []
x = ml.solve(b, tol=config.AMG_TOL, maxiter=300, accel="cg",
residuals=residuals)
res = float(np.linalg.norm(b - A @ x) / max(np.linalg.norm(b), 1e-300))
if not np.isfinite(res) or res > 1e-6:
raise SolverError(
f"AMG-CG did not converge (residual {res:.2e}). Try a "
f"different grid size, or force the direct solver by raising "
f"SPSOLVE_MAX_UNKNOWNS in config.py."
)
return x, SolveInfo(method="amg+cg", n_unknowns=n,
iterations=max(len(residuals) - 1, 0), residual=res)
def _solve_cg_jacobi(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
# The matrix is SPD, so CG is guaranteed to converge. Jacobi is the
# only preconditioner in scipy that keeps the preconditioned operator
# SPD without a factorization that can break down at this scale.
n = A.shape[0]
d = A.diagonal()
M = sla.LinearOperator((n, n), lambda v: v / d)
iters = 0
+2 -1
View File
@@ -42,7 +42,8 @@ def main(argv=None) -> int:
ap.add_argument("--extra-cu-um", type=float, default=None,
help="override the added copper in mask openings [um]")
ap.add_argument("--force-iterative", action="store_true",
help="use CG (Jacobi) regardless of problem size")
help="use the iterative solver (AMG-CG, or Jacobi-CG "
"without pyamg) regardless of problem size")
args = ap.parse_args(argv)
if args.cell_um is not None: