Skip to content

feat: share the Block-AP batch builder and first-step guard with any HF Trainer - #2

Merged
gapsong merged 5 commits into
mainfrom
fm/qpeft-helpers-m2
Oct 9, 2026
Merged

gapsong merged 5 commits into
mainfrom
fm/qpeft-helpers-m2

Conversation

@gapsong

@gapsong gapsong commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Intent

Plan step 1 of the qpeft axolotl integration (plan in firstmate report qpeft-axolotl-s7, section 8): extract the reusable training pieces out of QATTrainer so the future axolotl plugin (shipped inside qpeft, later steps) can use them without QATTrainer. Pieces: the Block-AP calibration batch builder (was private QATTrainer._block_ap_batches) and the 'first optimizer step with lr > 0 must move a trainable parameter' guard callback (was private _FirstStepMustMoveParams). Make them a public, deep module with a tiny interface that both QATTrainer and the future plugin call. Result: new qpeft/hf_trainer.py with block_ap_batches(trainer, , train_size, seqlen, batch_size) and FirstStepMustMoveParams() (no constructor args). Captain priority: design after Ousterhout, A Philosophy of Software Design - deep modules, small interfaces hiding complexity, no shallow wrappers or pass-through methods, no leaked internals; keep invariant 2 (refuse instead of approximate). Deliberate decisions: (1) block_ap_batches takes the HF Trainer, not pre-made batches, so the private Trainer._prepare_inputs and get_train_dataloader stay hidden in one place instead of in every caller. (2) The callback no longer holds the trainer; it reads model and optimizer from the HF callback event kwargs (verified in transformers: the callback handler's model/optimizer are set to trainer.model/trainer.optimizer before on_train_begin). Because of that, the existing fp16-skip test no longer patches the accelerator property, and the old test that called the removed private block_ap_batches now checks that QATTrainer's block_ap args reach the batches (spying run_block_ap); the builder itself is tested on a plain HF Trainer in tests/test_hf_trainer.py. (3) Deliberately NOT merged into one 'Block-AP phase' call (batches + run_block_ap + set_phase e2e_qp): that is plan step 4's decision once the plugin is the second user. (4) hf_trainer.py needs transformers and is not exported from qpeft/init so 'import qpeft' / star-import stays torch-only; import path is qpeft.hf_trainer. (5) Block-AP args have no defaults in block_ap_batches on purpose (no second source of truth beside QATTrainingArguments). Must be a pure refactor: QATTrainer behavior unchanged - verified by the existing suite plus a run of old vs new code that gave a bit-identical merged model. Known pre-existing gap, fix explicitly WANTED by the captain: a previous no-mistakes test step found that the fp16 GradScaler skip check (accelerate's step_was_skipped, and the old accelerator.optimizer_step_was_skipped) is not set with HF's default fused AdamW, so a skipped first fp16 step can falsely abort training; its fix (detect the skip by a drop in the GradScaler scale instead of the accelerate flag) was lost only because the commit failed on a missing git identity, now fixed. If the test step finds it again, fixing it in this PR is in scope and is the one allowed behavior change (it makes the guard correct, never weakens fake_quant == merge). Also requested: add a short 'Design principle: deep modules (Ousterhout)' section (2-4 lines) to qpeft's CLAUDE.md. The firstmate helper fm-ensure-agents-md.sh moved CLAUDE.md to AGENTS.md with CLAUDE.md as a symlink (own commit, no content change); the section lives in AGENTS.md. README structure tree and docs/specs/qat_trainer.md got one-line pointers. Keep the PR small and reviewable; the captain reads all code. Never weaken exactness (fake_quant == merge). Tests run locally only (pytest); no GitHub Actions dependency.

What Changed

  • New qpeft/hf_trainer.py (needs transformers, not exported from qpeft/__init__) with two public pieces moved out of QATTrainer. block_ap_batches(trainer, *, train_size, seqlen, batch_size) was the private QATTrainer._block_ap_batches. FirstStepMustMoveParams() was the private _FirstStepMustMoveParams; it no longer holds the trainer and reads model and optimizer from the HF callback arguments. QATTrainer now calls both, with the same Block-AP args.
  • Fix in the first-step guard: it also detects an fp16 step that the GradScaler skipped when the GradScaler scale drops, and not only from accelerate's step_was_skipped flag. Before, with HF's default fused AdamW, a skipped first fp16 step could stop training by mistake.
  • Tests: new tests/test_hf_trainer.py tests the batch builder on a plain HF Trainer, and tests the guard with stub and real Trainer runs (including real fp16 skips). tests/test_qat_trainer.py now checks that QATTrainer's block_ap_* args reach the batches. CLAUDE.md moved to AGENTS.md (CLAUDE.md is now a symlink to it), and AGENTS.md gets a short "Design principle: deep modules (Ousterhout)" section. The README structure tree and docs/specs/qat_trainer.md now point to the new module.

Risk Assessment

✅ Low: The extraction matches the old QATTrainer code. The combined skip check (accelerate step_was_skipped OR a GradScaler scale drop) now covers non-fused AMP, fused AdamW and DeepSpeed. Checked against transformers 5.19 and accelerate 1.15: the callback handler gets the accelerate-wrapped optimizer before on_train_begin, and that wrapper exposes .scaler and step_was_skipped.

