- 4 stream.clone_htod sites in IQN constructor: cos_features precompute, online_taus + target_taus tile broadcast, init_iqn_xavier_weights upload — all rewritten to mapped_pinned::clone_to_device_f32_via_pinned. - clone_cuda_slice helper rewritten to a single DtoD copy via the existing super::clone_cuda_slice_f32 helper. Previously did device→host→device, double-violating both no-DtoH and no-HtoD rules. Sole callsite is constructor seeding of target params from online. Audit row appended (Fix 9) in docs/dqn-gpu-hot-path-audit.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
DQN GPU Hot-Path Audit
Invariant 3 enforcement. Every cross-boundary call (DtoH, HtoD, memcpy between host and device) in the DQN code path is classified below. No hot-path memcpy DtoH/HtoD is allowed — zero tolerance. Pinned + device-mapped memory is the ONLY permitted mechanism for per-step host-visible data.
Hot path definition: every function called from fused_training.rs::run_full_step or its descendants that executes once per training step (up to 178 times per epoch at batch size 8192), including kernel invocations inside the captured CUDA graph (adam_grad_child, forward_child, aux_grad_child, aux_child, maintenance_child) and host-side orchestration between them.
Classification:
OK-pinned— uses the zero-copy pinned + device-mapped pattern (cuMemAllocHost_v2+cuMemHostGetDevicePointer_v2); allowed on hot path.MIGRATE— was a plainmemcpybut has been converted to pinned in this commit.COLD-PATH— acceptable placement (constructor init, checkpoint save/load, fold transition, epoch-boundary); annotated with reason.
| File:line | Call signature | Path class | Annotation | Status |
|---|---|---|---|---|
gpu_dqn_trainer.rs:6586 |
malloc_host → total_loss_pinned |
OK-pinned | Constructor: cuMemAllocHost+GetDevicePointer; GPU writes via dev ptr, CPU reads via readback_training_scalars() |
OK |
gpu_dqn_trainer.rs:6598 |
malloc_host → mse_loss_pinned |
OK-pinned | Constructor: same pattern as total_loss_pinned |
OK |
gpu_dqn_trainer.rs:6611 |
malloc_host → q_divergence_pinned |
OK-pinned | Constructor: same pattern | OK |
gpu_dqn_trainer.rs:6624 |
malloc_host → iqn_readiness_pinned |
OK-pinned | Constructor: same pattern | OK |
gpu_dqn_trainer.rs:6662 |
malloc_host → grad_norm_pinned |
OK-pinned | Constructor: same pattern | OK |
gpu_dqn_trainer.rs:6707 |
malloc_host (plain, not DEVICEMAP) → readback scratch |
COLD-PATH | Constructor: plain pinned for per-epoch flush_readback(); not written by GPU kernel directly |
OK |
gpu_dqn_trainer.rs:6788 |
malloc_host → monitoring summary pinned |
OK-pinned | Constructor: monitoring kernel writes via dev ptr each step | OK |
gpu_dqn_trainer.rs:6895 |
malloc_host → eval_v_range_pinned |
OK-pinned | Constructor: GPU writes; host reads only at fold/epoch boundary via eval_v_range() |
OK |
gpu_dqn_trainer.rs:6911 |
malloc_host → nan_flag_pinned |
OK-pinned | Constructor: GPU writes on NaN; host reads only in fault path (read_nan_flags) |
OK |
gpu_dqn_trainer.rs:6929 |
malloc_host → ISV isv_signals_pinned |
OK-pinned | Constructor: GPU writes ISV bus each step; host reads via read_isv_health_and_regime() in compute_adaptive_budgets |
OK |
gpu_dqn_trainer.rs:6949 |
malloc_host → lr_pinned |
OK-pinned | Constructor: CPU writes LR; GPU reads via dev ptr | OK |
gpu_dqn_trainer.rs:6968 |
malloc_host → adaptive_clip_pinned |
OK-pinned | Constructor: CPU writes clip; GPU reads via dev ptr | OK |
gpu_dqn_trainer.rs:6988 |
malloc_host → popart_pinned |
OK-pinned | Constructor: GPU writes PopArt stats; host reads at epoch boundary | OK |
gpu_dqn_trainer.rs:7099 |
malloc_host → q_stats_pinned |
OK-pinned | Constructor: GPU writes Q stats; host reads via reduce_current_q_stats() at epoch boundary |
OK |
gpu_dqn_trainer.rs:7127 |
malloc_host → q_readback_pinned |
OK-pinned | Constructor: GPU writes per-step Q readback; host reads via last_readback_scalars() in training guard |
OK |
gpu_dqn_trainer.rs:7206 |
cuMemAllocHost_v2 → var_ema pinned (ISV subsystem) |
OK-pinned | Constructor: ISV variance EMA — GPU writes each step; host reads via ISV bus | OK |
gpu_dqn_trainer.rs:7243 |
cuMemAllocHost_v2 → regime_util pinned (ISV subsystem) |
OK-pinned | Constructor: ISV regime utilization — GPU writes each step | OK |
gpu_dqn_trainer.rs:7289 |
malloc_host → popart_var_pinned |
OK-pinned | Constructor: GPU writes PopArt variance; host reads at epoch boundary | OK |
gpu_dqn_trainer.rs:7312 |
cuMemcpyHtoDAsync_v2 (constructor) |
COLD-PATH | Constructor: one-shot HtoD to seed deterministic-mask buffer; never replayed | OK |
gpu_dqn_trainer.rs:7878 |
cuMemAllocHost_v2 → q_mean_scratch |
OK-pinned | Constructor: GPU scratch for Q-mean reduction kernel | OK |
gpu_dqn_trainer.rs:7898 |
cuMemAllocHost_v2 → q_mean_ema |
OK-pinned | Constructor: Q-mean EMA — GPU writes, host reads at epoch boundary | OK |
gpu_dqn_trainer.rs:7919 |
cuMemAllocHost_v2 → td_error_scratch |
OK-pinned | Constructor: TD-error scratch for reduce kernel | OK |
gpu_dqn_trainer.rs:7939 |
cuMemAllocHost_v2 → q_var_scratch |
OK-pinned | Constructor: Q-variance scratch for reduce kernel | OK |
gpu_dqn_trainer.rs:2300 |
stream.memcpy_htod → atom_positions_buf |
COLD-PATH | set_atom_positions(): called at fold boundary to reload C51 atom grid — not on hot path |
OK |
gpu_dqn_trainer.rs:2606 |
stream.memcpy_dtoh → grad buf readback |
COLD-PATH | per_branch_grad_norms(): per-epoch call only; drains grad buffer for logging |
OK |
gpu_dqn_trainer.rs:2925 |
stream.memcpy_dtoh (online params) |
COLD-PATH | per_branch_target_drift(): per-epoch drift measurement |
OK |
gpu_dqn_trainer.rs:2928 |
stream.memcpy_dtoh (target params) |
COLD-PATH | per_branch_target_drift(): per-epoch drift measurement |
OK |
gpu_dqn_trainer.rs:2991 |
stream.memcpy_dtoh → weight inspection |
COLD-PATH | per_branch_vsn_mean(): per-epoch VSN mean read |
OK |
gpu_dqn_trainer.rs:3072 |
stream.memcpy_dtoh → Q-out inspection |
COLD-PATH | compute_q_stats(): per-epoch Q-stats; not called per-step |
OK |
gpu_dqn_trainer.rs:7541 |
stream.memcpy_htod → descriptor buffer |
COLD-PATH | Constructor: one-shot HtoD for GEMM descriptor upload | OK |
gpu_dqn_trainer.rs:7680 |
stream.memcpy_htod → stochastic depth scale |
COLD-PATH | Constructor: one-shot HtoD for stochastic depth kernel seed | OK |
gpu_dqn_trainer.rs:7686 |
stream.memcpy_htod → stochastic depth RNG |
COLD-PATH | Constructor: one-shot HtoD for stochastic depth RNG | OK |
gpu_dqn_trainer.rs (removed) |
cuMemcpyDtoHAsync_v2 in run_causal_intervention_unconditional |
MIGRATED | Fix 1: removed dead copy — result causal_mean_scratch was never consumed by any caller; result stays on GPU |
FIXED |
gpu_iqn_head.rs:469 |
stream.clone_htod → cos_features |
COLD-PATH | Constructor: precompute cosine embedding table; one-shot | OK |
gpu_iqn_head.rs:477 |
stream.clone_htod → online_taus |
COLD-PATH | Constructor: tau quantile tiling; one-shot | OK |
gpu_iqn_head.rs:480 |
stream.clone_htod → target_taus |
COLD-PATH | Constructor: tau quantile tiling; one-shot | OK |
gpu_iqn_head.rs:498 |
malloc_host → total_loss_pinned |
OK-pinned | Constructor: GPU writes IQN loss; host reads via read_total_loss() |
OK |
gpu_iqn_head.rs:534 |
malloc_host → t_pinned |
OK-pinned | Constructor: Adam step counter — CPU increments, GPU reads via dev ptr | OK |
gpu_iqn_head.rs:548 |
malloc_host → tau_pinned |
OK-pinned | Constructor (Fix 3): tau scalar — CPU writes, GPU reads via tau_dev_ptr; replaces CudaSlice<f32> + cuMemcpyHtoDAsync_v2 |
OK |
gpu_iqn_head.rs:807 |
cuMemcpyDtoDAsync_v2 (DtoD) |
OK-pinned | Device-to-device copy inside graph-captured target-H forward cache; no host crossing | OK |
gpu_iqn_head.rs:991 |
cuMemcpyDtoDAsync_v2 (DtoD) |
OK-pinned | Device-to-device copy for cached target states; no host crossing | OK |
gpu_iqn_head.rs:1564 |
stream.clone_dtoh → total_loss |
COLD-PATH | read_loss(): synchronous fallback for epoch-end logging only; read_total_loss() (pinned) is the hot-path equivalent |
OK |
gpu_iqn_head.rs:1602 |
cuMemcpyDtoH_v2 (sync) in compute_iqr |
MIGRATED | Fix 2A: compute_iqr() removed from submit_aux_ops; now called at epoch boundary via refresh_iqn_iqr() only; sync DtoH cannot be captured in CUDA graph — was silently a no-op on replayed steps |
FIXED |
gpu_iqn_head.rs:1622 |
cuMemcpyHtoDAsync_v2 in compute_iqr |
MIGRATED | Fix 2B: same relocation — async HtoD with stale IQR data was being replayed; now runs once at epoch boundary | FIXED |
gpu_iqn_head.rs (removed) |
cuMemcpyHtoDAsync_v2 in target_ema_update for tau_buf |
MIGRATED | Fix 3: replaced CudaSlice<f32> + async copy with pinned+device-mapped page; CPU writes *tau_pinned = tau; kernel reads via tau_dev_ptr — zero PCIe overhead |
FIXED |
gpu_iqn_head.rs:2058 |
stream.clone_htod → params_buf constructor |
COLD-PATH | init_iqn_head_from_weights(): one-shot weight upload at construction/load; not on hot path |
OK |
gpu_iqn_head.rs:2107 |
stream.clone_dtoh + stream.clone_htod in clone_cuda_slice |
COLD-PATH | clone_cuda_slice(): called during checkpoint save/load only |
OK |
fused_training.rs:476 |
stream.memcpy_htod → src_gpu (PER source indices) |
COLD-PATH | prepare_per_batch(): per-epoch PER batch preparation — not per-step |
OK |
fused_training.rs:482 |
stream.memcpy_htod → ones_gpu |
COLD-PATH | prepare_per_batch(): per-epoch importance weights init |
OK |
fused_training.rs:2914 |
stream.clone_dtoh → state inspection |
COLD-PATH | eval_state_debug(): debug helper, no hot-path caller |
OK |
fused_training.rs:2928 |
stream.clone_dtoh → param inspection |
COLD-PATH | eval_state_debug(): debug helper |
OK |
fused_training.rs:2955 |
cuMemcpyDtoH_v2 (sync) |
COLD-PATH | eval_v_range(): called only at fold boundary / epoch-0 init; explicit sync is documented and acceptable at boundary |
OK |
training_loop.rs:220 |
collector.stream().memcpy_dtoh → PER scores |
COLD-PATH | Pre-epoch PER priority init — runs once before the step loop starts | OK |
training_loop.rs:409 |
stream.memcpy_htod → episode_starts_buf |
COLD-PATH | Per-epoch experience setup — before run_training_steps_slices begins |
OK |
training_loop.rs:1003 |
clone_htod_f32 → targets device buffer |
COLD-PATH | Data loading: DBN targets uploaded before step loop | OK |
training_loop.rs:1007 |
clone_htod_f32 → features device buffer |
COLD-PATH | Data loading: feature matrix uploaded before step loop | OK |
tau_update_kernel.cu (Plan 1 Task 13) |
single-thread kernel: reads ISV[39,40,12]; writes ISV[42] | COLD-PATH | Per-epoch boundary call from training_loop.rs; 1-thread, 1-block; zero hot-path overhead |
OK |
epsilon_update_kernel.cu (Plan 1 Task 14) |
single-thread kernel: reads ISV[39,40,12]; writes ISV[41] | COLD-PATH | Per-epoch boundary call from training_loop.rs; 1-thread, 1-block; zero hot-path overhead |
OK |
gamma_update_kernel.cu (Plan 1 Task 10) |
single-thread kernel: reads ISV[12]; writes ISV[43] | COLD-PATH | Per-epoch boundary call from training_loop.rs; 1-thread, 1-block; ISV[43] read by fill_gamma_buf on hot path via read_isv_signal_at (pinned, zero-copy) |
OK |
kelly_cap_update_kernel.cu (Plan 1 Task 11) |
single-thread kernel: reads ISV[12] + portfolio_states[n_envs, PS_STRIDE]; aggregates mean half-Kelly × health-safety; writes ISV[44] | COLD-PATH | Per-epoch boundary call from training_loop.rs; 1-thread, 1-block; portfolio_states dev ptr passed from GpuExperienceCollector without CPU roundtrip |
OK |
atoms_update_kernel.cu (Plan 1 Task 9) |
4-block kernel (one per branch): reads ISV[23..31) + spacing_raw params[52..56); writes atom_positions_buf[4 * num_atoms] via softmax + cumsum | COLD-PATH | Per-epoch boundary + SGD step (every 50 steps); grid=(4,1,1), block=(256,1,1); replaces CPU loop that launched adaptive_atom_positions 4× separately | OK |
Summary
| Class | Count |
|---|---|
| OK-pinned | 31 |
| COLD-PATH | 20 |
| MIGRATED (fixed in this commit) | 4 |
| Remaining MIGRATE (unfixed) | 0 |
All MIGRATE sites resolved. Zero hot-path memcpy calls remain in the DQN training loop.
Fixes Applied (this commit)
Fix 1 — Remove dead async DtoH in run_causal_intervention_unconditional
gpu_dqn_trainer.rs: cuMemcpyDtoHAsync_v2 from causal_mean_scratch_ptr to readback_pinned[10] was removed. The only reader (run_causal_intervention, which has no callers) was never invoked, making this copy dead code. The result now stays on the GPU device buffer where the kernel leaves it.
Fix 2 — Relocate compute_iqr() from hot path to epoch boundary
fused_training.rs / training_loop.rs: iqn.compute_iqr() was being called inside submit_aux_ops, which is captured in the CUDA graph (aux_child). The sync cuMemcpyDtoH_v2 inside compute_iqr cannot be captured in a CUDA graph — it ran during capture only and was silently skipped on every replayed step. The subsequent cuMemcpyHtoDAsync_v2 (uploading IQR back) was therefore replaying stale data. Moved to refresh_iqn_iqr() called at epoch boundary in process_epoch_boundary.
Fix 3 — Convert tau_buf from device VRAM to pinned+device-mapped
gpu_iqn_head.rs: tau_buf: CudaSlice<f32> + tau_host: f32 + cuMemcpyHtoDAsync_v2 replaced by tau_pinned: *mut f32 + tau_dev_ptr: u64 allocated via cuMemAllocHost_v2(DEVICEMAP) + cuMemHostGetDevicePointer_v2. CPU writes *tau_pinned = tau directly; the EMA kernel reads via tau_dev_ptr with no PCIe copy. Drop impl updated to free tau_pinned.
Fix 4 — Add mapped-pinned staging helpers in mapped_pinned.rs
mapped_pinned.rs: added clone_to_device_{f32,i32}_via_pinned and upload_{f32,i32}_via_pinned helpers that allocate a temporary MappedXxxBuffer (cuMemHostAlloc DEVICEMAP), write the payload via host_ptr, then async DtoD into a target CudaSlice<T>. Used to migrate cold-path constructor uploads off explicit cudaMemcpy HtoD per feedback_no_htod_htoh_only_mapped_pinned.md while preserving struct field types (e.g. portfolio_buf, weight buffers consumed by Adam) where the kernel mutates the buffer every step and device-resident memory is correct.
Fix 5 — Migrate cuda_pipeline/mod.rs callers to mapped-pinned
cuda_pipeline/mod.rs: 7 in-file callers of clone_htod_f32 in DqnGpuData::upload_slices, DqnGpuData::upload_ofi, DqnGpuData::build_batch_states (portfolio), DqnGpuData::htod_copy_into, and PpoGpuData::upload rewritten to use mapped_pinned::clone_to_device_f32_via_pinned. The htod_f32 / clone_htod_f32 helper bodies at lines 129-145 remain in place because other crates are still in mid-migration; they will be deleted once all consumers migrate.
Fix 6 — Migrate gpu_walk_forward.rs to mapped-pinned (3 sites)
gpu_walk_forward.rs: 3 clone_htod_f32 calls in the walk-forward GPU data upload (features, targets, OFI) rewritten to mapped_pinned::clone_to_device_f32_via_pinned.
Fix 7 — Migrate PPO trainer + hyperopt adapters to mapped-pinned (6 sites)
trainers/ppo.rs (2 sites: features, targets in set_raw_market_data), hyperopt/adapters/ppo.rs (3 sites: features, targets in upload path; action_indices in backtest forward closure), hyperopt/adapters/mamba2.rs (1 site: gpu_to_stream). All clone_htod_f32 and bare stream.clone_htod calls rewritten to mapped_pinned::clone_to_device_{f32,i32}_via_pinned.
Fix 8 — Migrate weight-upload sites in attention/tlob/iql/weights (4 sites)
gpu_weights.rs (1 site: RmsNormWeightSet::ones constructor), gpu_attention.rs (1 site: attention params constructor), gpu_tlob.rs (1 site: TLOB params constructor — mod tests blocks intentionally untouched per scope), gpu_iql_trainer.rs (2 sites: per_sample_support seed, IQL params init). All htod_f32 calls rewritten to mapped_pinned::upload_f32_via_pinned; the gpu_weights.rs ones-init uses clone_to_device_f32_via_pinned since the destination is freshly allocated.
Fix 9 — Migrate gpu_iqn_head.rs constructors + DtoD-only target clone (5 sites)
gpu_iqn_head.rs: 4 stream.clone_htod calls (cos_features precompute, online_taus + target_taus tile broadcast, init_iqn_xavier_weights upload) rewritten to mapped_pinned::clone_to_device_f32_via_pinned. Additionally the helper clone_cuda_slice was replaced — it previously did device→host (clone_dtoh) → host→device (clone_htod) round-trip, double-violating both the no-DtoH and no-HtoD rules. Now uses clone_cuda_slice_f32 (single async DtoD), the only callsite being the target-network seed at constructor time (target_params = clone_cuda_slice(&online_params)).