Repository navigation
[AutoTP] Support tied embeddings for row-parallel output-head training - #8680
Conversation
…ut-head training The row-parallel output-head tie check scanned the module tree during the replacement walk. When a tied embedding matched a partition_config spec, it was sliced into a new Parameter before lm_head was visited, so the check no longer saw the tie and the head was sharded while the embedding kept a separate copy. Snapshot weight ties from the original model in __init__ and consult that snapshot instead. Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
bf0a8b5 to
f96d8bb
Compare
Row-parallel output-head training rejected a weight tied to the input embedding. Shard every tied nn.Embedding along the same hidden dimension with a new HiddenParallelEmbedding that reuses the head's weight shard and all-gathers each token's slices into the replicated activation. Tied embeddings are skipped by _slice_embedding so traversal order cannot break the tie, and all tie partners are validated before the shared weight is partitioned. Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
| name=embed_full_name, | ||
| tp_meta=self.tp_meta) | ||
| # Guards re-entry when the replacement walk later reaches this module. | ||
| setattr(hidden_parallel_embedding, "replaced", True) |
There was a problem hiding this comment.
Every test that exercises the tied row-parallel output head registers embed_tokens before lm_head, and in those fixtures the guard setattr(hidden_parallel_embedding, "replaced", True) is never consulted — the embedding is protected by _tied_row_parallel_embedding_ids instead. (A tie alias reached later in the walk does consult it, but no such fixture uses a row-parallel head.)
When the head is visited first the guard becomes load-bearing: _replace_with_config then dispatches the freshly installed HiddenParallelEmbedding (2-dim weight, not an nn.Embedding) into _create_row_parallel_layer and wraps it a second time, and the replaced flag is the only check that stops it — without it embed_tokens ends up as a LinearAllreduce.
A head-first OutputModel case would cover it.
There was a problem hiding this comment.
Good catch — added a head-first parametrization in 12ae314. It fails without the replaced guard (embed_tokens becomes LinearAllreduce) and passes with it.
Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
|
Thanks for your help with the merge, @delock. I checked the latest run and found two failing cases, which I’ve reproduced. I’m investigating whether they’re due to a bug or rounding differences. |
My suspicion is that this is a precision-related issue. I was able to reproduce the failure with TF32 enabled, but it did not occur when I forced FP32. I’m not sure what the exact CI environment is, though. I suggest we first address the timeout in @delock Thanks |
Looks like modal is hitting 5400 sec (1.5 hour limit). Let's watch another nightly run to see what is the actual finish time. It is 1h08m on Oct-4. |
## Problem Since deepspeedai#8680 landed, `modal-torch-latest / DeepSpeedAI CI` has failed for every merge-queue entry that runs this test on Modal's L40S runners (deepspeedai#8794, deepspeedai#8701, deepspeedai#8670). Example: [run 38009529440](https://github.com/deepspeedai/DeepSpeed/actions/runs/38009529440/job/114086150257). ``` FAILED tests/unit/v1/autotp/test_autotp_training.py::TestTiedRowParallelOutputHeadTraining::test_training_matches_unsharded_reference[35-3-False-0] FAILED tests/unit/v1/autotp/test_autotp_training.py::TestTiedRowParallelOutputHeadTraining::test_training_matches_unsharded_reference[35-3-True-0] Mismatched elements: 1 / 670 (0.1%) Greatest absolute difference: 1.1173542588949203e-06 at index (1, 1, 45) (up to 1e-06 allowed) ``` deepspeedai#8680's own merge-queue run and the next nightly run both used the AWS fallback, where the test passes, so the failure wasn't caught there. ## Root cause The row-parallel head (`LinearAllreduceWithReplicatedInput`) slices the hidden vector, computes a partial matmul on each rank, and all-reduces the partial logits. The reference is one unsharded fp32 matmul, and the two summation orders round differently. The tied head uses the embedding's N(0,1) weights, so logits reach about 14, where one fp32 ulp is about 1e-6. Logits that are near zero through cancellation can therefore differ by more than `atol=1e-6`. Even the fp32 reference differs from a float64 ground truth by up to 2.2e-6. ## Fix Use torch.testing's default fp32 tolerance (`atol=1e-5`, `rtol=1.3e-6`) for every fp32 `assert_close` in `test_autotp_training.py`. That covers 17 checks, including both row-parallel logits checks, and matches the 19 fp32 checks in the file that already use the default. The half-precision tolerances in `run_tp_layer_fwd_bwd` are unchanged. The default is enough for what these tests check: - The sharding bugs they target (wrong shard offset, missing all-reduce, dropped tied-gradient contribution, tied weights drifting apart) move values by 0.1 to 10. - A simulated precision regression (all-reduce inputs rounded to bf16) still fails every check it failed under the old tolerances. The one exception is the SGD-update check, which can't detect it under either tolerance. - The custom values were tighter than the default only for values below about 1, and looser for larger ones such as the losses. ## Evidence (2x H200) - In a sweep of 40 init seeds, every parametrization of the tied test has seeds that fail the old logits check (worst ratio to the allowed tolerance: 1.19). Under the default, the worst case uses under 30% of the limit. - Changing only the seed to 8199 reproduces the CI failure: `[35-3-False-0]` and `[35-3-True-0]` fail with `1 / 670` mismatched and `1.25e-06 (up to 1e-06 allowed)`. - Every other changed check stays under 3% of the default limit, recorded per call across the 4 affected test classes. ## Testing Hardware: 2x NVIDIA H200, torch 2.12.0+cu130, Python 3.12. - `pytest tests/unit/v1/autotp/test_autotp_training.py -k "TestRowParallelOutputHeadTraining or TestTiedRowParallelOutputHeadTraining"`: 8 passed. - `pytest tests/unit/v1/autotp/test_autotp_training.py -m sequential -k "TestTiedEmbeddingAliasesAndFallback or TestTiedVocabParallelLMHead"`: 30 passed. - Seed-8199 reproduction, which fails before this change: passes with the default logits tolerance. - `pre-commit run --files tests/unit/v1/autotp/test_autotp_training.py`: passed. No L40S is available locally. This PR's merge-queue run selects this test file, so it will check the fix on Modal's L40S runners unless the run falls back to AWS. --------- Signed-off-by: Hongwei Chen <hongweichen@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-up to #8538 (part of #8173).
Summary
Row-parallel output-head training (#8538) rejected an
lm_head/embed_outwhose weight is tied tothe input embedding, and that rejection could be bypassed silently (see Bug below). This PR fixes
the bug and supports the tied case by sharding the tied
nn.Embeddingalong the same hiddendimension as the row-parallel head, so both share one physical per-rank shard.
Bug: tie silently broken when the embedding is visited first
The tie was detected by scanning
self.module.modules()during the replacement walk. The walkvisits
embed_tokensbeforelm_head, so whenpartition_configalso matches the embedding,_slice_embeddingswaps it for a new module with a new Parameter first. By the timelm_headwaschecked, no other module aliased its weight, the check passed, and the head was sharded while the
embedding kept a separate copy — with no error.
Fixed by snapshotting weight ties from the original, pre-replacement model in
AutoTP.__init__,following the approach the vocab-parallel path already takes for the same ordering issue
(introduced in #8309, now
_tied_vocab_parallel_embedding_ids).Changes
HiddenParallelEmbedding(layers.py): looks up each token's hidden-dimension slice andall-gathers the slices into the replicated activation (
GatherFromTensorParallelRegion, souneven hidden shards work). When tied, it reuses the row-parallel head's already-sharded weight
Parameterand partition sizes; its universal-checkpoint metadata matchesLinearAllreduce's.padding_idx,scale_grad_by_freq, and Gemma3's scaled embedding are preserved;max_norm,sparse, and other custom forwards are rejected.AutoTP(auto_tp.py):tied to a row-parallel training head so
_slice_embeddingleaves them alone regardless oftraversal order.
_create_row_parallel_layervalidates all tie partners before partitioning the sharedweight, then replaces every tied embedding alias with a
HiddenParallelEmbedding. A tie to anon-
nn.Embeddingmodule still raises; a conflicting explicit embedding spec is supersededwith a warning.
autotp-training.mddescribes the tied path and notes thatvocab_parallel_lm_headisusually preferable for tied models, since a row-parallel head all-reduces full vocabulary logits.
Not changed: untied embeddings that match a spec still go through
_slice_embedding, whoseun-gathered hidden slice is intentional for per-head embeddings such as T5's
relative_attention_bias.Tests
test_tp_partition_config_path.py): tie is shared with/without an embedding specvisited first (the embedding-spec case reproduces the bug above on master); rejection for
non-embedding ties and
max_normleaves the model untouched; Gemma3 scaled lookup matches theoriginal module.
TestTiedRowParallelOutputHeadTraining, 2 GPUs): three optimizer steps against anunsharded reference comparing logits, the full tied gradient (lookup + projection
contributions), and updated weights, covering even/uneven hidden (32/35),
padding_idx,embedding-spec ordering, and ZeRO-0/2; universal-checkpoint affine map rebuilds the tied weight
from uneven shards. Mutation check: detaching the embedding's weight in the lookup fails all
four training cases.
tests/unit/module_inject/andtests/unit/v1/autotp/: 225 passed, 12 skipped on3x NVIDIA GeForce RTX 5090 D.