The scripts are in the repository, not in the installed package. Clone it,
then
cd recipes/community/grpo-group-size-at-fixed-budget before running the commands below. Browse this recipe on GitHub.num_generations) across G ∈ {2, 4, 8} while every
arm spends the same 768 rollouts, and ask whether the knob moves held-out
GSM8K accuracy past the noise floor. Because the budget is fixed, G is not
“more sampling”; it is group size traded against prompt coverage, which is
the choice you actually face when the GPU hours are already decided.
What you will learn: how to sweep a training knob without the arms costing
different amounts, how to get a noise floor out of the sweep itself for free,
and what fraction of a fixed rollout budget each group size throws away on
groups that carry no gradient. You need a Modal account and about 30 minutes
of one L40S per three arms; --dry-run needs neither, and no model API key
is used anywhere in this recipe — the reward and the metric are both programs.
Why fixed budget
recipes/04-train/grpo/train_modal.py sets
G=8 costs four
times G=2, and an arm that wins may have won on compute. Here the
generation batch is pinned at 16 and only the grouping moves:
All three arms finished within seconds of each other on identical hardware,
which is the check that the budget really was held still.
Run it
Smoke first:
modal run sweep_modal.py --steps 2 --holdout-n 8 --only 4
is about three minutes and proves the whole path.
The setup
- Base:
Qwen/Qwen2.5-1.5B-Instruct, LoRA r=16 α=32 on all attention and MLP projections, lr 5e-6, β(KL)=0.04,scale_rewards=True, temperature 0.8. Gradient checkpointing is off: this trainer generates during training. - Reward:
wai.verify.MathEqual()— a program, not a judge. - Held-out set: 100 GSM8K test questions, 4 samples each, scored by the same verifier at the same temperature as training.
- Decontamination:
wai.decontaminate(train_rows, holdout)before anything trains. 384 in, 384 kept, 0 contaminated — GSM8K’s train and test splits are genuinely disjoint, so this is a clean control rather than a save. - Powered for:
holdout_size(0.10, base=0.45, k=4)says 98 tasks; 100 at k=4 detects +0.10 at 80% power and would need 391 for +0.05. That bound was fixed before the GPU ran, and it is the reason a small gap here is reported as “not resolved” rather than “absent”.
The training reward is the eval metric, on purpose
Both areMathEqual on the final answer. For a knob sweep that is what you
want — any gap between arms is the knob, not reward design. It does mean this
run has no proxy-versus-target gap to detect, so it says nothing about reward
hacking; hack_scan has nothing to do here.
What you get
Held-out accuracy: flat, at every group size. Nothing here moved, and the run was sized in advance to say so honestly. Noise floor first — the same untrained weights, the same 100 tasks, three separate containers:
Each arm against its own base, paired on the same 100 tasks:
And the knob question itself, arm against arm on the same holdout:
Every interval contains zero and every delta sits under the 0.063 noise band.
At 768 rollouts, group size did not move held-out GSM8K accuracy, and the
intervals rule out any effect bigger than about ±0.07. They do not rule out a
smaller one:
holdout_size says the +0.010 between G=4 and G=8 would need
about 9,800 tasks to resolve. This run was powered for +0.10 and found
nothing that large — that is the whole claim.
The number that did move
The knob has a large, clean, monotone effect one level down — on how much of the budget carries any gradient at all. A group whose members all pass or all fail has zero advantage, so those rollouts bought nothing:
The share of unanimous groups has to fall as G grows, so the raw column
proves nothing by itself. The comparison against
p^G + (1-p)^G at each arm’s
own mean reward is the result: at G=8 a homogeneous model predicts 1.5% of
groups wasted and the run wastes 28% — nineteen times more. The gap widens
sharply with G.
The reason is prompt heterogeneity. GSM8K at this model is not 100 coin flips
at p≈0.57; it is mostly questions the model always gets right and questions it
always gets wrong, with few borderline ones. Bigger groups keep re-sampling
the saturated prompts and keep learning nothing from them.
What that is worth knowing for: it prices dynamic sampling. Dropping
unanimous groups and refilling (DAPO; wai.select(mode="rl", band=(0.2, 0.8))
is the same rule applied to rows) looks nearly pointless at G=8 under the
textbook estimate — 1.5% of the budget. On this task it reclaims 28%. If you
are choosing between “raise G” and “filter the groups”, the filter is worth
about nineteen times more than the arithmetic suggests, and this is the
cheapest measurement that tells you so.
What would have made the accuracy move
Not the knob. 48 steps at 768 rollouts is roughly one pass over 384 prompts; the training reward drifted 0.54–0.59 across arms with no trend, and KL stayed near zero. The arms were never going to separate on the metric at this budget. The honest reading is that this run measures the knob’s mechanism well and its outcome not at all, and it says which is which.What did not work
pip install whileaiinside the pinned trainer image gives whileai 0.53, 57 releases old, wherewai.verifydoes not exist. Diagnosed and filed as #661.sweep_modal.pypinswhileai==0.110for this reason, and you should pin it too.wai.eval_varianceandwai.holdout_sizeareAttributeError. The docs list them besidepass_atandcompare; they are only atwhileai.simulations.*(#662).- Nothing in the SDK sweeps a training knob. The fan-out, the fixed-budget
bookkeeping, the per-container base pass and the arm-vs-arm comparison in
these two files are all hand-written
(#663). The measurement
half —
rows,pass_at,compare,holdout_size— is excellent and is why this recipe is short. - 34% of completions hit the 320-token cap and a truncated answer scores
zero. It is equal across arms so the comparison survives, but every pass
rate here is depressed by it. Raising
max_completion_lengthto 512 is the first thing to change.
Cost
Three L40S containers in parallel, one per arm: base eval (~4 min), 48 steps (16:24), trained eval (~4 min). About 25 minutes wall clock, ~75 GPU-minutes total, about $2.40 at Modal’s posted L40S rate. No model API key is used by this recipe at any point. Run ids:ap-S1wLfBnYlccNubBrzbNfw9 (the sweep),
ap-FPEked5LpA3b0nOuH1mS7h (the 3-minute smoke). Both stopped, 0 containers.
Next
- More steps, not more arms. The mechanism table says G=8 gets 552
gradient-carrying rollouts per 768 against G=2’s 236. If that matters it
should show up over 300–500 steps, not 48. Same three arms, same holdout,
--steps 400: about $12 and it is the run this one earns. - Filter instead of enlarging. Add a fourth arm at G=2 with unanimous groups dropped and refilled, so it spends its 768 rollouts on prompts that carry advantage. If the 19× number means anything, that arm should beat plain G=8 at the same cost — and that is a much more useful claim than “bigger groups are better”.
- Raise
max_completion_lengthto 512. 30–35% of completions hit the 320-token cap and a truncated answer scores zero, which puts a floor of pure length artifact under every reward here. - Pick a task with borderline prompts. The whole effect above is driven
by GSM8K being saturated at this model size.
select(mode="rl", band=(0.2, 0.8))on the base model’s own rollouts would say, before any training, how many prompts are even eligible to teach anything — that check costs one eval pass and should precede a sweep like this.