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 xtwx_target_is_gpu(&self, n: usize, p: usize, materialized: bool) -> bool {
153        materialized && n > 0 && p > 0 && self.xtwx_flops(n, p) >= self.dense_reduction_flops_min()
154    }
155
156    pub const fn xtwy_target_is_gpu(
157        &self,
158        n: usize,
159        px: usize,
160        q: usize,
161        materialized: bool,
162    ) -> bool {
163        materialized
164            && n > 0
165            && px > 0
166            && q > 0
167            && self.xtwy_flops(n, px, q) >= self.dense_reduction_flops_min()
168    }
169
170    /// Whether a batched Pólya-Gamma draw of `n` rows is worth dispatching to
171    /// the device.
172    ///
173    /// A PG batch is a fused elementwise kernel: one independent rejection
174    /// sampler per row, no reduction and no cross-row reuse. So the row count
175    /// *is* the work, and what the device has to overcome is launch latency
176    /// plus the `n·(4 + 8)` bytes staged in and `n·8` staged back out — a
177    /// transfer/launch amortisation question rather than an arithmetic-intensity
178    /// one. That is exactly what `fused_kernel_min_n` carries: calibration sets
179    /// it to twice the device's *measured* XtWX crossover row count, so the
180    /// crossover is a per-device measurement rather than a tuned literal, and a
181    /// faster host CPU moves it up on that host instead of failing the kernel.
182    #[inline]
183    pub const fn polya_gamma_batch_target_is_gpu(&self, n: usize) -> bool {
184        n >= self.fused_kernel_min_n
185    }
186
187    pub const fn dense_hessian_work_target_is_gpu(&self, n: usize, p: usize) -> bool {
188        n > 0
189            && p >= Self::DEVICE_LOOP_MIN_P
190            && self.xtwx_flops(n, p) >= self.dense_reduction_flops_min()
191    }
192
193    const fn dense_reduction_flops_min(&self) -> u128 {
194        if self.xtwx_flops_min < self.gemm_min_flops {
195            self.xtwx_flops_min as u128
196        } else {
197            self.gemm_min_flops as u128
198        }
199    }
200
201    const fn xtwx_flops(&self, n: usize, p: usize) -> u128 {
202        2u128 * (n as u128) * (p as u128) * (p as u128)
203    }
204
205    const fn xtwy_flops(&self, n: usize, px: usize, q: usize) -> u128 {
206        2u128 * (n as u128) * (px as u128) * (q as u128)
207    }
208
209    /// Minimum total CG-amortised matvec flops below which the host↔device
210    /// transfer of the row frames + CG vectors is not repaid by the device
211    /// matvec, so the reduced-Schur PCG hot loop stays on the CPU.
212    ///
213    /// The dense-Direct path keys on `dense_reduction_flops_min` (a single big
214    /// factorization). The matrix-free SAE matvec is different: no single apply
215    /// trips that floor (each is a stack of `n` tiny `d×d` solves + sparse
216    /// `m·k` gather/scatter), but the *whole CG solve* runs the apply
217    /// `O(cg_iters)` times over the same resident frames. The device wins when
218    /// the **summed** matvec work over the solve exceeds the one-time staging
219    /// cost — so the gate keys on `cg_iters · per_apply_flops`, not one apply.
220    ///
221    /// Set one order of magnitude below the dense floor: the matvec frames stay
222    /// resident across CG iterations (uploaded once), so the per-flop transfer
223    /// amortization is `1/cg_iters` of a cold dense launch, and the breakeven
224    /// drops accordingly.
225    pub const MATVEC_OFFLOAD_FLOPS_MIN: u128 = 10_000_000;
226
227    /// Thin-curve (`d_atom = 1`) SAE dictionaries are the common manifold-SAE
228    /// production shape: each per-row frame is a scalar, so the staged device
229    /// payload is much smaller than the general `d > 1` row-frame bundle, while
230    /// the work is still a large batched gather/scatter over `K` atoms and `n`
231    /// rows.  Use a lower admission floor for this scalar-frame regime so a
232    /// realistic token block with a moderately wide curve dictionary is not kept
233    /// on the CPU solely because the conservative general-frame lower-bound
234    /// undercounts the transpose cross term.
235    pub const THIN_CURVE_MATVEC_OFFLOAD_FLOPS_MIN: u128 = 1_000_000;
236
237    /// Conservative seed for the reduced-Schur PCG iteration count when the
238    /// caller cannot supply a measured budget. InexactPCG on an SAE β-block of
239    /// width `k` converges in `O(√κ)` iterations; this floor keeps the work
240    /// estimate honest (≥ this many applies) without over-claiming a tight
241    /// solve. Used only to amortise the staging cost in the work estimate.
242    pub const MATVEC_OFFLOAD_MIN_CG_ITERS: usize = 8;
243
244    /// Per-apply flop estimate for one reduced-Schur matvec `S·x` of a
245    /// matrix-free SAE Kronecker system, as a pure function of the system shape.
246    ///
247    /// Per row block `i` the apply does: a forward cross-block GEMV
248    /// `v_i = H_tβ^(i)·x` (`≈ 2·d·k` multiply-adds, with the per-row latent
249    /// depth `d` as the M-frame width and `k` the border), a `d×d` triangular
250    /// solve through the cached Cholesky factor (`≈ d²`), and a transpose
251    /// cross-block GEMV `H_βt^(i)·w_i` (`≈ 2·d·k`). The two `2·d·k` GEMVs would
252    /// sum to `4·d·k`; this estimate deliberately undercounts to a single
253    /// `2·d·k` cross term as a conservative (lower-bound) admission floor, so
254    /// the apply is modelled as `≈ n·(2·d·k + d²)`. This is a deliberate
255    /// lower bound on the true `≈ n·(4·d·k + d²)` arithmetic — admitting a
256    /// shape under the smaller figure can only be more conservative, never
257    /// over-eager. It is keyed on the *frame depth* `d` (M) and border width
258    /// `k` (p), not row count alone, so LLM shapes (few rows, wide `k`, modest
259    /// `d`) register arithmetic the row-count gate misses.
260    ///
261    /// USE FOR DISPATCH GATING ONLY. This is **not** a flop count: it omits the
262    /// transpose cross-block GEMV (`2·d·k`), so it is a strict lower bound on the
263    /// true per-apply work `n·(4·d·k + d²)`. The gate can therefore only
264    /// under-admit, never over-admit. Do not reuse it for benchmark / speedup
265    /// accounting.
266    const fn admission_work_lower_bound(n: usize, k: usize, d: usize) -> u128 {
267        let n = n as u128;
268        let k = k as u128;
269        let d = d as u128;
270        // 2·d·k cross-block apply (forward only) + d² per-row solve — the
271        // transpose GEMV is intentionally dropped so this stays a lower bound.
272        n.saturating_mul(
273            2u128
274                .saturating_mul(d)
275                .saturating_mul(k)
276                .saturating_add(d * d),
277        )
278    }
279
280    /// Work-based admission for offloading the **reduced-Schur PCG matvec**
281    /// (the InexactPCG hot loop for matrix-free SAE β-blocks) to the device.
282    ///
283    /// This is the Phase-1 (#1017) re-keying: the dense gates key on row count
284    /// (`xtwx_n_min`, `row_kernel_min_n` at 50k) or a single big-factorization
285    /// flop floor, neither of which the SAE LLM shape trips — `(n≈2000) ×
286    /// (k≈2048) × (d≈8)` is *thousands of small dense ops*, no single op large,
287    /// so the row-count gate keeps the whole fit on one CPU core. Here the gate
288    /// is the **total batched work over the CG solve**:
289    ///
290    /// ```text
291    /// estimated_device_flops = cg_iters · per_apply_flops(n, k, d)
292    /// should_offload = estimated_device_flops ≥ T_breakeven
293    /// ```
294    ///
295    /// where `T_breakeven = MATVEC_OFFLOAD_FLOPS_MIN` accounts for the
296    /// host↔device staging of the row frames + CG vectors amortised over the
297    /// `cg_iters` applies that reuse the resident frames (so the per-flop
298    /// transfer cost is `1/cg_iters` of a cold launch, an order of magnitude
299    /// below the dense-Direct floor).
300    ///
301    /// Pure function of the shape: no device needed to evaluate, so it is unit-
302    /// testable. The caller still falls back to the bit-identical CPU matvec
303    /// whenever the backend build declines, so admitting a shape never changes
304    /// the numerics — only where the `Σ_i Y_iᵀ(Y_i x)` flops execute.
305    ///
306    /// * `n`        — number of row blocks (SAE observations / latent rows).
307    /// * `k`        — border β width (the SAE decoder atom count `K`).
308    /// * `d`        — per-row latent / active-frame depth (the M dimension).
309    /// * `cg_iters` — expected PCG iteration budget; the per-apply work is
310    ///   multiplied by this because the frames stay resident across iterations.
311    ///   Pass [`Self::MATVEC_OFFLOAD_MIN_CG_ITERS`] when no measured budget is
312    ///   available; a tighter (smaller) value only makes the gate stricter.
313    ///
314    /// ## Live arrow-Schur call site
315    ///
316    /// `crate::solver::arrow_schur::maybe_inject_gpu_schur_matvec` gates the
317    /// InexactPCG reduced-Schur matvec injection on this predicate:
318    /// `reduced_schur_matvec_should_offload(sys.rows.len(), sys.k, sys.d,
319    /// options.pcg.max_iterations.min(options.trust_region.max_iterations))`,
320    /// where `sys.d` is the system's max per-row latent depth and the iteration
321    /// budget is the same `max_iterations` the PCG loop launches with.
322    /// `try_device_arrow_direct` (the **dense** Direct point solve) correctly
323    /// keeps `dense_hessian_work_target_is_gpu`: that path is a single large
324    /// factorization, not the amortised matvec.
325    pub const fn reduced_schur_matvec_should_offload(
326        &self,
327        n: usize,
328        k: usize,
329        d: usize,
330        cg_iters: usize,
331    ) -> bool {
332        if n == 0 || k == 0 || d == 0 || cg_iters == 0 {
333            return false;
334        }
335        // The border width must clear the device-loop floor: below it the per-
336        // apply launch latency (one kernel sequence per matvec) dominates any
337        // arithmetic regardless of how many CG iterations run.
338        if k < Self::DEVICE_LOOP_MIN_P {
339            return false;
340        }
341        let per_apply = Self::admission_work_lower_bound(n, k, d);
342        let total = per_apply.saturating_mul(cg_iters as u128);
343        let floor = if d == 1 {
344            Self::THIN_CURVE_MATVEC_OFFLOAD_FLOPS_MIN
345        } else {
346            Self::MATVEC_OFFLOAD_FLOPS_MIN
347        };
348        total >= floor
349    }
350}
351
352/// Factorization strategy for the arrow-Schur border (shared `β`) solve, chosen
353/// from the *shape* of the joint system rather than a single fixed border-width
354/// cut (`ArrowSolverMode::automatic`'s `DIRECT_SOLVE_MAX_K = 2000`).
355///
356/// The border width alone is a blunt selector: it cannot see that the data-fit
357/// contribution to the `k × k` border is only rank `Σ_i d_i ≈ n·d`. For the
358/// #1017 color arm (`n = 180`, per-row depth `d = 2`, border `k = 15360`) the
359/// data information is rank `360` yet a dense Direct solve pays a full `k³/3 ≈
360/// 1.2e12`-flop Cholesky — the measured 26-min-class fit. This maps cleanly onto
361/// the two `ArrowSolverMode` variants the solver already implements.
362#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
363pub enum ArrowBorderStrategy {
364    /// Eliminate the per-row blocks, form the dense `k × k` reduced Schur, and
365    /// Cholesky-factor it (`ArrowSolverMode::Direct`). Appropriate for modest,
366    /// near-square borders where the `k³/3` factorization is cheap and the
367    /// data-fit rank is comparable to `k`.
368    DenseDirect,
369    /// Solve the reduced Schur iteratively by matrix-free PCG
370    /// (`ArrowSolverMode::InexactPCG`), never materialising the `k × k` factor.
371    /// Appropriate when the dense `k³` factorization dominates and/or the
372    /// data-fit contribution to the border is rank-deficient (`n·d < k`).
373    ReducedIterative,
374}
375
376/// Cost model + recommendation for the arrow-Schur border solve, a pure function
377/// of the joint-system shape (unit-testable, no device required).
378///
379/// This operationalises the measured #1017 finding that the full arrow-Schur
380/// Newton solve is dominated by the dense `k × k` border Cholesky (the on-device
381/// dense Direct solve was measured at ~0.94× — a slowdown — because the `k³/3`
382/// factorization, not the GPU-favourable batched per-row work, is the bottleneck
383/// at LLM/SAE border widths). The lever the issue calls for is to *shrink or
384/// factor the dense border* so the batched `n`-row work dominates; the plan
385/// makes that decision inspectable and honest.
386///
387/// ## Flop model (deliberate, documented approximations)
388///
389/// * **Dense Direct** ≈ `2·n·d·k²` (assemble the reduced Schur: per row a
390///   rank-`d` symmetric update `H_βt (H_tt)⁻¹ H_tβ` to the `k × k` border,
391///   `≈ 2·d·k²` flops) `+ k³/3` (Cholesky of the dense `k × k` Schur).
392/// * **Reduced iterative** ≈ `cg_iters · n·(4·d·k + d²)` (matrix-free PCG:
393///   per matvec a forward + transpose cross-block GEMV `4·d·k` plus the per-row
394///   `d × d` solve `d²`, summed over `n` row blocks, over `cg_iters` applies).
395///
396/// Both are dispatch-grade estimates, not exact operation counts; they omit
397/// preconditioner setup and lower-order terms symmetrically, so their ratio (the
398/// only thing the recommendation consumes) is meaningful while neither figure
399/// should be reused for speedup accounting.
400///
401/// ## Status
402///
403/// Advisory / diagnostic. It is **not** wired into the live
404/// `ArrowSolverMode::automatic` selector: replacing the fixed `DIRECT_SOLVE_MAX_K`
405/// cut with this shape-driven crossover changes which production fits take the
406/// Direct vs PCG path and must be validated on GPU hardware (#1017 Phase 2–4)
407/// before it can change numerics. Today it is consumed by the honest
408/// `examples/full_color_fit_1017.rs` measurement harness (modeled-vs-measured)
409/// and by the unit tests below.
410#[derive(Clone, Copy, Debug, Eq, PartialEq)]
411pub struct ArrowBorderSolvePlan {
412    /// Number of per-row blocks (SAE observations / latent rows).
413    pub n: usize,
414    /// Border `β` width (the SAE decoder atom count `K` × basis width).
415    pub k: usize,
416    /// Per-row latent / active-frame depth (the `M` dimension).
417    pub d: usize,
418    /// CG iteration budget assumed for the iterative estimate.
419    pub cg_iters: usize,
420    /// Effective rank of the data-fit contribution to the `k × k` border,
421    /// bounded by `Σ_i d_i ≈ n·d` and never more than `k`.
422    pub data_fit_rank: usize,
423    /// True when `n·d < k`: the dense `k × k` Cholesky spends `O(k³)` factorising
424    /// a border whose data information is only rank `n·d` — the pathological
425    /// wide-sparse-border regime (color arm: `n·d = 360 ≪ k = 15360`).
426    pub dense_border_rank_deficient: bool,
427    /// `≈ 2·n·d·k² + k³/3` — reduced-Schur assembly plus dense border Cholesky.
428    pub dense_direct_flops: u128,
429    /// `≈ cg_iters · n·(4·d·k + d²)` — matrix-free PCG matvecs.
430    pub reduced_iterative_flops: u128,
431    /// The recommended strategy. `DenseDirect` is chosen only for a full-rank
432    /// border (`n·d ≥ k`, so the `k × k` reduced Schur is non-singular and its
433    /// Cholesky exists) whose `k³/3` border factorization is no costlier than
434    /// the matrix-free CG solve at `cg_iters`; otherwise `ReducedIterative`. A
435    /// rank-deficient border is always `ReducedIterative` — a dense Cholesky of
436    /// a singular border does not exist.
437    pub recommended: ArrowBorderStrategy,
438    /// Whether running the *recommended* strategy on the device is expected to
439    /// pay off. For `ReducedIterative` this is `reduced_schur_matvec_should_offload`;
440    /// for `DenseDirect` the device wins only when the batched per-row assembly
441    /// work (`2·n·d·k²`, GPU-favourable batched GEMM/POTRF) at least matches the
442    /// border Cholesky (`k³/3`) *and* clears the dense flop floor — the honest
443    /// encoding of the measured 0.94× dense-Direct-on-device slowdown.
444    pub device_favorable: bool,
445}
446
447impl GpuDispatchPolicy {
448
449}
450
451/// The aspirational single-GPU design-row throughput the #1412 decision gate is
452/// supposed to establish for the LLM-shape batched-Cholesky + tile-GEMM fit
453/// pipeline: 100 000 design rows processed per wall-clock second per device.
454///
455/// The original gate *claimed* this number without ever measuring it. The
456/// honest contract is the other way around: a benchmark
457/// (`examples/throughput_1412.rs`) measures the true rows/sec on a real device,
458/// and `GpuThroughputVerdict::from_measurement` reports whether the measured
459/// value meets the target — the verdict is a *function of the measurement*, not
460/// a hardcoded assertion. See `tests/owed_1412.rs`.
461pub const GPU_THROUGHPUT_TARGET_ROWS_PER_SEC: f64 = 100_000.0;
462
463/// Outcome of comparing a *measured* GPU throughput against the target. The
464/// only way to construct one is `Self::from_measurement`, so a verdict can
465/// never assert a target that was not actually established by a measurement.
466#[derive(Clone, Copy, Debug, PartialEq)]
467pub struct GpuThroughputVerdict {
468    /// The measured design-rows-per-second on the device under test.
469    pub measured_rows_per_sec: f64,
470    /// The target the measurement is compared against.
471    pub target_rows_per_sec: f64,
472    /// `measured / target`. ≥ 1.0 means the target was established.
473    pub fraction_of_target: f64,
474    /// True iff `measured_rows_per_sec >= target_rows_per_sec`.
475    pub meets_target: bool,
476}
477
478impl GpuThroughputVerdict {
479
480}
481
482/// Why a Stage-3 encode deployment decision could not be made from a real device
483/// measurement (#988, #1412). Each variant is a state in which the
484/// `100_000` rows/sec/GPU target was neither established NOR refuted on a
485/// device — the decision is blocked on hardware, not green-washed from a CPU
486/// proxy.
487#[derive(Clone, Copy, Debug, PartialEq, Eq)]
488pub enum EncodeDecisionBlocked {
489    /// No CUDA device on this host: the exact encode could not be measured on a
490    /// device at all (a CPU rate cannot substitute — that was the #1412 defect).
491    NoDevice,
492    /// A device is present but there is no device-resident *exact-encode* kernel,
493    /// so the FULL per-row encode cannot be measured on the device. (The resident
494    /// normal-equations solve in [`crate::encode_throughput`] is only ONE
495    /// component of the encode, not the encode; a component measurement cannot
496    /// decide the encode surrogate question — #988.)
497    NoDeviceEncodeKernel,
498    /// A device is present and a measurement was attempted, but the device path
499    /// did not engage (false routing) — refused rather than reported as a pass.
500    DeviceNotEngaged,
501}
502
503/// Tri-state Stage-3 encode deployment / amortized-surrogate decision
504/// (#988, #1412).
505///
506/// The decision the throughput gate exists to make is empirical: does the EXACT
507/// per-row encode clear the `100_000` rows/sec/GPU deployment target on a real
508/// device? Only a real device measurement can answer it:
509///   * [`Self::Met`] — a device measurement CLEARED the target: ship the exact
510///     encode; the certified amortized surrogate is NOT needed.
511///   * [`Self::Unmet`] — a device measurement MISSED the target: the certified
512///     amortized surrogate becomes justified.
513///   * [`Self::Undetermined`] — no device measurement is available. The decision
514///     is BLOCKED on hardware; it is neither "surrogate unneeded" nor "surrogate
515///     justified".
516///
517/// The critical anti-green-wash property (#1412): there is NO constructor that
518/// takes a CPU rate. A CPU measurement, however fast, can never move the decision
519/// out of [`Self::Undetermined`]. Projecting a CPU rate through an assumed
520/// CPU→GPU factor to declare the target met was the exact #1412 defect and is
521/// structurally impossible here — [`Self::Met`] / [`Self::Unmet`] come only from
522/// `Self::from_device_measurement` with `engaged == true`.
523#[derive(Clone, Copy, Debug, PartialEq)]
524pub enum EncodeDeploymentDecision {
525    /// A device measurement established the deployment target.
526    Met {
527        /// The measured device rows/sec that cleared the target.
528        measured_rows_per_sec: f64,
529        /// The target it was compared against.
530        target_rows_per_sec: f64,
531    },
532    /// A device measurement fell short of the deployment target.
533    Unmet {
534        /// The measured device rows/sec that missed the target.
535        measured_rows_per_sec: f64,
536        /// The target it was compared against.
537        target_rows_per_sec: f64,
538    },
539    /// No device measurement is available; the decision is blocked on hardware.
540    Undetermined {
541        /// Why no device measurement could be made.
542        reason: EncodeDecisionBlocked,
543    },
544}
545
546impl EncodeDeploymentDecision {
547
548    /// Construct the blocked decision for a host that cannot measure the exact
549    /// encode on a device. This is the honest CPU-only / no-device-kernel outcome
550    /// — the deployment target is left undetermined rather than projected.
551    #[must_use]
552    pub fn blocked(reason: EncodeDecisionBlocked) -> Self {
553        Self::Undetermined { reason }
554    }
555
556}
557
558/// Which `(response, link)` family the Stage 3.3 device-resident PIRLS loop
559/// can evaluate without going through the Level-B raw-body NVRTC path.
560///
561/// Mirrors `PirlsRowFamily::ALL` at the policy layer so the predicate stays
562/// linkable from the CPU PIRLS entry without dragging a Linux-only enum into
563/// every host compilation unit.
564#[derive(Clone, Copy, Debug, Eq, PartialEq)]
565pub enum PirlsLoopFamilyKind {
566    BernoulliLogit,
567    BernoulliProbit,
568    BernoulliCLogLog,
569    PoissonLog,
570    GaussianIdentity,
571    GammaLog,
572}
573
574#[derive(Clone, Copy, Debug, Eq, PartialEq)]
575pub enum PirlsLoopCurvatureKind {
576    Fisher,
577    Observed,
578}
579
580/// Inputs to `should_run_reml_outer_on_device`. The admission predicate
581/// for routing the *outer* REML BFGS-over-ρ loop onto a fully device-resident
582/// driver (rather than the host orchestrator that hops out per step).
583///
584/// Fields are intentionally lifted from data the CPU REML entry has on hand
585/// before it touches the seed generator or the inner P-IRLS loop, so the
586/// admission check is allocation-free and can short-circuit before any
587/// device call.
588#[derive(Clone, Copy, Debug)]
589pub struct RemlOuterAdmission {
590    /// Active design rows (post-transform).
591    pub n: usize,
592    /// Active design columns / penalised-Hessian dimension.
593    pub p: usize,
594    /// Number of smoothing parameters ρ the outer BFGS optimises over.
595    pub num_rho: usize,
596    /// Inner family / link pair the device-resident PIRLS loop can evaluate.
597    /// `None` means the family does not map onto the six JIT-cached row
598    /// kernels — the outer loop must stay on the host orchestrator because
599    /// the inner step would already hop out anyway.
600    pub family: Option<PirlsLoopFamilyKind>,
601    /// Curvature surface the inner loop will use; tied to `family` via
602    /// `pirls_loop_curvature_for`.
603    pub curvature: PirlsLoopCurvatureKind,
604    /// True when the CUDA runtime is initialised on this host.
605    pub gpu_available: bool,
606}
607
608/// Inputs to `should_use_gpu_pirls_loop`. Each field comes from data the
609/// CPU PIRLS entry has on hand before it touches the eigendecomposition
610/// engine, so the admission check itself is allocation-free and can short-
611/// circuit before any heavy work happens.
612#[derive(Clone, Copy, Debug)]
613pub struct PirlsLoopAdmission {
614    /// Number of rows in the active (post-transform) design matrix.
615    pub n: usize,
616    /// Number of columns in the active design (i.e. `p` of `Xᵀ X`).
617    pub p: usize,
618    /// `Some(_)` when the inner family maps onto one of the six JIT-cached
619    /// `PirlsRowFamily` variants; `None` for custom families that still
620    /// require Stage 6 Level B and have not yet been admitted here.
621    pub family: Option<PirlsLoopFamilyKind>,
622    /// Curvature surface the inner loop will use; the GPU loop has Fisher +
623    /// Observed kernels, anything else (e.g. expected-projection surrogates)
624    /// is not admitted.
625    pub curvature: PirlsLoopCurvatureKind,
626    /// True when the CUDA runtime is initialised on this host (i.e.
627    /// lossless Auto resolution returned an available runtime).
628    pub gpu_available: bool,
629}
630
631impl GpuDispatchPolicy {
632    /// Minimum design column count for the device-resident inner/outer loops.
633    ///
634    /// Below this width the per-iteration `XᵀWX + Cholesky` is dominated by
635    /// launch latency and PCIe staging rather than arithmetic, so the host LM
636    /// loop (which populates the full `PirlsResult` surface as a free
637    /// side-effect) is strictly cheaper. Shared by both the inner PIRLS and
638    /// outer REML admission predicates so they cannot drift apart.
639    pub const DEVICE_LOOP_MIN_P: usize = 32;
640
641    /// Conservative admission predicate for routing
642    /// `fit_model_for_fixed_rho_with_adaptive_kkt` through the Stage 3.3
643    /// device-resident PIRLS loop instead of the CPU LM loop.
644    ///
645    /// The threshold is the dense `XᵀWX` work estimate, not row count alone:
646    /// LLM/SAE fits can have only a few thousand rows but thousands of columns,
647    /// so `2*n*p^2` already dwarfs launch/staging overhead. Smaller fits stay on
648    /// the CPU LM loop where the full `PirlsResult` surface (firth, EDF,
649    /// per-row weights, …) is already populated as a free side-effect of the
650    /// iteration.
651    pub const fn should_use_gpu_pirls_loop(&self, adm: PirlsLoopAdmission) -> bool {
652        if !adm.gpu_available {
653            return false;
654        }
655        if !self.dense_hessian_work_target_is_gpu(adm.n, adm.p) {
656            return false;
657        }
658        match adm.family {
659            Some(_) => true,
660            None => false,
661        }
662    }
663
664}
665
666#[cfg(test)]
667mod refinement_policy_tests {
668    use super::*;
669
670    #[test]
671    fn refinement_policy_admits_large_p() {
672        let pol = GpuDispatchPolicy::default();
673        // Default policy is Refinement; large p should be admitted.
674        assert!(pol.iterative_refinement_should_attempt(512));
675        assert!(pol.iterative_refinement_should_attempt(GpuDispatchPolicy::REFINEMENT_MIN_P));
676    }
677
678    #[test]
679    fn refinement_policy_rejects_small_p() {
680        let pol = GpuDispatchPolicy::default();
681        assert!(!pol.iterative_refinement_should_attempt(GpuDispatchPolicy::REFINEMENT_MIN_P - 1));
682        assert!(!pol.iterative_refinement_should_attempt(0));
683    }
684
685    #[test]
686    fn off_policy_never_attempts_refinement() {
687        let pol = GpuDispatchPolicy {
688            mixed_precision: GpuMixedPrecisionPolicy::Off,
689            ..Default::default()
690        };
691        assert!(!pol.iterative_refinement_should_attempt(1024));
692    }
693
694    #[test]
695    fn never_policy_never_attempts_refinement() {
696        let pol = GpuDispatchPolicy {
697            mixed_precision: GpuMixedPrecisionPolicy::Never,
698            ..Default::default()
699        };
700        assert!(!pol.iterative_refinement_should_attempt(1024));
701    }
702}
703
704#[cfg(test)]
705mod fused_batch_dispatch_tests {
706    use super::*;
707
708    /// The dominant large-scale PG draw shape — one variate per data row per
709    /// Gibbs iteration — is admitted, and a batch small enough that launch and
710    /// staging dominate is refused. The refusal is the load-bearing half: a
711    /// predicate that admitted everything would let a dispatch-worthiness test
712    /// pass without saying anything about the shape it ran.
713    #[test]
714    fn polya_gamma_admits_large_batch_and_refuses_small() {
715        let pol = GpuDispatchPolicy::default();
716        assert!(pol.polya_gamma_batch_target_is_gpu(200_000));
717        assert!(pol.polya_gamma_batch_target_is_gpu(pol.fused_kernel_min_n));
718        assert!(!pol.polya_gamma_batch_target_is_gpu(pol.fused_kernel_min_n - 1));
719        assert!(!pol.polya_gamma_batch_target_is_gpu(16));
720        assert!(!pol.polya_gamma_batch_target_is_gpu(0));
721    }
722
723}
724
725#[cfg(test)]
726mod reduced_schur_matvec_offload_tests {
727    use super::*;
728
729    /// The LLM/SAE shape the whole #1017 Phase-1 re-keying targets: a few
730    /// thousand row blocks, a *wide* border (decoder atom count in the
731    /// thousands), a modest per-row frame depth, and a realistic CG budget.
732    /// The row-count gate (50k) and the dense-Direct flop floor both miss this
733    /// "thousands of tiny dense ops" shape; the work-amortised matvec gate must
734    /// fire on it.
735    #[test]
736    fn admits_llm_sae_matvec_shape() {
737        let pol = GpuDispatchPolicy::default();
738        // n≈2000 rows, k≈2048 atoms, M≈8 frame depth — n is far below the 50k
739        // row gate, yet the summed CG matvec work is large.
740        assert!(pol.reduced_schur_matvec_should_offload(
741            2_000,
742            2_048,
743            8,
744            GpuDispatchPolicy::MATVEC_OFFLOAD_MIN_CG_ITERS,
745        ));
746        // The same shape would be rejected by the row-count-style dense gate,
747        // confirming the re-keying is what admits it.
748        assert!(!pol.dense_hessian_work_target_is_gpu(2_000, 8));
749    }
750
751    /// Even with only a single conservative CG iteration the wide LLM border
752    /// clears the breakeven (the per-apply work alone is `2_000·(2·8·2_048 +
753    /// 8²) ≈ 6.6e7` flops > 1e7 by the conservative `n·(2·d·k + d²)` model;
754    /// the true `n·(4·d·k + d²)` arithmetic is ≈1.3e8),
755    /// so the gate is not relying on an inflated iteration count.
756    #[test]
757    fn admits_llm_shape_with_one_cg_iter() {
758        let pol = GpuDispatchPolicy::default();
759        assert!(pol.reduced_schur_matvec_should_offload(2_000, 2_048, 8, 1));
760    }
761
762    /// #1783: the primary manifold-SAE regime is a `d_atom = 1` curve
763    /// dictionary.  Its scalar row frames have much lower staging cost than the
764    /// general framed matvec, so realistic token blocks must not be stranded on
765    /// the CPU merely because the conservative admission lower bound is thin in
766    /// `d`.
767    #[test]
768    fn admits_thin_curve_atoms_at_realistic_scale() {
769        let pol = GpuDispatchPolicy::default();
770        assert!(pol.reduced_schur_matvec_should_offload(24_576, 64, 1, 1));
771        assert!(pol.reduced_schur_matvec_should_offload(40_456, 256, 1, 1));
772        assert!(!pol.reduced_schur_matvec_should_offload(300, 6, 1, 8));
773    }
774
775    /// Tiny shapes where the host↔device transfer dominates must stay on the
776    /// CPU: a handful of rows, a narrow border, shallow frames. The summed
777    /// matvec work is orders of magnitude below the staging breakeven.
778    #[test]
779    fn rejects_tiny_shape_where_transfer_dominates() {
780        let pol = GpuDispatchPolicy::default();
781        assert!(!pol.reduced_schur_matvec_should_offload(
782            30,
783            8,
784            2,
785            GpuDispatchPolicy::MATVEC_OFFLOAD_MIN_CG_ITERS,
786        ));
787        // The 300×8 shape the production seam tests use as the "stay CPU"
788        // canary is rejected here too.
789        assert!(!pol.reduced_schur_matvec_should_offload(300, 8, 4, 16));
790    }
791
792    /// A narrow border (k below the device-loop floor) is rejected regardless
793    /// of how much row/iteration work is piled on: per-apply launch latency
794    /// dominates a sub-`DEVICE_LOOP_MIN_P` border.
795    #[test]
796    fn rejects_narrow_border_even_with_huge_row_count() {
797        let pol = GpuDispatchPolicy::default();
798        let narrow = GpuDispatchPolicy::DEVICE_LOOP_MIN_P - 1;
799        assert!(!pol.reduced_schur_matvec_should_offload(1_000_000, narrow, 64, 64));
800    }
801
802    /// Degenerate dimensions are never offloaded (no work, or no solve).
803    #[test]
804    fn rejects_degenerate_dimensions() {
805        let pol = GpuDispatchPolicy::default();
806        assert!(!pol.reduced_schur_matvec_should_offload(0, 2_048, 8, 8));
807        assert!(!pol.reduced_schur_matvec_should_offload(2_000, 0, 8, 8));
808        assert!(!pol.reduced_schur_matvec_should_offload(2_000, 2_048, 0, 8));
809        assert!(!pol.reduced_schur_matvec_should_offload(2_000, 2_048, 8, 0));
810    }
811
812    /// The gate is monotone in the CG budget: once a shape is admitted at a
813    /// given iteration count it stays admitted for any larger count (more
814    /// applies over the same resident frames only improves amortization), and
815    /// a borderline shape crosses the breakeven as iterations grow.
816    #[test]
817    fn monotone_in_cg_iters() {
818        let pol = GpuDispatchPolicy::default();
819        // A border at the floor with shallow frames and few rows: per-apply
820        // work ~ n·(2·d·k + d²). Choose a shape that is below breakeven at 1
821        // iter but above it once enough iterations accumulate.
822        let (n, k, d) = (200usize, GpuDispatchPolicy::DEVICE_LOOP_MIN_P, 4usize);
823        // per_apply ≈ 200·(2·4·32 + 16) = 200·272 = 54_400 flops.
824        assert!(!pol.reduced_schur_matvec_should_offload(n, k, d, 1));
825        // Once the summed work clears 1e7 the gate fires; ~184 iters here.
826        assert!(pol.reduced_schur_matvec_should_offload(n, k, d, 1_000));
827        // Monotonicity: admitted at 1_000 ⇒ admitted at every larger budget.
828        assert!(pol.reduced_schur_matvec_should_offload(n, k, d, 5_000));
829    }
830
831    /// The admission lower bound must stay strictly below the true per-apply
832    /// work `n·(4·d·k + d²)` for any non-degenerate cross-block shape (it drops
833    /// the transpose GEMV). Treating the lower bound as a flop count would
834    /// over-report device speedups, so this asserts the gap is real.
835    #[test]
836    fn admission_lower_bound_undercounts_actual_work() {
837        for &(n, k, d) in &[
838            (2_000usize, 2_048usize, 8usize),
839            (200, GpuDispatchPolicy::DEVICE_LOOP_MIN_P, 4),
840            (1, 1, 1),
841        ] {
842            let lower = GpuDispatchPolicy::admission_work_lower_bound(n, k, d);
843            // True per-apply work models the full forward+transpose GEMV pair
844            // plus the d×d solve: n·(4·d·k + d²).
845            let actual = (n as u128) * (4 * (d as u128) * (k as u128) + (d as u128) * (d as u128));
846            assert!(
847                lower < actual,
848                "admission lower bound {lower} must undercount actual work {actual} for ({n},{k},{d})"
849            );
850        }
851    }
852}
853