Testing

I ran the targeted test files test_hf_trainer.py and test_qat_trainer.py; all passed, and the fp16 tests really ran on MPS. Then I drove the product live on the locally cached tiny Qwen3 model. QATTrainer end to end (Block-AP, E2E-QP, merge check, save, load) on base and HEAD code gave the same artifact sha256 (980a066a...) and the same logits after load, with merge equivalence max|delta| = 0. A plain HF Trainer with the new qpeft.hf_trainer pieces worked as an integration would use it. With fp16 on MPS and an overflowing first step, the real GradScaler skipped steps 1-3. Fused AdamW never set accelerate's step_was_skipped. The base guard falsely stopped training; the new guard did not, for both fused and non-fused AdamW. The new guard still stopped lr=0 runs and runs where the step moves nothing. import qpeft stays torch-only. Only the DeepSpeed fp16 skip path was not driven live, because there is no DeepSpeed and no CUDA here. All evidence logs and scripts are in the evidence directory. The worktree is clean. One temp dir under $TMPDIR (base snapshot plus run outputs) stays: a permission prompt blocked its removal.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
QATTrainer end to end (Block-AP -> E2E-QP -> merge check -> save -> load) gives a bit-identical merged int model on base and HEAD code (pure refactor) ✅ pass live qat_trainer_new.log and qat_trainer_base.log: same sha256 980a066a..., same loaded logits, merge equivalence max|delta|=0 on 56 layers
A plain HF Trainer (no QATTrainer) uses block_ap_batches(trainer, train_size, seqlen, batch_size): labels dropped, sequences cut, rows capped, batches on the trainer's device, then run_block_ap + E2E-… ✅ pass live plain_trainer_*.log: block_ap_batches: [(2, 16), (2, 16)] keys: ['input_ids'] device: mps:0, then Block-AP per-block MSE drops and training runs
fp16 on MPS with HF default fused AdamW: the GradScaler skips the first step(s) and FirstStepMustMoveParams does NOT falsely stop the run (fix wanted by the captain) ✅ pass live plain_trainer_fp16_skip_fused.log: scale 65536->32768 with step_was_skipped=False, params unmoved, run finishes; plain_trainer_old_callback_fp16_skip_fused.log: the base guard falsely raises in the sa…
fp16 on MPS with non-fused AdamW: a skipped step (accelerate flag set) is not checked, and the run finishes ✅ pass live plain_trainer_fp16_skip_torch.log: step_was_skipped=True for steps 1-3, params move at step 4, RESULT: training finished
Adversarial: a dead run is still stopped (lr 0 the whole run; optimizer step that moves nothing, fused and non-fused) ✅ pass live plain_trainer_lr0.log: 'training ended without an optimizer step at lr > 0'; plain_trainer_nomove_fused.log / nomove_torch.log: 'no trainable parameter changed in the first step with lr > 0'
import qpeft and star-import stay torch-only; the helpers are reachable only as qpeft.hf_trainer with the agreed tiny signatures ✅ pass live import_check.txt: transformers not in sys.modules, not exported, block_ap_batches(trainer, *, train_size, seqlen, batch_size), FirstStepMustMoveParams()
DeepSpeed fp16 overflow skip (no GradScaler, only optimizer.step_was_skipped) is not seen as a dead run ⏸️ untested no DeepSpeed is not installed in .venv (ModuleNotFoundError), and DeepSpeed fp16 ZeRO needs CUDA. This macOS host has only MPS, so no DeepSpeed optimizer wrapper can be built here. To drive it live, run…
Evidence: QATTrainer run, HEAD code (sha256 + merge check + load)
qpeft from: ~/.no-mistakes/worktrees/87bf05bcdbbf/01M4GRYTPPD7M16ZV1QYMQ72Q5/qpeft/__init__.py

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 1213.59it/s]
[QATTrainer] EfficientQATConfig bits=4 group_size=64 backend=auto layers=56
[QATTrainer] targets=['down_proj', 'gate_proj', 'k_proj', 'o_proj', 'q_proj', 'up_proj', 'v_proj']
[QATTrainer] Block-AP: epochs=1 weight_lr=1e-05 quant_lr=0.0001 samples<=6 seqlen<=32; E2E-QP: lr=1e-05 max_grad_norm=0.3
[block_ap] block 0/7: mean mse 8.495e-04 -> 6.300e-04
[block_ap] block 1/7: mean mse 1.609e-03 -> 1.386e-03
[block_ap] block 2/7: mean mse 2.889e-03 -> 2.405e-03
[block_ap] block 3/7: mean mse 6.001e-03 -> 5.545e-03
[block_ap] block 4/7: mean mse 8.269e-03 -> 7.594e-03
[block_ap] block 5/7: mean mse 1.307e-02 -> 1.204e-02
[block_ap] block 6/7: mean mse 2.469e-02 -> 2.272e-02
[block_ap] block 7/7: mean mse 4.988e-02 -> 4.610e-02
[QATTrainer] Block-AP done -> codes frozen -> E2E-QP (scale only)

  0%|          | 0/6 [00:00<?, ?it/s]
 17%|█▋        | 1/6 [00:00<00:01,  3.23it/s]
 33%|███▎      | 2/6 [00:00<00:00,  4.00it/s]
                                             

 33%|███▎      | 2/6 [00:00<00:00,  4.00it/s]
 50%|█████     | 3/6 [00:00<00:00,  4.35it/s]
 67%|██████▋   | 4/6 [00:00<00:00,  4.54it/s]
                                             

 67%|██████▋   | 4/6 [00:00<00:00,  4.54it/s]
 83%|████████▎ | 5/6 [00:01<00:00,  4.66it/s]
