Files
foxhunt/crates/ml-dqn/src/gpu_replay_buffer.rs
jgrusewski 29d58efca2 perf: pre-allocate insert + flush scratch buffers (zero cuMemAlloc per step)
Eliminated 5 per-step GPU allocations:
- insert_prio_buf, insert_idx_buf, insert_ep_buf: 3x cuMemAlloc per
  insert_batch/insert_batch_bf16 call (up to 4M elements each)
- scratch_f32: reusable 1-element temp for flush_max_priority and
  apply_max_priority_scalar (was 2x a32f per call)
- update_batch_spare: pre-allocated spare for None→Some priority swap,
  replenished by flush_max_priority (was 1x a32f per epoch start)

cuMemAlloc is a synchronizing driver call that stalls the GPU pipeline.
With 8192 batch_size and multiple inserts per epoch, this was measurable
overhead on H100.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:38:50 +02:00

930 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(unsafe_code)] // Required for CUDA kernel launches
//! GPU-Resident Replay Buffer -- pure cudarc `CudaSlice` internals.
//!
//! PER sampling uses a prefix sum over `priorities_pa` (priority^alpha).
//! `per_prefix_scan` builds the prefix sum, `per_sample` does uniform
//! threshold search with Philox RNG.
//!
//! Output `GpuBatchPtrs` wraps gathered data as raw `CudaSlice` buffers
//! for downstream neural network consumption.
use std::sync::Arc;
use cudarc::driver::{CudaFunction, CudaSlice, CudaStream, DevicePtr, DevicePtrMut, LaunchConfig, PushKernelArg};
use ml_core::nvtx::NvtxRange;
use ml_core::MLError;
// ---------------------------------------------------------------------------
// GPU batch output (CudaSlice-based, no Candle Tensor dependency)
// ---------------------------------------------------------------------------
/// Pre-built GPU batch for training. All fields are raw `CudaSlice` on GPU.
#[allow(missing_debug_implementations)]
/// Zero-alloc batch: raw GPU pointers to pre-allocated sample buffers.
/// Valid until the next `sample_proportional` call (buffers are reused).
/// No cuMemAlloc, no DtoD clone — pure pointer pass-through.
pub struct GpuBatchPtrs {
pub states_ptr: u64,
pub next_states_ptr: u64,
pub actions_ptr: u64,
pub rewards_ptr: u64,
pub dones_ptr: u64,
pub weights_ptr: u64,
pub indices_ptr: u64,
pub episode_ids_ptr: u64,
pub batch_size: usize,
pub state_dim: usize,
}
// ---------------------------------------------------------------------------
// Compiled kernel cache
// ---------------------------------------------------------------------------
struct ReplayKernels {
scatter_insert_f32: CudaFunction,
scatter_insert_u32: CudaFunction,
gather_f32: CudaFunction,
gather_u32: CudaFunction,
/// #30 F32 row gather for f32 state storage.
gather_f32_rows: CudaFunction,
is_weights_f32: CudaFunction,
normalize_weights_f32: CudaFunction,
fill_from_gpu_f32: CudaFunction,
reduce_max_f32: CudaFunction,
i64_to_u32: CudaFunction,
max_of_two_f32: CudaFunction,
// PER prefix sum kernels
per_update_pa: CudaFunction,
per_insert_pa: CudaFunction,
per_prefix_scan: CudaFunction,
per_sample: CudaFunction,
}
impl ReplayKernels {
fn compile(stream: &Arc<CudaStream>) -> Result<Self, MLError> {
let ctx = stream.context();
// Load precompiled replay buffer kernels cubin
static RB_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/replay_buffer_kernels.cubin"));
let rb_mod = ctx.load_cubin(RB_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("rb cubin load: {e}")))?;
let ld = |n: &str| -> Result<CudaFunction, MLError> {
rb_mod.load_function(n).map_err(|e| MLError::ModelError(format!("load {n}: {e}")))
};
// Load precompiled PER prefix sum kernels cubin
static PER_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/per_kernels.cubin"));
let per_mod = ctx.load_cubin(PER_CUBIN.to_vec())
.map_err(|e| MLError::ModelError(format!("per cubin load: {e}")))?;
let per_ld = |n: &str| -> Result<CudaFunction, MLError> {
per_mod.load_function(n).map_err(|e| MLError::ModelError(format!("per {n}: {e}")))
};
Ok(Self {
scatter_insert_f32: ld("scatter_insert_f32")?,
scatter_insert_u32: ld("scatter_insert_u32")?,
gather_f32: ld("gather_f32")?, gather_u32: ld("gather_u32")?,
gather_f32_rows: ld("gather_f32_rows")?,
is_weights_f32: ld("is_weights_f32")?,
normalize_weights_f32: ld("normalize_weights_f32")?,
fill_from_gpu_f32: ld("fill_from_gpu_f32")?,
reduce_max_f32: ld("reduce_max_f32")?,
i64_to_u32: ld("i64_to_u32")?,
max_of_two_f32: ld("max_of_two_f32")?,
per_update_pa: per_ld("per_update_pa")?,
per_insert_pa: per_ld("per_insert_pa")?,
per_prefix_scan: per_ld("per_prefix_scan")?,
per_sample: per_ld("per_sample")?,
})
}
}
fn lcfg(n: usize) -> LaunchConfig {
let t = 256_u32; let b = (n as u32).div_ceil(t);
LaunchConfig { grid_dim: (b.max(1), 1, 1), block_dim: (t, 1, 1), shared_mem_bytes: 0 }
}
// ---------------------------------------------------------------------------
// Config + Buffer
// ---------------------------------------------------------------------------
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, Clone)]
pub struct GpuReplayBufferConfig {
pub capacity: usize, pub state_dim: usize, pub alpha: f32,
pub beta_start: f32, pub beta_max: f32, pub beta_annealing_steps: usize,
pub epsilon: f32, pub max_memory_bytes: usize,
/// Maximum batch size for pre-allocated sampling buffers. Defaults to 1024.
pub max_batch_size: usize,
}
pub struct GpuReplayBuffer {
config: GpuReplayBufferConfig,
stream: Arc<CudaStream>,
kernels: ReplayKernels,
states: CudaSlice<f32>, next_states: CudaSlice<f32>,
actions: CudaSlice<u32>, rewards: CudaSlice<f32>,
dones: CudaSlice<f32>, priorities: CudaSlice<f32>,
/// Episode IDs per buffer slot `[capacity]` i32 on GPU.
/// `episode_ids[i] = i / episode_length`. Written during `insert_batch_with_episode_ids`.
episode_ids: CudaSlice<i32>,
write_cursor: usize, size: usize,
max_priority: CudaSlice<f32>,
pending_max_priority: Option<CudaSlice<f32>>,
current_step: usize,
// PER prefix sum buffers
priorities_pa: CudaSlice<f32>,
prefix_sum: CudaSlice<f32>,
scan_tile_state: CudaSlice<u32>,
scan_tile_aggregate: CudaSlice<f32>,
scan_tile_prefix: CudaSlice<f32>,
scan_tile_counter: CudaSlice<u32>,
// Pre-allocated PER sampling buffers (zero cuMemAlloc after warmup)
sample_indices_i64: CudaSlice<i64>,
sample_indices_u32: CudaSlice<u32>,
sample_states: CudaSlice<f32>,
sample_next_states: CudaSlice<f32>,
sample_actions: CudaSlice<u32>,
sample_rewards: CudaSlice<f32>,
sample_dones: CudaSlice<f32>,
sample_priorities: CudaSlice<f32>,
sample_weights: CudaSlice<f32>,
sample_max_weight: CudaSlice<f32>,
sample_episode_ids: CudaSlice<i32>,
total_sum_buf: CudaSlice<f32>,
// Pre-allocated scratch for update_priorities_gpu (zero cuMemAlloc per step)
update_batch_max: CudaSlice<f32>, // [1] per-batch atomicMax accumulator
update_max_merge: CudaSlice<f32>, // [1] merge pending + batch max
update_batch_spare: Option<CudaSlice<f32>>, // [1] spare for None→Some swap (zero alloc)
// Pre-allocated insert scratch buffers (zero cuMemAlloc per insert)
insert_prio_buf: CudaSlice<f32>, // [insert_scratch_cap] priority broadcast
insert_idx_buf: CudaSlice<u32>, // [insert_scratch_cap] per_insert_pa indices
insert_ep_buf: CudaSlice<i32>, // [insert_scratch_cap] episode IDs
insert_scratch_cap: usize, // current allocation size
scratch_f32: CudaSlice<f32>, // [1] reusable temp for flush/apply_max
rng_step: u32,
/// Size of the most recent `sample_proportional` call. Used by `sample_weights_ref` /
/// `sample_indices_ref` to return correctly-sized views (pre-allocated buffers are
/// `max_batch_size` large; only the first `last_batch_size` elements are valid).
last_batch_size: usize,
}
impl Drop for GpuReplayBuffer {
fn drop(&mut self) {
// Synchronize stream before CudaSlice fields drop.
// PER sampling and priority update kernels may have pending work.
// Without sync, cuMemFree races with async kernel writes.
#[allow(unsafe_code)]
unsafe {
cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream());
}
}
}
impl GpuReplayBuffer {
pub fn new(config: GpuReplayBufferConfig, stream: &Arc<CudaStream>) -> Result<Self, MLError> {
let (cap, sd) = (config.capacity, config.state_dim);
let mbs = if config.max_batch_size == 0 { 1024 } else { config.max_batch_size };
let need = 2 * cap * sd * 2 + 5 * cap * 4;
if need > config.max_memory_bytes {
#[allow(clippy::integer_division)]
return Err(MLError::ModelError(format!(
"GPU replay buffer needs {} MB (limit {} MB)",
need / (1024 * 1024), config.max_memory_bytes / (1024 * 1024),
)));
}
let k = ReplayKernels::compile(stream)?;
let s = a32f(stream, cap * sd, "s")?;
let ns = a32f(stream, cap * sd, "ns")?;
let a = a32u(stream, cap, "a")?;
let r = a32f(stream, cap, "r")?;
let d = a32f(stream, cap, "d")?;
let p = a32f(stream, cap, "p")?;
let mut mp = a32f(stream, 1, "mp")?;
stream.memcpy_htod(&[1.0_f32], &mut mp).map_err(|e| MLError::ModelError(format!("mp: {e}")))?;
// PER prefix sum buffers
let priorities_pa = a32f(stream, cap, "priorities_pa")?;
let prefix_sum = a32f(stream, cap, "prefix_sum")?;
let tile_size = 256_usize;
let num_scan_tiles = cap.div_ceil(tile_size);
let scan_tile_state = stream.alloc_zeros::<u32>(num_scan_tiles)
.map_err(|e| MLError::ModelError(format!("alloc scan_tile_state: {e}")))?;
let scan_tile_aggregate = a32f(stream, num_scan_tiles, "scan_tile_agg")?;
let scan_tile_prefix = a32f(stream, num_scan_tiles, "scan_tile_pfx")?;
let scan_tile_counter = stream.alloc_zeros::<u32>(1)
.map_err(|e| MLError::ModelError(format!("alloc scan_tile_counter: {e}")))?;
// Pre-allocate PER sampling buffers (zero cuMemAlloc after warmup)
let si64 = stream.alloc_zeros::<i64>(mbs).map_err(|e| MLError::ModelError(format!("alloc s_i64: {e}")))?;
let su32 = a32u(stream, mbs, "s_idx")?;
let ss = a32f(stream, mbs * sd, "s_states")?;
let sns = a32f(stream, mbs * sd, "s_nstates")?;
let sa = a32u(stream, mbs, "s_act")?;
let sr = a32f(stream, mbs, "s_rew")?;
let sdn = a32f(stream, mbs, "s_done")?;
let sp = a32f(stream, mbs, "s_pri")?;
let sw = a32f(stream, mbs, "s_wt")?;
let smw = a32f(stream, 1, "s_mw")?;
let tsb = a32f(stream, 1, "ts_buf")?;
let ep_ids = a32i(stream, cap, "episode_ids")?;
let s_ep = a32i(stream, mbs, "s_episode_ids")?;
// Pre-allocate PER update scratch buffers (zero cuMemAlloc per step)
let update_batch_max = a32f(stream, 1, "update_batch_max")?;
let update_max_merge = a32f(stream, 1, "update_max_merge")?;
let update_batch_spare = a32f(stream, 1, "update_batch_spare")?;
// Pre-allocate insert scratch buffers (zero cuMemAlloc per insert)
// Initial size = max_batch_size; grows if insert_batch receives larger batches.
let isc = mbs;
let insert_prio_buf = a32f(stream, isc, "ins_prio")?;
let insert_idx_buf = a32u(stream, isc, "ins_idx")?;
let insert_ep_buf = a32i(stream, isc, "ins_ep")?;
let scratch_f32 = a32f(stream, 1, "scratch")?;
Ok(Self {
config, stream: Arc::clone(stream), kernels: k,
states: s, next_states: ns, actions: a, rewards: r, dones: d, priorities: p,
episode_ids: ep_ids,
write_cursor: 0, size: 0, max_priority: mp,
pending_max_priority: None, current_step: 0,
priorities_pa, prefix_sum,
scan_tile_state, scan_tile_aggregate, scan_tile_prefix, scan_tile_counter,
sample_indices_i64: si64,
sample_indices_u32: su32, sample_states: ss,
sample_next_states: sns, sample_actions: sa,
sample_rewards: sr, sample_dones: sdn,
sample_priorities: sp, sample_weights: sw,
sample_episode_ids: s_ep,
sample_max_weight: smw, total_sum_buf: tsb,
update_batch_max, update_max_merge, update_batch_spare: Some(update_batch_spare),
insert_prio_buf, insert_idx_buf, insert_ep_buf, insert_scratch_cap: isc,
scratch_f32,
rng_step: 0,
last_batch_size: 0,
})
}
pub const fn len(&self) -> usize { self.size }
pub const fn capacity(&self) -> usize { self.config.capacity }
pub const fn is_empty(&self) -> bool { self.size == 0 }
pub const fn can_sample(&self, bs: usize) -> bool { self.size >= bs }
pub fn current_beta(&self) -> f32 {
if self.config.beta_annealing_steps == 0 { return self.config.beta_max; }
let p = ((self.current_step as f32) / (self.config.beta_annealing_steps as f32)).min(1.0);
self.config.beta_start + (self.config.beta_max - self.config.beta_start) * p
}
pub const fn step(&mut self) { self.current_step = self.current_step.saturating_add(1); }
pub fn clear(&mut self) -> Result<(), MLError> {
self.write_cursor = 0; self.size = 0;
self.stream.memcpy_htod(&[1.0_f32], &mut self.max_priority).map_err(|e| MLError::ModelError(format!("{e}")))?;
self.stream.memset_zeros(&mut self.priorities_pa).map_err(|e| MLError::ModelError(format!("priorities_pa clear: {e}")))?;
self.stream.memset_zeros(&mut self.prefix_sum).map_err(|e| MLError::ModelError(format!("prefix_sum clear: {e}")))?;
self.pending_max_priority = None; self.current_step = 0; Ok(())
}
pub const fn stream(&self) -> &Arc<CudaStream> { &self.stream }
pub const fn alpha(&self) -> f32 { self.config.alpha }
pub const fn epsilon(&self) -> f32 { self.config.epsilon }
pub const fn state_dim(&self) -> usize { self.config.state_dim }
/// Grow insert scratch buffers if `eff` exceeds current allocation.
/// Only triggers cuMemAlloc on the first large insert — subsequent calls reuse.
fn ensure_insert_scratch(&mut self, eff: usize) -> Result<(), MLError> {
if eff <= self.insert_scratch_cap { return Ok(()); }
self.insert_prio_buf = a32f(&self.stream, eff, "ins_prio")?;
self.insert_idx_buf = a32u(&self.stream, eff, "ins_idx")?;
self.insert_ep_buf = a32i(&self.stream, eff, "ins_ep")?;
self.insert_scratch_cap = eff;
Ok(())
}
pub fn insert_batch(&mut self, sf: &CudaSlice<f32>, nf: &CudaSlice<f32>,
ac: &CudaSlice<u32>, rw: &CudaSlice<f32>, dn: &CudaSlice<f32>, bs: usize,
) -> Result<(), MLError> {
if bs == 0 { return Ok(()); }
let (cap, sd) = (self.config.capacity, self.config.state_dim);
let (eff, off) = if bs > cap { (cap, bs - cap) } else { (bs, 0) };
self.ensure_insert_scratch(eff)?;
let el = eff * sd;
// #30: f32 states — scatter insert directly (no bf16 cast)
let ss = if off > 0 { sf.slice(off * sd..) } else { sf.slice(0..) };
let sn = if off > 0 { nf.slice(off * sd..) } else { nf.slice(0..) };
let (ci, cpi, sdi, bsi) = (self.write_cursor as i32, cap as i32, sd as i32, eff as i32);
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.states).arg(&ss).arg(&ci).arg(&cpi).arg(&sdi).arg(&bsi)
.launch(lcfg(el)).map_err(|e| MLError::ModelError(format!("sc s f32: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.next_states).arg(&sn).arg(&ci).arg(&cpi).arg(&sdi).arg(&bsi)
.launch(lcfg(el)).map_err(|e| MLError::ModelError(format!("sc n f32: {e}")))?;
}
let sa = if off > 0 { ac.slice(off..) } else { ac.slice(0..) };
let sr = if off > 0 { rw.slice(off..) } else { rw.slice(0..) };
let sd2 = if off > 0 { dn.slice(off..) } else { dn.slice(0..) };
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_u32)
.arg(&self.actions).arg(&sa).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc a: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.rewards).arg(&sr).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc r: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.dones).arg(&sd2).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc d: {e}")))?;
}
// Broadcast max_priority into pre-allocated prio buffer
unsafe {
self.stream.launch_builder(&self.kernels.fill_from_gpu_f32)
.arg(&mut self.insert_prio_buf).arg(&self.max_priority).arg(&bsi)
.launch(lcfg(eff))
.map_err(|e| MLError::ModelError(format!("fill mp: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.priorities).arg(&self.insert_prio_buf).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc p: {e}")))?;
}
// Write priority^alpha to priorities_pa using pre-allocated index buffer
let insert_indices: Vec<u32> = (0..eff)
.map(|j| ((self.write_cursor + j) % cap) as u32)
.collect();
self.stream.memcpy_htod(&insert_indices, &mut self.insert_idx_buf)
.map_err(|e| MLError::ModelError(format!("ib idx htod: {e}")))?;
let al = self.config.alpha;
let cap_i = self.config.capacity as i32;
unsafe {
self.stream.launch_builder(&self.kernels.per_insert_pa)
.arg(&self.priorities_pa).arg(&self.insert_idx_buf).arg(&self.insert_prio_buf).arg(&al)
.arg(&cap_i).arg(&bsi)
.launch(lcfg(eff))
.map_err(|e| MLError::ModelError(format!("per_insert_pa: {e}")))?;
}
// Write episode IDs using pre-allocated buffer
let ep_ids_host: Vec<i32> = (0..eff)
.map(|j| ((self.write_cursor + j) % cap) as i32)
.collect();
self.stream.memcpy_htod(&ep_ids_host, &mut self.insert_ep_buf)
.map_err(|e| MLError::ModelError(format!("ep htod: {e}")))?;
unsafe {
let ep_dst = &*(&self.episode_ids as *const CudaSlice<i32> as *const CudaSlice<u32>);
let ep_src = &*(&self.insert_ep_buf as *const CudaSlice<i32> as *const CudaSlice<u32>);
self.stream.launch_builder(&self.kernels.scatter_insert_u32)
.arg(ep_dst).arg(ep_src).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc ep: {e}")))?;
}
self.write_cursor = (self.write_cursor + eff) % cap;
self.size = (self.size + eff).min(cap);
Ok(())
}
/// Insert a batch where states are already `CudaSlice<half::bf16>` and
/// rewards/dones are `CudaSlice<f32>` (no bf16 NaN risk).
///
/// Skips the f32→bf16 cast for states (already bf16). Rewards/dones
/// scatter-insert as f32 directly.
/// #30: Accept f32 states from the experience collector.
/// States are stored as f32 in the replay buffer (no bf16 truncation).
pub fn insert_batch_bf16(
&mut self,
sf: &CudaSlice<f32>, // f32 states from experience collector
nf: &CudaSlice<f32>, // f32 next_states
ac: &CudaSlice<u32>,
rw: &CudaSlice<f32>,
dn: &CudaSlice<f32>,
bs: usize,
) -> Result<(), MLError> {
if bs == 0 { return Ok(()); }
let (cap, sd) = (self.config.capacity, self.config.state_dim);
let (eff, off) = if bs > cap { (cap, bs - cap) } else { (bs, 0) };
self.ensure_insert_scratch(eff)?;
let el = eff * sd;
// #30: f32 states — scatter insert directly (full precision storage)
let ss = if off > 0 { sf.slice(off * sd..) } else { sf.slice(0..) };
let sn = if off > 0 { nf.slice(off * sd..) } else { nf.slice(0..) };
let (ci, cpi, sdi, bsi) = (self.write_cursor as i32, cap as i32, sd as i32, eff as i32);
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.states).arg(&ss).arg(&ci).arg(&cpi).arg(&sdi).arg(&bsi)
.launch(lcfg(el)).map_err(|e| MLError::ModelError(format!("sc s f32: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.next_states).arg(&sn).arg(&ci).arg(&cpi).arg(&sdi).arg(&bsi)
.launch(lcfg(el)).map_err(|e| MLError::ModelError(format!("sc n f32: {e}")))?;
}
let sa = if off > 0 { ac.slice(off..) } else { ac.slice(0..) };
{
let sr = if off > 0 { rw.slice(off..) } else { rw.slice(0..) };
let sd2 = if off > 0 { dn.slice(off..) } else { dn.slice(0..) };
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_u32)
.arg(&self.actions).arg(&sa).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc a: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.rewards).arg(&sr).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc r f32: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.dones).arg(&sd2).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc d f32: {e}")))?;
}
}
// Broadcast max_priority into pre-allocated prio buffer
unsafe {
self.stream.launch_builder(&self.kernels.fill_from_gpu_f32)
.arg(&mut self.insert_prio_buf).arg(&self.max_priority).arg(&bsi)
.launch(lcfg(eff))
.map_err(|e| MLError::ModelError(format!("fill mp: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.scatter_insert_f32)
.arg(&self.priorities).arg(&self.insert_prio_buf).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc p: {e}")))?;
}
// Write priority^alpha to priorities_pa using pre-allocated buffers
let insert_indices: Vec<u32> = (0..eff)
.map(|j| ((self.write_cursor + j) % cap) as u32)
.collect();
self.stream.memcpy_htod(&insert_indices, &mut self.insert_idx_buf)
.map_err(|e| MLError::ModelError(format!("ib idx htod: {e}")))?;
let al = self.config.alpha;
let cap_i = self.config.capacity as i32;
unsafe {
self.stream.launch_builder(&self.kernels.per_insert_pa)
.arg(&self.priorities_pa).arg(&self.insert_idx_buf).arg(&self.insert_prio_buf).arg(&al)
.arg(&cap_i).arg(&bsi)
.launch(lcfg(eff))
.map_err(|e| MLError::ModelError(format!("per_insert_pa: {e}")))?;
}
// Episode IDs using pre-allocated buffer
let ep_ids_host: Vec<i32> = (0..eff)
.map(|j| ((self.write_cursor + j) % cap) as i32)
.collect();
self.stream.memcpy_htod(&ep_ids_host, &mut self.insert_ep_buf)
.map_err(|e| MLError::ModelError(format!("ep htod: {e}")))?;
unsafe {
let ep_dst = &*(&self.episode_ids as *const CudaSlice<i32> as *const CudaSlice<u32>);
let ep_src = &*(&self.insert_ep_buf as *const CudaSlice<i32> as *const CudaSlice<u32>);
self.stream.launch_builder(&self.kernels.scatter_insert_u32)
.arg(ep_dst).arg(ep_src).arg(&ci).arg(&cpi).arg(&bsi)
.launch(lcfg(eff)).map_err(|e| MLError::ModelError(format!("sc ep: {e}")))?;
}
self.write_cursor = (self.write_cursor + eff) % cap;
self.size = (self.size + eff).min(cap);
Ok(())
}
pub fn sample_proportional(&mut self, batch_size: usize) -> Result<GpuBatchPtrs, MLError> {
let _nvtx = NvtxRange::new("per_sample_proportional");
if !self.can_sample(batch_size) {
return Err(MLError::ModelError(format!("Cannot sample {batch_size} from {}", self.size)));
}
let mbs = if self.config.max_batch_size == 0 { 1024 } else { self.config.max_batch_size };
if batch_size > mbs {
return Err(MLError::ModelError(format!(
"batch_size {batch_size} exceeds max_batch_size {mbs}"
)));
}
let (n, sd) = (self.size, self.config.state_dim);
let nb = -self.current_beta();
let bsi = batch_size as i32;
// Step 1: Prefix scan of priorities_pa[0..size]
// Zero tile state + counter before each scan.
self.stream.memset_zeros(&mut self.scan_tile_state)
.map_err(|e| MLError::ModelError(format!("zero scan state: {e}")))?;
self.stream.memset_zeros(&mut self.scan_tile_counter)
.map_err(|e| MLError::ModelError(format!("zero scan counter: {e}")))?;
let size_i = n as i32;
let actual_tiles = n.div_ceil(256);
let num_tiles_i = actual_tiles as i32;
// Limit grid to max resident blocks to prevent spin-wait deadlock.
// Each block grabs tiles dynamically via atomicAdd on tile_counter.
let max_resident_blocks = 512_u32; // H100: 132 SMs × ~4 blocks/SM
let scan_blocks = (actual_tiles as u32).min(max_resident_blocks);
unsafe {
self.stream.launch_builder(&self.kernels.per_prefix_scan)
.arg(&self.priorities_pa)
.arg(&mut self.prefix_sum)
.arg(&self.scan_tile_state)
.arg(&self.scan_tile_aggregate)
.arg(&self.scan_tile_prefix)
.arg(&self.scan_tile_counter)
.arg(&size_i)
.arg(&num_tiles_i)
.launch(LaunchConfig {
grid_dim: (scan_blocks.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 256 * 4,
})
.map_err(|e| MLError::ModelError(format!("per_prefix_scan: {e}")))?;
}
// Step 2: Sample via binary search + gather priorities_pa
self.rng_step = self.rng_step.wrapping_add(1);
let seed = self.rng_step;
unsafe {
self.stream.launch_builder(&self.kernels.per_sample)
.arg(&self.prefix_sum)
.arg(&self.priorities_pa)
.arg(&mut self.sample_indices_i64)
.arg(&mut self.sample_priorities)
.arg(&mut self.total_sum_buf)
.arg(&seed)
.arg(&size_i)
.arg(&bsi)
.launch(lcfg(batch_size))
.map_err(|e| MLError::ModelError(format!("per_sample: {e}")))?;
}
// Step 3: i64 -> u32 indices for output
// SAFETY: sample_indices_u32 and sample_indices_i64 are valid device allocations.
unsafe {
self.stream.launch_builder(&self.kernels.i64_to_u32)
.arg(&mut self.sample_indices_u32).arg(&self.sample_indices_i64).arg(&bsi)
.launch(lcfg(batch_size)).map_err(|e| MLError::ModelError(format!("i2u: {e}")))?;
}
// Step 3: gather into pre-allocated buffers
let sdi = sd as i32;
// SAFETY: sample_states, states, sample_indices_i64 are valid device allocations.
// #30: gather f32 rows (full-precision state storage)
unsafe {
self.stream.launch_builder(&self.kernels.gather_f32_rows)
.arg(&mut self.sample_states).arg(&self.states).arg(&self.sample_indices_i64).arg(&sdi).arg(&bsi)
.launch(lcfg(batch_size * sd)).map_err(|e| MLError::ModelError(format!("g s f32: {e}")))?;
}
unsafe {
self.stream.launch_builder(&self.kernels.gather_f32_rows)
.arg(&mut self.sample_next_states).arg(&self.next_states).arg(&self.sample_indices_i64).arg(&sdi).arg(&bsi)
.launch(lcfg(batch_size * sd)).map_err(|e| MLError::ModelError(format!("g n f32: {e}")))?;
}
// SAFETY: same context, actions buffer valid.
unsafe {
let cap_i32 = self.capacity() as i32;
self.stream.launch_builder(&self.kernels.gather_u32)
.arg(&mut self.sample_actions).arg(&self.actions).arg(&self.sample_indices_i64).arg(&bsi).arg(&cap_i32)
.launch(lcfg(batch_size)).map_err(|e| MLError::ModelError(format!("g a: {e}")))?;
}
// SAFETY: same context, rewards buffer valid (f32).
unsafe {
let cap_i32 = self.capacity() as i32;
self.stream.launch_builder(&self.kernels.gather_f32)
.arg(&mut self.sample_rewards).arg(&self.rewards).arg(&self.sample_indices_i64).arg(&bsi).arg(&cap_i32)
.launch(lcfg(batch_size)).map_err(|e| MLError::ModelError(format!("g r: {e}")))?;
}
// SAFETY: same context, dones buffer valid (f32).
unsafe {
let cap_i32 = self.capacity() as i32;
self.stream.launch_builder(&self.kernels.gather_f32)
.arg(&mut self.sample_dones).arg(&self.dones).arg(&self.sample_indices_i64).arg(&bsi).arg(&cap_i32)
.launch(lcfg(batch_size)).map_err(|e| MLError::ModelError(format!("g d: {e}")))?;
}
// Step 3b: gather episode_ids for HER strategies.
// Reinterpret i32 buffers as u32 (same bit width) for the gather_u32 kernel.
// SAFETY: episode_ids and sample_episode_ids are CudaSlice<i32>, same layout as u32.
// The gather kernel copies raw bytes, so reinterpret is safe for same-size types.
unsafe {
let ep_src = &*(&self.episode_ids as *const CudaSlice<i32> as *const CudaSlice<u32>);
let ep_dst = &mut *(&mut self.sample_episode_ids as *mut CudaSlice<i32> as *mut CudaSlice<u32>);
let cap_ep = self.capacity() as i32;
self.stream.launch_builder(&self.kernels.gather_u32)
.arg(ep_dst).arg(ep_src).arg(&self.sample_indices_i64).arg(&bsi).arg(&cap_ep)
.launch(lcfg(batch_size)).map_err(|e| MLError::ModelError(format!("g ep: {e}")))?;
}
// Step 6: IS weights via GPU-resident total_sum (zero CPU readback)
// is_weights_f32 reads total_sum from GPU pointer.
// SAFETY: sample_weights, sample_priorities, total_sum_buf are valid device allocations.
unsafe {
self.stream.launch_builder(&self.kernels.is_weights_f32)
.arg(&mut self.sample_weights).arg(&self.sample_priorities).arg(&self.total_sum_buf)
.arg(&nb).arg(&(n as i32)).arg(&bsi)
.launch(lcfg(batch_size)).map_err(|e| MLError::ModelError(format!("isw: {e}")))?;
}
// Step 7: reduce max weight
self.stream.memset_zeros(&mut self.sample_max_weight)
.map_err(|e| MLError::ModelError(format!("mw zero: {e}")))?;
let rt = 256_u32.min(batch_size as u32).max(1);
let rb = (batch_size as u32).div_ceil(rt);
// SAFETY: sample_weights, sample_max_weight are valid device allocations.
unsafe {
self.stream.launch_builder(&self.kernels.reduce_max_f32)
.arg(&self.sample_weights).arg(&mut self.sample_max_weight).arg(&bsi)
.launch(LaunchConfig { grid_dim: (rb.max(1),1,1), block_dim: (rt,1,1), shared_mem_bytes: rt*4 })
.map_err(|e| MLError::ModelError(format!("rm: {e}")))?;
}
// Step 8: normalize weights by max
// SAFETY: sample_weights, sample_max_weight are valid. Normalization divides element-wise.
unsafe {
self.stream.launch_builder(&self.kernels.normalize_weights_f32)
.arg(&mut self.sample_weights).arg(&self.sample_max_weight).arg(&bsi)
.launch(lcfg(batch_size)).map_err(|e| MLError::ModelError(format!("nw: {e}")))?;
}
// Record the batch size so that sample_weights_ref / sample_indices_ref return
// correctly-sized views (the pre-allocated buffers are max_batch_size elements,
// but only the first `batch_size` are valid after this sample).
self.last_batch_size = batch_size;
// Zero-alloc: return raw pointers to pre-allocated sample buffers.
// Gather kernels already wrote into self.sample_* buffers above.
// No DtoD clone, no cuMemAlloc — pure GPU-resident references.
Ok(GpuBatchPtrs {
states_ptr: self.sample_states.raw_ptr(),
next_states_ptr: self.sample_next_states.raw_ptr(),
actions_ptr: self.sample_actions.raw_ptr(),
rewards_ptr: self.sample_rewards.raw_ptr(),
dones_ptr: self.sample_dones.raw_ptr(),
weights_ptr: self.sample_weights.raw_ptr(),
indices_ptr: self.sample_indices_u32.raw_ptr(),
episode_ids_ptr: self.sample_episode_ids.raw_ptr(),
batch_size,
state_dim: sd,
})
}
/// Reference to pre-allocated sample indices buffer (valid after sample_proportional).
///
/// Returns a view sized to the most recent `sample_proportional` batch size.
/// The underlying buffer is `max_batch_size` elements; only `last_batch_size` are valid.
pub fn sample_indices_ref(&self) -> cudarc::driver::CudaView<'_, u32> {
self.sample_indices_u32.slice(..self.last_batch_size)
}
/// Reference to pre-allocated sample episode IDs buffer (valid after sample_proportional).
pub fn sample_episode_ids_ref(&self) -> &CudaSlice<i32> { &self.sample_episode_ids }
/// Reference to pre-allocated sample weights buffer (valid after sample_proportional).
///
/// Returns a view sized to the most recent `sample_proportional` batch size.
/// The underlying buffer is `max_batch_size` elements; only `last_batch_size` are valid.
pub fn sample_weights_ref(&self) -> cudarc::driver::CudaView<'_, f32> {
self.sample_weights.slice(..self.last_batch_size)
}
pub fn update_priorities_gpu_raw(&mut self, _indices_ptr: u64, td_errors: &CudaSlice<half::bf16>, bs: usize, ext_stream: Option<&Arc<CudaStream>>) -> Result<(), MLError> {
let idx_slice = unsafe { &*(&self.sample_indices_u32 as *const CudaSlice<u32>) };
self.update_priorities_gpu(idx_slice, td_errors, bs, ext_stream)
}
/// Update PER priorities from TD errors using the prefix-sum kernel.
///
/// `per_update_pa` reads bf16 td_errors directly (no cast needed),
/// computes `(|td|^alpha + eps)`, and writes to both `priorities` and
/// `priorities_pa`. Prefix sum is rebuilt lazily before sampling.
///
/// If `ext_stream` is provided, all GPU work runs on that stream instead of
/// the replay buffer's own stream. This avoids cross-stream synchronization
/// when the TD errors were computed on a different stream (e.g., the trainer's
/// main stream). All pre-allocated scratch buffers are used — zero per-step
/// GPU allocations.
pub fn update_priorities_gpu(
&mut self,
indices: &CudaSlice<u32>,
td_errors: &CudaSlice<half::bf16>,
bs: usize,
ext_stream: Option<&Arc<CudaStream>>,
) -> Result<(), MLError> {
if bs == 0 { return Ok(()); }
let stream_owned = ext_stream.cloned().unwrap_or_else(|| Arc::clone(&self.stream));
let stream = &stream_owned;
let (al, ep, bsi) = (self.config.alpha, self.config.epsilon, bs as i32);
let cap_i = self.config.capacity as i32;
stream.memset_zeros(&mut self.update_batch_max)
.map_err(|e| MLError::ModelError(format!("zero batch_max: {e}")))?;
// per_update_pa: reads bf16 td_errors directly, writes priorities + priorities_pa
unsafe {
stream.launch_builder(&self.kernels.per_update_pa)
.arg(&self.priorities_pa)
.arg(&self.priorities)
.arg(&self.update_batch_max)
.arg(indices)
.arg(td_errors)
.arg(&al).arg(&ep).arg(&cap_i).arg(&bsi)
.launch(lcfg(bs))
.map_err(|e| MLError::ModelError(format!("per_update_pa: {e}")))?;
}
// Merge batch max into pending (unchanged logic)
match self.pending_max_priority.take() {
Some(prev) => {
unsafe {
stream.launch_builder(&self.kernels.max_of_two_f32)
.arg(&self.update_max_merge).arg(&prev).arg(&self.update_batch_max)
.launch(LaunchConfig { grid_dim: (1,1,1), block_dim: (1,1,1), shared_mem_bytes: 0 })
.map_err(|e| MLError::ModelError(format!("max_of_two: {e}")))?;
}
self.pending_max_priority = Some(std::mem::replace(&mut self.update_max_merge, prev));
}
None => {
// Rotate: batch_max → pending, spare → batch_max (zero alloc)
// spare was pre-allocated at construction; replenished by flush_max_priority.
if let Some(spare) = self.update_batch_spare.take() {
self.pending_max_priority = Some(std::mem::replace(&mut self.update_batch_max, spare));
} else {
// Fallback: first epoch before flush replenishes spare
let fresh = a32f(stream, 1, "bm_epoch")?;
self.pending_max_priority = Some(std::mem::replace(&mut self.update_batch_max, fresh));
}
}
}
Ok(())
}
pub fn flush_max_priority(&mut self) -> Result<(), MLError> {
if let Some(pend) = self.pending_max_priority.take() {
unsafe {
self.stream.launch_builder(&self.kernels.max_of_two_f32)
.arg(&mut self.scratch_f32).arg(&pend).arg(&self.max_priority)
.launch(LaunchConfig { grid_dim: (1,1,1), block_dim: (1,1,1), shared_mem_bytes: 0 })
.map_err(|e| MLError::ModelError(format!("flush max_of_two: {e}")))?;
}
let num_bytes = std::mem::size_of::<f32>();
let src_ptr = {
let (ptr, guard) = self.scratch_f32.device_ptr(&self.stream);
let _no_drop = std::mem::ManuallyDrop::new(guard);
ptr
};
let dst_ptr = {
let (ptr, guard) = self.max_priority.device_ptr_mut(&self.stream);
let _no_drop = std::mem::ManuallyDrop::new(guard);
ptr
};
unsafe {
cudarc::driver::result::memcpy_dtod_async(
dst_ptr, src_ptr, num_bytes, self.stream.cu_stream(),
).map_err(|e| MLError::ModelError(format!("flush dtod: {e}")))?;
}
// Replenish spare with the consumed pend buffer (zero alloc next epoch)
self.update_batch_spare = Some(pend);
}
Ok(())
}
pub fn apply_max_priority_scalar(&mut self, mp: f32) -> Result<(), MLError> {
if mp > 0.0 {
self.stream.memcpy_htod(&[mp], &mut self.scratch_f32)
.map_err(|e| MLError::ModelError(format!("amp htod: {e}")))?;
// max(scratch_f32=mp, max_priority) → update_max_merge
unsafe {
self.stream.launch_builder(&self.kernels.max_of_two_f32)
.arg(&mut self.update_max_merge).arg(&self.scratch_f32).arg(&self.max_priority)
.launch(LaunchConfig { grid_dim: (1,1,1), block_dim: (1,1,1), shared_mem_bytes: 0 })
.map_err(|e| MLError::ModelError(format!("amp max: {e}")))?;
}
let num_bytes = std::mem::size_of::<f32>();
let src_ptr = {
let (ptr, guard) = self.update_max_merge.device_ptr(&self.stream);
let _no_drop = std::mem::ManuallyDrop::new(guard);
ptr
};
let dst_ptr = {
let (ptr, guard) = self.max_priority.device_ptr_mut(&self.stream);
let _no_drop = std::mem::ManuallyDrop::new(guard);
ptr
};
unsafe {
cudarc::driver::result::memcpy_dtod_async(
dst_ptr, src_ptr, num_bytes, self.stream.cu_stream(),
).map_err(|e| MLError::ModelError(format!("amp dtod: {e}")))?;
}
}
Ok(())
}
/// Raw `CudaSlice` accessors for direct GPU consumption.
pub const fn states_slice(&self) -> &CudaSlice<f32> { &self.states }
pub const fn next_states_slice(&self) -> &CudaSlice<f32> { &self.next_states }
pub const fn actions_slice(&self) -> &CudaSlice<u32> { &self.actions }
pub const fn rewards_slice(&self) -> &CudaSlice<f32> { &self.rewards }
pub const fn dones_slice(&self) -> &CudaSlice<f32> { &self.dones }
pub const fn priorities_slice(&self) -> &CudaSlice<f32> { &self.priorities }
}
impl std::fmt::Debug for GpuReplayBuffer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GpuReplayBuffer")
.field("capacity", &self.config.capacity).field("size", &self.size)
.field("state_dim", &self.config.state_dim)
.field("write_cursor", &self.write_cursor).finish()
}
}
fn a32f(s: &Arc<CudaStream>, n: usize, nm: &str) -> Result<CudaSlice<f32>, MLError> {
s.alloc_zeros::<f32>(n).map_err(|e| MLError::ModelError(format!("alloc {nm}: {e}")))
}
fn a32u(s: &Arc<CudaStream>, n: usize, nm: &str) -> Result<CudaSlice<u32>, MLError> {
s.alloc_zeros::<u32>(n).map_err(|e| MLError::ModelError(format!("alloc {nm}: {e}")))
}
fn a32i(s: &Arc<CudaStream>, n: usize, nm: &str) -> Result<CudaSlice<i32>, MLError> {
s.alloc_zeros::<i32>(n).map_err(|e| MLError::ModelError(format!("alloc {nm}: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_stream() -> Arc<CudaStream> {
cudarc::driver::CudaContext::new(0)
.expect("CUDA required")
.new_stream()
.expect("CUDA stream")
}
#[test]
fn test_creation() {
let c = GpuReplayBufferConfig { capacity: 1000, state_dim: 48, alpha: 0.6, beta_start: 0.4, beta_max: 1.0, beta_annealing_steps: 100_000, epsilon: 1e-6, max_memory_bytes: 4<<30, max_batch_size: 256 };
let b = GpuReplayBuffer::new(c, &make_stream()).expect("buf");
assert_eq!(b.len(), 0); assert_eq!(b.capacity(), 1000); assert!(b.is_empty());
}
#[test]
fn test_beta() {
let c = GpuReplayBufferConfig { capacity: 100, state_dim: 4, alpha: 0.6, beta_start: 0.4, beta_max: 1.0, beta_annealing_steps: 1000, epsilon: 1e-6, max_memory_bytes: 4<<30, max_batch_size: 64 };
let mut b = GpuReplayBuffer::new(c, &make_stream()).expect("buf");
assert!((b.current_beta() - 0.4).abs() < 1e-6);
for _ in 0..500 { b.step(); }
assert!(b.current_beta() > 0.4 && b.current_beta() < 1.0);
for _ in 0..600 { b.step(); }
assert!((b.current_beta() - 1.0).abs() < 1e-6);
}
#[test]
fn test_clear() {
let c = GpuReplayBufferConfig { capacity: 100, state_dim: 4, alpha: 0.6, beta_start: 0.4, beta_max: 1.0, beta_annealing_steps: 1000, epsilon: 1e-6, max_memory_bytes: 4<<30, max_batch_size: 64 };
let mut b = GpuReplayBuffer::new(c, &make_stream()).expect("buf");
b.step(); b.clear().expect("clear");
assert_eq!(b.len(), 0); assert_eq!(b.current_step, 0);
}
/// Regression test: prefix scan must use `size` tiles, not `capacity` tiles.
/// With capacity=1M and size=256, the old code scanned 3907 tiles instead of 1.
/// On H100 with capacity=24.7M this caused a hang (96K tiles × decoupled lookback).
#[test]
#[ignore] // Requires CUDA GPU
fn test_per_prefix_scan_large_capacity() {
let stream = make_stream();
let cap = 1_000_000;
let sd = 4;
let bs = 64;
let c = GpuReplayBufferConfig {
capacity: cap, state_dim: sd, alpha: 0.6,
beta_start: 0.4, beta_max: 1.0, beta_annealing_steps: 1000,
epsilon: 1e-6, max_memory_bytes: 8 << 30, max_batch_size: bs,
};
let mut b = GpuReplayBuffer::new(c, &stream).expect("buf");
// Insert only 256 experiences into a 1M-slot buffer (ratio 3906:1)
let n = 256;
let s = stream.alloc_zeros::<f32>(n * sd).unwrap();
let ns = stream.alloc_zeros::<f32>(n * sd).unwrap();
let a = stream.alloc_zeros::<u32>(n).unwrap();
let mut r = stream.alloc_zeros::<f32>(n).unwrap();
let d = stream.alloc_zeros::<f32>(n).unwrap();
// Give non-zero rewards so priorities are non-zero after update
let rewards_host: Vec<f32> = (0..n).map(|i| (i as f32 + 1.0) * 0.01).collect();
stream.memcpy_htod(&rewards_host, &mut r).unwrap();
b.insert_batch(&s, &ns, &a, &r, &d, n).unwrap();
assert_eq!(b.len(), n);
// sample_proportional triggers per_prefix_scan — must complete, not hang
let batch = b.sample_proportional(bs).expect("sample must not hang");
assert_eq!(batch.batch_size, bs);
// Verify all sampled indices are within [0, size)
let mut host_idx = vec![0u32; bs];
unsafe {
cudarc::driver::sys::cuStreamSynchronize(stream.cu_stream());
}
stream.memcpy_dtoh(&b.sample_indices_u32.slice(..bs), &mut host_idx).unwrap();
for (i, &idx) in host_idx.iter().enumerate() {
assert!(
(idx as usize) < n,
"index {i} = {idx} exceeds buffer size {n}"
);
}
}
}