docs: IQL full integration design spec — 5 components

Covers: PER advantage-weighted staleness, per-sample C51 atom
support centered on V(s), per-branch advantage decomposition,
dual-tau expectile gap exploration, and v_range dead code removal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-13 14:45:26 +02:00
parent cfc44be13c
commit b593f038f7

View File

@@ -0,0 +1,274 @@
# IQL Full Integration Design
## Goal
Wire IQL's V(s) output into every component that can benefit from it: PER priorities, C51 atom support, gradient scaling, and exploration. Eliminate all hardcoded constants — everything derives from the data.
## Architecture
IQL trains V(s) via expectile regression on Q(s, a_taken). A second IQL instance at τ=0.3 provides the expectile gap for uncertainty estimation. The five integration points are:
1. **PER priority modulation with staleness correction** — advantage-weighted replay with age-decay
2. **Per-sample C51 atom support** — each sample gets `[v_min, v_max, delta_z]` centered on V(s)
3. **Per-branch advantage decomposition** — dynamic gradient scaling per action branch
4. **Expectile gap exploration** — state-dependent epsilon from dual-tau uncertainty
5. **Dead code cleanup** — remove batch-level v_range adaptation infrastructure
## Component 1: PER Priority Modulation + Staleness
### Kernel: `iql_modulate_td_errors`
Located in `iql_value_kernel.cu`. Modifies `td_errors_buf [B]` in-place before the seg_tree update.
```
td_errors[b] *= clamp(adv_weights[b], 1/K, K) * exp(-λ * age[b] / τ_stale)
```
- `K = exp(β * 3 * σ_adv)` — dynamic clamp derived from advantage temperature and observed advantage standard deviation
- `σ_adv` — running EMA of advantage standard deviation, updated each step from the batch
- `age[b] = current_step - step_collected[b]` — transition age in training steps
- `λ` — staleness decay rate, config param `iql_staleness_lambda` (default 0.001)
- `τ_stale` — age normalizer, config param `iql_staleness_tau` (default 10000.0)
### Advantage Variance Tracking
A reduce kernel `iql_adv_variance_reduce` computes `mean(adv_weights)` and `var(adv_weights)` from the batch. Output goes to a pinned device-mapped `σ_adv_ema` scalar (host-side EMA update, graph-safe).
### Data Requirements
- `age[b]` is derived from the replay buffer's circular index: `age[b] = (buffer_write_pos - indices[b]) % buffer_capacity`. Higher distance = older transition. No replay buffer schema change needed — `indices [B]` and `buffer_write_pos` are already available in the GPU batch.
### Config Additions
```rust
// DQNHyperparameters
pub iql_staleness_lambda: f32, // default 0.001
pub iql_staleness_tau: f32, // default 10000.0
```
### Files Touched
- `iql_value_kernel.cu` — new kernels: `iql_modulate_td_errors`, `iql_adv_variance_reduce`
- `gpu_iql_trainer.rs` — new buffers: `adv_sigma_ema` (pinned scalar), new methods: `modulate_td_errors`, `update_adv_sigma`
- `fused_training.rs` — call `modulate_td_errors` between IQL step and PER update
- `config.rs` — two new hyperparams
## Component 2: Per-Sample C51 Atom Support
### Design
Each sample gets its own distributional support centered on V(s), with width derived from the Q-value spread around V(s):
```
q_spread(b) = max(|Q_max(s_b) - V(s_b)|, |Q_min(s_b) - V(s_b)|)
half_width(b) = q_spread(b) * (1 + gamma)
v_min(b) = V(s_b) - half_width(b)
v_max(b) = V(s_b) + half_width(b)
delta_z(b) = (v_max(b) - v_min(b)) / (num_atoms - 1)
```
The `(1 + gamma)` headroom factor accounts for the Bellman backup shifting the support.
When `delta_z(b) < 1e-7` (all Q-values identical for this sample — point mass distribution), the sample's loss contribution is skipped. This is the only guard — derived from f32 machine epsilon, not a tuning knob.
### Precomputation Kernel: `iql_compute_per_sample_support`
- Reads: `v_out_buf [B]`, `q_out_buf [B, total_actions]`, `gamma` (scalar)
- Writes: `per_sample_support [B, 3]` — packed `[v_min, v_max, delta_z]` per sample
- Launch: `grid=ceil(B/256), block=256` — one thread per sample, each thread scans `total_actions` Q-values
- Lives in `iql_value_kernel.cu`
### C51 Loss Kernel Changes (`c51_loss_batched`)
- Replace parameter `const float* __restrict__ v_range_buf` (`[2]`) with `const float* __restrict__ per_sample_support` (`[B, 3]`)
- Remove shared `v_min/v_max` read from `v_range_buf`
- Per-sample register loads:
```cuda
float v_min = per_sample_support[sample_id * 3];
float v_max = per_sample_support[sample_id * 3 + 1];
float delta_z = per_sample_support[sample_id * 3 + 2];
```
- Guard: `if (delta_z < 1e-7f) { per_sample_loss[sample_id] = 0.0f; td_errors[sample_id] = 0.0f; return; }`
- `shmem_support` array computed in-kernel: `shmem_support[j] = v_min + j * delta_z`
- Bellman projection uses per-sample `v_min/v_max/delta_z` — same math, per-sample values
### Dead Code Removal
With per-sample support, the following become dead code and are deleted:
- `GpuDqnTrainer::adapt_v_range_full`
- `GpuDqnTrainer::adapt_v_range`
- `GpuDqnTrainer::adapt_v_range_with_gap`
- `GpuDqnTrainer::v_range` (the `[f32; 2]` getter)
- `GpuDqnTrainer::v_range_buf_ptr`
- `v_range_pinned` field and its allocation
- `v_range_dev_ptr` field
- `reward_std_ema` field
- All callers in `training_loop.rs` that pass Q-stats to `adapt_v_range*`
- The `v_range_buf` allocation in `GpuDqnTrainer::new`
### Files Touched
- `iql_value_kernel.cu` — new kernel: `iql_compute_per_sample_support`
- `c51_loss_kernel.cu` — change `v_range_buf [2]` to `per_sample_support [B, 3]`, per-sample support logic
- `gpu_dqn_trainer.rs` — delete v_range infrastructure, add `per_sample_support_buf [B, 3]` allocation, update C51 launch to pass new buffer
- `gpu_iql_trainer.rs` — new method: `compute_per_sample_support`
- `fused_training.rs` — call `compute_per_sample_support` before graph_forward, remove v_range adaptation calls
- `training_loop.rs` — remove `adapt_v_range*` calls and Q-stat plumbing for v_range
### Note on Graph Capture and One-Step Lag
`per_sample_support [B, 3]` depends on `v_out_buf` (from IQL forward) and `q_out_buf` (from `populate_q_out`), which both require graph_forward to have already run. Therefore the C51 kernel inside graph_forward uses `per_sample_support` from the PREVIOUS step (one-step lag). This is the same pattern as the target network — the support adapts with a one-step delay, which provides natural smoothing. On step 0, `per_sample_support` is initialized from Xavier V(s) output and initial Q-values (both small but non-zero).
The buffer pointer is stable (pre-allocated `CudaSlice`), so the CUDA Graph captures the pointer and the per-step content changes are picked up on replay. Same pattern as `states_buf`.
## Component 3: Per-Branch Advantage Decomposition
### Kernel: `iql_per_branch_advantage`
Computes per-branch advantage magnitudes by marginalizing Q over the other 3 branches:
```
For each sample b, for each branch d (0..3):
Q_marginal(b, d, a_d) = mean over other 3 branches of Q(s_b, a) where a has branch d = a_d
A_branch(b, d) = |Q_marginal(b, d, a_taken_d) - V(s_b)|
branch_scale(b, d) = A_branch(b, d) / max_d(A_branch(b, d))
```
- Reads: `q_out_buf [B, total_actions]`, `v_out_buf [B]`, `actions_buf [B]`
- Writes: `branch_scales_buf [B, 4]`
- Launch: `grid=B, block=256` — one block per sample, threads cooperate on marginalization
For 4 branches of size 3 each (81 total actions), each branch marginalization averages over 27 actions. The block's 256 threads handle this cooperatively — 64 threads per branch, each thread handles ceil(27/64) actions.
The `max_d` normalization ensures the highest-advantage branch gets scale 1.0, others get proportionally less. No hardcoded zeros — if magnitude genuinely matters for a state, it gets gradient.
### C51 Grad Kernel Changes (`c51_grad_kernel`)
- New parameter: `const float* __restrict__ branch_scales` — `[B, 4]`
- Line 72: delete `float branch_scale = (d == 1) ? 0.0f : 1.0f;`
- Replace with: `float branch_scale = branch_scales[b * 4 + d];`
### Files Touched
- `iql_value_kernel.cu` — new kernel: `iql_per_branch_advantage`
- `c51_grad_kernel.cu` — replace hardcoded branch_scale with per-sample buffer read
- `gpu_iql_trainer.rs` — new buffer: `branch_scales_buf [B, 4]`, new method: `compute_branch_scales`
- `gpu_dqn_trainer.rs` — update C51 grad kernel launch to pass `branch_scales_buf`
- `fused_training.rs` — call `compute_branch_scales` in `submit_aux_ops`
## Component 4: Expectile Gap Exploration
### Architecture
A second `GpuIqlTrainer` instance at τ=0.3. Both trainers share the same `q_taken_buf` (gathered Q-values), but train separate V(s) networks with different expectile parameters.
The gap `V_0.7(s) - V_0.3(s)` measures advantage distribution spread — high gap = high uncertainty about the best action = explore more.
### Gap Kernel: `iql_expectile_gap`
- Reads: `v_out_high [B]` (τ=0.7 trainer), `v_out_low [B]` (τ=0.3 trainer)
- Writes: `expectile_gap_buf [B]`, `gap_mean_buf [1]` (batch mean)
- Two-phase: element-wise subtraction (`grid=ceil(B/256), block=256`) + sequential mean reduce (`grid=1, block=1`)
### Per-Sample Epsilon Kernel: `iql_compute_per_sample_epsilon`
Converts gap to state-dependent exploration rate:
```
epsilon(b) = base_epsilon * sigmoid(gap[b] / gap_mean - 1.0)
```
- When `gap[b] == gap_mean`: sigmoid(0) = 0.5 → half the base epsilon
- When `gap[b] >> gap_mean`: sigmoid → 1 → full exploration
- When `gap[b] << gap_mean`: sigmoid → 0 → exploit confidently
- `gap_mean` normalizer is relative — no hardcoded thresholds
- Reads: `expectile_gap_buf [B]`, `gap_mean_buf [1]`, `base_epsilon` (scalar)
- Writes: `per_sample_epsilon_buf [B]`
- Launch: `grid=ceil(B/256), block=256`
### Epsilon-Greedy Kernel Changes (`epsilon_greedy_kernel.cu`)
- The `branching_action_select` kernel currently takes a scalar `epsilon`
- Replace with `const float* __restrict__ per_sample_epsilon` — `[B]`
- Each thread reads `per_sample_epsilon[b]` instead of the uniform scalar
### Experience Collector Changes
- `GpuExperienceCollector` gains a `per_sample_epsilon_buf: CudaSlice<f32>` input
- `FusedTrainingCtx` passes the buffer from the IQL low trainer's gap computation
### Second IQL Trainer Initialization
In `FusedTrainingCtx::new`:
```rust
let iql_low_config = GpuIqlConfig {
expectile_tau: 1.0 - hyperparams.iql_expectile_tau, // mirror: 0.7 → 0.3
..iql_config.clone()
};
let gpu_iql_low = GpuIqlTrainer::new(stream.clone(), iql_low_config)?;
```
The low-tau trainer uses the same architecture, same lr, same batch size. Only the expectile parameter differs. The mirroring `1 - tau` ensures the gap is symmetric around the median.
### Files Touched
- `iql_value_kernel.cu` — new kernels: `iql_expectile_gap`, `iql_compute_per_sample_epsilon`
- `epsilon_greedy_kernel.cu` — per-sample epsilon parameter
- `gpu_iql_trainer.rs` — new buffers: `expectile_gap_buf [B]`, `gap_mean_buf [1]`, `per_sample_epsilon_buf [B]`, new methods
- `gpu_action_selector.rs` — pass per-sample epsilon buffer
- `gpu_experience_collector.rs` — accept and forward per-sample epsilon
- `fused_training.rs` — initialize second IQL trainer, run both, compute gap + epsilon
## Component 5: Dead Code Cleanup
All batch-level v_range infrastructure is removed (listed in Component 2). Additionally:
- Delete the `IQL_EXPECTILE_TAU` compile-time define from the old kernel (now a runtime parameter)
- Delete the `total_loss` parameter from the old `iql_forward_loss_kernel` signature (already done in earlier fix)
- Remove `use_iql` config flag — IQL is mandatory (both τ=0.7 and τ=0.3 always active)
- Remove `Option<GpuIqlTrainer>` — make it a plain `GpuIqlTrainer` field
- Delete `if let Some(ref mut iql)` conditionals — replace with direct field access
## Testing Strategy
All features ship together (big bang). Validation:
1. **Compilation**: `SQLX_OFFLINE=true cargo check --workspace`
2. **Smoke test**: `test_no_nan_after_graph_capture` — verifies graph capture + replay with all new kernels
3. **Stability test**: `test_production_training_stability` — 10-epoch training with all features active
4. **Determinism test**: Two identical runs, compare EPOCH_DIAG values — should be bit-identical within-process (zero atomicAdd maintained)
5. **Metrics monitoring**: Track `atom_entropy`, `atom_utilization` (should improve with per-sample support), action diversity per branch (should shift with dynamic branch scaling), and exploration rate distribution (should vary with expectile gap)
## Summary of New CUDA Kernels
| Kernel | File | Launch | Purpose |
|--------|------|--------|---------|
| `iql_modulate_td_errors` | `iql_value_kernel.cu` | `ceil(B/256), 256` | PER priority × advantage × staleness |
| `iql_adv_variance_reduce` | `iql_value_kernel.cu` | `1, 1` | Batch advantage σ for dynamic clamp |
| `iql_compute_per_sample_support` | `iql_value_kernel.cu` | `ceil(B/256), 256` | V(s)-centered `[v_min, v_max, delta_z]` per sample |
| `iql_per_branch_advantage` | `iql_value_kernel.cu` | `B, 256` | Q-marginal → branch scales |
| `iql_expectile_gap` | `iql_value_kernel.cu` | `ceil(B/256), 256` + `1, 1` | V_high - V_low + batch mean |
| `iql_compute_per_sample_epsilon` | `iql_value_kernel.cu` | `ceil(B/256), 256` | Gap → state-dependent epsilon |
## Summary of New GPU Buffers
| Buffer | Shape | Owner | Purpose |
|--------|-------|-------|---------|
| `per_sample_support` | `[B, 3]` | `GpuIqlTrainer` | Per-sample C51 atom support (pointer passed to `GpuDqnTrainer` for C51 launch) |
| `branch_scales_buf` | `[B, 4]` | `GpuIqlTrainer` | Dynamic per-branch gradient scale |
| `expectile_gap_buf` | `[B]` | `GpuIqlTrainer` | V_high - V_low per sample |
| `gap_mean_buf` | `[1]` | `GpuIqlTrainer` | Batch mean of expectile gap |
| `per_sample_epsilon_buf` | `[B]` | `GpuIqlTrainer` | State-dependent exploration rate |
| `adv_sigma_ema` | scalar (pinned) | `GpuIqlTrainer` | Running σ of advantage weights |
## Config Changes
| Field | Type | Default | Purpose |
|-------|------|---------|---------|
| `iql_staleness_lambda` | `f32` | `0.001` | Age-decay rate for PER staleness |
| `iql_staleness_tau` | `f32` | `10000.0` | Age normalizer (steps) |
`use_iql` removed — IQL is mandatory. `iql_expectile_tau` stays (controls high-tau trainer). Low-tau trainer derives from `1 - iql_expectile_tau`.