fix: forward beta to GRPOConfig (GRPO ran with no KL term) and stop growing the vocab every run - #96
Draft
kayyar-roblox wants to merge 4 commits into
Draft
fix: forward beta to GRPOConfig (GRPO ran with no KL term) and stop growing the vocab every run#96kayyar-roblox wants to merge 4 commits into
kayyar-roblox wants to merge 4 commits into
Conversation
Two independent bugs found while running a GRPO smoke test on Apple Silicon.
1. GRPO never forwarded `beta` to GRPOConfig, so training.beta was inert and
every GRPO run used TRL's default beta=0.0 -- no KL term at all. With no
anchor to the reference policy, GSM8K training collapses to emitting bare
digits inside empty <think> tags, which earns full format+digit reward while
abandoning chain-of-thought. Measured: held-out accuracy 44% -> 17%.
GSPO already forwarded beta; GRPO did not.
epsilon/epsilon_high/steps_per_generation are deliberately left un-forwarded
for GRPO: their TrainingConfig defaults are GSPO paper values and would
silently tighten GRPO clipping from TRL's 0.2 to 3e-4.
2. setup_tokenizer_with_model called add_special_tokens({"pad_token": "[PAD]"})
unconditionally, making the preceding `if pad_token is None` check dead code.
For models that already pad (Qwen2.5 pads with <|endoftext|>) this appended a
redundant token, and the unconditional resize_token_embeddings then changed
the embedding row count (Qwen2.5-0.5B: 151936 -> 151666, a shrink of the
published matrix). PEFT therefore serialized the full embed_tokens + lm_head
into every adapter: 561 MB of which 98.4% was embeddings, for 4.4 MB of LoRA.
Now pad falls back to eos when absent (no vocab growth for Llama 3 / GPT-2
either), and the embedding is only ever grown, never shrunk.
Adapter size for the smoke recipe: 561 MB -> 17.6 MB.
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.
Fix GRPO KL regularization and per-checkpoint vocab growth
Found while running a GRPO/RLVR smoke test on Apple Silicon. Two independent bugs, each verified by execution, plus a reproducible ablation showing the first one silently destroys held-out accuracy.
Bug 1 — GRPO ran with no KL penalty, and no knob could enable one
grpo_trainer.py:setup_training_argsforwardednum_generationsandmax_completion_lengthbut neverbeta. TRL 1.2.0'sGRPOConfigdefaults tobeta=0.0, so every GRPO run trained with the KL term switched off andtraining.betawas silently inert.gspo_trainer.pyalready forwarded it; GRPO did not.With nothing anchoring the policy to its reference, the shaping rewards are free to dominate. On GSM8K the policy learns that an empty
<think>block plus a bare integer collects the full format reward (0.5) and digit reward (0.5):Training-time telemetry, same 32 steps and same data, only
betadiffers — theklchannel is exactly zero before the fix:epsilon/epsilon_high/steps_per_generationare deliberately not forwarded for GRPO: theirTrainingConfigdefaults (3e-4 / 4e-4 / 4) are the GSPO paper's values, and applying them to GRPO would silently tighten clipping from TRL's 0.2 to 3e-4. They stay GSPO-only until GRPO gets its own defaults.Bug 2 — every adapter checkpoint carried the full embedding matrix
core/trainer_base.py:setup_tokenizer_with_modelcalledadd_special_tokens({"pad_token": "[PAD]"})unconditionally, which made the precedingif tokenizer.pad_token is Nonecheck dead code. Qwen2.5 already pads with<|endoftext|>, so this appended a redundant token (len(tokenizer)151665 → 151666). The unconditionalresize_token_embeddings(len(tokenizer))then changed the embedding row count — for Qwen2.5-0.5B a shrink of the published matrix from 151936 to 151666 rows — so PEFT flagged the embedding as modified and serialized it into every checkpoint:98.4% embeddings, for 4.4 M of actual LoRA. 561 MB per checkpoint on a 0.5B model; ~2 GB on an 8B one, multiplied by
save_stepsand by S3 checkpoint upload where enabled.The fix reuses
eosas pad when a tokenizer has none (so Llama 3 / GPT-2 don't grow either) and only ever grows the embedding, never shrinks it — a naive!=guard would truncate Qwen's published 151936 rows down to 151665.adapter_model.safetensorsAblation
Held-out GSM8K
test[:100], greedy, paired (identical problems every arm), 32-step runs overtrain[:64], Qwen2.5-0.5B-Instruct + LoRA. Reproduce withscripts/ab_eval_grpo.py.Two scorings are reported because the repo's
extract_answerrequires<answer>tags, which the base model never emits — scoring it strictly reports 0% no matter how good its arithmetic is:<answer>-tag extractor, i.e. what GRPO actually optimizesMcNemar exact tests on the paired outcomes:
noklvs baseklvs baseklvsnoklklvsnoklWhat this shows: forwarding
betaconverts a statistically significant −27 point accuracy regression into one that is statistically indistinguishable from the untrained base (−7 points, p=0.27), while significantly improving both the optimized metric (+13 points strict) and true accuracy (+20 points loose) over the current trainer.What this does not show: the fixed run does not beat the base model on format-blind math (37% vs 44%). 32 steps on 64 prompts is a smoke test, not a training budget — the claim here is that the KL term stops GRPO from actively destroying the policy, not that this recipe improves reasoning.
Also included
recipes/training/grpo/qwen2_5_0_5B_mps_smoke.yaml— a GRPO recipe sized for a single Apple Silicon box (0.5B model,train[:64], 32 steps ≈ 5 min, 1.4 GB unified memory), with measured throughput annotated. Useful as a CI-able smoke test for the RLVR path; the stock GRPO recipe pulls OpenMathInstruct-2's 1M-row split as a second dataset, which is not a smoke test.scripts/ab_eval_grpo.py— the paired evaluator used above. Scores with the repo's own template and reward functions, and auto-detects whether an adapter carries a legacy resized embedding so pre- and post-fix checkpoints can be compared in one run.Reproduction
Environment: M3 Max / 64 GB, macOS, torch 2.13.0, transformers 5.8.0, trl 1.2.0, peft 0.19.0.
Note on scope of testing: the
klarm above was trained on the exact code path in this PR for Qwen2.5 (which has a pad token, so theeosfallback branch is not reached). Theeosfallback and the resize guard were verified separately against Qwen2.5 and GPT-2.Not addressed here
Found during the same audit, left out to keep this PR reviewable:
DPOConfignever receivesbetaeither —dpo_trainer.pyhas zero references to it, so DPO always trains at TRL's default 0.1 andtraining.betais silently ignored. Latent (no shipped DPO recipe sets it) but it is the DPO hyperparameter.data.datasets[0].name=override syntax does not work.set_nested_value(utils/recipe_overrides.py:35) splits only on., producing a bogusdatasets[0]key and aTypeError. Documented in 7 places acrosstrainers/README.mdandrecipes/training/README.md.data.max_prompt_lengthnever truncates prompts for GRPO/GSPO — TRL 1.2.0'sGRPOConfighas no such field. It only acts as the subtrahend formax_completion_length, so the recipe comment "Maximum length for prompts" is misleading.save_only_modelandprediction_loss_only.exact_matchis raw string equality, so 8/1000 GSM8K gold answers containing commas can never match;count_xml's trailing penalty is unbounded (5000 trailing chars → −4.5, swamping the +2.0 correctness reward);extract_answerreturns the entire completion when no<answer>tag is present;digit_rewardrejects negatives and decimals.custom_reward_funcships in the live reward list returning a constant0.0.Happy to split any of these into follow-ups.