Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Linear Solvers

Every optimizer iteration reduces to one linear solve. This chapter documents the backends in src/linalg/: what each one computes, the equations as implemented, and how to configure them. Formulas name the module they come from.

Two traits define the contract (linalg/mod.rs):

MethodReturns
solve_normal_equation(r, J) solving
solve_augmented_equation(r, J, damping) solving
hessian_vec_product(v) for the un-damped
get_gradient() — note the sign
get_hessian() when the backend happens to hold one; None is valid

get_hessian() returning None is not a failure. A backend that eliminates straight from never materialises , and callers must degrade to hessian_vec_product rather than error — that is what makes the matrix-free paths expressible.

JacobianMode picks the assembly: Sparse (default) or Dense (below ~500 DOF). LinearSolverType then picks the algorithm within that mode, and the optimizer rejects mismatched combinations (levenberg_marquardt.rs).

Normal equations

For the non-Schur backends, and are formed once per iteration (sparse/normal_eq.rs). The sparsity of , of , and the value permutation linking them are cached across iterations, so each evaluation is a parallel value gather plus faer’s parallel sparse product. The cache is keyed on ’s sparsity, which must not change between iterations.

Sparse Cholesky

SparseCholeskySolver — factorization of (or ). Symbolic factorization cached, numeric pass repeated. The default for pose graphs.

Sparse QR

SparseQRSolver — factorizes directly rather than forming , which squares the condition number. More robust on ill-conditioned problems, at a higher per-iteration cost.

Dense Cholesky / QR

DenseCholeskySolver, DenseQRSolver — for JacobianMode::Dense. Both route their SPD solves through one shared solve_spd helper (dense/cholesky.rs) so the factorization and error mapping exist in exactly one place.


The Schur complement

Why

In bundle adjustment most variables are landmarks. Ladybug has 1,723 cameras and 156,502 landmarks: 15,507 retained DOF against 469,506 eliminated. Landmarks are mutually unconnected — no factor touches two of them — so the landmark block of the Hessian is block-diagonal and can be eliminated cheaply, leaving a system in the cameras alone.

The algebra

Partition the variables into an eliminated set (e, Ceres’s “group 0”) and a retained set (k, “group 1”). The system becomes (linalg/schur/partition.rs):

Eliminating gives the reduced system:

and back-substitution recovers the eliminated half:

Nothing here mentions cameras or points — the partition is agnostic to what the variables mean, which is why the same code covers classic BA, inverse-depth parameterisations, LiDAR features and marginalization.

The precondition, and how it is enforced

The elimination is exact only when is block-diagonal, i.e. the eliminated variables are mutually unconnected. Violating it yields a wrong step with no other symptom, so it is checked rather than assumed:

  • Solvers that form call SchurPartition::verify_block_diagonal (partition.rs) — one pass over the eliminated columns’ nonzeros.
  • The matrix-free path never forms , so it checks against ’s rows instead: a residual row touching two eliminated variables is the same violation (sparse/schur/implicit.rs, ensure_structure).

Both report a typed error naming the two offending variables.

Damping under Schur

is applied to both blocks before elimination — damping the reduced system instead would not be the same problem (levenberg_marquardt.rs, and explicit.rs):

Landmark block inversion

is formed blockwise. Every Schur solver shares one regularized-retry policy, EliminatedBlocks::invert_in_place (partition.rs), so a landmark is regularized identically regardless of which solver eliminated it.

Choosing what to eliminate

SchurOrdering (linalg/schur/ordering.rs) classifies variables. Manual marks always apply:

#![allow(unused)]
fn main() {
problem.mark_for_elimination(landmark_key);
}

Auto-classification by manifold type and DOF is available but off by default:

#![allow(unused)]
fn main() {
let ordering = SchurOrdering::new().with_auto_detect(true);
}

