Fused moment-screen executor — design / scoping (2026-06-08, Codex-reviewed)

Phase 5a of the AOM/moment accuracy-then-throughput work. Scoping doc for review BEFORE any kernel code. Revised after an adversarial Codex review (raw review: fused_moment_executor_design.codex_review_2026-06-08.md) that corrected several overclaims in the first draft — see “Codex review incorporated” at the bottom.

TL;DR — direction confirmed, diagnosis must be measured first

The handoff’s deferred “big task” was a fused many-chain/fold/candidate IKPLS scoring executor. That targets the wrong layer: the PLS moment scorer is already on CUDA and is not where large-p wall-clock goes. Codex concurs with redirecting to the upstream moment / data-movement layer, but flags that the first draft overclaimed which sublayer dominates and mislabeled the cost as “host CPU GEMM.”

Corrected thesis: on large-p datasets the staged campaign spends its time on the host side of the per-candidate moment pipeline — but the GEMMs themselves already route to cuBLAS, so the cost is most plausibly per-candidate data movement (H2D/D2H), device allocations, and launch/sync overhead, plus genuinely host-side Ridge eigensolvesnot CPU matmul. This must be confirmed by a phase-timing profile (5b-0) before any kernel is written. 5b-0 is a hard gate, not a warm-up.

5b-0 MEASURED (2026-06-08, nsys + before/after)

Profiled one BERRY brix screen (n=1434, p=2101, compact, 71.3 s) with nsys --sample=cpu + a correct-but-neutral first change, and the measurement overturned the draft hypothesis:

  • #1 host hotspot: n4m_householder_qr = 37.1% of CPU samples (libn4m.so) — the Ridge GCV solve (ridge.cpp / sweep.cpp solve_square_qr, single-threaded O(p³/n³) QR). Note: this runs even though n_ridge_moment_* counters are 0 — Ridge candidates are scored through a QR path the moment counters don’t track.

  • #2: synchronous cudaMemcpy = 4.59 s (98% of CUDA API time, 888 blocking copies, one 174 ms) — host-blocking H2D/D2H, ~6% of wall-clock, ~20% of samples land in the libcuda copy machinery.

  • CPU OpenBLAS blas_thread_server ~6%; cuda_dispatch::gemm only 2.6%; cudaMalloc+cudaFree = 14 ms total (negligible).

  • First change (neutral, kept): gave cuda_dispatch::gemm a thread-local reusable device scratch arena (kills per-call cudaMalloc/Free). Result: RMSEP bit-identical on BERRY/COLZA/LUCAS, speedup 1.00× — because alloc/GEMM was never the cost. The change is correct and foundational for residency, just not the bottleneck.

