linalg
triwarp.linalg
Sparse linear-algebra helpers shared by the parametrization and smoothing solvers.
Two layers:
min_quad_with_fixedand its two steps (free_partition,assemble_interior_system) portigl::min_quad_with_fixed: minimize a quadratic form with some degrees of freedom pinned, by eliminating the fixed rows/columns into a reduced symmetric system.solve_spd_columnsandspd_column_solversolve one symmetric positive-definite operator against several right-hand-side columns in a single conjugate-gradient call.
Why batch the columns. Solving a k-column system as a Python loop of k independent
cg calls costs the sum of the per-column iteration counts, and every CG iteration then
launches k separate sets of reduction and AXPY kernels.
replicated_operator instead presents the same operator as
k independent subproblems over one flat k * n vector: the solver then advances all columns
together and stops on the worst-case residual, so the cost becomes max of the per-column
iteration counts with one set of vector kernels per iteration. The sparse matrix is never
replicated in memory — the matvec issues k bsr_mv calls against the single operator.
Whose conjugate gradient. One column goes to warp.optim.linear.cg; more than one goes to
this module's own _BatchedCg, which runs the same iteration and the same stopping rule. The
split is not a preference — it is the one input for which Warp's reduction degrades: batching is
expressed to Warp as batch_offsets, and that is exactly what makes its dot-product reduction
take a per-column path whose cost grows with the vector length rather than staying flat. Reducing
per column with a real two-stage tree keeps the batching and drops that cost; see _BatchedCg
for the mechanism.
Determinism. Build each operator natively at its final dtype in a single
warp.sparse.bsr_from_triplets and never recast or rebuild it. A rebuild re-sorts and
duplicate-accumulates an order the CSR already has, which is wasted work; and if the rebuild's
triplet buffers are ever sized off BsrMatrix.nnz (a stale capacity, not the true entry count —
see nnz_sync()) the buffers' tail reaches bsr_from_triplets uninitialized. Nothing here
recasts an operator, and Q_uu is assembled as a CSR directly, without any triplet build, so it
carries an exact nnz.
Why Jacobi. Every solve here preconditions with warp.optim.linear.preconditioner(A, "diag").
On a cotangent Laplacian a preconditioner costing k mat-vecs per iteration cuts the iteration
count by only about sqrt(k), so total work scales as k / sqrt(k) = sqrt(k) — single-level
preconditioning loses on this operator class, and only a multilevel method escapes it. IC(0) would
help if it were available, but Warp has no sparse triangular solve — warp.sparse exposes only
bsr_from_triplets and warp.optim.linear only the Krylov methods and a diagonal
preconditioner — and no parallel substitute for a triangular solve keeps its advantage: a fully
parallel Jacobi-sweep approximation, an exact apply parallelized by graph coloring (whose
uncoalesced access and extra launches eat most of its own iteration win), and natural-ordering
level sets (whose level count swings wildly with the input's vertex numbering) are all worse than
Jacobi in practice. Chebyshev as a single-level preconditioner is a further alternative and is also
worse; Chebyshev as the multigrid V-cycle's smoother is a separate question — see
_MULTIGRID_SWEEPS — and neither decline covers the other.
Two further obstacles are specific to this repository. Obtuse triangles give negative cotangent
weights (triwarp/kernels/laplacian.py), so -L is often not the M-matrix that IC(0) existence
requires; and both heat_geodesic and
heat_signed_distance solve a -L with a genuine constant
null space, where IC(0) hits a zero pivot on the last row of every connected component.
The multilevel option is multigrid_preconditioner:
smoothed aggregation, the one scheme that breaks the conjugate-gradient iteration count's growth
with problem size rather than paying it down by a constant factor. It is not the default, and the
reason is the setup rather than the cycle: building the hierarchy (one aggregation, one power
iteration, a bsr_transposed and three bsr_mm per level) has a real fixed cost, so the
V-cycle wins exactly where the solve it replaces is long enough to amortize that setup, and loses
on a well-conditioned or already-fast-converging system. See
CG_MULTIGRID_DOMINANCE for how the gate decides which
systems clear that bar.
A GPU sparse direct solver (cuDSS through nvmath-python and CuPy) was also considered and
declined for the same reason the multilevel preconditioner is gated: its cost is a host-side
symbolic factorization plan that is flat regardless of how well-conditioned the system is, so it
wins exactly on the systems the multigrid gate already routes to a hierarchy and loses everywhere
CG converges quickly. It would also add an optional CUDA-only dependency, and a direct solver is
singular on the empty rows CG tolerates (unreferenced free vertices, which a caller would have to
pin and restore). One idea from that investigation is worth keeping in mind for a future factor-
once, solve-many caller: reusing a factorization plan across solves of one sparsity pattern is
comparatively cheap, so a caller that resolves the same operator repeatedly (as arap already
does with its own preconditioner) is the shape that would benefit.
Nothing cheap predicts in advance which side of the multigrid-vs-Jacobi line a system falls on,
which is why preconditioner="auto" uses a capped Jacobi probe rather than a heuristic predictor
of the iteration count; see CG_PROBE_ITERATIONS.
The aggregation keeps a strength-of-connection threshold (_MULTIGRID_THETA) rather than every
off-diagonal, because on a graded (anisotropic) patch keeping every off-diagonal aggregates across
the weak direction and the hierarchy converges far more slowly.
Routing through "auto" decides from the operator, not the caller, and the axis that matters
is conditioning: only a system whose off-diagonal dominance clears
CG_MULTIGRID_DOMINANCE benefits from a hierarchy. A plain
Laplacian (tutte, min_quad_with_fixed on a raw cotangent matrix) and lscm's coupled u/v
system sit below that bar and a forced hierarchy regresses them, where a squared operator
(harmonic at k >= 2) sits comfortably above it. So
harmonic passes "auto" at k >= 2 and "diag" below,
and
min_quad_with_fixed keeps "diag" as its default rather
than becoming a second "auto" caller.
harmonic at k=2 on a strongly graded patch may not converge under Jacobi
A k=2 biharmonic operator on a strongly graded patch can be outside what
Jacobi-preconditioned conjugate gradient reaches in float64 at all: the iteration can hit
its cap and return a residual several orders of magnitude above the requested tolerance, with
nothing but a UserWarning to say so, producing a visibly wrong UV map. A caller who needs
that combination should pass a stronger preconditioner and check the warning.
That heat system M - tL heat_geodesic solves needs no
multilevel help of its own: it converges in a small, size-independent number of iterations, because
t = h**2 makes it a small perturbation of the mass matrix.
CG_TOLERANCE
module-attribute
CG_TOLERANCE = 1e-10
CG_MAXITER_FACTOR
module-attribute
CG_MAXITER_FACTOR = 10
CG_CHECK_EVERY
module-attribute
CG_CHECK_EVERY = 0
CG_CHECK_EVERY_FALLBACK
module-attribute
CG_CHECK_EVERY_FALLBACK = 10
CG_PROBE_ITERATIONS
module-attribute
CG_PROBE_ITERATIONS = 2000
CG_MULTIGRID_MIN_UNKNOWNS
module-attribute
CG_MULTIGRID_MIN_UNKNOWNS = 1500
CG_MULTIGRID_LARGE_UNKNOWNS
module-attribute
CG_MULTIGRID_LARGE_UNKNOWNS = 7000
CG_MULTIGRID_SIZE_FLOOR
module-attribute
CG_MULTIGRID_SIZE_FLOOR = 1.9
CG_MULTIGRID_DOMINANCE
module-attribute
CG_MULTIGRID_DOMINANCE = 2.15
min_quad_with_fixed
min_quad_with_fixed(
q: BsrMatrix[float64],
fixed_mask: array[bool],
fixed_values: Array2dFloat,
*,
tol: float = CG_TOLERANCE,
check_every: int = CG_CHECK_EVERY,
preconditioner: str = "diag",
) -> tuple[twt.Array2dFloat, wp.array[wp.int32], int]
Minimize a quadratic form with pinned degrees of freedom.
Eliminates the fixed degrees of freedom from 0.5 x' Q x and solves the reduced symmetric
system Q_uu x_u = -Q_ub bc for every right-hand-side column at once. Handles both shapes the
parametrization solvers need: several independent columns over n_vertices unknowns
(harmonic / tutte, one column per UV coordinate) and a single coupled column over
2 * n_vertices unknowns (lscm).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
q
|
BsrMatrix[float64]
|
|
required |
fixed_mask
|
array[bool]
|
Length- |
required |
fixed_values
|
Array2dFloat
|
|
required |
tol
|
float
|
Relative residual tolerance for the conjugate-gradient solve. |
CG_TOLERANCE
|
check_every
|
int
|
Iterations between residual tests, forwarded to
|
CG_CHECK_EVERY
|
preconditioner
|
str
|
Forwarded to |
'diag'
|
Returns:
| Name | Type | Description |
|---|---|---|
solution |
Array2dFloat
|
|
free_map |
array[int32]
|
Length- |
n_free |
int
|
Number of unpinned degrees of freedom. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Notes
The elimination is igl::min_quad_with_fixed's, without its Aeq linear-equality block --
no caller here needs one.
When every degree of freedom is pinned (n_free == 0) the prescribed values are the whole
answer: an empty solution is returned without a solve, so this case also works on the CPU.
Reconstructing the full field from solution / free_map is left to the caller's scatter.
Source code in triwarp/linalg.py
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 | |
free_partition
free_partition(
fixed_mask: array[bool],
) -> tuple[wp.array[wp.int32], int]
Compact remap of the unpinned degrees of freedom, plus their count.
Thin inversion of mask_to_compact_ranks:
fixed_mask marks the constrained degrees of freedom, and the returned map indexes the
reduced system built over the complement.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fixed_mask
|
array[bool]
|
Length- |
required |
Returns:
| Name | Type | Description |
|---|---|---|
free_map |
array[int32]
|
Length- |
n_free |
int
|
Number of unpinned degrees of freedom. |
See Also
Source code in triwarp/linalg.py
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 | |
assemble_interior_system
assemble_interior_system(
q: BsrMatrix[float64],
fixed_mask: array[bool],
free_map: array[int32],
fixed_values: Array2dFloat,
n_free: int,
) -> tuple[wps.BsrMatrix[wp.float64], twt.Array2dFloat]
Extract the free-free block Q_uu and the constant right-hand side -Q_ub bc.
A CSR-to-CSR extraction in two launches over the n_dofs rows of q: the first counts
each free row's surviving entries (and accumulates the pinned-column contributions into the
n_rhs right-hand-side rows, which needs the same row scan), a prefix sum turns those counts
into row offsets, and the second fills columns / values in place, remapped through
free_map. The result is handed to a warp.sparse.BsrMatrix directly.
Why not bsr_from_triplets
Because the input is already a CSR. Emitting q.nnz triplets and letting
bsr_from_triplets sort and duplicate-accumulate them re-derives an order the input
already had, at real cost for a large operator. Row order comes from the launch index and
column order from q's own rows through the monotone free_map, so the extracted
matrix is sorted by construction.
It also avoids a second, quieter cost: bsr_from_triplets leaves nnz at the
triplet count, which is q.nnz — an upper bound on the true entry count of a
lightly-pinned Q_uu — so every downstream bsr_mv would be dimensioned for the
unreduced matrix. The count here is exact.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
q
|
BsrMatrix[float64]
|
|
required |
fixed_mask
|
array[bool]
|
Length- |
required |
free_map
|
array[int32]
|
Compact remap from |
required |
fixed_values
|
Array2dFloat
|
|
required |
n_free
|
int
|
Number of unpinned degrees of freedom. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
q_uu |
``warp.sparse.BsrMatrix``
|
|
rhs |
Array2dFloat
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
See Also
Source code in triwarp/linalg.py
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 | |
solve_spd
solve_spd(
matrix: BsrMatrix,
rhs: array,
solution: array,
*,
tol: float = CG_TOLERANCE,
maxiter: int | None = None,
check_every: int = CG_CHECK_EVERY_FALLBACK,
preconditioner: LinearOperator | None = None,
name: str = "solve_spd",
) -> tuple[int, float, float]
Solve one symmetric positive-definite system by preconditioned conjugate gradient.
The single-right-hand-side form of
solve_spd_columns, and deliberately dtype-agnostic: it
carries scalar float64 systems as well as the wp.mat22d-block operators the
tangent-field solvers build, whose right-hand side is an array of wp.vec2d. solution is
the initial guess and is overwritten in place, so warm starting is free.
This exists so the Jacobi preconditioner and the iteration cap are decided in one place rather than re-derived at every call site.
check_every defaults to the host-side cadence here
Unlike solve_spd_columns, which defaults to
CG_CHECK_EVERY (the device-side test), this defaults to
CG_CHECK_EVERY_FALLBACK — Warp's own default —
so the return values stay host scalars. The device-side check trades a host readback for a
conditional-graph loop, which only pays off when the solve runs long enough to amortize it;
the short solves that call this (tens of iterations) run slower with it. Pass
check_every=0 where a solve is known to be long.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
matrix
|
BsrMatrix
|
Symmetric positive-(semi-)definite operator. Scalar or block dtype. |
required |
rhs
|
array
|
Right-hand side, with as many rows as |
required |
solution
|
array
|
Initial guess, overwritten with the result. Same shape and dtype as |
required |
tol
|
float
|
Relative residual tolerance. |
CG_TOLERANCE
|
maxiter
|
int | None
|
Iteration cap. When |
None
|
check_every
|
int
|
Residual-test cadence; |
CG_CHECK_EVERY_FALLBACK
|
preconditioner
|
LinearOperator | None
|
Optional Jacobi preconditioner for |
None
|
name
|
str
|
Caller name, used in the non-convergence warning. |
'solve_spd'
|
Returns:
| Type | Description |
|---|---|
tuple[int, float, float]
|
Whatever |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
When the solve exhausts |
See Also
Source code in triwarp/linalg.py
410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 | |
solve_spd_columns
solve_spd_columns(
matrix: BsrMatrix[float64],
rhs: Array2dFloat,
solution: Array2dFloat,
*,
tol: float = CG_TOLERANCE,
maxiter: int | None = None,
check_every: int = CG_CHECK_EVERY,
preconditioner: str = "diag",
) -> tuple[int, float, float]
Solve one symmetric positive-definite operator against several right-hand-side columns.
Single batched conjugate-gradient call: all n_rhs columns advance together and convergence
uses the worst-case residual, so the iteration count is the maximum over columns rather than
their sum. solution is used as the initial guess (warm starting is therefore free) and is
overwritten in place.
check_every=0 returns device arrays, not host scalars
Under the default check_every=0 nothing is ever read back, so the three returned values
are 1-element device arrays — and the second and third are the squared residual norm
and squared absolute tolerance, matching warp.optim.linear.cg. Code that formats or
compares them must call .numpy() first, which reintroduces the host sync the setting
exists to avoid. Pass a positive check_every to get host scalars back. solution is
unaffected either way.
On a device without conditional CUDA graphs there is nowhere to put the device-side test,
so check_every=0 is replaced by
CG_CHECK_EVERY_FALLBACK and host scalars come
back instead. Warp's own behaviour in that case is to run every solve to maxiter —
CG_MAXITER_FACTOR * n iterations of guaranteed waste — so substituting the cadence is
the only usable reading of the request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
matrix
|
BsrMatrix[float64]
|
|
required |
rhs
|
Array2dFloat
|
|
required |
solution
|
Array2dFloat
|
|
required |
tol
|
float
|
Relative residual tolerance, as a ratio of the right-hand-side norm. |
CG_TOLERANCE
|
maxiter
|
int | None
|
Iteration cap. Defaults to |
None
|
check_every
|
int
|
How many iterations run between residual tests. |
CG_CHECK_EVERY
|
preconditioner
|
str
|
|
'diag'
|
Returns:
| Type | Description |
|---|---|
tuple[int, float, float]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Warns:
| Type | Description |
|---|---|
UserWarning
|
When the solve exhausts |
Notes
check_every is a pure performance knob on every path but one — it cannot change the
converged answer, only how far past the tolerance the solver may overshoot before it notices.
The exception is the exactly-two-column, "diag"-preconditioned path (this module's internal
block conjugate gradient): it shares one Krylov subspace across both columns, so a positive
check_every can let a batch of iterations apply a real, coupled update to a column that
already crossed its own tolerance before the next readback catches up. Its device-side default
(0) scales with how much work a single cg call does, and the regimes disagree, so read
the one that matches the caller:
- Cold single solves — one
cgcall from a zero initial guess, the shapeharmonic/tutte/smooth_regiontake. This is the regime the default is set for, and it is a clear win there. - Warm-started solves inside an iteration loop —
arap, whose right-hand side changes every iteration so each solve still runs tens of CG iterations from the previous answer. The default is neutral to mildly positive here. - Repeated near-converged solves over one [
spd_column_solver] [triwarp.linalg.spd_column_solver] state — the same right-hand side re-solved back to back, so every call after the first converges in one or two iterations. Here the device-side check's fixed per-call cost dominates a solve that short, so it is a real loss; pass a positivecheck_everyto a state driven that way. - Raising
check_everywell above the default is a loss in every regime: the readback it saves is cheap, while the extra iterations it can cause on top of the tolerance are real work — the smaller the solve, the worse the trade.
The preconditioner is not a knob worth turning beyond "diag" / "multigrid" /
"auto" above: IC(0) and Chebyshev both come out a wash or a loss against the Jacobi
preconditioner used here. See "Why Jacobi" in the triwarp.linalg module
documentation.
The per-iteration cost of batching was a separate lever and has been taken: with more than one
column this runs triwarp's own conjugate gradient rather than warp.optim.linear's, because
Warp's reduction degrades on precisely the batched input the worst-case stopping rule needs. See
"Whose conjugate gradient" in the triwarp.linalg module documentation.
See Also
Source code in triwarp/linalg.py
512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 | |
spd_column_solver
spd_column_solver(
matrix: BsrMatrix[float64],
rhs: Array2dFloat,
solution: Array2dFloat,
*,
tol: float = CG_TOLERANCE,
maxiter: int | None = None,
check_every: int = CG_CHECK_EVERY,
preconditioner: str = "diag",
) -> wpl.LinearSolverState | _BatchedCg | _BlockCg2
Pre-allocated batched conjugate-gradient state, for repeated solves of one operator.
Same solve as solve_spd_columns, but the temporary buffers
and the batch layout are allocated once and the returned state is callable. Build it outside a
per-iteration loop and call it inside: solve_spd_columns would otherwise reallocate its
temporaries and rebuild the operator wrappers on every iteration.
rhs and solution are captured at construction, so a loop that overwrites those same
buffers in place can call the state with no arguments.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
matrix
|
BsrMatrix[float64]
|
|
required |
rhs
|
Array2dFloat
|
|
required |
solution
|
Array2dFloat
|
|
required |
tol
|
float
|
Relative residual tolerance. |
CG_TOLERANCE
|
maxiter
|
int | None
|
Iteration cap. Defaults to |
None
|
check_every
|
int
|
Iterations between residual tests; see
|
CG_CHECK_EVERY
|
preconditioner
|
str
|
|
'diag'
|
Returns:
| Type | Description |
|---|---|
``warp.optim.linear.LinearSolverState`` | internal batched state
|
Callable solver state: a single-column solve returns Warp's own
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Examples:
solver = spd_column_solver(q_uu, rhs, solution, tol=1e-10)
for _ in range(max_iterations):
... # rewrite rhs in place
solver()
See Also
Source code in triwarp/linalg.py
660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 | |
replicated_operator
replicated_operator(
matrix: BsrMatrix[float64] | LinearOperator,
n_columns: int,
) -> wpl.LinearOperator
Present one (n, n) operator as n_columns independent subproblems over a flat vector.
The returned operator acts on length-n_columns * n vectors laid out as n_columns
contiguous blocks — exactly the memory of a contiguous (n_columns, n) array — and carries
the batch_offsets that make warp.optim.linear's solvers iterate all blocks together and
converge on the worst-case residual. matrix is not replicated in memory: the matvec
applies the single operator to each block in turn.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
matrix
|
BsrMatrix[float64] | LinearOperator
|
|
required |
n_columns
|
int
|
Number of independent right-hand-side columns. |
required |
Returns:
| Type | Description |
|---|---|
``warp.optim.linear.LinearOperator``
|
Batched operator of shape |
Notes
A block-diagonal matrix would give the same batching at n_columns times the operator
memory, and 2x2 blocks holding a scalar multiple of the identity would cost four times the
storage and flops per scalar entry. Replicating only the matvec avoids both.
batch_offsets is attached and max_batch_length deliberately is not, although this
function knows the value exactly (every subproblem is n scalars): passing it to skip
redundant reduction levels for a known maximum subproblem size made no measurable difference
here, so it would be one more argument to explain for nothing. warp.optim.linear's
restart option was also considered and is worse: resetting the search direction throws away
the Krylov space CG has built, costing more iterations rather than buying better-conditioned
ones.
See Also
Source code in triwarp/linalg.py
1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 | |
multigrid_preconditioner
multigrid_preconditioner(
matrix: BsrMatrix[float64],
n_columns: int = 1,
*,
seed: int = 0,
) -> wpl.LinearOperator
Smoothed-aggregation multigrid preconditioner for a symmetric positive-semi-definite operator.
A single-level preconditioner cannot break conjugate gradient's growth in the mesh size -- see
"Why Jacobi" in the triwarp.linalg module documentation for the alternatives
that were tried and rejected -- because cutting the iteration count by sqrt(k) at k
mat-vecs per apply leaves the total work growing. A multigrid V-cycle attacks the low-frequency
error the smoother cannot see, so the iteration count grows much more slowly with the problem
size than it does under Jacobi.
The returned operator acts on length-n_columns * n vectors laid out as n_columns
contiguous blocks, exactly as replicated_operator does,
so it drops into M= beside a Jacobi preconditioner without any other change.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
matrix
|
BsrMatrix[float64]
|
|
required |
n_columns
|
int
|
Number of independent right-hand-side columns the operator will be applied to. |
1
|
seed
|
int
|
Seeds the aggregation's randomized priorities and the power iteration's start vector, so a hierarchy is a deterministic function of the operator and this number. |
0
|
Returns:
| Type | Description |
|---|---|
``warp.optim.linear.LinearOperator``
|
The V-cycle, of shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the returned operator's |
Notes
Setup is not free and it is not amortized over anything: building the hierarchy costs one
aggregation, one power iteration, two bsr_mm and one bsr_transposed per level. Ask for
this where the solve dominates the call, and pass the same operator's preconditioner into a
loop rather than rebuilding it -- solve_spd's preconditioner
parameter exists for exactly that.
The setup is host cost and it is per level, not per unknown, so it cannot be cut by making
the operator smaller. A level's cost is dominated by host-side overhead in
warp.sparse.bsr_mm, which makes device-to-host readbacks to size its output and so costs
roughly the same regardless of the level's size. That is Warp's, not this package's, so "make
the hierarchy cheap enough to run unconditionally" is not a lever available here; the reachable
version is to build fewer levels, which is what this module's _MULTIGRID_MAX_COARSE is
set for.
Coarsening stops at 128 rows, or earlier if a level fails to shrink; the coarsest operator is
then inverted densely on the host, which is exact and is a single launch inside the cycle where
an iterative coarse solve would be a data-dependent loop. When coarsening stalls while the level
is still too large to factor, there is no usable hierarchy and this hands back
warp.optim.linear.preconditioner(matrix, "diag") rather than a cycle whose coarse solve is a
guess -- so a caller never has to branch on the operator's shape.
That fallback is silent, and the strength threshold can trigger it
A stalled hierarchy is indistinguishable from a weak one at the call site: the solve simply
runs at its Jacobi iteration count. _MULTIGRID_THETA is what decides how easily it
happens -- raising it makes more off-diagonals weak, and past some threshold an operator can
stop coarsening entirely and come back at exactly the Jacobi count. So an aggregation
change that "did nothing" should be checked against the level count before it is read as a
change that did not help.
Source code in triwarp/linalg.py
1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 | |