Files
foxhunt/docs/dqn-gpu-hot-path-audit.md
jgrusewski 5f26c57514 perf(dqn): online softmax in compute_expected_q (kernel #3)
nsys profile of multi_fold_convergence on L40S identified compute_expected_q
as the #3 GPU consumer at 12.9% (207ms / 1382 calls — ~150 µs per call,
compute-bound at typical batch=8192 × num_atoms=51). The kernel computed each
per-action softmax over atoms in 3 passes (max → sum_exp → normalize+expected
+var+entropy+util), re-reading v_row[z]+adv_a[z] 3 times per atom for each of
the 4 × ~3.25 = 13 actions per sample.

Replaced with online softmax (running-max + running-sum) so expected_q,
sum_z_sq, and entropy all accumulate in a single forward pass over atoms.
Atom utilisation still needs a 2nd pass because it depends on the final
S = sum exp(logit-M) being known to compute prob_z = exp(logit_z-M)/S for
the threshold test.

Inner loop reduction: 3 → 2 passes, ~33% fewer global loads of branch
advantage logits (which dominate because v_row[z] is reused across 13
(action, branch) pairs and may stay in L1, while adv_a[z] flips per
action and is pure global).

Online accumulation pattern (Page-Olshen):
  M, S, TZ, TZ², TLM = -inf, 0, 0, 0, 0
  for z in 0..num_atoms:
      logit = v_row[z] + adv_a[z]
      if logit > M:
          scale = exp(M - logit)   # ≤ 1, no overflow
          S, TZ, TZ², TLM *= scale
          M = logit
      w = exp(logit - M)
      S   += w
      TZ  += w * z_val
      TZ² += w * z_val²
      TLM += w * (logit - M)
  expected_q = TZ / S
  sum_z_sq   = TZ² / S
  entropy    = log(S) - TLM / S    # analytical: -sum(p log p)

Correctness: bit-stable when atoms processed in fixed order
(z = 0..num_atoms-1). The analytical entropy form is mathematically
identical to -sum(p log p):
  -sum(p log p) = log(S) - (1/S) * sum(exp(logit-M) * (logit-M))
                = log(S) - TLM / S
The previous code's `if (prob > 1e-10f)` underflow guard is no longer needed:
exp(logit - M) underflows cleanly to 0 when logit << M, and the analytical
form does not multiply tiny probs by very negative logs. Bit-stable per
feedback_stop_on_anomaly.md — no fuzzy tolerance change.

Atom-stat block-sum reduction unchanged (warp shfl + shared-mem bank,
deterministic).

Expected wall-clock saving: ~33% of 207ms = ~70ms across the smoke run; on
production batch=8192 × 1382 calls per fold, roughly proportional. Lower-bound
estimate — at low batch (smoke) the kernel may be launch-bound rather than
load-bound. nsys re-profile after deployment will quantify.

ABI unchanged; no Rust caller changes required. Per Invariant 7 the audit
doc dqn-gpu-hot-path-audit.md is updated with Fix 19 entry.

Build: SQLX_OFFLINE=true cargo check -p ml --lib clean (12 warnings, baseline).
Tests: cargo test -p ml --lib --no-run clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:33:14 +02:00

