1use std::sync::OnceLock;
20
21use ndarray::{Array2, ArrayView2};
22
23use gam_gpu::gpu_error::GpuError;
24#[cfg(target_os = "linux")]
25use gam_gpu::gpu_error::GpuResultExt;
26use gam_gpu::{GpuDecision, GpuKernel, decide};
27
28#[cfg(target_os = "linux")]
29use std::collections::HashMap;
30#[cfg(target_os = "linux")]
31use std::sync::{Arc, Mutex};
32
33#[cfg(target_os = "linux")]
34use cudarc::driver::{CudaContext, CudaModule, CudaSlice, CudaStream};
35
36#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
40pub enum SphereSpectralKernelKind {
41 Sobolev,
43 Pseudo,
45}
46
47impl SphereSpectralKernelKind {
48 pub fn coefficients(self, lmax: usize, m: usize) -> Vec<f64> {
52 match self {
53 SphereSpectralKernelKind::Sobolev => {
54 crate::basis::sobolev_s2_truncated_coefficients(lmax, m)
55 }
56 SphereSpectralKernelKind::Pseudo => {
57 crate::basis::pseudo_s2_truncated_coefficients(lmax, m)
58 }
59 }
60 }
61
62 pub const fn tag(self) -> &'static str {
64 match self {
65 SphereSpectralKernelKind::Sobolev => "sobolev",
66 SphereSpectralKernelKind::Pseudo => "pseudo",
67 }
68 }
69}
70
71#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
75pub enum DeviceMatrixLayout {
76 ColumnMajor,
77}
78
79pub fn latlon_to_xyz_host(latlon: ArrayView2<'_, f64>, radians: bool) -> Result<Vec<f64>, String> {
86 if latlon.ncols() != 2 {
87 return Err(format!(
88 "latlon_to_xyz_host: expected (_, 2) lat/lon matrix, got shape {:?}",
89 latlon.shape()
90 ));
91 }
92 let deg = if radians {
93 1.0
94 } else {
95 std::f64::consts::PI / 180.0
96 };
97 let n = latlon.nrows();
98 let mut out = Vec::with_capacity(3 * n);
99 for row in latlon.outer_iter() {
100 let lat = row[0] * deg;
101 let lon = row[1] * deg;
102 let (s_lat, c_lat) = lat.sin_cos();
103 let (s_lon, c_lon) = lon.sin_cos();
104 out.push(c_lat * c_lon);
106 out.push(c_lat * s_lon);
107 out.push(s_lat);
108 }
109 Ok(out)
110}
111
112#[cfg(target_os = "linux")]
119pub struct DeviceS2KernelMatrix {
120 pub rows: usize,
121 pub cols: usize,
122 pub ld: usize,
123 pub col_major_dev: CudaSlice<f64>,
124 pub stream: Arc<CudaStream>,
125}
126
127#[cfg(not(target_os = "linux"))]
128pub struct DeviceS2KernelMatrix {
129 pub rows: usize,
130 pub cols: usize,
131 pub ld: usize,
132 pub col_major_dev: Vec<f64>,
134}
135
136impl DeviceS2KernelMatrix {
137 #[cfg(target_os = "linux")]
153 pub fn to_host_array(&self) -> Result<Array2<f64>, GpuError> {
154 let needed = self.ld * self.cols;
155 let mut staging = PinnedLease::acquire(self.stream.context(), needed)?;
156 self.stream
157 .memcpy_dtoh(&self.col_major_dev, staging.as_mut_slice())
158 .gpu_ctx("DeviceS2KernelMatrix dtoh (pinned)")?;
159 self.stream
160 .synchronize()
161 .gpu_ctx("DeviceS2KernelMatrix synchronize (pinned)")?;
162 Ok(col_major_to_row_major_parallel(
163 staging.as_slice(),
164 self.rows,
165 self.cols,
166 self.ld,
167 ))
168 }
169
170 #[cfg(not(target_os = "linux"))]
171 pub fn to_host_array(&self) -> Result<Array2<f64>, GpuError> {
172 let mut col_major = vec![0.0_f64; self.ld * self.cols];
176 self.copy_to_host_col_major(&mut col_major)?;
177 Ok(col_major_to_row_major_parallel(
178 &col_major, self.rows, self.cols, self.ld,
179 ))
180 }
181
182 #[cfg(target_os = "linux")]
193 pub fn copy_to_host_col_major(&self, dst: &mut [f64]) -> Result<(), GpuError> {
194 let needed = self.ld * self.cols;
195 if dst.len() != needed {
196 gam_gpu::gpu_bail!(
197 "DeviceS2KernelMatrix::copy_to_host_col_major: dst.len()={} expected {}",
198 dst.len(),
199 needed
200 );
201 }
202 self.stream
203 .memcpy_dtoh(&self.col_major_dev, dst)
204 .gpu_ctx("DeviceS2KernelMatrix dtoh")?;
205 self.stream
206 .synchronize()
207 .gpu_ctx("DeviceS2KernelMatrix synchronize")?;
208 Ok(())
209 }
210
211 #[cfg(not(target_os = "linux"))]
212 pub fn copy_to_host_col_major(&self, dst: &mut [f64]) -> Result<(), GpuError> {
213 let needed = self.ld * self.cols;
214 if dst.len() != needed {
215 gam_gpu::gpu_bail!(
216 "DeviceS2KernelMatrix::copy_to_host_col_major: dst.len()={} expected {}",
217 dst.len(),
218 needed
219 );
220 }
221 dst.copy_from_slice(&self.col_major_dev);
222 Ok(())
223 }
224
225}
226
227fn col_major_to_row_major_parallel(
243 col_major: &[f64],
244 rows: usize,
245 cols: usize,
246 ld: usize,
247) -> Array2<f64> {
248 use rayon::prelude::*;
249
250 assert!(ld >= rows, "ld {ld} must be >= rows {rows}");
251 assert!(
252 col_major.len() >= ld * cols,
253 "col_major len {} < ld*cols {}",
254 col_major.len(),
255 ld * cols
256 );
257
258 const BLOCK_ROWS: usize = 128;
261
262 let mut out_flat = vec![0.0_f64; rows * cols];
263 out_flat
264 .par_chunks_mut(BLOCK_ROWS * cols)
265 .enumerate()
266 .for_each(|(block_idx, out_block)| {
267 let r0 = block_idx * BLOCK_ROWS;
268 let block_rows = out_block.len() / cols;
269 for j in 0..cols {
270 let base = j * ld + r0;
271 let src_col = &col_major[base..base + block_rows];
272 for (local_i, &v) in src_col.iter().enumerate() {
274 out_block[local_i * cols + j] = v;
275 }
276 }
277 });
278
279 Array2::from_shape_vec((rows, cols), out_flat).expect("row-major buffer has rows*cols elements")
280}
281
282#[cfg(target_os = "linux")]
293struct PinnedF64 {
294 ptr: *mut f64,
295 len: usize,
296 freed: bool,
297}
298
299#[cfg(target_os = "linux")]
300impl PinnedF64 {
301 fn alloc(ctx: &Arc<CudaContext>, len: usize) -> Result<Self, GpuError> {
304 ctx.bind_to_thread().gpu_ctx("PinnedF64 bind_to_thread")?;
305 let bytes = len
306 .checked_mul(std::mem::size_of::<f64>())
307 .ok_or_else(|| gam_gpu::gpu_err!("PinnedF64: len={len} byte size overflows usize"))?;
308 let raw = unsafe { cudarc::driver::result::malloc_host(bytes, 0) }
313 .gpu_ctx("PinnedF64 cuMemHostAlloc")?;
314 let ptr = raw as *mut f64;
315 if ptr.is_null() {
316 gam_gpu::gpu_bail!("PinnedF64: cuMemHostAlloc returned null for {bytes} bytes");
317 }
318 Ok(Self {
319 ptr,
320 len,
321 freed: false,
322 })
323 }
324
325 fn as_mut_slice(&mut self) -> &mut [f64] {
326 unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) }
329 }
330
331 fn as_slice(&self) -> &[f64] {
332 unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
334 }
335}
336
337#[cfg(target_os = "linux")]
338impl Drop for PinnedF64 {
339 fn drop(&mut self) {
340 if self.freed {
341 return;
342 }
343 self.freed = true;
344 if let Err(err) =
349 unsafe { cudarc::driver::result::free_host(self.ptr as *mut std::ffi::c_void) }
350 {
351 log::debug!(
352 "PinnedF64::drop: cuMemFreeHost failed ({err}); the pinned host allocation \
353 is leaked for the remaining process lifetime"
354 );
355 }
356 }
357}
358
359#[cfg(target_os = "linux")]
365unsafe impl Send for PinnedF64 {}
366
367#[cfg(target_os = "linux")]
376const PINNED_POOL_MAX_BUFFERS: usize = 4;
377
378#[cfg(target_os = "linux")]
379static PINNED_POOL: OnceLock<Mutex<Vec<PinnedF64>>> = OnceLock::new();
380
381#[cfg(target_os = "linux")]
385struct PinnedLease {
386 buf: Option<PinnedF64>,
387}
388
389#[cfg(target_os = "linux")]
390impl PinnedLease {
391 fn acquire(ctx: &Arc<CudaContext>, len: usize) -> Result<Self, GpuError> {
394 let pool = PINNED_POOL.get_or_init(|| Mutex::new(Vec::new()));
395 if let Ok(mut guard) = pool.lock() {
396 if let Some(pos) = guard.iter().position(|b| b.len == len) {
397 return Ok(Self {
398 buf: Some(guard.swap_remove(pos)),
399 });
400 }
401 }
402 Ok(Self {
403 buf: Some(PinnedF64::alloc(ctx, len)?),
404 })
405 }
406
407 fn as_mut_slice(&mut self) -> &mut [f64] {
408 self.buf
409 .as_mut()
410 .expect("PinnedLease buffer present until drop")
411 .as_mut_slice()
412 }
413
414 fn as_slice(&self) -> &[f64] {
415 self.buf
416 .as_ref()
417 .expect("PinnedLease buffer present until drop")
418 .as_slice()
419 }
420}
421
422#[cfg(target_os = "linux")]
423impl Drop for PinnedLease {
424 fn drop(&mut self) {
425 let Some(buf) = self.buf.take() else {
426 return;
427 };
428 if let Some(pool) = PINNED_POOL.get() {
429 if let Ok(mut guard) = pool.lock() {
430 if guard.len() < PINNED_POOL_MAX_BUFFERS {
431 guard.push(buf);
432 return;
433 }
434 guard.remove(0);
438 guard.push(buf);
439 return;
440 }
441 }
442 drop(buf);
444 }
445}
446
447#[derive(Clone, Debug)]
458pub struct S2KernelBuildInputs<'a> {
459 pub n: usize,
460 pub m: usize,
461 pub lmax: usize,
462 pub data_xyz: &'a [f64],
463 pub centers_xyz: &'a [f64],
464 pub coeffs: &'a [f64],
465 pub kind: SphereSpectralKernelKind,
466 pub layout: DeviceMatrixLayout,
467}
468
469impl<'a> S2KernelBuildInputs<'a> {
470 fn validate(&self) -> Result<(), GpuError> {
471 if self.lmax == 0 {
472 return Err(GpuError::DriverCallFailed {
473 reason: "S2KernelBuildInputs: lmax must be >= 1".into(),
474 });
475 }
476 if self.data_xyz.len() != 3 * self.n {
477 gam_gpu::gpu_bail!(
478 "S2KernelBuildInputs: data_xyz.len()={} != 3*n={}",
479 self.data_xyz.len(),
480 3 * self.n
481 );
482 }
483 if self.centers_xyz.len() != 3 * self.m {
484 gam_gpu::gpu_bail!(
485 "S2KernelBuildInputs: centers_xyz.len()={} != 3*m={}",
486 self.centers_xyz.len(),
487 3 * self.m
488 );
489 }
490 if self.coeffs.len() != self.lmax + 1 {
491 gam_gpu::gpu_bail!(
492 "S2KernelBuildInputs: coeffs.len()={} != lmax+1={}",
493 self.coeffs.len(),
494 self.lmax + 1
495 );
496 }
497 if self.coeffs[0] != 0.0 {
498 return Err(GpuError::DriverCallFailed {
499 reason: "S2KernelBuildInputs: coeffs[0] must be 0 (mean-zero kernel)".into(),
500 });
501 }
502 Ok(())
503 }
504}
505
506#[cfg(target_os = "linux")]
516const KERNEL_TEMPLATE: &str = r#"
517// LMAX is supplied by the host via a `#define LMAX ...` prepended to
518// this source before NVRTC compilation (see `SphereGpuBackend::module_for`).
519// Recover cos(gamma) from the two half-angle chord lengths instead of
520// x dot c. The dot product rounds 1 - O(gamma^2) to 1 near coincidence,
521// permanently destroying the separation before the spectral evaluator sees it.
522// Here u = |x-c|^2 / (|x-c|^2 + |x+c|^2) and
523// v = |x+c|^2 / (|x-c|^2 + |x+c|^2), so both singular ends are carried
524// without cancellation and exact coincidence gives u=0, v=1 by construction.
525__device__ __forceinline__
526double s2_chord_cos_gamma(
527 double xi,
528 double yi,
529 double zi,
530 double cxj,
531 double cyj,
532 double czj
533) {
534 const double dx = xi - cxj;
535 const double dy = yi - cyj;
536 const double dz = zi - czj;
537 const double sx = xi + cxj;
538 const double sy = yi + cyj;
539 const double sz = zi + czj;
540 const double chord_sq = fma(dx, dx, fma(dy, dy, dz * dz));
541 const double anti_chord_sq = fma(sx, sx, fma(sy, sy, sz * sz));
542 const double scale = chord_sq + anti_chord_sq;
543
544 double u = chord_sq / scale;
545 double v = anti_chord_sq / scale;
546 if (u > 1.0) u = 1.0;
547 if (u < 0.0) u = 0.0;
548 if (v > 1.0) v = 1.0;
549 if (v < 0.0) v = 0.0;
550
551 double cos_gamma = v - u;
552 if (cos_gamma > 1.0) cos_gamma = 1.0;
553 if (cos_gamma < -1.0) cos_gamma = -1.0;
554 return cos_gamma;
555}
556
557extern "C" __global__
558__launch_bounds__(256)
559void s2_wahba_legendre_colmajor(
560 const double* __restrict__ data_xyz, // n × 3 (row-major flat)
561 const double* __restrict__ centers_xyz, // m × 3 (row-major flat)
562 const double* __restrict__ coeffs, // length LMAX + 1, coeffs[0] = 0
563 int n,
564 int m,
565 long long ld,
566 double* __restrict__ out // ld × m column-major
567) {
568 const int i = blockIdx.y * blockDim.y + threadIdx.y;
569 const int j = blockIdx.x * blockDim.x + threadIdx.x;
570 if (i >= n || j >= m) return;
571
572 // Load (x_i, y_i, z_i) and (cx_j, cy_j, cz_j) into registers.
573 const double xi = data_xyz[3 * i + 0];
574 const double yi = data_xyz[3 * i + 1];
575 const double zi = data_xyz[3 * i + 2];
576 const double cxj = centers_xyz[3 * j + 0];
577 const double cyj = centers_xyz[3 * j + 1];
578 const double czj = centers_xyz[3 * j + 2];
579
580 // Stable half-angle chord geometry; no near-coincident dot-product loss.
581 const double t = s2_chord_cos_gamma(xi, yi, zi, cxj, cyj, czj);
582
583 // Legendre 3-term recurrence in registers.
584 // P_0(t) = 1, P_1(t) = t.
585 double p_prev = 1.0;
586 double p_curr = t;
587 double acc = coeffs[0] * p_prev + coeffs[1] * p_curr;
588
589 #pragma unroll 8
590 for (int ell = 1; ell < LMAX; ++ell) {
591 const double lf = (double) ell;
592 const double inv = 1.0 / (lf + 1.0);
593 // p_{ell+1} = ((2ell+1) * t * p_curr - ell * p_prev) / (ell+1)
594 const double p_next =
595 fma((2.0 * lf + 1.0) * t, p_curr, -lf * p_prev) * inv;
596 acc = fma(coeffs[ell + 1], p_next, acc);
597 p_prev = p_curr;
598 p_curr = p_next;
599 }
600
601 out[(long long) j * ld + (long long) i] = acc;
602}
603
604// Fused Householder-constrained kernel (Phase 3). Z = I - beta · v · v^T,
605// the constrained design is X_s = B[:, 1..m] - beta * (B · v) · v[1..m]^T,
606// i.e. drop the first column after applying Z. Each thread computes one
607// row of B in registers (m kernel evaluations), forms d_i = B_row · v,
608// then emits X_s[i, j_out] = B_row[j_out + 1] - beta * d_i * v[j_out + 1]
609// for j_out in 0..m-1.
610//
611// Grid: 1D over rows (block_dim.x rows per block). Each thread iterates
612// over centers in an inner loop — register-bound by the per-row state
613// (xyz_i, p_prev, p_curr, acc, and a small per-center scratch).
614extern "C" __global__
615__launch_bounds__(128)
616void s2_wahba_householder_constrained_colmajor(
617 const double* __restrict__ data_xyz, // n × 3
618 const double* __restrict__ centers_xyz, // m × 3
619 const double* __restrict__ coeffs, // length LMAX + 1
620 const double* __restrict__ v, // length m, Householder vector
621 double beta,
622 int n,
623 int m,
624 long long ld_out,
625 double* __restrict__ out // ld_out × (m-1) column-major
626) {
627 const int i = blockIdx.x * blockDim.x + threadIdx.x;
628 if (i >= n) return;
629
630 const double xi = data_xyz[3 * i + 0];
631 const double yi = data_xyz[3 * i + 1];
632 const double zi = data_xyz[3 * i + 2];
633
634 // Pass 1: compute d_i = sum_j v[j] * B[i, j].
635 double d_i = 0.0;
636 for (int j = 0; j < m; ++j) {
637 const double cxj = centers_xyz[3 * j + 0];
638 const double cyj = centers_xyz[3 * j + 1];
639 const double czj = centers_xyz[3 * j + 2];
640 const double t = s2_chord_cos_gamma(xi, yi, zi, cxj, cyj, czj);
641
642 double p_prev = 1.0;
643 double p_curr = t;
644 double acc = coeffs[0] * p_prev + coeffs[1] * p_curr;
645 #pragma unroll 8
646 for (int ell = 1; ell < LMAX; ++ell) {
647 const double lf = (double) ell;
648 const double inv = 1.0 / (lf + 1.0);
649 const double p_next =
650 fma((2.0 * lf + 1.0) * t, p_curr, -lf * p_prev) * inv;
651 acc = fma(coeffs[ell + 1], p_next, acc);
652 p_prev = p_curr;
653 p_curr = p_next;
654 }
655 d_i = fma(v[j], acc, d_i);
656 }
657
658 // Pass 2: emit X_s[i, j_out] = B[i, j_out+1] - beta * d_i * v[j_out+1].
659 const double bd = beta * d_i;
660 for (int j_out = 0; j_out < m - 1; ++j_out) {
661 const int j = j_out + 1;
662 const double cxj = centers_xyz[3 * j + 0];
663 const double cyj = centers_xyz[3 * j + 1];
664 const double czj = centers_xyz[3 * j + 2];
665 const double t = s2_chord_cos_gamma(xi, yi, zi, cxj, cyj, czj);
666
667 double p_prev = 1.0;
668 double p_curr = t;
669 double acc = coeffs[0] * p_prev + coeffs[1] * p_curr;
670 #pragma unroll 8
671 for (int ell = 1; ell < LMAX; ++ell) {
672 const double lf = (double) ell;
673 const double inv = 1.0 / (lf + 1.0);
674 const double p_next =
675 fma((2.0 * lf + 1.0) * t, p_curr, -lf * p_prev) * inv;
676 acc = fma(coeffs[ell + 1], p_next, acc);
677 p_prev = p_curr;
678 p_curr = p_next;
679 }
680 const double xs = acc - bd * v[j];
681 out[(long long) j_out * ld_out + (long long) i] = xs;
682 }
683}
684"#;
685
686#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
696pub struct S2ModuleCacheKey {
697 pub cc_major: i32,
698 pub cc_minor: i32,
699 pub lmax: u32,
700 pub kind: SphereSpectralKernelKind,
701 pub layout: DeviceMatrixLayout,
702}
703
704pub const fn sphere_gpu_compiled() -> bool {
707 cfg!(target_os = "linux")
708}
709
710#[must_use]
717pub fn sphere_kernel_decision(n: usize, m: usize, lmax: usize) -> Result<GpuDecision, GpuError> {
718 let large_enough = match gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())?
719 {
720 Some(runtime) => {
721 let ld = ((n + 31) / 32) * 32;
722 let needed_bytes = ld
723 .saturating_mul(m)
724 .saturating_mul(std::mem::size_of::<f64>());
725 let budget = runtime.memory_budget_bytes;
726 n.saturating_mul(m) >= 1_000_000 && lmax <= 200 && needed_bytes <= budget
727 }
728 None => false,
729 };
730 decide(
731 GpuKernel::SpatialKernelOperator,
732 gam_gpu::GpuEligibility::from_flags(sphere_gpu_compiled(), large_enough),
733 )
734}
735
736#[must_use]
742pub fn truncated_device_kind(
743 kernel: crate::basis::SphereWahbaKernel,
744) -> Option<(SphereSpectralKernelKind, u16)> {
745 use crate::basis::SphereWahbaKernel;
746 match kernel {
747 SphereWahbaKernel::SobolevTruncated { lmax } => {
748 Some((SphereSpectralKernelKind::Sobolev, lmax))
749 }
750 SphereWahbaKernel::PseudoTruncated { lmax } => {
751 Some((SphereSpectralKernelKind::Pseudo, lmax))
752 }
753 SphereWahbaKernel::Sobolev | SphereWahbaKernel::Pseudo => None,
754 }
755}
756
757pub fn try_build_truncated_kernel_matrix_gpu(
779 data: ArrayView2<'_, f64>,
780 centers: ArrayView2<'_, f64>,
781 penalty_order: usize,
782 radians: bool,
783 kernel: crate::basis::SphereWahbaKernel,
784) -> Option<Result<Array2<f64>, GpuError>> {
785 let (kind, lmax) = truncated_device_kind(kernel)?;
786 let n = data.nrows();
787 let m = centers.nrows();
788 if n == 0 || m == 0 || lmax == 0 {
789 return None;
790 }
791 let decision = match sphere_kernel_decision(n, m, lmax as usize) {
792 Ok(decision) => decision,
793 Err(error) => return Some(Err(error)),
794 };
795 if !decision.use_gpu {
796 return None;
799 }
800 Some(build_truncated_kernel_matrix_gpu_admitted(
802 data,
803 centers,
804 penalty_order,
805 radians,
806 kind,
807 lmax,
808 ))
809}
810
811fn build_truncated_kernel_matrix_gpu_admitted(
815 data: ArrayView2<'_, f64>,
816 centers: ArrayView2<'_, f64>,
817 penalty_order: usize,
818 radians: bool,
819 kind: SphereSpectralKernelKind,
820 lmax: u16,
821) -> Result<Array2<f64>, GpuError> {
822 let n = data.nrows();
823 let m = centers.nrows();
824 let data_xyz = latlon_to_xyz_host(data, radians)
825 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
826 let centers_xyz = latlon_to_xyz_host(centers, radians)
827 .map_err(|reason| GpuError::DriverCallFailed { reason })?;
828 let coeffs = kind.coefficients(lmax as usize, penalty_order);
832 let inputs = S2KernelBuildInputs {
833 n,
834 m,
835 lmax: lmax as usize,
836 data_xyz: &data_xyz,
837 centers_xyz: ¢ers_xyz,
838 coeffs: &coeffs,
839 kind,
840 layout: DeviceMatrixLayout::ColumnMajor,
841 };
842 let device_matrix = build_kernel_matrix_device(inputs)?;
843 let out = device_matrix.to_host_array()?;
844 if !out.sum().is_finite() {
855 return Err(GpuError::DriverCallFailed {
856 reason: "sphere GPU truncated kernel produced a non-finite value".to_string(),
857 });
858 }
859 Ok(out)
860}
861
862#[cfg(target_os = "linux")]
863struct SphereGpuContext {
864 ctx: Arc<CudaContext>,
865 stream: Arc<CudaStream>,
866 modules: Mutex<HashMap<S2ModuleCacheKey, Arc<CudaModule>>>,
867 cc_major: i32,
868 cc_minor: i32,
869}
870
871pub struct SphereGpuBackend {
874 #[cfg(target_os = "linux")]
875 inner: SphereGpuContext,
876}
877
878impl SphereGpuBackend {
879 pub fn probe() -> Result<&'static Self, GpuError> {
881 static BACKEND: OnceLock<Result<SphereGpuBackend, GpuError>> = OnceLock::new();
882 BACKEND
883 .get_or_init(|| {
884 #[cfg(target_os = "linux")]
885 {
886 Self::probe_linux()
887 }
888 #[cfg(not(target_os = "linux"))]
889 {
890 Err(GpuError::DriverLibraryUnavailable {
891 reason: "sphere GPU backend is Linux-only".to_string(),
892 })
893 }
894 })
895 .as_ref()
896 .map_err(GpuError::clone)
897 }
898
899 #[cfg(target_os = "linux")]
900 fn probe_linux() -> Result<Self, GpuError> {
901 let parts = gam_gpu::backend_probe::probe_cuda_backend("sphere")?;
902 Ok(SphereGpuBackend {
903 inner: SphereGpuContext {
904 ctx: parts.ctx,
905 stream: parts.stream,
906 modules: Mutex::new(HashMap::new()),
907 cc_major: parts.capability.compute_major,
908 cc_minor: parts.capability.compute_minor,
909 },
910 })
911 }
912
913 #[cfg(target_os = "linux")]
916 fn module_for(&self, key: S2ModuleCacheKey) -> Result<Arc<CudaModule>, GpuError> {
917 if let Ok(guard) = self.inner.modules.lock() {
918 if let Some(existing) = guard.get(&key) {
919 return Ok(existing.clone());
920 }
921 }
922 let src = format!("#define LMAX {}\n{}", key.lmax, KERNEL_TEMPLATE);
932 let ptx = gam_gpu::device_cache::compile_ptx_arch(&src).gpu_ctx_with(|err| {
933 format!(
934 "sphere NVRTC compile (kind={}, lmax={}): {err}",
935 key.kind.tag(),
936 key.lmax
937 )
938 })?;
939 let module = self
940 .inner
941 .ctx
942 .load_module(ptx)
943 .gpu_ctx("sphere module load")?;
944 if let Ok(mut guard) = self.inner.modules.lock() {
945 guard.entry(key).or_insert_with(|| module.clone());
946 }
947 Ok(module)
948 }
949
950 #[cfg(target_os = "linux")]
951 fn cc(&self) -> (i32, i32) {
952 (self.inner.cc_major, self.inner.cc_minor)
953 }
954}
955
956pub fn build_kernel_matrix_device(
963 inputs: S2KernelBuildInputs<'_>,
964) -> Result<DeviceS2KernelMatrix, GpuError> {
965 inputs.validate()?;
966
967 #[cfg(target_os = "linux")]
968 {
969 use cudarc::driver::{LaunchConfig, PushKernelArg};
970 let backend = SphereGpuBackend::probe()?;
971 let (cc_major, cc_minor) = backend.cc();
972 let key = S2ModuleCacheKey {
973 cc_major,
974 cc_minor,
975 lmax: inputs.lmax as u32,
976 kind: inputs.kind,
977 layout: inputs.layout,
978 };
979 let module = backend.module_for(key)?;
980 let func = module
981 .load_function("s2_wahba_legendre_colmajor")
982 .gpu_ctx("sphere load_function raw")?;
983 let stream = backend.inner.stream.clone();
984
985 let data_dev = stream
986 .clone_htod(inputs.data_xyz)
987 .gpu_ctx("sphere htod data_xyz")?;
988 let centers_dev = stream
989 .clone_htod(inputs.centers_xyz)
990 .gpu_ctx("sphere htod centers_xyz")?;
991 let coeffs_dev = stream
992 .clone_htod(inputs.coeffs)
993 .gpu_ctx("sphere htod coeffs")?;
994
995 let n = inputs.n;
996 let m = inputs.m;
997 let ld = ((n + 31) / 32) * 32;
998 let mut out_dev = stream
999 .alloc_zeros::<f64>(ld * m)
1000 .gpu_ctx_with(|err| format!("sphere alloc out (ld={ld}, m={m}): {err}"))?;
1001
1002 let block_x: u32 = 32;
1004 let block_y: u32 = 8;
1005 let grid_x: u32 = ((m as u32) + block_x - 1) / block_x;
1006 let grid_y: u32 = ((n as u32) + block_y - 1) / block_y;
1007 let cfg = LaunchConfig {
1008 grid_dim: (grid_x, grid_y, 1),
1009 block_dim: (block_x, block_y, 1),
1010 shared_mem_bytes: 0,
1011 };
1012 let n_i32: i32 =
1013 i32::try_from(n).map_err(|_| gam_gpu::gpu_err!("sphere n={n} overflows i32"))?;
1014 let m_i32: i32 =
1015 i32::try_from(m).map_err(|_| gam_gpu::gpu_err!("sphere m={m} overflows i32"))?;
1016 let ld_i64: i64 = ld as i64;
1017
1018 let mut builder = stream.launch_builder(&func);
1019 builder
1020 .arg(&data_dev)
1021 .arg(¢ers_dev)
1022 .arg(&coeffs_dev)
1023 .arg(&n_i32)
1024 .arg(&m_i32)
1025 .arg(&ld_i64)
1026 .arg(&mut out_dev);
1027 unsafe { builder.launch(cfg) }.gpu_ctx("sphere raw kernel launch")?;
1032 stream
1033 .synchronize()
1034 .gpu_ctx("sphere raw kernel synchronize")?;
1035
1036 Ok(DeviceS2KernelMatrix {
1037 rows: n,
1038 cols: m,
1039 ld,
1040 col_major_dev: out_dev,
1041 stream,
1042 })
1043 }
1044
1045 #[cfg(not(target_os = "linux"))]
1046 {
1047 Err(GpuError::DriverLibraryUnavailable {
1048 reason: "sphere GPU backend is Linux-only".to_string(),
1049 })
1050 }
1051}
1052
1053#[derive(Clone, Debug)]
1106pub struct PenalisedLsSolution {
1107 pub beta: Vec<f64>,
1109 pub weighted_residual_ssq: f64,
1111 pub log_det_hessian: f64,
1113}
1114
1115#[cfg(test)]
1120mod sphere_gpu_tests {
1121 use super::*;
1122 use crate::basis::sphere_half_angle::{SphereTrig, half_angle_separation_scalar};
1123 use crate::basis::{
1124 SphereWahbaKernel, sobolev_s2_truncated_coefficients, sphere_truncated_spectral_eval,
1125 spherical_wahba_kernel_matrix_with_kind,
1126 };
1127 use ndarray::Array2;
1128
1129 fn small_latlon_grid(n_lat: usize, n_lon: usize) -> Array2<f64> {
1130 let mut rows = Vec::with_capacity(n_lat * n_lon);
1132 for i in 0..n_lat {
1133 let lat = -85.0 + (170.0 * i as f64) / (n_lat.saturating_sub(1).max(1) as f64);
1134 for j in 0..n_lon {
1135 let lon = -180.0 + (360.0 * j as f64) / (n_lon.saturating_sub(1).max(1) as f64);
1136 rows.push(lat);
1137 rows.push(lon);
1138 }
1139 }
1140 Array2::from_shape_vec((n_lat * n_lon, 2), rows).unwrap()
1141 }
1142
1143 fn cuda_available_for_test(label: &str) -> bool {
1144 match gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::GpuPolicy::Auto) {
1145 Ok(Some(_)) => true,
1146 Ok(None) => {
1147 eprintln!("[sphere_gpu test] no CUDA device — skipping {label}");
1148 false
1149 }
1150 Err(error) => panic!("[sphere_gpu test] CUDA resolution failed for {label}: {error}"),
1151 }
1152 }
1153
1154 fn assert_sphere_decision_declines_without_device(n: usize, m: usize, lmax: usize) {
1162 let decision = sphere_kernel_decision(n, m, lmax)
1163 .expect("the sphere GPU decision must not fault on a device-free host");
1164 assert!(
1165 !decision.use_gpu,
1166 "no CUDA runtime on this host, yet the sphere dispatch decision admitted the \
1167 device for (n={n}, m={m}, lmax={lmax}) — reason={}",
1168 decision.reason
1169 );
1170 }
1171
1172 fn assert_device_kernel_entry_refuses(inputs: S2KernelBuildInputs<'_>) {
1177 assert!(
1178 build_kernel_matrix_device(inputs).is_err(),
1179 "no CUDA runtime on this host, yet the device kernel entry returned a matrix \
1180 — the admitted-only device path fabricated a host answer (#1551 class)"
1181 );
1182 }
1183
1184 fn assert_cpu_kernel_matches_stable_spectral_definition(
1190 kernel_matrix: &Array2<f64>,
1191 data_latlon: &Array2<f64>,
1192 centers_latlon: &Array2<f64>,
1193 coeffs: &[f64],
1194 ) {
1195 let (n, m) = kernel_matrix.dim();
1196 let to_radians = std::f64::consts::PI / 180.0;
1197 let mut max_abs = 0.0_f64;
1198 for i in 0..n {
1199 let point = SphereTrig::from_radians(
1200 data_latlon[(i, 0)] * to_radians,
1201 data_latlon[(i, 1)] * to_radians,
1202 );
1203 for j in 0..m {
1204 let center = SphereTrig::from_radians(
1205 centers_latlon[(j, 0)] * to_radians,
1206 centers_latlon[(j, 1)] * to_radians,
1207 );
1208 let separation = half_angle_separation_scalar(point, center);
1209 let expected = sphere_truncated_spectral_eval(separation.cos_gamma(), coeffs);
1210 max_abs = max_abs.max((kernel_matrix[(i, j)] - expected).abs());
1211 }
1212 }
1213 assert!(
1214 max_abs < 1e-12,
1215 "CPU truncated-spectral kernel matrix departs from the stable half-angle \
1216 elementwise definition: max |delta| = {max_abs:.3e}"
1217 );
1218 }
1219
1220 #[test]
1221 fn sum_finite_guard_accepts_finite_rejects_nonfinite() {
1222 let finite = Array2::<f64>::from_shape_fn((5, 7), |(i, j)| (i as f64 - 2.0) * (j as f64));
1227 assert!(finite.sum().is_finite());
1228
1229 let mut with_nan = finite.clone();
1230 with_nan[[3, 4]] = f64::NAN;
1231 assert!(!with_nan.sum().is_finite());
1232
1233 let mut with_pos_inf = finite.clone();
1234 with_pos_inf[[0, 0]] = f64::INFINITY;
1235 assert!(!with_pos_inf.sum().is_finite());
1236
1237 let mut with_neg_inf = finite.clone();
1238 with_neg_inf[[4, 6]] = f64::NEG_INFINITY;
1239 assert!(!with_neg_inf.sum().is_finite());
1240 }
1241
1242 #[test]
1243 fn xyz_preprocessing_matches_unit_sphere() {
1244 let latlon = ndarray::array![
1245 [0.0, 0.0],
1246 [90.0, 0.0],
1247 [0.0, 90.0],
1248 [-90.0, 17.5],
1249 [45.0, -120.0],
1250 ];
1251 let xyz = latlon_to_xyz_host(latlon.view(), false).expect("xyz");
1252 assert_eq!(xyz.len(), 3 * 5);
1253 for i in 0..5 {
1254 let nrm2 = xyz[3 * i] * xyz[3 * i]
1255 + xyz[3 * i + 1] * xyz[3 * i + 1]
1256 + xyz[3 * i + 2] * xyz[3 * i + 2];
1257 assert!((nrm2 - 1.0).abs() < 1e-15, "row {i} not unit norm: {nrm2}");
1258 }
1259 assert!((xyz[0] - 1.0).abs() < 1e-15);
1261 assert!(xyz[1].abs() < 1e-15);
1262 assert!(xyz[2].abs() < 1e-15);
1263 assert!(xyz[3].abs() < 1e-15);
1265 assert!(xyz[4].abs() < 1e-15);
1266 assert!((xyz[5] - 1.0).abs() < 1e-15);
1267 assert!(xyz[6].abs() < 1e-15);
1269 assert!((xyz[7] - 1.0).abs() < 1e-15);
1270 assert!(xyz[8].abs() < 1e-15);
1271 }
1272
1273 #[test]
1274 fn truncated_spectral_at_same_point_matches_sum_of_coefficients() {
1275 for m_penalty in 1..=4 {
1279 for &lmax in &[5_usize, 20, 50] {
1280 let coeffs = sobolev_s2_truncated_coefficients(lmax, m_penalty);
1281 let expected: f64 = coeffs.iter().sum();
1282 let got = sphere_truncated_spectral_eval(1.0, &coeffs);
1283 assert!(
1284 (got - expected).abs() < 1e-13,
1285 "K(x,x) identity broken at m={m_penalty}, L={lmax}: got {got:.6e}, expected {expected:.6e}"
1286 );
1287 }
1288 }
1289 }
1290
1291 #[test]
1292 fn truncated_spectral_at_antipode_matches_alternating_sum() {
1293 for m_penalty in 1..=4 {
1296 for &lmax in &[5_usize, 20, 50] {
1297 let coeffs = sobolev_s2_truncated_coefficients(lmax, m_penalty);
1298 let expected: f64 = coeffs
1299 .iter()
1300 .enumerate()
1301 .map(|(ell, c)| if ell % 2 == 0 { *c } else { -*c })
1302 .sum();
1303 let got = sphere_truncated_spectral_eval(-1.0, &coeffs);
1304 assert!(
1305 (got - expected).abs() < 1e-13,
1306 "K(x,-x) identity broken at m={m_penalty}, L={lmax}: got {got:.6e}, expected {expected:.6e}"
1307 );
1308 }
1309 }
1310 }
1311
1312 #[test]
1313 fn truncated_spectral_matrix_is_symmetric() {
1314 let centers = ndarray::array![
1318 [10.0_f64, 20.0],
1319 [-30.0, 100.0],
1320 [45.0, -60.0],
1321 [-89.0, 0.0],
1322 [0.0, 180.0],
1323 [60.0, -179.9],
1324 ];
1325 for m_penalty in [1usize, 2, 4] {
1326 for &lmax in &[10_usize, 30] {
1327 let mat = spherical_wahba_kernel_matrix_with_kind(
1328 centers.view(),
1329 centers.view(),
1330 m_penalty,
1331 false,
1332 SphereWahbaKernel::SobolevTruncated { lmax: lmax as u16 },
1333 )
1334 .expect("kernel matrix");
1335 let n = centers.nrows();
1336 let mut max_asym = 0.0_f64;
1337 for i in 0..n {
1338 for j in 0..n {
1339 let d = (mat[(i, j)] - mat[(j, i)]).abs();
1340 if d > max_asym {
1341 max_asym = d;
1342 }
1343 }
1344 }
1345 assert!(
1346 max_asym < 1e-13,
1347 "K not symmetric at m={m_penalty}, L={lmax}: max |K - Kᵀ| = {max_asym:.3e}"
1348 );
1349 }
1350 }
1351 }
1352
1353 #[test]
1354 fn truncated_coefficients_have_zero_constant_mode() {
1355 for m in 1..=4 {
1356 let c = sobolev_s2_truncated_coefficients(50, m);
1357 assert_eq!(c.len(), 51);
1358 assert_eq!(c[0], 0.0);
1359 assert!(c[1] > 0.0);
1360 for ell in 2..=50 {
1362 assert!(
1363 c[ell] < c[ell - 1] + 1e-15,
1364 "Sobolev coefficient not non-increasing at m={m}, ell={ell}: {} vs {}",
1365 c[ell],
1366 c[ell - 1]
1367 );
1368 }
1369 }
1370 }
1371
1372 #[test]
1373 fn truncated_spectral_matches_matrix_helper() {
1374 let m_penalty = 2;
1378 let lmax = 20;
1379 let coeffs = sobolev_s2_truncated_coefficients(lmax, m_penalty);
1380 let data = ndarray::array![[12.5, -34.0]];
1381 let centers = ndarray::array![[40.0, 10.0]];
1382 let mat = spherical_wahba_kernel_matrix_with_kind(
1383 data.view(),
1384 centers.view(),
1385 m_penalty,
1386 false,
1387 SphereWahbaKernel::SobolevTruncated { lmax: lmax as u16 },
1388 )
1389 .expect("kernel matrix");
1390 let to_radians = std::f64::consts::PI / 180.0;
1393 let point = SphereTrig::from_radians(data[(0, 0)] * to_radians, data[(0, 1)] * to_radians);
1394 let center =
1395 SphereTrig::from_radians(centers[(0, 0)] * to_radians, centers[(0, 1)] * to_radians);
1396 let expected = sphere_truncated_spectral_eval(
1397 half_angle_separation_scalar(point, center).cos_gamma(),
1398 &coeffs,
1399 );
1400 assert!(
1401 (mat[(0, 0)] - expected).abs() < 1e-13,
1402 "matrix helper differs from scalar evaluator: {} vs {}",
1403 mat[(0, 0)],
1404 expected
1405 );
1406 }
1407
1408 #[test]
1414 fn sphere_gpu_raw_kernel_parity_vs_cpu_truncated() {
1415 let mut data_ll = small_latlon_grid(7, 9);
1416 let mut centers_ll = small_latlon_grid(5, 7);
1417 centers_ll[(0, 0)] = 12.5;
1420 centers_ll[(0, 1)] = -34.0;
1421 data_ll[(0, 0)] = 12.5 + 1.0e-8;
1422 data_ll[(0, 1)] = -34.0;
1423 let data_xyz = latlon_to_xyz_host(data_ll.view(), false).unwrap();
1424 let centers_xyz = latlon_to_xyz_host(centers_ll.view(), false).unwrap();
1425 let n = data_ll.nrows();
1426 let m = centers_ll.nrows();
1427 let penalty = 2usize;
1428 let lmax = 20usize;
1429 let coeffs = sobolev_s2_truncated_coefficients(lmax, penalty);
1430
1431 let inputs = S2KernelBuildInputs {
1432 n,
1433 m,
1434 lmax,
1435 data_xyz: &data_xyz,
1436 centers_xyz: ¢ers_xyz,
1437 coeffs: &coeffs,
1438 kind: SphereSpectralKernelKind::Sobolev,
1439 layout: DeviceMatrixLayout::ColumnMajor,
1440 };
1441
1442 let cpu = spherical_wahba_kernel_matrix_with_kind(
1443 data_ll.view(),
1444 centers_ll.view(),
1445 penalty,
1446 false,
1447 SphereWahbaKernel::SobolevTruncated { lmax: lmax as u16 },
1448 )
1449 .expect("cpu kernel matrix");
1450
1451 assert_cpu_kernel_matches_stable_spectral_definition(&cpu, &data_ll, ¢ers_ll, &coeffs);
1454
1455 if !cuda_available_for_test("raw-kernel parity") {
1456 assert_sphere_decision_declines_without_device(n, m, lmax);
1457 assert_device_kernel_entry_refuses(inputs);
1458 return;
1459 }
1460 SphereGpuBackend::probe()
1463 .expect("[sphere_gpu test] backend probe must succeed on a CUDA host");
1464 let dev_mat = build_kernel_matrix_device(inputs).expect("device kernel matrix");
1465 let gpu = dev_mat.to_host_array().expect("dtoh kernel matrix");
1466
1467 let mut max_abs = 0.0_f64;
1468 for i in 0..n {
1469 for j in 0..m {
1470 let d = (gpu[(i, j)] - cpu[(i, j)]).abs();
1471 if d > max_abs {
1472 max_abs = d;
1473 }
1474 }
1475 }
1476 assert!(
1477 max_abs < 1e-11,
1478 "GPU vs CPU truncated parity max |Δ| = {max_abs:.3e} >= 1e-11"
1479 );
1480 }
1481
1482 #[test]
1515 fn sphere_gpu_end_to_end_fit_dispatches_to_device_else_declines() {
1516 use crate::basis::{CenterStrategy, SphereMethod, SphericalSplineBasisSpec, SphericalSplineIdentifiability, build_spherical_spline_basis};
1517
1518 let n_lat = 500usize;
1519 let n_lon = 400usize;
1520 let m: usize = 200;
1521 let lmax: u16 = 50;
1522
1523 if !cuda_available_for_test("end-to-end fit dispatch") {
1524 assert_sphere_decision_declines_without_device(n_lat * n_lon, m, lmax as usize);
1525 return;
1526 }
1527 SphereGpuBackend::probe()
1530 .expect("[sphere_gpu end-to-end dispatch] backend probe must succeed on a CUDA host");
1531
1532 let admit = sphere_kernel_decision(5_000, m, lmax as usize)
1539 .expect("the sphere GPU decision must not fault on a CUDA host");
1540 assert!(
1541 admit.use_gpu,
1542 "a CUDA device is present and (n=5000, m={m}) is exactly at the sphere device-work \
1543 crossover, yet the dispatch decision kept it on the host — reason={}",
1544 admit.reason
1545 );
1546 let refuse = sphere_kernel_decision(4_999, m, lmax as usize)
1547 .expect("the sphere GPU decision must not fault on a CUDA host");
1548 assert!(
1549 !refuse.use_gpu,
1550 "(n=4999, m={m}) is one row below the sphere device-work crossover, yet the dispatch \
1551 decision admitted the device — reason={}",
1552 refuse.reason
1553 );
1554
1555 let data_ll = small_latlon_grid(n_lat, n_lon);
1556 let spec_gpu = SphericalSplineBasisSpec {
1557 center_strategy: CenterStrategy::FarthestPoint { num_centers: m },
1558 penalty_order: 2,
1559 double_penalty: false,
1560 radians: false,
1561 method: SphereMethod::Wahba,
1562 max_degree: None,
1563 wahba_kernel: SphereWahbaKernel::SobolevTruncated { lmax },
1564 identifiability: SphericalSplineIdentifiability::CenterSumToZero,
1565 };
1566
1567 let t0 = std::time::Instant::now();
1573 let built = build_spherical_spline_basis(data_ll.view(), &spec_gpu)
1574 .expect("the end-to-end sphere build must succeed on a CUDA host");
1575 let build_secs = t0.elapsed().as_secs_f64();
1576
1577 assert_eq!(
1578 built.design.nrows(),
1579 data_ll.nrows(),
1580 "the device-dispatched sphere build returned {} design rows for {} data rows",
1581 built.design.nrows(),
1582 data_ll.nrows()
1583 );
1584 assert!(
1585 built.design.ncols() > 0 && built.design.ncols() <= m,
1586 "the device-dispatched sphere build returned {} design columns for m={m} centers",
1587 built.design.ncols()
1588 );
1589 let beta = ndarray::Array1::<f64>::ones(built.design.ncols());
1593 for row in (0..built.design.nrows()).step_by(data_ll.nrows() / 64 + 1) {
1594 let value = built.design.dot_row(row, &beta);
1595 assert!(
1596 value.is_finite(),
1597 "the device-dispatched sphere design row {row} sums to {value}, not a finite number"
1598 );
1599 }
1600
1601 eprintln!(
1606 "[sphere_gpu end-to-end dispatch] n={} m={m} L={lmax} build={build_secs:.3}s reason={}",
1607 data_ll.nrows(),
1608 admit.reason
1609 );
1610 }
1611
1612 #[test]
1634 fn sphere_gpu_end_to_end_fit_parity_vs_cpu_truncated() {
1635 use crate::basis::{
1636 select_spherical_farthest_point_centers, spherical_wahba_kernel_matrix_with_kind,
1637 };
1638 use faer::Side;
1639 use gam_linalg::faer_ndarray::FaerCholesky;
1640
1641 let data_ll = small_latlon_grid(25, 40);
1643 assert_eq!(data_ll.nrows(), 1000);
1644 let n = data_ll.nrows();
1645 let m: usize = 80;
1646 let lmax_u16: u16 = 15;
1647 let lmax: usize = lmax_u16 as usize;
1648 let penalty_order: usize = 2;
1649 let kernel = SphereWahbaKernel::SobolevTruncated { lmax: lmax_u16 };
1650 let lambda: f64 = 1.0e-3;
1651
1652 let centers_ll = select_spherical_farthest_point_centers(data_ll.view(), m, false)
1654 .expect("farthest-point centers");
1655 assert_eq!(centers_ll.nrows(), m);
1656
1657 let z = Array2::<f64>::eye(centers_ll.nrows());
1660 let p = z.ncols();
1661 assert_eq!(p, m);
1662
1663 let k_cc = spherical_wahba_kernel_matrix_with_kind(
1668 centers_ll.view(),
1669 centers_ll.view(),
1670 penalty_order,
1671 false,
1672 kernel,
1673 )
1674 .expect("centers×centers kernel");
1675 let s_full = z.t().dot(&k_cc).dot(&z);
1676
1677 let raw_design_cpu = spherical_wahba_kernel_matrix_with_kind(
1679 data_ll.view(),
1680 centers_ll.view(),
1681 penalty_order,
1682 false,
1683 kernel,
1684 )
1685 .expect("CPU raw design");
1686 let x_s_cpu = raw_design_cpu.dot(&z);
1687
1688 let data_xyz = latlon_to_xyz_host(data_ll.view(), false).expect("data xyz");
1690 let centers_xyz = latlon_to_xyz_host(centers_ll.view(), false).expect("centers xyz");
1691 let coeffs = crate::basis::sobolev_s2_truncated_coefficients(lmax, penalty_order);
1692 let inputs = S2KernelBuildInputs {
1693 n,
1694 m,
1695 lmax,
1696 data_xyz: &data_xyz,
1697 centers_xyz: ¢ers_xyz,
1698 coeffs: &coeffs,
1699 kind: SphereSpectralKernelKind::Sobolev,
1700 layout: DeviceMatrixLayout::ColumnMajor,
1701 };
1702 let mut y = ndarray::Array1::<f64>::zeros(n);
1708 for i in 0..n {
1709 let lat_rad = data_ll[(i, 0)].to_radians();
1710 let lon_rad = data_ll[(i, 1)].to_radians();
1711 y[i] = (2.0 * lat_rad).sin() * (3.0 * lon_rad).cos()
1713 + 0.25 * lat_rad.cos() * (5.0 * lon_rad).sin();
1714 }
1715
1716 let solve_penalised = |x_s: &ndarray::Array2<f64>| -> ndarray::Array1<f64> {
1721 let xtx = x_s.t().dot(x_s);
1722 let mut a = xtx;
1723 for i in 0..p {
1724 for j in 0..p {
1725 a[(i, j)] += lambda * s_full[(i, j)];
1726 }
1727 }
1728 let rhs = x_s.t().dot(&y);
1729 let factor = a
1730 .cholesky(Side::Lower)
1731 .expect("penalised normal equations are SPD under λ > 0");
1732 factor.solvevec(&rhs)
1733 };
1734
1735 let beta_cpu = solve_penalised(&x_s_cpu);
1736 assert_eq!(beta_cpu.len(), p);
1737 let yhat_cpu = x_s_cpu.dot(&beta_cpu);
1738 assert_eq!(x_s_cpu.dim(), (n, p));
1739
1740 assert_cpu_kernel_matches_stable_spectral_definition(
1745 &raw_design_cpu,
1746 &data_ll,
1747 ¢ers_ll,
1748 &coeffs,
1749 );
1750 {
1751 let mut a = x_s_cpu.t().dot(&x_s_cpu);
1752 for i in 0..p {
1753 for j in 0..p {
1754 a[(i, j)] += lambda * s_full[(i, j)];
1755 }
1756 }
1757 let residual = a.dot(&beta_cpu) - x_s_cpu.t().dot(&y);
1758 let rhs_scale = x_s_cpu
1759 .t()
1760 .dot(&y)
1761 .iter()
1762 .fold(0.0_f64, |acc, v| acc.max(v.abs()))
1763 .max(1.0);
1764 let max_residual = residual.iter().fold(0.0_f64, |acc, v| acc.max(v.abs()));
1765 assert!(
1766 max_residual <= 1e-9 * rhs_scale,
1767 "CPU penalised normal equations not solved: ‖(XᵀX + λS)β − Xᵀy‖∞ = \
1768 {max_residual:.3e} (rhs scale {rhs_scale:.3e})"
1769 );
1770 }
1771
1772 if !cuda_available_for_test("end-to-end fit parity") {
1773 assert_sphere_decision_declines_without_device(n, m, lmax);
1774 assert_device_kernel_entry_refuses(inputs);
1775 return;
1776 }
1777 SphereGpuBackend::probe()
1780 .expect("[sphere gpu parity] sphere GPU backend probe must succeed on a CUDA host");
1781 let raw_dev = build_kernel_matrix_device(inputs).expect("GPU raw design");
1782 let raw_design_gpu = raw_dev.to_host_array().expect("dtoh GPU raw design");
1783 let x_s_gpu = raw_design_gpu.dot(&z);
1784
1785 assert_eq!(x_s_gpu.dim(), (n, p));
1786
1787 let mut raw_xs_delta = 0.0_f64;
1797 let mut xs_scale = 0.0_f64;
1798 for (a, b) in x_s_cpu.iter().zip(x_s_gpu.iter()) {
1799 raw_xs_delta = raw_xs_delta.max((a - b).abs());
1800 xs_scale = xs_scale.max(a.abs());
1801 }
1802 let cond = {
1805 use gam_linalg::faer_ndarray::FaerEigh;
1806 let xtx = x_s_cpu.t().dot(&x_s_cpu);
1807 let mut a = xtx;
1808 for i in 0..p {
1809 for j in 0..p {
1810 a[(i, j)] += lambda * s_full[(i, j)];
1811 }
1812 }
1813 let (mut lo, mut hi) = (f64::INFINITY, 0.0_f64);
1814 if let Ok((vals, _)) = a.eigh(faer::Side::Lower) {
1815 for &v in vals.iter() {
1816 lo = lo.min(v);
1817 hi = hi.max(v);
1818 }
1819 }
1820 if lo > 0.0 { hi / lo } else { f64::INFINITY }
1823 };
1824 assert!(
1829 raw_xs_delta <= 1e-12 * xs_scale.max(1.0),
1830 "GPU vs CPU sphere design matrix max |Δ| = {raw_xs_delta:.3e} > {:.3e} \
1831 (scale {xs_scale:.3e}) — the kernel itself drifted (this is the genuine \
1832 GPU output, NOT a conditioning artifact)",
1833 1e-12 * xs_scale.max(1.0)
1834 );
1835
1836 let beta_gpu = solve_penalised(&x_s_gpu);
1837 assert_eq!(beta_gpu.len(), p);
1838
1839 let yhat_gpu = x_s_gpu.dot(&beta_gpu);
1843
1844 let mut max_beta_delta = 0.0_f64;
1845 for k in 0..p {
1846 let d = (beta_cpu[k] - beta_gpu[k]).abs();
1847 if d > max_beta_delta {
1848 max_beta_delta = d;
1849 }
1850 }
1851 let mut max_fit_delta = 0.0_f64;
1852 for i in 0..n {
1853 let d = (yhat_cpu[i] - yhat_gpu[i]).abs();
1854 if d > max_fit_delta {
1855 max_fit_delta = d;
1856 }
1857 }
1858
1859 eprintln!(
1860 "[sphere_gpu fit parity] n={n} m={m} p={p} lmax={lmax} λ={lambda:.1e} \
1861 raw_xs|Δ|={raw_xs_delta:.3e} cond={cond:.3e} \
1862 max|Δβ|={max_beta_delta:.3e} max|Δŷ|={max_fit_delta:.3e}"
1863 );
1864
1865 assert!(
1872 max_fit_delta <= 1.0e-9,
1873 "GPU vs CPU truncated-spectral fitted-value max |Δ| = {max_fit_delta:.3e} > 1e-9"
1874 );
1875
1876 let beta_tol = (1e-15 * cond * (1.0 + xs_scale)).max(1e-9) * 16.0;
1888 assert!(
1889 max_beta_delta <= beta_tol,
1890 "GPU vs CPU truncated-spectral coefficient max |Δ| = {max_beta_delta:.3e} > \
1891 condition-aware tol {beta_tol:.3e} (cond={cond:.3e}). Raw design parity is \
1892 {raw_xs_delta:.3e}; a drift THIS much larger than cond·ULP is a real solve/kernel \
1893 mismatch, not conditioning."
1894 );
1895 }
1896}