Skip to main content

gam_gpu/
policy.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
4pub enum GpuMixedPrecisionPolicy {
5    /// Always use fp64 factorization; no refinement attempted.
6    Off,
7    /// Attempt fp32 Cholesky factorization followed by up to
8    /// `REFINEMENT_MAX_STEPS` fp64-residual refinement steps. Policy admits
9    /// the attempt only when `p ≥ REFINEMENT_MIN_P` (so that the fp64 GEMV
10    /// overhead is amortized) and the measured residual drops monotonically.
11    /// Falls back to fp64 factorization automatically when the residual does
12    /// not decrease (κ(A)·u ≥ 1 regime) or when the fp32 POTRF itself fails.
13    Refinement,
14    /// Always use fp64 factorization; equivalent to `Off` but signals that
15    /// an explicit policy decision was taken.
16    Never,
17}
18
19#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
20pub struct GpuDispatchPolicy {
21    pub xtwx_n_min: usize,
22    pub xtwx_flops_min: usize,
23    pub xtwx_use_fused_below_p: usize,
24    pub gemm_min_flops: usize,
25    pub potrf_min_p: usize,
26    pub small_dense_batched_potrf_max_p: usize,
27    pub small_dense_batched_potrf_min_batch: usize,
28    pub syevd_min_p: usize,
29    pub sparse_min_nnz: usize,
30    pub fused_kernel_min_n: usize,
31    pub keep_design_resident_min_bytes: usize,
32    pub prefer_gpu_factorization_min_p: usize,
33    pub row_kernel_min_n: usize,
34    pub mixed_precision: GpuMixedPrecisionPolicy,
35}
36
37impl Default for GpuDispatchPolicy {
38    /// Conservative seed thresholds used before device calibration and when
39    /// calibration cannot run on the current host.
40    ///
41    /// The production runtime replaces these with
42    /// [`crate::calibration::calibrated_policy_for_device`] after the CUDA
43    /// probe selects a concrete device. Keep these values conservative: they
44    /// are the typed baseline for CPU-only builds, failed calibration, and unit
45    /// tests that exercise policy predicates without initializing CUDA.
46    fn default() -> Self {
47        Self {
48            xtwx_n_min: 50_000,
49            xtwx_flops_min: 100_000_000,
50            xtwx_use_fused_below_p: 256,
51            gemm_min_flops: 100_000_000,
52            potrf_min_p: 512,
53            small_dense_batched_potrf_max_p: 32,
54            small_dense_batched_potrf_min_batch: 8,
55            syevd_min_p: 256,
56            sparse_min_nnz: 1_000_000,
57            fused_kernel_min_n: 100_000,
58            keep_design_resident_min_bytes: 32 * 1024 * 1024,
59            prefer_gpu_factorization_min_p: 512,
60            row_kernel_min_n: 50_000,
61            mixed_precision: GpuMixedPrecisionPolicy::Refinement,
62        }
63    }
64}
65
66impl GpuDispatchPolicy {
67    /// The smallest `gemm_min_flops` ANY production dispatch policy can carry.
68    ///
69    /// Production policies are exactly two: [`Self::default`] (seed,
70    /// `gemm_min_flops = 1e8`) and the device-calibrated policy, whose
71    /// `crossover_flops` can lower the floor at most to the flop count of the
72    /// smallest calibration measurement — the 64×64×64 GEMM in
73    /// `calibration::GEMM_DIMS`, i.e. `2·64³ = 524_288` (a compile-time assert
74    /// in `calibration.rs` pins the correspondence). Work below this floor is
75    /// therefore inadmissible for GPU dispatch under EVERY reachable policy, so
76    /// a caller may refuse it BEFORE probing the device — this is the pre-probe
77    /// size gate that lets CPU-sized problems skip CUDA context creation
78    /// entirely (the startup-tax ordering fix). Work at or above it must fall
79    /// through to the probed runtime's real (possibly calibrated) policy gate,
80    /// so genuinely GPU-sized problems behave exactly as before.
81    pub const MIN_CALIBRATABLE_GEMM_FLOPS: u128 = 524_288;
82
83    /// The smallest `potrf_min_p` ANY production dispatch policy can carry:
84    /// the smallest POTRF calibration dimension (`calibration::POTRF_DIMS[0]`,
85    /// pinned by a compile-time assert there). A single (batch ≤ 1) POTRF with
86    /// `p` below this is inadmissible under every reachable policy.
87    pub const MIN_CALIBRATABLE_POTRF_P: usize = 64;
88
89    /// The smallest `row_kernel_min_n` / `xtwx_n_min` ANY production dispatch
90    /// policy can carry: the smallest XtWX calibration row count
91    /// (`calibration::XTWX_DIMS[0].0`, pinned by a compile-time assert there).
92    /// A row-kernel workload with fewer rows is inadmissible under every
93    /// reachable policy, so per-fit GPU-eligibility deciders may refuse it
94    /// BEFORE probing the device.
95    pub const MIN_CALIBRATABLE_ROW_KERNEL_N: usize = 2_048;
96
97    /// The smallest `fused_kernel_min_n` ANY production dispatch policy can
98    /// carry.
99    ///
100    /// Device calibration derives the fused-kernel crossover as twice its
101    /// measured row-kernel crossover. The calibration grid pins that row floor
102    /// to [`Self::MIN_CALIBRATABLE_ROW_KERNEL_N`], so a smaller fused batch is
103    /// inadmissible under every reachable policy and can remain on the CPU
104    /// without probing CUDA merely to discover the device-specific threshold.
105    pub const MIN_CALIBRATABLE_FUSED_KERNEL_N: usize =
106        2 * Self::MIN_CALIBRATABLE_ROW_KERNEL_N;
107
108    /// Minimum problem dimension for the fp32+refinement path.
109    ///
110    /// Below this threshold the fp64 GEMV needed for the residual check costs
111    /// more than the savings from fp32 factorization. The threshold is set so
112    /// that a single `p × p` DGEMV (2p² flops) is at least 10× cheaper than
113    /// the `p³/3` POTRF (i.e. p ≥ 64) while still leaving margin for the
114    /// POTRF/POTRS launches. In practice `p ≥ 64` matches the existing
115    /// `potrf_min_p = 512` floor for GPU dispatch, so the refinement path only
116    /// activates when the GPU factorization path is already chosen.
117    pub const REFINEMENT_MIN_P: usize = 64;
118
119    /// Maximum number of fp32-correction steps per solve.
120    ///
121    /// Two steps suffice for κ(A) ≤ 10⁵ at fp32 (u ≈ 6 × 10⁻⁸): after step
122    /// 1 the error is O(κ u)² ≈ 10⁻⁶, after step 2 it is O(κ u)⁴ ≈ 10⁻¹²,
123    /// which is well within the fp64 unit roundoff of 10⁻¹⁶ × κ. A cap of 3
124    /// is used defensively.
125    pub const REFINEMENT_MAX_STEPS: usize = 3;
126
127    /// Relative residual tolerance for declaring convergence.
128    ///
129    /// `‖r‖ / ‖b‖ ≤ tol` is considered a converged solve. 10⁻¹² is two
130    /// orders of magnitude above the fp64 machine epsilon times a moderate
131    /// condition number, leaving the policy conservative.
132    pub const REFINEMENT_TOL: f64 = 1e-12;
133
134    /// Return `true` when the policy and problem size together suggest that
135    /// attempting fp32 factorization + iterative refinement will be profitable.
136    ///
137    /// The predicate is conservative:
138    ///   * `GpuMixedPrecisionPolicy::Off` or `Never` → always `false`.
139    ///   * `Refinement` with `p < REFINEMENT_MIN_P` → `false` (GEMV overhead
140    ///     not amortised by fp32 POTRF savings below this threshold).
141    ///   * Otherwise `true`; the caller still falls back to fp64 factorization
142    ///     when the runtime fp32 POTRF fails or when the measured residual is
143    ///     non-monotone.
144    #[inline]
145    pub const fn iterative_refinement_should_attempt(&self, p: usize) -> bool {
146        match self.mixed_precision {
147            GpuMixedPrecisionPolicy::Off | GpuMixedPrecisionPolicy::Never => false,
148            GpuMixedPrecisionPolicy::Refinement => p >= Self::REFINEMENT_MIN_P,
149        }
150    }
151
152    pub const fn dense_gemv_target_is_gpu(&self, n: usize, p: usize, resident: bool) -> bool {
153        resident || n.saturating_mul(p).saturating_mul(2) >= self.gemm_min_flops
154    }
155
156    pub const fn xtwx_target_is_gpu(&self, n: usize, p: usize, materialized: bool) -> bool {
157        materialized && n > 0 && p > 0 && self.xtwx_flops(n, p) >= self.dense_reduction_flops_min()
158    }
159
160    pub const fn xtwy_target_is_gpu(
161        &self,
162        n: usize,
163        px: usize,
164        q: usize,
165        materialized: bool,
166    ) -> bool {
167        materialized
168            && n > 0
169            && px > 0
170            && q > 0
171            && self.xtwy_flops(n, px, q) >= self.dense_reduction_flops_min()
172    }
173
174    pub const fn potrf_target_is_gpu(&self, p: usize, h_resident: bool) -> bool {
175        h_resident && p >= self.potrf_min_p
176    }
177
178    /// Whether a batched Pólya-Gamma draw of `n` rows is worth dispatching to
179    /// the device.
180    ///
181    /// A PG batch is a fused elementwise kernel: one independent rejection
182    /// sampler per row, no reduction and no cross-row reuse. So the row count
183    /// *is* the work, and what the device has to overcome is launch latency
184    /// plus the `n·(4 + 8)` bytes staged in and `n·8` staged back out — a
185    /// transfer/launch amortisation question rather than an arithmetic-intensity
186    /// one. That is exactly what `fused_kernel_min_n` carries: calibration sets
187    /// it to twice the device's *measured* XtWX crossover row count, so the
188    /// crossover is a per-device measurement rather than a tuned literal, and a
189    /// faster host CPU moves it up on that host instead of failing the kernel.
190    #[inline]
191    pub const fn polya_gamma_batch_target_is_gpu(&self, n: usize) -> bool {
192        n >= self.fused_kernel_min_n
193    }
194
195    /// Whether a batched per-row BMS kernel over `n` design rows is worth
196    /// dispatching to the device.
197    ///
198    /// The flex-row HVP and dense-block builders are batched over rows with the
199    /// per-row frames staged once, which is the same shape `row_kernel_min_n`
200    /// is calibrated for (it is set directly from the measured XtWX crossover
201    /// row count). Keyed on rows rather than a wall-clock ratio so the decision
202    /// is a property of the workload and the device, not of whoever else is on
203    /// the box.
204    #[inline]
205    pub const fn row_batch_target_is_gpu(&self, n: usize) -> bool {
206        n >= self.row_kernel_min_n
207    }
208
209    pub const fn dense_hessian_work_target_is_gpu(&self, n: usize, p: usize) -> bool {
210        n > 0
211            && p >= Self::DEVICE_LOOP_MIN_P
212            && self.xtwx_flops(n, p) >= self.dense_reduction_flops_min()
213    }
214
215    const fn dense_reduction_flops_min(&self) -> u128 {
216        if self.xtwx_flops_min < self.gemm_min_flops {
217            self.xtwx_flops_min as u128
218        } else {
219            self.gemm_min_flops as u128
220        }
221    }
222
223    const fn xtwx_flops(&self, n: usize, p: usize) -> u128 {
224        2u128 * (n as u128) * (p as u128) * (p as u128)
225    }
226
227    const fn xtwy_flops(&self, n: usize, px: usize, q: usize) -> u128 {
228        2u128 * (n as u128) * (px as u128) * (q as u128)
229    }
230
231    /// Minimum total CG-amortised matvec flops below which the host↔device
232    /// transfer of the row frames + CG vectors is not repaid by the device
233    /// matvec, so the reduced-Schur PCG hot loop stays on the CPU.
234    ///
235    /// The dense-Direct path keys on `dense_reduction_flops_min` (a single big
236    /// factorization). The matrix-free SAE matvec is different: no single apply
237    /// trips that floor (each is a stack of `n` tiny `d×d` solves + sparse
238    /// `m·k` gather/scatter), but the *whole CG solve* runs the apply
239    /// `O(cg_iters)` times over the same resident frames. The device wins when
240    /// the **summed** matvec work over the solve exceeds the one-time staging
241    /// cost — so the gate keys on `cg_iters · per_apply_flops`, not one apply.
242    ///
243    /// Set one order of magnitude below the dense floor: the matvec frames stay
244    /// resident across CG iterations (uploaded once), so the per-flop transfer
245    /// amortization is `1/cg_iters` of a cold dense launch, and the breakeven
246    /// drops accordingly.
247    pub const MATVEC_OFFLOAD_FLOPS_MIN: u128 = 10_000_000;
248
249    /// Thin-curve (`d_atom = 1`) SAE dictionaries are the common manifold-SAE
250    /// production shape: each per-row frame is a scalar, so the staged device
251    /// payload is much smaller than the general `d > 1` row-frame bundle, while
252    /// the work is still a large batched gather/scatter over `K` atoms and `n`
253    /// rows.  Use a lower admission floor for this scalar-frame regime so a
254    /// realistic token block with a moderately wide curve dictionary is not kept
255    /// on the CPU solely because the conservative general-frame lower-bound
256    /// undercounts the transpose cross term.
257    pub const THIN_CURVE_MATVEC_OFFLOAD_FLOPS_MIN: u128 = 1_000_000;
258
259    /// Conservative seed for the reduced-Schur PCG iteration count when the
260    /// caller cannot supply a measured budget. InexactPCG on an SAE β-block of
261    /// width `k` converges in `O(√κ)` iterations; this floor keeps the work
262    /// estimate honest (≥ this many applies) without over-claiming a tight
263    /// solve. Used only to amortise the staging cost in the work estimate.
264    pub const MATVEC_OFFLOAD_MIN_CG_ITERS: usize = 8;
265
266    /// Per-apply flop estimate for one reduced-Schur matvec `S·x` of a
267    /// matrix-free SAE Kronecker system, as a pure function of the system shape.
268    ///
269    /// Per row block `i` the apply does: a forward cross-block GEMV
270    /// `v_i = H_tβ^(i)·x` (`≈ 2·d·k` multiply-adds, with the per-row latent
271    /// depth `d` as the M-frame width and `k` the border), a `d×d` triangular
272    /// solve through the cached Cholesky factor (`≈ d²`), and a transpose
273    /// cross-block GEMV `H_βt^(i)·w_i` (`≈ 2·d·k`). The two `2·d·k` GEMVs would
274    /// sum to `4·d·k`; this estimate deliberately undercounts to a single
275    /// `2·d·k` cross term as a conservative (lower-bound) admission floor, so
276    /// the apply is modelled as `≈ n·(2·d·k + d²)`. This is a deliberate
277    /// lower bound on the true `≈ n·(4·d·k + d²)` arithmetic — admitting a
278    /// shape under the smaller figure can only be more conservative, never
279    /// over-eager. It is keyed on the *frame depth* `d` (M) and border width
280    /// `k` (p), not row count alone, so LLM shapes (few rows, wide `k`, modest
281    /// `d`) register arithmetic the row-count gate misses.
282    ///
283    /// USE FOR DISPATCH GATING ONLY. This is **not** a flop count: it omits the
284    /// transpose cross-block GEMV (`2·d·k`), so it is a strict lower bound on the
285    /// true per-apply work `n·(4·d·k + d²)`. The gate can therefore only
286    /// under-admit, never over-admit. Do not reuse it for benchmark / speedup
287    /// accounting.
288    const fn admission_work_lower_bound(n: usize, k: usize, d: usize) -> u128 {
289        let n = n as u128;
290        let k = k as u128;
291        let d = d as u128;
292        // 2·d·k cross-block apply (forward only) + d² per-row solve — the
293        // transpose GEMV is intentionally dropped so this stays a lower bound.
294        n.saturating_mul(
295            2u128
296                .saturating_mul(d)
297                .saturating_mul(k)
298                .saturating_add(d * d),
299        )
300    }
301
302    /// Work-based admission for offloading the **reduced-Schur PCG matvec**
303    /// (the InexactPCG hot loop for matrix-free SAE β-blocks) to the device.
304    ///
305    /// This is the Phase-1 (#1017) re-keying: the dense gates key on row count
306    /// (`xtwx_n_min`, `row_kernel_min_n` at 50k) or a single big-factorization
307    /// flop floor, neither of which the SAE LLM shape trips — `(n≈2000) ×
308    /// (k≈2048) × (d≈8)` is *thousands of small dense ops*, no single op large,
309    /// so the row-count gate keeps the whole fit on one CPU core. Here the gate
310    /// is the **total batched work over the CG solve**:
311    ///
312    /// ```text
313    /// estimated_device_flops = cg_iters · per_apply_flops(n, k, d)
314    /// should_offload = estimated_device_flops ≥ T_breakeven
315    /// ```
316    ///
317    /// where `T_breakeven = MATVEC_OFFLOAD_FLOPS_MIN` accounts for the
318    /// host↔device staging of the row frames + CG vectors amortised over the
319    /// `cg_iters` applies that reuse the resident frames (so the per-flop
320    /// transfer cost is `1/cg_iters` of a cold launch, an order of magnitude
321    /// below the dense-Direct floor).
322    ///
323    /// Pure function of the shape: no device needed to evaluate, so it is unit-
324    /// testable. The caller still falls back to the bit-identical CPU matvec
325    /// whenever the backend build declines, so admitting a shape never changes
326    /// the numerics — only where the `Σ_i Y_iᵀ(Y_i x)` flops execute.
327    ///
328    /// * `n`        — number of row blocks (SAE observations / latent rows).
329    /// * `k`        — border β width (the SAE decoder atom count `K`).
330    /// * `d`        — per-row latent / active-frame depth (the M dimension).
331    /// * `cg_iters` — expected PCG iteration budget; the per-apply work is
332    ///   multiplied by this because the frames stay resident across iterations.
333    ///   Pass [`Self::MATVEC_OFFLOAD_MIN_CG_ITERS`] when no measured budget is
334    ///   available; a tighter (smaller) value only makes the gate stricter.
335    ///
336    /// ## Live arrow-Schur call site
337    ///
338    /// `crate::solver::arrow_schur::maybe_inject_gpu_schur_matvec` gates the
339    /// InexactPCG reduced-Schur matvec injection on this predicate:
340    /// `reduced_schur_matvec_should_offload(sys.rows.len(), sys.k, sys.d,
341    /// options.pcg.max_iterations.min(options.trust_region.max_iterations))`,
342    /// where `sys.d` is the system's max per-row latent depth and the iteration
343    /// budget is the same `max_iterations` the PCG loop launches with.
344    /// `try_device_arrow_direct` (the **dense** Direct point solve) correctly
345    /// keeps `dense_hessian_work_target_is_gpu`: that path is a single large
346    /// factorization, not the amortised matvec.
347    pub const fn reduced_schur_matvec_should_offload(
348        &self,
349        n: usize,
350        k: usize,
351        d: usize,
352        cg_iters: usize,
353    ) -> bool {
354        if n == 0 || k == 0 || d == 0 || cg_iters == 0 {
355            return false;
356        }
357        // The border width must clear the device-loop floor: below it the per-
358        // apply launch latency (one kernel sequence per matvec) dominates any
359        // arithmetic regardless of how many CG iterations run.
360        if k < Self::DEVICE_LOOP_MIN_P {
361            return false;
362        }
363        let per_apply = Self::admission_work_lower_bound(n, k, d);
364        let total = per_apply.saturating_mul(cg_iters as u128);
365        let floor = if d == 1 {
366            Self::THIN_CURVE_MATVEC_OFFLOAD_FLOPS_MIN
367        } else {
368            Self::MATVEC_OFFLOAD_FLOPS_MIN
369        };
370        total >= floor
371    }
372}
373
374/// Factorization strategy for the arrow-Schur border (shared `β`) solve, chosen
375/// from the *shape* of the joint system rather than a single fixed border-width
376/// cut (`ArrowSolverMode::automatic`'s `DIRECT_SOLVE_MAX_K = 2000`).
377///
378/// The border width alone is a blunt selector: it cannot see that the data-fit
379/// contribution to the `k × k` border is only rank `Σ_i d_i ≈ n·d`. For the
380/// #1017 color arm (`n = 180`, per-row depth `d = 2`, border `k = 15360`) the
381/// data information is rank `360` yet a dense Direct solve pays a full `k³/3 ≈
382/// 1.2e12`-flop Cholesky — the measured 26-min-class fit. This maps cleanly onto
383/// the two `ArrowSolverMode` variants the solver already implements.
384#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
385pub enum ArrowBorderStrategy {
386    /// Eliminate the per-row blocks, form the dense `k × k` reduced Schur, and
387    /// Cholesky-factor it (`ArrowSolverMode::Direct`). Appropriate for modest,
388    /// near-square borders where the `k³/3` factorization is cheap and the
389    /// data-fit rank is comparable to `k`.
390    DenseDirect,
391    /// Solve the reduced Schur iteratively by matrix-free PCG
392    /// (`ArrowSolverMode::InexactPCG`), never materialising the `k × k` factor.
393    /// Appropriate when the dense `k³` factorization dominates and/or the
394    /// data-fit contribution to the border is rank-deficient (`n·d < k`).
395    ReducedIterative,
396}
397
398/// Cost model + recommendation for the arrow-Schur border solve, a pure function
399/// of the joint-system shape (unit-testable, no device required).
400///
401/// This operationalises the measured #1017 finding that the full arrow-Schur
402/// Newton solve is dominated by the dense `k × k` border Cholesky (the on-device
403/// dense Direct solve was measured at ~0.94× — a slowdown — because the `k³/3`
404/// factorization, not the GPU-favourable batched per-row work, is the bottleneck
405/// at LLM/SAE border widths). The lever the issue calls for is to *shrink or
406/// factor the dense border* so the batched `n`-row work dominates; the plan
407/// makes that decision inspectable and honest.
408///
409/// ## Flop model (deliberate, documented approximations)
410///
411/// * **Dense Direct** ≈ `2·n·d·k²` (assemble the reduced Schur: per row a
412///   rank-`d` symmetric update `H_βt (H_tt)⁻¹ H_tβ` to the `k × k` border,
413///   `≈ 2·d·k²` flops) `+ k³/3` (Cholesky of the dense `k × k` Schur).
414/// * **Reduced iterative** ≈ `cg_iters · n·(4·d·k + d²)` (matrix-free PCG:
415///   per matvec a forward + transpose cross-block GEMV `4·d·k` plus the per-row
416///   `d × d` solve `d²`, summed over `n` row blocks, over `cg_iters` applies).
417///
418/// Both are dispatch-grade estimates, not exact operation counts; they omit
419/// preconditioner setup and lower-order terms symmetrically, so their ratio (the
420/// only thing the recommendation consumes) is meaningful while neither figure
421/// should be reused for speedup accounting.
422///
423/// ## Status
424///
425/// Advisory / diagnostic. It is **not** wired into the live
426/// `ArrowSolverMode::automatic` selector: replacing the fixed `DIRECT_SOLVE_MAX_K`
427/// cut with this shape-driven crossover changes which production fits take the
428/// Direct vs PCG path and must be validated on GPU hardware (#1017 Phase 2–4)
429/// before it can change numerics. Today it is consumed by the honest
430/// `examples/full_color_fit_1017.rs` measurement harness (modeled-vs-measured)
431/// and by the unit tests below.
432#[derive(Clone, Copy, Debug, Eq, PartialEq)]
433pub struct ArrowBorderSolvePlan {
434    /// Number of per-row blocks (SAE observations / latent rows).
435    pub n: usize,
436    /// Border `β` width (the SAE decoder atom count `K` × basis width).
437    pub k: usize,
438    /// Per-row latent / active-frame depth (the `M` dimension).
439    pub d: usize,
440    /// CG iteration budget assumed for the iterative estimate.
441    pub cg_iters: usize,
442    /// Effective rank of the data-fit contribution to the `k × k` border,
443    /// bounded by `Σ_i d_i ≈ n·d` and never more than `k`.
444    pub data_fit_rank: usize,
445    /// True when `n·d < k`: the dense `k × k` Cholesky spends `O(k³)` factorising
446    /// a border whose data information is only rank `n·d` — the pathological
447    /// wide-sparse-border regime (color arm: `n·d = 360 ≪ k = 15360`).
448    pub dense_border_rank_deficient: bool,
449    /// `≈ 2·n·d·k² + k³/3` — reduced-Schur assembly plus dense border Cholesky.
450    pub dense_direct_flops: u128,
451    /// `≈ cg_iters · n·(4·d·k + d²)` — matrix-free PCG matvecs.
452    pub reduced_iterative_flops: u128,
453    /// The recommended strategy. `DenseDirect` is chosen only for a full-rank
454    /// border (`n·d ≥ k`, so the `k × k` reduced Schur is non-singular and its
455    /// Cholesky exists) whose `k³/3` border factorization is no costlier than
456    /// the matrix-free CG solve at `cg_iters`; otherwise `ReducedIterative`. A
457    /// rank-deficient border is always `ReducedIterative` — a dense Cholesky of
458    /// a singular border does not exist.
459    pub recommended: ArrowBorderStrategy,
460    /// Whether running the *recommended* strategy on the device is expected to
461    /// pay off. For `ReducedIterative` this is `reduced_schur_matvec_should_offload`;
462    /// for `DenseDirect` the device wins only when the batched per-row assembly
463    /// work (`2·n·d·k²`, GPU-favourable batched GEMM/POTRF) at least matches the
464    /// border Cholesky (`k³/3`) *and* clears the dense flop floor — the honest
465    /// encoding of the measured 0.94× dense-Direct-on-device slowdown.
466    pub device_favorable: bool,
467}
468
469impl GpuDispatchPolicy {
470    /// Assembly flops for the dense reduced Schur: per row a rank-`d` update to
471    /// the `k × k` border (`≈ 2·d·k²`), summed over `n` rows.
472    const fn dense_schur_assembly_flops(n: usize, k: usize, d: usize) -> u128 {
473        2u128
474            .saturating_mul(n as u128)
475            .saturating_mul(d as u128)
476            .saturating_mul((k as u128).saturating_mul(k as u128))
477    }
478
479    /// Cholesky flops for the dense `k × k` reduced Schur: `≈ k³/3`.
480    const fn dense_border_cholesky_flops(k: usize) -> u128 {
481        let k = k as u128;
482        k.saturating_mul(k).saturating_mul(k) / 3
483    }
484
485    /// Total matrix-free PCG flops: `cg_iters · n·(4·d·k + d²)`.
486    const fn reduced_iterative_flops(n: usize, k: usize, d: usize, cg_iters: usize) -> u128 {
487        let n = n as u128;
488        let k = k as u128;
489        let d = d as u128;
490        let per_apply = n.saturating_mul(
491            4u128
492                .saturating_mul(d)
493                .saturating_mul(k)
494                .saturating_add(d.saturating_mul(d)),
495        );
496        per_apply.saturating_mul(cg_iters as u128)
497    }
498
499    /// Build the shape-driven [`ArrowBorderSolvePlan`] for a joint arrow-Schur
500    /// system with `n` row blocks, border width `k`, per-row depth `d`, and an
501    /// assumed CG budget `cg_iters` (pass
502    /// [`Self::MATVEC_OFFLOAD_MIN_CG_ITERS`] when none is measured; a smaller
503    /// value only biases the recommendation toward `DenseDirect`, never the
504    /// reverse).
505    ///
506    /// Degenerate shapes (`n`, `k`, or `d` zero) return an all-zero plan
507    /// recommending `DenseDirect` (the trivial/empty solve stays on the simple
508    /// path) with `device_favorable = false`.
509    pub fn arrow_border_solve_plan(
510        &self,
511        n: usize,
512        k: usize,
513        d: usize,
514        cg_iters: usize,
515    ) -> ArrowBorderSolvePlan {
516        if n == 0 || k == 0 || d == 0 {
517            return ArrowBorderSolvePlan {
518                n,
519                k,
520                d,
521                cg_iters,
522                data_fit_rank: 0,
523                dense_border_rank_deficient: false,
524                dense_direct_flops: 0,
525                reduced_iterative_flops: 0,
526                recommended: ArrowBorderStrategy::DenseDirect,
527                device_favorable: false,
528            };
529        }
530
531        let assembly = Self::dense_schur_assembly_flops(n, k, d);
532        let border_chol = Self::dense_border_cholesky_flops(k);
533        let dense_direct_flops = assembly.saturating_add(border_chol);
534        let iters = if cg_iters == 0 { 1 } else { cg_iters };
535        let reduced_iterative_flops = Self::reduced_iterative_flops(n, k, d, iters);
536
537        let data_fit_rank = (n.saturating_mul(d)).min(k);
538        let dense_border_rank_deficient = n.saturating_mul(d) < k;
539
540        // Recommend the exact dense factorization only when it is both VALID and
541        // not the bottleneck:
542        //   * Validity — a rank-deficient border (`n·d < k`) has a singular
543        //     `k × k` reduced Schur, so its Cholesky does not exist. DenseDirect
544        //     is inadmissible there and we must solve matrix-free. (The pure
545        //     assembly+Cholesky-vs-iterative flop rule this replaced ignored
546        //     rank and could recommend factorizing a provably-singular border
547        //     whenever the small-`k` dense flops happened to be the cheaper
548        //     count — e.g. `n=1, d=1, k=2`.)
549        //   * Cost — the reduced-Schur reduction is an embarrassingly-parallel
550        //     batched GEMM whichever path runs; the term that scales badly with
551        //     border width is the `k³/3` Cholesky. Prefer the exact,
552        //     RHS-reusable, convergence-free dense solve while that Cholesky is
553        //     no costlier than the full matrix-free CG solve, and fall to
554        //     ReducedIterative once the `k³` factorization overtakes it.
555        let recommended =
556            if !dense_border_rank_deficient && border_chol <= reduced_iterative_flops {
557                ArrowBorderStrategy::DenseDirect
558            } else {
559                ArrowBorderStrategy::ReducedIterative
560            };
561
562        let device_favorable = match recommended {
563            ArrowBorderStrategy::ReducedIterative => {
564                self.reduced_schur_matvec_should_offload(n, k, d, iters)
565            }
566            ArrowBorderStrategy::DenseDirect => {
567                // Dense Direct wins on device only when the batched per-row
568                // assembly work dominates the (poorly GPU-scaling, and here
569                // rank-deficient) border Cholesky, and the total clears the
570                // dense reduction floor. This is the honest encoding of the
571                // measured 0.94× on-device dense-Direct slowdown: when the k³
572                // Cholesky dominates, stay on the CPU.
573                assembly >= border_chol && dense_direct_flops >= self.dense_reduction_flops_min()
574            }
575        };
576
577        ArrowBorderSolvePlan {
578            n,
579            k,
580            d,
581            cg_iters: iters,
582            data_fit_rank,
583            dense_border_rank_deficient,
584            dense_direct_flops,
585            reduced_iterative_flops,
586            recommended,
587            device_favorable,
588        }
589    }
590}
591
592/// The aspirational single-GPU design-row throughput the #1412 decision gate is
593/// supposed to establish for the LLM-shape batched-Cholesky + tile-GEMM fit
594/// pipeline: 100 000 design rows processed per wall-clock second per device.
595///
596/// The original gate *claimed* this number without ever measuring it. The
597/// honest contract is the other way around: a benchmark
598/// (`examples/throughput_1412.rs`) measures the true rows/sec on a real device,
599/// and [`GpuThroughputVerdict::from_measurement`] reports whether the measured
600/// value meets the target — the verdict is a *function of the measurement*, not
601/// a hardcoded assertion. See `tests/owed_1412.rs`.
602pub const GPU_THROUGHPUT_TARGET_ROWS_PER_SEC: f64 = 100_000.0;
603
604/// Outcome of comparing a *measured* GPU throughput against the target. The
605/// only way to construct one is [`Self::from_measurement`], so a verdict can
606/// never assert a target that was not actually established by a measurement.
607#[derive(Clone, Copy, Debug, PartialEq)]
608pub struct GpuThroughputVerdict {
609    /// The measured design-rows-per-second on the device under test.
610    pub measured_rows_per_sec: f64,
611    /// The target the measurement is compared against.
612    pub target_rows_per_sec: f64,
613    /// `measured / target`. ≥ 1.0 means the target was established.
614    pub fraction_of_target: f64,
615    /// True iff `measured_rows_per_sec >= target_rows_per_sec`.
616    pub meets_target: bool,
617}
618
619impl GpuThroughputVerdict {
620    /// Build a verdict from a measured throughput against
621    /// [`GPU_THROUGHPUT_TARGET_ROWS_PER_SEC`]. A non-finite or non-positive
622    /// measurement can never meet the target (it is not a usable measurement).
623    #[inline]
624    pub fn from_measurement(measured_rows_per_sec: f64) -> Self {
625        Self::from_measurement_against(measured_rows_per_sec, GPU_THROUGHPUT_TARGET_ROWS_PER_SEC)
626    }
627
628    /// Build a verdict against an explicit target (used by tests that probe the
629    /// comparison logic without depending on the global target constant).
630    #[inline]
631    pub fn from_measurement_against(measured_rows_per_sec: f64, target_rows_per_sec: f64) -> Self {
632        let usable = measured_rows_per_sec.is_finite() && measured_rows_per_sec > 0.0;
633        let fraction_of_target = if usable && target_rows_per_sec > 0.0 {
634            measured_rows_per_sec / target_rows_per_sec
635        } else {
636            0.0
637        };
638        Self {
639            measured_rows_per_sec,
640            target_rows_per_sec,
641            fraction_of_target,
642            meets_target: usable && measured_rows_per_sec >= target_rows_per_sec,
643        }
644    }
645}
646
647/// Why a Stage-3 encode deployment decision could not be made from a real device
648/// measurement (#988, #1412). Each variant is a state in which the
649/// `100_000` rows/sec/GPU target was neither established NOR refuted on a
650/// device — the decision is blocked on hardware, not green-washed from a CPU
651/// proxy.
652#[derive(Clone, Copy, Debug, PartialEq, Eq)]
653pub enum EncodeDecisionBlocked {
654    /// No CUDA device on this host: the exact encode could not be measured on a
655    /// device at all (a CPU rate cannot substitute — that was the #1412 defect).
656    NoDevice,
657    /// A device is present but there is no device-resident *exact-encode* kernel,
658    /// so the FULL per-row encode cannot be measured on the device. (The resident
659    /// normal-equations solve in [`crate::encode_throughput`] is only ONE
660    /// component of the encode, not the encode; a component measurement cannot
661    /// decide the encode surrogate question — #988.)
662    NoDeviceEncodeKernel,
663    /// A device is present and a measurement was attempted, but the device path
664    /// did not engage (false routing) — refused rather than reported as a pass.
665    DeviceNotEngaged,
666}
667
668/// Tri-state Stage-3 encode deployment / amortized-surrogate decision
669/// (#988, #1412).
670///
671/// The decision the throughput gate exists to make is empirical: does the EXACT
672/// per-row encode clear the `100_000` rows/sec/GPU deployment target on a real
673/// device? Only a real device measurement can answer it:
674///   * [`Self::Met`] — a device measurement CLEARED the target: ship the exact
675///     encode; the certified amortized surrogate is NOT needed.
676///   * [`Self::Unmet`] — a device measurement MISSED the target: the certified
677///     amortized surrogate becomes justified.
678///   * [`Self::Undetermined`] — no device measurement is available. The decision
679///     is BLOCKED on hardware; it is neither "surrogate unneeded" nor "surrogate
680///     justified".
681///
682/// The critical anti-green-wash property (#1412): there is NO constructor that
683/// takes a CPU rate. A CPU measurement, however fast, can never move the decision
684/// out of [`Self::Undetermined`]. Projecting a CPU rate through an assumed
685/// CPU→GPU factor to declare the target met was the exact #1412 defect and is
686/// structurally impossible here — [`Self::Met`] / [`Self::Unmet`] come only from
687/// [`Self::from_device_measurement`] with `engaged == true`.
688#[derive(Clone, Copy, Debug, PartialEq)]
689pub enum EncodeDeploymentDecision {
690    /// A device measurement established the deployment target.
691    Met {
692        /// The measured device rows/sec that cleared the target.
693        measured_rows_per_sec: f64,
694        /// The target it was compared against.
695        target_rows_per_sec: f64,
696    },
697    /// A device measurement fell short of the deployment target.
698    Unmet {
699        /// The measured device rows/sec that missed the target.
700        measured_rows_per_sec: f64,
701        /// The target it was compared against.
702        target_rows_per_sec: f64,
703    },
704    /// No device measurement is available; the decision is blocked on hardware.
705    Undetermined {
706        /// Why no device measurement could be made.
707        reason: EncodeDecisionBlocked,
708    },
709}
710
711impl EncodeDeploymentDecision {
712    /// The ONLY path to a `Met`/`Unmet` decision: a device measurement that
713    /// actually engaged the device and produced a usable rate. `engaged == false`
714    /// (false routing / CPU decline) or a non-finite / non-positive rate yields
715    /// [`Self::Undetermined`] — never a fabricated pass or fail.
716    #[must_use]
717    pub fn from_device_measurement(engaged: bool, measured_rows_per_sec: f64) -> Self {
718        Self::from_device_measurement_against(
719            engaged,
720            measured_rows_per_sec,
721            GPU_THROUGHPUT_TARGET_ROWS_PER_SEC,
722        )
723    }
724
725    /// [`Self::from_device_measurement`] against an explicit target (for tests
726    /// that probe the decision logic without the global target constant).
727    #[must_use]
728    pub fn from_device_measurement_against(
729        engaged: bool,
730        measured_rows_per_sec: f64,
731        target_rows_per_sec: f64,
732    ) -> Self {
733        let usable = measured_rows_per_sec.is_finite() && measured_rows_per_sec > 0.0;
734        if !engaged || !usable {
735            return Self::Undetermined {
736                reason: EncodeDecisionBlocked::DeviceNotEngaged,
737            };
738        }
739        if measured_rows_per_sec >= target_rows_per_sec {
740            Self::Met {
741                measured_rows_per_sec,
742                target_rows_per_sec,
743            }
744        } else {
745            Self::Unmet {
746                measured_rows_per_sec,
747                target_rows_per_sec,
748            }
749        }
750    }
751
752    /// Construct the blocked decision for a host that cannot measure the exact
753    /// encode on a device. This is the honest CPU-only / no-device-kernel outcome
754    /// — the deployment target is left undetermined rather than projected.
755    #[must_use]
756    pub fn blocked(reason: EncodeDecisionBlocked) -> Self {
757        Self::Undetermined { reason }
758    }
759
760    /// True ONLY when a device measurement cleared the target: the exact encode
761    /// ships and no surrogate is built. Never true from a CPU proxy.
762    #[must_use]
763    pub fn surrogate_unneeded(&self) -> bool {
764        matches!(self, Self::Met { .. })
765    }
766
767    /// True ONLY when a device measurement missed the target: the certified
768    /// amortized surrogate becomes justified. Never true without a measurement.
769    #[must_use]
770    pub fn surrogate_justified(&self) -> bool {
771        matches!(self, Self::Unmet { .. })
772    }
773
774    /// True when no device measurement is available and the decision is blocked
775    /// on hardware (neither [`Self::surrogate_unneeded`] nor
776    /// [`Self::surrogate_justified`]).
777    #[must_use]
778    pub fn is_undetermined(&self) -> bool {
779        matches!(self, Self::Undetermined { .. })
780    }
781}
782
783/// Which `(response, link)` family the Stage 3.3 device-resident PIRLS loop
784/// can evaluate without going through the Level-B raw-body NVRTC path.
785///
786/// Mirrors `PirlsRowFamily::ALL` at the policy layer so the predicate stays
787/// linkable from the CPU PIRLS entry without dragging a Linux-only enum into
788/// every host compilation unit.
789#[derive(Clone, Copy, Debug, Eq, PartialEq)]
790pub enum PirlsLoopFamilyKind {
791    BernoulliLogit,
792    BernoulliProbit,
793    BernoulliCLogLog,
794    PoissonLog,
795    GaussianIdentity,
796    GammaLog,
797}
798
799#[derive(Clone, Copy, Debug, Eq, PartialEq)]
800pub enum PirlsLoopCurvatureKind {
801    Fisher,
802    Observed,
803}
804
805/// Inputs to [`should_run_reml_outer_on_device`]. The admission predicate
806/// for routing the *outer* REML BFGS-over-ρ loop onto a fully device-resident
807/// driver (rather than the host orchestrator that hops out per step).
808///
809/// Fields are intentionally lifted from data the CPU REML entry has on hand
810/// before it touches the seed generator or the inner P-IRLS loop, so the
811/// admission check is allocation-free and can short-circuit before any
812/// device call.
813#[derive(Clone, Copy, Debug)]
814pub struct RemlOuterAdmission {
815    /// Active design rows (post-transform).
816    pub n: usize,
817    /// Active design columns / penalised-Hessian dimension.
818    pub p: usize,
819    /// Number of smoothing parameters ρ the outer BFGS optimises over.
820    pub num_rho: usize,
821    /// Inner family / link pair the device-resident PIRLS loop can evaluate.
822    /// `None` means the family does not map onto the six JIT-cached row
823    /// kernels — the outer loop must stay on the host orchestrator because
824    /// the inner step would already hop out anyway.
825    pub family: Option<PirlsLoopFamilyKind>,
826    /// Curvature surface the inner loop will use; tied to `family` via
827    /// `pirls_loop_curvature_for`.
828    pub curvature: PirlsLoopCurvatureKind,
829    /// True when the CUDA runtime is initialised on this host.
830    pub gpu_available: bool,
831}
832
833/// Inputs to [`should_use_gpu_pirls_loop`]. Each field comes from data the
834/// CPU PIRLS entry has on hand before it touches the eigendecomposition
835/// engine, so the admission check itself is allocation-free and can short-
836/// circuit before any heavy work happens.
837#[derive(Clone, Copy, Debug)]
838pub struct PirlsLoopAdmission {
839    /// Number of rows in the active (post-transform) design matrix.
840    pub n: usize,
841    /// Number of columns in the active design (i.e. `p` of `Xᵀ X`).
842    pub p: usize,
843    /// `Some(_)` when the inner family maps onto one of the six JIT-cached
844    /// `PirlsRowFamily` variants; `None` for custom families that still
845    /// require Stage 6 Level B and have not yet been admitted here.
846    pub family: Option<PirlsLoopFamilyKind>,
847    /// Curvature surface the inner loop will use; the GPU loop has Fisher +
848    /// Observed kernels, anything else (e.g. expected-projection surrogates)
849    /// is not admitted.
850    pub curvature: PirlsLoopCurvatureKind,
851    /// True when the CUDA runtime is initialised on this host (i.e.
852    /// lossless Auto resolution returned an available runtime).
853    pub gpu_available: bool,
854}
855
856impl GpuDispatchPolicy {
857    /// Minimum design column count for the device-resident inner/outer loops.
858    ///
859    /// Below this width the per-iteration `XᵀWX + Cholesky` is dominated by
860    /// launch latency and PCIe staging rather than arithmetic, so the host LM
861    /// loop (which populates the full `PirlsResult` surface as a free
862    /// side-effect) is strictly cheaper. Shared by both the inner PIRLS and
863    /// outer REML admission predicates so they cannot drift apart.
864    pub const DEVICE_LOOP_MIN_P: usize = 32;
865
866    /// Conservative admission predicate for routing
867    /// `fit_model_for_fixed_rho_with_adaptive_kkt` through the Stage 3.3
868    /// device-resident PIRLS loop instead of the CPU LM loop.
869    ///
870    /// The threshold is the dense `XᵀWX` work estimate, not row count alone:
871    /// LLM/SAE fits can have only a few thousand rows but thousands of columns,
872    /// so `2*n*p^2` already dwarfs launch/staging overhead. Smaller fits stay on
873    /// the CPU LM loop where the full `PirlsResult` surface (firth, EDF,
874    /// per-row weights, …) is already populated as a free side-effect of the
875    /// iteration.
876    pub const fn should_use_gpu_pirls_loop(&self, adm: PirlsLoopAdmission) -> bool {
877        if !adm.gpu_available {
878            return false;
879        }
880        if !self.dense_hessian_work_target_is_gpu(adm.n, adm.p) {
881            return false;
882        }
883        match adm.family {
884            Some(_) => true,
885            None => false,
886        }
887    }
888
889    /// Admission predicate for routing the outer REML BFGS-over-ρ loop onto
890    /// a device-resident driver that keeps the BFGS state (ρ, gradient,
891    /// Hessian approx) on-device and only downloads the per-step scalar
892    /// metrics (objective value, gradient norm, convergence flag).
893    ///
894    /// The dense-work threshold piggybacks on the existing inner-PIRLS admission
895    /// predicate because the device-resident outer loop calls
896    /// `pirls_loop_on_stream` per step and must not pay the host hop for small
897    /// fits the inner loop would have rejected anyway. The
898    /// `num_rho ≥ 2` floor rules out the trivial single-smoother case where
899    /// host orchestration is already negligible and the device BFGS state
900    /// (one length-`num_rho` gradient + a `num_rho × num_rho` Hessian
901    /// approx) collapses to a couple of scalars not worth keeping on device.
902    pub const fn should_run_reml_outer_on_device(&self, adm: RemlOuterAdmission) -> bool {
903        if !adm.gpu_available {
904            return false;
905        }
906        if !self.dense_hessian_work_target_is_gpu(adm.n, adm.p) {
907            return false;
908        }
909        if adm.num_rho < 2 {
910            return false;
911        }
912        match adm.family {
913            Some(_) => true,
914            None => false,
915        }
916    }
917}
918
919#[cfg(test)]
920mod refinement_policy_tests {
921    use super::*;
922
923    #[test]
924    fn refinement_policy_admits_large_p() {
925        let pol = GpuDispatchPolicy::default();
926        // Default policy is Refinement; large p should be admitted.
927        assert!(pol.iterative_refinement_should_attempt(512));
928        assert!(pol.iterative_refinement_should_attempt(GpuDispatchPolicy::REFINEMENT_MIN_P));
929    }
930
931    #[test]
932    fn refinement_policy_rejects_small_p() {
933        let pol = GpuDispatchPolicy::default();
934        assert!(!pol.iterative_refinement_should_attempt(GpuDispatchPolicy::REFINEMENT_MIN_P - 1));
935        assert!(!pol.iterative_refinement_should_attempt(0));
936    }
937
938    #[test]
939    fn off_policy_never_attempts_refinement() {
940        let pol = GpuDispatchPolicy {
941            mixed_precision: GpuMixedPrecisionPolicy::Off,
942            ..Default::default()
943        };
944        assert!(!pol.iterative_refinement_should_attempt(1024));
945    }
946
947    #[test]
948    fn never_policy_never_attempts_refinement() {
949        let pol = GpuDispatchPolicy {
950            mixed_precision: GpuMixedPrecisionPolicy::Never,
951            ..Default::default()
952        };
953        assert!(!pol.iterative_refinement_should_attempt(1024));
954    }
955}
956
957#[cfg(test)]
958mod fused_batch_dispatch_tests {
959    use super::*;
960
961    /// The dominant large-scale PG draw shape — one variate per data row per
962    /// Gibbs iteration — is admitted, and a batch small enough that launch and
963    /// staging dominate is refused. The refusal is the load-bearing half: a
964    /// predicate that admitted everything would let a dispatch-worthiness test
965    /// pass without saying anything about the shape it ran.
966    #[test]
967    fn polya_gamma_admits_large_batch_and_refuses_small() {
968        let pol = GpuDispatchPolicy::default();
969        assert!(pol.polya_gamma_batch_target_is_gpu(200_000));
970        assert!(pol.polya_gamma_batch_target_is_gpu(pol.fused_kernel_min_n));
971        assert!(!pol.polya_gamma_batch_target_is_gpu(pol.fused_kernel_min_n - 1));
972        assert!(!pol.polya_gamma_batch_target_is_gpu(16));
973        assert!(!pol.polya_gamma_batch_target_is_gpu(0));
974    }
975
976    /// Same contract for the batched per-row BMS kernels.
977    #[test]
978    fn row_batch_admits_large_and_refuses_small() {
979        let pol = GpuDispatchPolicy::default();
980        assert!(pol.row_batch_target_is_gpu(pol.row_kernel_min_n));
981        assert!(!pol.row_batch_target_is_gpu(pol.row_kernel_min_n - 1));
982        assert!(!pol.row_batch_target_is_gpu(0));
983    }
984
985    /// Both predicates are monotone in the row count: work only ever moves a
986    /// shape toward the device, never away from it. This is what lets a test
987    /// assert the decision once and have it hold for any larger fixture.
988    #[test]
989    fn both_predicates_are_monotone_in_rows() {
990        let pol = GpuDispatchPolicy::default();
991        for n in [0usize, 1, 1_000, 49_999, 50_000, 99_999, 100_000, 1_000_000] {
992            if pol.polya_gamma_batch_target_is_gpu(n) {
993                assert!(pol.polya_gamma_batch_target_is_gpu(n + 1));
994            }
995            if pol.row_batch_target_is_gpu(n) {
996                assert!(pol.row_batch_target_is_gpu(n + 1));
997            }
998        }
999    }
1000
1001    /// A device whose calibration measured a *faster* host CPU carries a higher
1002    /// crossover, and the same fixture is then honestly refused rather than
1003    /// reported as a kernel regression. This is the property the wall-clock
1004    /// ratio gates could not express: they read the box and were credited to
1005    /// the code.
1006    #[test]
1007    fn a_faster_host_cpu_raises_the_crossover_rather_than_failing_the_kernel() {
1008        let fast_host = GpuDispatchPolicy {
1009            fused_kernel_min_n: 4_000_000,
1010            row_kernel_min_n: 4_000_000,
1011            ..Default::default()
1012        };
1013        assert!(!fast_host.polya_gamma_batch_target_is_gpu(200_000));
1014        assert!(!fast_host.row_batch_target_is_gpu(200_000));
1015        assert!(fast_host.polya_gamma_batch_target_is_gpu(4_000_000));
1016    }
1017}
1018
1019#[cfg(test)]
1020mod reduced_schur_matvec_offload_tests {
1021    use super::*;
1022
1023    /// The LLM/SAE shape the whole #1017 Phase-1 re-keying targets: a few
1024    /// thousand row blocks, a *wide* border (decoder atom count in the
1025    /// thousands), a modest per-row frame depth, and a realistic CG budget.
1026    /// The row-count gate (50k) and the dense-Direct flop floor both miss this
1027    /// "thousands of tiny dense ops" shape; the work-amortised matvec gate must
1028    /// fire on it.
1029    #[test]
1030    fn admits_llm_sae_matvec_shape() {
1031        let pol = GpuDispatchPolicy::default();
1032        // n≈2000 rows, k≈2048 atoms, M≈8 frame depth — n is far below the 50k
1033        // row gate, yet the summed CG matvec work is large.
1034        assert!(pol.reduced_schur_matvec_should_offload(
1035            2_000,
1036            2_048,
1037            8,
1038            GpuDispatchPolicy::MATVEC_OFFLOAD_MIN_CG_ITERS,
1039        ));
1040        // The same shape would be rejected by the row-count-style dense gate,
1041        // confirming the re-keying is what admits it.
1042        assert!(!pol.dense_hessian_work_target_is_gpu(2_000, 8));
1043    }
1044
1045    /// Even with only a single conservative CG iteration the wide LLM border
1046    /// clears the breakeven (the per-apply work alone is `2_000·(2·8·2_048 +
1047    /// 8²) ≈ 6.6e7` flops > 1e7 by the conservative `n·(2·d·k + d²)` model;
1048    /// the true `n·(4·d·k + d²)` arithmetic is ≈1.3e8),
1049    /// so the gate is not relying on an inflated iteration count.
1050    #[test]
1051    fn admits_llm_shape_with_one_cg_iter() {
1052        let pol = GpuDispatchPolicy::default();
1053        assert!(pol.reduced_schur_matvec_should_offload(2_000, 2_048, 8, 1));
1054    }
1055
1056    /// #1783: the primary manifold-SAE regime is a `d_atom = 1` curve
1057    /// dictionary.  Its scalar row frames have much lower staging cost than the
1058    /// general framed matvec, so realistic token blocks must not be stranded on
1059    /// the CPU merely because the conservative admission lower bound is thin in
1060    /// `d`.
1061    #[test]
1062    fn admits_thin_curve_atoms_at_realistic_scale() {
1063        let pol = GpuDispatchPolicy::default();
1064        assert!(pol.reduced_schur_matvec_should_offload(24_576, 64, 1, 1));
1065        assert!(pol.reduced_schur_matvec_should_offload(40_456, 256, 1, 1));
1066        assert!(!pol.reduced_schur_matvec_should_offload(300, 6, 1, 8));
1067    }
1068
1069    /// Tiny shapes where the host↔device transfer dominates must stay on the
1070    /// CPU: a handful of rows, a narrow border, shallow frames. The summed
1071    /// matvec work is orders of magnitude below the staging breakeven.
1072    #[test]
1073    fn rejects_tiny_shape_where_transfer_dominates() {
1074        let pol = GpuDispatchPolicy::default();
1075        assert!(!pol.reduced_schur_matvec_should_offload(
1076            30,
1077            8,
1078            2,
1079            GpuDispatchPolicy::MATVEC_OFFLOAD_MIN_CG_ITERS,
1080        ));
1081        // The 300×8 shape the production seam tests use as the "stay CPU"
1082        // canary is rejected here too.
1083        assert!(!pol.reduced_schur_matvec_should_offload(300, 8, 4, 16));
1084    }
1085
1086    /// A narrow border (k below the device-loop floor) is rejected regardless
1087    /// of how much row/iteration work is piled on: per-apply launch latency
1088    /// dominates a sub-`DEVICE_LOOP_MIN_P` border.
1089    #[test]
1090    fn rejects_narrow_border_even_with_huge_row_count() {
1091        let pol = GpuDispatchPolicy::default();
1092        let narrow = GpuDispatchPolicy::DEVICE_LOOP_MIN_P - 1;
1093        assert!(!pol.reduced_schur_matvec_should_offload(1_000_000, narrow, 64, 64));
1094    }
1095
1096    /// Degenerate dimensions are never offloaded (no work, or no solve).
1097    #[test]
1098    fn rejects_degenerate_dimensions() {
1099        let pol = GpuDispatchPolicy::default();
1100        assert!(!pol.reduced_schur_matvec_should_offload(0, 2_048, 8, 8));
1101        assert!(!pol.reduced_schur_matvec_should_offload(2_000, 0, 8, 8));
1102        assert!(!pol.reduced_schur_matvec_should_offload(2_000, 2_048, 0, 8));
1103        assert!(!pol.reduced_schur_matvec_should_offload(2_000, 2_048, 8, 0));
1104    }
1105
1106    /// The gate is monotone in the CG budget: once a shape is admitted at a
1107    /// given iteration count it stays admitted for any larger count (more
1108    /// applies over the same resident frames only improves amortization), and
1109    /// a borderline shape crosses the breakeven as iterations grow.
1110    #[test]
1111    fn monotone_in_cg_iters() {
1112        let pol = GpuDispatchPolicy::default();
1113        // A border at the floor with shallow frames and few rows: per-apply
1114        // work ~ n·(2·d·k + d²). Choose a shape that is below breakeven at 1
1115        // iter but above it once enough iterations accumulate.
1116        let (n, k, d) = (200usize, GpuDispatchPolicy::DEVICE_LOOP_MIN_P, 4usize);
1117        // per_apply ≈ 200·(2·4·32 + 16) = 200·272 = 54_400 flops.
1118        assert!(!pol.reduced_schur_matvec_should_offload(n, k, d, 1));
1119        // Once the summed work clears 1e7 the gate fires; ~184 iters here.
1120        assert!(pol.reduced_schur_matvec_should_offload(n, k, d, 1_000));
1121        // Monotonicity: admitted at 1_000 ⇒ admitted at every larger budget.
1122        assert!(pol.reduced_schur_matvec_should_offload(n, k, d, 5_000));
1123    }
1124
1125    /// The admission lower bound must stay strictly below the true per-apply
1126    /// work `n·(4·d·k + d²)` for any non-degenerate cross-block shape (it drops
1127    /// the transpose GEMV). Treating the lower bound as a flop count would
1128    /// over-report device speedups, so this asserts the gap is real.
1129    #[test]
1130    fn admission_lower_bound_undercounts_actual_work() {
1131        for &(n, k, d) in &[
1132            (2_000usize, 2_048usize, 8usize),
1133            (200, GpuDispatchPolicy::DEVICE_LOOP_MIN_P, 4),
1134            (1, 1, 1),
1135        ] {
1136            let lower = GpuDispatchPolicy::admission_work_lower_bound(n, k, d);
1137            // True per-apply work models the full forward+transpose GEMV pair
1138            // plus the d×d solve: n·(4·d·k + d²).
1139            let actual = (n as u128) * (4 * (d as u128) * (k as u128) + (d as u128) * (d as u128));
1140            assert!(
1141                lower < actual,
1142                "admission lower bound {lower} must undercount actual work {actual} for ({n},{k},{d})"
1143            );
1144        }
1145    }
1146}
1147
1148#[cfg(test)]
1149mod arrow_border_solve_plan_tests {
1150    use super::*;
1151
1152    /// The #1017 color arm — few rows, shallow per-row depth, a very wide border
1153    /// (`k = 15360 = 3 × 5120`). The dense `k³/3` Cholesky (`≈ 1.2e12` flops)
1154    /// dwarfs a matrix-free PCG solve at any realistic CG budget, and the border
1155    /// is grossly rank-deficient (`n·d = 360 ≪ k`). The plan must recommend
1156    /// `ReducedIterative` and flag the rank deficiency.
1157    #[test]
1158    fn color_arm_recommends_reduced_iterative_and_flags_rank_deficiency() {
1159        let pol = GpuDispatchPolicy::default();
1160        let plan = pol.arrow_border_solve_plan(180, 15_360, 2, 30);
1161        assert_eq!(plan.recommended, ArrowBorderStrategy::ReducedIterative);
1162        assert!(plan.dense_border_rank_deficient);
1163        assert_eq!(plan.data_fit_rank, 360);
1164        // The dense path is orders of magnitude more expensive here.
1165        assert!(plan.dense_direct_flops > plan.reduced_iterative_flops * 100);
1166        // The recommended (iterative) path is device-favorable at this shape:
1167        // the wide border × summed CG work clears the matvec offload floor.
1168        assert!(plan.device_favorable);
1169    }
1170
1171    /// A modest, near-square border where the data-fit rank is comparable to `k`
1172    /// and the `k³/3` Cholesky is cheap: dense Direct is the right call.
1173    #[test]
1174    fn small_square_border_recommends_dense_direct() {
1175        let pol = GpuDispatchPolicy::default();
1176        // n·d = 400 > k = 64: not rank-deficient; a 64³/3 Cholesky is trivial.
1177        let plan = pol.arrow_border_solve_plan(200, 64, 2, 8);
1178        assert_eq!(plan.recommended, ArrowBorderStrategy::DenseDirect);
1179        assert!(!plan.dense_border_rank_deficient);
1180        assert_eq!(plan.data_fit_rank, 64);
1181    }
1182
1183    /// The rank-deficiency flag is exactly `n·d < k`, and `data_fit_rank` is
1184    /// clamped at `k` (the border can carry no more than `k` data directions).
1185    #[test]
1186    fn rank_flag_and_clamp_track_n_d_versus_k() {
1187        let pol = GpuDispatchPolicy::default();
1188        // n·d == k exactly: full-rank border, not deficient.
1189        let exact = pol.arrow_border_solve_plan(50, 100, 2, 8);
1190        assert!(!exact.dense_border_rank_deficient);
1191        assert_eq!(exact.data_fit_rank, 100);
1192        // n·d one below k: deficient.
1193        let deficient = pol.arrow_border_solve_plan(49, 100, 2, 8);
1194        assert!(deficient.dense_border_rank_deficient);
1195        assert_eq!(deficient.data_fit_rank, 98);
1196    }
1197
1198    /// The recommendation is monotone toward `ReducedIterative` as the border
1199    /// widens at fixed row work: once the dense `k³` term overtakes the linear-
1200    /// in-`k` iterative cost, growing `k` keeps it recommending iterative.
1201    #[test]
1202    fn wider_border_only_moves_toward_iterative() {
1203        let pol = GpuDispatchPolicy::default();
1204        let narrow = pol.arrow_border_solve_plan(200, 128, 4, 16);
1205        let wide = pol.arrow_border_solve_plan(200, 8_192, 4, 16);
1206        // The wide border must recommend iterative.
1207        assert_eq!(wide.recommended, ArrowBorderStrategy::ReducedIterative);
1208        // If the narrow one already recommends iterative, the wide one still
1209        // does (monotone); if not, the wide one is a strict switch. Either way
1210        // the wide border's dense/iterative flop ratio exceeds the narrow one's.
1211        let narrow_ratio = narrow.dense_direct_flops as f64 / narrow.reduced_iterative_flops as f64;
1212        let wide_ratio = wide.dense_direct_flops as f64 / wide.reduced_iterative_flops as f64;
1213        assert!(wide_ratio > narrow_ratio);
1214    }
1215
1216    /// A larger CG budget makes the iterative path more expensive, so the
1217    /// crossover can only move toward `DenseDirect`, never away from it. If a
1218    /// shape is `DenseDirect` at a small budget it stays `DenseDirect` at a
1219    /// larger one.
1220    #[test]
1221    fn larger_cg_budget_never_switches_away_from_dense() {
1222        let pol = GpuDispatchPolicy::default();
1223        let shape = (200usize, 96usize, 3usize);
1224        let small = pol.arrow_border_solve_plan(shape.0, shape.1, shape.2, 4);
1225        let large = pol.arrow_border_solve_plan(shape.0, shape.1, shape.2, 400);
1226        if small.recommended == ArrowBorderStrategy::DenseDirect {
1227            assert_eq!(large.recommended, ArrowBorderStrategy::DenseDirect);
1228        }
1229        assert!(large.reduced_iterative_flops >= small.reduced_iterative_flops);
1230    }
1231
1232    /// Degenerate shapes yield an all-zero plan on the trivial `DenseDirect`
1233    /// path and are never device-favorable.
1234    #[test]
1235    fn degenerate_shapes_are_trivial_dense_and_not_device_favorable() {
1236        let pol = GpuDispatchPolicy::default();
1237        for shape in [(0usize, 100usize, 2usize), (100, 0, 2), (100, 100, 0)] {
1238            let plan = pol.arrow_border_solve_plan(shape.0, shape.1, shape.2, 8);
1239            assert_eq!(plan.recommended, ArrowBorderStrategy::DenseDirect);
1240            assert!(!plan.device_favorable);
1241            assert_eq!(plan.dense_direct_flops, 0);
1242            assert_eq!(plan.reduced_iterative_flops, 0);
1243        }
1244    }
1245
1246    /// A zero CG budget is treated as one apply (a plan must still be
1247    /// comparable), never a divide-by-zero or an all-free iterative path.
1248    #[test]
1249    fn zero_cg_budget_is_treated_as_one_apply() {
1250        let pol = GpuDispatchPolicy::default();
1251        let plan = pol.arrow_border_solve_plan(180, 15_360, 2, 0);
1252        assert_eq!(plan.cg_iters, 1);
1253        assert!(plan.reduced_iterative_flops > 0);
1254    }
1255}
1256
1257#[cfg(test)]
1258mod encode_deployment_decision_tests {
1259    use super::*;
1260
1261    /// #1412 anti-green-wash core: a CPU rate can NEVER produce a `Met`/`Unmet`
1262    /// decision. The only Met/Unmet constructor requires `engaged == true`; a
1263    /// CPU-only host has no device measurement, so it can only ever be
1264    /// `Undetermined`, no matter how fast the CPU is.
1265    #[test]
1266    fn cpu_rate_can_never_meet_or_refute_the_target() {
1267        // Even a CPU rate a thousand times the target cannot certify the gate:
1268        // there is simply no `from_cpu_measurement` — the type has no such door.
1269        // The blocked constructor is the only CPU-side option.
1270        let cpu_only = EncodeDeploymentDecision::blocked(EncodeDecisionBlocked::NoDevice);
1271        assert!(cpu_only.is_undetermined());
1272        assert!(!cpu_only.surrogate_unneeded());
1273        assert!(!cpu_only.surrogate_justified());
1274
1275        // A "device" measurement that did not engage (false routing) is refused —
1276        // it becomes Undetermined even with a huge rate.
1277        let false_routed = EncodeDeploymentDecision::from_device_measurement(false, 1.0e9);
1278        assert!(false_routed.is_undetermined());
1279        assert!(!false_routed.surrogate_unneeded());
1280    }
1281
1282    #[test]
1283    fn engaged_measurement_decides_by_the_number() {
1284        let target = GPU_THROUGHPUT_TARGET_ROWS_PER_SEC;
1285        // Clears the target => Met => surrogate unneeded.
1286        let met = EncodeDeploymentDecision::from_device_measurement(true, target * 2.0);
1287        assert!(matches!(met, EncodeDeploymentDecision::Met { .. }));
1288        assert!(met.surrogate_unneeded());
1289        assert!(!met.surrogate_justified());
1290        assert!(!met.is_undetermined());
1291
1292        // Misses the target => Unmet => surrogate justified.
1293        let unmet = EncodeDeploymentDecision::from_device_measurement(true, target * 0.25);
1294        assert!(matches!(unmet, EncodeDeploymentDecision::Unmet { .. }));
1295        assert!(unmet.surrogate_justified());
1296        assert!(!unmet.surrogate_unneeded());
1297
1298        // Exact boundary meets the target.
1299        let boundary = EncodeDeploymentDecision::from_device_measurement(true, target);
1300        assert!(boundary.surrogate_unneeded());
1301    }
1302
1303    #[test]
1304    fn engaged_but_non_usable_rate_is_undetermined_not_a_pass() {
1305        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1306            let d = EncodeDeploymentDecision::from_device_measurement(true, bad);
1307            assert!(
1308                d.is_undetermined(),
1309                "an engaged-but-unusable rate {bad} must be Undetermined, not a decision"
1310            );
1311            assert!(!d.surrogate_unneeded());
1312            assert!(!d.surrogate_justified());
1313        }
1314    }
1315
1316    #[test]
1317    fn blocked_reasons_are_all_undetermined() {
1318        for reason in [
1319            EncodeDecisionBlocked::NoDevice,
1320            EncodeDecisionBlocked::NoDeviceEncodeKernel,
1321            EncodeDecisionBlocked::DeviceNotEngaged,
1322        ] {
1323            let d = EncodeDeploymentDecision::blocked(reason);
1324            assert!(d.is_undetermined());
1325            assert!(!d.surrogate_unneeded());
1326            assert!(!d.surrogate_justified());
1327        }
1328    }
1329}