38 KiB
Raw Blame History

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 plain memcpy but 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_hosttotal_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_hostmse_loss_pinned OK-pinned Constructor: same pattern as total_loss_pinned OK
gpu_dqn_trainer.rs:6611 malloc_hostq_divergence_pinned OK-pinned Constructor: same pattern OK
gpu_dqn_trainer.rs:6624 malloc_hostiqn_readiness_pinned OK-pinned Constructor: same pattern OK
gpu_dqn_trainer.rs:6662 malloc_hostgrad_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_hosteval_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_hostnan_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_hostlr_pinned OK-pinned Constructor: CPU writes LR; GPU reads via dev ptr OK
gpu_dqn_trainer.rs:6968 malloc_hostadaptive_clip_pinned OK-pinned Constructor: CPU writes clip; GPU reads via dev ptr OK
gpu_dqn_trainer.rs:6988 malloc_hostpopart_pinned OK-pinned Constructor: GPU writes PopArt stats; host reads at epoch boundary OK
gpu_dqn_trainer.rs:7099 malloc_hostq_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_hostq_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_v2var_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_v2regime_util pinned (ISV subsystem) OK-pinned Constructor: ISV regime utilization — GPU writes each step OK
gpu_dqn_trainer.rs:7289 malloc_hostpopart_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_v2q_mean_scratch OK-pinned Constructor: GPU scratch for Q-mean reduction kernel OK
gpu_dqn_trainer.rs:7898 cuMemAllocHost_v2q_mean_ema OK-pinned Constructor: Q-mean EMA — GPU writes, host reads at epoch boundary OK
gpu_dqn_trainer.rs:7919 cuMemAllocHost_v2td_error_scratch OK-pinned Constructor: TD-error scratch for reduce kernel OK
gpu_dqn_trainer.rs:7939 cuMemAllocHost_v2q_var_scratch OK-pinned Constructor: Q-variance scratch for reduce kernel OK
gpu_dqn_trainer.rs:2300 stream.memcpy_htodatom_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_htodcos_features COLD-PATH Constructor: precompute cosine embedding table; one-shot OK
gpu_iqn_head.rs:477 stream.clone_htodonline_taus COLD-PATH Constructor: tau quantile tiling; one-shot OK
gpu_iqn_head.rs:480 stream.clone_htodtarget_taus COLD-PATH Constructor: tau quantile tiling; one-shot OK
gpu_iqn_head.rs:498 malloc_hosttotal_loss_pinned OK-pinned Constructor: GPU writes IQN loss; host reads via read_total_loss() OK
gpu_iqn_head.rs:534 malloc_hostt_pinned OK-pinned Constructor: Adam step counter — CPU increments, GPU reads via dev ptr OK
gpu_iqn_head.rs:548 malloc_hosttau_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_dtohtotal_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_htodparams_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_htodsrc_gpu (PER source indices) COLD-PATH prepare_per_batch(): per-epoch PER batch preparation — not per-step OK
fused_training.rs:482 stream.memcpy_htodones_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_htodepisode_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
gpu_portfolio.rs:174 actions_buf upload (per simulate_batch_gpu) MIGRATED Promoted actions_buf from CudaSlice<i32> to MappedI32Buffer(MAX_BATCH_SIZE) allocated once at constructor. Per-call host writes go through host_ptr; kernel arg switched to dev_ptr u64. Zero HtoD copy on the simulate hot path. FIXED
gpu_portfolio.rs:122,296 portfolio_state_buf init/reset MIGRATED env_step kernel writes back to portfolio_state_buf, so the destination must remain a CudaSlice<f32>. Init/reset now stage via a temporary MappedF32Buffer + memcpy_dtod_async (new helper upload_portfolio_state). FIXED
decision_transformer.rs:913 scratch.adam_t upload per train_step_gpu MIGRATED HOT path: 4-byte step counter was uploaded via stream.memcpy_htod every backward pass. Promoted DtScratch.adam_t from CudaSlice<i32>[1] to MappedI32Buffer(1). Per-step host write goes through host_ptr; Adam kernel reads via dev_ptr. FIXED
decision_transformer.rs:478 DT params init upload (constructor) MIGRATED params is mutated by Adam, so destination must remain CudaSlice<f32>. Xavier-init upload now stages via MappedF32Buffer + memcpy_dtod_async through new helper upload_host_to_cuda_f32. FIXED
decision_transformer.rs:501 DT wd_mask init (constructor) MIGRATED wd_mask is read-only by GPU. Promoted from CudaSlice<f32> to MappedF32Buffer. CPU writes 1.0s through host_ptr; kernel reads via dev_ptr. FIXED
decision_transformer.rs:1105 trajectory build episode_starts MIGRATED One-time per pre-training data build (cold path). Replaced clone_htod with MappedI32Buffer + direct host_ptr write; kernel reads via dev_ptr. FIXED
decision_transformer.rs:1147 trajectory build ep_rewards MIGRATED Same trajectory builder cold path. Replaced htod_f32 with MappedF32Buffer; CPU writes via host_ptr, return_to_go kernel reads via dev_ptr. FIXED
gpu_experience_collector.rs:2992 hindsight bar_indices upload (per collect_experiences_gpu) MIGRATED HOT path: alloc + memcpy_htod ran every collect when hindsight active. Added persistent bar_indices_pinned: MappedI32Buffer(alloc_episodes * alloc_timesteps * 2) allocated at constructor. Per-call host writes go through host_ptr; kernel arg switched to dev_ptr u64. FIXED
gpu_experience_collector.rs:3186 feature_mask upload (per-collect at t==0) MIGRATED HOT path: was alloc + memcpy_htod every epoch. Promoted feature_mask_buf from Option<CudaSlice<f32>> to Option<MappedF32Buffer>. Reallocates only when mask size changes (still a cold-path event); CPU writes via host_ptr, state_gather reads via dev_ptr. FIXED
gpu_experience_collector.rs:3097 episode_starts upload (per-collect) MIGRATED episode_starts_buf is GPU-mutated by domain_rand_episode_starts, so the destination must remain a CudaSlice. Stage via MappedI32Buffer + memcpy_dtod_async through upload_host_to_cuda_i32_via_pinned. FIXED
gpu_experience_collector.rs:2009 upload_expert_actions (warm) MIGRATED Promoted expert_actions_gpu from Option<CudaSlice<i32>> to Option<MappedI32Buffer>. Direct host_ptr write replaces the alloc + memcpy_htod pair. FIXED
gpu_experience_collector.rs:3816 update_per_sample_support (per-epoch) MIGRATED per_sample_support_buf is read-only by GPU; promoted from CudaSlice<f32> to MappedF32Buffer. Compute_expected_q + quantile_q_select now receive dev_ptr u64 kernel args. FIXED
gpu_experience_collector.rs:1076,3848 portfolio_states init/reset (cold) MIGRATED portfolio_states is GPU-mutated by env_step. Init/reset stage via MappedF32Buffer + memcpy_dtod_async through upload_host_to_cuda_f32_via_pinned. FIXED
gpu_experience_collector.rs:1093 epoch_state init (cold) MIGRATED epoch_state is GPU-mutated. Replaced clone_htod_f32 with explicit alloc_zeros + upload_host_to_cuda_f32_via_pinned. FIXED
gpu_experience_collector.rs:1341 saboteur_base init (cold) MIGRATED saboteur_base is GPU-mutated by saboteur_select. Replaced memcpy_htod with upload_host_to_cuda_f32_via_pinned. FIXED
gpu_experience_collector.rs:2784 trade_stats_buf zero (per-epoch) MIGRATED The htod_f32 of an all-zeros vector was needlessly going through PCIe. Replaced with stream.memset_zeros — fully GPU-side. FIXED

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 (perf) — Async-eval pipeline: split launch_metrics_and_download (2026-04-28)

