I have everything I need. I traced the full dual path, the spd_solve/ReusableDeviceBuffer/kernel infrastructure, the build wiring, and the existing test. Here is the implementation roadmap.
B2 Implementation Roadmap — Device-resident dual-Ridge solve (upload-once K + reuse-buffers Cholesky)¶
Scope: internal cpp/src/core/cuda_dispatch.{hpp,cpp}, cpp/src/core/cuda_kernels.{hpp,cu}, cpp/src/core/sweep.cpp, and one test in cpp/tests/test_internal_linalg.cpp. No public n4m_* symbol, catalog, or Python change. ABI stays 1.22.0 (these are internal n4m::cuda_dispatch C++ symbols, hidden by visibility — same status as B1’s spd_solve). fp64 throughout. Host fallback preserved; N4M_CUDA_RIDGE_DISABLE still forces the full host path.
1. Confirmed call structure (with the loop quoted)¶
The gram K is built once per fold, and the three consumers are then called per‑λ on that same K. Confirmed by reading the source:
prepare_ridge_dual_design(sweep.cpp:301) buildsout.K = X_work·X_workᵀexactly once vialinalg::gemmatsweep.cpp:359-364.All three per‑λ consumers re-form
Kon the host and re-solve, every call, on the identicaldesign.Kand identicaldesign.Y_workRHS:predict_ridge_from_dual_design—K = design.K; K[i*n+i] += lambda(sweep.cpp:525-526),solve_dual_spd(K, n, design.Y_work, q, alpha)(sweep.cpp:529).ridge_dual_cross_heldout_sse— same shift (sweep.cpp:573-574), same solve (sweep.cpp:577).fit_ridge_from_dual_design— same shift (sweep.cpp:619-620), same solve (sweep.cpp:623).
solve_dual_spd(sweep.cpp:451) routes tocuda_dispatch::spd_solve(sweep.cpp:462) whenridge_cuda_dual_enabled(n)(sweep.cpp:435), else hostsolve_square_qr(sweep.cpp:468).spd_solve(cuda_dispatch.cpp:1156) re‑uploads the full n×nA(copy_h2dat:1197) and allocates freshDevicePtrs every call (:1193-1195,:1214) — this is the measured H2D + malloc churn.
The prep loop (built once per fold, sweep.cpp:2018-2044):
std::vector<RidgeDualDesign> fold_dual_designs(static_cast<std::size_t>(actual_cv));
std::vector<unsigned char> use_dual_cross_fold(static_cast<std::size_t>(actual_cv), 0);
if (do_ridge) {
for (std::int32_t fold = 0; fold < actual_cv; ++fold) {
const auto fold_ix = static_cast<std::size_t>(fold);
if (use_materialized_fold[fold_ix] == 0) continue;
st = prepare_ridge_dual_design(ctx, cfg, fold_designs[fold_ix].X_train, ...,
fold_dual_designs[fold_ix]); // builds .K ONCE
if (st != N4M_OK) return st;
if (should_use_ridge_dual_cross(...)) {
...
st = prepare_ridge_dual_cross(ctx, cfg, fold_dual_designs[fold_ix], Xc,
fold_rows[fold_ix], fold_dual_designs[fold_ix]);
use_dual_cross_fold[fold_ix] = 1;
}
}
}
The consuming λ‑outer / fold‑inner loop (sweep.cpp:2159-2200, condensed):
for (std::size_t candidate = 0; candidate < lambdas.size(); ++candidate) { // λ OUTER
const double lambda = lambdas[candidate];
for (std::int32_t fold = 0; fold < actual_cv; ++fold) { // fold INNER
const auto fold_ix = static_cast<std::size_t>(fold);
if (use_materialized_fold[fold_ix] != 0) {
if (use_dual_cross_fold[fold_ix] != 0) {
if (score_only) ridge_dual_cross_heldout_sse(ctx, fold_dual_designs[fold_ix], ..., lambda, fold_sse); // :2178
else predict_ridge_from_dual_design(ctx, fold_dual_designs[fold_ix], lambda, pred); // :2183
} else {
fit_ridge_from_dual_design(ctx, fold_dual_designs[fold_ix], lambda, fit); // :2189
}
} else { /* moment / eigen-path branch — not dual */ } // :2201
}
}
Key structural facts that drive the design:
Loop order is λ‑outer, fold‑inner, so a single device‑resident K per fold must persist for the whole λ loop (all folds coexist). This exactly mirrors the existing host eigen‑path vector
std::vector<RidgeMomentEigenPath> ridge_moment_eigen_pathsprepared per fold atsweep.cpp:2139-2157(gatedlambdas.size() > 1U,sweep.cpp:2141) and consumed per (λ,fold) in theelsebranch atsweep.cpp:2203-2211.For BERRY n=1434,
design.Kis 1434²×8 ≈ 16.4 MB, re-uploaded once per (λ×fold) ≈ 360 calls = the 1.77 scudaMemcpyand 1232cudaMallocs the profile flagged.The two final‑fit sites (
sweep.cpp:2553,sweep.cpp:2657) are single‑λ fits on the full‑dataall_dual_design; they are not in the grid loop and need no prepared handle.
2. Recommendation: Shape (A) — Upload‑once + reuse‑buffers Cholesky (variant A1: per‑fold device‑resident K)¶
Recommended. Reject (B) eigh amortization for B2.
Justification, anchored to the measured profile:
(A) Upload‑once + reuse‑buffers Cholesky |
(B) Eigh amortization |
|
|---|---|---|
Kills per‑λ K H2D (16 MB × ~360) — the 78% cost |
✅ (one H2D per fold) |
✅ (one H2D per fold) |
Kills per‑call malloc churn (1232) |
✅ (persistent + shared reusable buffers) |
✅ |
Per‑λ device work |
one D2D 16 MB + tiny diag kernel + |
tiny diag‑scale kernel + 1 gemm ( |
Per‑fold prep cost |
upload K (one H2D) |
|
Numerical equivalence to current |
bit‑identical matrix into the same |
different algorithm (eigendecomp vs Cholesky) → needs its own tolerance argument |
Code size / risk |
small, additive; mirrors the eigen‑path vector pattern |
larger; new syevd path + diag‑scale + two gemms |
The profile says copies dominate (78%) and GPU Cholesky is sub‑ms. (A) removes exactly the dominant cost (per‑λ H2D + mallocs) while keeping the cheap per‑λ potrf/potrs. (B) additionally removes the per‑λ potrf — but that is already sub‑ms, so (B) spends extra risk (eigendecomposition equivalence, a heavier per‑fold prep) to optimize a non‑bottleneck. (A) is the higher value/risk ratio. Keep (B) on the shelf for a future block only if a re‑profile ever shows per‑λ potrf rising to the top.
Within (A), use variant A1 (one persistent device K + RHS per fold, all coexisting for the sweep), because the loop is λ‑outer/fold‑inner. A single shared device‑K reused across folds would force a re‑upload on every inner fold step (i.e., per λ) — that is the current behavior and would not remove the H2D. A1 mirrors ridge_moment_eigen_paths (a per‑fold prepared state held in a vector across the λ loop) and changes nothing about candidate scores.
3. Precise specification for Codex¶
3.1 New internal device API (cuda_dispatch)¶
Add to cpp/src/core/cuda_dispatch.hpp (after the spd_solve decl at :115-120):
// Per-fold prepared dual-Ridge solver (B2). Holds the device-resident gram
// K (n x n, row-major symmetric) and the column-major RHS B (n x q) uploaded
// ONCE, so the whole lambda grid reuses one H2D. Opaque; defined in the .cpp.
struct PreparedDualRidge;
// Upload K and B once. Returns nullptr if no GPU is available or any device
// allocation/upload fails — the caller MUST treat nullptr as "use the host /
// per-call path" (no error is fatal here). K is n x n row-major symmetric
// (either triangle valid); B is n x q row-major.
PreparedDualRidge* prepare_dual_ridge(std::size_t n, std::size_t q,
const double* K, const double* B,
std::string* error);
// Solve (K + lambda*I) X = B for the stored K/B, writing X (n x q row-major).
// Forms A = K on device (D2D), adds lambda to A's diagonal, runs cuSOLVER
// Cholesky, reusing one thread-local work buffer. Return codes match spd_solve:
// 0: success
// 1: not positive definite (caller falls back to host QR)
// 2: CUDA / cuSOLVER / allocation failure
int dual_ridge_solve(PreparedDualRidge* handle, double lambda,
double* X, std::string* error);
// Release the device buffers. Safe on nullptr.
void destroy_dual_ridge(PreparedDualRidge* handle) noexcept;
Implementation in cpp/src/core/cuda_dispatch.cpp (place next to spd_solve, after :1260):
struct PreparedDualRidge(named namespace, file scope): ownsReusableDeviceBuffer<double> dK(sizedn*n) andReusableDeviceBuffer<double> dB0(sizedn*q, column‑major pristine RHS), plusstd::size_t n, q; int lwork;. Reuse the existingReusableDeviceBuffer<T>(cuda_dispatch.cpp:148) for RAIIcudaFree.prepare_dual_ridge: mirrorspd_solve’s guards (:1166-1189—cuda_runtime_available, null/zero checks, int‑range,mul_overflows). Then:h->dK.ensure(n*n); copy_h2d(h->dK.get(), K, n*n*sizeof(double));(full symmetric K, same asspd_solve:1197).Build column‑major
Bcmexactly asspd_solve:1199-1204(Bcm[target*n + i] = B[i*q + target]),h->dB0.ensure(n*q); copy_h2d(h->dB0.get(), Bcm.data(), ...).cusolverDnDpotrf_bufferSize(state().solver, CUBLAS_FILL_MODE_LOWER, ni, h->dK.get(), ni, &lwork)(depends only on n/uplo, so computing once in prepare is valid); storeh->lwork.Return
h.release(). Any exception (incl.std::bad_alloc) → set*error, returnnullptr(no throw across the call; nullptr means host fallback).
dual_ridge_solve:Guards (
cuda_runtime_available,handle != nullptr,X != nullptr),ni=(int)h->n,qi=(int)h->q.thread_local ReusableDualWorkspace ws; ws.ensure(h->n, h->q, h->lwork);— a new small class (mirrorReusablePls1MomentWorkspace,:187) holdingReusableDeviceBuffer<double> dA(n*n), dB(n*q), dWork(lwork); ReusableDeviceBuffer<int> dInfo(1).copy_d2d_contiguous(h->dK.get(), ws.dA(), h->n*h->n, "dual_ridge K->A");(existing helper,cuda_dispatch.cpp:495; default null stream).check_cuda(add_scaled_identity(ws.dA(), h->n, lambda, /*stream=*/nullptr), "add_scaled_identity");(new kernel — §3.2).copy_d2d_contiguous(h->dB0.get(), ws.dB(), h->n*h->q, "dual_ridge B0->B");(must copy into a work RHS;potrsoverwrites it — never touch the pristinedB0).cusolverDnDpotrf(state().solver, CUBLAS_FILL_MODE_LOWER, ni, ws.dA(), ni, ws.dWork(), h->lwork, ws.dInfo());copy_d2h(&info, ws.dInfo(), sizeof(int));info>0 → return 1(not PD → host QR),info<0 → return 2. (Same semantics/uplo asspd_solve:1208-1228.)cusolverDnDpotrs(state().solver, CUBLAS_FILL_MODE_LOWER, ni, qi, ws.dA(), ni, ws.dB(), ni, ws.dInfo()); check info!=0 → 2.copy_d2h(Xcm, ws.dB(), ...)then transpose column‑major → row‑majorXexactly asspd_solve:1240-1246.return 0. Same try/catch envelope asspd_solve:1248-1259.
destroy_dual_ridge:delete handle;(ReusableDeviceBuffer destructors free device memory).
3.2 Diagonal‑add kernel (cuda_kernels)¶
Recommend the tiny custom kernel over cublasDaxpy (no need to materialize a device vector of λ; the diagonal index is layout‑agnostic).
Add to cpp/src/core/cuda_kernels.hpp (after :67):
// A += lambda on the diagonal: A[i*n + i] += lambda for i in [0, n).
// A is n x n. The diagonal index i*n+i is identical for row-major and
// column-major, so this matches the host K[i*n+i] += lambda bit-for-bit.
cudaError_t add_scaled_identity(double* A, std::size_t n, double lambda,
cudaStream_t stream) noexcept;
Add to cpp/src/core/cuda_kernels.cu (mirror the launcher pattern at :195-215, block size 256, grid ceil(n/256)):
__global__ void add_scaled_identity_kernel(double* A, int n, double lambda) {
const int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= n) return;
A[static_cast<std::size_t>(i) * static_cast<std::size_t>(n) + i] += lambda;
}
cudaError_t add_scaled_identity(double* A, std::size_t n, double lambda,
cudaStream_t stream) noexcept {
if (n == 0) return cudaSuccess;
if (A == nullptr ||
n > static_cast<std::size_t>(std::numeric_limits<int>::max())) {
return cudaErrorInvalidValue;
}
constexpr int kBlock = 256;
const dim3 grid(static_cast<unsigned int>((n + kBlock - 1) / kBlock));
add_scaled_identity_kernel<<<grid, dim3(kBlock), 0, stream>>>(
A, static_cast<int>(n), lambda);
return cudaGetLastError();
}
No CMake change: cuda_kernels.cu is already compiled into n4m_core (cpp/src/CMakeLists.txt:85-88).
3.3 sweep.cpp prepared‑state mirror + loop integration¶
Mirror the host eigen‑path vector. All additions guarded by #if defined(N4M_USE_CUDA).
RAII wrapper near the
RidgeMomentEigenPathstruct (sweep.cpp:75):
#if defined(N4M_USE_CUDA)
struct PreparedDualGpu {
::n4m::cuda_dispatch::PreparedDualRidge* handle{nullptr};
PreparedDualGpu() = default;
~PreparedDualGpu() { ::n4m::cuda_dispatch::destroy_dual_ridge(handle); }
PreparedDualGpu(const PreparedDualGpu&) = delete;
PreparedDualGpu& operator=(const PreparedDualGpu&) = delete;
PreparedDualGpu(PreparedDualGpu&& o) noexcept : handle(o.handle) { o.handle = nullptr; }
PreparedDualGpu& operator=(PreparedDualGpu&& o) noexcept {
if (this != &o) { ::n4m::cuda_dispatch::destroy_dual_ridge(handle); handle = o.handle; o.handle = nullptr; }
return *this;
}
bool valid() const noexcept { return handle != nullptr; }
};
#endif
One shared α‑solve helper that the three consumers call, replacing their inline
K = design.K; K[i*n+i]+=λ; solve_dual_spd(...)(sweep.cpp:525-529,:573-577,:619-623):
[[nodiscard]] n4m_status_t solve_dual_alpha(Context& ctx,
const RidgeDualDesign& design,
double lambda,
const PreparedDualGpu* gpu,
std::vector<double>& alpha) {
const std::size_t n = design.n_samples, q = design.n_targets;
#if defined(N4M_USE_CUDA)
if (gpu != nullptr && gpu->valid()) {
alpha.assign(n * q, 0.0);
std::string err;
const int s = ::n4m::cuda_dispatch::dual_ridge_solve(gpu->handle, lambda, alpha.data(), &err);
if (s == 0) return N4M_OK; // s==1 (not PD) / s==2 (runtime) → host fallback below
}
#else
(void)gpu;
#endif
std::vector<double> K = design.K;
for (std::size_t i = 0; i < n; ++i) K[i * n + i] += lambda;
return solve_dual_spd(K, n, design.Y_work, q, alpha); // unchanged B1 path (GPU per-call or host QR)
}
Then the three consumers gain a trailing defaulted param const PreparedDualGpu* gpu = nullptr and replace their inline shift+solve with solve_dual_alpha(ctx, design, lambda, gpu, alpha). (When N4M_USE_CUDA is off, PreparedDualGpu does not exist — declare the param as const void* gpu = nullptr is messy; instead guard the param itself: keep the three signatures CUDA‑agnostic by forward‑declaring struct PreparedDualGpu; unconditionally and only defining it under CUDA. The gpu pointer is only dereferenced inside the #if defined(N4M_USE_CUDA) block of solve_dual_alpha, so a forward declaration suffices for non‑CUDA builds.)
Defaulted nullptr means the two final‑fit sites (sweep.cpp:2553, :2657) compile unchanged and keep today’s behavior.
Prepared‑handle vector, declared next to the eigen‑path vector (
sweep.cpp:2139) and populated in the per‑fold prep loop (insert at the end of each fold iteration, after the optional cross build, ~sweep.cpp:2042):
std::vector<PreparedDualGpu> fold_prepared_dual(static_cast<std::size_t>(actual_cv)); // entries default-null
...
// inside the prep loop, after fold_dual_designs[fold_ix] (and any cross) is built:
#if defined(N4M_USE_CUDA)
if (lambdas.size() > 1U && ridge_cuda_dual_enabled(fold_dual_designs[fold_ix].n_samples)) {
std::string err;
fold_prepared_dual[fold_ix].handle = ::n4m::cuda_dispatch::prepare_dual_ridge(
fold_dual_designs[fold_ix].n_samples, q,
fold_dual_designs[fold_ix].K.data(),
fold_dual_designs[fold_ix].Y_work.data(), &err); // nullptr on failure → host path
}
#endif
Gating reuses ridge_cuda_dual_enabled (sweep.cpp:435), so the n≥256 threshold, runtime check, and N4M_CUDA_RIDGE_DISABLE all apply unchanged. lambdas.size() > 1U mirrors the eigen‑path amortization gate (sweep.cpp:2141).
Three call sites in the grid loop pass the per‑fold handle:
ridge_dual_cross_heldout_sse(ctx, fold_dual_designs[fold_ix], Yc, q, rows, lambda, fold_sse, &fold_prepared_dual[fold_ix])(sweep.cpp:2178)predict_ridge_from_dual_design(ctx, fold_dual_designs[fold_ix], lambda, pred, &fold_prepared_dual[fold_ix])(sweep.cpp:2183)fit_ridge_from_dual_design(ctx, fold_dual_designs[fold_ix], lambda, fit, &fold_prepared_dual[fold_ix])(sweep.cpp:2189)
For non‑CUDA builds these still compile because the param is a forward‑declared pointer; pass
&fold_prepared_dual[fold_ix]only under#if defined(N4M_USE_CUDA), elsenullptr— or keepfold_prepared_dualdefined only under CUDA and select the argument with a small#if. Either is fine; prefer one#ifat the three call sites for clarity.
4. Correctness traps (explicit)¶
No fold leakage by construction. Each materialized‑dual fold owns its own persistent
PreparedDualRidge(its owndK,dB0); there is no shared persistent K across folds. The single thread‑localReusableDualWorkspace(dA,dB,dWork,dInfo) is fully overwritten at the start of every solve:copy_d2d_contiguous(dK→dA, n*n)writes alln_f²leading elements andcopy_d2d_contiguous(dB0→dB, n*q)writes alln_f·qleading elements. After a larger fold,ws.ensurekeeps the larger capacity, butpotrf/potrsoperate strictly on the leadingni×ni/ni×qiregion withld = ni, so stale tail bytes are never read. State this in a comment at thews.ensurecall.On‑device λ‑shift is bit‑identical to host
K[i*n+i] += λ. The off‑diagonal bytes ofdAare a verbatim D2D copy of the host‑builtdK; the diagonal elementA[i*n+i]equalsK[i*n+i](same bytes) and the kernel performs a single IEEE‑754 round‑to‑nearest fp64 add of the sameλ(plain+, no FMA contraction) — identical to the host. The matrix fed topotrfis therefore bit‑for‑bit the same matrix that currentspd_solvewould upload after the host formedK+λI. Since the same cuSOLVERpotrf/potrsthen run on the same GPU,dual_ridge_solvereproducesspd_solveto ~1e‑12 — far inside the 1e‑8 gate.Pristine RHS preserved.
potrsoverwrites its RHS; never passdB0. Always solve in the workdB(copied fromdB0each call). One mistakenpotrsondB0would silently corrupt every later λ for that fold.Serial‑handle contract.
dual_ridge_solveuses the process‑wide singletonstate().solver/state().handle(cuda_dispatch.cpp:82,:47-50) and the default (null) stream, which serializes the D2D copy → diag kernel →potrf→potrsin issue order; thecopy_d2h(info)forces a sync. The sweep λ/fold loop is single‑threaded and serial (same contractsolve_dual_spddocuments atsweep.cpp:458-459), so thethread_localshared workspace is never reentered concurrently. Do not introduce streams here.Fallback paths.
prepare_dual_ridgereturningnullptr(no GPU, disabled, or alloc failure) ⇒gpu->valid()is false ⇒solve_dual_alpharuns the unchanged B1solve_dual_spdpath.dual_ridge_solvereturning1(not PD) or2(runtime) ⇒ same fallback.N4M_CUDA_RIDGE_DISABLE=1⇒ridge_cuda_dual_enabledfalse ⇒ no handle prepared ⇒ full host path, and the fallbacksolve_dual_spdalso sees it disabled ⇒ host QR. End‑to‑end host behavior is byte‑for‑byte the pre‑B2 path.ABI / surface. All new symbols live in
n4m::cuda_dispatch(internal, visibility‑hidden) andsweep.cpp’s anonymous namespace — non4m_*export, no header incpp/include/n4m/, no.def/.mapchange. ABI stays 1.22.0;cpp/abi/expected_symbols_*.txtunchanged.Device‑memory footprint. Resident ≈ Σ_folds(
n_f²+n_f·q) + max‑fold work buffers (n_f²+n_f·q+lwork). BERRY ≈ 10 folds × 16 MB ≈ ~180 MB — acceptable. Thebad_alloc→nullptrcontract is the safety net: if a fold’s prepare OOMs, that fold silently falls back to per‑callspd_solve. No hard budget needed; note this in the prepare comment.
5. File‑by‑file roadmap + green gates¶
Edits (in order):
cpp/src/core/cuda_kernels.hpp— declareadd_scaled_identity(after:67).cpp/src/core/cuda_kernels.cu— defineadd_scaled_identity_kernel+ launcher (mirror:195-215).cpp/src/core/cuda_dispatch.hpp— declarestruct PreparedDualRidge;,prepare_dual_ridge,dual_ridge_solve,destroy_dual_ridge(after:120).cpp/src/core/cuda_dispatch.cpp— definePreparedDualRidge,ReusableDualWorkspace, and the three functions (after:1260); reuseReusableDeviceBuffer(:148),copy_h2d/copy_d2h(:303/:312),copy_d2d_contiguous(:495),check_cusolver/check_cuda(:352/:358),state()(:82).cpp/src/core/sweep.cpp—forward‑declare
struct PreparedDualGpu;unconditionally; define it under#if defined(N4M_USE_CUDA)near:75;add
solve_dual_alphahelper; route the three consumers through it; add the defaultedgpuparam topredict_ridge_from_dual_design(:505),ridge_dual_cross_heldout_sse(:549),fit_ridge_from_dual_design(:607);declare
fold_prepared_dualnear:2139; populate it in the prep loop at~:2042; pass&fold_prepared_dual[fold_ix]at the three grid call sites (:2178,:2183,:2189).
cpp/tests/test_internal_linalg.cpp— addtest_cuda_prepared_dual_ridge_matches_spd_solve()(mirror:305) and register it inrun_internal_linalg_tests(:376-384).
No CMake change — CUDA sources already wired (cpp/src/CMakeLists.txt:85-88, :219-221; link at n4m_targets.cmake:146-149).
The new test (the precise numeric gate for trap #2):
Skip if
!cuda_runtime_available().Build SPD gram
K(n=64, q=3) as in the existing test (:315-334) but without the+1.0diagonal bump (the prepared path adds λ on device).For
λ ∈ {1e-3, 0.1, 1.0, 10.0}: referenceX_refviaspd_solve(n,q,Kλ,B,X_ref)whereKλ = K; Kλ[i*n+i]+=λ(host‑formed, the B1 path). Prepared:h = prepare_dual_ridge(n,q,K,B); dual_ridge_solve(h,λ,X_dev). Assertmax|X_ref−X_dev| ≤ 1e-8(expect ~1e‑12).Not‑PD: a
KwhoseK+0·Iis singular (reuse the rank‑deficient diagonal matrix at:362-365) →dual_ridge_solve(h, 0.0, …) == 1.destroy_dual_ridge(h).
Green gates (run all, in order):
# CUDA build + unit + internal tests (the precise equivalence gate)
cmake --preset blas-omp -DN4M_WITH_CUDA=ON # or the repo's cuda preset
cmake --build --preset blas-omp -j
ctest --preset blas-omp --output-on-failure
./build/blas-omp/cpp/tests/n4m_tests --test-case="*" # doctest
./build/blas-omp/cpp/tests/n4m_internal_tests # runs test_cuda_prepared_dual_ridge_matches_spd_solve
# dev-release (non-CUDA) build must still compile/link (forward-declared handle, nullptr path)
cmake --preset dev-release && cmake --build --preset dev-release --parallel
ctest --preset dev-release --output-on-failure
# catalog / ABI (must be unchanged — internal only)
python catalog/scripts/validate.py --check-references --strict-abi
python catalog/scripts/reconcile_abi.py --check
nm -D --defined-only build/blas-omp/cpp/src/libn4m.so.<ver> | awk '{print $3}' | sort -u \
| diff -u cpp/abi/expected_symbols_linux.txt - # expect empty diff
git diff --check
# run_moment_sweep parity: host vs B2-GPU candidate scores ≤ 1e-8
# BERRY screen (the B1 benchmark dataset), score parity + timing:
N4M_CUDA_RIDGE_DISABLE=1 <berry-screen-cmd> --out-csv /tmp/b2_host.csv
<berry-screen-cmd> --out-csv /tmp/b2_gpu.csv
# assert candidate_scores / RMSEP identical to ≤1e-8 (same check B1 used: "RMSEP preserved"),
# and confirm the cudaMemcpy/cudaMalloc collapse via nsys on the BERRY screen.
Expected outcome: BERRY screen drops from the post‑B1 ~8.27 s by eliminating the 1.77 s cudaMemcpy (per‑λ K H2D) and the ~0.4 s malloc/free churn (1232 mallocs), with candidate scores bit‑equivalent to the host path within 1e‑8.