The default is off for a specific reason (ordering.rs): Rn(3) is also how self-calibration represents intrinsics [focal, k1, k2]. Auto-detecting “any Rn(3) is a landmark” would silently eliminate intrinsic blocks and corrupt . Turn it on only when you know every Rn(3) in the problem is a landmark.


The four Schur solvers

LinearSolverTypeSub-configModeCeres equivalent
ExplicitSparseSchurExplicitSchurVariant::SparseSparseSPARSE_SCHUR
ExplicitSparseSchurExplicitSchurVariant::ChunkedSparseSchurEliminator
ExplicitSparseSchurExplicitSchurVariant::IterativeSparseITERATIVE_SCHUR + use_explicit_schur_complement
ImplicitSparseSchurpreconditionerSparseITERATIVE_SCHUR
ExplicitDenseSchur—DenseDENSE_SCHUR

ExplicitSchurVariant::Sparse

Forms , builds explicitly, factorizes with sparse Cholesky. The most accurate: it solves the reduced system exactly, and is the reference the other paths are measured against.

is accumulated into a dense buffer and filtered back to sparse (explicit.rs). That buffer is the current scaling limit — 1.9 GB at Ladybug’s 15,507 retained DOF.

ExplicitSchurVariant::Chunked

Algebraically identical to Sparse — the benchmark confirms bit-identical RMSE on all four datasets — but builds chunk by chunk directly from , never forming , , or the value permutation. On the largest BAL problem those account for ~24 GB of the ~32 GB needed before elimination can start.

Per chunk (the rows sharing one eliminated variable), with and :

This trades time for memory and is slower in wall clock — 1.2×–2.1× the Sparse variant. The chunk sweep is inherently serial: reading rows out of a column-major without building a CSR copy relies on one forward-only cursor per retained column, which forces chunks to be visited in increasing row order. The path it replaces is fully parallel, so on a many-core machine the lost parallelism outweighs the saved work. Requires each eliminated variable’s rows to be contiguous, which Problem::group_rows_for_elimination arranges.

ExplicitSchurVariant::Iterative

Forms explicitly like Sparse, then solves it with PCG instead of Cholesky. Carries the same memory cost while solving less exactly, so it buys nothing on memory — but it measures 0.5×–1.3× Sparse on time.

ImplicitSparseSchur — the matrix-free path

Forms neither nor . Writing , PCG only ever needs ’s action on a vector, and expanding that action so it reads alone gives the whole solver (implicit.rs):

Four passes over ’s nonzeros, no intermediate matrix. This is why it exists: for BA carries a dense block for every pair of cameras sharing a landmark — fill-in does not have — so cost scales with rather than . Ceres states the same cost model for ITERATIVE_SCHUR.

Everything else it needs also comes from (linalg/schur/jacobian_ops.rs): the gradient , the damping diagonal , and the blocks as column dot products.

get_hessian() returns None here; the quadratic model is served exactly as .

This is what for_bundle_adjustment() selects.

ExplicitDenseSchur

The same explicit construction over a dense Hessian, for JacobianMode::Dense. Targets a few thousand DOF — a dense for the smallest BAL dataset would be ~485k × 485k.


Preconditioners

SchurPreconditioner applies to both PCG paths. The three match Ceres’s options for ITERATIVE_SCHUR:

This crateCeresBuilt from
NoneIDENTITY— (unpreconditioned)
BlockDiagonalJACOBIdiagonal blocks of (i.e. )
SchurJacobi (default)SCHUR_JACOBIdiagonal blocks of itself

SchurJacobi is the true one — it includes the elimination correction, so it preconditions rather than the unreduced retained block:

summed over the eliminated blocks visible to — captured by a visibility index cached per sparsity pattern, so this costs rather than . Each block is inverted with the shared retry policy; a block that will not invert falls back to identity, because a preconditioner is a convergence aid and must not fail the solve.

PCG and the forcing sequence