gpu_backtest_evaluator.rs + trainer/metrics.rs + trainer/training_loop.rs: the prior merge 3c0d26292 (async-diag — eval on dedicated stream + spawn_blocking checkpoint, building on d9cb14f1b + 673b04a8d) wired the GPU side of cross-stream pipelining correctly (training stream cuda_stream, eval stream validation_stream, cuStreamWaitEvent barrier on train_done_event) but left a host-side stream.synchronize() inside launch_metrics_and_download that blocked the CPU thread for the FULL eval drain (~25-30s/epoch on L40S). The host thread is the same one that submits the next epoch's training kernels via run_full_step — so until eval drained, training submission was gated on it, and the dedicated validation_stream provided no actual wall-clock benefit.

Split into two record/consume halves at the buffer-readback layer:

  • launch_metrics_and_record_event(&mut self) -> Result<(), MLError> — submits the metrics kernel + DtoD-async copies of actions_history_buf / intent_mag_buf / picked_action_history_buf into mapped-pinned mirrors (actions_history_pinned: MappedI32Buffer allocated in new(), the other two lazy-allocated alongside their device buffers in ensure_action_select_ready). Records eval_done_event (lazy-created on first launch via stream.context().new_event(None), re-recorded each epoch via event.record(stream)). Returns immediately — no host wait.
  • consume_metrics_after_event(&self) -> Result<Vec<WindowMetrics>, MLError> — calls event.synchronize() (the SOLE host-side wait per epoch boundary), then read_volatiles the mapped-pinned metrics_buf for the per-window metrics. After the event syncs, the four action-distribution helpers (read_eval_action_distribution_per_*, read_eval_intent_magnitude_distribution, read_chunked_actions_direction_distribution) read directly from the now-coherent mapped-pinned mirrors — no memcpy_dtoh per call (the prior implementation allocated a fresh Vec<i32> of size n_windows * max_len and did a synchronous DtoH each call, which violates feedback_no_htod_htoh_only_mapped_pinned.md AND added 4 extra host-side stalls beyond the metrics one). All four mirrors land in the same event-sync barrier as the metrics readback.

