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 eigensolves — not 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.cppsolve_square_qr, single-threaded O(p³/n³) QR). Note: this runs even thoughn_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::gemmonly 2.6%;cudaMalloc+cudaFree= 14 ms total (negligible).First change (neutral, kept): gave
cuda_dispatch::gemma 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=120host 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.86withn·p²is confounded — large p correlates with large n, chain count, refit load, and stage mix, and at BERRY scalep³andn·p²are same-order, so the correlation cannot separate transform from build from eigensolve.The BERRY
n_ridge_moment_*=0reading 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:
Base moment build is NOT host-CPU GEMM in CUDA builds.
compute_moments(moments.cpp:190-201,264-270) buildsxtx/xtyvialinalg::gemm, which in CUDA builds dispatches tocuda_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.The dense O(p³) operator→moment transform is only one route.
transform_moments_by_operatordoesopᵀ·xty(O(p²q)) +xtx·op+opᵀ·temp(2×O(p³)) ataom_sweep.cpp:674-687, but the code tries structured/banded transforms first (aom_sweep.cpp:1323-1344,1425-1468) and disables dense forp>min_trainexcept a tiny cap (aom_sweep.cpp:2222-2225). “Every candidate = two O(p³) GEMMs” is false in general.Wide-Ridge does NOT host-materialize via
should_materialize_cpu_wide_ridgein CUDA builds — it returns false immediately whenuses_cuda_linalg_build()(aom_sweep.cpp:555-560). Materialized fallback can still occur via unsupported moment routes, but not from this predicate.Ridge GCV is host-only (confirmed) but amortizes lambdas.
fit_ridge_from_momentsuses host vectors + hostsolve_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_pathsweep.cpp:2157-2172) — not QR/eigh per λ. cuSOLVER is not linked (onlyCUDA::cudart CUDA::cublas,n4m_targets.cmake:144-148).The PLS scorer is device-resident only inside the scorer. It takes host
C/spointers 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):
Device-resident base moments per fold-config — compute
XtX/XtY/yyonce and keep on device, instead of host-owned buffers re-copied per candidate.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.
Device-pointer scorer entry — add an internal variant of the PLS scorer that accepts device-resident
C/s/yy, eliminating the per-tile H2D ofhC/hs.(Conditional) device Ridge GCV — only if 5b-0 shows host Ridge is a material fraction; would add a cuSOLVER dependency (currently unlinked).
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
Contextor 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 honorCUDA_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_testsCUDA & dev-release, targeted CUDA pytest, catalogsplit/--check-references/--strict-abi+reconcile_abi --check,git diff --check), with host-equivalence asserted and ABI held at 1.22.0.
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.