Files
foxhunt/crates
jgrusewski 24f96ab78b fix(n1): distillation now actually pulls weights during collapse
Three root-cause bugs made the collapse-recovery distillation mechanism
a silent no-op. Diagnosed via 50-epoch smoke test trajectory: Q-gap
peaked at 1.2 in epoch 2 then collapsed irreversibly by epoch 6, with
HEALTH_DIAG reporting `distill=off` throughout despite the trigger
conditions firing every epoch. Fixed each bug in turn and re-ran:
Q-gap now stabilizes above 0.18 from epoch 33 onwards, final 1.18.

Bug 1: timing
  apply_distillation_gradient ran at epoch-boundary and SAXPY'd into
  grad_buf. But the next training step's graph_forward.replay() starts
  with `cuMemsetD32Async(grad_buf, 0, total_params)` — wiping the
  contribution before any Adam update could see it. Moved SAXPY into
  the per-step aux-op phase (between graph_forward and graph_adam),
  matching the cadence of CQL/IQN/ensemble gradients.

Bug 2: CUDA Graph scalar baking
  Moving SAXPY to per-step hit a deeper issue: graph capture bakes
  kernel scalar args at capture time. The alpha value was captured
  at 0.0 (initial) and never updated across replays, regardless of
  per-epoch recomputation. Fix: new dqn_distill_saxpy_kernel reads
  health from isv_signals[LEARNING_HEALTH_INDEX=12] directly and
  computes alpha in-kernel. `distill_best_buf` is a stable device
  pointer; its contents are DtoD-refreshed at epoch boundary when
  maybe_snapshot_params accepts a new best. Zero CPU writes on any
  path — pure GPU dataflow.

Bug 3: snapshot gate using wrong signal
  The snapshot gate passed `self.last_q_gap` (an EMA that was stuck
  at 0 due to broken propagation — see companion commit). Gate was
  `health ≥ 0.65 OR winrate_fallback`, neither of which opened in
  runs where high-q_gap epochs and high-winrate epochs don't overlap.
  Replaced with q_gap-primary gate: `epoch_q_gap ≥ dynamic_floor`,
  where the floor is `0.5 × decaying_peak` scaled by a per-epoch 0.99
  decay. Adapts to network size automatically (production peaks at
  ~0.5 → floor 0.25; smoke test peaks at ~0.05 → floor 0.025).

Also drops the winrate-fallback "inflate health to 0.75" hack from
training_loop — q_gap is the direct measure of what distillation
preserves, no proxies needed.

Verified: local E1 smoke test (RTX 3050 Ti, 50 epochs) shows
distillation engaging from epoch 2 onwards and keeping Q-gap above
0.18 for epochs 33-50. Production L40S 50-epoch run pending deploy.

Files changed:
- dqn_utility_kernels.cu: new dqn_distill_saxpy_kernel (numerically
  unchanged from saxpy_f32_kernel; alpha computed from ISV per-thread)
- gpu_dqn_trainer.rs: distill_saxpy_aux kernel handle, distill_best_buf
  stable device buffer initialized from params at construction,
  apply_distillation_gradient() rewritten, mirror_best_snapshot_to_distill_buf()
  invoked on snapshot acceptance, maybe_snapshot_params uses dynamic
  q_gap floor via SnapshotRing::observe_q_gap/dynamic_q_gap_floor
- q_snapshot.rs: SnapshotRing grows max_q_gap_observed decaying-peak
  tracker, observe_q_gap() + dynamic_q_gap_floor() helpers,
  MIN_SNAPSHOT_Q_GAP constant dropped in favor of relative floor,
  module docstring rewritten to explain q_gap-primary gate
- fused_training.rs: set_distill_alpha + distill_alpha_per_step field
  dropped (no longer needed — kernel reads ISV directly),
  submit_aux_ops calls apply_distillation_gradient() unconditionally
- training_loop.rs: snapshot call simplified — passes epoch_q_gap
  (raw, not the stuck EMA), drops winrate fallback and distill alpha
  plumbing; also calls fused.update_eval_v_range() in the epoch-end
  Q-stats block (the path previously writing to per_branch_q_gap_ema
  was disabled via `if false` guard, leaving the health EMA frozen
  at zero)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:10:54 +02:00
..