1use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayViewMut1};
74
75use gam_gpu::gpu_error::GpuError;
76use gam_linalg::pcg::{DotReduction, pcg_core};
77
78#[derive(Clone, Copy, Debug)]
84pub struct ProbeSeed(pub u64);
85
86impl Default for ProbeSeed {
87 fn default() -> Self {
88 Self(0xCAFE_BABE)
91 }
92}
93
94#[derive(Clone, Debug)]
100pub enum DerivativeHessian<'a> {
101 Dense(ArrayView2<'a, f64>),
104 WeightedGram {
110 row_weights: ArrayView1<'a, f64>,
111 penalty_extra: Option<ArrayView2<'a, f64>>,
112 },
113}
114
115impl DerivativeHessian<'_> {
116 fn dim_p(&self, expected_p: usize, expected_n: usize) -> Result<(), GpuError> {
117 match self {
118 DerivativeHessian::Dense(matrix) => {
119 if matrix.nrows() != expected_p || matrix.ncols() != expected_p {
120 gam_gpu::gpu_bail!(
121 "reml_trace dense H_j: shape {:?} != ({expected_p}, {expected_p})",
122 matrix.dim()
123 );
124 }
125 }
126 DerivativeHessian::WeightedGram {
127 row_weights,
128 penalty_extra,
129 } => {
130 if row_weights.len() != expected_n {
131 gam_gpu::gpu_bail!(
132 "reml_trace structural H_j: row_weights.len()={} != n={expected_n}",
133 row_weights.len()
134 );
135 }
136 if let Some(p_extra) = penalty_extra
137 && (p_extra.nrows() != expected_p || p_extra.ncols() != expected_p)
138 {
139 gam_gpu::gpu_bail!(
140 "reml_trace structural H_j penalty_extra: shape {:?} != ({expected_p}, {expected_p})",
141 p_extra.dim()
142 );
143 }
144 }
145 }
146 Ok(())
147 }
148}
149
150#[derive(Clone, Debug)]
152pub struct RemlTraceHutchinsonInput<'a> {
153 pub penalized_hessian: ArrayView2<'a, f64>,
155 pub derivatives: Vec<DerivativeHessian<'a>>,
157 pub design: Option<ArrayView2<'a, f64>>,
160 pub probe_count: usize,
162 pub seed: ProbeSeed,
164}
165
166#[derive(Clone, Debug)]
168pub struct RemlTraceHutchinsonEvidence {
169 pub logdet_hessian: f64,
172 pub gradient_rho_logdet: Array1<f64>,
174 pub gradient_rho_stderr: Array1<f64>,
179 pub probe_count: usize,
181}
182
183pub const HUTCHINSON_GPU_MIN_P: usize = 512;
189pub const HUTCHINSON_GPU_MIN_K: usize = 8;
191pub const HUTCHINSON_GPU_MAX_K: usize = 128;
192
193#[must_use]
201pub fn should_use_gpu_hutchinson(
202 p: usize,
203 probe_count: usize,
204 prefers_stochastic: bool,
205 kernel_matches_hinv: bool,
206 plain_spd_logdet: bool,
207 projected_penalty_subspace_active: bool,
208) -> bool {
209 p >= HUTCHINSON_GPU_MIN_P
210 && (HUTCHINSON_GPU_MIN_K..=HUTCHINSON_GPU_MAX_K).contains(&probe_count)
211 && prefers_stochastic
212 && kernel_matches_hinv
213 && plain_spd_logdet
214 && !projected_penalty_subspace_active
215}
216
217#[inline]
226pub fn splitmix64_mix(z: u64) -> u64 {
227 gam_linalg::utils::splitmix64_hash(z)
228}
229
230#[inline]
238pub fn rademacher_entry(seed: u64, k: u64, i: u64) -> f64 {
239 const ZETA: u64 = 0xD1B5_4A32_D192_ED03;
240 const GAMMA: u64 = 0x8CB9_2BA7_2F9D_E81F;
241 let composite = seed ^ k.wrapping_mul(ZETA) ^ i.wrapping_mul(GAMMA);
242 let h = splitmix64_mix(composite);
243 if (h >> 63) == 0 { 1.0 } else { -1.0 }
244}
245
246pub fn fill_rademacher_host(seed: ProbeSeed, p: usize, k: usize, out: &mut [f64]) {
249 assert_eq!(
250 out.len(),
251 p * k,
252 "fill_rademacher_host: out buffer length {} != p*K = {}*{}",
253 out.len(),
254 p,
255 k
256 );
257 for col in 0..k {
258 for row in 0..p {
259 out[col * p + row] = rademacher_entry(seed.0, col as u64, row as u64);
260 }
261 }
262}
263
264pub fn evidence_derivatives_hutchinson_cpu(
275 input: &RemlTraceHutchinsonInput<'_>,
276) -> Result<RemlTraceHutchinsonEvidence, String> {
277 validate_inputs(input)?;
278 let p = input.penalized_hessian.nrows();
279 let d = input.derivatives.len();
280 let k = input.probe_count;
281
282 let h = input.penalized_hessian.to_owned();
284 let factor = cholesky_lower(&h)?;
285 let logdet_hessian = 2.0 * (0..p).map(|i| factor[[i, i]].ln()).sum::<f64>();
286
287 let mut z = vec![0.0_f64; p * k];
289 fill_rademacher_host(input.seed, p, k, &mut z);
290
291 use rayon::prelude::*;
299 let mut w = vec![0.0_f64; p * k];
300 w.par_chunks_mut(p)
301 .zip(z.par_chunks(p))
302 .for_each(|(w_col, z_col)| {
303 let solved = solve_cholesky(&factor, z_col);
304 w_col.copy_from_slice(&solved);
305 });
306
307 let mut q = vec![0.0_f64; d * k]; for (j, derivative) in input.derivatives.iter().enumerate() {
315 let q_row = &mut q[j * k..(j + 1) * k];
316 match derivative {
317 DerivativeHessian::Dense(matrix) => {
318 q_row
319 .par_iter_mut()
320 .zip(z.par_chunks(p).zip(w.par_chunks(p)))
321 .for_each(|(q_jk, (z_col, w_col))| {
322 let mut y = vec![0.0_f64; p];
324 for r in 0..p {
325 let mut acc = 0.0_f64;
326 for c in 0..p {
327 acc += matrix[[r, c]] * w_col[c];
328 }
329 y[r] = acc;
330 }
331 let mut zy = 0.0_f64;
332 for i in 0..p {
333 zy += z_col[i] * y[i];
334 }
335 *q_jk = zy;
336 });
337 }
338 DerivativeHessian::WeightedGram {
339 row_weights,
340 penalty_extra,
341 } => {
342 let design = input.design.as_ref().expect("design validated");
343 let n = design.nrows();
344 q_row
345 .par_iter_mut()
346 .zip(z.par_chunks(p).zip(w.par_chunks(p)))
347 .for_each(|(q_jk, (z_col, w_col))| {
348 let mut acc = 0.0_f64;
350 for row in 0..n {
351 let mut rz = 0.0_f64;
352 let mut rw = 0.0_f64;
353 for col_idx in 0..p {
354 rz += design[[row, col_idx]] * z_col[col_idx];
355 rw += design[[row, col_idx]] * w_col[col_idx];
356 }
357 acc += row_weights[row] * rz * rw;
358 }
359 if let Some(pen) = penalty_extra {
360 for r in 0..p {
361 let mut row_acc = 0.0_f64;
362 for c in 0..p {
363 row_acc += pen[[r, c]] * w_col[c];
364 }
365 acc += z_col[r] * row_acc;
366 }
367 }
368 *q_jk = acc;
369 });
370 }
371 }
372 }
373
374 let (means, stderrs) = reduce_mean_stderr(&q, d, k);
375 let mut gradient_rho_logdet = Array1::<f64>::zeros(d);
376 let mut gradient_rho_stderr = Array1::<f64>::zeros(d);
377 for j in 0..d {
378 gradient_rho_logdet[j] = 0.5 * means[j];
379 gradient_rho_stderr[j] = 0.5 * stderrs[j];
380 }
381
382 Ok(RemlTraceHutchinsonEvidence {
383 logdet_hessian,
384 gradient_rho_logdet,
385 gradient_rho_stderr,
386 probe_count: k,
387 })
388}
389
390pub fn evidence_derivatives_hutchinson_gpu(
400 input: RemlTraceHutchinsonInput<'_>,
401) -> Result<RemlTraceHutchinsonEvidence, String> {
402 validate_inputs(&input)?;
403
404 #[cfg(target_os = "linux")]
405 {
406 if gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())
407 .map_err(|error| error.to_string())?
408 .is_some()
409 {
410 match linux_cuda::evidence_derivatives(&input) {
411 Ok(evidence) => return Ok(evidence),
412 Err(GpuError::NoDeviceKernel { .. }) => {
413 }
416 Err(other) => return Err(String::from(other)),
417 }
418 }
419 }
420
421 evidence_derivatives_hutchinson_cpu(&input)
422}
423
424pub const HUTCHINSON_ADAPTIVE_REL_TOL: f64 = 0.01;
431pub const HUTCHINSON_ADAPTIVE_TAU_REL: f64 = 1e-8;
434
435pub struct AdaptiveTraceEvidence {
459 pub logdet_hessian: f64,
460 pub traces: Array1<f64>,
461 pub stderrs: Array1<f64>,
464 pub probe_count: usize,
465 pub converged: bool,
466}
467
468pub fn evidence_traces_adaptive<'a>(
469 penalized_hessian: ArrayView2<'a, f64>,
470 derivatives: Vec<DerivativeHessian<'a>>,
471 design: Option<ArrayView2<'a, f64>>,
472 seed: ProbeSeed,
473 rel_tol: f64,
474 tau_rel: f64,
475) -> Result<AdaptiveTraceEvidence, String> {
476 const SCHEDULE: [usize; 4] = [16, 32, 64, 128];
478
479 let d = derivatives.len();
480 if d == 0 {
481 return Err("evidence_traces_adaptive: derivatives is empty".to_string());
482 }
483 if !(rel_tol > 0.0) {
484 return Err(format!(
485 "evidence_traces_adaptive: rel_tol must be > 0 (got {rel_tol})"
486 ));
487 }
488 if !(tau_rel > 0.0) {
489 return Err(format!(
490 "evidence_traces_adaptive: tau_rel must be > 0 (got {tau_rel})"
491 ));
492 }
493
494 let mut last_logdet = 0.0_f64;
495 let mut last_traces = Array1::<f64>::zeros(d);
496 let mut last_stderrs = Array1::<f64>::zeros(d);
497 let mut last_k = 0_usize;
498 let mut converged = false;
499
500 for &k in &SCHEDULE {
501 let input = RemlTraceHutchinsonInput {
502 penalized_hessian,
503 derivatives: derivatives.clone(),
504 design,
505 probe_count: k,
506 seed,
507 };
508 let evidence = evidence_derivatives_hutchinson_gpu(input)?;
509 last_logdet = evidence.logdet_hessian;
510 last_k = k;
511
512 for j in 0..d {
516 last_traces[j] = 2.0 * evidence.gradient_rho_logdet[j];
517 last_stderrs[j] = 2.0 * evidence.gradient_rho_stderr[j];
518 }
519
520 let mut worst = 0.0_f64;
525 for j in 0..d {
526 let denom = last_traces[j].abs().max(tau_rel);
527 let r = last_stderrs[j] / denom;
528 if r > worst {
529 worst = r;
530 }
531 }
532 if worst <= rel_tol {
533 converged = true;
534 break;
535 }
536 }
537
538 Ok(AdaptiveTraceEvidence {
539 logdet_hessian: last_logdet,
540 traces: last_traces,
541 stderrs: last_stderrs,
542 probe_count: last_k,
543 converged,
544 })
545}
546
547pub const PCG_HVP_REL_TOL: f64 = 1e-6;
556
557pub const PCG_HVP_MAX_ITERS: usize = 200;
563
564pub fn evidence_traces_adaptive_hvp<F>(
595 p: usize,
596 mut hvp: F,
597 derivatives: Vec<DerivativeHessian<'_>>,
598 design: Option<ArrayView2<'_, f64>>,
599 seed: ProbeSeed,
600 rel_tol: f64,
601 tau_rel: f64,
602) -> Result<AdaptiveTraceEvidence, String>
603where
604 F: FnMut(&[f64], &mut [f64]),
605{
606 const SCHEDULE: [usize; 4] = [16, 32, 64, 128];
607
608 let d = derivatives.len();
609 if d == 0 {
610 return Err("evidence_traces_adaptive_hvp: derivatives is empty".to_string());
611 }
612 if p == 0 {
613 return Err("evidence_traces_adaptive_hvp: p must be > 0".to_string());
614 }
615 if !(rel_tol > 0.0) {
616 return Err(format!(
617 "evidence_traces_adaptive_hvp: rel_tol must be > 0 (got {rel_tol})"
618 ));
619 }
620 if !(tau_rel > 0.0) {
621 return Err(format!(
622 "evidence_traces_adaptive_hvp: tau_rel must be > 0 (got {tau_rel})"
623 ));
624 }
625
626 let mut last_traces = Array1::<f64>::zeros(d);
627 let mut last_stderrs = Array1::<f64>::zeros(d);
628 let mut last_k = 0_usize;
629 let mut converged = false;
630
631 let mut z = vec![0.0_f64; p];
632 let mut w = vec![0.0_f64; p];
633
634 let mut q_means = vec![0.0_f64; d];
641 let mut q_m2 = vec![0.0_f64; d];
642
643 for &k_target in &SCHEDULE {
644 for s in q_means.iter_mut() {
648 *s = 0.0;
649 }
650 for s in q_m2.iter_mut() {
651 *s = 0.0;
652 }
653
654 for k_idx in 0..k_target {
655 for i in 0..p {
657 z[i] = rademacher_entry(seed.0, k_idx as u64, i as u64);
658 }
659 cg_solve(&mut hvp, &z, &mut w, PCG_HVP_REL_TOL, PCG_HVP_MAX_ITERS);
661
662 for j in 0..d {
665 let q = match &derivatives[j] {
666 DerivativeHessian::Dense(matrix) => {
667 let mut y = 0.0_f64;
668 for r in 0..p {
669 let mut hr_w = 0.0_f64;
670 for c in 0..p {
671 hr_w += matrix[[r, c]] * w[c];
672 }
673 y += z[r] * hr_w;
674 }
675 y
676 }
677 DerivativeHessian::WeightedGram {
678 row_weights,
679 penalty_extra,
680 } => {
681 let design_view = design.as_ref().ok_or_else(|| {
682 "evidence_traces_adaptive_hvp: WeightedGram derivative requires \
683 design matrix"
684 .to_string()
685 })?;
686 let n = design_view.nrows();
687 let mut acc = 0.0_f64;
688 for row in 0..n {
689 let mut rz = 0.0_f64;
690 let mut rw = 0.0_f64;
691 for ci in 0..p {
692 rz += design_view[[row, ci]] * z[ci];
693 rw += design_view[[row, ci]] * w[ci];
694 }
695 acc += row_weights[row] * rz * rw;
696 }
697 if let Some(pen) = penalty_extra {
698 for r in 0..p {
699 let mut row_acc = 0.0_f64;
700 for c in 0..p {
701 row_acc += pen[[r, c]] * w[c];
702 }
703 acc += z[r] * row_acc;
704 }
705 }
706 acc
707 }
708 };
709 let count = (k_idx + 1) as f64;
711 let delta = q - q_means[j];
712 q_means[j] += delta / count;
713 let delta2 = q - q_means[j];
714 q_m2[j] += delta * delta2;
715 }
716 }
717
718 let n = k_target as f64;
719 let mut worst_ratio = 0.0_f64;
720 for j in 0..d {
721 let mean = q_means[j];
722 let var = if n > 1.0 { q_m2[j] / (n - 1.0) } else { 0.0 };
726 let se = var.sqrt() / n.sqrt();
727 last_traces[j] = mean;
728 last_stderrs[j] = se;
729 let denom = mean.abs().max(tau_rel);
730 let r = se / denom;
731 if r > worst_ratio {
732 worst_ratio = r;
733 }
734 }
735 last_k = k_target;
736 if worst_ratio <= rel_tol {
737 converged = true;
738 break;
739 }
740 }
741
742 Ok(AdaptiveTraceEvidence {
743 logdet_hessian: f64::NAN,
744 traces: last_traces,
745 stderrs: last_stderrs,
746 probe_count: last_k,
747 converged,
748 })
749}
750
751fn cg_solve<F>(hvp: &mut F, b: &[f64], w: &mut [f64], rel_tol: f64, max_iters: usize)
772where
773 F: FnMut(&[f64], &mut [f64]),
774{
775 let n = b.len();
776 assert!(w.len() == n);
777
778 let rhs = ArrayView1::from(b);
779 let precond = Array1::<f64>::ones(n);
780 let mut solution = ArrayViewMut1::from(w);
781
782 pcg_core(
783 |v: &Array1<f64>, out: &mut Array1<f64>| {
784 let v_slice = v.as_slice().expect("contiguous CG direction view");
786 let out_slice = out.as_slice_mut().expect("contiguous CG matvec view");
787 hvp(v_slice, out_slice);
788 },
789 &rhs,
790 &precond.view(),
791 rel_tol,
792 max_iters,
793 0,
794 false,
795 DotReduction::Reordered,
796 &mut solution,
797 );
798}
799
800#[must_use]
821pub fn should_bypass_cpu_with_gpu_adaptive(
822 p: usize,
823 dense_spd_h_resident: bool,
824 plain_spd_logdet: bool,
825 prefers_stochastic: bool,
826 projected_penalty_subspace_active: bool,
827) -> bool {
828 p >= HUTCHINSON_GPU_MIN_P
829 && dense_spd_h_resident
830 && plain_spd_logdet
831 && prefers_stochastic
832 && !projected_penalty_subspace_active
833}
834
835#[cfg(target_os = "linux")]
840mod linux_cuda {
841 use super::{
842 DerivativeHessian, ProbeSeed, RemlTraceHutchinsonEvidence, RemlTraceHutchinsonInput,
843 reduce_mean_stderr,
844 };
845 use cudarc::cublas::sys::cublasOperation_t;
846 use cudarc::cublas::{CudaBlas, Gemm, GemmConfig};
847 use cudarc::cusolver::DnHandle;
848 use cudarc::driver::{CudaContext, CudaModule, CudaStream, LaunchConfig, PushKernelArg};
849 use gam_gpu::driver::to_col_major;
850 use gam_gpu::gpu_error::{GpuError, GpuResultExt};
851 use gam_gpu::solver::{
852 cholesky_logdet_from_col_major, context_and_stream, pinned_htod, potrf_in_place,
853 potrs_in_place,
854 };
855 use std::sync::Arc;
856
857 pub(super) const PTX_SOURCE: &str = r#"
872extern "C" __device__ unsigned long long splitmix64_mix(unsigned long long z) {
873 z += 0x9E3779B97F4A7C15ULL;
874 unsigned long long x = z;
875 x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL;
876 x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL;
877 return x ^ (x >> 31);
878}
879
880extern "C" __global__ void fill_rademacher_splitmix(
881 unsigned long long seed,
882 unsigned int p,
883 unsigned int K,
884 double* __restrict__ Z)
885{
886 unsigned int i = blockIdx.x * blockDim.x + threadIdx.x;
887 unsigned int k = blockIdx.y;
888 if (i >= p || k >= K) return;
889 const unsigned long long ZETA = 0xD1B54A32D192ED03ULL;
890 const unsigned long long GAMMA = 0x8CB92BA72F9DE81FULL;
891 unsigned long long composite =
892 seed
893 ^ (((unsigned long long)k) * ZETA)
894 ^ (((unsigned long long)i) * GAMMA);
895 unsigned long long h = splitmix64_mix(composite);
896 double v = (h >> 63) == 0 ? 1.0 : -1.0;
897 Z[(size_t)k * (size_t)p + (size_t)i] = v;
898}
899
900extern "C" __device__ double block_reduce_sum(double v) {
901 __shared__ double smem[32];
902 int lane = threadIdx.x & 31;
903 int wid = threadIdx.x >> 5;
904 for (int off = 16; off > 0; off >>= 1) {
905 v += __shfl_down_sync(0xffffffff, v, off);
906 }
907 if (lane == 0) smem[wid] = v;
908 __syncthreads();
909 double total = 0.0;
910 int n_warps = (blockDim.x + 31) >> 5;
911 if (threadIdx.x < (unsigned)n_warps) total = smem[threadIdx.x];
912 if (wid == 0) {
913 for (int off = 16; off > 0; off >>= 1) {
914 total += __shfl_down_sync(0xffffffff, total, off);
915 }
916 }
917 return total;
918}
919
920extern "C" __global__ void reduce_q_dense(
921 unsigned int p,
922 unsigned int K,
923 unsigned int D,
924 const double* __restrict__ Z,
925 const double* __restrict__ Y_stack,
926 double* __restrict__ Q)
927{
928 unsigned int k = blockIdx.x;
929 unsigned int j = blockIdx.y;
930 if (k >= K || j >= D) return;
931 const double* z_col = Z + (size_t)k * (size_t)p;
932 const double* y_col = Y_stack + ((size_t)j * (size_t)K + (size_t)k) * (size_t)p;
933 double partial = 0.0;
934 for (unsigned int i = threadIdx.x; i < p; i += blockDim.x) {
935 partial += z_col[i] * y_col[i];
936 }
937 double total = block_reduce_sum(partial);
938 if (threadIdx.x == 0) {
939 Q[(size_t)j * (size_t)K + (size_t)k] = total;
940 }
941}
942
943extern "C" __global__ void reduce_q_weighted_gram(
944 unsigned int n,
945 unsigned int K,
946 unsigned int D,
947 const double* __restrict__ RZ,
948 const double* __restrict__ RW,
949 const double* __restrict__ A_stack,
950 double* __restrict__ Q)
951{
952 unsigned int k = blockIdx.x;
953 unsigned int j = blockIdx.y;
954 if (k >= K || j >= D) return;
955 const double* rz_col = RZ + (size_t)k * (size_t)n;
956 const double* rw_col = RW + (size_t)k * (size_t)n;
957 const double* a_col = A_stack + (size_t)j * (size_t)n;
958 double partial = 0.0;
959 for (unsigned int i = threadIdx.x; i < n; i += blockDim.x) {
960 partial += a_col[i] * rz_col[i] * rw_col[i];
961 }
962 double total = block_reduce_sum(partial);
963 if (threadIdx.x == 0) {
964 Q[(size_t)j * (size_t)K + (size_t)k] = total;
965 }
966}
967"#;
968
969 const THREADS_PER_BLOCK: u32 = 256;
970
971 fn module(ctx: &Arc<CudaContext>) -> Result<&'static Arc<CudaModule>, GpuError> {
972 static CACHE: gam_gpu::device_cache::PtxModuleCache =
973 gam_gpu::device_cache::PtxModuleCache::new();
974 CACHE.get_or_compile(ctx, "reml_trace", PTX_SOURCE)
975 }
976
977 pub(super) fn evidence_derivatives(
978 input: &RemlTraceHutchinsonInput<'_>,
979 ) -> Result<RemlTraceHutchinsonEvidence, GpuError> {
980 let p = input.penalized_hessian.nrows();
981 let d = input.derivatives.len();
982 let k = input.probe_count;
983 let (ctx, stream) =
984 context_and_stream().map_err(|reason| GpuError::DriverCallFailed { reason })?;
985 let solver = DnHandle::new(stream.clone()).gpu_ctx("reml_trace cusolver init")?;
986 let blas = CudaBlas::new(stream.clone()).gpu_ctx("reml_trace cublas init")?;
987 let compiled = module(&ctx)?;
988 let module_handle: &Arc<CudaModule> = compiled;
989
990 let h_col = to_col_major(&input.penalized_hessian);
992 let mut h_dev =
993 pinned_htod(&stream, &h_col).map_err(|reason| GpuError::DriverCallFailed { reason })?;
994 potrf_in_place(&solver, &stream, p, &mut h_dev)
995 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
996 let factor_col = stream
997 .clone_dtoh(&h_dev)
998 .gpu_ctx("reml_trace download factor")?;
999 let logdet_hessian = cholesky_logdet_from_col_major(&factor_col, p);
1000
1001 let total_z = p
1003 .checked_mul(k)
1004 .ok_or_else(|| gam_gpu::gpu_err!("reml_trace Z size overflow: p={p}, K={k}"))?;
1005 let mut z_dev = stream
1006 .alloc_zeros::<f64>(total_z)
1007 .gpu_ctx("reml_trace alloc Z")?;
1008 launch_fill_rademacher(&stream, module_handle, input.seed, p, k, &mut z_dev)?;
1009
1010 let mut w_dev = stream
1013 .alloc_zeros::<f64>(total_z)
1014 .gpu_ctx("reml_trace alloc W")?;
1015 copy_device_slice(&stream, &z_dev, &mut w_dev)?;
1016 potrs_in_place(&solver, &stream, p, k, &h_dev, &mut w_dev)
1017 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
1018
1019 let mut dense_indices: Vec<usize> = Vec::new();
1021 let mut gram_indices: Vec<usize> = Vec::new();
1022 for (j, deriv) in input.derivatives.iter().enumerate() {
1023 match deriv {
1024 DerivativeHessian::Dense(_) => dense_indices.push(j),
1025 DerivativeHessian::WeightedGram { .. } => gram_indices.push(j),
1026 }
1027 }
1028
1029 let mut q_host = vec![0.0_f64; d * k];
1030
1031 if !dense_indices.is_empty() {
1036 for &j in &dense_indices {
1037 let DerivativeHessian::Dense(matrix) = &input.derivatives[j] else {
1038 panic!(
1045 "reml_trace dense path: derivative index {j} is in dense_indices but \
1046 input.derivatives[{j}] is not DerivativeHessian::Dense — \
1047 dense_indices partition invariant violated"
1048 );
1049 };
1050 let hj_col = to_col_major(matrix);
1051 let hj_dev = pinned_htod(&stream, &hj_col)
1052 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
1053 let mut y_dev = stream
1054 .alloc_zeros::<f64>(total_z)
1055 .map_err(|err| gam_gpu::gpu_err!("reml_trace alloc Y_j (j={j}): {err}"))?;
1056 gemm_nn(
1057 &blas,
1058 GemmShape {
1059 m: p,
1060 n: k,
1061 k_inner: p,
1062 lda: p,
1063 ldb: p,
1064 ldc: p,
1065 },
1066 &hj_dev,
1067 &w_dev,
1068 &mut y_dev,
1069 )?;
1070 let mut q_j_dev = stream
1071 .alloc_zeros::<f64>(k)
1072 .gpu_ctx_with(|err| format!("reml_trace alloc Q_j (j={j}): {err}"))?;
1073 launch_reduce_q_dense(
1074 &stream,
1075 module_handle,
1076 p,
1077 k,
1078 1,
1079 &z_dev,
1080 &y_dev,
1081 &mut q_j_dev,
1082 )?;
1083 let q_host_j = stream
1084 .clone_dtoh(&q_j_dev)
1085 .gpu_ctx_with(|err| format!("reml_trace download Q_j (j={j}): {err}"))?;
1086 q_host[j * k..(j + 1) * k].copy_from_slice(&q_host_j);
1087 }
1088 }
1089
1090 if !gram_indices.is_empty() {
1093 let design = input
1094 .design
1095 .as_ref()
1096 .ok_or_else(|| GpuError::DriverCallFailed {
1097 reason: "reml_trace: structural derivative present but design=None".to_string(),
1098 })?;
1099 let n = design.nrows();
1100 let design_col = to_col_major(design);
1101 let x_dev = pinned_htod(&stream, &design_col)
1102 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
1103 let mut rz_dev = stream
1104 .alloc_zeros::<f64>(
1105 n.checked_mul(k)
1106 .ok_or_else(|| gam_gpu::gpu_err!("reml_trace RZ overflow: n={n}, K={k}"))?,
1107 )
1108 .gpu_ctx("reml_trace alloc RZ")?;
1109 let mut rw_dev = stream
1110 .alloc_zeros::<f64>(n * k)
1111 .gpu_ctx("reml_trace alloc RW")?;
1112 gemm_nn(
1114 &blas,
1115 GemmShape {
1116 m: n,
1117 n: k,
1118 k_inner: p,
1119 lda: n,
1120 ldb: p,
1121 ldc: n,
1122 },
1123 &x_dev,
1124 &z_dev,
1125 &mut rz_dev,
1126 )?;
1127 gemm_nn(
1129 &blas,
1130 GemmShape {
1131 m: n,
1132 n: k,
1133 k_inner: p,
1134 lda: n,
1135 ldb: p,
1136 ldc: n,
1137 },
1138 &x_dev,
1139 &w_dev,
1140 &mut rw_dev,
1141 )?;
1142
1143 let d_gram = gram_indices.len();
1145 let mut a_stack = Vec::<f64>::with_capacity(n * d_gram);
1146 for &j in &gram_indices {
1147 let DerivativeHessian::WeightedGram { row_weights, .. } = &input.derivatives[j]
1148 else {
1149 panic!(
1155 "reml_trace structural path: derivative index {j} is in gram_indices \
1156 but input.derivatives[{j}] is not DerivativeHessian::WeightedGram — \
1157 gram_indices partition invariant violated"
1158 );
1159 };
1160 let slice = row_weights.as_slice().ok_or_else(|| {
1161 gam_gpu::gpu_err!("reml_trace structural H_j={j} row_weights not contiguous")
1162 })?;
1163 a_stack.extend_from_slice(slice);
1164 }
1165 let a_dev = pinned_htod(&stream, &a_stack)
1166 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
1167 let mut q_dev = stream
1168 .alloc_zeros::<f64>(d_gram * k)
1169 .map_err(|err| gam_gpu::gpu_err!("reml_trace alloc Q_gram: {err}"))?;
1170 launch_reduce_q_weighted_gram(
1171 &stream,
1172 module_handle,
1173 n,
1174 k,
1175 d_gram,
1176 &rz_dev,
1177 &rw_dev,
1178 &a_dev,
1179 &mut q_dev,
1180 )?;
1181 let q_host_gram = stream
1182 .clone_dtoh(&q_dev)
1183 .gpu_ctx("reml_trace download Q_gram")?;
1184 for (slot, &j) in gram_indices.iter().enumerate() {
1185 q_host[j * k..(j + 1) * k].copy_from_slice(&q_host_gram[slot * k..(slot + 1) * k]);
1186 }
1187 for &j in &gram_indices {
1191 let DerivativeHessian::WeightedGram { penalty_extra, .. } = &input.derivatives[j]
1192 else {
1193 panic!(
1200 "reml_trace structural penalty_extra: derivative index {j} is in \
1201 gram_indices but input.derivatives[{j}] is not \
1202 DerivativeHessian::WeightedGram — gram_indices partition invariant \
1203 violated"
1204 );
1205 };
1206 if let Some(pen) = penalty_extra {
1207 let z_host = stream
1208 .clone_dtoh(&z_dev)
1209 .gpu_ctx("reml_trace download Z for penalty_extra")?;
1210 let w_host = stream
1211 .clone_dtoh(&w_dev)
1212 .gpu_ctx("reml_trace download W for penalty_extra")?;
1213 for col in 0..k {
1214 let z_col = &z_host[col * p..(col + 1) * p];
1215 let w_col = &w_host[col * p..(col + 1) * p];
1216 let mut acc = 0.0_f64;
1217 for r in 0..p {
1218 let mut row_acc = 0.0_f64;
1219 for c in 0..p {
1220 row_acc += pen[[r, c]] * w_col[c];
1221 }
1222 acc += z_col[r] * row_acc;
1223 }
1224 q_host[j * k + col] += acc;
1225 }
1226 }
1227 }
1228 }
1229
1230 let (means, stderrs) = reduce_mean_stderr(&q_host, d, k);
1231 let mut gradient_rho_logdet = ndarray::Array1::<f64>::zeros(d);
1232 let mut gradient_rho_stderr = ndarray::Array1::<f64>::zeros(d);
1233 for j in 0..d {
1234 gradient_rho_logdet[j] = 0.5 * means[j];
1235 gradient_rho_stderr[j] = 0.5 * stderrs[j];
1236 }
1237
1238 Ok(RemlTraceHutchinsonEvidence {
1239 logdet_hessian,
1240 gradient_rho_logdet,
1241 gradient_rho_stderr,
1242 probe_count: k,
1243 })
1244 }
1245
1246 fn launch_fill_rademacher(
1249 stream: &Arc<CudaStream>,
1250 module: &Arc<CudaModule>,
1251 seed: ProbeSeed,
1252 p: usize,
1253 k: usize,
1254 z: &mut cudarc::driver::CudaSlice<f64>,
1255 ) -> Result<(), GpuError> {
1256 let func = module
1257 .load_function("fill_rademacher_splitmix")
1258 .gpu_ctx("reml_trace load fill_rademacher")?;
1259 let grid_x = ((p as u32) + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1260 let cfg = LaunchConfig {
1261 grid_dim: (grid_x, k as u32, 1),
1262 block_dim: (THREADS_PER_BLOCK, 1, 1),
1263 shared_mem_bytes: 0,
1264 };
1265 let seed_arg: u64 = seed.0;
1266 let p_arg: u32 = p as u32;
1267 let k_arg: u32 = k as u32;
1268 unsafe {
1271 stream
1272 .launch_builder(&func)
1273 .arg(&seed_arg)
1274 .arg(&p_arg)
1275 .arg(&k_arg)
1276 .arg(z)
1277 .launch(cfg)
1278 }
1279 .map(|_| ())
1280 .gpu_ctx("reml_trace launch fill_rademacher")
1281 }
1282
1283 fn launch_reduce_q_dense(
1284 stream: &Arc<CudaStream>,
1285 module: &Arc<CudaModule>,
1286 p: usize,
1287 k: usize,
1288 d: usize,
1289 z: &cudarc::driver::CudaSlice<f64>,
1290 y_stack: &cudarc::driver::CudaSlice<f64>,
1291 q: &mut cudarc::driver::CudaSlice<f64>,
1292 ) -> Result<(), GpuError> {
1293 let func = module
1294 .load_function("reduce_q_dense")
1295 .gpu_ctx("reml_trace load reduce_q_dense")?;
1296 let cfg = LaunchConfig {
1297 grid_dim: (k as u32, d as u32, 1),
1298 block_dim: (THREADS_PER_BLOCK, 1, 1),
1299 shared_mem_bytes: 0,
1300 };
1301 let p_arg: u32 = p as u32;
1302 let k_arg: u32 = k as u32;
1303 let d_arg: u32 = d as u32;
1304 unsafe {
1307 stream
1308 .launch_builder(&func)
1309 .arg(&p_arg)
1310 .arg(&k_arg)
1311 .arg(&d_arg)
1312 .arg(z)
1313 .arg(y_stack)
1314 .arg(q)
1315 .launch(cfg)
1316 }
1317 .map(|_| ())
1318 .gpu_ctx("reml_trace launch reduce_q_dense")
1319 }
1320
1321 fn launch_reduce_q_weighted_gram(
1322 stream: &Arc<CudaStream>,
1323 module: &Arc<CudaModule>,
1324 n: usize,
1325 k: usize,
1326 d: usize,
1327 rz: &cudarc::driver::CudaSlice<f64>,
1328 rw: &cudarc::driver::CudaSlice<f64>,
1329 a_stack: &cudarc::driver::CudaSlice<f64>,
1330 q: &mut cudarc::driver::CudaSlice<f64>,
1331 ) -> Result<(), GpuError> {
1332 let func = module
1333 .load_function("reduce_q_weighted_gram")
1334 .gpu_ctx("reml_trace load reduce_q_weighted_gram")?;
1335 let cfg = LaunchConfig {
1336 grid_dim: (k as u32, d as u32, 1),
1337 block_dim: (THREADS_PER_BLOCK, 1, 1),
1338 shared_mem_bytes: 0,
1339 };
1340 let n_arg: u32 = n as u32;
1341 let k_arg: u32 = k as u32;
1342 let d_arg: u32 = d as u32;
1343 unsafe {
1345 stream
1346 .launch_builder(&func)
1347 .arg(&n_arg)
1348 .arg(&k_arg)
1349 .arg(&d_arg)
1350 .arg(rz)
1351 .arg(rw)
1352 .arg(a_stack)
1353 .arg(q)
1354 .launch(cfg)
1355 }
1356 .map(|_| ())
1357 .gpu_ctx("reml_trace launch reduce_q_weighted_gram")
1358 }
1359
1360 fn copy_device_slice(
1361 stream: &Arc<CudaStream>,
1362 src: &cudarc::driver::CudaSlice<f64>,
1363 dst: &mut cudarc::driver::CudaSlice<f64>,
1364 ) -> Result<(), GpuError> {
1365 stream.memcpy_dtod(src, dst).gpu_ctx("reml_trace dtod copy")
1366 }
1367
1368 struct GemmShape {
1369 m: usize,
1370 n: usize,
1371 k_inner: usize,
1372 lda: usize,
1373 ldb: usize,
1374 ldc: usize,
1375 }
1376
1377 fn gemm_nn(
1378 blas: &CudaBlas,
1379 shape: GemmShape,
1380 a: &cudarc::driver::CudaSlice<f64>,
1381 b: &cudarc::driver::CudaSlice<f64>,
1382 c: &mut cudarc::driver::CudaSlice<f64>,
1383 ) -> Result<(), GpuError> {
1384 let GemmShape {
1385 m,
1386 n,
1387 k_inner,
1388 lda,
1389 ldb,
1390 ldc,
1391 } = shape;
1392 let cfg = GemmConfig::<f64> {
1393 transa: cublasOperation_t::CUBLAS_OP_N,
1394 transb: cublasOperation_t::CUBLAS_OP_N,
1395 m: m as i32,
1396 n: n as i32,
1397 k: k_inner as i32,
1398 alpha: 1.0,
1399 lda: lda as i32,
1400 ldb: ldb as i32,
1401 beta: 0.0,
1402 ldc: ldc as i32,
1403 };
1404 unsafe { blas.gemm(cfg, a, b, c) }.gpu_ctx("reml_trace cublas dgemm")
1407 }
1408}
1409
1410fn validate_inputs(input: &RemlTraceHutchinsonInput<'_>) -> Result<(), String> {
1415 let (p, p2) = input.penalized_hessian.dim();
1416 if p == 0 || p != p2 {
1417 return Err(format!("reml_trace input H must be square, got {p}x{p2}"));
1418 }
1419 if input.probe_count < 2 {
1420 return Err(format!(
1421 "reml_trace requires probe_count >= 2 for a sample SE, got {}",
1422 input.probe_count
1423 ));
1424 }
1425 let needs_design = input
1426 .derivatives
1427 .iter()
1428 .any(|d| matches!(d, DerivativeHessian::WeightedGram { .. }));
1429 if needs_design && input.design.is_none() {
1430 return Err("reml_trace: structural derivative present but design=None".to_string());
1431 }
1432 let n = input.design.as_ref().map(|x| x.nrows()).unwrap_or(0);
1433 if let Some(x) = input.design.as_ref()
1434 && x.ncols() != p
1435 {
1436 return Err(format!(
1437 "reml_trace design has {} columns, expected p={p}",
1438 x.ncols()
1439 ));
1440 }
1441 for (j, derivative) in input.derivatives.iter().enumerate() {
1442 derivative
1443 .dim_p(p, n)
1444 .map_err(String::from)
1445 .map_err(|e| format!("reml_trace derivative {j}: {e}"))?;
1446 }
1447 Ok(())
1448}
1449
1450fn reduce_mean_stderr(q: &[f64], d: usize, k: usize) -> (Vec<f64>, Vec<f64>) {
1455 assert_eq!(
1456 q.len(),
1457 d * k,
1458 "reduce_mean_stderr: q buffer length {} != D*K = {}*{}",
1459 q.len(),
1460 d,
1461 k
1462 );
1463 let mut means = vec![0.0_f64; d];
1464 let mut stderrs = vec![0.0_f64; d];
1465 let inv_k = 1.0 / (k as f64);
1466 for j in 0..d {
1467 let row = &q[j * k..(j + 1) * k];
1468 let mean = row.iter().copied().sum::<f64>() * inv_k;
1469 means[j] = mean;
1470 if k >= 2 {
1471 let var = row.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / ((k - 1) as f64);
1472 stderrs[j] = (var / (k as f64)).sqrt();
1473 }
1474 }
1475 (means, stderrs)
1476}
1477
1478fn cholesky_lower(matrix: &Array2<f64>) -> Result<Array2<f64>, String> {
1481 let n = matrix.nrows();
1482 let mut l = Array2::<f64>::zeros((n, n));
1483 for i in 0..n {
1484 for j in 0..=i {
1485 let mut sum = matrix[[i, j]];
1486 for k in 0..j {
1487 sum -= l[[i, k]] * l[[j, k]];
1488 }
1489 if i == j {
1490 if sum <= 0.0 {
1491 return Err(format!(
1492 "reml_trace CPU Cholesky: non-SPD diagonal {sum} at row {i}"
1493 ));
1494 }
1495 l[[i, j]] = sum.sqrt();
1496 } else {
1497 l[[i, j]] = sum / l[[j, j]];
1498 }
1499 }
1500 }
1501 Ok(l)
1502}
1503
1504fn solve_cholesky(l: &Array2<f64>, rhs: &[f64]) -> Vec<f64> {
1505 let n = l.nrows();
1506 let mut y = vec![0.0_f64; n];
1507 for i in 0..n {
1508 let mut sum = rhs[i];
1509 for k in 0..i {
1510 sum -= l[[i, k]] * y[k];
1511 }
1512 y[i] = sum / l[[i, i]];
1513 }
1514 let mut x = vec![0.0_f64; n];
1515 for i in (0..n).rev() {
1516 let mut sum = y[i];
1517 for k in (i + 1)..n {
1518 sum -= l[[k, i]] * x[k];
1519 }
1520 x[i] = sum / l[[i, i]];
1521 }
1522 x
1523}
1524
1525#[cfg(test)]
1530mod tests {
1531 use super::*;
1532 use ndarray::{Array2, ArrayView2};
1533
1534 fn make_spd(p: usize, jitter: f64) -> Array2<f64> {
1535 let mut h = Array2::<f64>::zeros((p, p));
1536 for i in 0..p {
1537 for j in 0..p {
1538 h[[i, j]] = if i == j {
1539 p as f64 + jitter
1540 } else {
1541 1.0 / (1.0 + (i as f64 - j as f64).abs())
1542 };
1543 }
1544 }
1545 h
1546 }
1547
1548 fn random_dense_sym(p: usize, seed: u64) -> Array2<f64> {
1549 let mut a = Array2::<f64>::zeros((p, p));
1550 let mut s = seed;
1551 for i in 0..p {
1552 for j in i..p {
1553 s = splitmix64_mix(s.wrapping_add(1));
1554 let v = ((s >> 11) as f64) / ((1u64 << 53) as f64) - 0.5;
1555 a[[i, j]] = v;
1556 a[[j, i]] = v;
1557 }
1558 }
1559 a
1560 }
1561
1562 fn exact_trace_hinv_a(h: ArrayView2<f64>, a: ArrayView2<f64>) -> f64 {
1563 let p = h.nrows();
1564 let factor = cholesky_lower(&h.to_owned()).expect("SPD");
1565 let mut trace = 0.0;
1566 for col in 0..p {
1567 let mut e = vec![0.0_f64; p];
1568 e[col] = 1.0;
1569 let w = solve_cholesky(&factor, &e);
1570 let mut diag = 0.0;
1572 for i in 0..p {
1573 diag += a[[col, i]] * w[i];
1574 }
1575 trace += diag;
1576 }
1577 trace
1578 }
1579
1580 #[test]
1581 fn splitmix_is_deterministic_and_disperses() {
1582 assert_eq!(splitmix64_mix(42), splitmix64_mix(42));
1585 let mut bits_seen = 0u64;
1586 for x in 0u64..64 {
1587 bits_seen |= splitmix64_mix(x);
1588 }
1589 assert_eq!(
1590 bits_seen,
1591 u64::MAX,
1592 "splitmix should cover every bit position across 64 inputs"
1593 );
1594 }
1595
1596 #[test]
1597 fn rademacher_entries_are_pm_one_and_stateless() {
1598 let seed = ProbeSeed(0xCAFE_BABE);
1599 for k in 0..16u64 {
1600 for i in 0..32u64 {
1601 let v = rademacher_entry(seed.0, k, i);
1602 assert!(
1603 v == 1.0 || v == -1.0,
1604 "non-pm1 entry at (k={k}, i={i}): {v}"
1605 );
1606 let v2 = rademacher_entry(seed.0, k, i);
1607 assert_eq!(v, v2, "same (k,i) must hash to same value");
1608 }
1609 }
1610 }
1611
1612 #[test]
1613 fn rademacher_common_random_numbers_match_for_prefix() {
1614 let p = 50;
1616 let mut z16 = vec![0.0_f64; p * 16];
1617 let mut z32 = vec![0.0_f64; p * 32];
1618 fill_rademacher_host(ProbeSeed(7), p, 16, &mut z16);
1619 fill_rademacher_host(ProbeSeed(7), p, 32, &mut z32);
1620 for col in 0..16 {
1621 for row in 0..p {
1622 assert_eq!(
1623 z16[col * p + row],
1624 z32[col * p + row],
1625 "CRN broken at (col={col}, row={row})"
1626 );
1627 }
1628 }
1629 }
1630
1631 #[test]
1632 fn cpu_hutchinson_unbiased_against_exact_small_spd() {
1633 let p = 16;
1634 let h = make_spd(p, 0.5);
1635 let a1 = random_dense_sym(p, 0x1234);
1636 let a2 = random_dense_sym(p, 0x5678);
1637 let exact1 = exact_trace_hinv_a(h.view(), a1.view());
1638 let exact2 = exact_trace_hinv_a(h.view(), a2.view());
1639 let input = RemlTraceHutchinsonInput {
1640 penalized_hessian: h.view(),
1641 derivatives: vec![
1642 DerivativeHessian::Dense(a1.view()),
1643 DerivativeHessian::Dense(a2.view()),
1644 ],
1645 design: None,
1646 probe_count: 4096,
1647 seed: ProbeSeed(0xCAFE_BABE),
1648 };
1649 let evidence = evidence_derivatives_hutchinson_cpu(&input).expect("ok");
1650 let est1 = 2.0 * evidence.gradient_rho_logdet[0];
1652 let est2 = 2.0 * evidence.gradient_rho_logdet[1];
1653 let se1 = 2.0 * evidence.gradient_rho_stderr[0];
1656 let se2 = 2.0 * evidence.gradient_rho_stderr[1];
1657 let tol1 = 6.0 * se1.max(1e-8);
1658 let tol2 = 6.0 * se2.max(1e-8);
1659 assert!(
1660 (est1 - exact1).abs() <= tol1,
1661 "Hutchinson est {est1} too far from exact {exact1} (tol={tol1}, se={})",
1662 evidence.gradient_rho_stderr[0]
1663 );
1664 assert!(
1665 (est2 - exact2).abs() <= tol2,
1666 "Hutchinson est {est2} too far from exact {exact2} (tol={tol2})"
1667 );
1668 }
1669
1670 #[test]
1671 fn structural_path_matches_dense_for_xtwx() {
1672 let n = 40;
1675 let p = 8;
1676 let mut x = Array2::<f64>::zeros((n, p));
1677 let mut s = 11u64;
1678 for r in 0..n {
1679 for c in 0..p {
1680 s = splitmix64_mix(s.wrapping_add(1));
1681 x[[r, c]] = ((s >> 11) as f64) / ((1u64 << 53) as f64) - 0.5;
1682 }
1683 }
1684 let a: Vec<f64> = (0..n).map(|i| 0.5 + 0.01 * (i as f64)).collect();
1685 let a_arr = ndarray::Array1::from(a);
1686 let mut hj_dense = Array2::<f64>::zeros((p, p));
1688 for r in 0..p {
1689 for c in 0..p {
1690 let mut acc = 0.0;
1691 for i in 0..n {
1692 acc += x[[i, r]] * a_arr[i] * x[[i, c]];
1693 }
1694 hj_dense[[r, c]] = acc;
1695 }
1696 }
1697 let mut h = make_spd(p, 1.0);
1699 for i in 0..p {
1700 h[[i, i]] += 1.0;
1701 }
1702 let input_dense = RemlTraceHutchinsonInput {
1703 penalized_hessian: h.view(),
1704 derivatives: vec![DerivativeHessian::Dense(hj_dense.view())],
1705 design: None,
1706 probe_count: 32,
1707 seed: ProbeSeed(123),
1708 };
1709 let input_struct = RemlTraceHutchinsonInput {
1710 penalized_hessian: h.view(),
1711 derivatives: vec![DerivativeHessian::WeightedGram {
1712 row_weights: a_arr.view(),
1713 penalty_extra: None,
1714 }],
1715 design: Some(x.view()),
1716 probe_count: 32,
1717 seed: ProbeSeed(123),
1718 };
1719 let e_dense = evidence_derivatives_hutchinson_cpu(&input_dense).expect("ok");
1720 let e_struct = evidence_derivatives_hutchinson_cpu(&input_struct).expect("ok");
1721 assert!(
1723 (e_dense.gradient_rho_logdet[0] - e_struct.gradient_rho_logdet[0]).abs() < 1e-9,
1724 "dense vs structural mismatch: dense={}, struct={}",
1725 e_dense.gradient_rho_logdet[0],
1726 e_struct.gradient_rho_logdet[0]
1727 );
1728 }
1729
1730 #[test]
1731 fn finite_difference_check_against_logdet() {
1732 let p = 10;
1734 let h0 = make_spd(p, 0.2);
1735 let a = random_dense_sym(p, 0xABCD);
1736 let eps = 1e-4;
1737 let mut hp = h0.clone();
1738 let mut hm = h0.clone();
1739 for i in 0..p {
1740 for j in 0..p {
1741 hp[[i, j]] += eps * a[[i, j]];
1742 hm[[i, j]] -= eps * a[[i, j]];
1743 }
1744 }
1745 let ld = |m: &Array2<f64>| -> f64 {
1746 let l = cholesky_lower(m).unwrap();
1747 2.0 * (0..p).map(|i| l[[i, i]].ln()).sum::<f64>()
1748 };
1749 let fd = (ld(&hp) - ld(&hm)) / (2.0 * eps);
1750 let exact = exact_trace_hinv_a(h0.view(), a.view());
1751 assert!(
1752 (fd - exact).abs() / exact.abs().max(1e-12) < 1e-6,
1753 "FD logdet derivative {fd} != exact trace {exact}"
1754 );
1755 let input = RemlTraceHutchinsonInput {
1757 penalized_hessian: h0.view(),
1758 derivatives: vec![DerivativeHessian::Dense(a.view())],
1759 design: None,
1760 probe_count: 4096,
1761 seed: ProbeSeed(0xAA55),
1762 };
1763 let evidence = evidence_derivatives_hutchinson_cpu(&input).expect("ok");
1764 let se = evidence.gradient_rho_stderr[0];
1766 let tol = 8.0 * se.max(1e-8);
1767 assert!(
1768 (evidence.gradient_rho_logdet[0] - 0.5 * exact).abs() < tol,
1769 "Hutchinson gradient {} not within 8·SE of 0.5·exact={}",
1770 evidence.gradient_rho_logdet[0],
1771 0.5 * exact
1772 );
1773 }
1774
1775 #[test]
1776 fn gate_rejects_below_min_p() {
1777 assert!(!should_use_gpu_hutchinson(64, 16, true, true, true, false));
1778 }
1779
1780 #[test]
1781 fn gate_rejects_k_out_of_range() {
1782 assert!(!should_use_gpu_hutchinson(2000, 4, true, true, true, false));
1783 assert!(!should_use_gpu_hutchinson(
1784 2000, 200, true, true, true, false
1785 ));
1786 }
1787
1788 #[test]
1789 fn gate_rejects_when_subspace_active() {
1790 assert!(!should_use_gpu_hutchinson(2000, 16, true, true, true, true));
1791 }
1792
1793 #[test]
1794 fn gate_accepts_canonical_case() {
1795 assert!(should_use_gpu_hutchinson(2000, 16, true, true, true, false));
1796 }
1797
1798 #[test]
1807 fn block_2_6_adaptive_unbiased_against_exact_p512() {
1808 let p = 64;
1811 let h = make_spd(p, 0.5);
1812 let a = random_dense_sym(p, 0xBADC0DE);
1813 let exact = exact_trace_hinv_a(h.view(), a.view());
1814 let evidence = evidence_traces_adaptive(
1815 h.view(),
1816 vec![DerivativeHessian::Dense(a.view())],
1817 None,
1818 ProbeSeed(0xA5A5A5),
1819 HUTCHINSON_ADAPTIVE_REL_TOL,
1820 HUTCHINSON_ADAPTIVE_TAU_REL,
1821 )
1822 .expect("adaptive run ok");
1823 let est = evidence.traces[0];
1824 let se = evidence.stderrs[0];
1825 let tol = (8.0 * se).max(0.05 * exact.abs());
1826 assert!(
1827 (est - exact).abs() <= tol,
1828 "adaptive est {est} far from exact {exact} (tol={tol}, se={se}, K={})",
1829 evidence.probe_count
1830 );
1831 }
1832
1833 #[test]
1834 fn block_2_6_same_probes_cpu_vs_dispatch() {
1835 let p = 32;
1842 let h = make_spd(p, 0.3);
1843 let a = random_dense_sym(p, 0x1357);
1844 let input = RemlTraceHutchinsonInput {
1845 penalized_hessian: h.view(),
1846 derivatives: vec![DerivativeHessian::Dense(a.view())],
1847 design: None,
1848 probe_count: 16,
1849 seed: ProbeSeed(0xBEEF),
1850 };
1851 let cpu = evidence_derivatives_hutchinson_cpu(&input).expect("cpu");
1852 let dispatch = evidence_derivatives_hutchinson_gpu(input).expect("dispatch");
1853 let diff = (cpu.gradient_rho_logdet[0] - dispatch.gradient_rho_logdet[0]).abs();
1854 assert!(
1855 diff < 1e-9,
1856 "same-probes CPU vs GPU dispatch differ: cpu={}, dispatch={}, diff={diff}",
1857 cpu.gradient_rho_logdet[0],
1858 dispatch.gradient_rho_logdet[0]
1859 );
1860 }
1861
1862 #[test]
1863 fn block_2_6_fd_logdet_matches_adaptive() {
1864 let p = 24;
1867 let h = make_spd(p, 0.4);
1868 let a = random_dense_sym(p, 0x2468);
1869 let eps = 1e-4;
1870 let mut hp = h.clone();
1871 let mut hm = h.clone();
1872 for i in 0..p {
1873 for j in 0..p {
1874 hp[[i, j]] += eps * a[[i, j]];
1875 hm[[i, j]] -= eps * a[[i, j]];
1876 }
1877 }
1878 let ld = |m: &Array2<f64>| -> f64 {
1879 let l = cholesky_lower(m).expect("SPD");
1880 2.0 * (0..p).map(|i| l[[i, i]].ln()).sum::<f64>()
1881 };
1882 let fd = (ld(&hp) - ld(&hm)) / (2.0 * eps);
1883 let evidence = evidence_traces_adaptive(
1884 h.view(),
1885 vec![DerivativeHessian::Dense(a.view())],
1886 None,
1887 ProbeSeed(0x9999),
1888 HUTCHINSON_ADAPTIVE_REL_TOL,
1889 HUTCHINSON_ADAPTIVE_TAU_REL,
1890 )
1891 .expect("adaptive ok");
1892 let est = evidence.traces[0];
1893 let se = evidence.stderrs[0];
1894 let tol = (8.0 * se).max(0.05 * fd.abs());
1895 assert!(
1896 (est - fd).abs() <= tol,
1897 "adaptive trace {est} disagrees with FD logdet derivative {fd} (tol={tol})"
1898 );
1899 }
1900
1901 #[test]
1902 fn block_2_6_k_4096_matches_exact_tightly() {
1903 let p = 40;
1908 let h = make_spd(p, 0.6);
1909 let a = random_dense_sym(p, 0xDEAD);
1910 let exact = exact_trace_hinv_a(h.view(), a.view());
1911 let input = RemlTraceHutchinsonInput {
1912 penalized_hessian: h.view(),
1913 derivatives: vec![DerivativeHessian::Dense(a.view())],
1914 design: None,
1915 probe_count: 4096,
1916 seed: ProbeSeed(0xC0FFEE),
1917 };
1918 let evidence = evidence_derivatives_hutchinson_gpu(input).expect("ok");
1919 let est = 2.0 * evidence.gradient_rho_logdet[0];
1920 let se = 2.0 * evidence.gradient_rho_stderr[0];
1921 let tol = (6.0 * se).max(1e-3 * exact.abs());
1922 assert!(
1923 (est - exact).abs() <= tol,
1924 "K=4096 Hutchinson {est} not within 6·SE of exact {exact} (tol={tol}, se={se})"
1925 );
1926 }
1927
1928 #[test]
1929 fn block_2_6_crn_prefix_match_across_schedule() {
1930 let p = 50;
1936 let seed = ProbeSeed(0x4242_4242);
1937 let mut z16 = vec![0.0_f64; p * 16];
1938 let mut z32 = vec![0.0_f64; p * 32];
1939 let mut z64 = vec![0.0_f64; p * 64];
1940 fill_rademacher_host(seed, p, 16, &mut z16);
1941 fill_rademacher_host(seed, p, 32, &mut z32);
1942 fill_rademacher_host(seed, p, 64, &mut z64);
1943 for col in 0..16 {
1944 for row in 0..p {
1945 assert_eq!(z16[col * p + row], z32[col * p + row]);
1946 assert_eq!(z16[col * p + row], z64[col * p + row]);
1947 }
1948 }
1949 for col in 0..32 {
1950 for row in 0..p {
1951 assert_eq!(z32[col * p + row], z64[col * p + row]);
1952 }
1953 }
1954 }
1955
1956 #[test]
1961 fn block_2_7_hvp_path_matches_dense_adaptive() {
1962 let p = 40;
1966 let h = make_spd(p, 0.7);
1967 let a = random_dense_sym(p, 0xABBA);
1968 let seed = ProbeSeed(0x707);
1969
1970 let dense = evidence_traces_adaptive(
1971 h.view(),
1972 vec![DerivativeHessian::Dense(a.view())],
1973 None,
1974 seed,
1975 HUTCHINSON_ADAPTIVE_REL_TOL,
1976 HUTCHINSON_ADAPTIVE_TAU_REL,
1977 )
1978 .expect("dense ok");
1979
1980 let h_clone = h.clone();
1981 let hvp_evidence = evidence_traces_adaptive_hvp(
1982 p,
1983 |v: &[f64], out: &mut [f64]| {
1984 for r in 0..p {
1985 let mut acc = 0.0_f64;
1986 for c in 0..p {
1987 acc += h_clone[[r, c]] * v[c];
1988 }
1989 out[r] = acc;
1990 }
1991 },
1992 vec![DerivativeHessian::Dense(a.view())],
1993 None,
1994 seed,
1995 HUTCHINSON_ADAPTIVE_REL_TOL,
1996 HUTCHINSON_ADAPTIVE_TAU_REL,
1997 )
1998 .expect("hvp ok");
1999
2000 let exact = exact_trace_hinv_a(h.view(), a.view());
2004 let se_dense = dense.stderrs[0];
2005 let se_hvp = hvp_evidence.stderrs[0];
2006 let tol_dense = (8.0 * se_dense).max(0.05 * exact.abs());
2007 let tol_hvp = (8.0 * se_hvp).max(0.05 * exact.abs());
2008 assert!(
2009 (dense.traces[0] - exact).abs() <= tol_dense,
2010 "dense adaptive {} not near exact {} (tol {})",
2011 dense.traces[0],
2012 exact,
2013 tol_dense
2014 );
2015 assert!(
2016 (hvp_evidence.traces[0] - exact).abs() <= tol_hvp,
2017 "hvp adaptive {} not near exact {} (tol {})",
2018 hvp_evidence.traces[0],
2019 exact,
2020 tol_hvp
2021 );
2022 assert!(hvp_evidence.logdet_hessian.is_nan());
2024 }
2025
2026 #[test]
2027 fn block_2_7_hvp_stderr_matches_dense_reduce_mean_stderr() {
2028 let p = 36;
2037 let h = make_spd(p, 0.6);
2038 let a = random_dense_sym(p, 0x5151);
2039 let seed = ProbeSeed(0xBEEF);
2040 let force_full_schedule = 1e-12_f64;
2041
2042 let dense = evidence_traces_adaptive(
2043 h.view(),
2044 vec![DerivativeHessian::Dense(a.view())],
2045 None,
2046 seed,
2047 force_full_schedule,
2048 HUTCHINSON_ADAPTIVE_TAU_REL,
2049 )
2050 .expect("dense ok");
2051
2052 let h_clone = h.clone();
2053 let hvp = evidence_traces_adaptive_hvp(
2054 p,
2055 |v: &[f64], out: &mut [f64]| {
2056 for r in 0..p {
2057 let mut acc = 0.0_f64;
2058 for c in 0..p {
2059 acc += h_clone[[r, c]] * v[c];
2060 }
2061 out[r] = acc;
2062 }
2063 },
2064 vec![DerivativeHessian::Dense(a.view())],
2065 None,
2066 seed,
2067 force_full_schedule,
2068 HUTCHINSON_ADAPTIVE_TAU_REL,
2069 )
2070 .expect("hvp ok");
2071
2072 assert_eq!(dense.probe_count, 128);
2074 assert_eq!(hvp.probe_count, dense.probe_count);
2075
2076 let sd_dense = dense.stderrs[0];
2077 let sd_hvp = hvp.stderrs[0];
2078 assert!(
2079 sd_dense > 0.0,
2080 "dense SE should be positive, got {sd_dense}"
2081 );
2082 let rel = (sd_hvp - sd_dense).abs() / sd_dense;
2083 assert!(
2084 rel <= 1e-3,
2085 "HVP SE {sd_hvp} disagrees with dense reduce_mean_stderr SE {sd_dense} \
2086 (rel {rel}); the two paths must share the Bessel-corrected (K−1) convention"
2087 );
2088 }
2089
2090 #[test]
2091 fn block_2_7_cg_solves_diagonal_in_one_iteration() {
2092 let p = 8;
2096 let diag: Vec<f64> = (0..p).map(|i| 1.0 + i as f64).collect();
2097 let b: Vec<f64> = (0..p).map(|i| (i as f64) + 0.5).collect();
2098 let mut w = vec![0.0_f64; p];
2099 let diag_clone = diag.clone();
2100 cg_solve(
2101 &mut |v: &[f64], out: &mut [f64]| {
2102 for i in 0..p {
2103 out[i] = diag_clone[i] * v[i];
2104 }
2105 },
2106 &b,
2107 &mut w,
2108 1e-12,
2109 PCG_HVP_MAX_ITERS,
2110 );
2111 for i in 0..p {
2112 let expected = b[i] / diag[i];
2113 assert!(
2114 (w[i] - expected).abs() < 1e-10,
2115 "diagonal CG: w[{i}]={} expected {expected}",
2116 w[i]
2117 );
2118 }
2119 }
2120
2121 #[test]
2134 fn block_2_8_hill_climb_adaptive_vs_exact_at_p2000_d8() {
2135 let on_v100 = cfg!(target_os = "linux")
2138 && gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::GpuPolicy::Auto)
2139 .unwrap_or_else(|error| panic!("GPU probe fault in hill-climb gate: {error}"))
2140 .is_some();
2141 let (p, d): (usize, usize) = if on_v100 { (2000, 8) } else { (256, 4) };
2142
2143 let mut h = Array2::<f64>::zeros((p, p));
2144 for i in 0..p {
2145 for j in 0..p {
2146 h[[i, j]] = if i == j {
2147 p as f64 + 1.0
2148 } else {
2149 1.0 / (1.0 + (i as f64 - j as f64).abs())
2150 };
2151 }
2152 }
2153 let derivs_owned: Vec<Array2<f64>> = (0..d)
2154 .map(|k| random_dense_sym(p, 0x1000 + k as u64))
2155 .collect();
2156 let derivs: Vec<DerivativeHessian<'_>> = derivs_owned
2157 .iter()
2158 .map(|a| DerivativeHessian::Dense(a.view()))
2159 .collect();
2160
2161 let t_exact_start = std::time::Instant::now();
2165 let factor = cholesky_lower(&h).expect("SPD");
2166 let mut exact_traces = vec![0.0_f64; d];
2167 for (j, a) in derivs_owned.iter().enumerate() {
2168 let mut acc = 0.0_f64;
2169 for col in 0..p {
2170 let mut rhs = vec![0.0_f64; p];
2171 for r in 0..p {
2172 rhs[r] = a[[r, col]];
2173 }
2174 let w = solve_cholesky(&factor, &rhs);
2175 acc += w[col];
2176 }
2177 exact_traces[j] = acc;
2178 }
2179 let t_exact = t_exact_start.elapsed();
2180
2181 let t_adaptive_start = std::time::Instant::now();
2183 let evidence = evidence_traces_adaptive(
2184 h.view(),
2185 derivs,
2186 None,
2187 ProbeSeed(0xB10C),
2188 HUTCHINSON_ADAPTIVE_REL_TOL,
2189 HUTCHINSON_ADAPTIVE_TAU_REL,
2190 )
2191 .expect("adaptive ok");
2192 let t_adaptive = t_adaptive_start.elapsed();
2193
2194 for j in 0..d {
2197 let se = evidence.stderrs[j];
2198 let tol = (10.0 * se).max(0.05 * exact_traces[j].abs());
2199 let diff = (evidence.traces[j] - exact_traces[j]).abs();
2200 assert!(
2201 diff <= tol,
2202 "block_2_8: derivative {j} adaptive {} disagrees with exact {} (tol {tol}, diff {diff})",
2203 evidence.traces[j],
2204 exact_traces[j]
2205 );
2206 }
2207
2208 let speedup = t_exact.as_secs_f64() / t_adaptive.as_secs_f64().max(1e-9);
2209 eprintln!(
2210 "block_2_8 hill-climb [p={p}, d={d}, V100={on_v100}]: \
2211 exact={:?}, adaptive={:?}, speedup={:.2}× (K={}, converged={})",
2212 t_exact, t_adaptive, speedup, evidence.probe_count, evidence.converged
2213 );
2214 if on_v100 {
2215 assert!(
2231 evidence.stderrs.iter().all(|s| s.is_finite() && *s >= 0.0),
2232 "adaptive trace evidence must carry finite standard errors"
2233 );
2234 assert!(
2235 (evidence.probe_count as usize) * 8 < p,
2236 "adaptive trace probe budget {} is not sublinear in p={p}: \
2237 the exact path would be cheaper — adaptivity regressed",
2238 evidence.probe_count
2239 );
2240 }
2241 }
2242}