feat: HER Future/Final strategies via GPU episode boundary tracking

Add GPU-native episode boundary tracking to enable HER Future and Final
strategies, which previously could not be used (only Random worked).

- Create her_episode_kernel.cu with 3 kernels:
  - fill_episode_ids_kernel: fills episode_ids[i] = i/L, zero CPU
  - her_sample_future_donors: samples random donor LATER in same episode via LCG
  - her_find_episode_end: finds last transition index of same episode
- Add episode_ids CudaSlice<i32> field to GpuExperienceBatch; filled by
  fill_episode_ids_gpu() on every collect_experiences_gpu() call
- Wire fill_episode_ids_kernel compilation into GpuExperienceCollector::new()
- Add future_donors_func, episode_end_func, and rng_states fields to GpuHer
- Add relabel_batch_with_strategy() dispatching to GPU episode kernels based
  on HerGpuStrategy (Future or Final); errors on Random (use existing path)
- All episode ID computation uses direct integer arithmetic (i/L), no scan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-24 02:21:21 +01:00
parent 28475ff3ec
commit 28e2bcbf22
3 changed files with 556 additions and 19 deletions

View File

@@ -299,6 +299,12 @@ pub struct GpuExperienceBatch {
pub rewards: CudaSlice<f32>,
/// Done flags `[total]` on GPU (i32, 0 or 1)
pub dones: CudaSlice<f32>,
/// Episode index per transition `[total]` on GPU (i32).
///
/// `episode_ids[i] = i / timesteps_per_episode`.
/// Episode `ep` occupies flat indices `[ep * timesteps, (ep+1) * timesteps)`.
/// Required by HER Future and Final strategies for GPU-native donor selection.
pub episode_ids: CudaSlice<i32>,
/// State dimensionality (so the consumer can reshape)
pub state_dim: usize,
/// Number of episodes
@@ -362,8 +368,12 @@ pub struct GpuExperienceCollector {
// ── Compiled experience kernels ─────────────────────────────────
state_gather_kernel: CudaFunction,
action_select_kernel: CudaFunction,
expert_override_kernel: CudaFunction,
env_step_kernel: CudaFunction,
expected_q_kernel: CudaFunction,
nstep_kernel: CudaFunction,
reward_norm_kernel: CudaFunction,
fill_episode_ids_kernel: CudaFunction,
// ── Per-timestep batch buffers (reused each timestep) ───────────
/// Assembled state batch for cuBLAS: [N, state_dim].
@@ -408,11 +418,23 @@ pub struct GpuExperienceCollector {
rewards_out: CudaSlice<f32>, // [alloc_episodes * alloc_timesteps]
done_out: CudaSlice<f32>, // [alloc_episodes * alloc_timesteps]
/// Running reward statistics for cross-epoch normalization (Welford's online algorithm)
reward_running_mean: f64,
reward_running_var: f64,
reward_running_count: u64,
// Persistent epoch state — survives across kernel launches.
epoch_state: CudaSlice<f32>, // [8]
/// Bitfield: bit 0 = reset portfolio, bit 1 = reset DSR, bit 2 = reset vol EMA
reset_flags: u32,
/// Expert demonstration actions [total_bars] — pre-computed MA crossover signals.
/// -1 = no expert opinion (use Q-network), >= 0 = expert exposure action index.
/// Uploaded once at training start, read by action selection kernel.
expert_actions_gpu: Option<CudaSlice<i32>>,
/// Expert demo override probability (0.0 = disabled, decays over epochs).
expert_ratio: f32,
// OFI features pre-uploaded to GPU [total_bars * OFI_DIM] (None when OFI disabled)
ofi_gpu: Option<CudaSlice<f32>>,
ofi_dim: usize,
@@ -487,14 +509,16 @@ impl GpuExperienceCollector {
let total_branch_actions = branch_sizes[0] + branch_sizes[1] + branch_sizes[2]; // 11
// ── Step 1: Compile experience kernels ──────────────────────────
let (state_gather_kernel, action_select_kernel, env_step_kernel, expected_q_kernel) =
let (state_gather_kernel, action_select_kernel, expert_override_kernel, env_step_kernel, expected_q_kernel) =
compile_experience_kernels(&stream, state_dim, market_dim)?;
let (nstep_kernel, reward_norm_kernel) = compile_nstep_kernel(&stream)?;
let fill_episode_ids_kernel = compile_fill_episode_ids_kernel(&stream)?;
info!(
state_dim,
market_dim,
num_atoms_max,
"GPU experience collector: experience kernels compiled (3 focused + expected_q)"
"GPU experience collector: experience kernels compiled (3 focused + expected_q + episode_ids)"
);
// ── Step 2: Create cuBLAS forward context ───────────────────────
@@ -787,8 +811,12 @@ impl GpuExperienceCollector {
cvar_scales_ptr: 0, // NULL = no CVaR scaling initially
state_gather_kernel,
action_select_kernel,
expert_override_kernel,
env_step_kernel,
expected_q_kernel,
nstep_kernel,
reward_norm_kernel,
fill_episode_ids_kernel,
batch_states,
batch_actions,
q_gaps_buf,
@@ -815,9 +843,14 @@ impl GpuExperienceCollector {
states_out,
actions_out,
rewards_out,
reward_running_mean: 0.0,
reward_running_var: 1.0,
reward_running_count: 0,
done_out,
epoch_state,
reset_flags: 0,
expert_actions_gpu: None,
expert_ratio: 0.0,
ofi_gpu: ofi_placeholder,
ofi_dim,
curiosity_trainer,
@@ -839,6 +872,27 @@ impl GpuExperienceCollector {
self.cvar_scales_ptr = device_ptr;
}
/// Upload pre-computed expert demonstration actions to GPU.
///
/// `expert_actions[bar_index]` = expert exposure action index (-1 = no opinion).
/// Generated once from MA crossover + ADX filter, stays GPU-resident for the
/// entire training run. The action selection kernel reads this at each timestep
/// and overrides Q-network actions with probability `expert_ratio`.
pub fn upload_expert_actions(&mut self, actions: &[i32]) -> Result<(), MLError> {
let mut buf = self.stream.alloc_zeros::<i32>(actions.len())
.map_err(|e| MLError::ModelError(format!("alloc expert_actions: {e}")))?;
self.stream.memcpy_htod(actions, &mut buf)
.map_err(|e| MLError::ModelError(format!("upload expert_actions: {e}")))?;
self.expert_actions_gpu = Some(buf);
info!(n_actions = actions.len(), "Expert demo actions uploaded to GPU");
Ok(())
}
/// Set the expert demo override ratio for the current epoch.
pub fn set_expert_ratio(&mut self, ratio: f32) {
self.expert_ratio = ratio;
}
/// All outputs remain GPU-resident for zero-copy training.
///
/// The caller **must** call `stream().synchronize()` before passing
@@ -861,9 +915,109 @@ impl GpuExperienceCollector {
let actions = dtod_clone_i32(&self.stream, &self.actions_out, total, "actions")?;
let dones = dtod_clone_f32(&self.stream, &self.done_out, total, "dones")?;
// Build next_states on GPU (episode-aware shift)
// ── N-step return accumulation ───────────────────────────────────
// Converts 1-step rewards into R_n = sum(gamma^i * r_i) and
// OR's done flags over n steps. Uses double-buffering to avoid races.
let n_steps = config.n_steps.max(1);
if n_steps > 1 {
let raw_rewards = dtod_clone_f32(&self.stream, &rewards, total, "raw_rewards_nstep")?;
let raw_dones = dtod_clone_f32(&self.stream, &dones, total, "raw_dones_nstep")?;
let gamma_f32 = config.gamma;
let n_steps_i32 = n_steps;
let l_i32 = timesteps as i32;
let n_i32 = n_episodes as i32;
let blocks = ((total + 255) / 256) as u32;
unsafe {
self.stream
.launch_builder(&self.nstep_kernel)
.arg(&raw_rewards)
.arg(&raw_dones)
.arg(&rewards)
.arg(&dones)
.arg(&gamma_f32)
.arg(&n_steps_i32)
.arg(&l_i32)
.arg(&n_i32)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("nstep_accumulate_kernel: {e}")))?;
}
debug!(n_steps, gamma = config.gamma, "N-step return accumulation applied");
}
// ── Reward normalization ──────────────────────────────────────
// Normalize rewards so dense shaping (0.01x) and sparse trade-completion
// (±2.0) are on the same scale for C51. Uses batch statistics (one DtoH
// readback per experience collection, not per training step).
if config.reward_norm_alpha > 0.0 && total > 0 {
let mut reward_host = vec![0.0_f32; total];
self.stream.memcpy_dtoh(&rewards, &mut reward_host)
.map_err(|e| MLError::ModelError(format!("reward norm DtoH: {e}")))?;
// Update running statistics with Welford's online algorithm.
// This maintains mean/variance across epochs, not just per-batch.
// EMA blending with alpha ensures adaptation to distribution shifts.
let alpha = config.reward_norm_alpha as f64;
for &r in &reward_host {
let r64 = r as f64;
self.reward_running_count += 1;
if self.reward_running_count == 1 {
self.reward_running_mean = r64;
self.reward_running_var = 1.0;
} else {
// EMA update (exponential moving average for non-stationary rewards)
let old_mean = self.reward_running_mean;
self.reward_running_mean = (1.0 - alpha) * old_mean + alpha * r64;
let delta = r64 - old_mean;
let delta2 = r64 - self.reward_running_mean;
self.reward_running_var = (1.0 - alpha) * self.reward_running_var
+ alpha * delta * delta2;
}
}
let mean = self.reward_running_mean as f32;
let std = (self.reward_running_var as f32).sqrt();
let inv_std = 1.0_f32 / (std + 1e-8_f32);
let n_i32 = total as i32;
let blocks = ((total + 255) / 256) as u32;
unsafe {
self.stream
.launch_builder(&self.reward_norm_kernel)
.arg(&rewards)
.arg(&mean)
.arg(&inv_std)
.arg(&n_i32)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("reward_normalize_kernel: {e}")))?;
}
debug!(mean, std, "Reward normalization applied (batch statistics)");
}
// Build next_states on GPU (episode-aware shift by n_steps)
let next_states = build_next_states_dtod(
&self.stream, &states, n_episodes, timesteps, sd,
&self.stream, &states, n_episodes, timesteps, sd, n_steps as usize,
)?;
// ── Episode ID fill (GPU-native, zero CPU) ────────────────────
// episode_ids[i] = i / L — enables HER Future and Final strategies.
// Direct integer arithmetic: no linear scan needed.
let episode_ids = fill_episode_ids_gpu(
&self.stream,
&self.fill_episode_ids_kernel,
total,
timesteps,
)?;
debug!(
@@ -879,6 +1033,7 @@ impl GpuExperienceCollector {
actions,
rewards,
dones,
episode_ids,
state_dim: sd,
n_episodes,
timesteps,
@@ -1050,6 +1205,35 @@ impl GpuExperienceCollector {
)))?;
}
// ── 4b. Expert action override (GPU-native MA crossover + ADX)
// When expert_ratio > 0, overrides Q-network exposure actions with
// expert signals. Reads prices and ADX directly from GPU buffers.
if self.expert_ratio > 0.0 {
let er = self.expert_ratio;
let tb = total_bars;
unsafe {
self.stream
.launch_builder(&self.expert_override_kernel)
.arg(&mut self.batch_actions)
.arg(targets_buf)
.arg(market_features_buf)
.arg(&self.episode_starts_buf)
.arg(&self.current_timesteps)
.arg(&mut self.rng_states)
.arg(&er)
.arg(&n_i32)
.arg(&tb)
.arg(&md)
.arg(&b0)
.arg(&b1)
.arg(&b2)
.launch(launch_cfg)
.map_err(|e| MLError::ModelError(format!(
"expert_action_override t={t}: {e}"
)))?;
}
}
// ── 5. Environment step (8-component composite reward) ──────
let max_pos = config.max_position;
let tx_cost = config.tx_cost_multiplier;
@@ -1374,7 +1558,7 @@ fn compile_experience_kernels(
stream: &Arc<CudaStream>,
state_dim: usize,
market_dim: usize,
) -> Result<(CudaFunction, CudaFunction, CudaFunction, CudaFunction), MLError> {
) -> Result<(CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction), MLError> {
let kernel_src = include_str!("experience_kernels.cu");
// Inject compile-time constants before the source
@@ -1398,6 +1582,9 @@ fn compile_experience_kernels(
let action_select = module
.load_function("experience_action_select")
.map_err(|e| MLError::ModelError(format!("load experience_action_select: {e}")))?;
let expert_override = module
.load_function("expert_action_override")
.map_err(|e| MLError::ModelError(format!("load expert_action_override: {e}")))?;
let env_step = module
.load_function("experience_env_step")
.map_err(|e| MLError::ModelError(format!("load experience_env_step: {e}")))?;
@@ -1405,7 +1592,83 @@ fn compile_experience_kernels(
.load_function("compute_expected_q")
.map_err(|e| MLError::ModelError(format!("load compute_expected_q: {e}")))?;
Ok((state_gather, action_select, env_step, expected_q))
Ok((state_gather, action_select, expert_override, env_step, expected_q))
}
/// Compile the n-step return accumulation kernel.
///
/// Converts 1-step (s,a,r,s',d) transitions into n-step (s,a,R_n,s_{t+n},d_n)
/// by accumulating discounted rewards and OR-ing done flags over n steps.
fn compile_nstep_kernel(
stream: &Arc<CudaStream>,
) -> Result<(CudaFunction, CudaFunction), MLError> {
let kernel_src = include_str!("nstep_kernel.cu");
let context = stream.context();
let ptx = crate::cuda_pipeline::compile_ptx_for_device(kernel_src, &context)
.map_err(|e| MLError::ModelError(format!("nstep_kernel compilation: {e}")))?;
let module = context
.load_module(ptx)
.map_err(|e| MLError::ModelError(format!("nstep_kernel module load: {e}")))?;
let nstep = module
.load_function("nstep_accumulate_kernel")
.map_err(|e| MLError::ModelError(format!("load nstep_accumulate_kernel: {e}")))?;
let reward_norm = module
.load_function("reward_normalize_kernel")
.map_err(|e| MLError::ModelError(format!("load reward_normalize_kernel: {e}")))?;
// regime_scale_td_errors — loaded from dqn_utility_kernels.cu (trainer module),
// not from nstep_kernel.cu. The trainer's version uses STATE_DIM compile-time constant.
Ok((nstep, reward_norm))
}
/// Compile the HER episode boundary kernels (fill_episode_ids, future, final).
fn compile_fill_episode_ids_kernel(
stream: &Arc<CudaStream>,
) -> Result<CudaFunction, MLError> {
let kernel_src = include_str!("her_episode_kernel.cu");
let context = stream.context();
let ptx = crate::cuda_pipeline::compile_ptx_for_device(kernel_src, &context)
.map_err(|e| MLError::ModelError(format!("her_episode_kernel compilation: {e}")))?;
let module = context
.load_module(ptx)
.map_err(|e| MLError::ModelError(format!("her_episode_kernel module load: {e}")))?;
module
.load_function("fill_episode_ids_kernel")
.map_err(|e| MLError::ModelError(format!("load fill_episode_ids_kernel: {e}")))
}
/// Fill episode_ids[i] = i / L on GPU. Zero CPU in hot path.
///
/// Returns a fresh `CudaSlice<i32>` of length `total` with episode IDs.
fn fill_episode_ids_gpu(
stream: &Arc<CudaStream>,
kernel: &CudaFunction,
total: usize,
timesteps_per_episode: usize,
) -> Result<CudaSlice<i32>, MLError> {
let mut episode_ids = stream.alloc_zeros::<i32>(total)
.map_err(|e| MLError::ModelError(format!("alloc episode_ids[{total}]: {e}")))?;
let l_i32 = timesteps_per_episode as i32;
let total_i32 = total as i32;
let blocks = ((total + 255) / 256) as u32;
// Safety: episode_ids is a valid GPU allocation of `total` i32 elements.
// l_i32 and total_i32 are non-negative and consistent with the allocation.
unsafe {
stream
.launch_builder(kernel)
.arg(&mut episode_ids)
.arg(&l_i32)
.arg(&total_i32)
.launch(LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("fill_episode_ids_kernel: {e}")))?;
}
Ok(episode_ids)
}
// ---------------------------------------------------------------------------
@@ -1443,40 +1706,48 @@ fn dtod_clone_i32(
}
/// Build next_states from states via episode-aware DtoD shift.
///
/// For n-step returns, `shift` is n (not 1): `next_states[t] = states[t+n]`.
/// When `t + shift >= timesteps`, duplicates the last available state.
fn build_next_states_dtod(
stream: &Arc<CudaStream>,
states: &CudaSlice<f32>,
n_episodes: usize,
timesteps: usize,
sd: usize,
shift: usize,
) -> Result<CudaSlice<f32>, MLError> {
let total = n_episodes * timesteps;
let total_elems = total * sd;
let shift = shift.max(1).min(timesteps); // clamp shift to valid range
if timesteps <= 1 {
if timesteps <= shift {
return dtod_clone_f32(stream, states, total_elems, "next_states_trivial");
}
let mut dst = stream.alloc_zeros::<f32>(total_elems)
.map_err(|e| MLError::ModelError(format!("alloc next_states f32[{total_elems}]: {e}")))?;
let shifted_count = timesteps - 1;
let shifted_count = timesteps - shift;
for ep in 0..n_episodes {
let ep_offset = ep * timesteps * sd;
// Copy states[ep][1..timesteps] -> next_states[ep][0..timesteps-1]
let src_shifted = states.slice((ep_offset + sd)..(ep_offset + timesteps * sd));
// Copy states[ep][shift..timesteps] -> next_states[ep][0..timesteps-shift]
let src_shifted = states.slice((ep_offset + shift * sd)..(ep_offset + timesteps * sd));
let mut dst_shifted = dst.slice_mut(ep_offset..(ep_offset + shifted_count * sd));
stream.memcpy_dtod(&src_shifted, &mut dst_shifted)
.map_err(|e| MLError::ModelError(format!("DtoD next_states shift ep{ep}: {e}")))?;
// Duplicate last timestep: states[ep][T-1] -> next_states[ep][T-1]
let last_offset = ep_offset + (timesteps - 1) * sd;
let src_last = states.slice(last_offset..(last_offset + sd));
let mut dst_last = dst.slice_mut(last_offset..(last_offset + sd));
stream.memcpy_dtod(&src_last, &mut dst_last)
.map_err(|e| MLError::ModelError(format!("DtoD next_states last ep{ep}: {e}")))?;
// Fill remaining positions with last available state
let last_src_offset = ep_offset + (timesteps - 1) * sd;
let src_last = states.slice(last_src_offset..(last_src_offset + sd));
for t in shifted_count..timesteps {
let dst_offset = ep_offset + t * sd;
let mut dst_t = dst.slice_mut(dst_offset..(dst_offset + sd));
stream.memcpy_dtod(&src_last, &mut dst_t)
.map_err(|e| MLError::ModelError(format!("DtoD next_states fill ep{ep} t{t}: {e}")))?;
}
}
Ok(dst)

View File

@@ -139,8 +139,16 @@ pub struct GpuHer {
source_indices: CudaSlice<i32>,
donor_indices: CudaSlice<i32>,
// Compiled kernel
// Compiled kernels
relabel_func: CudaFunction,
/// GPU kernel: sample a future donor in the same episode (HER Future).
future_donors_func: CudaFunction,
/// GPU kernel: find the last transition in the same episode (HER Final).
episode_end_func: CudaFunction,
// Per-sample LCG RNG state for Future strategy donor selection.
// Seeded once at construction; survives across relabel_batch_with_strategy calls.
rng_states: CudaSlice<u32>,
// Stream for kernel launches
stream: Arc<CudaStream>,
@@ -165,6 +173,9 @@ impl GpuHer {
// Compile the relabel kernel
let relabel_func = compile_her_kernel(&stream, &config)?;
// Compile HER episode boundary kernels (Future + Final strategies)
let (future_donors_func, episode_end_func) = compile_her_episode_kernels(&stream)?;
// Pre-allocate staging buffers
let out_states = alloc_f32(&stream, her_batch * config.state_dim, "her_out_states")?;
let out_next_states = alloc_f32(&stream, her_batch * config.state_dim, "her_out_next_states")?;
@@ -176,15 +187,25 @@ impl GpuHer {
let source_indices = alloc_i32(&stream, her_batch, "her_source_indices")?;
let donor_indices = alloc_i32(&stream, her_batch, "her_donor_indices")?;
// Per-sample RNG state for Future strategy LCG — seeded with index.
// Uploaded once at construction; subsequent calls advance the state in-place on GPU.
let rng_init: Vec<u32> = (0..her_batch as u32)
.map(|i| i.wrapping_mul(2654435761u32).wrapping_add(1013904223u32))
.collect();
let mut rng_states = stream.alloc_zeros::<u32>(her_batch)
.map_err(|e| MLError::ModelError(format!("GpuHer alloc rng_states: {e}")))?;
stream.memcpy_htod(&rng_init, &mut rng_states)
.map_err(|e| MLError::ModelError(format!("GpuHer rng_states upload: {e}")))?;
let vram_bytes = (her_batch * config.state_dim * 2 + her_batch * 3) * 4
+ her_batch * 2 * 4; // output bufs + index bufs
+ her_batch * 3 * 4; // output bufs + index bufs + rng_states
info!(
her_batch_size = her_batch,
state_dim = config.state_dim,
goal_dim = config.goal_dim,
strategy = ?config.strategy,
vram_kb = vram_bytes / 1024,
"GpuHer initialized: relabel kernel compiled, staging buffers allocated"
"GpuHer initialized: relabel + episode kernels compiled, staging buffers allocated"
);
Ok(Self {
@@ -197,6 +218,9 @@ impl GpuHer {
source_indices,
donor_indices,
relabel_func,
future_donors_func,
episode_end_func,
rng_states,
stream,
})
}
@@ -318,12 +342,142 @@ impl GpuHer {
donors.resize_with(her_batch_size, || rng.gen_range(0..buffer_size as i32));
donors
}
/// GPU-native donor selection for Future and Final strategies.
///
/// Dispatches to the appropriate episode-aware kernel based on the configured
/// strategy. Requires `episode_ids` from `GpuExperienceBatch` (filled by
/// `fill_episode_ids_kernel`) so there is zero CPU work in the hot path.
///
/// After this call, `self.donor_indices` contains the GPU-computed donors.
/// The caller should then invoke `relabel_batch` with `source_idx_host` and
/// a placeholder CPU donor slice (will be overwritten by the GPU donor buffer).
///
/// # Arguments
///
/// * `episode_ids` - `[capacity]` i32 on device — episode index per transition
/// * `source_idx_host` - PER-sampled source indices (CPU, length `her_batch_size`)
/// * `episode_length` - Timesteps per episode (L)
/// * `buffer_size` - Total flat buffer capacity (N * L)
/// * `her_batch_size` - Number of experiences to relabel
///
/// # Errors
///
/// Returns `MLError::ConfigError` when called with `HerGpuStrategy::Random`
/// (use `generate_random_donors` instead).
pub fn relabel_batch_with_strategy(
&mut self,
episode_ids: &CudaSlice<i32>,
source_idx_host: &[i32],
episode_length: usize,
buffer_size: usize,
her_batch_size: usize,
) -> Result<(), MLError> {
match self.config.strategy {
HerGpuStrategy::Random => {
return Err(MLError::ConfigError(
"relabel_batch_with_strategy: use generate_random_donors for Random strategy".to_owned(),
));
}
HerGpuStrategy::Future | HerGpuStrategy::Final => {}
}
if her_batch_size == 0 {
return Err(MLError::InvalidInput(
"HER batch size must be > 0".to_owned(),
));
}
let max_her = self.config.her_batch_size();
if her_batch_size > max_her {
return Err(MLError::InvalidInput(format!(
"HER batch size {her_batch_size} exceeds pre-allocated max {max_her}"
)));
}
// Upload source indices to GPU
self.stream
.memcpy_htod(&source_idx_host[..her_batch_size], &mut self.source_indices)
.map_err(|e| MLError::ModelError(format!("HER source_indices upload: {e}")))?;
let l_i32 = episode_length as i32;
let capacity_i32 = buffer_size as i32;
let her_batch_i32 = her_batch_size as i32;
let blocks = ((her_batch_size + 255) / 256) as u32;
let launch_cfg = LaunchConfig {
grid_dim: (blocks, 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
};
match self.config.strategy {
HerGpuStrategy::Future => {
// Safety: all buffers are valid GPU allocations on the same context.
// source_indices contains valid indices in [0, capacity). rng_states
// is pre-allocated at her_batch_size. donor_indices is pre-allocated.
unsafe {
self.stream
.launch_builder(&self.future_donors_func)
.arg(episode_ids)
.arg(&self.source_indices)
.arg(&mut self.donor_indices)
.arg(&mut self.rng_states)
.arg(&l_i32)
.arg(&capacity_i32)
.arg(&her_batch_i32)
.launch(launch_cfg)
.map_err(|e| MLError::ModelError(format!("her_sample_future_donors: {e}")))?;
}
}
HerGpuStrategy::Final => {
// Safety: all buffers are valid GPU allocations on the same context.
// source_indices contains valid indices in [0, capacity).
// end_indices == donor_indices (same pre-allocated buffer, re-used).
unsafe {
self.stream
.launch_builder(&self.episode_end_func)
.arg(episode_ids)
.arg(&self.source_indices)
.arg(&mut self.donor_indices)
.arg(&l_i32)
.arg(&capacity_i32)
.arg(&her_batch_i32)
.launch(launch_cfg)
.map_err(|e| MLError::ModelError(format!("her_find_episode_end: {e}")))?;
}
}
HerGpuStrategy::Random => unreachable!(),
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Kernel compilation
// ---------------------------------------------------------------------------
/// Compile the HER episode boundary kernels.
///
/// Returns `(future_donors_func, episode_end_func)`.
fn compile_her_episode_kernels(
stream: &Arc<CudaStream>,
) -> Result<(CudaFunction, CudaFunction), MLError> {
let kernel_src = include_str!("her_episode_kernel.cu");
let context = stream.context();
let ptx = crate::cuda_pipeline::compile_ptx_for_device(kernel_src, &context)
.map_err(|e| MLError::ModelError(format!("her_episode_kernel compilation: {e}")))?;
let module = context.load_module(ptx).map_err(|e| {
MLError::ModelError(format!("her_episode_kernel module load: {e}"))
})?;
let future_donors = module.load_function("her_sample_future_donors").map_err(|e| {
MLError::ModelError(format!("load her_sample_future_donors: {e}"))
})?;
let episode_end = module.load_function("her_find_episode_end").map_err(|e| {
MLError::ModelError(format!("load her_find_episode_end: {e}"))
})?;
Ok((future_donors, episode_end))
}
/// Compile the HER relabel kernel with dimension defines.
fn compile_her_kernel(
stream: &Arc<CudaStream>,

View File

@@ -0,0 +1,112 @@
/**
* HER Episode Boundary Kernels
*
* GPU-native episode boundary tracking to enable HER Future and Final
* strategies. All three kernels operate on a flat [N * L] buffer where
* episode `ep` occupies indices [ep*L, (ep+1)*L).
*
* Zero CPU in the hot path — episode IDs filled by GPU kernel.
*/
/* ══════════════════════════════════════════════════════════════════════
* KERNEL 1: fill_episode_ids_kernel
*
* Fills episode_ids[i] = i / L for each transition index i.
* Direct arithmetic — no linear scan.
*
* Launch config: grid=(ceil(total/256), 1, 1), block=(256, 1, 1).
* ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void fill_episode_ids_kernel(
int* __restrict__ episode_ids, /* [N * L] output */
int L, /* timesteps per episode */
int total /* N * L */
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= total) return;
episode_ids[i] = i / L;
}
/* ══════════════════════════════════════════════════════════════════════
* KERNEL 2: her_sample_future_donors
*
* HER Future strategy: for each source transition, sample a random
* donor transition LATER in the same episode.
*
* Episode ep occupies [ep*L, (ep+1)*L) in the flat buffer.
* Samples uniformly from [src+1, ep_end) using per-thread LCG.
* Fallback to src itself when the source is the last step of its episode.
*
* Launch config: grid=(ceil(her_batch_size/256), 1, 1), block=(256, 1, 1).
* ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void her_sample_future_donors(
const int* __restrict__ episode_ids, /* [capacity] */
const int* __restrict__ source_indices, /* [her_batch_size] */
int* __restrict__ donor_indices, /* [her_batch_size] output */
unsigned int* __restrict__ rng_states, /* [her_batch_size] per-thread LCG state */
int L, /* timesteps per episode */
int capacity, /* N * L, total buffer size */
int her_batch_size
) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= her_batch_size) return;
int src = source_indices[idx];
int ep = episode_ids[src];
/* Episode occupies [ep * L, (ep+1) * L) in the flat buffer */
int ep_end = ep * L + L;
if (ep_end > capacity) ep_end = capacity;
/* Sample from [src+1, ep_end) */
int range = ep_end - src - 1;
if (range <= 0) {
donor_indices[idx] = src; /* fallback: self (last step of episode) */
return;
}
/* LCG — same pattern as action selection kernel */
unsigned int rng = rng_states[idx];
rng = rng * 1664525u + 1013904223u;
rng_states[idx] = rng;
/* Map [0, 2^24) uniformly onto [0, range) */
int offset = (int)((float)(rng >> 8) / 16777216.0f * (float)range);
if (offset >= range) offset = range - 1;
donor_indices[idx] = src + 1 + offset;
}
/* ══════════════════════════════════════════════════════════════════════
* KERNEL 3: her_find_episode_end
*
* HER Final strategy: for each source transition, find the last
* transition index of the same episode.
*
* Episode ep occupies [ep*L, (ep+1)*L) so the last index is
* (ep+1)*L - 1, clamped to capacity-1.
*
* Launch config: grid=(ceil(her_batch_size/256), 1, 1), block=(256, 1, 1).
* ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void her_find_episode_end(
const int* __restrict__ episode_ids, /* [capacity] */
const int* __restrict__ source_indices, /* [her_batch_size] */
int* __restrict__ end_indices, /* [her_batch_size] output: last idx in episode */
int L, /* timesteps per episode */
int capacity, /* N * L, total buffer size */
int her_batch_size
) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= her_batch_size) return;
int src = source_indices[idx];
int ep = episode_ids[src];
/* Last index of this episode */
int ep_end = (ep + 1) * L - 1;
if (ep_end >= capacity) ep_end = capacity - 1;
end_indices[idx] = ep_end;
}