Skip to content

[AutoTP] Support tied embeddings for row-parallel output-head training - #8680

Merged
delock merged 4 commits into
deepspeedai:masterfrom
jinyouzhi:fix/autotp-row-head-tie-snapshot
Oct 9, 2026
Merged

delock merged 4 commits into
deepspeedai:masterfrom
jinyouzhi:fix/autotp-row-head-tie-snapshot

Conversation

@jinyouzhi

@jinyouzhi jinyouzhi commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #8538 (part of #8173).

Summary

Row-parallel output-head training (#8538) rejected an lm_head/embed_out whose weight is tied to
the 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.Embedding along the same hidden
dimension 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 walk
visits embed_tokens before lm_head, so when partition_config also matches the embedding,
_slice_embedding swaps it for a new module with a new Parameter first. By the time lm_head was
checked, no other module aliased its weight, the check passed, and the head was sharded while the
embedding kept a separate copy — with no error.

AutoTPConfig(layer_specs=[
    TPLayerSpec(patterns=[r".*embed_tokens\.weight$"], partition_type=PartitionType.ROW),
    TPLayerSpec(patterns=[r".*lm_head\.weight$"], partition_type=PartitionType.ROW),
])
# before: no error, model.lm_head.weight is model.embed_tokens.weight -> False
# after:  both modules share one hidden-dimension shard

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 and
    all-gathers the slices into the replicated activation (GatherFromTensorParallelRegion, so
    uneven hidden shards work). When tied, it reuses the row-parallel head's already-sharded weight
    Parameter and partition sizes; its universal-checkpoint metadata matches LinearAllreduce'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):
    • Snapshots each module's weight-tie partners from the original model, and records embeddings
      tied to a row-parallel training head so _slice_embedding leaves them alone regardless of
      traversal order.
    • _create_row_parallel_layer validates all tie partners before partitioning the shared
      weight, then replaces every tied embedding alias with a HiddenParallelEmbedding. A tie to a
      non-nn.Embedding module still raises; a conflicting explicit embedding spec is superseded
      with a warning.
  • Docs: autotp-training.md describes the tied path and notes that vocab_parallel_lm_head is
    usually 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, whose
un-gathered hidden slice is intentional for per-head embeddings such as T5's
relative_attention_bias.

Tests

  • Unit (test_tp_partition_config_path.py): tie is shared with/without an embedding spec
    visited first (the embedding-spec case reproduces the bug above on master); rejection for
    non-embedding ties and max_norm leaves the model untouched; Gemma3 scaled lookup matches the
    original module.
  • Integration (TestTiedRowParallelOutputHeadTraining, 2 GPUs): three optimizer steps against an
    unsharded 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/ and tests/unit/v1/autotp/: 225 passed, 12 skipped on
    3x NVIDIA GeForce RTX 5090 D.

…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>
@jinyouzhi
jinyouzhi force-pushed the fix/autotp-row-head-tie-snapshot branch from bf0a8b5 to f96d8bb Compare September 27, 2026 10:39
@jinyouzhi jinyouzhi changed the title [AutoTP] Detect tied weights before replacement for row-parallel output-head training [AutoTP] Support tied embeddings for row-parallel output-head training Sep 27, 2026
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>
@delock
delock self-requested a review September 28, 2026 14:40
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added a head-first parametrization in 12ae314. It fails without the replaced guard (embed_tokens becomes LinearAllreduce) and passes with it.

@delock
delock enabled auto-merge October 4, 2026 06:51
@delock
delock added this pull request to the merge queue Oct 4, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 4, 2026
@delock
delock added this pull request to the merge queue Oct 4, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 4, 2026
@jinyouzhi

Copy link
Copy Markdown
Contributor Author

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.

@jinyouzhi

Copy link
Copy Markdown
Contributor Author

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 modal-torch-latest, then use the resulting failure report to decide on the next steps.

@delock Thanks

@delock

delock commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

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 modal-torch-latest, then use the resulting failure report to decide on the next steps.

@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.

@delock
delock added this pull request to the merge queue Oct 6, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 6, 2026
@delock
delock added this pull request to the merge queue Oct 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 9, 2026
@delock
delock added this pull request to the merge queue Oct 9, 2026
Merged via the queue into deepspeedai:master with commit 3c6a5ca Oct 9, 2026
13 checks passed
banxingmjj pushed a commit to openanolis/DeepSpeed that referenced this pull request Oct 10, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants