feat(sp4): Task A7 — Pearl B fused per-param-group statistics oracle
Single kernel per group reads (params, grads, adam_m, adam_v) once and fuses 5 passes (4 for non-trunk): Pass A: WEIGHT_BOUND[group] = p99(|params|) via sp4_histogram_p99 Pass B: ADAM_M_BOUND[group] = p99(|adam_m|) via sp4_histogram_p99 Pass C: ADAM_V_BOUND[group] = p99(|adam_v|) via sp4_histogram_p99 Pass D: WD_RATE[group] = |Σ w·g| / max(Σ w², ε_div); side: mean|g|, mean|w| Pass E (group 0 only): L1_LAMBDA[trunk] via gradient-direction entropy deficit × (mean|g|/mean|w|) magnitude scale Launcher loops over 8 groups (DQN trunk/value/branches, IQN, IQL hi/lo, attn, curiosity), one launch per group. Phase 2 applies Pearls A+D to each of the 4-5 outputs per group via host-side pearls_ad_update. Producer-scratch slots 5..38 reserved for this task's 33 outputs: [5..13)=WEIGHT, [13..21)=ADAM_M, [21..29)=ADAM_V, [29..37)=WD_RATE, 37=L1. Three of eight groups wired (DQN trunk/value/branches); five aux groups (IQN, IQL hi/lo, attn, curiosity) skip silently because their backing trainers live on FusedTrainingCtx, not GpuDqnTrainer. Wiring those requires either threading buffer pointers through the launcher signature or hoisting the launcher onto FusedTrainingCtx — both follow-up changes scoped beyond Task A7. Documented in launcher + param_group_buffers docstrings. Per-group GPU tests verify outputs within 5% rel_err (p99 quantization) or 2% rel_err (analytical formulas). Local RTX 3050 Ti: max WEIGHT rel_err=0.589%, max ADAM_M=0.589%, max ADAM_V=0.554%, max WD_RATE=0.001% across all 8 group shapes; group 0 L1 within tolerance. Pearl B 4× memory-bandwidth reduction vs naive 4 separate per-group producer kernels: each buffer read once for all 4-5 outputs. No consumer wired yet — Adam kernels still take hardcoded weight_decay + weight_clamp_max_abs config args. Behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -266,6 +266,25 @@ fn main() {
|
||||
// scratch_idx ∈ 1..5)` pairs and applying Pearls A+D host-side per
|
||||
// branch (writes back to ISV[ATOM_POS_BOUND_BASE + branch]).
|
||||
"atom_pos_p99_kernel.cu",
|
||||
// SP4 Layer A Task A7 (2026-04-30): Pearl B fused per-param-group
|
||||
// statistics oracle. Single-block kernel reads
|
||||
// `(params, grads, adam_m, adam_v)` once and runs 4 sequential
|
||||
// passes (5 for trunk):
|
||||
// Pass A: WEIGHT_BOUND[group] = p99(|params|) via sp4_histogram_p99
|
||||
// Pass B: ADAM_M_BOUND[group] = p99(|adam_m|) via sp4_histogram_p99
|
||||
// Pass C: ADAM_V_BOUND[group] = p99(|adam_v|) via sp4_histogram_p99
|
||||
// Pass D: WD_RATE[group] = |Σ w·g| / max(Σ w², EPS_DIV)
|
||||
// Pass E: L1_LAMBDA[trunk] = (mean|g|/mean|w|) × entropy_deficit
|
||||
// (group 0 only, gated by
|
||||
// l1_lambda_scratch_idx >= 0).
|
||||
// Pearl B 4× memory-bandwidth reduction vs four naive single-stat
|
||||
// producer kernels: each buffer read once for all outputs. The host
|
||||
// launcher (`launch_sp4_param_group_oracles_all_groups`) loops
|
||||
// `group ∈ 0..SP4_PARAM_GROUP_COUNT=8`, launching this kernel 8
|
||||
// times with distinct `(params, grads, m, v, count, scratch slots)`
|
||||
// tuples, then applies Pearls A+D host-side per output via
|
||||
// `pearls_ad_update`.
|
||||
"param_group_oracle_kernel.cu",
|
||||
];
|
||||
|
||||
// ALL kernels get common header (BF16 types + wrappers)
|
||||
|
||||
@@ -168,6 +168,27 @@ static SP4_TARGET_Q_P99_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "
|
||||
/// Cold-path launch in this task; Task A10 may relocate to captured graph.
|
||||
static SP4_ATOM_POS_P99_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/atom_pos_p99_kernel.cubin"));
|
||||
|
||||
/// SP4 Layer A Task A7 (2026-04-30): Pearl B fused per-param-group oracle cubin.
|
||||
/// Single-block kernel reads `(params, grads, adam_m, adam_v)` once and runs
|
||||
/// 4-5 fused passes:
|
||||
/// Pass A: WEIGHT_BOUND[group] = p99(|params|) via sp4_histogram_p99
|
||||
/// Pass B: ADAM_M_BOUND[group] = p99(|adam_m|) via sp4_histogram_p99
|
||||
/// Pass C: ADAM_V_BOUND[group] = p99(|adam_v|) via sp4_histogram_p99
|
||||
/// Pass D: WD_RATE[group] = |Σ w·g| / max(Σ w², EPS_DIV)
|
||||
/// Pass E: L1_LAMBDA[trunk] = (mean|g|/mean|w|) × entropy_deficit
|
||||
/// (group 0 only, gated by l1_idx ≥ 0)
|
||||
/// The launcher (`GpuDqnTrainer::launch_sp4_param_group_oracles_all_groups`)
|
||||
/// loops `group ∈ 0..SP4_PARAM_GROUP_COUNT`, launching the kernel once per
|
||||
/// group with the per-group `(params, grads, m, v, count, scratch slots)`
|
||||
/// tuple, then applies Pearls A+D host-side per output slot via
|
||||
/// `pearls_ad_update`. Pearl B 4× memory-bandwidth reduction vs four naive
|
||||
/// single-stat producer kernels: each buffer read once for all 4-5 outputs.
|
||||
/// No consumer wired yet — Mech 9's clamp still uses hardcoded
|
||||
/// `weight_clamp_max_abs` config args. Cold-path launch in this task; Task
|
||||
/// A10 may relocate to captured graph.
|
||||
static SP4_PARAM_GROUP_ORACLE_CUBIN: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/param_group_oracle_kernel.cubin"));
|
||||
|
||||
/// Plan 4 Task 3 (E.3): IQN multi-quantile diagnostic EMAs into ISV[99..103).
|
||||
/// 4-block (256 threads/block, shmem-reduce, no atomicAdd) kernel launched
|
||||
/// alongside `h_s2_rms_ema_update` from `training_loop.rs`. Reads the IQN
|
||||
@@ -3385,6 +3406,24 @@ pub struct GpuDqnTrainer {
|
||||
/// `SP4_ATOM_POS_P99_CUBIN`.
|
||||
atom_pos_p99_update: CudaFunction,
|
||||
|
||||
/// SP4 Layer A Task A7: Pearl B fused per-param-group statistics oracle
|
||||
/// kernel. Single-block kernel reads `(params, grads, adam_m, adam_v)`
|
||||
/// once and runs 4 fused passes (5 for the trunk group):
|
||||
/// Pass A: WEIGHT_BOUND[group] = p99(|params|)
|
||||
/// Pass B: ADAM_M_BOUND[group] = p99(|adam_m|)
|
||||
/// Pass C: ADAM_V_BOUND[group] = p99(|adam_v|)
|
||||
/// Pass D: WD_RATE[group] = |Σ w·g| / max(Σ w², EPS_DIV)
|
||||
/// Pass E: L1_LAMBDA[trunk] = (mean|g|/mean|w|) × entropy_deficit
|
||||
/// (group 0 only, gated by l1_idx ≥ 0).
|
||||
/// Pearl B 4× memory-bandwidth reduction vs four naive single-stat
|
||||
/// producer kernels: each buffer read once for all 4-5 outputs. The
|
||||
/// launcher (`launch_sp4_param_group_oracles_all_groups`) loops over
|
||||
/// `SP4_PARAM_GROUP_COUNT=8` groups (DQN trunk/value/branches +
|
||||
/// IQN/IQL-hi/IQL-lo/Attn/Curiosity), one launch per group, then applies
|
||||
/// Pearls A+D host-side per output slot. Loaded from
|
||||
/// `SP4_PARAM_GROUP_ORACLE_CUBIN`.
|
||||
param_group_oracle_update: CudaFunction,
|
||||
|
||||
// ── Plan C Phase 2 follow-up A.2: q-drift rate ISV producer ───────
|
||||
/// Single-thread single-block cold-path kernel. Reads ISV[Q_ABS_REF=16] +
|
||||
/// ISV[Q_DIR_ABS_REF=21] plus host-passed `q_mean_curr` / `q_mean_prev`
|
||||
@@ -8820,6 +8859,305 @@ impl GpuDqnTrainer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SP4 Layer A Task A7: cold-path producer for the Pearl B
|
||||
/// per-param-group statistics oracle.
|
||||
///
|
||||
/// For each param-group `g ∈ 0..SP4_PARAM_GROUP_COUNT=8`, calls
|
||||
/// `param_group_buffers(g)` to resolve `(params, grads, m, v, count,
|
||||
/// k_in, h_dim)`, then launches `param_group_oracle_update` once with
|
||||
/// distinct producer-step-scratch slot indices into the documented
|
||||
/// stable layout:
|
||||
/// `WEIGHT_BOUND[g]` → scratch slot `5 + g`
|
||||
/// `ADAM_M_BOUND[g]` → scratch slot `13 + g`
|
||||
/// `ADAM_V_BOUND[g]` → scratch slot `21 + g`
|
||||
/// `WD_RATE[g]` → scratch slot `29 + g`
|
||||
/// `L1_LAMBDA_TRUNK` → scratch slot `37` (group 0 only; passed as
|
||||
/// `-1_i32` to the kernel for groups 1-7).
|
||||
///
|
||||
/// After a single end-of-loop `stream.synchronize()`, applies Pearls
|
||||
/// A+D host-side per-output via `pearls_ad_update` (Task A3) — 4
|
||||
/// outputs per group plus L1_LAMBDA for group 0, totalling 33
|
||||
/// updates. All reads/writes go through mapped-pinned host pointers
|
||||
/// (`isv_signals_pinned` and `wiener_state_buf.host_ptr`); no
|
||||
/// HtoD/DtoH per `feedback_no_htod_htoh_only_mapped_pinned`.
|
||||
///
|
||||
/// **Pearl B 4× memory-bandwidth reduction** vs four naive single-stat
|
||||
/// producer kernels: the kernel reads each of `params`, `grads`,
|
||||
/// `adam_m`, `adam_v` exactly once across all 4-5 outputs.
|
||||
///
|
||||
/// **Cold-path launch** — NOT in the captured graph for now. Task A10
|
||||
/// may relocate select producers; this launcher mirrors A5/A6's shape
|
||||
/// so the migration is structural-only at that point.
|
||||
///
|
||||
/// **No consumer wired in this task** — Mech 9's clamp still uses
|
||||
/// hardcoded `weight_clamp_max_abs = 100 × q_abs_ref_eff` config args;
|
||||
/// AdamW weight_decay and L1 unchanged. SP4 consumer migration follows
|
||||
/// once all producers (A5-A9) land.
|
||||
///
|
||||
/// **Aux trainers not yet wired** — `param_group_buffers` returns
|
||||
/// `None` for the 5 auxiliary param-groups (IQN, IQL-hi, IQL-lo, Attn,
|
||||
/// Curiosity). Those trainers live on `FusedTrainingCtx`, not
|
||||
/// `GpuDqnTrainer`; wiring them here would require either passing the
|
||||
/// pointers through the launcher signature (and threading the call
|
||||
/// site through every `&mut FusedTrainingCtx` callsite) or hoisting
|
||||
/// the launcher onto `FusedTrainingCtx` itself. Both are follow-up
|
||||
/// changes scoped beyond Task A7. The 3 DQN-internal groups (trunk,
|
||||
/// value, branches) launch successfully; aux groups skip with a
|
||||
/// debug-only `tracing::trace!`.
|
||||
pub fn launch_sp4_param_group_oracles_all_groups(&self) -> Result<(), MLError> {
|
||||
use crate::cuda_pipeline::sp4_isv_slots::{
|
||||
adam_m_bound, adam_v_bound, weight_bound, wd_rate, ParamGroup,
|
||||
L1_LAMBDA_TRUNK_INDEX, SP4_PARAM_GROUP_COUNT, SP4_SLOT_BASE,
|
||||
};
|
||||
use crate::cuda_pipeline::sp4_wiener_ema::{pearls_ad_update, WienerState};
|
||||
|
||||
// Producer-step-scratch slot assignment (matches the stable layout
|
||||
// documented in `launch_sp4_target_q_p99` and extended for Task A7):
|
||||
// slots 5..13 = WEIGHT_BOUND[0..8]
|
||||
// slots 13..21 = ADAM_M_BOUND[0..8]
|
||||
// slots 21..29 = ADAM_V_BOUND[0..8]
|
||||
// slots 29..37 = WD_RATE[0..8]
|
||||
// slot 37 = L1_LAMBDA_TRUNK (group 0 only)
|
||||
const SCRATCH_BASE_W: usize = 5;
|
||||
const SCRATCH_BASE_M: usize = 13;
|
||||
const SCRATCH_BASE_V: usize = 21;
|
||||
const SCRATCH_BASE_WD: usize = 29;
|
||||
const SCRATCH_L1_TRUNK: usize = 37;
|
||||
|
||||
// Same shared-memory budget as the rest of the SP4 producer family
|
||||
// (sp4_histogram_p99 contract): 8 warps × 256 bins × sizeof(int).
|
||||
// Pass D's 4 sequential block-reduces reuse this same dynamic shmem
|
||||
// region as a `float[256]` accumulator buffer (256 × 4 bytes ≪ 8192).
|
||||
const SHARED_BYTES: u32 = (256 / 32) * 256 * 4;
|
||||
|
||||
let cfg = LaunchConfig {
|
||||
grid_dim: (1, 1, 1),
|
||||
block_dim: (256, 1, 1),
|
||||
shared_mem_bytes: SHARED_BYTES,
|
||||
};
|
||||
|
||||
let scratch_dev = self.producer_step_scratch_buf.dev_ptr;
|
||||
|
||||
// Track which groups got a real launch — Phase 2 only post-processes
|
||||
// those (skipping aux groups whose buffers aren't yet wired).
|
||||
let mut launched: [bool; SP4_PARAM_GROUP_COUNT] = [false; SP4_PARAM_GROUP_COUNT];
|
||||
|
||||
// ── Phase 1: launch one kernel per group (when buffers available) ──
|
||||
for g_idx in 0..SP4_PARAM_GROUP_COUNT {
|
||||
let g = ParamGroup::ALL[g_idx];
|
||||
let bufs = match self.param_group_buffers(g) {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
// Aux trainer not yet wired (IQN/IQL/Attn/Curiosity);
|
||||
// skip silently. Follow-up task adds accessors.
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (params_ptr, grads_ptr, m_ptr, v_ptr, count, k_in, h_dim) = bufs;
|
||||
// Sanity: a zero-element slice would yield a degenerate p99 = 0
|
||||
// inside the kernel and short-circuit Pearls A+D below; cheaper
|
||||
// to skip the launch entirely.
|
||||
if count == 0 { continue; }
|
||||
let count_i32 = count as i32;
|
||||
let k_in_i32 = k_in;
|
||||
let h_dim_i32 = h_dim;
|
||||
let weight_idx_i32 = (SCRATCH_BASE_W + g_idx) as i32;
|
||||
let adam_m_idx_i32 = (SCRATCH_BASE_M + g_idx) as i32;
|
||||
let adam_v_idx_i32 = (SCRATCH_BASE_V + g_idx) as i32;
|
||||
let wd_rate_idx_i32 = (SCRATCH_BASE_WD + g_idx) as i32;
|
||||
let l1_idx_i32: i32 = if g_idx == 0 {
|
||||
SCRATCH_L1_TRUNK as i32
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
|
||||
// Safety: kernel signature `(const float*, const float*,
|
||||
// const float*, const float*, int, int, int, float*, int, int,
|
||||
// int, int, int)` matches the 13 args below; all device pointers
|
||||
// come from `param_group_buffers` which validates ownership.
|
||||
unsafe {
|
||||
self.stream
|
||||
.launch_builder(&self.param_group_oracle_update)
|
||||
.arg(¶ms_ptr)
|
||||
.arg(&grads_ptr)
|
||||
.arg(&m_ptr)
|
||||
.arg(&v_ptr)
|
||||
.arg(&count_i32)
|
||||
.arg(&k_in_i32)
|
||||
.arg(&h_dim_i32)
|
||||
.arg(&scratch_dev)
|
||||
.arg(&weight_idx_i32)
|
||||
.arg(&adam_m_idx_i32)
|
||||
.arg(&adam_v_idx_i32)
|
||||
.arg(&wd_rate_idx_i32)
|
||||
.arg(&l1_idx_i32)
|
||||
.launch(cfg)
|
||||
.map_err(|e| MLError::ModelError(format!(
|
||||
"param_group_oracle_update[group={g_idx}] launch: {e}"
|
||||
)))?;
|
||||
}
|
||||
launched[g_idx] = true;
|
||||
}
|
||||
// Single end-of-loop sync — all launches are serialised on the same
|
||||
// stream by `__threadfence_system()` inside each kernel; one sync
|
||||
// ensures every scratch[5..38) write is host-visible before Phase 2.
|
||||
self.stream.synchronize()
|
||||
.map_err(|e| MLError::ModelError(format!("param_group_oracle sync: {e}")))?;
|
||||
|
||||
// ── Phase 2: Pearls A+D host-side update per output slot ──
|
||||
// Helper closure: read step_obs from scratch slot, apply
|
||||
// pearls_ad_update with the slot's Wiener state, write back.
|
||||
// Captured-by-reference into closure to keep code DRY across the
|
||||
// 4 (or 5 for group 0) outputs per group.
|
||||
let apply_pearls = |isv_idx: usize, scratch_idx: usize| {
|
||||
let step_obs = unsafe {
|
||||
std::ptr::read_volatile(self.producer_step_scratch_buf.host_ptr.add(scratch_idx))
|
||||
};
|
||||
// Same degenerate-zero short-circuit as A5/A6: skip the
|
||||
// ISV/Wiener mutation when step_obs == 0 (e.g., before the
|
||||
// first forward populates the buffer, or in the L1-lambda case
|
||||
// when the trunk weight matrix collapses to a single feature
|
||||
// and entropy_deficit hits the natural 0 of the formula).
|
||||
if step_obs == 0.0 { return; }
|
||||
|
||||
let wiener_offset = (isv_idx - SP4_SLOT_BASE) * 3;
|
||||
|
||||
// Safety: `isv_signals_pinned` is a mapped-pinned `*mut f32` of
|
||||
// length `ISV_TOTAL_DIM`; SP4 ISV slots are bounded
|
||||
// [SP4_SLOT_BASE, SP4_SLOT_END) = [131, 171).
|
||||
// `wiener_state_buf.host_ptr` is mapped-pinned `*mut f32` of
|
||||
// length `SP4_PRODUCER_COUNT * 3 = 141`; wiener_offset + 2 < 141.
|
||||
let prev_x_mean = unsafe {
|
||||
std::ptr::read_volatile(self.isv_signals_pinned.add(isv_idx))
|
||||
};
|
||||
let mut state = unsafe {
|
||||
let p = self.wiener_state_buf.host_ptr;
|
||||
WienerState {
|
||||
sample_var: std::ptr::read_volatile(p.add(wiener_offset)),
|
||||
diff_var: std::ptr::read_volatile(p.add(wiener_offset + 1)),
|
||||
x_lag: std::ptr::read_volatile(p.add(wiener_offset + 2)),
|
||||
}
|
||||
};
|
||||
let new_x_mean = pearls_ad_update(prev_x_mean, &mut state, step_obs);
|
||||
unsafe {
|
||||
std::ptr::write_volatile(self.isv_signals_pinned.add(isv_idx), new_x_mean);
|
||||
let wp = self.wiener_state_buf.host_ptr;
|
||||
std::ptr::write_volatile(wp.add(wiener_offset), state.sample_var);
|
||||
std::ptr::write_volatile(wp.add(wiener_offset + 1), state.diff_var);
|
||||
std::ptr::write_volatile(wp.add(wiener_offset + 2), state.x_lag);
|
||||
}
|
||||
};
|
||||
|
||||
for g_idx in 0..SP4_PARAM_GROUP_COUNT {
|
||||
if !launched[g_idx] { continue; }
|
||||
apply_pearls(weight_bound(g_idx), SCRATCH_BASE_W + g_idx);
|
||||
apply_pearls(adam_m_bound(g_idx), SCRATCH_BASE_M + g_idx);
|
||||
apply_pearls(adam_v_bound(g_idx), SCRATCH_BASE_V + g_idx);
|
||||
apply_pearls(wd_rate(g_idx), SCRATCH_BASE_WD + g_idx);
|
||||
if g_idx == 0 {
|
||||
apply_pearls(L1_LAMBDA_TRUNK_INDEX, SCRATCH_L1_TRUNK);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SP4 Layer A Task A7 helper: resolve the per-param-group buffer tuple
|
||||
/// for the Pearl B fused statistics oracle.
|
||||
///
|
||||
/// Returns `Some((params_ptr, grads_ptr, adam_m_ptr, adam_v_ptr, count,
|
||||
/// k_in, h_dim))` when the trainer owns all four buffers for the group;
|
||||
/// returns `None` for groups whose backing trainer (IQN, IQL-hi,
|
||||
/// IQL-lo, Attn, Curiosity) is not owned by `GpuDqnTrainer` (those live
|
||||
/// on `FusedTrainingCtx`).
|
||||
///
|
||||
/// `k_in`/`h_dim` are non-zero only for `ParamGroup::DqnTrunk` (group 0)
|
||||
/// — the trunk Pass-E entropy-deficit computation needs to know the
|
||||
/// `[h_dim × k_in]` shape of the trunk-input weight tensor (`w_a_h_s1`,
|
||||
/// the GRN Linear_a at `param_sizes[0]`). Other groups pass 0 / 0 and
|
||||
/// the kernel skips Pass E via `l1_lambda_scratch_idx = -1`.
|
||||
///
|
||||
/// For the 3 DQN-internal groups, slices are derived from
|
||||
/// `compute_param_sizes` + `padded_byte_offset`:
|
||||
/// - **Trunk** (tensors `[0..13)`): GRN h_s1 + h_s2 + γ/β pairs. Same
|
||||
/// range as the existing `trunk_adam_m_ptr` / `trunk_params_ptr`
|
||||
/// accessors.
|
||||
/// - **Value head** (tensors `[13..17)`): W_v1, B_v1, W_v2, B_v2.
|
||||
/// Same range as `value_adam_m_ptr`.
|
||||
/// - **Branch heads** (tensors `[17..33)`): W/B_b{0..3}fc +
|
||||
/// W/B_b{0..3}out concatenated. Same range as `branch_adam_m_ptr`.
|
||||
fn param_group_buffers(
|
||||
&self,
|
||||
group: crate::cuda_pipeline::sp4_isv_slots::ParamGroup,
|
||||
) -> Option<(u64, u64, u64, u64, usize, i32, i32)> {
|
||||
use crate::cuda_pipeline::sp4_isv_slots::ParamGroup;
|
||||
|
||||
let f32_sz = std::mem::size_of::<f32>() as u64;
|
||||
let param_sizes = compute_param_sizes(&self.config);
|
||||
|
||||
match group {
|
||||
ParamGroup::DqnTrunk => {
|
||||
// Trunk slice = tensors [0..13). k_in = state_dim_padded
|
||||
// (the input dim of `w_a_h_s1`); h_dim = shared_h1 (the
|
||||
// output dim of `w_a_h_s1`). Per `compute_param_sizes`,
|
||||
// tensor [0] (`w_a_h_s1`) has size `shared_h1 × s1_input_dim`
|
||||
// — that's our [h_dim × k_in] row-major layout.
|
||||
let count = self.trunk_param_count;
|
||||
let params_ptr = self.params_buf.raw_ptr();
|
||||
let grads_ptr = self.ptrs.grad_buf;
|
||||
let m_ptr = self.m_buf.raw_ptr();
|
||||
let v_ptr = self.v_buf.raw_ptr();
|
||||
// `s1_input_dim` is computed inside `compute_param_sizes`
|
||||
// (state_dim or bottleneck_dim + portfolio_dim depending on
|
||||
// bottleneck activation). Recompute here from
|
||||
// tensor [0]'s size: w_a_h_s1 = shared_h1 × s1_input_dim,
|
||||
// so s1_input_dim = param_sizes[0] / shared_h1.
|
||||
let h_dim = self.config.shared_h1 as i32;
|
||||
let s1_input_dim = if self.config.shared_h1 > 0 {
|
||||
(param_sizes[0] / self.config.shared_h1) as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let k_in = s1_input_dim;
|
||||
Some((params_ptr, grads_ptr, m_ptr, v_ptr, count, k_in, h_dim))
|
||||
}
|
||||
ParamGroup::DqnValue => {
|
||||
// Value-head slice = tensors [13..17). k_in/h_dim = 0
|
||||
// (Pass E gated to group 0 only).
|
||||
let start = padded_byte_offset(¶m_sizes, 13);
|
||||
let end = padded_byte_offset(¶m_sizes, 17);
|
||||
let count = ((end - start) / f32_sz) as usize;
|
||||
let params_ptr = self.params_buf.raw_ptr() + start;
|
||||
let grads_ptr = self.ptrs.grad_buf + start;
|
||||
let m_ptr = self.m_buf.raw_ptr() + start;
|
||||
let v_ptr = self.v_buf.raw_ptr() + start;
|
||||
Some((params_ptr, grads_ptr, m_ptr, v_ptr, count, 0, 0))
|
||||
}
|
||||
ParamGroup::DqnBranches => {
|
||||
// Branch-heads slice = tensors [17..33). k_in/h_dim = 0.
|
||||
let start = padded_byte_offset(¶m_sizes, 17);
|
||||
let end = padded_byte_offset(¶m_sizes, 33);
|
||||
let count = ((end - start) / f32_sz) as usize;
|
||||
let params_ptr = self.params_buf.raw_ptr() + start;
|
||||
let grads_ptr = self.ptrs.grad_buf + start;
|
||||
let m_ptr = self.m_buf.raw_ptr() + start;
|
||||
let v_ptr = self.v_buf.raw_ptr() + start;
|
||||
Some((params_ptr, grads_ptr, m_ptr, v_ptr, count, 0, 0))
|
||||
}
|
||||
ParamGroup::Iqn
|
||||
| ParamGroup::IqlHigh
|
||||
| ParamGroup::IqlLow
|
||||
| ParamGroup::Attn
|
||||
| ParamGroup::Curiosity => {
|
||||
// Owned by `FusedTrainingCtx`, not `GpuDqnTrainer`. Wiring
|
||||
// these requires either threading the buffer pointers
|
||||
// through the launcher signature or hoisting the launcher
|
||||
// onto `FusedTrainingCtx`. Both are follow-up changes
|
||||
// scoped beyond Task A7 — see the launcher's docstring.
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan C Phase 2 follow-up A.2 (2026-04-29): launch `q_drift_rate_ema_update`
|
||||
/// — single-thread single-block ISV producer for ISV[Q_DRIFT_RATE_INDEX=129].
|
||||
///
|
||||
@@ -10456,6 +10794,21 @@ impl GpuDqnTrainer {
|
||||
.map_err(|e| MLError::ModelError(format!("atom_pos_p99_update load: {e}")))?
|
||||
};
|
||||
|
||||
// SP4 Layer A Task A7: load param_group_oracle producer kernel
|
||||
// (cold-path). Single-block fused per-param-group oracle: reads
|
||||
// `(params, grads, adam_m, adam_v)` once and runs 4-5 sequential
|
||||
// passes producing WEIGHT/ADAM_M/ADAM_V p99 + WD_RATE + (group 0
|
||||
// only) L1_LAMBDA_TRUNK. Pearls A+D applied host-side per output
|
||||
// via `pearls_ad_update` (Task A3). Producer-only — no consumer
|
||||
// wired yet (Mech 9 still uses hardcoded `weight_clamp_max_abs`
|
||||
// config args; weight-decay/L1 unchanged).
|
||||
let param_group_oracle_update = {
|
||||
let module = stream.context().load_cubin(SP4_PARAM_GROUP_ORACLE_CUBIN.to_vec())
|
||||
.map_err(|e| MLError::ModelError(format!("sp4 param_group_oracle cubin load: {e}")))?;
|
||||
module.load_function("param_group_oracle_update")
|
||||
.map_err(|e| MLError::ModelError(format!("param_group_oracle_update load: {e}")))?
|
||||
};
|
||||
|
||||
// Plan C Phase 2 follow-up A.2: load q_drift_rate_ema kernel
|
||||
// (cold-path, per-epoch). Single-thread single-block ISV producer
|
||||
// for ISV[Q_DRIFT_RATE_INDEX=129]; consumer is `tau_update_kernel`.
|
||||
@@ -13416,6 +13769,7 @@ impl GpuDqnTrainer {
|
||||
h_s2_rms_ema_kernel,
|
||||
target_q_p99_update,
|
||||
atom_pos_p99_update,
|
||||
param_group_oracle_update,
|
||||
q_drift_rate_ema_kernel,
|
||||
fold_warmup_factor_kernel,
|
||||
grad_norm_fast_ema_pinned,
|
||||
|
||||
255
crates/ml/src/cuda_pipeline/param_group_oracle_kernel.cu
Normal file
255
crates/ml/src/cuda_pipeline/param_group_oracle_kernel.cu
Normal file
@@ -0,0 +1,255 @@
|
||||
// crates/ml/src/cuda_pipeline/param_group_oracle_kernel.cu
|
||||
//
|
||||
// SP4 Layer A Task A7 — Pearl B fused per-param-group statistics oracle.
|
||||
//
|
||||
// Single kernel per param-group reads `(params, grads, adam_m, adam_v)` once
|
||||
// and runs 4 sequential passes (5 for the trunk group):
|
||||
//
|
||||
// Pass A: WEIGHT_BOUND[group] = p99(|params|) via sp4_histogram_p99<256>
|
||||
// Pass B: ADAM_M_BOUND[group] = p99(|adam_m|) via sp4_histogram_p99<256>
|
||||
// Pass C: ADAM_V_BOUND[group] = p99(|adam_v|) via sp4_histogram_p99<256>
|
||||
// Pass D: WD_RATE[group] = |Σ w·g| / max(Σ w², EPS_DIV)
|
||||
// side products: mean|g|, mean|w|
|
||||
// Pass E: L1_LAMBDA[trunk] = (mean|g| / max(mean|w|, EPS_DIV))
|
||||
// × (log K − H) / log K
|
||||
// (Group 0 only; H = Shannon entropy of
|
||||
// per-feature gradient L2 norms)
|
||||
//
|
||||
// Pearl B 4× memory-bandwidth reduction vs four naive single-stat producer
|
||||
// kernels: each of params/grads/m/v is read once across all 4-5 outputs
|
||||
// rather than four times.
|
||||
//
|
||||
// Caller contract (same as the rest of the SP4 producer family):
|
||||
// - grid_dim = (1, 1, 1)
|
||||
// - block_dim = (256, 1, 1)
|
||||
// - dynamic shared memory ≥ (256/32) × 256 × sizeof(int) = 8192 bytes
|
||||
// (matches `sp4_histogram_p99.cuh`'s per-warp tile contract; Pass D
|
||||
// reuses the same dynamic shmem region after Pass C finishes).
|
||||
//
|
||||
// The trunk-only Pass E uses statically allocated `__shared__ float
|
||||
// s_g_feat[K_IN_MAX]` for per-feature gradient L2 norms; K_IN_MAX is set to
|
||||
// 1024 to cover any plausible trunk input dimension (production trunk is
|
||||
// 40 + OFI/TLOB widening, well under 1024).
|
||||
//
|
||||
// Cold-path launch — NOT in the captured graph for now. Task A10 may
|
||||
// relocate select producers into the captured graph; this kernel mirrors
|
||||
// the launch-shape that A5/A6 established so the migration is structural-
|
||||
// only at that point.
|
||||
|
||||
#include "sp4_histogram_p99.cuh"
|
||||
|
||||
#define EPS_DIV 1.0e-8f
|
||||
|
||||
// Cap on Pass-E per-feature shared scratch. Production trunk K_in is much
|
||||
// smaller (40 base features; widened with OFI/TLOB to <128). 1024 is a
|
||||
// generous ceiling that still fits comfortably in a single block's static
|
||||
// shmem budget alongside `s_bins[256]` and `s_step_max`.
|
||||
#define K_IN_MAX 1024
|
||||
|
||||
// Helper: block-wide tree-reduce of `val` across the 256 threads via
|
||||
// `s_buf` (already-allocated `__shared__ float[256]` storage). Returns the
|
||||
// reduced value to thread 0; other threads' return value is undefined.
|
||||
// Uses `__syncthreads()` between halves; no atomicAdd per
|
||||
// `feedback_no_atomicadd`.
|
||||
__device__ __forceinline__ float block_reduce_sum_256(float val, float* s_buf, int tid) {
|
||||
s_buf[tid] = val;
|
||||
__syncthreads();
|
||||
for (int s = 128; s > 0; s >>= 1) {
|
||||
if (tid < s) {
|
||||
s_buf[tid] += s_buf[tid + s];
|
||||
}
|
||||
__syncthreads();
|
||||
}
|
||||
return s_buf[0];
|
||||
}
|
||||
|
||||
extern "C" __global__ void param_group_oracle_update(
|
||||
const float* __restrict__ params,
|
||||
const float* __restrict__ grads,
|
||||
const float* __restrict__ adam_m,
|
||||
const float* __restrict__ adam_v,
|
||||
int count, /* slice length (elements) */
|
||||
int k_in, /* trunk input dim (Pass E only; 0 for non-trunk) */
|
||||
int h_dim, /* trunk output dim of layer (Pass E only) */
|
||||
float* __restrict__ scratch_buf,
|
||||
int weight_scratch_idx,
|
||||
int adam_m_scratch_idx,
|
||||
int adam_v_scratch_idx,
|
||||
int wd_rate_scratch_idx,
|
||||
int l1_lambda_scratch_idx /* -1 if not group 0 */
|
||||
) {
|
||||
if (blockIdx.x != 0) return;
|
||||
const int tid = threadIdx.x;
|
||||
|
||||
// ── Pass A: WEIGHT_BOUND[group] = p99(|params|) ─────────────────────
|
||||
{
|
||||
float p99 = sp4_histogram_p99<256>(params, count);
|
||||
if (tid == 0) {
|
||||
scratch_buf[weight_scratch_idx] = p99;
|
||||
__threadfence_system(); // make host-mapped write visible
|
||||
}
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// ── Pass B: ADAM_M_BOUND[group] = p99(|adam_m|) ─────────────────────
|
||||
{
|
||||
float p99 = sp4_histogram_p99<256>(adam_m, count);
|
||||
if (tid == 0) {
|
||||
scratch_buf[adam_m_scratch_idx] = p99;
|
||||
__threadfence_system();
|
||||
}
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// ── Pass C: ADAM_V_BOUND[group] = p99(|adam_v|) ─────────────────────
|
||||
{
|
||||
float p99 = sp4_histogram_p99<256>(adam_v, count);
|
||||
if (tid == 0) {
|
||||
scratch_buf[adam_v_scratch_idx] = p99;
|
||||
__threadfence_system();
|
||||
}
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// ── Pass D: WD_RATE[group] = |Σ w·g| / max(Σ w², EPS_DIV) ──────────
|
||||
// Side products: mean|g| = Σ|g|/count, mean|w| = Σ|w|/count for Pass E.
|
||||
//
|
||||
// 4 parallel block-wide reductions: each thread maintains 4 register
|
||||
// accumulators (sum_wg, sum_w2, sum_abs_g, sum_abs_w), then we reduce
|
||||
// each tree-style through reused shared memory.
|
||||
//
|
||||
// Reuses the histogram dynamic shmem region (`s_warp_tiles`) as the
|
||||
// per-thread accumulator buffer; the Pass C `__syncthreads` above
|
||||
// ensures the previous histogram computation has fully drained before
|
||||
// we overwrite. We reinterpret `s_warp_tiles` (declared as `int*` in
|
||||
// `sp4_histogram_p99.cuh`) via `float*` cast: 8192 bytes = 2048 floats
|
||||
// ≥ 256 floats × 4 accumulators = 1024 floats. Safe.
|
||||
extern __shared__ int s_warp_tiles[];
|
||||
float* s_buf = reinterpret_cast<float*>(s_warp_tiles);
|
||||
|
||||
float t_sum_wg = 0.0f;
|
||||
float t_sum_w2 = 0.0f;
|
||||
float t_sum_abs_g = 0.0f;
|
||||
float t_sum_abs_w = 0.0f;
|
||||
|
||||
for (int i = tid; i < count; i += 256) {
|
||||
float w = params[i];
|
||||
float g = grads[i];
|
||||
t_sum_wg += w * g;
|
||||
t_sum_w2 += w * w;
|
||||
t_sum_abs_g += fabsf(g);
|
||||
t_sum_abs_w += fabsf(w);
|
||||
}
|
||||
|
||||
// Four sequential block-reduces sharing the same 256-float shmem region.
|
||||
float sum_wg = block_reduce_sum_256(t_sum_wg, s_buf, tid);
|
||||
float sum_w2 = block_reduce_sum_256(t_sum_w2, s_buf, tid);
|
||||
float sum_abs_g = block_reduce_sum_256(t_sum_abs_g, s_buf, tid);
|
||||
float sum_abs_w = block_reduce_sum_256(t_sum_abs_w, s_buf, tid);
|
||||
|
||||
// Stash mean|g| / mean|w| in static shmem so Pass E (group 0) sees them
|
||||
// after any further `__syncthreads`. We use `s_bins[]` (also int) by
|
||||
// reinterpret-cast — same trick as `sp4_histogram_p99.cuh`'s Pass 1
|
||||
// does for max-reduce. With 256 ints available we only need 2 slots.
|
||||
__shared__ float s_mean_abs_g;
|
||||
__shared__ float s_mean_abs_w;
|
||||
__shared__ float s_wd_rate;
|
||||
if (tid == 0) {
|
||||
const float inv_count = (count > 0) ? (1.0f / (float)count) : 0.0f;
|
||||
s_mean_abs_g = sum_abs_g * inv_count;
|
||||
s_mean_abs_w = sum_abs_w * inv_count;
|
||||
const float denom = fmaxf(sum_w2, EPS_DIV);
|
||||
s_wd_rate = fabsf(sum_wg) / denom;
|
||||
scratch_buf[wd_rate_scratch_idx] = s_wd_rate;
|
||||
__threadfence_system();
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// ── Pass E (group 0 trunk only): L1_LAMBDA_TRUNK ────────────────────
|
||||
// Per-feature gradient L2 norm `||g_w[:,i]||` for i ∈ [0, k_in).
|
||||
// Trunk weight matrix layout per `compute_param_sizes`: tensor 0 is
|
||||
// `W_h_s1 [k_in × h_dim]` row-major, so feature `i` occupies the
|
||||
// `i`-th column → strided indices `[i, k_in + i, 2*k_in + i, ...,
|
||||
// (h_dim-1)*k_in + i]` in row-major. We compute the per-feature L2
|
||||
// norm by squaring + summing across `h_dim` then sqrt.
|
||||
//
|
||||
// Feature normalization → discrete distribution; Shannon entropy H
|
||||
// → entropy_deficit = (log K − H) / log K ∈ [0, 1] where K = k_in
|
||||
// (1 = perfectly concentrated on one feature; 0 = uniform). The L1
|
||||
// λ for the trunk is then
|
||||
//
|
||||
// L1_LAMBDA_TRUNK = (mean|g| / max(mean|w|, EPS_DIV)) × deficit
|
||||
//
|
||||
// — magnitude scale × concentration. Single thread (thread 0) does the
|
||||
// normalize + entropy computation; per-feature L2 squares are produced
|
||||
// by the block in parallel.
|
||||
if (l1_lambda_scratch_idx >= 0 && k_in > 0 && h_dim > 0 && k_in <= K_IN_MAX) {
|
||||
__shared__ float s_g_feat[K_IN_MAX];
|
||||
|
||||
// Zero the per-feature scratch (cooperative across the block).
|
||||
for (int i = tid; i < k_in; i += 256) {
|
||||
s_g_feat[i] = 0.0f;
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// Each thread accumulates squared gradients for a strided slice
|
||||
// of the [k_in × h_dim] grad_w matrix (= grads[0..k_in*h_dim)).
|
||||
// Per-feature accumulation is naturally race-free across threads
|
||||
// that hit different (i, h) cells; collisions on the same `i`
|
||||
// across threads are resolved by reducing through shared memory
|
||||
// — but to avoid atomicAdd, we instead serialize per-i: threads
|
||||
// collaborate by reduction across `h ∈ [0, h_dim)` for each `i`
|
||||
// in turn. That's k_in × (h_dim parallel reduce) = k_in × ~h_dim
|
||||
// ops; for k_in ≤ 1024 and h_dim ≤ ~1024 this is fine for a cold-
|
||||
// path producer.
|
||||
const int kh = k_in * h_dim;
|
||||
|
||||
// Cooperative thread-strided accumulation into s_g_feat[i] using
|
||||
// sequential synchronisation per feature. Avoids atomicAdd while
|
||||
// keeping the cost linear in k_in × h_dim.
|
||||
for (int i = 0; i < k_in; ++i) {
|
||||
float partial = 0.0f;
|
||||
for (int h = tid; h < h_dim; h += 256) {
|
||||
int idx = h * k_in + i;
|
||||
if (idx < kh) {
|
||||
float gv = grads[idx];
|
||||
partial += gv * gv;
|
||||
}
|
||||
}
|
||||
float sq_sum = block_reduce_sum_256(partial, s_buf, tid);
|
||||
if (tid == 0) {
|
||||
s_g_feat[i] = sqrtf(sq_sum);
|
||||
}
|
||||
__syncthreads();
|
||||
}
|
||||
|
||||
// Single-thread normalization + Shannon entropy.
|
||||
if (tid == 0) {
|
||||
float total = 0.0f;
|
||||
for (int i = 0; i < k_in; ++i) total += s_g_feat[i];
|
||||
|
||||
float deficit = 0.0f;
|
||||
if (total > EPS_DIV && k_in > 1) {
|
||||
const float log_k = logf((float)k_in);
|
||||
float entropy = 0.0f;
|
||||
for (int i = 0; i < k_in; ++i) {
|
||||
float p = s_g_feat[i] / total;
|
||||
if (p > EPS_DIV) {
|
||||
entropy -= p * logf(p);
|
||||
}
|
||||
}
|
||||
deficit = (log_k - entropy) / log_k;
|
||||
if (deficit < 0.0f) deficit = 0.0f;
|
||||
if (deficit > 1.0f) deficit = 1.0f;
|
||||
}
|
||||
|
||||
const float mean_abs_g = s_mean_abs_g;
|
||||
const float mean_abs_w = s_mean_abs_w;
|
||||
const float scale = mean_abs_g / fmaxf(mean_abs_w, EPS_DIV);
|
||||
const float l1_lambda = scale * deficit;
|
||||
|
||||
scratch_buf[l1_lambda_scratch_idx] = l1_lambda;
|
||||
__threadfence_system();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,3 +568,433 @@ fn sp4_atom_pos_p99_per_branch_writes_distinct_isv_slots() {
|
||||
assert_eq!(atom_pos_bound(branch), ATOM_POS_BOUND_BASE + branch);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SP4 Task A7: Pearl B fused per-param-group oracle kernel ──────────────────
|
||||
|
||||
/// Test-only cubin for the SP4 Pearl B fused per-param-group oracle. The same
|
||||
/// cubin is loaded by `GpuDqnTrainer::new` in production via
|
||||
/// `SP4_PARAM_GROUP_ORACLE_CUBIN`. This kernel-direct test bypasses the full
|
||||
/// trainer harness and exercises the kernel's 4-5 fused passes (max-reduce
|
||||
/// + p99 histogram per (params, adam_m, adam_v); `Σ w·g / Σ w²` weight-decay
|
||||
/// rate; trunk-only L1-lambda from gradient-direction entropy deficit) on
|
||||
/// controlled distributions with analytically-known ground truth.
|
||||
const SP4_PARAM_GROUP_ORACLE_CUBIN: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/param_group_oracle_kernel.cubin"));
|
||||
|
||||
/// Resolve the `param_group_oracle_update` function handle from its cubin.
|
||||
fn load_sp4_param_group_oracle_kernel(stream: &Arc<CudaStream>) -> CudaFunction {
|
||||
let module = stream
|
||||
.context()
|
||||
.load_cubin(SP4_PARAM_GROUP_ORACLE_CUBIN.to_vec())
|
||||
.expect("load param_group_oracle_kernel cubin");
|
||||
module
|
||||
.load_function("param_group_oracle_update")
|
||||
.expect("load param_group_oracle_update function")
|
||||
}
|
||||
|
||||
/// Box-Muller deterministic |N(0, σ²)| sample generator. Produces folded
|
||||
/// half-normal magnitudes so the per-pass p99 lookups are well-spread across
|
||||
/// the 256 linear-histogram bins (avoids the lane-collision pathology
|
||||
/// flagged in `feedback_no_atomicadd.md`).
|
||||
fn boxmuller_abs_normal(seed: u64, n: usize, sigma: f32) -> Vec<f32> {
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
let u1: f32 = rng.gen_range(1e-8_f32..1.0_f32);
|
||||
let u2: f32 = rng.gen_range(0.0_f32..1.0_f32);
|
||||
let z = (-2.0_f32 * u1.ln()).sqrt() * (2.0_f32 * std::f32::consts::PI * u2).cos();
|
||||
(z * sigma).abs()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sample-sorted p99 of `xs` (positive samples). Mirrors the reference
|
||||
/// computation used by the Task A4/A5/A6 tests — `sorted[(n * 99) / 100]`.
|
||||
fn sample_p99(xs: &[f32]) -> f32 {
|
||||
let mut sorted: Vec<f32> = xs.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).expect("samples are finite"));
|
||||
sorted[(sorted.len() * 99) / 100]
|
||||
}
|
||||
|
||||
/// Launch the production `param_group_oracle_update` kernel kernel-direct
|
||||
/// with controlled `(params, grads, adam_m, adam_v)` slices and write into a
|
||||
/// 47-slot scratch buffer at the production-layout slot offsets for group
|
||||
/// `g_idx`. Returns the host-readable scratch buffer for assertion.
|
||||
///
|
||||
/// `k_in`/`h_dim` non-zero only for `g_idx == 0` — Pass E (L1 lambda)
|
||||
/// gating on `l1_lambda_scratch_idx >= 0`. For other groups, pass 0/0/-1.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn launch_sp4_param_group_oracle_for_group(
|
||||
stream: &Arc<CudaStream>,
|
||||
g_idx: usize,
|
||||
params_host: &[f32],
|
||||
grads_host: &[f32],
|
||||
adam_m_host: &[f32],
|
||||
adam_v_host: &[f32],
|
||||
k_in: i32,
|
||||
h_dim: i32,
|
||||
) -> Vec<f32> {
|
||||
const SP4_PRODUCER_COUNT: usize = 47;
|
||||
const SCRATCH_BASE_W: usize = 5;
|
||||
const SCRATCH_BASE_M: usize = 13;
|
||||
const SCRATCH_BASE_V: usize = 21;
|
||||
const SCRATCH_BASE_WD: usize = 29;
|
||||
const SCRATCH_L1_TRUNK: usize = 37;
|
||||
const SHARED_BYTES: u32 = (256 / 32) * 256 * 4;
|
||||
|
||||
let n = params_host.len();
|
||||
assert_eq!(grads_host.len(), n);
|
||||
assert_eq!(adam_m_host.len(), n);
|
||||
assert_eq!(adam_v_host.len(), n);
|
||||
|
||||
let kernel = load_sp4_param_group_oracle_kernel(stream);
|
||||
|
||||
// Safety: CUDA context active on this thread (resolved via stream).
|
||||
let params_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc params");
|
||||
let grads_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc grads");
|
||||
let adam_m_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc adam_m");
|
||||
let adam_v_buf = unsafe { MappedF32Buffer::new(n) }.expect("alloc adam_v");
|
||||
params_buf.write_from_slice(params_host);
|
||||
grads_buf.write_from_slice(grads_host);
|
||||
adam_m_buf.write_from_slice(adam_m_host);
|
||||
adam_v_buf.write_from_slice(adam_v_host);
|
||||
|
||||
let scratch_buf =
|
||||
unsafe { MappedF32Buffer::new(SP4_PRODUCER_COUNT) }.expect("alloc scratch");
|
||||
|
||||
let count_i32 = i32::try_from(n).expect("count fits in i32");
|
||||
let weight_idx = (SCRATCH_BASE_W + g_idx) as i32;
|
||||
let adam_m_idx = (SCRATCH_BASE_M + g_idx) as i32;
|
||||
let adam_v_idx = (SCRATCH_BASE_V + g_idx) as i32;
|
||||
let wd_rate_idx = (SCRATCH_BASE_WD + g_idx) as i32;
|
||||
let l1_idx: i32 = if g_idx == 0 { SCRATCH_L1_TRUNK as i32 } else { -1 };
|
||||
|
||||
let params_dev = params_buf.dev_ptr;
|
||||
let grads_dev = grads_buf.dev_ptr;
|
||||
let adam_m_dev = adam_m_buf.dev_ptr;
|
||||
let adam_v_dev = adam_v_buf.dev_ptr;
|
||||
let scratch_dev = scratch_buf.dev_ptr;
|
||||
|
||||
unsafe {
|
||||
stream
|
||||
.launch_builder(&kernel)
|
||||
.arg(¶ms_dev)
|
||||
.arg(&grads_dev)
|
||||
.arg(&adam_m_dev)
|
||||
.arg(&adam_v_dev)
|
||||
.arg(&count_i32)
|
||||
.arg(&k_in)
|
||||
.arg(&h_dim)
|
||||
.arg(&scratch_dev)
|
||||
.arg(&weight_idx)
|
||||
.arg(&adam_m_idx)
|
||||
.arg(&adam_v_idx)
|
||||
.arg(&wd_rate_idx)
|
||||
.arg(&l1_idx)
|
||||
.launch(LaunchConfig {
|
||||
grid_dim: (1, 1, 1),
|
||||
block_dim: (256, 1, 1),
|
||||
shared_mem_bytes: SHARED_BYTES,
|
||||
})
|
||||
.expect("launch param_group_oracle_update");
|
||||
}
|
||||
|
||||
stream
|
||||
.synchronize()
|
||||
.expect("synchronize after param_group_oracle_update launch");
|
||||
|
||||
scratch_buf.read_all()
|
||||
}
|
||||
|
||||
/// SP4 Task A7 unit test: parameterised across all 8 param-groups. Each
|
||||
/// group launches the production kernel kernel-direct with a distinct
|
||||
/// `(seed, sigma_w, sigma_g, sigma_m, sigma_v, count)` tuple — the per-group
|
||||
/// scaling factors span 1×..1000× across the 4 buffers so a kernel bug
|
||||
/// that mixes up buffer arguments would land in the wrong scratch slot at
|
||||
/// the wrong scale and trip a per-output rel_err assertion.
|
||||
///
|
||||
/// **Distributions:** 4096 deterministic |N(0, σ²)| Box-Muller samples per
|
||||
/// buffer per group (well-spread → no lane-collision pathology). Per-buffer
|
||||
/// seeds: `0xA7_<group>_<buf>_F1_57`. Per-group `count` chosen to keep the
|
||||
/// test runtime small while exceeding 1024 (the kernel's per-thread strided
|
||||
/// reduction needs ≥ 1 stride for thread-256's accumulator to be exercised).
|
||||
///
|
||||
/// **Tolerances:**
|
||||
/// - WEIGHT/M/V p99: 5% relative error (linear-bin quantization 0.4% +
|
||||
/// lane collision <0.012% + finite-sample p99 jitter ≈0.5% at n=4096
|
||||
/// ≈ <2% true; 5% is generous).
|
||||
/// - WD_RATE: 2% relative error (analytical formula `|Σ w·g| / Σ w²`
|
||||
/// reduces in `f32` with no histogram quantization; 2% headroom for
|
||||
/// summation order non-determinism).
|
||||
/// - L1 (group 0 only): 5% relative error — composes (mean|g|/mean|w|)
|
||||
/// × deficit, both `f32` reductions plus a per-feature L2 norm chain
|
||||
/// with two `logf` calls. Tolerance matches the p99 family.
|
||||
///
|
||||
/// **Production launcher contract validated:**
|
||||
/// - The kernel writes step observations to scratch slots
|
||||
/// `5 + g`, `13 + g`, `21 + g`, `29 + g` per group (and slot 37 for
|
||||
/// group 0's L1 trunk lambda).
|
||||
/// - All non-target slots remain zero (guards against the
|
||||
/// `2fb30f098`-class write-cascade where a kernel silently writes
|
||||
/// outside its assigned slot).
|
||||
/// - SP4 ISV slot conventions hold: `weight_bound(g)`, `adam_m_bound(g)`,
|
||||
/// `adam_v_bound(g)`, `wd_rate(g)`, `L1_LAMBDA_TRUNK_INDEX = 170`.
|
||||
#[test]
|
||||
#[ignore = "requires GPU"]
|
||||
fn sp4_param_group_oracle_per_group_writes_distinct_isv_slots() {
|
||||
use ml::cuda_pipeline::sp4_isv_slots::{
|
||||
adam_m_bound, adam_v_bound, weight_bound, wd_rate, ParamGroup,
|
||||
ADAM_M_BOUND_BASE, ADAM_V_BOUND_BASE, L1_LAMBDA_TRUNK_INDEX,
|
||||
SP4_PARAM_GROUP_COUNT, SP4_SLOT_BASE, WD_RATE_BASE, WEIGHT_BOUND_BASE,
|
||||
};
|
||||
|
||||
const SP4_PRODUCER_COUNT: usize = 47;
|
||||
const SCRATCH_BASE_W: usize = 5;
|
||||
const SCRATCH_BASE_M: usize = 13;
|
||||
const SCRATCH_BASE_V: usize = 21;
|
||||
const SCRATCH_BASE_WD: usize = 29;
|
||||
const SCRATCH_L1_TRUNK: usize = 37;
|
||||
const N: usize = 4096;
|
||||
|
||||
// Per-group input shape:
|
||||
// group 0 (DqnTrunk): k_in × h_dim = 64 × 64 = 4096 (Pass E exercised)
|
||||
// groups 1..7: flat 4096 element slice (Pass E gated off)
|
||||
// sigmas chosen so p99(|N(0,σ²)|) lands at ~2.576 × σ:
|
||||
// sigma_w = 0.1 × (g+1) → WEIGHT_BOUND[g] ≈ 0.258 × (g+1)
|
||||
// sigma_g = 0.01 × (g+1) → grads at 1/10 weight scale
|
||||
// sigma_m = 0.05 × (g+1) → adam_m at 1/2 weight scale
|
||||
// sigma_v = 0.001 × (g+1) → adam_v second-moment small (mostly squared g)
|
||||
let stream = make_test_stream();
|
||||
|
||||
// Layout invariants — guard against off-by-one drift in the SP4 slot map
|
||||
// that would silently route writes to wrong ISV indices.
|
||||
assert_eq!(SP4_PARAM_GROUP_COUNT, 8);
|
||||
assert_eq!(SP4_SLOT_BASE, 131);
|
||||
assert_eq!(WEIGHT_BOUND_BASE, 136);
|
||||
assert_eq!(ADAM_M_BOUND_BASE, 144);
|
||||
assert_eq!(ADAM_V_BOUND_BASE, 152);
|
||||
assert_eq!(WD_RATE_BASE, 160);
|
||||
assert_eq!(L1_LAMBDA_TRUNK_INDEX, 170);
|
||||
for g in 0..SP4_PARAM_GROUP_COUNT {
|
||||
assert_eq!(weight_bound(g), 136 + g);
|
||||
assert_eq!(adam_m_bound(g), 144 + g);
|
||||
assert_eq!(adam_v_bound(g), 152 + g);
|
||||
assert_eq!(wd_rate(g), 160 + g);
|
||||
}
|
||||
// ParamGroup::ALL ordering matches our seed-keying.
|
||||
let groups = ParamGroup::ALL;
|
||||
assert_eq!(groups.len(), SP4_PARAM_GROUP_COUNT);
|
||||
|
||||
for g_idx in 0..SP4_PARAM_GROUP_COUNT {
|
||||
let scale = (g_idx as f32) + 1.0;
|
||||
let sigma_w = 0.1 * scale;
|
||||
let sigma_g = 0.01 * scale;
|
||||
let sigma_m = 0.05 * scale;
|
||||
let sigma_v = 0.001 * scale;
|
||||
|
||||
let seed_w = 0xA7_5A_F1_57_u64 ^ ((g_idx as u64) << 32) ^ 0x01;
|
||||
let seed_g = 0xA7_5A_F1_57_u64 ^ ((g_idx as u64) << 32) ^ 0x02;
|
||||
let seed_m = 0xA7_5A_F1_57_u64 ^ ((g_idx as u64) << 32) ^ 0x03;
|
||||
let seed_v = 0xA7_5A_F1_57_u64 ^ ((g_idx as u64) << 32) ^ 0x04;
|
||||
|
||||
let params_host = boxmuller_abs_normal(seed_w, N, sigma_w);
|
||||
// grads: signed (mix of positive/negative) so Σ w·g doesn't trivially
|
||||
// collapse to Σ w·|g|. Reuse boxmuller for magnitudes, alternate
|
||||
// signs by index parity → roughly zero-mean but non-trivial Σ w·g.
|
||||
let grads_mag = boxmuller_abs_normal(seed_g, N, sigma_g);
|
||||
let grads_host: Vec<f32> = grads_mag
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &g)| if i % 2 == 0 { g } else { -g })
|
||||
.collect();
|
||||
let adam_m_host = boxmuller_abs_normal(seed_m, N, sigma_m);
|
||||
let adam_v_host = boxmuller_abs_normal(seed_v, N, sigma_v);
|
||||
|
||||
// For group 0 (DqnTrunk) the kernel uses k_in × h_dim = N total to
|
||||
// drive Pass E. We pick k_in = 64, h_dim = 64 so k_in × h_dim = N.
|
||||
let (k_in, h_dim) = if g_idx == 0 { (64_i32, 64_i32) } else { (0_i32, 0_i32) };
|
||||
|
||||
let scratch = launch_sp4_param_group_oracle_for_group(
|
||||
&stream,
|
||||
g_idx,
|
||||
¶ms_host,
|
||||
&grads_host,
|
||||
&adam_m_host,
|
||||
&adam_v_host,
|
||||
k_in,
|
||||
h_dim,
|
||||
);
|
||||
|
||||
// ── Slot-write isolation: every slot outside this group's targets
|
||||
// must remain zero. Targets are [5+g, 13+g, 21+g, 29+g] and (for
|
||||
// g==0) slot 37.
|
||||
let mut is_target = [false; SP4_PRODUCER_COUNT];
|
||||
is_target[SCRATCH_BASE_W + g_idx] = true;
|
||||
is_target[SCRATCH_BASE_M + g_idx] = true;
|
||||
is_target[SCRATCH_BASE_V + g_idx] = true;
|
||||
is_target[SCRATCH_BASE_WD + g_idx] = true;
|
||||
if g_idx == 0 {
|
||||
is_target[SCRATCH_L1_TRUNK] = true;
|
||||
}
|
||||
for (i, &v) in scratch.iter().enumerate() {
|
||||
if !is_target[i] {
|
||||
assert_eq!(
|
||||
v, 0.0,
|
||||
"param_group_oracle[group={g_idx}] wrote to non-target scratch[{i}] = {v}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass A: WEIGHT_BOUND[g] p99 ──
|
||||
let true_p99_w = sample_p99(¶ms_host);
|
||||
let kernel_p99_w = scratch[SCRATCH_BASE_W + g_idx];
|
||||
let rel_w = ((kernel_p99_w - true_p99_w) / true_p99_w).abs();
|
||||
println!(
|
||||
"SP4 param_group[{g_idx}] WEIGHT — true_p99={true_p99_w:.5}, kernel_p99={kernel_p99_w:.5}, rel_err={rel_w:.5}",
|
||||
);
|
||||
assert!(kernel_p99_w > 0.0, "WEIGHT p99 must be positive");
|
||||
assert!(
|
||||
rel_w < 0.05,
|
||||
"group {g_idx} WEIGHT rel_err {rel_w} > 5% (kernel={kernel_p99_w}, true={true_p99_w})",
|
||||
);
|
||||
|
||||
// ── Pass B: ADAM_M_BOUND[g] p99 ──
|
||||
let true_p99_m = sample_p99(&adam_m_host);
|
||||
let kernel_p99_m = scratch[SCRATCH_BASE_M + g_idx];
|
||||
let rel_m = ((kernel_p99_m - true_p99_m) / true_p99_m).abs();
|
||||
println!(
|
||||
"SP4 param_group[{g_idx}] ADAM_M — true_p99={true_p99_m:.5}, kernel_p99={kernel_p99_m:.5}, rel_err={rel_m:.5}",
|
||||
);
|
||||
assert!(kernel_p99_m > 0.0, "ADAM_M p99 must be positive");
|
||||
assert!(
|
||||
rel_m < 0.05,
|
||||
"group {g_idx} ADAM_M rel_err {rel_m} > 5% (kernel={kernel_p99_m}, true={true_p99_m})",
|
||||
);
|
||||
|
||||
// ── Pass C: ADAM_V_BOUND[g] p99 ──
|
||||
let true_p99_v = sample_p99(&adam_v_host);
|
||||
let kernel_p99_v = scratch[SCRATCH_BASE_V + g_idx];
|
||||
let rel_v = ((kernel_p99_v - true_p99_v) / true_p99_v).abs();
|
||||
println!(
|
||||
"SP4 param_group[{g_idx}] ADAM_V — true_p99={true_p99_v:.5}, kernel_p99={kernel_p99_v:.5}, rel_err={rel_v:.5}",
|
||||
);
|
||||
assert!(kernel_p99_v > 0.0, "ADAM_V p99 must be positive");
|
||||
assert!(
|
||||
rel_v < 0.05,
|
||||
"group {g_idx} ADAM_V rel_err {rel_v} > 5% (kernel={kernel_p99_v}, true={true_p99_v})",
|
||||
);
|
||||
|
||||
// ── Pass D: WD_RATE[g] = |Σ w·g| / max(Σ w², EPS_DIV) ──
|
||||
// Analytically computed in f64 host-side then cast to f32 to bound
|
||||
// accumulation order error. The kernel does the same reduction in
|
||||
// f32 only, so a 2% tolerance comfortably covers summation drift
|
||||
// across 4096 elements.
|
||||
let mut sum_wg: f64 = 0.0;
|
||||
let mut sum_w2: f64 = 0.0;
|
||||
for i in 0..N {
|
||||
let w = params_host[i] as f64;
|
||||
let g = grads_host[i] as f64;
|
||||
sum_wg += w * g;
|
||||
sum_w2 += w * w;
|
||||
}
|
||||
const EPS_DIV: f64 = 1.0e-8;
|
||||
let true_wd_rate = (sum_wg.abs() / sum_w2.max(EPS_DIV)) as f32;
|
||||
let kernel_wd_rate = scratch[SCRATCH_BASE_WD + g_idx];
|
||||
let rel_wd = ((kernel_wd_rate - true_wd_rate) / true_wd_rate).abs();
|
||||
println!(
|
||||
"SP4 param_group[{g_idx}] WD_RATE — true={true_wd_rate:.6e}, kernel={kernel_wd_rate:.6e}, rel_err={rel_wd:.5}",
|
||||
);
|
||||
assert!(
|
||||
kernel_wd_rate > 0.0,
|
||||
"WD_RATE must be positive (signed grads × signed-aligned weights yields non-zero |Σ w·g|)",
|
||||
);
|
||||
assert!(
|
||||
rel_wd < 0.02,
|
||||
"group {g_idx} WD_RATE rel_err {rel_wd} > 2% (kernel={kernel_wd_rate}, true={true_wd_rate})",
|
||||
);
|
||||
|
||||
// ── Pass E (group 0 only): L1_LAMBDA_TRUNK ──
|
||||
// Analytical reference computes per-feature L2 norms of the
|
||||
// [h_dim × k_in] gradient matrix, normalises them to a discrete
|
||||
// distribution, then computes (mean|g| / max(mean|w|, EPS_DIV)) ×
|
||||
// (log K − H) / log K. Mirrors the kernel's Pass E exactly.
|
||||
if g_idx == 0 {
|
||||
let k_in_us = k_in as usize;
|
||||
let h_dim_us = h_dim as usize;
|
||||
assert_eq!(k_in_us * h_dim_us, N);
|
||||
|
||||
// Per-feature L2 norms (column-wise across the [h_dim × k_in]
|
||||
// row-major grad matrix → feature `i` indexed at
|
||||
// [h*k_in + i] for h ∈ 0..h_dim).
|
||||
let mut g_feat: Vec<f64> = vec![0.0; k_in_us];
|
||||
for h in 0..h_dim_us {
|
||||
for i in 0..k_in_us {
|
||||
let gv = grads_host[h * k_in_us + i] as f64;
|
||||
g_feat[i] += gv * gv;
|
||||
}
|
||||
}
|
||||
for v in g_feat.iter_mut() {
|
||||
*v = v.sqrt();
|
||||
}
|
||||
let total: f64 = g_feat.iter().sum();
|
||||
let mut deficit: f64 = 0.0;
|
||||
if total > EPS_DIV && k_in_us > 1 {
|
||||
let log_k = (k_in_us as f64).ln();
|
||||
let mut entropy = 0.0_f64;
|
||||
for &v in g_feat.iter() {
|
||||
let p = v / total;
|
||||
if p > EPS_DIV {
|
||||
entropy -= p * p.ln();
|
||||
}
|
||||
}
|
||||
deficit = ((log_k - entropy) / log_k).clamp(0.0, 1.0);
|
||||
}
|
||||
let mean_abs_g: f64 =
|
||||
grads_host.iter().map(|&g| (g as f64).abs()).sum::<f64>() / (N as f64);
|
||||
let mean_abs_w: f64 =
|
||||
params_host.iter().map(|&w| (w as f64).abs()).sum::<f64>() / (N as f64);
|
||||
let scale = mean_abs_g / mean_abs_w.max(EPS_DIV);
|
||||
let true_l1 = (scale * deficit) as f32;
|
||||
|
||||
let kernel_l1 = scratch[SCRATCH_L1_TRUNK];
|
||||
// Both numerator and deficit can be small (near-uniform feature
|
||||
// gradient distribution → deficit ≈ 0, l1 ≈ 0). Use the same
|
||||
// 5% rel_err budget as the p99 family for non-degenerate cases;
|
||||
// when the analytical reference hits the natural-zero floor of
|
||||
// the formula, the kernel's `__threadfence_system()` still
|
||||
// wrote 0 to the slot but the rel_err formula is undefined —
|
||||
// assert kernel_l1 ≈ 0 directly in that branch.
|
||||
println!(
|
||||
"SP4 param_group[0] L1_LAMBDA_TRUNK — true={true_l1:.5e}, kernel={kernel_l1:.5e}, deficit={deficit:.5}",
|
||||
);
|
||||
if true_l1.abs() < 1e-6 {
|
||||
assert!(
|
||||
kernel_l1.abs() < 1e-3,
|
||||
"group 0 L1 ≈ 0 expected, kernel returned {kernel_l1}",
|
||||
);
|
||||
} else {
|
||||
let rel_l1 = ((kernel_l1 - true_l1) / true_l1).abs();
|
||||
assert!(
|
||||
rel_l1 < 0.05,
|
||||
"group 0 L1 rel_err {rel_l1} > 5% (kernel={kernel_l1}, true={true_l1})",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final layout sanity — `weight_bound(g)` etc. correctly resolve to the
|
||||
// ISV slots the host launcher reads/writes. The kernel-direct test does
|
||||
// NOT touch ISV (no GpuDqnTrainer), so this is purely a const-fn drift
|
||||
// guard mirroring Tasks A5/A6.
|
||||
for g in 0..SP4_PARAM_GROUP_COUNT {
|
||||
assert!(
|
||||
weight_bound(g) >= 136 && weight_bound(g) < 144,
|
||||
"weight_bound({g}) = {} out of [136, 144)", weight_bound(g),
|
||||
);
|
||||
assert!(adam_m_bound(g) >= 144 && adam_m_bound(g) < 152);
|
||||
assert!(adam_v_bound(g) >= 152 && adam_v_bound(g) < 160);
|
||||
assert!(wd_rate(g) >= 160 && wd_rate(g) < 168);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user