Block-tridiagonal solvers¶
Use this family when the unknown is partitioned into \(N\) blocks of size \(m\) and only adjacent blocks couple:
Typical sources are one-dimensional multi-field transport, radial finite volumes, line-implicit PDE methods, and spectral kinetic equations in which mode \(\ell\) couples only to \(\ell\pm1\).
Storage model¶
Input |
Shape |
Meaning |
|---|---|---|
|
|
\(L_k\); |
|
|
\(D_k\) |
|
|
\(U_k\); |
|
|
one or several right-hand sides |
The leading dimension is the structured direction. Dense physics coupling belongs inside each \(m\times m\) block.
Derivation: block Thomas elimination¶
Starting from the last block, eliminate \(x_{k+1}\). Define the Schur complements
Apply the same elimination to the right-hand side:
The remaining lower-bidiagonal system is solved upward:
SOLVAX factors every \(\Delta_k\) with dense partial-pivoting LU and applies triangular solves; it never forms \(\Delta_k^{-1}\). The factorization costs \(O(Nm^3)\) and each right-hand side costs \(O(Nm^2)\). Assembling and factoring the dense \(Nm\times Nm\) matrix would cost \(O(N^3m^3)\) and use \(O(N^2m^2)\) storage [DHS95, GVL13].
One-shot solve¶
x = sx.block_thomas(lower, diag, upper, rhs)
This is block_thomas_factor followed by block_thomas_solve. Use it when the
matrix is solved only once.
Factor once, solve many¶
factors = sx.block_thomas_factor(lower, diag, upper)
x_a = sx.block_thomas_solve(factors, rhs_a)
x_b = sx.block_thomas_solve(factors, rhs_b)
When blocks come from compact coefficients, avoid materializing the diagonal band before factorization:
def block_fn(k):
return lower_block(k), diagonal_block(k), upper_block(k)
factors = sx.block_thomas_factor_fn(block_fn, n_blocks=N)
x = sx.block_thomas_solve(factors, rhs)
x_t = sx.block_thomas_solve(factors, adjoint_rhs, transpose=True)
block_fn is evaluated once per index. The reusable factors necessarily retain
O(N m^2) LU and off-diagonal state, but no full diagonal input band is kept in
addition to the Schur factors.
BlockTridiagFactors contains:
delta_lu: LU storage for every Schur complement;delta_piv: corresponding dense pivot indices;lower,upper: off-diagonal bands used during substitution.
This split is useful for multiple forcing terms, repeated Newton corrections with a frozen Jacobian, and direct preconditioning.
Transposed solve and adjoints¶
x_t = sx.block_thomas_solve(factors, rhs, transpose=True)
For the chosen elimination order, the Schur complements of \(A^T\) are \(\Delta_k^T\). The same LU factors can therefore solve the transposed system by transposed triangular substitution; no second factorization is needed. This is the natural partner for implicit differentiation of \(A(\theta)x=b\).
Truncated low-mode solve¶
Suppose \(b_k=0\) for \(k\ge K\) and only \(x_0,\ldots,x_{K-1}\) enter an observable.
The high blocks still modify the low Schur complements, so they cannot simply
be deleted. They can, however, be eliminated without storing all high-mode
right-hand-side intermediates. block_thomas_truncated returns only the first
keep_lowest solution blocks:
x_low = sx.block_thomas_truncated(
lower, diag, upper, rhs_low, keep_lowest=3
)
Here rhs_low.shape[0] must equal keep_lowest. Peak retained substitution
storage scales as \(O(Km^2)\) rather than \(O(Nm^2)\), while the necessary downward
elimination still visits every block [Esc25].
When even the bands are too large to store, assemble them on demand:
def block_fn(k):
return lower_block(k), diagonal_block(k), upper_block(k)
x_low = sx.block_thomas_truncated_fn(
block_fn, n_blocks=N, rhs_low=rhs_low, keep_lowest=K
)
n_blocks and keep_lowest are static algorithm sizes under jit.
Each generated block index is assembled exactly once per solve. In the retained
head, the Schur update and all right-hand-side updates share one multi-column LU
solve. This matters when block assembly or dense triangular dispatch dominates.
Request a tail-aware algebraic residual without reconstructing another block:
x_low, residual_l2 = sx.block_thomas_truncated_fn_with_residual(
block_fn, n_blocks=N, rhs_low=rhs_low, keep_lowest=K,
residual_rhs_index=0,
)
This evaluates the retained Schur equations from the pivoted LU factors. It
includes the eliminated tail and does not materialize the diagonal band. Omit
residual_rhs_index to combine all right-hand sides in one RMS diagnostic.
Bounded-memory adjoint¶
The forward truncated solve is \(O(Km^2)\) in memory, but differentiating it with
plain reverse mode tapes the downward sweep over every block, so the
differentiated solve costs \(O(Nm^2)\) — the block-count independence is lost
exactly where gradient-based inversion needs it. Passing adjoint_window
selects a structure-preserving custom VJP that keeps the reverse pass bounded
too:
x_low = sx.block_thomas_truncated(
lower, diag, upper, rhs_low, keep_lowest=K, adjoint_window=w
)
Two facts make this exact where it can be and controlled where it cannot:
Right-hand-side gradient (exact). The transpose of a block-tridiagonal operator is block-tridiagonal with the off-diagonals swapped and transposed, so the cotangent map \(\bar b = P_K A^{-\top} E_K\,\bar x\) is itself a truncated solve of \(A^\top\). It runs at \(O(Km^2)\) and carries no truncation error, independent of
adjoint_window.Band gradient (windowed). The full primal and adjoint spread over all blocks but decay geometrically away from the retained head for block diagonally dominant systems [BBR13, DMS84]. Reconstructing the band gradients from a leading \((K+w)\)-block re-solve — the leading principal submatrix with a homogeneous closure — has error \(O(\rho^{2w})\) at the head, where \(\rho\in(0,1)\) is the per-block block-inverse decay rate: the dropped interface coupling is itself \(O(\rho^{w})\) small and propagates back to the head with a further \(O(\rho^{w})\). Peak reverse-mode memory is \(O((K+w)m^2)\); setting
adjoint_window >= n_blocksreproduces the exact gradient.
The result: forward and reverse run at memory independent of the block count,
so jax.grad through a truncated kinetic solve stays flat as \(N\) grows while
the naive tape grows linearly. This is the differentiable counterpart of the
truncated forward solve and the tool for adjoint-based source/transport
inversion on tall block systems.
With array bands, the bands themselves still occupy \(O(Nm^2)\); when blocks are assembled from a low-dimensional parameterization, the generated path removes that too:
def block_fn(params, k):
return lower_block(params, k), diagonal_block(params, k), upper_block(params, k)
x_low = sx.block_thomas_truncated_fn(
block_fn, n_blocks=N, rhs_low=rhs_low, keep_lowest=K,
params=params, adjoint_window=w,
)
The custom VJP then generates the transposed rows on the fly for the exact
right-hand-side gradient (three block assemblies per index) and pulls the
windowed band cotangents back through block_fn’s own derivative at each of
the leading \(K+w\) indices, so jax.grad with respect to params runs at
\(O((K+w)m^2)\) scratch — no band arrays exist in either direction, and the
reverse-mode footprint is measured flat from \(N=32\) to \(N=1024\) in the test
suite and the transport-inversion benchmark.
Residual gate¶
Validate a solve with an operator action independent of the factorization:
relative_residual = sx.block_tridiag_relative_residual(
lower, diag, upper, x, rhs
)
The diagnostic evaluates every block row, including high-mode tails. It is a numerical consistency gate, not a substitute for discretization convergence.
Assembly, factors, and transpose scope¶
block_thomas_truncated_fn calls block_fn once for each block index during a
primal solve and retains factors only for the requested low blocks. Those
truncated factors live only for that call. Use block_thomas_factor followed
by block_thomas_solve when factors must survive across right-hand sides or
when an exact transposed solve is required. A full transpose generally
propagates through the discarded high-mode tail, so SOLVAX does not claim that
an O(K) truncated factorization can provide an exact transpose action.
Mixed-precision variant¶
x = sx.mixed_precision_block_thomas(
lower, diag, upper, rhs,
factor_dtype=jnp.float32,
refine_steps=2,
)
The Schur-complement factors use low precision; working-precision residuals and defect corrections recover accuracy when the conditioning permits. See Mixed-precision iterative refinement for the convergence condition and diagnostics.
Comparison with alternatives¶
Method |
Prefer when |
Difference from block Thomas |
|---|---|---|
Scalar Thomas |
\(m=1\) or many independent scalar columns |
lower block overhead; specialized accelerator path |
Banded LU |
scalar bandwidth is small but not naturally blocked |
scalar band storage and static pivoting |
Dense LU |
only tiny systems or validation |
ignores structure and scales cubically in total size |
FGMRES + block preconditioner |
extra nonlocal couplings perturb a block-tridiagonal core |
matrix-free outer solve; direct method becomes approximate inverse |
Cyclic reduction |
massive parallelism dominates sequential sweep cost |
more parallel work and different numerical/storage trade-offs |
Failure modes¶
A singular or ill-conditioned Schur complement makes the factorization unstable.
Block LU stability is strongest for block-diagonally-dominant systems; test weakly dominant applications carefully [DHS95].
Incorrect block ordering can turn a truly local operator into apparent dense coupling. Reorder by the structured direction before giving up the method.
factor_dtype=float16orbfloat16is generally unsupported by the dense LU kernels used underneath; float32 is the practical low-precision path.
API summary¶
Runnable counterparts: examples/01_block_tridiagonal_kinetic.py,
examples/05_block_thomas_factor_solve.py, and
examples/16_mixed_precision_block_thomas.py.
From a source checkout, reproduce the kinetic-shaped CPU or accelerator benchmark with:
PYTHONPATH=src python benchmarks/benchmark_generated_block.py --output result.json
The JSON records the exact implementation hashes, JAX versions, device, cold compile time, warm samples, executable memory, and error against the materialized-band algorithm. Keep device families in separate result files; cross-device timing comparisons are otherwise not meaningful.
Measured results for this change (float64; medians, not universal hardware claims) are:
Device and workload |
v0.7.0 baseline |
Fused head solve |
Change |
|---|---|---|---|
Apple CPU, |
20.53 ms |
20.61 ms |
+0.36% |
RTX A4000, |
171.63 ms |
162.14 ms |
-5.53% |
The GPU compile time increased from 0.53 s to 0.65 s. The checked-in JSON under
benchmarks/results/ records raw samples, executable memory, software versions,
and source hashes. The change is therefore an accelerator/multi-RHS throughput
optimization with a cold-compile tradeoff, not a blanket speedup.