Config-driven model knobs: decoupled attention head dim, adapter pre-norm, weights-only warm-start - #198
Open
amazloumi wants to merge 3 commits into
Open
Config-driven model knobs: decoupled attention head dim, adapter pre-norm, weights-only warm-start#198amazloumi wants to merge 3 commits into
amazloumi wants to merge 3 commits into
Conversation
…-start exclude head_dim becomes an override (head_dim_override) resolved through the existing head_dim property, so n_heads * head_dim need not equal dim. Keeping the resolved value derived rather than stored matters: load_config overlays TOML onto a default config and carries unspecified fields forward, so a stored head_dim would go stale whenever dim or n_heads comes from TOML. Cross-attention inherits the model head dim only when the override is set; under the default coupling it keeps its own dim // n_heads so a custom cross_attention_n_heads is unaffected. adapter.pre_norm adds a registry-selected norm over the vision features before mlp_2layer's projection, in place of a model-specific adapter type. checkpoint.exclude_from_loading is now honored on a load_path warm start and ignored on a resume, which always restores full state.
Codecov Report✅ All modified and coverable lines are covered by tests.
🚀 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.
Summary
model.head_dim_override(default0→dim // n_heads) decouples the attention head width from the residual width, son_heads * head_dimneed not equaldim.dim % n_heads == 0is now required only when the head width is inferred; a negative override is rejected at config time.head_dimstays a derived property rather than a resolved field.load_configoverlays TOML and CLI values onto an already-constructed default config and carries unspecified fields forward, so a stored value would go stale as soon asdimorn_headscame from TOML.CrossAttentionBlockforwardshead_dimtoCrossAttention, but only when the override is set. Cross-attention has its own head count (vlm.cross_attention_n_heads), so under the default coupling it keeps its existingdim // n_headswidth.adapter.pre_normapplies a norm-registry-selected norm to the vision features beforemlp_2layer's first projection, exposed asln_q;""(default) builds no module. This is what keeps a model-specific adapter type out of core.checkpoint.exclude_from_loadingis now honored when warm-starting fromcheckpoint.load_pathand ignored on a resume, which always restores full state. It was previously declared but never read, and its docs described FQN prefixes rather than the DCP state keysCheckpointManager.loadaccepts.examples/-realignment work inkempnerforge/model/adapter.py,kempnerforge/config/adapter.pyandtests/unit/test_adapter.py(raggedattentional_pool), plusCHANGELOG.md; whichever lands second will conflict in those files.Testing
uv run ruff check kempnerforge/ tests/ scripts/passesuv run ruff format --check kempnerforge/ tests/ scripts/passes — 166 filesuv run pyright kempnerforge/passes (0 errors)uv run pytest tests/unit/ -v --timeout=120— 1785 passed, 3 skipped (+58 new)uv run pytest examples/vlm/tests -q— 33 passed;uv run pytest examples/vlm/eval/tests/unit -q— 104 passeduv run pytest tests/integration -q(4 GPUs) — 77 passed4-GPU
tests/distributed/in full — 99 passed, 2 skipped, on each of 4 ranks (test_checkpoint.py+test_resilience.pyalone: 13 passed per rank)Defaults unchanged:
dim=256, n_heads=4, n_kv_heads=2, 4 GPUs, seed 42, pre-tokenized data, 40 steps. Two runs of cleanorigin/main(separate checkout, own venv) are bit-identical to each other, and this branch is bit-identical to both at all 40 logged steps — step 110.4375, step 409.375. Losses read at full precision from TensorBoard event files, not the 4-dp console line. The comparator refuses instead of reporting equality when a run is missing, empty, truncated, step-shifted or non-finite, or when the control pair itself disagrees; all 9 refusal/power cases self-tested in the same job.Decoupled
head_dim, 4-GPU FSDP2:head_dim_override=96withn_heads=4, dim=256(4 * 96 = 384 != 256), 19,925,248 params — loss10.4375 → 10.0625over 25 steps,grad_norm 0.961 → 1.938.Decoupled
head_dim, 2 nodes × 4 GPUs viascripts/slurm/multinode.sh, FSDP2 overdp_shard=8— loss10.4375 → 9.5625over 30 steps,grad_norm 0.664 → 1.617, 1.03M tok/s, no wedge or rank divergence. A second 2-node run undertp=2 × dp_shard=4also completes 30 steps cleanly.TP is unaffected by this change. Clean
origin/mainwith a coupledhead_dimis already flat undertp=2, at both model sizes, so the flat loss is pre-existing and not this PR's. 4 GPUs, 25 steps, seed 42:origin/maindp_shard=4dp_shard=4origin/main(×2 runs)tp=2 × dp_shard=2tp=2 × dp_shard=2tp=2 × dp_shard=2origin/maindp_shard=4dp_shard=4origin/maintp=2 × dp_shard=2tp=2 × dp_shard=2Under
tp=2with the coupled config, branch andorigin/mainare bit-identical across all 25 steps (comparator PASS, same-code control pair bit-identical). The decoupled head dim trains normally under FSDP2 at both sizes.TP shards from
n_heads * head_dim, notdim:apply_tensor_parallelbuilds a plan of bareColwiseParallel()/RowwiseParallel()and contains no head-width arithmetic, so widths come from eachnn.Linear's ownout_features. Verified under gloo withdim=256, n_heads=4, n_kv_heads=2, head_dim=96, tp=2: globalq_proj (384, 256)→ per-rank(192, 256),k_proj (192, 256)→(96, 256),o_proj (256, 384)→(256, 192). Adim // tpshard would be(128, 256). Forward+backward gives finite non-zero grads.SIGTERM preemption drill, twice. (a) Cooperative path,
dim=1024/12 layers, 4 GPUs, SIGTERM to the worker ranks at step 300 of 900:Shutdown requested at step 300 — saving emergency checkpoint, agent exits 0,latest -> step_300,step=300,batches_yielded=300; resume logsResumed from step 300, 2,457,600 tokens seen(= 300 × 4 × 4 × 512, exact) andResumed DataLoader: epoch=0, skip_batches=300, then runs to 900. Two identical runs of this config are not bit-reproducible (same-code control pair max|d|3.125e-02), so the comparator refused an exact verdict; against that noise floor the resumed 900-step trajectory differs by max|d|3.125e-02— ratio 1.000, i.e. no more than two identical runs differ from each other. (b) Hard-kill path,dim=256, SIGTERM to the agent and all 4 workers at step 31 of 40:step_31was left with zero shards and auto-resume correctly fell back to the newest complete checkpointstep_30(skip_batches=30,245,760tokens, exact); that config is bit-reproducible, and the resumed 40-step trajectory is bit-identical to an uninterrupted run.Head-geometry consumer sweep: every consumer already reads
config.head_dim, so all follow the override —Attention(q/k/v/o widths,q_norm/k_norm, SDPA scale),KVCache,MoTAttention,CrossAttention, MoMa'sAttention, RoPE precompute inTransformerandpipeline_parallel,generate's cache sizing, the dense and MoE FLOP models,num_params_estimate. No site assumesn_heads * head_dim == dim: every output reshape uses-1ando_projmaps back todim. Tested per-arch — with the override set, everyAttention/MoTAttention/CrossAttentionmodule in ajoint_decoder,cross_attention,motandmomabuild reports the overridden width.Cross-attention regression guard: a coupled model (
dim=64, n_heads=8) withcross_attention_n_heads=2keepshead_dim=32andq_proj (64, 64), verified equal to cleanorigin/main. Forwarding the model'sdim // n_headsunconditionally would make ithead_dim=8,q_proj (16, 64).TOML overlay guard: a TOML setting
dim=256, n_heads=4with no override resolveshead_dim=64on both this branch and cleanorigin/main. Ahead_dimresolved into a field in__post_init__reads128here, carried from the4096/32default — silently wrong forconfigs/train/debug.toml,debug_moe.tomlandhf_wikitext.toml.Validation cases: negative override, indivisible
dimwith no override (and accepted with one), unknownadapter.pre_norm— all rejected at config time, before distributed init, with the options named.Warm-start/resume asymmetry, both directions:
exclude_from_loading=["optimizer"]reachesload(exclude_keys=...)on aload_pathwarm start, and is passed asNoneon a resume — including when a staleload_pathis also set. An empty list passesNone.pre_normdefault path: noln_qmodule, noln_qstate-dict key, and the projections' RNG draw identical to a build without the argument. With it set,forwardequalsproj2(act(proj1(ln_q(x))))exactly, andreset_parametersrestoresweight=1/bias=0after a meta-deviceto_empty.uv run pytest tests/smoke/ --smoke(4 GPUs) — 16 passed, 3 skipped, no failures; identical counts on cleanorigin/main. Coversdense/moe×fsdp/tp2_fsdp, plus compile, FP8, grad-accum, full AC, sigmoid router,pp2and inline eval.Re-run with a pre-tokenized
--data-pathto unskip the last three: bothTestAutoResumetests pass on this branch — RNG restore,StatefulDataLoaderreplay with monotonicbatches_yielded,train_state.ptownership gate, scheduler continuity, i.e. the pathrestore_checkpointsits on.Open, under investigation: in that same
--data-pathrun,TestRealConfigs::test_fp8_7b_configfailed on this branch (18 passed, 1 failed) and passed on cleanorigin/main(19 passed), same 4 GPUs, same job. The config resolves byte-identically on both trees (head_dim=128,ffn=14336, 8,030,261,248 params) and the run is text-only with no resume, so none of the four knobs is on its path — but that is an argument, not evidence. Re-running the test alone and interleaved across both trees to separate a real regression from suite-position / GPU-state effects. Not claiming this one is clean until that says so.uv run pytest tests/e2e --e2e(4 GPUs) — 5 failed / 26 passed on this branch vs 4 failed / 27 passed on cleanorigin/mainin the same session. The 4 shared failures (test_checkpoint_save_and_resume,test_moe_checkpoint_resume,test_pp_checkpoint_save_and_resume,test_sigterm_triggers_emergency_checkpoint) are the ones Several tests/e2e/ training tests fail on main (stale checkpoint assertions + PP/resume subprocess failures) #133 already records as pre-existing onmain. The one-test delta istest_hf_dataset_single_gpu, which Several tests/e2e/ training tests fail on main (stale checkpoint assertions + PP/resume subprocess failures) #133 records as network-flaky; re-run alone on the branch it passes 3/3, so the branch andmainfail the same 4 pre-existing tests.Re-checked against the design plan's core-admission test at completion: three general model knobs entering by general config fields (
pre_normalso through the norm registry) plus one correctness fix to an inert config field, defaults unchanged, useful to any decoupled-head-geometry model rather than one experiment.If training loop / parallelism / optimizers changed: parallelism application order (TP → EP → FP8 → AC → FSDP2) is untouched —
kempnerforge/distributed/has no diff.Closes #197