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