Caller migration (feedback_no_partial_refactor.md):

  • evaluate_dqn_graphed (existing public API, used by hyperopt + smoke test) becomes a thin synchronous wrapper: launch_async → consume. Public ABI unchanged.
  • New evaluate_dqn_graphed_async — launch-only sibling for the pipelined path. Returns ().
  • evaluate / evaluate_ppo / evaluate_supervised migrated inline to launch+consume — no caller pipelines them, so no behavioural change.
  • compute_validation_loss removed; replaced by launch_validation_loss (returns ()) + consume_validation_loss (returns f64 and emits the HEALTH_DIAG val [...] / val_dir_dist / val_picked_dir_dist lines).
  • training_loop.rs's validation reporting block now: epoch-start consumes any pending launch into cached_async_val_loss, epoch-end reports the cached value and submits a fresh launch with pending_val_loss = Some(0.0) as the "launched" sentinel. Bootstrap (epoch 0 / no validation_stream) does sync launch+consume. After the for-epoch loop terminates, a final consume drains the LAST epoch's launch so smoke tests / hyperopt that read last_eval_direction_dist() see the most recent epoch's data, not the second-to-last.

Mathematical identity preserved: every consumed val_sharpe is bit-identical to what the prior compute_validation_loss would have produced for the same epoch — the only change is when the host parses it (one epoch later, which matches the existing cached_async_val_loss lag semantics; HEALTH_DIAG val [...] emit moves with the consume). Expected wall-clock saving: ~25-30s/epoch on L40S.

Out-of-scope (remaining host-side stream syncs in eval, not part of this fix): gpu_backtest_evaluator.rs lines ~1596 + ~1863 — periodic self.stream.synchronize() calls every 10 chunks inside submit_dqn_step_loop_cublas for kernel-error detection. They block the host CPU thread but only for the chunked rollout duration of ~5 calls × per-chunk-residency-time per epoch (each catches deferred CUDA errors within 10 chunks of origin). Smaller in aggregate than the launch_metrics_and_download final wait this fix removed; future work to convert to async error-polling (e.g., eval_done_event.is_complete() polling from training submissions). Not adjacent to the perf-fix scope.

Fix 5 — 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 6 — 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 7 — 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 8 — 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 9 — 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 10 — 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)).

Fix 11 — Migrate gpu_her.rs to mapped-pinned + add MappedU32Buffer (3 sites)

gpu_her.rs: 1 cold-path constructor stream.memcpy_htod (rng_states u32 init) and 2 warm-path memcpy_htod (source_indices, donor_indices in relabel_batch) rewritten to mapped_pinned::upload_{u32,i32}_via_pinned. The warm sites re-allocate pinned staging per relabel call — acceptable on this cold path (relabel runs once per epoch). New MappedU32Buffer type added to mapped_pinned.rs mirroring MappedI32Buffer; carries a manual Debug impl so the workspace warning count stays at 13.

Fix 12 — Migrate gpu_backtest_evaluator.rs to mapped-pinned (5 sites)