100%|██████████| 6/6 [00:01<00:00,  4.73it/s]
                                             

100%|██████████| 6/6 [00:01<00:00,  4.73it/s]
                                             

100%|██████████| 6/6 [00:01<00:00,  4.73it/s]
100%|██████████| 6/6 [00:01<00:00,  4.48it/s]
{'loss': '8.955', 'grad_norm': '85.24', 'learning_rate': '9.33e-06', 'epoch': '0.3333'}
{'loss': '8.819', 'grad_norm': '70.87', 'learning_rate': '5e-06', 'epoch': '0.6667'}
{'loss': '8.757', 'grad_norm': '37.3', 'learning_rate': '6.699e-07', 'epoch': '1'}
{'train_runtime': '1.34', 'train_samples_per_second': '35.83', 'train_steps_per_second': '4.479', 'train_loss': '8.844', 'epoch': '1'}
[QATTrainer] merge equivalence OK on 56 layers, max|delta|=0.00e+00
merged artifact sha256: 980a066a146db37ec0c96000f9d8c3079233433668de7dea0038592053cdfa5d tensors: 203

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 2302.35it/s]
loaded int model logits[0,:3,:3]: [[0.5577409267425537, 1.3078299760818481, 5.419095516204834], [0.5096164345741272, 3.135775327682495, 5.901248931884766], [-0.08240721374750137, 3.4223906993865967, 4.663435459136963]]
Evidence: QATTrainer run, base commit code (same sha256)
qpeft from: /var/folders/nq/mt2sb54s2b99md8pfc25w7fc0000gn/T/tmp.QhLGMygf5N/qpeft_base/qpeft/__init__.py

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 13920.33it/s]
[QATTrainer] EfficientQATConfig bits=4 group_size=64 backend=auto layers=56
[QATTrainer] targets=['down_proj', 'gate_proj', 'k_proj', 'o_proj', 'q_proj', 'up_proj', 'v_proj']
[QATTrainer] Block-AP: epochs=1 weight_lr=1e-05 quant_lr=0.0001 samples<=6 seqlen<=32; E2E-QP: lr=1e-05 max_grad_norm=0.3
[block_ap] block 0/7: mean mse 8.495e-04 -> 6.300e-04
[block_ap] block 1/7: mean mse 1.609e-03 -> 1.386e-03
[block_ap] block 2/7: mean mse 2.889e-03 -> 2.405e-03
[block_ap] block 3/7: mean mse 6.001e-03 -> 5.545e-03
[block_ap] block 4/7: mean mse 8.269e-03 -> 7.594e-03
[block_ap] block 5/7: mean mse 1.307e-02 -> 1.204e-02
[block_ap] block 6/7: mean mse 2.469e-02 -> 2.272e-02
[block_ap] block 7/7: mean mse 4.988e-02 -> 4.610e-02
[QATTrainer] Block-AP done -> codes frozen -> E2E-QP (scale only)

  0%|          | 0/6 [00:00<?, ?it/s]
 17%|█▋        | 1/6 [00:00<00:01,  4.40it/s]
 33%|███▎      | 2/6 [00:00<00:00,  4.67it/s]
                                             

 33%|███▎      | 2/6 [00:00<00:00,  4.67it/s]
 50%|█████     | 3/6 [00:00<00:00,  4.72it/s]
 67%|██████▋   | 4/6 [00:00<00:00,  4.78it/s]
                                             

 67%|██████▋   | 4/6 [00:00<00:00,  4.78it/s]
 83%|████████▎ | 5/6 [00:01<00:00,  4.82it/s]
100%|██████████| 6/6 [00:01<00:00,  4.84it/s]
                                             

100%|██████████| 6/6 [00:01<00:00,  4.84it/s]
                                             

