1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
74
75use gam_gpu::gpu_error::GpuError;
76
77#[derive(Clone, Copy, Debug)]
83pub struct ProbeSeed(pub u64);
84
85impl Default for ProbeSeed {
86 fn default() -> Self {
87 Self(0xCAFE_BABE)
90 }
91}
92
93#[derive(Clone, Debug)]
99pub enum DerivativeHessian<'a> {
100 Dense(ArrayView2<'a, f64>),
103 WeightedGram {
109 row_weights: ArrayView1<'a, f64>,
110 penalty_extra: Option<ArrayView2<'a, f64>>,
111 },
112}
113
114impl DerivativeHessian<'_> {
115 fn dim_p(&self, expected_p: usize, expected_n: usize) -> Result<(), GpuError> {
116 match self {
117 DerivativeHessian::Dense(matrix) => {
118 if matrix.nrows() != expected_p || matrix.ncols() != expected_p {
119 gam_gpu::gpu_bail!(
120 "reml_trace dense H_j: shape {:?} != ({expected_p}, {expected_p})",
121 matrix.dim()
122 );
123 }
124 }
125 DerivativeHessian::WeightedGram {
126 row_weights,
127 penalty_extra,
128 } => {
129 if row_weights.len() != expected_n {
130 gam_gpu::gpu_bail!(
131 "reml_trace structural H_j: row_weights.len()={} != n={expected_n}",
132 row_weights.len()
133 );
134 }
135 if let Some(p_extra) = penalty_extra
136 && (p_extra.nrows() != expected_p || p_extra.ncols() != expected_p)
137 {
138 gam_gpu::gpu_bail!(
139 "reml_trace structural H_j penalty_extra: shape {:?} != ({expected_p}, {expected_p})",
140 p_extra.dim()
141 );
142 }
143 }
144 }
145 Ok(())
146 }
147}
148
149#[derive(Clone, Debug)]
151pub struct RemlTraceHutchinsonInput<'a> {
152 pub penalized_hessian: ArrayView2<'a, f64>,
154 pub derivatives: Vec<DerivativeHessian<'a>>,
156 pub design: Option<ArrayView2<'a, f64>>,
159 pub probe_count: usize,
161 pub seed: ProbeSeed,
163}
164
165#[derive(Clone, Debug)]
167pub struct RemlTraceHutchinsonEvidence {
168 pub logdet_hessian: f64,
171 pub gradient_rho_logdet: Array1<f64>,
173 pub gradient_rho_stderr: Array1<f64>,
178 pub probe_count: usize,
180}
181
182pub const HUTCHINSON_GPU_MIN_P: usize = 512;
188pub const HUTCHINSON_GPU_MIN_K: usize = 8;
190pub const HUTCHINSON_GPU_MAX_K: usize = 128;
191
192#[inline]
201pub fn splitmix64_mix(z: u64) -> u64 {
202 gam_linalg::utils::splitmix64_hash(z)
203}
204
205#[inline]
213pub fn rademacher_entry(seed: u64, k: u64, i: u64) -> f64 {
214 const ZETA: u64 = 0xD1B5_4A32_D192_ED03;
215 const GAMMA: u64 = 0x8CB9_2BA7_2F9D_E81F;
216 let composite = seed ^ k.wrapping_mul(ZETA) ^ i.wrapping_mul(GAMMA);
217 let h = splitmix64_mix(composite);
218 if (h >> 63) == 0 { 1.0 } else { -1.0 }
219}
220
221pub fn fill_rademacher_host(seed: ProbeSeed, p: usize, k: usize, out: &mut [f64]) {
224 assert_eq!(
225 out.len(),
226 p * k,
227 "fill_rademacher_host: out buffer length {} != p*K = {}*{}",
228 out.len(),
229 p,
230 k
231 );
232 for col in 0..k {
233 for row in 0..p {
234 out[col * p + row] = rademacher_entry(seed.0, col as u64, row as u64);
235 }
236 }
237}
238
239pub fn evidence_derivatives_hutchinson_cpu(
250 input: &RemlTraceHutchinsonInput<'_>,
251) -> Result<RemlTraceHutchinsonEvidence, String> {
252 validate_inputs(input)?;
253 let p = input.penalized_hessian.nrows();
254 let d = input.derivatives.len();
255 let k = input.probe_count;
256
257 let h = input.penalized_hessian.to_owned();
259 let factor = cholesky_lower(&h)?;
260 let logdet_hessian = 2.0 * (0..p).map(|i| factor[[i, i]].ln()).sum::<f64>();
261
262 let mut z = vec![0.0_f64; p * k];
264 fill_rademacher_host(input.seed, p, k, &mut z);
265
266 use rayon::prelude::*;
274 let mut w = vec![0.0_f64; p * k];
275 w.par_chunks_mut(p)
276 .zip(z.par_chunks(p))
277 .for_each(|(w_col, z_col)| {
278 let solved = solve_cholesky(&factor, z_col);
279 w_col.copy_from_slice(&solved);
280 });
281
282 let mut q = vec![0.0_f64; d * k]; for (j, derivative) in input.derivatives.iter().enumerate() {
290 let q_row = &mut q[j * k..(j + 1) * k];
291 match derivative {
292 DerivativeHessian::Dense(matrix) => {
293 q_row
294 .par_iter_mut()
295 .zip(z.par_chunks(p).zip(w.par_chunks(p)))
296 .for_each(|(q_jk, (z_col, w_col))| {
297 let mut y = vec![0.0_f64; p];
299 for r in 0..p {
300 let mut acc = 0.0_f64;
301 for c in 0..p {
302 acc += matrix[[r, c]] * w_col[c];
303 }
304 y[r] = acc;
305 }
306 let mut zy = 0.0_f64;
307 for i in 0..p {
308 zy += z_col[i] * y[i];
309 }
310 *q_jk = zy;
311 });
312 }
313 DerivativeHessian::WeightedGram {
314 row_weights,
315 penalty_extra,
316 } => {
317 let design = input.design.as_ref().expect("design validated");
318 let n = design.nrows();
319 q_row
320 .par_iter_mut()
321 .zip(z.par_chunks(p).zip(w.par_chunks(p)))
322 .for_each(|(q_jk, (z_col, w_col))| {
323 let mut acc = 0.0_f64;
325 for row in 0..n {
326 let mut rz = 0.0_f64;
327 let mut rw = 0.0_f64;
328 for col_idx in 0..p {
329 rz += design[[row, col_idx]] * z_col[col_idx];
330 rw += design[[row, col_idx]] * w_col[col_idx];
331 }
332 acc += row_weights[row] * rz * rw;
333 }
334 if let Some(pen) = penalty_extra {
335 for r in 0..p {
336 let mut row_acc = 0.0_f64;
337 for c in 0..p {
338 row_acc += pen[[r, c]] * w_col[c];
339 }
340 acc += z_col[r] * row_acc;
341 }
342 }
343 *q_jk = acc;
344 });
345 }
346 }
347 }
348
349 let (means, stderrs) = reduce_mean_stderr(&q, d, k);
350 let mut gradient_rho_logdet = Array1::<f64>::zeros(d);
351 let mut gradient_rho_stderr = Array1::<f64>::zeros(d);
352 for j in 0..d {
353 gradient_rho_logdet[j] = 0.5 * means[j];
354 gradient_rho_stderr[j] = 0.5 * stderrs[j];
355 }
356
357 Ok(RemlTraceHutchinsonEvidence {
358 logdet_hessian,
359 gradient_rho_logdet,
360 gradient_rho_stderr,
361 probe_count: k,
362 })
363}
364
365pub fn evidence_derivatives_hutchinson_gpu(
375 input: RemlTraceHutchinsonInput<'_>,
376) -> Result<RemlTraceHutchinsonEvidence, String> {
377 validate_inputs(&input)?;
378
379 #[cfg(target_os = "linux")]
380 {
381 if gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())
382 .map_err(|error| error.to_string())?
383 .is_some()
384 {
385 match linux_cuda::evidence_derivatives(&input) {
386 Ok(evidence) => return Ok(evidence),
387 Err(GpuError::NoDeviceKernel { .. }) => {
388 log::debug!(
389 "reml-trace evidence derivatives: no device kernel in this build; \
390 falling through to the CPU reference"
391 );
392 }
393 Err(other) => return Err(String::from(other)),
394 }
395 }
396 }
397
398 evidence_derivatives_hutchinson_cpu(&input)
399}
400
401pub const HUTCHINSON_ADAPTIVE_REL_TOL: f64 = 0.01;
408pub const HUTCHINSON_ADAPTIVE_TAU_REL: f64 = 1e-8;
411
412pub struct AdaptiveTraceEvidence {
436 pub logdet_hessian: f64,
437 pub traces: Array1<f64>,
438 pub stderrs: Array1<f64>,
441 pub probe_count: usize,
442 pub converged: bool,
443}
444
445pub fn evidence_traces_adaptive<'a>(
446 penalized_hessian: ArrayView2<'a, f64>,
447 derivatives: Vec<DerivativeHessian<'a>>,
448 design: Option<ArrayView2<'a, f64>>,
449 seed: ProbeSeed,
450 rel_tol: f64,
451 tau_rel: f64,
452) -> Result<AdaptiveTraceEvidence, String> {
453 const SCHEDULE: [usize; 4] = [16, 32, 64, 128];
455
456 let d = derivatives.len();
457 if d == 0 {
458 return Err("evidence_traces_adaptive: derivatives is empty".to_string());
459 }
460 if !(rel_tol > 0.0) {
461 return Err(format!(
462 "evidence_traces_adaptive: rel_tol must be > 0 (got {rel_tol})"
463 ));
464 }
465 if !(tau_rel > 0.0) {
466 return Err(format!(
467 "evidence_traces_adaptive: tau_rel must be > 0 (got {tau_rel})"
468 ));
469 }
470
471 let mut last_logdet = 0.0_f64;
472 let mut last_traces = Array1::<f64>::zeros(d);
473 let mut last_stderrs = Array1::<f64>::zeros(d);
474 let mut last_k = 0_usize;
475 let mut converged = false;
476
477 for &k in &SCHEDULE {
478 let input = RemlTraceHutchinsonInput {
479 penalized_hessian,
480 derivatives: derivatives.clone(),
481 design,
482 probe_count: k,
483 seed,
484 };
485 let evidence = evidence_derivatives_hutchinson_gpu(input)?;
486 last_logdet = evidence.logdet_hessian;
487 last_k = k;
488
489 for j in 0..d {
493 last_traces[j] = 2.0 * evidence.gradient_rho_logdet[j];
494 last_stderrs[j] = 2.0 * evidence.gradient_rho_stderr[j];
495 }
496
497 let mut worst = 0.0_f64;
502 for j in 0..d {
503 let denom = last_traces[j].abs().max(tau_rel);
504 let r = last_stderrs[j] / denom;
505 if r > worst {
506 worst = r;
507 }
508 }
509 if worst <= rel_tol {
510 converged = true;
511 break;
512 }
513 }
514
515 Ok(AdaptiveTraceEvidence {
516 logdet_hessian: last_logdet,
517 traces: last_traces,
518 stderrs: last_stderrs,
519 probe_count: last_k,
520 converged,
521 })
522}
523
524#[must_use]
545pub fn should_bypass_cpu_with_gpu_adaptive(
546 p: usize,
547 dense_spd_h_resident: bool,
548 plain_spd_logdet: bool,
549 prefers_stochastic: bool,
550 projected_penalty_subspace_active: bool,
551) -> bool {
552 p >= HUTCHINSON_GPU_MIN_P
553 && dense_spd_h_resident
554 && plain_spd_logdet
555 && prefers_stochastic
556 && !projected_penalty_subspace_active
557}
558
559#[cfg(target_os = "linux")]
564mod linux_cuda {
565 use super::{
566 DerivativeHessian, ProbeSeed, RemlTraceHutchinsonEvidence, RemlTraceHutchinsonInput,
567 reduce_mean_stderr,
568 };
569 use cudarc::cublas::sys::cublasOperation_t;
570 use cudarc::cublas::{CudaBlas, Gemm, GemmConfig};
571 use cudarc::cusolver::DnHandle;
572 use cudarc::driver::{CudaContext, CudaModule, CudaStream, LaunchConfig, PushKernelArg};
573 use gam_gpu::driver::to_col_major;
574 use gam_gpu::gpu_error::{GpuError, GpuResultExt};
575 use gam_gpu::solver::{
576 cholesky_logdet_from_col_major, context_and_stream, pinned_htod, potrf_in_place,
577 potrs_in_place,
578 };
579 use std::sync::Arc;
580
581 pub(super) const PTX_SOURCE: &str = r#"
596extern "C" __device__ unsigned long long splitmix64_mix(unsigned long long z) {
597 z += 0x9E3779B97F4A7C15ULL;
598 unsigned long long x = z;
599 x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL;
600 x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL;
601 return x ^ (x >> 31);
602}
603
604extern "C" __global__ void fill_rademacher_splitmix(
605 unsigned long long seed,
606 unsigned int p,
607 unsigned int K,
608 double* __restrict__ Z)
609{
610 unsigned int i = blockIdx.x * blockDim.x + threadIdx.x;
611 unsigned int k = blockIdx.y;
612 if (i >= p || k >= K) return;
613 const unsigned long long ZETA = 0xD1B54A32D192ED03ULL;
614 const unsigned long long GAMMA = 0x8CB92BA72F9DE81FULL;
615 unsigned long long composite =
616 seed
617 ^ (((unsigned long long)k) * ZETA)
618 ^ (((unsigned long long)i) * GAMMA);
619 unsigned long long h = splitmix64_mix(composite);
620 double v = (h >> 63) == 0 ? 1.0 : -1.0;
621 Z[(size_t)k * (size_t)p + (size_t)i] = v;
622}
623
624extern "C" __device__ double block_reduce_sum(double v) {
625 __shared__ double smem[32];
626 int lane = threadIdx.x & 31;
627 int wid = threadIdx.x >> 5;
628 for (int off = 16; off > 0; off >>= 1) {
629 v += __shfl_down_sync(0xffffffff, v, off);
630 }
631 if (lane == 0) smem[wid] = v;
632 __syncthreads();
633 double total = 0.0;
634 int n_warps = (blockDim.x + 31) >> 5;
635 if (threadIdx.x < (unsigned)n_warps) total = smem[threadIdx.x];
636 if (wid == 0) {
637 for (int off = 16; off > 0; off >>= 1) {
638 total += __shfl_down_sync(0xffffffff, total, off);
639 }
640 }
641 return total;
642}
643
644extern "C" __global__ void reduce_q_dense(
645 unsigned int p,
646 unsigned int K,
647 unsigned int D,
648 const double* __restrict__ Z,
649 const double* __restrict__ Y_stack,
650 double* __restrict__ Q)
651{
652 unsigned int k = blockIdx.x;
653 unsigned int j = blockIdx.y;
654 if (k >= K || j >= D) return;
655 const double* z_col = Z + (size_t)k * (size_t)p;
656 const double* y_col = Y_stack + ((size_t)j * (size_t)K + (size_t)k) * (size_t)p;
657 double partial = 0.0;
658 for (unsigned int i = threadIdx.x; i < p; i += blockDim.x) {
659 partial += z_col[i] * y_col[i];
660 }
661 double total = block_reduce_sum(partial);
662 if (threadIdx.x == 0) {
663 Q[(size_t)j * (size_t)K + (size_t)k] = total;
664 }
665}
666
667extern "C" __global__ void reduce_q_weighted_gram(
668 unsigned int n,
669 unsigned int K,
670 unsigned int D,
671 const double* __restrict__ RZ,
672 const double* __restrict__ RW,
673 const double* __restrict__ A_stack,
674 double* __restrict__ Q)
675{
676 unsigned int k = blockIdx.x;
677 unsigned int j = blockIdx.y;
678 if (k >= K || j >= D) return;
679 const double* rz_col = RZ + (size_t)k * (size_t)n;
680 const double* rw_col = RW + (size_t)k * (size_t)n;
681 const double* a_col = A_stack + (size_t)j * (size_t)n;
682 double partial = 0.0;
683 for (unsigned int i = threadIdx.x; i < n; i += blockDim.x) {
684 partial += a_col[i] * rz_col[i] * rw_col[i];
685 }
686 double total = block_reduce_sum(partial);
687 if (threadIdx.x == 0) {
688 Q[(size_t)j * (size_t)K + (size_t)k] = total;
689 }
690}
691"#;
692
693 const THREADS_PER_BLOCK: u32 = 256;
694
695 fn module(ctx: &Arc<CudaContext>) -> Result<&'static Arc<CudaModule>, GpuError> {
696 static CACHE: gam_gpu::device_cache::PtxModuleCache =
697 gam_gpu::device_cache::PtxModuleCache::new();
698 CACHE.get_or_compile(ctx, "reml_trace", PTX_SOURCE)
699 }
700
701 pub(super) fn evidence_derivatives(
702 input: &RemlTraceHutchinsonInput<'_>,
703 ) -> Result<RemlTraceHutchinsonEvidence, GpuError> {
704 let p = input.penalized_hessian.nrows();
705 let d = input.derivatives.len();
706 let k = input.probe_count;
707 let (ctx, stream) =
708 context_and_stream().map_err(|reason| GpuError::DriverCallFailed { reason })?;
709 let solver = DnHandle::new(stream.clone()).gpu_ctx("reml_trace cusolver init")?;
710 let blas = CudaBlas::new(stream.clone()).gpu_ctx("reml_trace cublas init")?;
711 let compiled = module(&ctx)?;
712 let module_handle: &Arc<CudaModule> = compiled;
713
714 let h_col = to_col_major(&input.penalized_hessian);
716 let mut h_dev =
717 pinned_htod(&stream, &h_col).map_err(|reason| GpuError::DriverCallFailed { reason })?;
718 potrf_in_place(&solver, &stream, p, &mut h_dev)
719 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
720 let factor_col = stream
721 .clone_dtoh(&h_dev)
722 .gpu_ctx("reml_trace download factor")?;
723 let logdet_hessian = cholesky_logdet_from_col_major(&factor_col, p);
724
725 let total_z = p
727 .checked_mul(k)
728 .ok_or_else(|| gam_gpu::gpu_err!("reml_trace Z size overflow: p={p}, K={k}"))?;
729 let mut z_dev = stream
730 .alloc_zeros::<f64>(total_z)
731 .gpu_ctx("reml_trace alloc Z")?;
732 launch_fill_rademacher(&stream, module_handle, input.seed, p, k, &mut z_dev)?;
733
734 let mut w_dev = stream
737 .alloc_zeros::<f64>(total_z)
738 .gpu_ctx("reml_trace alloc W")?;
739 copy_device_slice(&stream, &z_dev, &mut w_dev)?;
740 potrs_in_place(&solver, &stream, p, k, &h_dev, &mut w_dev)
741 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
742
743 let mut dense_indices: Vec<usize> = Vec::new();
745 let mut gram_indices: Vec<usize> = Vec::new();
746 for (j, deriv) in input.derivatives.iter().enumerate() {
747 match deriv {
748 DerivativeHessian::Dense(_) => dense_indices.push(j),
749 DerivativeHessian::WeightedGram { .. } => gram_indices.push(j),
750 }
751 }
752
753 let mut q_host = vec![0.0_f64; d * k];
754
755 if !dense_indices.is_empty() {
760 for &j in &dense_indices {
761 let DerivativeHessian::Dense(matrix) = &input.derivatives[j] else {
762 panic!(
769 "reml_trace dense path: derivative index {j} is in dense_indices but \
770 input.derivatives[{j}] is not DerivativeHessian::Dense — \
771 dense_indices partition invariant violated"
772 );
773 };
774 let hj_col = to_col_major(matrix);
775 let hj_dev = pinned_htod(&stream, &hj_col)
776 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
777 let mut y_dev = stream
778 .alloc_zeros::<f64>(total_z)
779 .map_err(|err| gam_gpu::gpu_err!("reml_trace alloc Y_j (j={j}): {err}"))?;
780 gemm_nn(
781 &blas,
782 GemmShape {
783 m: p,
784 n: k,
785 k_inner: p,
786 lda: p,
787 ldb: p,
788 ldc: p,
789 },
790 &hj_dev,
791 &w_dev,
792 &mut y_dev,
793 )?;
794 let mut q_j_dev = stream
795 .alloc_zeros::<f64>(k)
796 .gpu_ctx_with(|err| format!("reml_trace alloc Q_j (j={j}): {err}"))?;
797 launch_reduce_q_dense(
798 &stream,
799 module_handle,
800 p,
801 k,
802 1,
803 &z_dev,
804 &y_dev,
805 &mut q_j_dev,
806 )?;
807 let q_host_j = stream
808 .clone_dtoh(&q_j_dev)
809 .gpu_ctx_with(|err| format!("reml_trace download Q_j (j={j}): {err}"))?;
810 q_host[j * k..(j + 1) * k].copy_from_slice(&q_host_j);
811 }
812 }
813
814 if !gram_indices.is_empty() {
817 let design = input
818 .design
819 .as_ref()
820 .ok_or_else(|| GpuError::DriverCallFailed {
821 reason: "reml_trace: structural derivative present but design=None".to_string(),
822 })?;
823 let n = design.nrows();
824 let design_col = to_col_major(design);
825 let x_dev = pinned_htod(&stream, &design_col)
826 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
827 let mut rz_dev = stream
828 .alloc_zeros::<f64>(
829 n.checked_mul(k)
830 .ok_or_else(|| gam_gpu::gpu_err!("reml_trace RZ overflow: n={n}, K={k}"))?,
831 )
832 .gpu_ctx("reml_trace alloc RZ")?;
833 let mut rw_dev = stream
834 .alloc_zeros::<f64>(n * k)
835 .gpu_ctx("reml_trace alloc RW")?;
836 gemm_nn(
838 &blas,
839 GemmShape {
840 m: n,
841 n: k,
842 k_inner: p,
843 lda: n,
844 ldb: p,
845 ldc: n,
846 },
847 &x_dev,
848 &z_dev,
849 &mut rz_dev,
850 )?;
851 gemm_nn(
853 &blas,
854 GemmShape {
855 m: n,
856 n: k,
857 k_inner: p,
858 lda: n,
859 ldb: p,
860 ldc: n,
861 },
862 &x_dev,
863 &w_dev,
864 &mut rw_dev,
865 )?;
866
867 let d_gram = gram_indices.len();
869 let mut a_stack = Vec::<f64>::with_capacity(n * d_gram);
870 for &j in &gram_indices {
871 let DerivativeHessian::WeightedGram { row_weights, .. } = &input.derivatives[j]
872 else {
873 panic!(
879 "reml_trace structural path: derivative index {j} is in gram_indices \
880 but input.derivatives[{j}] is not DerivativeHessian::WeightedGram — \
881 gram_indices partition invariant violated"
882 );
883 };
884 let slice = row_weights.as_slice().ok_or_else(|| {
885 gam_gpu::gpu_err!("reml_trace structural H_j={j} row_weights not contiguous")
886 })?;
887 a_stack.extend_from_slice(slice);
888 }
889 let a_dev = pinned_htod(&stream, &a_stack)
890 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
891 let mut q_dev = stream
892 .alloc_zeros::<f64>(d_gram * k)
893 .map_err(|err| gam_gpu::gpu_err!("reml_trace alloc Q_gram: {err}"))?;
894 launch_reduce_q_weighted_gram(
895 &stream,
896 module_handle,
897 n,
898 k,
899 d_gram,
900 &rz_dev,
901 &rw_dev,
902 &a_dev,
903 &mut q_dev,
904 )?;
905 let q_host_gram = stream
906 .clone_dtoh(&q_dev)
907 .gpu_ctx("reml_trace download Q_gram")?;
908 for (slot, &j) in gram_indices.iter().enumerate() {
909 q_host[j * k..(j + 1) * k].copy_from_slice(&q_host_gram[slot * k..(slot + 1) * k]);
910 }
911 for &j in &gram_indices {
915 let DerivativeHessian::WeightedGram { penalty_extra, .. } = &input.derivatives[j]
916 else {
917 panic!(
924 "reml_trace structural penalty_extra: derivative index {j} is in \
925 gram_indices but input.derivatives[{j}] is not \
926 DerivativeHessian::WeightedGram — gram_indices partition invariant \
927 violated"
928 );
929 };
930 if let Some(pen) = penalty_extra {
931 let z_host = stream
932 .clone_dtoh(&z_dev)
933 .gpu_ctx("reml_trace download Z for penalty_extra")?;
934 let w_host = stream
935 .clone_dtoh(&w_dev)
936 .gpu_ctx("reml_trace download W for penalty_extra")?;
937 for col in 0..k {
938 let z_col = &z_host[col * p..(col + 1) * p];
939 let w_col = &w_host[col * p..(col + 1) * p];
940 let mut acc = 0.0_f64;
941 for r in 0..p {
942 let mut row_acc = 0.0_f64;
943 for c in 0..p {
944 row_acc += pen[[r, c]] * w_col[c];
945 }
946 acc += z_col[r] * row_acc;
947 }
948 q_host[j * k + col] += acc;
949 }
950 }
951 }
952 }
953
954 let (means, stderrs) = reduce_mean_stderr(&q_host, d, k);
955 let mut gradient_rho_logdet = ndarray::Array1::<f64>::zeros(d);
956 let mut gradient_rho_stderr = ndarray::Array1::<f64>::zeros(d);
957 for j in 0..d {
958 gradient_rho_logdet[j] = 0.5 * means[j];
959 gradient_rho_stderr[j] = 0.5 * stderrs[j];
960 }
961
962 Ok(RemlTraceHutchinsonEvidence {
963 logdet_hessian,
964 gradient_rho_logdet,
965 gradient_rho_stderr,
966 probe_count: k,
967 })
968 }
969
970 fn launch_fill_rademacher(
973 stream: &Arc<CudaStream>,
974 module: &Arc<CudaModule>,
975 seed: ProbeSeed,
976 p: usize,
977 k: usize,
978 z: &mut cudarc::driver::CudaSlice<f64>,
979 ) -> Result<(), GpuError> {
980 let func = module
981 .load_function("fill_rademacher_splitmix")
982 .gpu_ctx("reml_trace load fill_rademacher")?;
983 let grid_x = ((p as u32) + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
984 let cfg = LaunchConfig {
985 grid_dim: (grid_x, k as u32, 1),
986 block_dim: (THREADS_PER_BLOCK, 1, 1),
987 shared_mem_bytes: 0,
988 };
989 let seed_arg: u64 = seed.0;
990 let p_arg: u32 = p as u32;
991 let k_arg: u32 = k as u32;
992 unsafe {
995 stream
996 .launch_builder(&func)
997 .arg(&seed_arg)
998 .arg(&p_arg)
999 .arg(&k_arg)
1000 .arg(z)
1001 .launch(cfg)
1002 }
1003 .gpu_ctx("reml_trace launch fill_rademacher")?;
1004 Ok(())
1005 }
1006
1007 fn launch_reduce_q_dense(
1008 stream: &Arc<CudaStream>,
1009 module: &Arc<CudaModule>,
1010 p: usize,
1011 k: usize,
1012 d: usize,
1013 z: &cudarc::driver::CudaSlice<f64>,
1014 y_stack: &cudarc::driver::CudaSlice<f64>,
1015 q: &mut cudarc::driver::CudaSlice<f64>,
1016 ) -> Result<(), GpuError> {
1017 let func = module
1018 .load_function("reduce_q_dense")
1019 .gpu_ctx("reml_trace load reduce_q_dense")?;
1020 let cfg = LaunchConfig {
1021 grid_dim: (k as u32, d as u32, 1),
1022 block_dim: (THREADS_PER_BLOCK, 1, 1),
1023 shared_mem_bytes: 0,
1024 };
1025 let p_arg: u32 = p as u32;
1026 let k_arg: u32 = k as u32;
1027 let d_arg: u32 = d as u32;
1028 unsafe {
1031 stream
1032 .launch_builder(&func)
1033 .arg(&p_arg)
1034 .arg(&k_arg)
1035 .arg(&d_arg)
1036 .arg(z)
1037 .arg(y_stack)
1038 .arg(q)
1039 .launch(cfg)
1040 }
1041 .gpu_ctx("reml_trace launch reduce_q_dense")?;
1042 Ok(())
1043 }
1044
1045 fn launch_reduce_q_weighted_gram(
1046 stream: &Arc<CudaStream>,
1047 module: &Arc<CudaModule>,
1048 n: usize,
1049 k: usize,
1050 d: usize,
1051 rz: &cudarc::driver::CudaSlice<f64>,
1052 rw: &cudarc::driver::CudaSlice<f64>,
1053 a_stack: &cudarc::driver::CudaSlice<f64>,
1054 q: &mut cudarc::driver::CudaSlice<f64>,
1055 ) -> Result<(), GpuError> {
1056 let func = module
1057 .load_function("reduce_q_weighted_gram")
1058 .gpu_ctx("reml_trace load reduce_q_weighted_gram")?;
1059 let cfg = LaunchConfig {
1060 grid_dim: (k as u32, d as u32, 1),
1061 block_dim: (THREADS_PER_BLOCK, 1, 1),
1062 shared_mem_bytes: 0,
1063 };
1064 let n_arg: u32 = n as u32;
1065 let k_arg: u32 = k as u32;
1066 let d_arg: u32 = d as u32;
1067 unsafe {
1069 stream
1070 .launch_builder(&func)
1071 .arg(&n_arg)
1072 .arg(&k_arg)
1073 .arg(&d_arg)
1074 .arg(rz)
1075 .arg(rw)
1076 .arg(a_stack)
1077 .arg(q)
1078 .launch(cfg)
1079 }
1080 .gpu_ctx("reml_trace launch reduce_q_weighted_gram")?;
1081 Ok(())
1082 }
1083
1084 fn copy_device_slice(
1085 stream: &Arc<CudaStream>,
1086 src: &cudarc::driver::CudaSlice<f64>,
1087 dst: &mut cudarc::driver::CudaSlice<f64>,
1088 ) -> Result<(), GpuError> {
1089 stream.memcpy_dtod(src, dst).gpu_ctx("reml_trace dtod copy")
1090 }
1091
1092 struct GemmShape {
1093 m: usize,
1094 n: usize,
1095 k_inner: usize,
1096 lda: usize,
1097 ldb: usize,
1098 ldc: usize,
1099 }
1100
1101 fn gemm_nn(
1102 blas: &CudaBlas,
1103 shape: GemmShape,
1104 a: &cudarc::driver::CudaSlice<f64>,
1105 b: &cudarc::driver::CudaSlice<f64>,
1106 c: &mut cudarc::driver::CudaSlice<f64>,
1107 ) -> Result<(), GpuError> {
1108 let GemmShape {
1109 m,
1110 n,
1111 k_inner,
1112 lda,
1113 ldb,
1114 ldc,
1115 } = shape;
1116 let cfg = GemmConfig::<f64> {
1117 transa: cublasOperation_t::CUBLAS_OP_N,
1118 transb: cublasOperation_t::CUBLAS_OP_N,
1119 m: m as i32,
1120 n: n as i32,
1121 k: k_inner as i32,
1122 alpha: 1.0,
1123 lda: lda as i32,
1124 ldb: ldb as i32,
1125 beta: 0.0,
1126 ldc: ldc as i32,
1127 };
1128 unsafe { blas.gemm(cfg, a, b, c) }.gpu_ctx("reml_trace cublas dgemm")
1131 }
1132}
1133
1134fn validate_inputs(input: &RemlTraceHutchinsonInput<'_>) -> Result<(), String> {
1139 let (p, p2) = input.penalized_hessian.dim();
1140 if p == 0 || p != p2 {
1141 return Err(format!("reml_trace input H must be square, got {p}x{p2}"));
1142 }
1143 if input.probe_count < 2 {
1144 return Err(format!(
1145 "reml_trace requires probe_count >= 2 for a sample SE, got {}",
1146 input.probe_count
1147 ));
1148 }
1149 let needs_design = input
1150 .derivatives
1151 .iter()
1152 .any(|d| matches!(d, DerivativeHessian::WeightedGram { .. }));
1153 if needs_design && input.design.is_none() {
1154 return Err("reml_trace: structural derivative present but design=None".to_string());
1155 }
1156 let n = input.design.as_ref().map(|x| x.nrows()).unwrap_or(0);
1157 if let Some(x) = input.design.as_ref()
1158 && x.ncols() != p
1159 {
1160 return Err(format!(
1161 "reml_trace design has {} columns, expected p={p}",
1162 x.ncols()
1163 ));
1164 }
1165 for (j, derivative) in input.derivatives.iter().enumerate() {
1166 derivative
1167 .dim_p(p, n)
1168 .map_err(String::from)
1169 .map_err(|e| format!("reml_trace derivative {j}: {e}"))?;
1170 }
1171 Ok(())
1172}
1173
1174fn reduce_mean_stderr(q: &[f64], d: usize, k: usize) -> (Vec<f64>, Vec<f64>) {
1179 assert_eq!(
1180 q.len(),
1181 d * k,
1182 "reduce_mean_stderr: q buffer length {} != D*K = {}*{}",
1183 q.len(),
1184 d,
1185 k
1186 );
1187 let mut means = vec![0.0_f64; d];
1188 let mut stderrs = vec![0.0_f64; d];
1189 let inv_k = 1.0 / (k as f64);
1190 for j in 0..d {
1191 let row = &q[j * k..(j + 1) * k];
1192 let mean = row.iter().copied().sum::<f64>() * inv_k;
1193 means[j] = mean;
1194 if k >= 2 {
1195 let var = row.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / ((k - 1) as f64);
1196 stderrs[j] = (var / (k as f64)).sqrt();
1197 }
1198 }
1199 (means, stderrs)
1200}
1201
1202fn cholesky_lower(matrix: &Array2<f64>) -> Result<Array2<f64>, String> {
1205 let n = matrix.nrows();
1206 let mut l = Array2::<f64>::zeros((n, n));
1207 for i in 0..n {
1208 for j in 0..=i {
1209 let mut sum = matrix[[i, j]];
1210 for k in 0..j {
1211 sum -= l[[i, k]] * l[[j, k]];
1212 }
1213 if i == j {
1214 if sum <= 0.0 {
1215 return Err(format!(
1216 "reml_trace CPU Cholesky: non-SPD diagonal {sum} at row {i}"
1217 ));
1218 }
1219 l[[i, j]] = sum.sqrt();
1220 } else {
1221 l[[i, j]] = sum / l[[j, j]];
1222 }
1223 }
1224 }
1225 Ok(l)
1226}
1227
1228fn solve_cholesky(l: &Array2<f64>, rhs: &[f64]) -> Vec<f64> {
1229 let n = l.nrows();
1230 let mut y = vec![0.0_f64; n];
1231 for i in 0..n {
1232 let mut sum = rhs[i];
1233 for k in 0..i {
1234 sum -= l[[i, k]] * y[k];
1235 }
1236 y[i] = sum / l[[i, i]];
1237 }
1238 let mut x = vec![0.0_f64; n];
1239 for i in (0..n).rev() {
1240 let mut sum = y[i];
1241 for k in (i + 1)..n {
1242 sum -= l[[k, i]] * x[k];
1243 }
1244 x[i] = sum / l[[i, i]];
1245 }
1246 x
1247}
1248
1249#[cfg(test)]
1254mod tests {
1255 use super::*;
1256 use ndarray::{Array2, ArrayView2};
1257
1258 fn make_spd(p: usize, jitter: f64) -> Array2<f64> {
1259 let mut h = Array2::<f64>::zeros((p, p));
1260 for i in 0..p {
1261 for j in 0..p {
1262 h[[i, j]] = if i == j {
1263 p as f64 + jitter
1264 } else {
1265 1.0 / (1.0 + (i as f64 - j as f64).abs())
1266 };
1267 }
1268 }
1269 h
1270 }
1271
1272 fn random_dense_sym(p: usize, seed: u64) -> Array2<f64> {
1273 let mut a = Array2::<f64>::zeros((p, p));
1274 let mut s = seed;
1275 for i in 0..p {
1276 for j in i..p {
1277 s = splitmix64_mix(s.wrapping_add(1));
1278 let v = ((s >> 11) as f64) / ((1u64 << 53) as f64) - 0.5;
1279 a[[i, j]] = v;
1280 a[[j, i]] = v;
1281 }
1282 }
1283 a
1284 }
1285
1286 fn exact_trace_hinv_a(h: ArrayView2<f64>, a: ArrayView2<f64>) -> f64 {
1287 let p = h.nrows();
1288 let factor = cholesky_lower(&h.to_owned()).expect("SPD");
1289 let mut trace = 0.0;
1290 for col in 0..p {
1291 let mut e = vec![0.0_f64; p];
1292 e[col] = 1.0;
1293 let w = solve_cholesky(&factor, &e);
1294 let mut diag = 0.0;
1296 for i in 0..p {
1297 diag += a[[col, i]] * w[i];
1298 }
1299 trace += diag;
1300 }
1301 trace
1302 }
1303
1304 #[test]
1305 fn splitmix_is_deterministic_and_disperses() {
1306 assert_eq!(splitmix64_mix(42), splitmix64_mix(42));
1309 let mut bits_seen = 0u64;
1310 for x in 0u64..64 {
1311 bits_seen |= splitmix64_mix(x);
1312 }
1313 assert_eq!(
1314 bits_seen,
1315 u64::MAX,
1316 "splitmix should cover every bit position across 64 inputs"
1317 );
1318 }
1319
1320 #[test]
1321 fn rademacher_entries_are_pm_one_and_stateless() {
1322 let seed = ProbeSeed(0xCAFE_BABE);
1323 for k in 0..16u64 {
1324 for i in 0..32u64 {
1325 let v = rademacher_entry(seed.0, k, i);
1326 assert!(
1327 v == 1.0 || v == -1.0,
1328 "non-pm1 entry at (k={k}, i={i}): {v}"
1329 );
1330 let v2 = rademacher_entry(seed.0, k, i);
1331 assert_eq!(v, v2, "same (k,i) must hash to same value");
1332 }
1333 }
1334 }
1335
1336 #[test]
1337 fn rademacher_common_random_numbers_match_for_prefix() {
1338 let p = 50;
1340 let mut z16 = vec![0.0_f64; p * 16];
1341 let mut z32 = vec![0.0_f64; p * 32];
1342 fill_rademacher_host(ProbeSeed(7), p, 16, &mut z16);
1343 fill_rademacher_host(ProbeSeed(7), p, 32, &mut z32);
1344 for col in 0..16 {
1345 for row in 0..p {
1346 assert_eq!(
1347 z16[col * p + row],
1348 z32[col * p + row],
1349 "CRN broken at (col={col}, row={row})"
1350 );
1351 }
1352 }
1353 }
1354
1355 #[test]
1356 fn cpu_hutchinson_unbiased_against_exact_small_spd() {
1357 let p = 16;
1358 let h = make_spd(p, 0.5);
1359 let a1 = random_dense_sym(p, 0x1234);
1360 let a2 = random_dense_sym(p, 0x5678);
1361 let exact1 = exact_trace_hinv_a(h.view(), a1.view());
1362 let exact2 = exact_trace_hinv_a(h.view(), a2.view());
1363 let input = RemlTraceHutchinsonInput {
1364 penalized_hessian: h.view(),
1365 derivatives: vec![
1366 DerivativeHessian::Dense(a1.view()),
1367 DerivativeHessian::Dense(a2.view()),
1368 ],
1369 design: None,
1370 probe_count: 4096,
1371 seed: ProbeSeed(0xCAFE_BABE),
1372 };
1373 let evidence = evidence_derivatives_hutchinson_cpu(&input).expect("ok");
1374 let est1 = 2.0 * evidence.gradient_rho_logdet[0];
1376 let est2 = 2.0 * evidence.gradient_rho_logdet[1];
1377 let se1 = 2.0 * evidence.gradient_rho_stderr[0];
1380 let se2 = 2.0 * evidence.gradient_rho_stderr[1];
1381 let tol1 = 6.0 * se1.max(1e-8);
1382 let tol2 = 6.0 * se2.max(1e-8);
1383 assert!(
1384 (est1 - exact1).abs() <= tol1,
1385 "Hutchinson est {est1} too far from exact {exact1} (tol={tol1}, se={})",
1386 evidence.gradient_rho_stderr[0]
1387 );
1388 assert!(
1389 (est2 - exact2).abs() <= tol2,
1390 "Hutchinson est {est2} too far from exact {exact2} (tol={tol2})"
1391 );
1392 }
1393
1394 #[test]
1395 fn structural_path_matches_dense_for_xtwx() {
1396 let n = 40;
1399 let p = 8;
1400 let mut x = Array2::<f64>::zeros((n, p));
1401 let mut s = 11u64;
1402 for r in 0..n {
1403 for c in 0..p {
1404 s = splitmix64_mix(s.wrapping_add(1));
1405 x[[r, c]] = ((s >> 11) as f64) / ((1u64 << 53) as f64) - 0.5;
1406 }
1407 }
1408 let a: Vec<f64> = (0..n).map(|i| 0.5 + 0.01 * (i as f64)).collect();
1409 let a_arr = ndarray::Array1::from(a);
1410 let mut hj_dense = Array2::<f64>::zeros((p, p));
1412 for r in 0..p {
1413 for c in 0..p {
1414 let mut acc = 0.0;
1415 for i in 0..n {
1416 acc += x[[i, r]] * a_arr[i] * x[[i, c]];
1417 }
1418 hj_dense[[r, c]] = acc;
1419 }
1420 }
1421 let mut h = make_spd(p, 1.0);
1423 for i in 0..p {
1424 h[[i, i]] += 1.0;
1425 }
1426 let input_dense = RemlTraceHutchinsonInput {
1427 penalized_hessian: h.view(),
1428 derivatives: vec![DerivativeHessian::Dense(hj_dense.view())],
1429 design: None,
1430 probe_count: 32,
1431 seed: ProbeSeed(123),
1432 };
1433 let input_struct = RemlTraceHutchinsonInput {
1434 penalized_hessian: h.view(),
1435 derivatives: vec![DerivativeHessian::WeightedGram {
1436 row_weights: a_arr.view(),
1437 penalty_extra: None,
1438 }],
1439 design: Some(x.view()),
1440 probe_count: 32,
1441 seed: ProbeSeed(123),
1442 };
1443 let e_dense = evidence_derivatives_hutchinson_cpu(&input_dense).expect("ok");
1444 let e_struct = evidence_derivatives_hutchinson_cpu(&input_struct).expect("ok");
1445 assert!(
1447 (e_dense.gradient_rho_logdet[0] - e_struct.gradient_rho_logdet[0]).abs() < 1e-9,
1448 "dense vs structural mismatch: dense={}, struct={}",
1449 e_dense.gradient_rho_logdet[0],
1450 e_struct.gradient_rho_logdet[0]
1451 );
1452 }
1453
1454 #[test]
1455 fn finite_difference_check_against_logdet() {
1456 let p = 10;
1458 let h0 = make_spd(p, 0.2);
1459 let a = random_dense_sym(p, 0xABCD);
1460 let eps = 1e-4;
1461 let mut hp = h0.clone();
1462 let mut hm = h0.clone();
1463 for i in 0..p {
1464 for j in 0..p {
1465 hp[[i, j]] += eps * a[[i, j]];
1466 hm[[i, j]] -= eps * a[[i, j]];
1467 }
1468 }
1469 let ld = |m: &Array2<f64>| -> f64 {
1470 let l = cholesky_lower(m).unwrap();
1471 2.0 * (0..p).map(|i| l[[i, i]].ln()).sum::<f64>()
1472 };
1473 let fd = (ld(&hp) - ld(&hm)) / (2.0 * eps);
1474 let exact = exact_trace_hinv_a(h0.view(), a.view());
1475 assert!(
1476 (fd - exact).abs() / exact.abs().max(1e-12) < 1e-6,
1477 "FD logdet derivative {fd} != exact trace {exact}"
1478 );
1479 let input = RemlTraceHutchinsonInput {
1481 penalized_hessian: h0.view(),
1482 derivatives: vec![DerivativeHessian::Dense(a.view())],
1483 design: None,
1484 probe_count: 4096,
1485 seed: ProbeSeed(0xAA55),
1486 };
1487 let evidence = evidence_derivatives_hutchinson_cpu(&input).expect("ok");
1488 let se = evidence.gradient_rho_stderr[0];
1490 let tol = 8.0 * se.max(1e-8);
1491 assert!(
1492 (evidence.gradient_rho_logdet[0] - 0.5 * exact).abs() < tol,
1493 "Hutchinson gradient {} not within 8·SE of 0.5·exact={}",
1494 evidence.gradient_rho_logdet[0],
1495 0.5 * exact
1496 );
1497 }
1498
1499 #[test]
1508 fn block_2_6_adaptive_unbiased_against_exact_p512() {
1509 let p = 64;
1512 let h = make_spd(p, 0.5);
1513 let a = random_dense_sym(p, 0xBADC0DE);
1514 let exact = exact_trace_hinv_a(h.view(), a.view());
1515 let evidence = evidence_traces_adaptive(
1516 h.view(),
1517 vec![DerivativeHessian::Dense(a.view())],
1518 None,
1519 ProbeSeed(0xA5A5A5),
1520 HUTCHINSON_ADAPTIVE_REL_TOL,
1521 HUTCHINSON_ADAPTIVE_TAU_REL,
1522 )
1523 .expect("adaptive run ok");
1524 let est = evidence.traces[0];
1525 let se = evidence.stderrs[0];
1526 let tol = (8.0 * se).max(0.05 * exact.abs());
1527 assert!(
1528 (est - exact).abs() <= tol,
1529 "adaptive est {est} far from exact {exact} (tol={tol}, se={se}, K={})",
1530 evidence.probe_count
1531 );
1532 }
1533
1534 #[test]
1535 fn block_2_6_same_probes_cpu_vs_dispatch() {
1536 let p = 32;
1543 let h = make_spd(p, 0.3);
1544 let a = random_dense_sym(p, 0x1357);
1545 let input = RemlTraceHutchinsonInput {
1546 penalized_hessian: h.view(),
1547 derivatives: vec![DerivativeHessian::Dense(a.view())],
1548 design: None,
1549 probe_count: 16,
1550 seed: ProbeSeed(0xBEEF),
1551 };
1552 let cpu = evidence_derivatives_hutchinson_cpu(&input).expect("cpu");
1553 let dispatch = evidence_derivatives_hutchinson_gpu(input).expect("dispatch");
1554 let diff = (cpu.gradient_rho_logdet[0] - dispatch.gradient_rho_logdet[0]).abs();
1555 assert!(
1556 diff < 1e-9,
1557 "same-probes CPU vs GPU dispatch differ: cpu={}, dispatch={}, diff={diff}",
1558 cpu.gradient_rho_logdet[0],
1559 dispatch.gradient_rho_logdet[0]
1560 );
1561 }
1562
1563 #[test]
1564 fn block_2_6_fd_logdet_matches_adaptive() {
1565 let p = 24;
1568 let h = make_spd(p, 0.4);
1569 let a = random_dense_sym(p, 0x2468);
1570 let eps = 1e-4;
1571 let mut hp = h.clone();
1572 let mut hm = h.clone();
1573 for i in 0..p {
1574 for j in 0..p {
1575 hp[[i, j]] += eps * a[[i, j]];
1576 hm[[i, j]] -= eps * a[[i, j]];
1577 }
1578 }
1579 let ld = |m: &Array2<f64>| -> f64 {
1580 let l = cholesky_lower(m).expect("SPD");
1581 2.0 * (0..p).map(|i| l[[i, i]].ln()).sum::<f64>()
1582 };
1583 let fd = (ld(&hp) - ld(&hm)) / (2.0 * eps);
1584 let evidence = evidence_traces_adaptive(
1585 h.view(),
1586 vec![DerivativeHessian::Dense(a.view())],
1587 None,
1588 ProbeSeed(0x9999),
1589 HUTCHINSON_ADAPTIVE_REL_TOL,
1590 HUTCHINSON_ADAPTIVE_TAU_REL,
1591 )
1592 .expect("adaptive ok");
1593 let est = evidence.traces[0];
1594 let se = evidence.stderrs[0];
1595 let tol = (8.0 * se).max(0.05 * fd.abs());
1596 assert!(
1597 (est - fd).abs() <= tol,
1598 "adaptive trace {est} disagrees with FD logdet derivative {fd} (tol={tol})"
1599 );
1600 }
1601
1602 #[test]
1603 fn block_2_6_k_4096_matches_exact_tightly() {
1604 let p = 40;
1609 let h = make_spd(p, 0.6);
1610 let a = random_dense_sym(p, 0xDEAD);
1611 let exact = exact_trace_hinv_a(h.view(), a.view());
1612 let input = RemlTraceHutchinsonInput {
1613 penalized_hessian: h.view(),
1614 derivatives: vec![DerivativeHessian::Dense(a.view())],
1615 design: None,
1616 probe_count: 4096,
1617 seed: ProbeSeed(0xC0FFEE),
1618 };
1619 let evidence = evidence_derivatives_hutchinson_gpu(input).expect("ok");
1620 let est = 2.0 * evidence.gradient_rho_logdet[0];
1621 let se = 2.0 * evidence.gradient_rho_stderr[0];
1622 let tol = (6.0 * se).max(1e-3 * exact.abs());
1623 assert!(
1624 (est - exact).abs() <= tol,
1625 "K=4096 Hutchinson {est} not within 6·SE of exact {exact} (tol={tol}, se={se})"
1626 );
1627 }
1628
1629 #[test]
1630 fn block_2_6_crn_prefix_match_across_schedule() {
1631 let p = 50;
1637 let seed = ProbeSeed(0x4242_4242);
1638 let mut z16 = vec![0.0_f64; p * 16];
1639 let mut z32 = vec![0.0_f64; p * 32];
1640 let mut z64 = vec![0.0_f64; p * 64];
1641 fill_rademacher_host(seed, p, 16, &mut z16);
1642 fill_rademacher_host(seed, p, 32, &mut z32);
1643 fill_rademacher_host(seed, p, 64, &mut z64);
1644 for col in 0..16 {
1645 for row in 0..p {
1646 assert_eq!(z16[col * p + row], z32[col * p + row]);
1647 assert_eq!(z16[col * p + row], z64[col * p + row]);
1648 }
1649 }
1650 for col in 0..32 {
1651 for row in 0..p {
1652 assert_eq!(z32[col * p + row], z64[col * p + row]);
1653 }
1654 }
1655 }
1656
1657 #[test]
1670 fn block_2_8_hill_climb_adaptive_vs_exact_at_p2000_d8() {
1671 let on_v100 = cfg!(target_os = "linux")
1674 && gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::GpuPolicy::Auto)
1675 .unwrap_or_else(|error| panic!("GPU probe fault in hill-climb gate: {error}"))
1676 .is_some();
1677 let (p, d): (usize, usize) = if on_v100 { (2000, 8) } else { (256, 4) };
1678
1679 let mut h = Array2::<f64>::zeros((p, p));
1680 for i in 0..p {
1681 for j in 0..p {
1682 h[[i, j]] = if i == j {
1683 p as f64 + 1.0
1684 } else {
1685 1.0 / (1.0 + (i as f64 - j as f64).abs())
1686 };
1687 }
1688 }
1689 let derivs_owned: Vec<Array2<f64>> = (0..d)
1690 .map(|k| random_dense_sym(p, 0x1000 + k as u64))
1691 .collect();
1692 let derivs: Vec<DerivativeHessian<'_>> = derivs_owned
1693 .iter()
1694 .map(|a| DerivativeHessian::Dense(a.view()))
1695 .collect();
1696
1697 let t_exact_start = std::time::Instant::now();
1701 let factor = cholesky_lower(&h).expect("SPD");
1702 let mut exact_traces = vec![0.0_f64; d];
1703 for (j, a) in derivs_owned.iter().enumerate() {
1704 let mut acc = 0.0_f64;
1705 for col in 0..p {
1706 let mut rhs = vec![0.0_f64; p];
1707 for r in 0..p {
1708 rhs[r] = a[[r, col]];
1709 }
1710 let w = solve_cholesky(&factor, &rhs);
1711 acc += w[col];
1712 }
1713 exact_traces[j] = acc;
1714 }
1715 let t_exact = t_exact_start.elapsed();
1716
1717 let t_adaptive_start = std::time::Instant::now();
1719 let evidence = evidence_traces_adaptive(
1720 h.view(),
1721 derivs,
1722 None,
1723 ProbeSeed(0xB10C),
1724 HUTCHINSON_ADAPTIVE_REL_TOL,
1725 HUTCHINSON_ADAPTIVE_TAU_REL,
1726 )
1727 .expect("adaptive ok");
1728 let t_adaptive = t_adaptive_start.elapsed();
1729
1730 for j in 0..d {
1733 let se = evidence.stderrs[j];
1734 let tol = (10.0 * se).max(0.05 * exact_traces[j].abs());
1735 let diff = (evidence.traces[j] - exact_traces[j]).abs();
1736 assert!(
1737 diff <= tol,
1738 "block_2_8: derivative {j} adaptive {} disagrees with exact {} (tol {tol}, diff {diff})",
1739 evidence.traces[j],
1740 exact_traces[j]
1741 );
1742 }
1743
1744 let speedup = t_exact.as_secs_f64() / t_adaptive.as_secs_f64().max(1e-9);
1745 eprintln!(
1746 "block_2_8 hill-climb [p={p}, d={d}, V100={on_v100}]: \
1747 exact={:?}, adaptive={:?}, speedup={:.2}× (K={}, converged={})",
1748 t_exact, t_adaptive, speedup, evidence.probe_count, evidence.converged
1749 );
1750 if on_v100 {
1751 assert!(
1767 evidence.stderrs.iter().all(|s| s.is_finite() && *s >= 0.0),
1768 "adaptive trace evidence must carry finite standard errors"
1769 );
1770 assert!(
1771 (evidence.probe_count as usize) * 8 < p,
1772 "adaptive trace probe budget {} is not sublinear in p={p}: \
1773 the exact path would be cheaper — adaptivity regressed",
1774 evidence.probe_count
1775 );
1776 }
1777 }
1778}