gpu_backtest_evaluator.rs: 5 HtoD sites in GpuBacktestEvaluator::new + reset_evaluation_state rewritten to mapped-pinned staging. Sites: prices_buf (f32), features_buf (f32 — was already going through clone_htod_f32 helper), window_lens_buf (i32), portfolio_buf init (f32), and the per-evaluation reset of portfolio_buf. The fields stay typed CudaSlice<T> because the kernel mutates portfolio_buf each step (must remain device-resident); the read-only fields could become MappedXxxBuffer but keeping them as CudaSlice preserves the kernel-launch contract .arg(&self.field) already used at the many launch sites in the file.

Fix 13 — Migrate gpu_ppo_collector.rs to mapped-pinned (6 sites + persistent staging)

gpu_ppo_collector.rs: all 6 HtoD sites migrated.

  • Constructor (cold): portfolio_states init (htod_f32) → upload_f32_via_pinned; rng_states init (memcpy_htod) → upload_u32_via_pinned.
  • collect_experiences (warm but per-collect): episode_starts_buf (memcpy_htod) → upload_i32_via_pinned; barrier_config (htod_f32) → upload_f32_via_pinned.
  • reset_episodes (warm, per-epoch): replaced the Vec<f32> / Vec<u32> host staging fields with persistent MappedF32Buffer / MappedU32Buffer allocated once at construction. Resets fill via host_slice_mut() (zero-allocation, zero-HtoD); a single async DtoD seeds the persistent device-resident portfolio_states / rng_states (which the kernel mutates each step). Adds host_slice_mut() accessor on both mapped-pinned types.

Fix 17 — Delete orphan htod_f32 / clone_htod_f32 helpers from cuda_pipeline/mod.rs (2026-04-28)

After Fix 1..16 migrated all 80+ production callers off super::htod_f32 and super::clone_htod_f32, the helper bodies in cuda_pipeline/mod.rs:129-145 were unused outside #[cfg(test)] modules. Deleted both function definitions per feedback_no_legacy_aliases.md (no deprecated wrappers).

The two surviving test-block callers in gpu_tlob.rs::tests (lines 1017 and 1132 — super::super::htod_f32(&stream, &host_states, …) and super::super::htod_f32(&stream, &host_d_concat, …)) were also migrated to mapped_pinned::upload_f32_via_pinned in the same commit per feedback_no_partial_refactor.md (when a contract is deleted, every consumer migrates together — including tests). The test-only callers in signal_adapter.rs::tests (5 sites), gpu_action_selector.rs::tests (1), and cuda_pipeline/mod.rs::tests (1) all use bare stream.memcpy_htod / stream.memcpy_stod against the cudarc handle directly, not the deleted helpers — no change needed.

A doc-block was added at the deletion site in mod.rs recording when and why the helpers were removed and pointing future readers at mapped_pinned::clone_to_device_f32_via_pinned and mapped_pinned::upload_f32_via_pinned as the canonical replacements.

Final state of the HtoD migration sequence:

  • grep -rEn "stream\.memcpy_htod|memcpy_stod|stream\.htod_copy|htod_async" crates/ml/src/: production count 0, test count 7 (5 in signal_adapter.rs::tests, 1 in gpu_action_selector.rs::tests, 1 in cuda_pipeline/mod.rs::tests).
  • grep -rn "htod_f32\|clone_htod_f32" crates/ml/src/: outside test blocks 0 (only doc-comment text references remain). Helper definitions removed from mod.rs.

Fix 16 — Migrate training_loop.rs raw features/targets uploads (2 sites, 2026-04-28)

trainers/dqn/trainer/training_loop.rs:1250 (targets_raw) and :1254 (features_raw): both crate::cuda_pipeline::clone_htod_f32 callsites in the data-load helper rewritten to mapped_pinned::clone_to_device_f32_via_pinned. These run once per fold immediately before the step loop begins (the _raw suffix indicates the un-normalized arrays kept on-GPU for the eval-time critic). The .map_err(|e| anyhow::anyhow!("…raw upload: {e}")) wrapping is preserved verbatim — the helper already returns Result<_, String> which feeds anyhow::anyhow! directly, so the change is a 1:1 path swap.

Fix 15 — Migrate gpu_experience_collector.rs::upload_ofi_features (1 site, 2026-04-28)