100%|██████████| 6/6 [00:01<00:00,  4.84it/s]
100%|██████████| 6/6 [00:01<00:00,  4.78it/s]
{'loss': '8.955', 'grad_norm': '85.24', 'learning_rate': '9.33e-06', 'epoch': '0.3333'}
{'loss': '8.819', 'grad_norm': '70.87', 'learning_rate': '5e-06', 'epoch': '0.6667'}
{'loss': '8.757', 'grad_norm': '37.3', 'learning_rate': '6.699e-07', 'epoch': '1'}
{'train_runtime': '1.256', 'train_samples_per_second': '38.22', 'train_steps_per_second': '4.778', 'train_loss': '8.844', 'epoch': '1'}
[QATTrainer] merge equivalence OK on 56 layers, max|delta|=0.00e+00
merged artifact sha256: 980a066a146db37ec0c96000f9d8c3079233433668de7dea0038592053cdfa5d tensors: 203

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 10776.79it/s]
loaded int model logits[0,:3,:3]: [[0.5577409267425537, 1.3078299760818481, 5.419095516204834], [0.5096164345741272, 3.135775327682495, 5.901248931884766], [-0.08240721374750137, 3.4223906993865967, 4.663435459136963]]
Evidence: Old vs new merged artifact
base: merged artifact sha256: 980a066a146db37ec0c96000f9d8c3079233433668de7dea0038592053cdfa5d tensors: 203
new: merged artifact sha256: 980a066a146db37ec0c96000f9d8c3079233433668de7dea0038592053cdfa5d tensors: 203
both: [QATTrainer] merge equivalence OK on 56 layers, max|delta|=0.00e+00
Evidence: Plain Trainer fp16 skip, fused AdamW, NEW guard

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 10722.00it/s]
device: mps fp16: True optim: adamw_torch_fused
block_ap_batches: [(2, 16), (2, 16)] keys: ['input_ids'] device: mps:0
[block_ap] block 0/7: mean mse 1.032e-03 -> 8.717e-04
[block_ap] block 1/7: mean mse 3.186e-03 -> 2.942e-03
[block_ap] block 2/7: mean mse 6.217e-03 -> 5.857e-03
[block_ap] block 3/7: mean mse 1.044e-02 -> 9.614e-03
[block_ap] block 4/7: mean mse 1.328e-02 -> 1.275e-02
[block_ap] block 5/7: mean mse 2.103e-02 -> 1.999e-02
[block_ap] block 6/7: mean mse 3.914e-02 -> 3.736e-02
[block_ap] block 7/7: mean mse 8.352e-02 -> 8.162e-02
guard: qpeft.hf_trainer.FirstStepMustMoveParams()

  0%|          | 0/4 [00:00<?, ?it/s]
 25%|██▌       | 1/4 [00:00<00:01,  2.30it/s]
                                             

 25%|██▌       | 1/4 [00:00<00:01,  2.30it/s]
 50%|█████     | 2/4 [00:00<00:00,  4.17it/s]
                                             

 50%|█████     | 2/4 [00:00<00:00,  4.17it/s]
 75%|███████▌  | 3/4 [00:00<00:00,  5.64it/s]
                                             

 75%|███████▌  | 3/4 [00:00<00:00,  5.64it/s]
                                             

100%|██████████| 4/4 [00:00<00:00,  5.64it/s]
                                             

100%|██████████| 4/4 [00:00<00:00,  5.64it/s]
100%|██████████| 4/4 [00:00<00:00,  5.44it/s]
step 1: params moved=False grad_scale 65536.0 -> 32768.0 accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
{'loss': '12.55', 'grad_norm': 'inf', 'learning_rate': '0.0001', 'epoch': '0.1667'}
step 2: params moved=False grad_scale 32768.0 -> 16384.0 accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
{'loss': '12.6', 'grad_norm': 'inf', 'learning_rate': '7.5e-05', 'epoch': '0.3333'}
step 3: params moved=False grad_scale 16384.0 -> 8192.0 accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
{'loss': '12.83', 'grad_norm': 'inf', 'learning_rate': '5e-05', 'epoch': '0.5'}
step 4: params moved=True grad_scale 8192.0 -> 8192.0 accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
{'loss': '13.03', 'grad_norm': '109.3', 'learning_rate': '2.5e-05', 'epoch': '0.6667'}
{'train_runtime': '0.7429', 'train_samples_per_second': '21.54', 'train_steps_per_second': '5.384', 'train_loss': '12.75', 'epoch': '0.6667'}
RESULT: training finished (guard did not abort)
Evidence: Plain Trainer fp16 skip, fused AdamW, BASE guard (false abort reproduced)

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 13872.27it/s]
device: mps fp16: True optim: adamw_torch_fused
block_ap_batches: [(2, 16), (2, 16)] keys: ['input_ids'] device: mps:0
[block_ap] block 0/7: mean mse 1.032e-03 -> 8.717e-04
[block_ap] block 1/7: mean mse 3.186e-03 -> 2.942e-03
[block_ap] block 2/7: mean mse 6.217e-03 -> 5.857e-03
[block_ap] block 3/7: mean mse 1.044e-02 -> 9.614e-03
[block_ap] block 4/7: mean mse 1.328e-02 -> 1.275e-02
[block_ap] block 5/7: mean mse 2.103e-02 -> 1.999e-02
[block_ap] block 6/7: mean mse 3.914e-02 -> 3.736e-02
[block_ap] block 7/7: mean mse 8.352e-02 -> 8.162e-02
guard: BASE commit _FirstStepMustMoveParams(trainer)

  0%|          | 0/4 [00:00<?, ?it/s]
 25%|██▌       | 1/4 [00:00<00:00,  4.31it/s]step 1: params moved=False grad_scale 65536.0 -> 32768.0 accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
