gam_solve/arrow_schur/solve_options.rs
1//! Solver configuration and the batched block-solve abstraction: BA solver
2//! modes, PCG/trust-region/mixed-precision/proximal options, diagnostics, and
3//! the [`BatchedBlockSolver`] trait with its CPU implementation.
4
5use super::*;
6
7/// BA Schur solve variant for the reduced shared `β` system.
8///
9/// * [`ArrowSolverMode::Direct`] is BA's dense reduced-camera-system solve:
10/// eliminate the per-point/per-row blocks, form the reduced system, and
11/// Cholesky factor it. This is the Ceres/g2o default for modest camera
12/// counts and is appropriate here for `K <= 2000`.
13/// **GPU support: ✓** — requires dense H_ββ and dense per-row H_tβ slabs.
14///
15/// * [`ArrowSolverMode::SqrtBA`] ports Square-Root BA (Demmel/Gao/Gu et al.,
16/// CVPR 2021): Schur terms are formed as `(L_i^-1 H_tβ_i)^T
17/// (L_i^-1 H_tβ_i)` from the per-row square-root factor `L_i`, avoiding
18/// explicit `H_tt^-1 H_tβ` products. It is the preferred direct path when
19/// single-precision assembly is introduced or when row blocks are poorly
20/// conditioned.
21/// **GPU support: ✓** — requires dense H_ββ and dense per-row H_tβ slabs.
22///
23/// * [`ArrowSolverMode::InexactPCG`] ports "Bundle Adjustment in the Large"
24/// (Agarwal et al.): the Schur system is solved inexactly by PCG with a
25/// Jacobi Schur preconditioner, avoiding dense `K × K` factorization for
26/// SAE-manifold scale shared systems.
27/// **GPU support: CPU only** until the row-procedural H_tβ GPU PCG path
28/// (issue #288 Part B) is wired. The topology selector must not request
29/// `InexactPCG` via the GPU entry point; `solve_arrow_newton_step` returns
30/// `GpuRequiresDenseSystem` for matrix-free systems, and the wrapper in
31/// `solver/gpu/arrow_schur_gpu.rs` routes those to CPU InexactPCG
32/// automatically. At K ≥ 5000 the GPU PCG path will supersede the CPU path
33/// once the row-procedural H_tβ kernel and boxed GPU matvec backend in
34/// `run_pcg_with_preconditioner` are wired.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ArrowSolverMode {
37 Direct,
38 SqrtBA,
39 InexactPCG,
40}
41
42impl ArrowSolverMode {
43 /// BA-size heuristic: dense RCS for modest `K`, inexact Schur PCG for
44 /// large shared systems. This follows Agarwal et al.'s direct-vs-iterative
45 /// split for large BA, mapped from cameras to decoder coefficients.
46 pub const fn automatic(k: usize) -> Self {
47 if k <= DIRECT_SOLVE_MAX_K {
48 Self::Direct
49 } else {
50 Self::InexactPCG
51 }
52 }
53
54 /// Square-Root BA is the direct-solve stability mode for future f32
55 /// callers. Large `K` still routes to inexact PCG because dense Schur
56 /// storage dominates precision concerns at that scale.
57 pub const fn automatic_for_single_precision(k: usize) -> Self {
58 if k <= DIRECT_SOLVE_MAX_K {
59 Self::SqrtBA
60 } else {
61 Self::InexactPCG
62 }
63 }
64}
65
66/// Reason the Steihaug-CG loop stopped.
67#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
68pub enum PcgStopReason {
69 /// Residual fell below the relative tolerance threshold.
70 #[default]
71 Converged,
72 /// Loop exhausted max_iterations without converging.
73 MaxIter,
74 /// Step hit the trust-region boundary (Steihaug boundary projection).
75 TrustRegion,
76 /// Negative curvature detected in an unbounded solve.
77 Indefinite,
78 /// Non-positive or non-finite preconditioned residual after an update.
79 Stagnation,
80}
81
82/// Per-solve instrumentation counters returned alongside the PCG solution.
83///
84/// All fields default to zero; callers that do not need diagnostics simply
85/// ignore the value. The struct is Copy so passing it through return tuples
86/// is zero-overhead.
87#[derive(Debug, Default, Clone, Copy)]
88pub struct ArrowPcgDiagnostics {
89 /// Number of CG iterations executed.
90 pub iterations: usize,
91 /// Total calls to the Schur matvec A·p.
92 pub matvec_calls: usize,
93 /// Total calls to the preconditioner M^{-1}·r.
94 pub precond_apply_calls: usize,
95 /// Number of times the LM ridge was escalated before a successful factor.
96 pub ridge_escalations: usize,
97 /// Relative residual at termination; 0.0 when the RHS was zero.
98 pub final_relative_residual: f64,
99 /// Why the loop stopped.
100 pub stopping_reason: PcgStopReason,
101 /// Mixed-precision certificate outcome for this solve.
102 pub mixed_precision_status: MixedPrecisionStatus,
103 /// True only when the reduced-Schur solve was **actually executed on the
104 /// device**: either the fully device-resident batched Arrow-Schur Direct
105 /// sequence (`try_device_arrow_direct` → `solve_arrow_newton_step`) or the
106 /// device-resident matrix-free SAE PCG (`solve_sae_matrix_free_pcg`, which
107 /// runs the matvec in CUDA kernels over device-resident frames). It is NOT
108 /// set merely because a GPU runtime exists and a dispatch gate fired (#1209).
109 pub used_device_arrow: bool,
110 /// True when a reduced-Schur matvec backend was injected through
111 /// `maybe_inject_gpu_schur_matvec` but the matvec itself runs as a
112 /// **host** (CPU Rust/Rayon) procedural closure — both the matrix-free
113 /// `build_row_procedural_matvec` branch and the `cuda::build_schur_matvec_backend`
114 /// branch return host closures that evaluate `Σ_i Y_iᵀ(Y_i x)` on the CPU,
115 /// even when a CUDA context was opened to build the per-row factors. This
116 /// path must NOT report `used_device_arrow`: the arithmetic is host-side
117 /// (#1209). Distinct field so perf accounting never mistakes a host
118 /// procedural matvec for true device execution.
119 pub injected_host_procedural_matvec: bool,
120}
121
122/// Outcome of an opt-in mixed-precision arrow solve.
123#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
124pub enum MixedPrecisionStatus {
125 /// The caller did not request mixed precision or this solve mode cannot use it.
126 #[default]
127 Off,
128 /// The f32 factor solve was refined until the f64 backward-error certificate held.
129 Certified { refinement_steps: usize },
130 /// The kappa gate or solve shape rejected mixed precision and the f64 path ran.
131 /// The declining reason is logged at `info` level when the fallback fires.
132 F64Fallback,
133}
134
135/// PCG controls for BA's inexact reduced-camera-system solve.
136///
137/// The defaults mirror the loose inner tolerances used by inexact-step LM in
138/// "Bundle Adjustment in the Large": solve the Schur system only accurately
139/// enough for a useful trust-region step, then let the outer LM iteration
140/// correct the remaining error.
141#[derive(Debug, Clone)]
142pub struct ArrowPcgOptions {
143 pub max_iterations: usize,
144 pub relative_tolerance: f64,
145}
146
147impl Default for ArrowPcgOptions {
148 fn default() -> Self {
149 Self {
150 max_iterations: DEFAULT_PCG_MAX_ITERATIONS,
151 relative_tolerance: DEFAULT_PCG_RELATIVE_TOLERANCE,
152 }
153 }
154}
155
156/// Trust-region controls for Steihaug-CG on the reduced BA system.
157///
158/// This is the Ceres-style guard around LM: `ridge_t`/`ridge_beta` provide
159/// Levenberg damping, while the trust radius bounds the reduced shared step
160/// in Euclidean β coordinates using Steihaug's truncated-CG stopping rules for
161/// boundary hits and negative curvature.
162#[derive(Debug, Clone)]
163pub struct ArrowTrustRegionOptions {
164 pub radius: f64,
165 pub steihaug_relative_tolerance: f64,
166 pub max_iterations: usize,
167}
168
169impl Default for ArrowTrustRegionOptions {
170 fn default() -> Self {
171 Self {
172 radius: DEFAULT_TRUST_REGION_RADIUS,
173 steihaug_relative_tolerance: DEFAULT_PCG_RELATIVE_TOLERANCE,
174 max_iterations: DEFAULT_PCG_MAX_ITERATIONS,
175 }
176 }
177}
178
179/// Opt-in Carson--Higham mixed-precision refinement for dense arrow solves.
180///
181/// Default is [`ArrowSolvePrecisionPolicy::F64Only`]: exact f64 solves remain the default.
182/// [`ArrowSolvePrecisionPolicy::CertifiedMixed`] stores f32 copies of the per-row Cholesky
183/// factors and dense Schur factor, solves corrections in f32, and recomputes the
184/// residual in f64 against the original arrow blocks. The standard refinement
185/// certificate is the normwise backward error
186///
187/// `||r||_inf / (||H||_inf ||x||_inf + ||b||_inf) <= residual_relative_tolerance`.
188///
189/// The kappa gate enforces `kappa_estimate * u_f32 < kappa_unit_roundoff_margin`;
190/// when it fails, the solve reports [`MixedPrecisionStatus::F64Fallback`] and
191/// logs the reason before using the f64 path.
192#[derive(Debug, Clone, Copy, PartialEq)]
193pub enum ArrowSolvePrecisionPolicy {
194 F64Only,
195 CertifiedMixed {
196 max_refinement_steps: usize,
197 residual_relative_tolerance: f64,
198 kappa_unit_roundoff_margin: f64,
199 },
200}
201
202impl Default for ArrowSolvePrecisionPolicy {
203 fn default() -> Self {
204 Self::F64Only
205 }
206}
207
208impl ArrowSolvePrecisionPolicy {
209 pub fn certified_mixed() -> Self {
210 Self::CertifiedMixed {
211 max_refinement_steps: DEFAULT_MIXED_PRECISION_MAX_REFINEMENTS,
212 residual_relative_tolerance: DEFAULT_MIXED_PRECISION_CERTIFICATE_TOLERANCE,
213 kappa_unit_roundoff_margin: DEFAULT_MIXED_PRECISION_KAPPA_MARGIN,
214 }
215 }
216
217 pub(crate) fn is_enabled(self) -> bool {
218 matches!(self, ArrowSolvePrecisionPolicy::CertifiedMixed { .. })
219 }
220}
221
222/// Conditioning contract for the undamped evidence cache.
223///
224/// This is intentionally independent of Newton-step damping. Evidence may
225/// accept a genuinely positive but ill-conditioned operator, or may unit-pin
226/// numerical nulls so they contribute `log 1 = 0`; neither choice is permitted
227/// to alter the Newton system or its Tikhonov floor.
228#[derive(Debug, Clone, Copy, PartialEq)]
229pub enum ArrowEvidencePolicy {
230 Strict,
231 PositiveDefinite,
232 UnitDeflation { relative_floor: f64 },
233}
234
235impl ArrowEvidencePolicy {
236 pub(crate) fn factors_undamped_evidence(self) -> bool {
237 !matches!(self, Self::Strict)
238 }
239
240 pub(crate) fn reduced_schur_policy(self) -> ReducedSchurPolicy {
241 match self {
242 Self::Strict | Self::PositiveDefinite => ReducedSchurPolicy::StrictNewton,
243 Self::UnitDeflation { relative_floor } => {
244 ReducedSchurPolicy::EvidenceUnitDeflation { relative_floor }
245 }
246 }
247 }
248}
249
250/// Complete BA Schur solve options.
251///
252/// Use [`ArrowSolveOptions::automatic`] for normal latent-coordinate fits;
253/// use [`ArrowSolveOptions::sqrt_ba`] when the assembler has single-precision
254/// row blocks or an ill-conditioned gauge; use [`ArrowSolveOptions::inexact_pcg`]
255/// for SAE-manifold scale `K`.
256#[derive(Clone)]
257pub struct ArrowSolveOptions {
258 pub mode: ArrowSolverMode,
259 /// Backend policy owned by this solve. Keeping it in the immutable solve
260 /// request prevents one fit from changing another fit's device routing.
261 pub gpu_policy: gam_gpu::GpuPolicy,
262 pub pcg: ArrowPcgOptions,
263 pub trust_region: ArrowTrustRegionOptions,
264 /// Row chunk size for streaming direct/Square-Root Schur assembly.
265 pub streaming_chunk_size: Option<usize>,
266 /// Use the Riemannian latent projection before the Schur reduction. The
267 /// reduced Steihaug solve itself remains in Euclidean β coordinates.
268 pub riemannian_trust_region: bool,
269 /// Optional GPU-backed Schur matvec for CPU-driven `InexactPCG` at K ≥ 5000.
270 ///
271 /// When set, `run_pcg_with_preconditioner` delegates each `S·p` call to
272 /// this closure instead of the CPU `schur_matvec`. Constructed by
273 /// `crate::gpu_kernels::arrow_schur::gpu_schur_matvec_backend` when `cuda_selected()`
274 /// and the system has dense per-row H_tβ slabs. `None` means CPU-only PCG.
275 pub gpu_matvec: Option<GpuSchurMatvec>,
276 /// Conditioning contract for the separately-built undamped evidence cache.
277 pub evidence_policy: ArrowEvidencePolicy,
278 /// Arrow solve precision policy. Default is f64-only.
279 pub solve_precision: ArrowSolvePrecisionPolicy,
280 /// Optional spectral positive-definiteness floor on the *reduced Schur
281 /// complement* `S = H_ββ + ridge_β·I − Σ_i H_tβ^(i)ᵀ (H_tt^(i))⁻¹ H_tβ^(i)`,
282 /// as a relative fraction of `S`'s largest eigenvalue.
283 ///
284 /// `None` (default) keeps the strict contract: a non-PD `S` errors as
285 /// [`ArrowSchurError::SchurFactorFailed`] so the LM outer loop lifts
286 /// `ridge_beta` globally and re-forms `S`.
287 ///
288 /// `Some(floor)` engages the #1026 SAE co-collapse cure on the SOLVE path:
289 /// when the reduced Schur Cholesky refuses (collapsed atoms drive a per-row
290 /// `H_tt` near-singular, so the accumulated `(H_tt)⁻¹` over-subtracts `S`
291 /// into an INDEFINITE matrix), instead of rejecting and over-damping every
292 /// β direction with a global ridge, symmetric-eigendecompose `S` and clamp
293 /// every eigenvalue UP to `floor·max(λ)`. This is Levenberg–Marquardt
294 /// restricted to exactly the indefinite/collapsed subspace: the
295 /// well-conditioned β directions (`λ ≫ floor·max λ`) are untouched and the
296 /// step in those directions is the exact Newton step, while only the
297 /// collapsed directions receive the minimal damping needed for a PD solve.
298 /// The inner Newton then makes a real descent step rather than crawling
299 /// behind an inflated global ridge. Mirrors the per-row spectral floor the
300 /// evidence path uses for #1377/#1117/#1118
301 /// ([`super::factorization::factor_spectral_deflated_criterion_row`]); the
302 /// difference is the floored value — a small positive `floor·max λ`
303 /// (Tikhonov) for the solve, vs unit stiffness `+1` (`log 1 = 0`) for the
304 /// evidence log-det.
305 ///
306 /// Only consulted by the dense Direct / SqrtBA reduced solve (the only
307 /// caller of [`super::reduced_solve::solve_dense_reduced_system`]); the
308 /// InexactPCG path is unaffected.
309 pub newton_schur_tikhonov_rel_floor: Option<f64>,
310 /// #1017 device-resident framed SAE frame for the LM ridge ladder.
311 ///
312 /// When set (by [`super::newton_step::solve_with_lm_escalation_inner`] on a
313 /// device-admitted matrix-free SAE system), both the Direct SAE-PCG seam
314 /// ([`super::newton_step::try_device_arrow_direct_sae_pcg`]) and the native
315 /// large-border InexactPCG branch recompute only the ridge-dependent per-row
316 /// `ainv` per ladder trial and reuse the resident ridge-independent operand
317 /// buffers, instead of re-marshalling and re-uploading every operand through
318 /// `flatten_device_sae_frame_data` on each trial. The solve is bit-identical;
319 /// only the redundant per-trial upload is removed. `None` (default) keeps the
320 /// per-trial re-flatten path. A trait object (like [`GpuSchurMatvec`]) keeps
321 /// the CUDA-only device buffers out of these cfg-independent options.
322 pub sae_resident_frame:
323 Option<std::sync::Arc<dyn crate::gpu_kernels::arrow_schur::SaeResidentFrame + Send + Sync>>,
324}
325
326impl std::fmt::Debug for ArrowSolveOptions {
327 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328 f.debug_struct("ArrowSolveOptions")
329 .field("mode", &self.mode)
330 .field("gpu_policy", &self.gpu_policy)
331 .field("pcg", &self.pcg)
332 .field("trust_region", &self.trust_region)
333 .field("streaming_chunk_size", &self.streaming_chunk_size)
334 .field("riemannian_trust_region", &self.riemannian_trust_region)
335 .field("gpu_matvec", &self.gpu_matvec.is_some())
336 .field("evidence_policy", &self.evidence_policy)
337 .field("solve_precision", &self.solve_precision)
338 .field(
339 "newton_schur_tikhonov_rel_floor",
340 &self.newton_schur_tikhonov_rel_floor,
341 )
342 .field("sae_resident_frame", &self.sae_resident_frame.is_some())
343 .finish()
344 }
345}
346
347/// Globalization guard for non-convex arrow-Schur inner steps.
348///
349/// The raw Schur solve is exactly Newton. For non-convex analytic penalties,
350/// full Newton can cycle. This controller adds a proximal LM shift `mu I` to
351/// both blocks and accepts only Armijo-decreasing trial points.
352#[derive(Debug, Clone)]
353pub struct ArrowProximalCorrectionOptions {
354 pub initial_ridge: f64,
355 pub ridge_growth: f64,
356 pub max_attempts: usize,
357 pub armijo_c1: f64,
358 pub gradient_tolerance: f64,
359 /// Relative objective resolution below which the proximal correction
360 /// declares convergence instead of failing.
361 ///
362 /// Near a stationary point the largest decrease the damped Newton model can
363 /// still achieve shrinks to the floating-point resolution of the objective
364 /// itself: at proximal ridge `μ → μ_max` the accepted step length is
365 /// `O(‖g‖ / μ)`, so the realised change in the objective falls below
366 /// `rel_tol · (|f| + 1)`. At that scale the Armijo sufficient-decrease test
367 /// compares two values that differ only by rounding noise, and no further
368 /// productive decrease is achievable. Rather than raise
369 /// `AdaptiveCorrectionFailed`, the loop then returns the incumbent state
370 /// (a zero step) as converged. This does NOT mask genuine non-convergence:
371 /// it triggers only when every attempted step either fails to decrease the
372 /// objective by more than this resolution OR increases it by no more than
373 /// this resolution (pure rounding). A step that genuinely reduces the
374 /// objective is always taken first.
375 pub convergence_objective_rel_tol: f64,
376}
377
378impl Default for ArrowProximalCorrectionOptions {
379 fn default() -> Self {
380 Self {
381 initial_ridge: DEFAULT_PROXIMAL_INITIAL_RIDGE,
382 ridge_growth: DEFAULT_PROXIMAL_RIDGE_GROWTH,
383 max_attempts: DEFAULT_PROXIMAL_MAX_ATTEMPTS,
384 armijo_c1: DEFAULT_ARMIJO_C1,
385 gradient_tolerance: DEFAULT_GRADIENT_TOLERANCE,
386 convergence_objective_rel_tol: DEFAULT_PROXIMAL_CONVERGENCE_REL_TOL,
387 }
388 }
389}
390
391/// Accepted proximal arrow-Schur step and the damping that made it descent.
392#[derive(Debug, Clone)]
393pub struct ArrowAcceptedProximalStep {
394 pub delta_t: Array1<f64>,
395 pub delta_beta: Array1<f64>,
396 pub ridge_t: f64,
397 pub ridge_beta: f64,
398 pub proximal_ridge: f64,
399 pub objective_value: f64,
400 pub trial_objective_value: f64,
401 pub gradient_dot_step: f64,
402 pub attempts: usize,
403}
404
405impl ArrowSolveOptions {
406 /// Select Direct for `K <= 2000` and InexactPCG above, following BA RCS
407 /// practice for dense-vs-iterative reduced systems.
408 pub fn automatic(k: usize) -> Self {
409 Self {
410 mode: ArrowSolverMode::automatic(k),
411 gpu_policy: gam_gpu::GpuPolicy::Auto,
412 pcg: ArrowPcgOptions::default(),
413 trust_region: ArrowTrustRegionOptions::default(),
414 streaming_chunk_size: None,
415 riemannian_trust_region: false,
416 gpu_matvec: None,
417 evidence_policy: ArrowEvidencePolicy::Strict,
418 solve_precision: ArrowSolvePrecisionPolicy::F64Only,
419 newton_schur_tikhonov_rel_floor: None,
420 sae_resident_frame: None,
421 }
422 }
423
424 /// Force dense reduced-camera-system Cholesky, the classic BA direct
425 /// solve for small `K`.
426 pub fn direct() -> Self {
427 Self {
428 mode: ArrowSolverMode::Direct,
429 gpu_policy: gam_gpu::GpuPolicy::Auto,
430 pcg: ArrowPcgOptions::default(),
431 trust_region: ArrowTrustRegionOptions::default(),
432 streaming_chunk_size: None,
433 riemannian_trust_region: false,
434 gpu_matvec: None,
435 evidence_policy: ArrowEvidencePolicy::Strict,
436 solve_precision: ArrowSolvePrecisionPolicy::F64Only,
437 newton_schur_tikhonov_rel_floor: None,
438 sae_resident_frame: None,
439 }
440 }
441
442 /// Force Square-Root BA Schur assembly for the direct reduced solve.
443 pub fn sqrt_ba() -> Self {
444 Self {
445 mode: ArrowSolverMode::SqrtBA,
446 gpu_policy: gam_gpu::GpuPolicy::Auto,
447 pcg: ArrowPcgOptions::default(),
448 trust_region: ArrowTrustRegionOptions::default(),
449 streaming_chunk_size: None,
450 riemannian_trust_region: false,
451 gpu_matvec: None,
452 evidence_policy: ArrowEvidencePolicy::Strict,
453 solve_precision: ArrowSolvePrecisionPolicy::F64Only,
454 newton_schur_tikhonov_rel_floor: None,
455 sae_resident_frame: None,
456 }
457 }
458
459 /// Force inexact BA Schur PCG with Jacobi preconditioning.
460 pub fn inexact_pcg() -> Self {
461 Self {
462 mode: ArrowSolverMode::InexactPCG,
463 gpu_policy: gam_gpu::GpuPolicy::Auto,
464 pcg: ArrowPcgOptions::default(),
465 trust_region: ArrowTrustRegionOptions::default(),
466 streaming_chunk_size: None,
467 riemannian_trust_region: false,
468 gpu_matvec: None,
469 evidence_policy: ArrowEvidencePolicy::Strict,
470 solve_precision: ArrowSolvePrecisionPolicy::F64Only,
471 newton_schur_tikhonov_rel_floor: None,
472 sae_resident_frame: None,
473 }
474 }
475
476 pub fn with_streaming_chunk_size(mut self, chunk_size: Option<usize>) -> Self {
477 self.streaming_chunk_size = chunk_size.filter(|&chunk| chunk > 0);
478 self
479 }
480
481 /// Route every device probe made by this solve under one per-request
482 /// policy. In particular, `Off` returns to the exact CPU implementation
483 /// before touching a possibly broken CUDA installation.
484 pub fn with_gpu_policy(mut self, gpu_policy: gam_gpu::GpuPolicy) -> Self {
485 self.gpu_policy = gpu_policy;
486 self
487 }
488
489 /// Build an undamped evidence cache that accepts positive-definite factors
490 /// regardless of their Newton-step condition-number gate.
491 pub fn with_positive_definite_evidence(mut self) -> Self {
492 self.evidence_policy = ArrowEvidencePolicy::PositiveDefinite;
493 self
494 }
495
496 /// Build the undamped evidence β-Schur with original-coordinate unit
497 /// deflation at `relative_floor`.
498 pub fn with_evidence_unit_deflation(mut self, relative_floor: f64) -> Self {
499 self.evidence_policy = ArrowEvidencePolicy::UnitDeflation { relative_floor };
500 self
501 }
502
503 /// Enable Newton-only spectral Tikhonov damping on collapsed reduced-Schur
504 /// directions. This never changes the evidence value or inverse.
505 pub fn with_newton_schur_tikhonov(mut self, relative_floor: f64) -> Self {
506 self.newton_schur_tikhonov_rel_floor = Some(relative_floor);
507 self
508 }
509
510 pub fn with_solve_precision_policy(mut self, policy: ArrowSolvePrecisionPolicy) -> Self {
511 self.solve_precision = policy;
512 self
513 }
514
515 /// Turn certified mixed precision ON for the streaming/residency reduced
516 /// solve unless the caller already pinned an explicit policy (#1014).
517 ///
518 /// Only `F64Only` (the inherited default) is upgraded to `CertifiedMixed`;
519 /// a caller that deliberately set a policy keeps it. The reduced-Schur f64
520 /// factor and every evidence log-determinant are unaffected — see
521 /// [`mixed_precision_reduced_beta`].
522 #[must_use]
523 pub fn with_streaming_solve_precision_default(&self) -> Self {
524 let mut out = self.clone();
525 if matches!(out.solve_precision, ArrowSolvePrecisionPolicy::F64Only) {
526 out.solve_precision = ArrowSolvePrecisionPolicy::certified_mixed();
527 }
528 out
529 }
530}
531
532/// CPU/GPU seam for BA point-block work.
533///
534/// BA systems spend most time in independent point-block factorizations,
535/// triangular solves, and Schur block products. MegBA maps exactly these
536/// operations to GPU kernels. This trait keeps that boundary explicit so a
537/// CUDA/Ceres backend can replace [`CpuBatchedBlockSolver`] without changing
538/// `ArrowSchurSystem` algebra.
539pub trait BatchedBlockSolver {
540 /// Factor every per-row point block `H_tt^(i) + ridge_t I`, as in BA's
541 /// point elimination stage.
542 ///
543 /// `evidence_factorization` lifts the Newton-step κ rejection while still
544 /// requiring genuine PD unless the system's evidence deflation handles it.
545 fn factor_blocks(
546 &self,
547 rows: &[ArrowRowBlock],
548 ridge_t: f64,
549 d: usize,
550 evidence_factorization: bool,
551 ) -> Result<ArrowFactorSlab, ArrowSchurError>;
552
553 /// Factor under an explicit request policy. Backends without a device path
554 /// may ignore the policy; the production CPU/GPU hybrid honors it before
555 /// any runtime probe.
556 fn factor_blocks_with_policy(
557 &self,
558 rows: &[ArrowRowBlock],
559 ridge_t: f64,
560 d: usize,
561 evidence_factorization: bool,
562 gpu_policy: gam_gpu::GpuPolicy,
563 ) -> Result<ArrowFactorSlab, ArrowSchurError> {
564 match gpu_policy {
565 gam_gpu::GpuPolicy::Auto
566 | gam_gpu::GpuPolicy::Off
567 | gam_gpu::GpuPolicy::Required => {
568 self.factor_blocks(rows, ridge_t, d, evidence_factorization)
569 }
570 }
571 }
572
573 /// Solve one factored point block against a vector RHS.
574 fn solve_block_vector(
575 &self,
576 factor: ArrayView2<'_, f64>,
577 rhs: ArrayView1<'_, f64>,
578 ) -> Array1<f64>;
579
580 /// Solve one factored point block against a dense matrix RHS.
581 fn solve_block_matrix(
582 &self,
583 factor: ArrayView2<'_, f64>,
584 rhs: ArrayView2<'_, f64>,
585 ) -> Array2<f64>;
586
587 /// Apply the Square-Root BA lower-triangular solve `L_i^-1 rhs`.
588 fn sqrt_solve_block_matrix(
589 &self,
590 factor: ArrayView2<'_, f64>,
591 rhs: ArrayView2<'_, f64>,
592 ) -> Array2<f64>;
593
594 /// Subtract a row-local Schur product from the dense reduced system.
595 fn block_gemm_subtract(&self, schur: &mut Array2<f64>, left: &Array2<f64>, right: &Array2<f64>);
596}
597
598#[derive(Debug, Clone)]
599pub struct ArrowRowGaugeDeflation {
600 pub directions: Arc<[Vec<Array1<f64>>]>,
601}
602
603/// Orthonormal gauge basis on the reduced shared `beta` border.
604///
605/// Evidence factors use this as a Faddeev--Popov pin: for
606/// `Q = [q_1, ..., q_r]` and `P = I - Q Q^T`, the represented reduced
607/// operator is
608///
609/// `S_quot = P S P + Q Q^T`.
610///
611/// The quotient directions therefore contribute exactly `log(1) = 0` to the
612/// Laplace log-determinant, while inverse/trace consumers apply
613/// `P S_quot^-1 P`. Ordinary Newton solves do not consult this carrier; it is
614/// an evidence-coordinate contract only.
615#[derive(Debug, Clone)]
616pub struct ArrowBetaGaugeQuotient {
617 pub directions: Arc<[Array1<f64>]>,
618}
619
620impl ArrowBetaGaugeQuotient {
621 /// Orthonormalize a non-empty set of same-width border directions.
622 ///
623 /// A zero or linearly-dependent direction is a malformed gauge declaration,
624 /// not a numerical condition to hide, so construction fails loudly.
625 pub fn new(directions: Vec<Array1<f64>>) -> Result<Self, String> {
626 if directions.is_empty() {
627 return Err("ArrowBetaGaugeQuotient requires at least one direction".to_string());
628 }
629 let dim = directions[0].len();
630 if dim == 0 {
631 return Err("ArrowBetaGaugeQuotient directions must be non-empty".to_string());
632 }
633 let mut basis: Vec<Array1<f64>> = Vec::with_capacity(directions.len());
634 for (direction_idx, mut direction) in directions.into_iter().enumerate() {
635 if direction.len() != dim {
636 return Err(format!(
637 "ArrowBetaGaugeQuotient direction {direction_idx} length {} != {dim}",
638 direction.len()
639 ));
640 }
641 if direction.iter().any(|value| !value.is_finite()) {
642 return Err(format!(
643 "ArrowBetaGaugeQuotient direction {direction_idx} contains a non-finite value"
644 ));
645 }
646 for existing in &basis {
647 let coefficient = direction.dot(existing);
648 direction.scaled_add(-coefficient, existing);
649 }
650 let norm_sq = direction.dot(&direction);
651 if !(norm_sq.is_finite() && norm_sq > 0.0) {
652 return Err(format!(
653 "ArrowBetaGaugeQuotient direction {direction_idx} is zero or linearly dependent"
654 ));
655 }
656 direction *= norm_sq.sqrt().recip();
657 basis.push(direction);
658 }
659 Ok(Self {
660 directions: Arc::from(basis.into_boxed_slice()),
661 })
662 }
663
664 pub fn dimension(&self) -> usize {
665 self.directions.len()
666 }
667
668 pub(crate) fn border_dim(&self) -> usize {
669 self.directions[0].len()
670 }
671
672 /// `P x`, where `P = I - Q Q^T`.
673 pub fn project_complement(&self, x: ArrayView1<'_, f64>) -> Array1<f64> {
674 assert_eq!(x.len(), self.border_dim());
675 let mut out = x.to_owned();
676 for direction in self.directions.iter() {
677 let coefficient = out.dot(direction);
678 out.scaled_add(-coefficient, direction);
679 }
680 out
681 }
682
683 /// Dense Faddeev--Popov pin `P S P + Q Q^T`.
684 pub fn pin_reduced_schur(&self, schur: ArrayView2<'_, f64>) -> Array2<f64> {
685 let dim = self.border_dim();
686 assert_eq!(schur.dim(), (dim, dim));
687
688 // Apply P on the right, then on the left. Keeping the two projections
689 // explicit makes the implementation the exact matrix analogue of the
690 // matrix-free apply and avoids materializing a dense projector.
691 let mut right = schur.to_owned();
692 for direction in self.directions.iter() {
693 let schur_q = right.dot(direction);
694 for row in 0..dim {
695 for col in 0..dim {
696 right[[row, col]] -= schur_q[row] * direction[col];
697 }
698 }
699 }
700 let mut pinned = right;
701 for direction in self.directions.iter() {
702 let q_t_s = direction.dot(&pinned);
703 for row in 0..dim {
704 for col in 0..dim {
705 pinned[[row, col]] -= direction[row] * q_t_s[col];
706 }
707 }
708 }
709 for direction in self.directions.iter() {
710 for row in 0..dim {
711 for col in 0..dim {
712 pinned[[row, col]] += direction[row] * direction[col];
713 }
714 }
715 }
716 // Both the input Schur and the mathematical pin are symmetric. Clear
717 // the last-bit asymmetry from the two ordered dense projections before
718 // Cholesky so direct and matrix-free paths expose one self-adjoint op.
719 for row in 0..dim {
720 for col in (row + 1)..dim {
721 let value = 0.5 * (pinned[[row, col]] + pinned[[col, row]]);
722 pinned[[row, col]] = value;
723 pinned[[col, row]] = value;
724 }
725 }
726 pinned
727 }
728}
729
730impl ArrowRowGaugeDeflation {
731 pub fn new(directions: Vec<Vec<Array1<f64>>>) -> Self {
732 Self {
733 directions: Arc::from(directions.into_boxed_slice()),
734 }
735 }
736
737 pub(crate) fn row(&self, row: usize) -> &[Array1<f64>] {
738 self.directions.get(row).map(Vec::as_slice).unwrap_or(&[])
739 }
740}
741
742/// Current CPU implementation of the BA batched block interface.
743///
744/// It is intentionally plain Rust loops because `d` is tiny. The trait shape,
745/// not this implementation, is the load-bearing part for the future MegBA or
746/// Ceres backend.
747#[derive(Debug, Clone, Copy, Default)]
748pub struct CpuBatchedBlockSolver;
749
750impl BatchedBlockSolver for CpuBatchedBlockSolver {
751 fn factor_blocks(
752 &self,
753 rows: &[ArrowRowBlock],
754 ridge_t: f64,
755 d: usize,
756 evidence_factorization: bool,
757 ) -> Result<ArrowFactorSlab, ArrowSchurError> {
758 // Multi-GPU fast path: the per-row blocks `H_tt^(i) + ridge_t·I` are
759 // independent same-size SPD systems — exactly the batch
760 // `gam_gpu::try_cholesky_batched_lower_inplace` spreads across ALL
761 // usable devices (the batched POTRF tiles over the pool). It is only
762 // valid when every row is the uniform `d×d` shape; heterogeneous row
763 // dimensions keep the per-row CPU loop because the current cuSOLVER
764 // batched POTRF wrapper accepts one `(d, d)` shape per launch. It only
765 // succeeds when EVERY block is PD at
766 // the base ridge; a non-PD block returns `None`, so we fall back to the
767 // exact per-row CPU path that performs minimal per-block ridge
768 // escalation. After a successful batched factorization we re-apply the
769 // identical κ-conditioning rejection `factor_one_row` enforces, so the
770 // result is bit-for-bit equivalent (modulo IEEE reduction order) to the
771 // CPU loop: a barely-PD but ill-conditioned block forces the whole batch
772 // back onto the per-row path so its ridge can lift, never silently using
773 // a contaminated factor.
774 self.factor_blocks_with_policy(
775 rows,
776 ridge_t,
777 d,
778 evidence_factorization,
779 gam_gpu::global_policy(),
780 )
781 }
782
783 fn factor_blocks_with_policy(
784 &self,
785 rows: &[ArrowRowBlock],
786 ridge_t: f64,
787 d: usize,
788 evidence_factorization: bool,
789 gpu_policy: gam_gpu::GpuPolicy,
790 ) -> Result<ArrowFactorSlab, ArrowSchurError> {
791 if let Some(batched) =
792 try_factor_blocks_batched(rows, ridge_t, d, evidence_factorization, gpu_policy)?
793 {
794 return Ok(batched);
795 }
796 // Per-row Cholesky factorizations are INDEPENDENT (each reads only its own
797 // read-only `rows[i]` block), so factor rows in parallel then collect in
798 // row order — the ordered `collect` reproduces the serial push order
799 // bit-for-bit (no cross-row reduction; each block factored once). #1557 —
800 // pin any nested faer GEMM inside each row worker to `Par::Seq`.
801 let n = rows.len();
802 let parallel =
803 n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
804 let out = if parallel {
805 use rayon::prelude::*;
806 (0..n)
807 .into_par_iter()
808 .map(|row_idx| {
809 gam_problem::with_nested_parallel(|| {
810 factor_one_row(&rows[row_idx], ridge_t, d, row_idx, evidence_factorization)
811 })
812 })
813 .collect::<Result<Vec<_>, ArrowSchurError>>()?
814 } else {
815 let mut out = Vec::with_capacity(n);
816 for (row_idx, row) in rows.iter().enumerate() {
817 out.push(factor_one_row(
818 row,
819 ridge_t,
820 d,
821 row_idx,
822 evidence_factorization,
823 )?);
824 }
825 out
826 };
827 Ok(ArrowFactorSlab::from_blocks(out))
828 }
829
830 fn solve_block_vector(
831 &self,
832 factor: ArrayView2<'_, f64>,
833 rhs: ArrayView1<'_, f64>,
834 ) -> Array1<f64> {
835 match (factor.nrows(), factor.ncols(), rhs.len()) {
836 (1, 1, 1) => cholesky_solve_vector_fixed::<1>(factor, rhs),
837 (2, 2, 2) => cholesky_solve_vector_fixed::<2>(factor, rhs),
838 (3, 3, 3) => cholesky_solve_vector_fixed::<3>(factor, rhs),
839 (4, 4, 4) => cholesky_solve_vector_fixed::<4>(factor, rhs),
840 _ => cholesky_solve_vector(factor, rhs),
841 }
842 }
843
844 fn solve_block_matrix(
845 &self,
846 factor: ArrayView2<'_, f64>,
847 rhs: ArrayView2<'_, f64>,
848 ) -> Array2<f64> {
849 cholesky_solve_matrix(factor, rhs)
850 }
851
852 fn sqrt_solve_block_matrix(
853 &self,
854 factor: ArrayView2<'_, f64>,
855 rhs: ArrayView2<'_, f64>,
856 ) -> Array2<f64> {
857 forward_substitution_lower_matrix(factor, rhs)
858 }
859
860 fn block_gemm_subtract(
861 &self,
862 schur: &mut Array2<f64>,
863 left: &Array2<f64>,
864 right: &Array2<f64>,
865 ) {
866 // Performance: ndarray Array2 is row-major, so `right[[c, b]]` is
867 // unit-strided in `b`. The canonical (a, b, c) order produced
868 // strided reads of `left[[c, a]]` for every (a, b); reorder to
869 // (c, a, b) so the inner `b`-loop is contiguous in `right` and
870 // `left[[c, a]]` is hoisted out of the inner loop.
871 //
872 // Sparse SAE rows install a matrix-free `H_tβ` operator but the direct
873 // reduced-Schur path still asks `row_htbeta` for a dense `(d_i × K)`
874 // scratch matrix. Those rows have support on only the active atoms
875 // (`top_k · basis · p` columns), so blindly iterating the full `K × K`
876 // product made the Schur assembly compute-bound even though almost every
877 // entry multiplied by zero (#1995). Discover the non-zero column support
878 // of the two row factors once (`O(d_i·K)`) and multiply only the touched
879 // columns (`O(d_i·nnz_left·nnz_right)`). Dense callers still take the same
880 // arithmetic path with all columns active, while compact top-k rows scale
881 // with the active support rather than the global border width.
882 let k = schur.nrows();
883 let d = left.nrows();
884 assert_eq!(left.ncols(), k);
885 assert_eq!(right.nrows(), d);
886 assert_eq!(right.ncols(), k);
887 assert_eq!(schur.ncols(), k);
888
889 let mut left_active = Vec::with_capacity(k);
890 let mut right_active = Vec::with_capacity(k);
891 // HOT PATH (measured 2026-07-11, A10 real-workload profile: 50% of ALL
892 // fit cycles in non-inlined bounds-checked ndarray 2-D `IndexMut`, plus
893 // 13% self-time here): every scalar of the scan and of the rank-1
894 // update went through `[[i, j]]` element access — a function call, a
895 // bounds check, and 2-D stride arithmetic per double. Go through the
896 // contiguous row slices and flat 1-D indexing instead. The operation
897 // SEQUENCE is identical (same c → a → b order, same subtractions), so
898 // the result is bit-exact — no reduction-order change.
899 let schur_cols = schur.ncols();
900 let schur_flat = schur
901 .as_slice_mut()
902 .expect("block_gemm_subtract: reduced Schur must be standard-layout");
903 for c in 0..d {
904 left_active.clear();
905 right_active.clear();
906 let left_row = left.row(c);
907 let right_row = right.row(c);
908 let left_row = left_row
909 .as_slice()
910 .expect("block_gemm_subtract: left row must be contiguous");
911 let right_row = right_row
912 .as_slice()
913 .expect("block_gemm_subtract: right row must be contiguous");
914 for col in 0..k {
915 let l = left_row[col];
916 let r = right_row[col];
917 if l != 0.0 {
918 left_active.push((col, l));
919 }
920 if r != 0.0 {
921 right_active.push((col, r));
922 }
923 }
924 if left_active.is_empty() || right_active.is_empty() {
925 continue;
926 }
927 for &(a, lca) in &left_active {
928 let row_off = a * schur_cols;
929 if right_active.len() == k {
930 // Dense right row: at nnz == k the active list is exactly
931 // `col = 0..k` in order, so this contiguous fused loop is
932 // BIT-IDENTICAL to the sparse one below (same b order,
933 // same subtractions) while being vectorizable — the old
934 // codegen was 771 instructions with 18 bounds/compare
935 // patterns around exactly 2 scalar FP ops and zero vector
936 // ops (A10 asm read, 2026-07-11).
937 let schur_row = &mut schur_flat[row_off..row_off + k];
938 for (s, &r) in schur_row.iter_mut().zip(right_row) {
939 *s -= lca * r;
940 }
941 } else {
942 for &(b, rcb) in &right_active {
943 schur_flat[row_off + b] -= lca * rcb;
944 }
945 }
946 }
947 }
948 }
949}