gpu_experience_collector.rs:4258: warm-path super::clone_htod_f32 upload of OFI features rewritten to mapped_pinned::clone_to_device_f32_via_pinned. Called from training_loop's data-load phase before each fold's training run, so the OFI tensor (4M bars × 32 dims = ~512 MB) now stages through mapped-pinned + DtoD just like all other large data uploads. Error-type shift handled with .map_err(|e| MLError::ModelError(format!("upload_ofi_features: {e}")))? since the helper returns Result<_, String> and the function returns Result<_, MLError>.

Fix 14 — Migrate gpu_dqn_trainer.rs residual COLD ctor sites (8 sites, 2026-04-28)

gpu_dqn_trainer.rs: residual COLD ctor sites missed by the original Fix-1..13 audit are now migrated off explicit super::htod_f32 per feedback_no_htod_htoh_only_mapped_pinned.md.

  • :9922 sel_clip_buf 1-element init — selectivity gate clip-norm seed (1.0).
  • :10075-10078 spectral-norm init_u_s1 / init_v_s1 / init_u_s2 / init_v_s2 (4 separate uploads of trunk-encoder spectral u/v vectors).
  • :10095-10096 alloc_spec_pair! macro body — both u/v init uploads inside the macro now go through upload_f32_via_pinned. The macro's $lbl_u / $lbl_v are reused in the error-message format so per-pair failures (spec_u_v1, spec_v_v1, spec_u_a1, …) stay diagnostically distinct.
  • :11276 graph_params_host (60 floats: cross-branch graph message passing weights, 4 edges × 15 params each, near-identity W_msg init).
  • :11330 denoise_params_host (1800 floats: 2-step diffusion Q-refinement Xavier init for W1[24,24] + W2[12,24]).
  • :11476 qlstm_weights_host (528 floats: QLSTM Xavier init for 6 gates × 11 input × 8 head). All sites use mapped_pinned::upload_f32_via_pinned (canonical mapped-pinned + DtoD staging helper). The helper returns Result<_, String> whereas the ctor returns Result<_, MLError>, so each site wraps the error via .map_err(|e| MLError::ModelError(format!("<site> upload via pinned: {e}"))). Site labels preserved so backtraces remain readable.

Fix 18 (perf) — Eliminate per-step val gather DtoD copies via chunked gather kernel (2026-04-28)

experience_kernels.cu + gpu_backtest_evaluator.rs + metrics.rs: nsys profile of multi_fold_convergence on L40S identified backtest_state_gather as the #1 GPU-time consumer at 37.2% (596 ms / 60,588 calls — kernel launch latency dominated compute). The per-step pattern was:

for step_offset in 0..chunk_len {
  launch_gather(step) -> states_buf[N, padded_sd]   (1 launch)
  if tlob: tlob.forward(states_buf, n)              (4 cuBLAS SGEMMs + 3 kernel launches)
  memcpy_dtod_async(chunked_states_buf[step_offset*N..], states_buf, ...)   (1 DtoD)
}

Per chunk (DQN_BACKTEST_CHUNK_SIZE=512): 512 gather launches + 512 DtoD copies + 512 × 7 TLOB launches = 4608 kernel launches per chunk before forward Q-values can begin.

New kernel backtest_state_gather_chunk writes [chunk_len, N, padded_sd] directly into chunked_states_buf in a SINGLE launch (chunk_len * N threads, 1 thread per output row). Within a chunk portfolio_buf and plan_isv_buf are CONSTANT — env_step (Phase 5) and backtest_plan_state_isv (Phase 6) update them only at chunk boundary. So the batched gather is mathematically identical to the prior per-step + DtoD pattern; each thread reads its own (window, step_offset) row from independent feature offsets and writes its own state row. No reordering, no atomics, no shmem races.

Same change enables the val TLOB instance to run ONCE per chunk on the chunked buffer at batch = chunk_len * N instead of chunk_len separate forward calls each at batch = N. Val TLOB construction in metrics.rs::reset_evaluator_for_validation resized via new GpuBacktestEvaluator::val_tlob_batch_size() to DQN_BACKTEST_CHUNK_SIZE * n_windows; partial last chunks reuse the same buffers (forward(b) accepts any b <= construction_batch).