RESULT: guard stopped training: no trainable parameter changed in the first step with lr > 0 -- check the learning rates and the trainable set.
Evidence: fp16 skip probe (fused AdamW, MPS)
step 1: params moved=False grad_scale 65536.0 -> 32768.0 accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
...
step 4: params moved=True grad_scale 8192.0 -> 8192.0
NEW guard: RESULT: training finished (guard did not abort)
BASE guard: RESULT: guard stopped training: no trainable parameter changed in the first step with lr > 0
Evidence: Plain Trainer fp16 skip, non-fused AdamW, NEW guard

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 13217.04it/s]
device: mps fp16: True optim: adamw_torch
block_ap_batches: [(2, 16), (2, 16)] keys: ['input_ids'] device: mps:0
[block_ap] block 0/7: mean mse 1.032e-03 -> 8.717e-04
[block_ap] block 1/7: mean mse 3.186e-03 -> 2.942e-03
[block_ap] block 2/7: mean mse 6.217e-03 -> 5.857e-03
[block_ap] block 3/7: mean mse 1.044e-02 -> 9.614e-03
[block_ap] block 4/7: mean mse 1.328e-02 -> 1.275e-02
[block_ap] block 5/7: mean mse 2.103e-02 -> 1.999e-02
[block_ap] block 6/7: mean mse 3.914e-02 -> 3.736e-02
[block_ap] block 7/7: mean mse 8.352e-02 -> 8.162e-02
guard: qpeft.hf_trainer.FirstStepMustMoveParams()

  0%|          | 0/4 [00:00<?, ?it/s]
 25%|██▌       | 1/4 [00:00<00:00,  4.25it/s]
                                             

 25%|██▌       | 1/4 [00:00<00:00,  4.25it/s]
 50%|█████     | 2/4 [00:00<00:00,  6.34it/s]
                                             

 50%|█████     | 2/4 [00:00<00:00,  6.34it/s]
 75%|███████▌  | 3/4 [00:00<00:00,  7.50it/s]
                                             

 75%|███████▌  | 3/4 [00:00<00:00,  7.50it/s]
100%|██████████| 4/4 [00:00<00:00,  8.04it/s]
                                             

100%|██████████| 4/4 [00:00<00:00,  8.04it/s]
                                             

100%|██████████| 4/4 [00:00<00:00,  8.04it/s]
100%|██████████| 4/4 [00:00<00:00,  7.21it/s]
step 1: params moved=False grad_scale 65536.0 -> 32768.0 accelerate.step_was_skipped=True optimizer=AdamW(fused=None)
{'loss': '12.55', 'grad_norm': 'inf', 'learning_rate': '0.0001', 'epoch': '0.1667'}
step 2: params moved=False grad_scale 32768.0 -> 16384.0 accelerate.step_was_skipped=True optimizer=AdamW(fused=None)
{'loss': '12.6', 'grad_norm': 'inf', 'learning_rate': '0.0001', 'epoch': '0.3333'}
step 3: params moved=False grad_scale 16384.0 -> 8192.0 accelerate.step_was_skipped=True optimizer=AdamW(fused=None)
{'loss': '12.83', 'grad_norm': 'inf', 'learning_rate': '0.0001', 'epoch': '0.5'}
step 4: params moved=True grad_scale 8192.0 -> 8192.0 accelerate.step_was_skipped=False optimizer=AdamW(fused=None)
{'loss': '13.03', 'grad_norm': '109.3', 'learning_rate': '0.0001', 'epoch': '0.6667'}
{'train_runtime': '0.5611', 'train_samples_per_second': '28.52', 'train_steps_per_second': '7.129', 'train_loss': '12.75', 'epoch': '0.6667'}
RESULT: training finished (guard did not abort)
Evidence: Dead run lr=0 stopped

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 15798.08it/s]
device: mps fp16: False optim: adamw_torch_fused
block_ap_batches: [(2, 16), (2, 16)] keys: ['input_ids'] device: mps:0
[block_ap] block 0/7: mean mse 1.032e-03 -> 8.717e-04
[block_ap] block 1/7: mean mse 3.186e-03 -> 2.942e-03
[block_ap] block 2/7: mean mse 6.217e-03 -> 5.857e-03
[block_ap] block 3/7: mean mse 1.044e-02 -> 9.614e-03
[block_ap] block 4/7: mean mse 1.328e-02 -> 1.275e-02
[block_ap] block 5/7: mean mse 2.103e-02 -> 1.999e-02
[block_ap] block 6/7: mean mse 3.914e-02 -> 3.736e-02
[block_ap] block 7/7: mean mse 8.352e-02 -> 8.162e-02
guard: qpeft.hf_trainer.FirstStepMustMoveParams()

  0%|          | 0/4 [00:00<?, ?it/s]
 25%|██▌       | 1/4 [00:00<00:00,  3.64it/s]
                                             

 25%|██▌       | 1/4 [00:00<00:00,  3.64it/s]
 50%|█████     | 2/4 [00:00<00:00,  5.76it/s]
                                             

 50%|█████     | 2/4 [00:00<00:00,  5.76it/s]
 75%|███████▌  | 3/4 [00:00<00:00,  7.11it/s]
                                             

 75%|███████▌  | 3/4 [00:00<00:00,  7.11it/s]
100%|██████████| 4/4 [00:00<00:00,  7.81it/s]
                                             

100%|██████████| 4/4 [00:00<00:00,  7.81it/s]
                                             