Both PCG paths share one loop (linalg/schur/pcg.rs), starting from — every caller solves for a Newton step, not a general system that would benefit from a warm start.

Two stopping rules, and the second matters more. The residual rule is

but CG minimises the quadratic model , and that value is available for free from vectors already on hand:

A trust-region optimizer does not need the system solved accurately; it needs a step that reduces the model. Insisting on a small residual on an ill-conditioned burns iterations long after the model stops moving — measured on Ladybug, every solve ran the full iteration cap without the residual rule ever firing. So, following Ceres, the solve also stops on the forcing sequence with :

The shape tightens the requirement as iterations accumulate, so early Newton steps are cheap and approximate while later ones — where the optimizer is close and needs accuracy — are solved harder.

defaults to 1e-2, deliberately tighter than Ceres’s 1e-1, because 1e-1 measured 9.7% worse RMSE on Ladybug and that loss did not recover with more optimizer iterations. The sweep is tabulated at DEFAULT_ETA (pcg.rs). Set with_schur_cg_q_tolerance(0.0) to disable the rule and get an exact solve — what the cross-solver agreement tests do.

PcgResult::termination reports which rule fired: Residual, QuadraticModel, MaxIterations (the step is truncated — worth logging) or Breakdown.

Configuration

#![allow(unused)]
fn main() {
use apex_solver::linalg::{LinearSolverType, ExplicitSchurVariant, SchurPreconditioner};
use apex_solver::optimizer::levenberg_marquardt::LevenbergMarquardtConfig;

// The BA preset: matrix-free, Schur-Jacobi, forcing sequence on.
let config = LevenbergMarquardtConfig::for_bundle_adjustment();

// Exact reduced solve instead, when the step must not depend on a tolerance.
let exact = LevenbergMarquardtConfig::for_bundle_adjustment()
    .with_linear_solver_type(LinearSolverType::ExplicitSparseSchur)
    .with_schur_variant(ExplicitSchurVariant::Sparse);

// Memory-bound: never form JᵀJ, accept the slower serial sweep.
let low_memory = LevenbergMarquardtConfig::for_bundle_adjustment()
    .with_linear_solver_type(LinearSolverType::ExplicitSparseSchur)
    .with_schur_variant(ExplicitSchurVariant::Chunked);

// Tune the iterative paths.
let tuned = LevenbergMarquardtConfig::for_bundle_adjustment()
    .with_schur_preconditioner(SchurPreconditioner::SchurJacobi)
    .with_schur_cg_params(200, 1e-6)   // max iterations, residual tolerance
    .with_schur_cg_q_tolerance(1e-2);  // forcing sequence; 0.0 disables
}

Mark what to eliminate on the problem itself:

#![allow(unused)]
fn main() {
let point = problem.add_variable(ManifoldType::RN, position);
problem.mark_for_elimination(point);
}

Which to use

SituationChoice
Large BA (the default)ImplicitSparseSchur — fastest on the three large BAL sets, forms neither nor
The step must be exactExplicitSparseSchur / Sparse — no tolerance dependence
Small problemsExplicitSparseSchur / Sparse — direct factorization wins when is small
will not fitExplicitSparseSchur / Chunked — same answer, serial, memory-lean
Dense mode, few thousand DOFExplicitDenseSchur
Pose graphsNot Schur at all — SparseCholesky

Measured numbers for each are in Benchmarks.


Covariance estimation

Covariance::compute re-linearizes the problem at a point, forms a clean and inverts via sparse Cholesky or dense SVD:

#![allow(unused)]
fn main() {
use apex_solver::linalg::covariance::{Covariance, CovarianceAlgorithm, CovarianceOptions};

let cov = Covariance::compute(
    CovarianceOptions::new(CovarianceAlgorithm::SparseCholesky),
    &problem,
    &variables,
)?;
let block = cov.block(key); // dof × dof marginal in tangent space
}

The covariance is a property of the problem at a point — it never consults the optimizer’s last linear system.