Per-chunk launch reduction: from 2 * chunk_len + chunk_len * 7 to 1 + 7 for the gather+TLOB phase. With 512-step chunks that's 4608 → 8, a 576x reduction. Across the full smoke run (60K gather calls in profile), expected wall-clock saving on the gather/TLOB hot path is the bulk of the 37% + 6.5% + 5.2% + 4.8% = ~53.7% of GPU time the per-call cluster occupied.

Borrow restructure: the prior single-shot let ch_states = self.chunked_states_buf.as_ref()? at top of submit_dqn_step_loop_cublas was removed because TLOB now needs &mut self.chunked_states_buf. Replaced with per-chunk ch_states_base (raw u64 device pointer extracted in a tight scope), reused for both launch_gather_chunk and the Phase 2+3 compute_q_values_to call. The pointer is stable across the chunk because the Option<CudaSlice<f32>> does not reallocate.

No new HtoD/DtoH paths introduced. Strictly removes 512 per-chunk DtoD copies. Per feedback_no_partial_refactor.md: kernel ABI is new (separate function name), prior backtest_state_gather retained for evaluate() / evaluate_ppo() / evaluate_supervised() paths that still need a single-step writer.

Fix 19 (perf) — Online softmax in compute_expected_q: 3-pass → 2-pass (2026-04-28)

experience_kernels.cu: nsys profile of multi_fold_convergence on L40S identified compute_expected_q as the #3 GPU consumer at 12.9% (207 ms / 1382 calls — ~150 µs per call, compute-bound at typical batch=8192 × num_atoms=51). The kernel computed each per-action softmax over atoms in 3 passes (max → sum_exp → normalize+expected/var/entropy/util), re-reading v_row[z] + adv_a[z] 3 times per atom for each of the 4×~3.25 = 13 actions per sample.

Replaced with online softmax (Page-Olshen running-max + running-sum) so expected_q, sum_z_sq, and entropy all accumulate in a single forward pass over atoms. Atom utilisation still needs a 2nd pass because it depends on the final S = sum exp(logit-M) being known to compute prob_z = exp(logit_z - M)/S for the threshold test. Total: 3 → 2 passes, ~33% reduction in inner-loop global loads of branch advantage logits (which dominate because v_row[z] is reused across 13 (action, branch) pairs and may stay in L1, while adv_a[z] flips per action and is pure global).

Online accumulation:

M, S, TZ, TZ², TLM = -inf, 0, 0, 0, 0
for z in 0..num_atoms:
    logit = v_row[z] + adv_a[z]
    if logit > M:
        scale = exp(M - logit)   # ≤ 1, no overflow
        S, TZ, TZ², TLM *= scale
        M = logit
    w = exp(logit - M)            # ≤ 1
    S   += w
    TZ  += w * z_val
    TZ² += w * z_val²
    TLM += w * (logit - M)
expected_q = TZ / S
sum_z_sq   = TZ² / S
entropy    = log(S) - TLM / S    # analytical: -sum(p log p) where p = w/S

Correctness: bit-stable when atoms processed in fixed order (z = 0..num_atoms-1). The analytical entropy form is mathematically equivalent to -sum(p log p):

-sum(p log p) = -sum(p (logit - M - log S)) = log(S) * sum(p) - sum(p (logit-M))
             = log(S) - (1/S) * sum(exp(logit-M) * (logit-M))
             = log(S) - TLM / S

The previous code's if (prob > 1e-10f) underflow guard is no longer needed: exp(logit - M) underflows cleanly to 0 when logit << M, and the analytical form does not multiply tiny probs by very negative logs. Result is bit-stable per feedback_stop_on_anomaly.md — no fuzzy tolerance change.

Atom-stat block-sum reduction unchanged (warp shfl + shared-mem bank, deterministic).

Expected wall-clock saving on this kernel: ~33% of 207ms = ~70ms across the smoke run; on production batch=8192 × 1382 calls per fold, roughly proportional. Lower-bound estimate; actual saving depends on memory bandwidth vs compute mix — at low N (smoke) the kernel may be launch-bound rather than load-bound. nsys re-profile after deployment will quantify.

ABI unchanged; no Rust caller changes required.