Revised priority (data-driven): the moment-transform GEMM (draft’s #1 target) is minor. The real targets, in order: (1) move the Ridge GCV solve off the CPU householder-QR onto the GPU (cuSOLVER SPD Cholesky/eigh — the ridge normal system is SPD); (2) cut the 4.59 s of synchronous cudaMemcpy (device residency / pinned). The moment-transform batching matters only after these.

What is established vs hypothesized

Established (direct evidence):

  • BERRY brix (n=1434, p=2101) took 1212 s with GPU0 at ~0% util, one CPU core pinned. Across 51 datasets corr(log fit_time, log n·p²)=0.86; median fit 40.6 s (p≥1500) vs 3.9 s (p<800) — 10×. → The slow phase is not active CUDA kernels, and cost grows steeply with size.

  • The PLS moment scorer ran fully on device for BERRY (n_screen_pls_moment_cuda_device_cv_fits=120 host 0; refit 20 host 0). → The scorer is not the bottleneck.

Hypothesized (NOT yet proven — 5b-0 must apportion):

  • Which host phase dominates: base-moment build, per-candidate operator→moment transform, host Ridge eigensolves, or the data-movement/alloc/launch overhead around the cuBLAS calls. corr=0.86 with n·p² is confounded — large p correlates with large n, chain count, refit load, and stage mix, and at BERRY scale and n·p² are same-order, so the correlation cannot separate transform from build from eigensolve.

  • The BERRY n_ridge_moment_*=0 reading does not prove “wide Ridge materialized”; it could equally mean the Ridge moment route did not run. Needs the dual/materialized counters + active-head evidence.

Corrected code facts (verified by Codex against source)

These replace the first draft’s claims:

  1. Base moment build is NOT host-CPU GEMM in CUDA builds. compute_moments (moments.cpp:190-201,264-270) builds xtx/xty via linalg::gemm, which in CUDA builds dispatches to cuda_dispatch::gemm (common/linalg.hpp:173-178) — cuBLAS with per-call device alloc + H2D + D2H (cuda_dispatch.cpp:1439-1472). Buffers are host-owned; the matmul is on-device but wrapped in per-call copies. So the suspect is copy/alloc/launch overhead, not CPU matmul.

  2. The dense O(p³) operator→moment transform is only one route. transform_moments_by_operator does opᵀ·xty (O(p²q)) + xtx·op + opᵀ·temp (2×O(p³)) at aom_sweep.cpp:674-687, but the code tries structured/banded transforms first (aom_sweep.cpp:1323-1344,1425-1468) and disables dense for p>min_train except a tiny cap (aom_sweep.cpp:2222-2225). “Every candidate = two O(p³) GEMMs” is false in general.

  3. Wide-Ridge does NOT host-materialize via should_materialize_cpu_wide_ridge in CUDA builds — it returns false immediately when uses_cuda_linalg_build() (aom_sweep.cpp:555-560). Materialized fallback can still occur via unsupported moment routes, but not from this predicate.

  4. Ridge GCV is host-only (confirmed) but amortizes lambdas. fit_ridge_from_moments uses host vectors + host solve_square_qr (sweep.cpp:840-868, ridge.cpp:104-120); the eigen path is host (sweep.cpp:956-958, svd.c:263-283). But multiple λ share one host eigen prep per fold (sweep.cpp:2094-2110, fit_ridge_from_eigen_path sweep.cpp:2157-2172) — not QR/eigh per λ. cuSOLVER is not linked (only CUDA::cudart CUDA::cublas, n4m_targets.cmake:144-148).

  5. The PLS scorer is device-resident only inside the scorer. It takes host C/s pointers and copies them in per tile (cuda_dispatch.cpp:834-843), runs cuBLAS strided-batched (854-1072), copies W/P/Q back (1104-1113). Feeding it device-resident moments is not a drop-in — the scorer would need an internal device-pointer entry variant.

Current batching layout

The scorer pls1_moment_components_many_batched_tiled (cuda_dispatch.cpp:784) batches folds into device tiles (one cuBLAS strided-batched call per component over all folds of one candidate), dcur_yy/dQ device-resident, W/P/Q read back per tile. It is fp64, called once per (chain×candidate) from sweep.cpp:1636, fed host-built moments. Host-looped today: chain, candidate, and the entire moment-production pipeline that precedes the scorer.

Revised proposed design — kill per-candidate data movement, keep moments on device

Keep libn4m single-runtime. The intent is not “move CPU GEMM to GPU” (it’s already cuBLAS) but “stop round-tripping moments through host buffers per candidate” and batch the per-candidate launches so the device stays busy. Invariants preserved: fold-clean stateful heads, P-dependent detrend projector Q, MomentStats raw+centered fields, fp64-for-scores, train-CV-only selection.

Candidate components (each gated on 5b-0 evidence):

  1. Device-resident base moments per fold-config — compute XtX/XtY/yy once and keep on device, instead of host-owned buffers re-copied per candidate.

  2. Batched operator→moment transform on device, route-aware — for the routes that actually run (dense / banded / structured), apply the operator transform to device-resident base moments in batched cuBLAS calls over candidates. Do not force dense p×p batches for large p where the code already uses banded/structured.

  3. Device-pointer scorer entry — add an internal variant of the PLS scorer that accepts device-resident C/s/yy, eliminating the per-tile H2D of hC/hs.

  4. (Conditional) device Ridge GCV — only if 5b-0 shows host Ridge is a material fraction; would add a cuSOLVER dependency (currently unlinked).

  5. Batch chain×candidate into the tile dimension — so the executor is one call per stage-chunk, not one per candidate, eliminating the host launch/copy loop.

Internal device-state contract (new — per Codex)

Any device-resident state must be defined before code:

  • Memory budget. One p×p fp64 base moment at p=4200 (LUCAS) = 141 MB; raw + centered + transformed + temp + operator matrices multiply that. Must fit the existing PLS tile budget (choose_pls_batch_tile_jobs, cuda_dispatch.cpp:433-469) — chain×candidate×fold batching collides with the current per-job p×p budget and must share one accounting.

  • No persistent cross-call cache. Device state stays strictly internal to one public call (so “no ABI change” holds). Persisting base moments in Context or across calls changes lifetime/thread-safety/failure semantics even with symbols at ABI 1.22.0 (n4m_version.h:20-22) — out of scope unless explicitly designed.

  • fp64 + equivalence. Keep fp64; assert batched-device transform == host transform within tol; a degenerate-component batch must preserve the existing per-job failure/recovery semantics (don’t let one bad candidate poison the batch).

  • Fold-local state. MSC/EMSC re-fit per fold; the detrend projector/inverse is P-dependent (aom_sweep.cpp:904-928) — must not be hoisted across folds/P.

  • Device selection. CUDA state currently hardcodes device 0 (cuda_dispatch.cpp:50-56); the executor must honor CUDA_VISIBLE_DEVICES / the chosen device, not assume 0.

  • cuSOLVER gating. Only linked if 5b-3 is greenlit by profiling.

Increments (5b-0 is a HARD GATE)

  • 5b-0 — Phase-timing profile (mandatory, gates everything). Native phase timers (or NVTX ranges) around compute_moments*, transform_chain/operator-transform, Ridge eigen/direct fits, PLS scoring, and the Python chunk/checkpoint overhead, run on BERRY / COLZA / LUCAS with checkpoints off, scale_x grid fixed, plus Nsight Systems CUDA kernel-vs-copy time and the dual/materialized counters. Deliverable: a reproducible artifact apportioning the large-p host time. No kernel work until this names the dominant sublayer.

  • 5b-1..5b-5 (device-resident base moments → batched route-aware transform → device-pointer scorer entry → conditional cuSOLVER Ridge → chain×candidate batching): scope and order to be set by 5b-0’s measured split, biggest measured win first. Each behind the full green gate (n4m_tests + n4m_internal_tests CUDA & dev-release, targeted CUDA pytest, catalog split/--check-references/--strict-abi + reconcile_abi --check, git diff --check), with host-equivalence asserted and ABI held at 1.22.0.

Open questions — resolved (Codex + author agree)

  1. Scope: target the upstream moment / data-movement layer, not the PLS scorer — but narrow only after 5b-0; do not assume dense-transform+Ridge dominates. ✓

  2. cuSOLVER: not yet — keep Ridge host-side until 5b-0 shows it’s material. ✓

  3. Precision: fp64 first; fp32-screen/fp64-verify is a later experiment needing a recall/top-k-miss audit (the accuracy track already showed selection is variance- sensitive). ✓

  4. 5b-0 first: mandatory hard gate with native timers + route/materialized counters + CUDA copy/kernel time. ✓

Codex review incorporated (2026-06-08)

Changed vs first draft: (a) the cost is reframed from “host CPU GEMM” to “per-candidate data-movement/alloc/launch overhead + host eigensolves” — the GEMMs already route to cuBLAS; (b) the should_materialize_cpu_wide_ridge and “every candidate = two O(p³) GEMMs” and “Ridge QR/eigh per λ” claims are corrected/refuted with line evidence; (c) the empirical section now separates established from hypothesized and flags the n·p² confound and the counter over-reading; (d) 5b-0 is promoted to a hard gate that must apportion the host time before any kernel; (e) a device-state contract (memory at p=4200, tiling collision, fold-local state, device selection, no cross-call cache) is added; (f) feeding the scorer device moments is acknowledged to need a new device- pointer entry, not a drop-in. Raw review preserved alongside.

Why this is the right investment

The accuracy track established the accuracy ceiling is model expressiveness, not selection (RECALL_FIX_RESULTS.md). The remaining engine work is throughput, and the honest lever is the host-bound large-p moment pipeline (10× slower than small-p) — but what exactly to fuse is a measurement question, answered by 5b-0, not assumed.