100%|██████████| 4/4 [00:00<00:00,  7.81it/s]
100%|██████████| 4/4 [00:00<00:00,  6.80it/s]
step 1: params moved=False grad_scale None -> None accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
{'loss': '12.55', 'grad_norm': '96.18', 'learning_rate': '0', 'epoch': '0.1667'}
step 2: params moved=False grad_scale None -> None accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
{'loss': '12.6', 'grad_norm': '107.9', 'learning_rate': '0', 'epoch': '0.3333'}
step 3: params moved=False grad_scale None -> None accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
{'loss': '12.83', 'grad_norm': '126.2', 'learning_rate': '0', 'epoch': '0.5'}
step 4: params moved=False grad_scale None -> None accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
{'loss': '13.04', 'grad_norm': '109.3', 'learning_rate': '0', 'epoch': '0.6667'}
{'train_runtime': '0.5953', 'train_samples_per_second': '26.88', 'train_steps_per_second': '6.719', 'train_loss': '12.76', 'epoch': '0.6667'}
RESULT: guard stopped training: training ended without an optimizer step at lr > 0 -- check the learning rates and the trainable set.
Evidence: Dead run no-move stopped (fused)

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 12085.04it/s]
device: mps fp16: False optim: adamw_torch_fused
block_ap_batches: [(2, 16), (2, 16)] keys: ['input_ids'] device: mps:0
[block_ap] block 0/7: mean mse 1.032e-03 -> 8.717e-04
[block_ap] block 1/7: mean mse 3.186e-03 -> 2.942e-03
[block_ap] block 2/7: mean mse 6.217e-03 -> 5.857e-03
[block_ap] block 3/7: mean mse 1.044e-02 -> 9.614e-03
[block_ap] block 4/7: mean mse 1.328e-02 -> 1.275e-02
[block_ap] block 5/7: mean mse 2.103e-02 -> 1.999e-02
[block_ap] block 6/7: mean mse 3.914e-02 -> 3.736e-02
[block_ap] block 7/7: mean mse 8.352e-02 -> 8.162e-02
guard: qpeft.hf_trainer.FirstStepMustMoveParams()

  0%|          | 0/4 [00:00<?, ?it/s]
 25%|██▌       | 1/4 [00:00<00:01,  2.95it/s]step 1: params moved=False grad_scale None -> None accelerate.step_was_skipped=False optimizer=AdamW(fused=True)
RESULT: guard stopped training: no trainable parameter changed in the first step with lr > 0 -- check the learning rates and the trainable set.

 25%|██▌       | 1/4 [00:00<00:01,  2.21it/s]
Evidence: Dead run no-move stopped (non-fused)

Loading weights:   0%|          | 0/91 [00:00<?, ?it/s]
Loading weights: 100%|██████████| 91/91 [00:00<00:00, 14407.43it/s]
device: mps fp16: False optim: adamw_torch
block_ap_batches: [(2, 16), (2, 16)] keys: ['input_ids'] device: mps:0
[block_ap] block 0/7: mean mse 1.032e-03 -> 8.717e-04
[block_ap] block 1/7: mean mse 3.186e-03 -> 2.942e-03
[block_ap] block 2/7: mean mse 6.217e-03 -> 5.857e-03
[block_ap] block 3/7: mean mse 1.044e-02 -> 9.614e-03
[block_ap] block 4/7: mean mse 1.328e-02 -> 1.275e-02
[block_ap] block 5/7: mean mse 2.103e-02 -> 1.999e-02
[block_ap] block 6/7: mean mse 3.914e-02 -> 3.736e-02
[block_ap] block 7/7: mean mse 8.352e-02 -> 8.162e-02
guard: qpeft.hf_trainer.FirstStepMustMoveParams()

  0%|          | 0/4 [00:00<?, ?it/s]
 25%|██▌       | 1/4 [00:00<00:00,  4.61it/s]step 1: params moved=False grad_scale None -> None accelerate.step_was_skipped=False optimizer=AdamW(fused=None)
RESULT: guard stopped training: no trainable parameter changed in the first step with lr > 0 -- check the learning rates and the trainable set.

 25%|██▌       | 1/4 [00:00<00:00,  3.12it/s]
Evidence: torch-only import + public interface check
transformers imported by import qpeft / star-import: False
hf_trainer exported from qpeft: False
qpeft.hf_trainer public: block_ap_batches FirstStepMustMoveParams
block_ap_batches signature: (trainer, *, train_size: 'int', seqlen: 'int', batch_size: 'int') -> 'list[dict]'
FirstStepMustMoveParams(): ()
Evidence: Targeted pytest output
.................................................                        [100%]
49 passed in 5.41s
  • Evidence: Driver scripts (local file: ~/.no-mistakes/evidence/01M4GRYTPPD7M16ZV1QYMQ72Q5/scripts)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed (2) ✅
  • ⚠️ qpeft/hf_trainer.py:66 - This is the known gap the captain asked to have fixed. FirstStepMustMoveParams now says it works for 'any HF Trainer', but it detects a skipped fp16 step only through accelerate's optimizer.step_was_skipped. That flag is never set when the optimizer is fused AdamW. How it fails: accelerate swaps in its own optimizer.step, which sets _accelerate_step_called = True. Fused AdamW has _step_supports_amp_scaling, so the GradScaler always calls that step and lets the fused kernel skip the update when there is an overflow. So _is_overflow stays False. Concrete sequence: a plain HF Trainer (the planned axolotl path, and tests/test_hf_trainer.py) with the default optim (adamw_torch_fused in transformers 5.x), fp16=True, on CUDA. The first step overflows at the initial scale of 65536, which is common, so the GradScaler skips it. The params do not move, and line 70 raises 'no trainable parameter changed', so a healthy run stops by mistake. QATTrainer is not hit today because it builds a non-fused torch.optim.AdamW(groups). Fix, as the intent says: in on_step_begin, record optimizer.scaler.get_scale() when getattr(optimizer, &#39;scaler&#39;, None) is set. In on_step_end, treat a lower scale as a skipped step (the GradScaler halves the scale only on overflow). Remove the step_was_skipped read. Places that depend on the same rule: qpeft/hf_trainer.py:57-58 (on_step_begin must take the scale snapshot); tests/test_hf_trainer.py:91-92 and :124-129 (the fake optimizer and the skip test use step_was_skipped and must use a scaler whose scale drops); tests/test_qat_trainer.py:287-288 (patches AcceleratedOptimizer.step_was_skipped and must instead lower the scaler scale on the skipped step). Add a regression test that fails before the fix: a real or fake GradScaler that skips the step with no accelerate flag set. This is the one behavior change the intent allows. It does not weaken fake_quant == merge.

