perf(training): group optimizer parameters by their hyperparameters - #1409
Open
JESUSROYETH wants to merge 1 commit into
Open
perf(training): group optimizer parameters by their hyperparameters#1409JESUSROYETH wants to merge 1 commit into
JESUSROYETH wants to merge 1 commit into
Conversation
JESUSROYETH
requested review from
Borda,
SkalskiP,
isaacrob and
probicheaux
as code owners
August 30, 2026 00:55
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1409 +/- ##
=======================================
Coverage 86% 86%
=======================================
Files 114 114
Lines 14880 14932 +52
=======================================
+ Hits 12835 12887 +52
Misses 2045 2045 🚀 New features to boost your workflow:
|
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.
What
get_param_dictbuilt one optimizer parameter group per trainable tensor.torch.optim's foreach and fused AdamW kernels batch a group's parameters into a single multi-tensor launch, so one group per parameter kills that batching completely. Every step ran one single-tensor kernel launch per parameter, plus one Python iteration of the optimizer's per-group loop. Onrfdetr-nanothat is 465 groups covering 28 distinct(lr, weight_decay)combinations.The fix groups parameters by the hyperparameters they already had, so each keeps exactly the learning rate and weight decay it had before, and
AdamW.step()issues one launch per distinct configuration instead of one per tensor.rfdetr-nanorfdetr-smallrfdetr-mediumMeasurements
One L4 (
g2-standard-8, torch 2.9.1+cu129),develop@6674d858against this branch, arms interleaved run by run.AdamW.step()in isolation, real model parameters and gradients resident, 51 interleaved repetitions each,torch.cuda.synchronize()on both sides of every step:rfdetr-nanoTruerfdetr-nanoFalserfdetr-smallTruerfdetr-smallFalseFull training step through
RFDETR.train()on a 512-image COCO subset, 100 timed steps after 20 warm-up steps, one process per arm:rfdetr-nanorfdetr-smallrfdetr-smallrfdetr-smallThe saving is a fixed cost per optimizer step, not a fraction of it. That's why the percentage falls as the batch grows, while the paired absolute deltas above (before − after, per row) stay in a 48.5–65.3 ms range. That matches the 50.1 ms (nano) and 52.4 ms (small) the isolated measurement predicts. At the default
batch_size=4it is the larger number. Atbatch_size=16it is still a real 9–10%. Interquartile ranges do not overlap atbatch_size4 or 8 (rfdetr-nano, batch 4, round 1: before p25–p75 219.9–229.8 ms, after 168.4–176.4 ms). Atbatch_size=16they do overlap, and only the medians separate the two arms.Equivalence
AdamW works elementwise per parameter, and grouping only decides which hyperparameters apply and how the kernels batch. I started from identical weights and ran 12 steps with identical pseudo-gradients on a real
rfdetr-nano: all 465 parameter tensors come out bit-identical between the two groupings, withfused=Trueand withfused=False.test_each_parameter_keeps_its_own_hyperparameterschecks the same property statically: it compares the(lr, weight_decay)each parameter ends up with, parameter by parameter, against the pre-merge assignment. That way backbone layer-wise LR decay and the zero weight decay on norms/biases get checked instead of just assumed.test_each_parameter_keeps_its_layerwise_learning_raterepeats that check against the optimizerconfigure_optimizersactually builds.The managed
LambdaLRpresets are unaffected: a singlelr_lambdascales every group from its owninitial_lr, and merged groups share thelrthey were constructed with.Resuming a run started before this change
Optimizer.load_state_dictrefuses a state dict whose parameter-group count differs from the optimizer's, so a checkpoint written with the old layout would otherwise fail to resume.on_load_checkpointnow regroups it.regroup_unmerged_optimizer_staterecognises the one-group-per-parameter layout, reindexes the per-parameter state onto the merged groups, and collapses the paired LR-scheduler lists that hold one entry per parameter group (base_lrs,_last_lr,lr_lambdas,min_lrs). The merged layout comes straight from the saved groups, so it needs no model traversal, and state already saved in the current layout just passes through untouched.A scheduler wrapped in a linear warmup ramp (
SequentialLR, built by_wrap_with_warmupwheneverwarmup_epochs > 0with a non-managed scheduler) nests each wrapped scheduler's own state inside a_schedulerslist, instead of holding its per-group lists at the top level. The collapse recurses into that nesting too. Left alone, the wrapped scheduler's own stale per-group list raises once it becomes active, because itsget_lr()zips that list against the now-shorteroptimizer.param_groups. I reproduced this directly againsttorch.optim.lr_scheduler.SequentialLRbefore adding the recursive collapse.An explicit
lr_schedulerconstructed fromlr_scheduler_kwargs(rather than the managed"step"/"cosine"presets) that passes a value per parameter group — thinkLambdaLR's orMultiplicativeLR's list-valuedlr_lambda— needs that list sized to the new group count.torch.optimraisesValueErrorat scheduler construction if the length no longer matches.Memory
Peak allocated device memory over the timed steps is unchanged: 6388.59 MiB in both arms at
batch_size=8, and 12150.94 MiB in both arms atbatch_size=16(one baseline run at batch 16 peaked at 12041.06 MiB and the other at 12150.94 MiB, so that spread at batch 16 comes from the baseline itself, not from this change).I measured that directly rather than inferring it. The transient device memory of one
AdamW.step(), over 11 repetitions per arm, is 0.000 MiB withfused=Truein both groupings and 7.617 → 7.677 MiB withfused=False— a 0.060 MiB difference, with min = median = max in every arm on bothrfdetr-nanoandrfdetr-small, so there is no run-to-run spread to argue about. A merged group's foreach temporary spans the whole group, so the ceiling does rise from the largest single tensor (0.998M parameters, 3.8 MiB fp32) to the largest merged group (5.50M, 21.0 MiB fp32), but the measured cost at that ceiling is those 0.060 MiB. The auto-batch probe stays a valid upper bound either way — it already models the optimizer with one group covering every parameter.Tests
tests/training/test_param_groups.pyadds 17 tests (22 collected items counting their helper doctests). They cover parameter coverage and non-duplication, group count against the distinct hyperparameter combinations, per-parameter hyperparameter preservation, and frozen-parameter exclusion, plus the optimizerconfigure_optimizersactually builds. The checkpoint regrouping gets its own coverage too: loadability, state staying with its own parameter, scheduler-list collapse and values, aSequentialLR-wrapped scheduler's nested state and its activation past the warmup milestone, and already-merged or unrecognised state left untouched. Revertingget_param_dictto the pre-merge behaviour fails 12 of them.tests/training/test_detr_shim.py::TestOnLoadCheckpointadds one more test that drives the realon_load_checkpointhook end to end, notregroup_unmerged_optimizer_statedirectly. That way a wiring mistake in the hook itself (wrong argument, wrong order relative to the other normalisation steps) gets caught too. It fails if the hook's call toregroup_unmerged_optimizer_stateis removed.The CPU suite (
pytest src/ tests/ -n 1 -m "not gpu" --ignore=tests/run_smoke_all_models.py --ignore=tests/legacy/test_checkpoint_compat.py --ignore=tests/benchmarks --timeout=240) gives 4402 passed, 101 skipped, and one failure: the_export_tiny_modeldoctest intests/export/test_onnx_notes.py. I checked the same doctest against unmodifieddevelop@6674d858and got the same failure there too, so it's not from this change.tests/benchmarks/is excluded here because its COCO download-and-infer cases go over the 240 s per-test limit on this machine, unrelated to this change.configure_optimizersno longer re-filters the groups onrequires_grad: every source insideget_param_dictalready filters.test_frozen_parameters_are_excludedandtest_optimizer_covers_every_trainable_parameter_oncecheck that what reaches the optimizer stays the same.Not measured
Multi-GPU/DDP, MPS, XLA/TPU, and non-AdamW optimizers were not exercised. The change itself is optimizer-agnostic (it only changes the parameter groups handed to whatever optimizer is configured), but the numbers above are single-GPU CUDA AdamW only. mAP is not reported because the optimizer steps are bit-identical, not just approximately equal.