fix(finetune): NF4 QLoRA per-epoch val_loss must reflect trained adapters (FALSIFY-CUDA-EVAL-ADAPTER-SYNC-001) - #2257
Merged
Conversation
…ters (FALSIFY-CUDA-EVAL-ADAPTER-SYNC-001) On the CUDA path, `train_step` writes LoRA adapter deltas into the GPU-resident `cuda_blocks`, but `InstructPipeline::evaluate()` computes val_loss on the CPU via `model.forward_with_lora(x, &self.lora_layers)`. The CPU `lora_layers` are only refreshed by `sync_lora_to_cpu()`, which was invoked exclusively inside `save_checkpoint` — never before `evaluate()` in the epoch loop (`instruct_trainer.rs:243`). Consequence: per-epoch `val_loss` is byte-identical across every epoch and run, so `best_val_loss` collapses to the epoch-0 constant, `best_epoch` freezes at 0 (the `best/` checkpoint is stale-by-N-epochs), and early stopping fires on a phantom plateau. Trained weights themselves were fine — only the validation signal (and the decisions it drives) were dead. Fix: `evaluate()` calls `sync_lora_to_cpu()` before the CPU forward (now takes `&mut self`), so val_loss reflects the current GPU-trained adapters. Adds a `#[cfg(not(feature = "cuda"))]` no-op twin so the call is unconditional; on the CPU/WGPU paths `lora_layers` are updated in place and already current. This makes `evaluate` self-consistent for every caller, not just the trainer. Falsifier FALSIFY-CUDA-EVAL-ADAPTER-SYNC-001 (contract finetune-eval-adapter-sync-v1.yaml): injects a GPU-only adapter change via download→(set B nonzero)→upload — independent of the optimizer/clip path — then asserts the next evaluate() differs. Verified live on RTX 4090 (sm_89): RED (sync removed): val_before == val_after == 14.25047874 (|Δ|=0.0) GREEN (sync present): 14.25047874 -> 14.22203255 (|Δ|=0.02844620) GPU-gated (`--ignored`, needs APR_PARITY_MODEL); not yet CI-enforced pending a CUDA runner. CPU suite: 7612 pass (3 pre-existing prune insta-snapshot failures, unrelated module). Q/V-adapter scope bound documented in the contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
noahgift
enabled auto-merge
July 2, 2026 16:42
…iff-scoped mutation gap The standalone qwen2_1_5b_config() helper in the (cuda+ignore-gated) eval-sync falsifier was the only surviving diff-scoped mutant: cargo-mutants replaces it with Default::default(), but its sole caller is the GPU-gated #[ignore] test that CI cannot run, so nothing kills it. Inlining the config into the #[test] body removes the mutable helper (cargo-mutants skips #[test] fns). The only remaining diff mutant (evaluate -> Default::default()) is unviable — InstructBatchResult does not derive Default — so 0 missed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
noahgift
enabled auto-merge
July 2, 2026 17:28
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Jul 2, 2026
This was referenced Jul 2, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
On the CUDA path,
train_stepwrites LoRA adapter deltas into the GPU-residentcuda_blocks, butInstructPipeline::evaluate()computesval_losson the CPU viamodel.forward_with_lora(x, &self.lora_layers). The CPUlora_layersare only refreshed bysync_lora_to_cpu(), which was invoked exclusively insidesave_checkpoint— never beforeevaluate()in the epoch loop (instruct_trainer.rs:243).Consequence: per-epoch
val_lossis byte-identical across every epoch and every run, so:best_val_losscollapses to the epoch-0 constantbest_epochfreezes at 0 → thebest/checkpoint is stale-by-N-epochsTrained weights themselves were fine (final save syncs) — only the validation signal and the decisions it drives were dead.
The fix
evaluate()now callssync_lora_to_cpu()before the CPU forward (takes&mut self), soval_lossreflects the current GPU-trained adapters. A#[cfg(not(feature = "cuda"))]no-op twin keeps the call unconditional — on CPU/WGPU pathslora_layersare updated in place and already current. This makesevaluateself-consistent for every caller, not just the trainer.Falsifier —
FALSIFY-CUDA-EVAL-ADAPTER-SYNC-001Contract:
contracts/finetune-eval-adapter-sync-v1.yaml(pv validate/pv lintclean). The probe injects a GPU-only adapter change viadownload→(set B nonzero)→upload— deliberately independent of the optimizer/clip path so it runs on a fresh, uncorrupted CUDA context — then asserts the nextevaluate()differs.Verified live on RTX 4090 (sm_89):
Testing
prune::snapshot_testsinsta-snapshot failures — unrelated module, present onorigin/main)evaluate&mut selfchange: no other callers ofInstructPipeline::evaluateexist workspace-wide; touched test bindings updated tomut--ignored, needsAPR_PARITY_MODEL); not yet CI-enforced pending a CUDA runnerScope bound
sync_lora_to_cpureconciles Q and V adapters (the default QLoRA target set). Configs that train K/O/gate/up/down would still evaluate those partially stale — documented in the contract as a separate extension.Surfaced (tracked separately, not in this PR)
While building the falsifier, two orthogonal GPU-training defects re-surfaced on sm_89 (both →
CUDA_ERROR_ILLEGAL_ADDRESS): (A) the seq<32 batched-softmax partial-warpshfl.syncUB (a #2252 follow-up), and (B) the fused LoRA grad-clip PTX kernels (gradient_clip_gpu_scale/clip_scale_reduce/squared_sum_reduce) failing to JIT. Next thread: verify via the realapr finetune -m qloraCLI whether these degrade real training on current main.🤖 Generated with Claude Code