🔧 Fix applied.
2 warnings still open:

  • ⚠️ qpeft/hf_trainer.py:66 - This is the known gap the captain asked to have fixed. FirstStepMustMoveParams now says it works for 'any HF Trainer', but it detects a skipped fp16 step only through accelerate's optimizer.step_was_skipped. That flag is never set when the optimizer is fused AdamW. How it fails: accelerate swaps in its own optimizer.step, which sets _accelerate_step_called = True. Fused AdamW has _step_supports_amp_scaling, so the GradScaler always calls that step and lets the fused kernel skip the update when there is an overflow. So _is_overflow stays False. Concrete sequence: a plain HF Trainer (the planned axolotl path, and tests/test_hf_trainer.py) with the default optim (adamw_torch_fused in transformers 5.x), fp16=True, on CUDA. The first step overflows at the initial scale of 65536, which is common, so the GradScaler skips it. The params do not move, and line 70 raises 'no trainable parameter changed', so a healthy run stops by mistake. QATTrainer is not hit today because it builds a non-fused torch.optim.AdamW(groups). Fix, as the intent says: in on_step_begin, record optimizer.scaler.get_scale() when getattr(optimizer, &#39;scaler&#39;, None) is set. In on_step_end, treat a lower scale as a skipped step (the GradScaler halves the scale only on overflow). Remove the step_was_skipped read. Places that depend on the same rule: qpeft/hf_trainer.py:57-58 (on_step_begin must take the scale snapshot); tests/test_hf_trainer.py:91-92 and :124-129 (the fake optimizer and the skip test use step_was_skipped and must use a scaler whose scale drops); tests/test_qat_trainer.py:287-288 (patches AcceleratedOptimizer.step_was_skipped and must instead lower the scaler scale on the skipped step). Add a regression test that fails before the fix: a real or fake GradScaler that skips the step with no accelerate flag set. This is the one behavior change the intent allows. It does not weaken fake_quant == merge.
  • ⚠️ qpeft/hf_trainer.py:66 - Round 1 fixed fused AdamW, but it removed the accelerate skip flag completely, as that round told it to. This opened a gap for DeepSpeed. Now FirstStepMustMoveParams sees a skipped step only when the GradScaler scale goes down (_grad_scale reads optimizer.scaler). Under DeepSpeed, accelerate creates no GradScaler (accelerator.py: native_amp/scaler stay None for DistributedType.DEEPSPEED), and DeepSpeedOptimizerWrapper is built with scaler=None. So _grad_scale always returns 1.0, and a DeepSpeed fp16 overflow skip is never seen. The base code handled this case: accelerator.optimizer_step_was_skipped reads DeepSpeedOptimizerWrapper.step_was_skipped, which is optimizer.overflow. How it fails: a plain HF Trainer or QATTrainer with a DeepSpeed ZeRO-1/2 config and fp16 (the multi-GPU path HF provides, and a common setup for the planned axolotl plugin). The first step overflows at the default initial loss scale 2^16. DeepSpeed skips it, so no param moves, and line 73 raises 'no trainable parameter changed'. A healthy run stops by mistake. Fix: count a step as skipped when either signal is set: getattr(optimizer, &#39;step_was_skipped&#39;, False) or _grad_scale(optimizer) &lt; self.scale_before. The flag covers DeepSpeed and non-fused AMP, and the scale drop covers fused AdamW. Keep the comment on why the scale check is needed. Add a unit test with a fake optimizer that has step_was_skipped=True and no scaler. Same rule elsewhere: tests/test_hf_trainer.py:85-86 (_optimizer fake has no step_was_skipped).

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
QATTrainer end to end (Block-AP -> E2E-QP -> merge check -> save -> load) gives a bit-identical merged int model on base and HEAD code (pure refactor) ✅ pass live qat_trainer_new.log and qat_trainer_base.log: same sha256 980a066a..., same loaded logits, merge equivalence max|delta|=0 on 56 layers
A plain HF Trainer (no QATTrainer) uses block_ap_batches(trainer, train_size, seqlen, batch_size): labels dropped, sequences cut, rows capped, batches on the trainer's device, then run_block_ap + E2E-… ✅ pass live plain_trainer_*.log: block_ap_batches: [(2, 16), (2, 16)] keys: ['input_ids'] device: mps:0, then Block-AP per-block MSE drops and training runs
fp16 on MPS with HF default fused AdamW: the GradScaler skips the first step(s) and FirstStepMustMoveParams does NOT falsely stop the run (fix wanted by the captain) ✅ pass live plain_trainer_fp16_skip_fused.log: scale 65536->32768 with step_was_skipped=False, params unmoved, run finishes; plain_trainer_old_callback_fp16_skip_fused.log: the base guard falsely raises in the sa…
fp16 on MPS with non-fused AdamW: a skipped step (accelerate flag set) is not checked, and the run finishes ✅ pass live plain_trainer_fp16_skip_torch.log: step_was_skipped=True for steps 1-3, params move at step 4, RESULT: training finished
Adversarial: a dead run is still stopped (lr 0 the whole run; optimizer step that moves nothing, fused and non-fused) ✅ pass live plain_trainer_lr0.log: 'training ended without an optimizer step at lr > 0'; plain_trainer_nomove_fused.log / nomove_torch.log: 'no trainable parameter changed in the first step with lr > 0'
import qpeft and star-import stay torch-only; the helpers are reachable only as qpeft.hf_trainer with the agreed tiny signatures ✅ pass live import_check.txt: transformers not in sys.modules, not exported, block_ap_batches(trainer, *, train_size, seqlen, batch_size), FirstStepMustMoveParams()
DeepSpeed fp16 overflow skip (no GradScaler, only optimizer.step_was_skipped) is not seen as a dead run ⏸️ untested no DeepSpeed is not installed in .venv (ModuleNotFoundError), and DeepSpeed fp16 ZeRO needs CUDA. This macOS host has only MPS, so no DeepSpeed optimizer wrapper can be built here. To drive it live, run…
  • .venv/bin/python -m pytest tests/test_hf_trainer.py tests/test_qat_trainer.py -q -rs (49 passed, fp16 tests ran on MPS, none skipped)
  • PYTHONPATH=&lt;worktree&gt; python scripts/qat_run.py vs PYTHONPATH=&lt;base b01059f qpeft&gt; python scripts/qat_run.py: QATTrainer Block-AP -> E2E-QP -> merge check -> save_model -> QuantModel.from_pretrained on cached axolotl-ai-co/tiny-qwen3-129m, CPU, offline; sha256 of merged artifact + loaded logits compared
  • scripts/plain_trainer_run.py fp16_skip_fused / fp16_skip_torch: plain HF Trainer + QuantModel, block_ap_batches -> run_block_ap -> set_phase(e2e_qp) -> train fp16 on MPS with an inf first gradient; per-step probe of GradScaler scale, accelerate step_was_skipped, params moved
  • scripts/plain_trainer_run.py old_callback_fp16_skip_fused / old_callback_fp16_skip_torch: same run with the base commit's _FirstStepMustMoveParams(trainer) to reproduce the pre-fix false abort
  • scripts/plain_trainer_run.py lr0, nomove_fused, nomove_torch: adversarial dead runs in the real Trainer loop
  • python -c &#39;import sys, qpeft; from qpeft import *; ...&#39;: transformers not imported, hf_trainer not exported, public signatures checked
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Kun and others added 5 commits October 9, 2026 18:34
AGENTS.md is the file every coding agent reads; CLAUDE.md points at it,
so Claude Code still loads the same text. No content change.
Every future change should apply it: small interfaces that hide a lot,
complexity pulled down into the module, no pass-through layers, and
errors defined out of existence only where invariant 2 (refuse, do not
approximate) still holds.
… Trainer

The axolotl plugin (later steps) drives a Hugging Face Trainer it did
not build and needs two things QATTrainer kept private: the Block-AP
calibration batches and the "first step must move parameters" guard.
Both now live in qpeft/hf_trainer.py, and QATTrainer calls them.

  block_ap_batches(trainer, *, train_size, seqlen, batch_size)
  trainer.add_callback(FirstStepMustMoveParams())

Shape (deep modules): each piece takes only what it cannot find itself.

- block_ap_batches takes the trainer, not a list of batches. The hidden
  part is the Trainer-specific work: get_train_dataloader, the private
  _prepare_inputs (device placement), label removal, the sequence cut,
  the re-split and the exact row count. Taking pre-made batches would
  push _prepare_inputs into every caller.
- FirstStepMustMoveParams no longer holds the trainer. It reads model and
  optimizer from the callback events, which is what the Trainer hands
  every callback, and it reads the fp16 skip from the optimizer
  (accelerate's step_was_skipped, the flag that
  accelerator.optimizer_step_was_skipped itself reads). So it works with
  any Trainer through the plain callback contract.

Not merged into one "Block-AP phase" call (batches + run_block_ap +
set_phase): that is plan step 4's call to make, when the plugin is the
second user.

hf_trainer.py needs transformers, so `import qpeft` stays torch-only.

No behavior change: QATTrainer with the old and the new code gives a
bit-identical merged model (Block-AP + E2E-QP on a tiny Llama, CPU).
Two existing tests change with the move: the fp16-skip test now patches
AcceleratedOptimizer.step_was_skipped, and the batch-size test checks
that QATTrainer's block_ap_* args reach the batches. The batch builder
and the guard get their own tests on a plain Trainer.
@gapsong
gapsong merged commit b331078 into main Oct 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant