Skip to main content

gam_solve/pirls/
workspace.rs

1//! Reusable inner-loop scratch (`PirlsWorkspace`), the P-IRLS options bundle
2//! (`WorkingModelPirlsOptions`), the arrow-Schur structured-inner-solve
3//! descriptor, and the arrow-latent snapshot/restore/commit helpers.
4
5use super::*;
6
7pub struct PirlsWorkspace {
8    // Common IRLS buffers. Only O(n) state is kept persistently; any
9    // design-weighted n x p scratch must be streamed through bounded chunks.
10    pub wz: Array1<f64>,
11    pub eta_buf: Array1<f64>,
12    // Stage 2/4 assembly (use max needed sizes)
13    pub scaled_matrix: Array2<f64>,    // (<= p + ebrows) x p
14    pub final_aug_matrix: Array2<f64>, // (<= p + erows) x p
15    // Stage 5 RHS buffers
16    pub rhs_full: Array1<f64>, // length <= p + erows
17    // Gradient helper
18    pub weighted_residual: Array1<f64>,
19    // Step-halving direction (XΔβ)
20    pub delta_eta: Array1<f64>,
21    // Preallocated buffer for GEMV results (length p)
22    pub vec_buf_p: Array1<f64>,
23    // Cached sparse penalized-system workspace for sparse-native solve eligibility/assembly.
24    pub(crate) sparse_penalized_system_cache: Option<SparsePenalizedSystemCache>,
25    // Factorization scratch (avoid per-iteration allocation)
26    pub factorization_scratch: MemBuffer,
27    // Permutation buffers for LDLT
28    pub perm: Vec<usize>,
29    pub perm_inv: Vec<usize>,
30    // Buffer for in-place factorization (preserves original Hessian in WorkingState)
31    pub factorization_matrix: Array2<f64>,
32    // Buffer for sparse matrix scaling (avoid per-iteration allocation)
33    pub weighted_xvalues: Vec<f64>,
34    // Dense chunk buffer for streaming X'WX assembly on very large n.
35    pub weighted_x_chunk: Array2<f64>,
36    // Reusable p×p buffer for Hessian assembly (avoids per-iteration allocation).
37    pub hessian_buf: Array2<f64>,
38    // Reusable n-length buffer for X*β matvec (avoids per-iteration allocation in update).
39    pub matvec_buf: Array1<f64>,
40    // #1412: device-resident design `X` for the GPU `XᵀWX` Gram. The inner P-IRLS
41    // loop rebuilds the Gram once per Newton/LM iterate with the SAME design `X`
42    // (only the working weights `w` move), so re-uploading the full n×p `X` on
43    // every iterate starves the device on H2D staging (measured ~98% of the
44    // pipeline at <20% utilisation). This caches the device-resident `X` keyed on
45    // its host data pointer + shape, so the first Gram of an inner solve uploads
46    // `X` and every later iterate crosses only the n-vector `w` H2D and the p×p
47    // Gram D2H. `None` whenever CUDA is unavailable / the shape is below the GPU
48    // Gram threshold / the upload failed — the caller keeps its per-call path.
49    pub(crate) resident_design_gram: Option<(
50        usize,
51        usize,
52        usize,
53        gam_gpu::linalg_dispatch::ResidentDesignGram,
54    )>,
55}
56
57impl PirlsWorkspace {
58    /// Coefficient-space scratch for an exact sufficient-statistic solve.
59    ///
60    /// The Gaussian value-only rho lane has already reduced every data-row
61    /// contribution into `XᵀWX`, `XᵀW(y-offset)`, and the centered response
62    /// norm. It never enters an IRLS iteration, so allocating the general
63    /// workspace's five observation-length vectors would reintroduce O(n)
64    /// work before the zero-iteration branch can consume those statistics.
65    pub fn coefficient_only(p: usize) -> Self {
66        Self::new(0, p)
67    }
68
69    /// Scratch for an `n`-row, `p`-coefficient PIRLS solve.
70    ///
71    /// Penalty-block extents are deliberately *not* parameters: every stage
72    /// buffer that depends on them is allocated on demand by the path that
73    /// needs it, so sizing them here only pre-committed memory no consumer read.
74    pub fn new(n: usize, p: usize) -> Self {
75        // Stage buffers are allocated lazily: historically these were pre-sized to
76        // worst-case dimensions, which inflates memory when many PIRLS workspaces
77        // exist concurrently (e.g. parallel REML evals).
78        // The active code paths resize-on-demand where needed.
79
80        PirlsWorkspace {
81            wz: Array1::zeros(n),
82            eta_buf: Array1::zeros(n),
83            scaled_matrix: Array2::zeros((0, 0).f()),
84            final_aug_matrix: Array2::zeros((0, 0).f()),
85            rhs_full: Array1::zeros(0),
86            weighted_residual: Array1::zeros(n),
87            delta_eta: Array1::zeros(n),
88            vec_buf_p: Array1::zeros(p),
89            sparse_penalized_system_cache: None,
90            // Keep scratch minimal at init; grow only if/when a factorization path
91            // needs it.
92            factorization_scratch: {
93                let par = faer::Par::Seq;
94                let req = faer::linalg::cholesky::llt::factor::cholesky_in_place_scratch::<f64>(
95                    1,
96                    par,
97                    Spec::new(<LltParams as Auto<f64>>::auto()),
98                );
99                MemBuffer::new(req)
100            },
101            perm: vec![0; p],
102            perm_inv: vec![0; p],
103            factorization_matrix: Array2::zeros((0, 0)),
104            weighted_xvalues: Vec::new(),
105            weighted_x_chunk: Array2::zeros((0, 0).f()),
106            hessian_buf: Array2::zeros((0, 0).f()),
107            matvec_buf: Array1::zeros(n),
108            resident_design_gram: None,
109        }
110    }
111
112    pub(super) fn add_dense_xtwx_signed(
113        weights: &Array1<f64>,
114        weighted_x_scratch: &mut Array2<f64>,
115        x: &Array2<f64>,
116        out: &mut Array2<f64>,
117    ) {
118        *out =
119            crate::estimate::reml::assembly::xt_diag_x_dense_into(x, weights, weighted_x_scratch);
120    }
121
122    /// Ensure the sparse penalty cache is populated and consistent with `x` and `s_lambda`.
123    pub(crate) fn ensure_sparse_penalty_cache(
124        &mut self,
125        x: &SparseColMat<usize, f64>,
126        s_lambda: &Array2<f64>,
127    ) -> Result<(), EstimationError> {
128        let penalty_pattern = SparsePenaltyPattern::from_dense_upper(s_lambda, 1e-12);
129        let rebuild = match self.sparse_penalized_system_cache.as_ref() {
130            Some(cache) => !cache.matches(x, &penalty_pattern),
131            None => true,
132        };
133        if rebuild {
134            self.sparse_penalized_system_cache =
135                Some(SparsePenalizedSystemCache::new(x, penalty_pattern)?);
136        }
137        Ok(())
138    }
139
140    pub(crate) fn sparse_penalized_system_stats(
141        &mut self,
142        x: &SparseColMat<usize, f64>,
143        s_lambda: &Array2<f64>,
144    ) -> Result<SparsePenalizedSystemStats, EstimationError> {
145        self.ensure_sparse_penalty_cache(x, s_lambda)?;
146        Ok(self
147            .sparse_penalized_system_cache
148            .as_ref()
149            .expect("ensure_sparse_penalty_cache installs the cache or returns Err")
150            .stats())
151    }
152
153    // Phase 2 hook: numeric sparse penalized-system assembly in original coordinates.
154    pub(super) fn assemble_sparse_penalized_hessian(
155        &mut self,
156        x: &SparseColMat<usize, f64>,
157        weights: &Array1<f64>,
158        s_lambda: &Array2<f64>,
159        ridge: f64,
160        precomputed_xtwx: Option<&SparseXtwxPrecomputed>,
161    ) -> Result<SparseColMat<usize, f64>, EstimationError> {
162        self.ensure_sparse_penalty_cache(x, s_lambda)?;
163        self.sparse_penalized_system_cache
164            .as_mut()
165            .expect("ensure_sparse_penalty_cache installs the cache or returns Err")
166            .assemble_upper(x, weights, ridge, precomputed_xtwx)
167    }
168}
169
170#[derive(Clone, Debug)]
171pub struct WorkingModelPirlsOptions {
172    pub max_iterations: usize,
173    pub convergence_tolerance: f64,
174    pub adaptive_kkt_tolerance: Option<AdaptiveKktTolerance>,
175    pub max_step_halving: usize,
176    pub min_step_size: f64,
177    pub firth_bias_reduction: bool,
178    /// Optional lower bounds on coefficients (same coordinate system as `beta`).
179    /// Use `-inf` for unconstrained entries.
180    pub coefficient_lower_bounds: Option<Array1<f64>>,
181    /// Optional linear inequality constraints in current coefficient coordinates:
182    ///   A * beta >= b.
183    pub linear_constraints: Option<LinearInequalityConstraints>,
184    /// Optional warm-start hint for the Levenberg-Marquardt damping
185    /// coefficient. When set, the inner solver seeds `λ_LM` to this
186    /// value instead of the default `1e-6`. Clamped on consumption to
187    /// `[1e-6, 1e-3]` so a stale or pathological hint cannot poison the
188    /// solve: the upper bound costs at most three damping halvings
189    /// versus the cold default, which is dwarfed by the savings when
190    /// the hint is informative.
191    ///
192    /// Used by `execute_pirls_if_needed` (in `solver::reml::outer_eval`)
193    /// to persist the converged λ across consecutive PIRLS calls in a
194    /// single REML outer optimization, so the inner Newton does not
195    /// have to rediscover problem-specific damping at every accepted
196    /// outer iterate.
197    pub initial_lm_lambda: Option<f64>,
198    /// Optional arrow-Schur structured-inner-solve descriptor.
199    ///
200    /// When `Some`, every accepted LM Newton step inside the inner loop
201    /// is computed by the per-observation arrow-Schur path
202    /// ([`crate::arrow_schur::ArrowSchurSystem`]) instead of the
203    /// β-only `solve_newton_direction_dense`. When `None`, the existing
204    /// β-only path is used unchanged (back-compat: every existing call
205    /// site that does not opt in is unaffected).
206    ///
207    /// **Scope note.** This wires the *inner* Gauss–Newton step. The REML
208    /// outer-loop gradient w.r.t. `t` (which carries a shared `Schur⁻¹`
209    /// factor) is a separate plumbing change owned by the REML driver and is
210    /// **not** handled here.
211    pub arrow_schur: Option<ArrowSchurInnerConfig>,
212}
213
214/// Per-iteration arrow-Schur builder hook.
215///
216/// The driver supplies a closure that, given the current `β` iterate,
217/// returns a freshly-populated [`crate::arrow_schur::ArrowSchurSystem`]
218/// — i.e. the per-row `H_tt^(i)`, `H_tβ^(i)`, `g_t^(i)` blocks and the
219/// β-block `H_ββ`, `g_β`. The driver owns the assembly because the
220/// per-row Jacobians depend on the latent-coord term's basis (Duchon,
221/// Sphere, …) and the analytic-penalty contributions depend on the
222/// registry the outer-fit configuration owns. PIRLS only knows how to
223/// *solve* the bordered system once it has been assembled.
224#[derive(Clone)]
225pub struct ArrowSchurInnerConfig {
226    /// Number of latent rows `N`.
227    pub n_rows: usize,
228    /// Latent dimensionality `d`.
229    pub latent_dim: usize,
230    /// β dimensionality `K` (must match the inner Hessian dimension).
231    pub n_beta: usize,
232    /// Closure that builds the bordered system at the current `β` and
233    /// current latent `t` (the latter held externally by the driver, e.g.
234    /// in a `LatentCoordValues` registered alongside the working model).
235    /// Returning `None` signals "fall back to the β-only path for this
236    /// iteration" — useful for the seeding sweep before `t` has been
237    /// initialized.
238    pub build: std::sync::Arc<
239        dyn Fn(&Array1<f64>) -> Option<crate::arrow_schur::ArrowSchurSystem> + Send + Sync,
240    >,
241    /// BA Schur solve mode. `None` selects Direct for `K <= 2000` and
242    /// InexactPCG above, following "Bundle Adjustment in the Large".
243    pub solver_mode: Option<crate::arrow_schur::ArrowSolverMode>,
244    /// When set, assemble the reduced dense Schur block in row chunks.
245    pub streaming_chunk_size: Option<usize>,
246    /// Steihaug trust-region radius for the reduced shared step. This ports
247    /// the Ceres/BA trust-region guard while retaining PIRLS's LM damping.
248    pub trust_region_radius: f64,
249    /// Optional β-block column ranges for the block-Jacobi Schur preconditioner.
250    ///
251    /// When `Some`, the PIRLS driver calls
252    /// [`crate::arrow_schur::ArrowSchurSystem::set_block_offsets`] on
253    /// every system returned by the `build` closure, wiring the block-Jacobi
254    /// path without requiring each family's closure to call it manually.
255    ///
256    /// Derive from `ParameterBlockSpec` slices via
257    /// `gam_custom_family::block_offsets_from_specs`.  When
258    /// `None`, the preconditioner falls back to scalar-diagonal Jacobi (the
259    /// pre-#287 behaviour); when `Some([])` (empty slice), the same fallback
260    /// applies.
261    pub block_offsets: Option<Arc<[std::ops::Range<usize>]>>,
262    /// Callback that the inner solver invokes after each LM-attempted
263    /// joint step to write the latent tangent increment back into the
264    /// driver's `LatentCoordValues` via that latent's update rule
265    /// (`retract_flat_delta` for manifold latents). `delta_t` is the flat
266    /// row-major increment of length `n_rows * latent_dim`.
267    pub apply_delta_t: std::sync::Arc<dyn Fn(&Array1<f64>) + Send + Sync>,
268    /// Snapshot the driver's latent field before an LM trial step mutates it.
269    pub snapshot_t: std::sync::Arc<dyn Fn() -> Array1<f64> + Send + Sync>,
270    /// Restore a snapshot produced by [`Self::snapshot_t`] after any rejected
271    /// LM trial. Accepted trials deliberately do not call this hook: β and t
272    /// commit together.
273    pub restore_t: std::sync::Arc<dyn Fn(&Array1<f64>) + Send + Sync>,
274}
275
276impl std::fmt::Debug for ArrowSchurInnerConfig {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.debug_struct("ArrowSchurInnerConfig")
279            .field("n_rows", &self.n_rows)
280            .field("latent_dim", &self.latent_dim)
281            .field("n_beta", &self.n_beta)
282            .field("solver_mode", &self.solver_mode)
283            .field("streaming_chunk_size", &self.streaming_chunk_size)
284            .field("trust_region_radius", &self.trust_region_radius)
285            .field(
286                "block_offsets",
287                &self.block_offsets.as_ref().map(|o| o.len()),
288            )
289            .finish_non_exhaustive()
290    }
291}
292
293pub(crate) fn restore_arrow_latent_if_needed(
294    options: &WorkingModelPirlsOptions,
295    snapshot: Option<Array1<f64>>,
296) {
297    if let (Some(arrow_cfg), Some(snapshot)) = (options.arrow_schur.as_ref(), snapshot) {
298        arrow_cfg.restore_t.as_ref()(&snapshot);
299    }
300}
301
302pub(super) fn restore_pending_arrow_latent_if_needed(
303    options: &WorkingModelPirlsOptions,
304    pending_snapshot: &mut Option<Array1<f64>>,
305) {
306    restore_arrow_latent_if_needed(options, pending_snapshot.take());
307}
308
309pub(super) fn commit_pending_arrow_latent(pending_snapshot: &mut Option<Array1<f64>>) {
310    drop(pending_snapshot.take());
311}