Skip to content

Always tie embeddings for LongT5 and Pop2Piano - #47620

Merged
zucchini-nlp merged 10 commits into
huggingface:mainfrom
jiqing-feng:fix_structural_tying
Aug 20, 2026
Merged

zucchini-nlp merged 10 commits into
huggingface:mainfrom
jiqing-feng:fix_structural_tying

Conversation

@jiqing-feng

@jiqing-feng jiqing-feng commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

CI

Problem

LongT5 and Pop2Piano load with randomly initialized embeddings from their official checkpoints.

Loading google/long-t5-local-base:

encoder.embed_tokens.weight | MISSING
decoder.embed_tokens.weight | MISSING
lm_head.weight              | MISSING
shared std 10.09 vs embed std 1.00

Generation is garbage: Stancld/longt5-tglobal-large-16384-pubmed-3k_steps produces
'muralvoenseenseensevo(0)(0)(0)...'.

Cause

tie_word_embeddings=False historically meant "don't scale the decoder outputs" (T5 1.1), not
"don't share the embeddings". The checkpoints store only shared.weight, so the flag must not be
allowed to disable tying. Every official checkpoint sets it to False:
google/long-t5-local-base, google/long-t5-tglobal-base,
Stancld/longt5-tglobal-large-16384-pubmed-3k_steps, sweetcocoa/pop2piano.

#42815 handled exactly this for t5, mt5, umt5, udop and vilt — force the flag to True and
move the scaling to a dedicated scale_decoder_outputs attribute. Two models did not get the guard:

  • Pop2Piano — a regression: Single config attribute for weight tying #42815 removed self.tie_encoder_decoder = True # forcing it
    from the config without adding a replacement.
  • LongT5 — a long-standing gap: its config has never handled tying at all.

It became load-breaking once get_expanded_tied_weights_keys started returning {} for the whole
_tied_weights_keys mapping when the flag is False.

Fix

Apply the same two lines the other T5-family configs already have:

self.scale_decoder_outputs = self.tie_word_embeddings
self.tie_word_embeddings = True

and read the new attribute where the scaling happens:

- if self.config.tie_word_embeddings:
+ if self.config.scale_decoder_outputs:
      sequence_output = sequence_output * (self.model_dim**-0.5)

Plus a test_tie_word_embeddings for each model, matching the one mt5 already has.

Net diff: 6 files, +73/-4. modeling_utils.py is untouched.

Notes

  • Fixing this in the Hub configs instead would break older transformers, which still reads
    tie_word_embeddings to decide whether to scale — 1.1 checkpoints would silently get the T5 1.0
    scaling.
  • Pop2Piano's breakage is latent today: the Hub ships a .bin and pickle preserves shared storage,
    but a safetensors round-trip loses it.
  • Forcing the tie does not wrongly tie lm_head. On google/flan-t5-small, which stores it
    separately, tie_weights sees both keys present with different values, warns, and leaves them
    untied.
  • Config round-trip is stable: a 1.1-style config reloads as tie=True, scale=False.

Tests

tests/models/{longt5,pop2piano,t5} — 738 passed, 889 skipped, 5668 subtests passed.

Remaining wart (out of scope)

One bool still gates the entire _tied_weights_keys dict, and in the T5 family its meaning is
overloaded. That is why #42815 missed two models. Renaming tie_word_embeddings → tie_weights,
as suggested in review, would be a good follow-up.

`tie_word_embeddings` only controls whether the output embeddings are tied to
the input embeddings, but `get_expanded_tied_weights_keys` was returning an
empty mapping whenever it was False, dropping the structural entries as well.

For encoder-decoder models, `encoder.embed_tokens` and `decoder.embed_tokens`
are aliases of `shared` and are absent from the checkpoints, so they ended up
randomly initialized (e.g. longt5, which has `tie_word_embeddings=False`).

Only drop the entries pointing at `get_output_embeddings()` instead.
`tie_word_embeddings` only controls whether the output embeddings are tied to
the input embeddings, but `get_expanded_tied_weights_keys` was returning an
empty mapping whenever it was False, dropping the structural entries as well.

For encoder-decoder models, `encoder.embed_tokens` and `decoder.embed_tokens`
are aliases of `shared` and are absent from the checkpoints, so they ended up
randomly initialized (e.g. longt5, which has `tie_word_embeddings=False`).

Keep the entries tying an input embedding to another input embedding, and only
drop the ones targeting the output head.
@jiqing-feng
jiqing-feng force-pushed the fix_structural_tying branch from bd66db0 to 84193d1 Compare July 29, 2026 06:15
`tie_word_embeddings=False` only unties the output head; the encoder/decoder
embedding aliases are structural and stay tied. These two counts were 2 from
huggingface#26292/huggingface#26422 until huggingface#41580 bumped them to 3/4 to match the all-or-nothing gate.

@zucchini-nlp zucchini-nlp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not 100% sure since I didn't check all official LongT5 ckpt, prob it is same as other T5 models that tie weights always? We had issue with T6 in teh past and hardcoded the value like this

self.tie_word_embeddings = True

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ran the filter's predicate against the real _tied_weights_keys on 2ef79f8 and it picks exactly the intended split, but T5 shows a case the FSMT test doesn't cover.

T5ForConditionalGeneration with tie_word_embeddings=False, applying the new predicate by hand:

entry parent modules kept
lm_head.weight → shared.weight (Linear, Embedding) no
encoder.embed_tokens.weight → shared.weight (Embedding, Embedding) yes
decoder.embed_tokens.weight → shared.weight (Embedding, Embedding) yes

That's the behaviour the PR argues for: the nn.Linear head stays gated on the flag, the two input-embedding aliases survive. FSMT splits the same way — decoder.output_projection is nn.Linear at modeling_fsmt.py:534 so it drops, and the encoder.embed_tokens → decoder.embed_tokens pair is (Embedding, Embedding) so it stays.

Worth adding a T5 case next to the FSMT one, because T5 exercises something FSMT can't: two targets sharing one source, where the dropped entry (lm_head) and the kept entries all point at shared.weight. FSMT's mapping has the source as decoder.embed_tokens.weight for both, so it never tests a partially-filtered fan-out. A regression that dropped the whole mapping when any entry fails the predicate would pass the FSMT test and fail on T5.

Two things about the predicate itself:

modules.get(...) returns None for a name that isn't a module, and isinstance(None, nn.Embedding) is False, so a regex-pattern key — which _tied_weights_keys explicitly supports, that's what the expansion below exists for — gets silently dropped when tie_word_embeddings=False. d_fine is called out in the comments a few lines down as having "complicated regex patterns". Those patterns won't rsplit into a real module name, so if any model has a regex-form entry that is structurally an embedding alias, this filter removes it rather than expanding it. Might be worth resolving the regex first, or at least a comment saying regex-form entries are intentionally treated as head-tying.

self.named_modules(remove_duplicate=False) is built on every call, and get_expanded_tied_weights_keys is called per submodule from the all_submodels=True branch. Only on the tie_word_embeddings=False path, so it's not the common one — just noting it's inside a loop.

Measured with real model classes (T5ForConditionalGeneration, FSMTForConditionalGeneration) on tiny configs, transformers 5.13.1, applying the predicate to _tied_weights_keys as read from main @ 2ef79f8. I did not run the modified get_expanded_tied_weights_keys end to end — the installed 5.13.1 predates the if not tie_word_embeddings: return {} early return this PR is rewriting, so its output isn't comparable. The parent-module types and the mappings above are from the current source.

@jiqing-feng

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed check — the predicted behaviour matches what I measured. Added the T5 test
and a comment on the regex case in 4e3835a.

T5 test. Added test_ensure_weights_are_shared to T5ModelTest: 1 distinct storage with
tie_word_embeddings=True, 2 with False. It fails on the base commit with 4 != 2, so it does
guard the fix.

One correction on the motivation though: FSMT does exercise a partially-filtered fan-out. Its two
entries both point at decoder.embed_tokens.weight, so a regression that dropped the whole mapping
when any entry fails the predicate would give 3 there, not 2, and the FSMT test would catch it. T5 is
still worth adding for a different reason — its source (shared) is a third, standalone module
rather than one of the tied embeddings, and it's the example the PR comment names.

Regex-form keys. You're right that they don't resolve to a module and get dropped. I scanned all
74 model classes with regex-form entries in _tied_weights_keys; exactly one has
tie_word_embeddings=False:

Qwen3OmniMoeTalkerForConditionalGeneration
  _tied_weights_keys = {"codec_head": "model.codec_embedding.weight"}
  type(codec_head)   = nn.Linear
  expanded           = {}      # same as main

So there's no behaviour change today, and dropping it is semantically correct since codec_head is a
head. But it's only accidentally correct — "codec_head" has no dot, so rsplit(".", 1)[0] returns
the key itself rather than a parent module. Added a comment recording that regex/module-form entries
are intentionally treated as head-tying, and that no model currently pairs them with
tie_word_embeddings=False. Resolving the regex first would be dead code right now, so I left it.

named_modules in a loop. Agreed, but it only runs on the tie_word_embeddings=False path
during __init__/from_pretrained, never in the forward path, so I left it as is.

@ArthurZucker

Copy link
Copy Markdown
Collaborator

Hey! Sorry there's a bit too much to read.
The premise:

structural — {"encoder.embed_tokens.weight": "shared.weight", ...}

Is wrong. these are not structural.
If a model needs only these to be tied, you just set {"encoder.embed_tokens.weight": "shared.weight"} as the keys to be tied. That's the intended purpose of this flag. Its name does not reflect the usage it should be tie_weights.

Reverts the framework-level change: `_tied_weights_keys` is an all-or-nothing
mapping gated by `tie_word_embeddings`, so `get_expanded_tied_weights_keys`
should not split it into "structural" and "head" entries. `modeling_utils` and
the fsmt/mbart/t5 test expectations are back to their state on main.

The actual bug is per-model. LongT5 and Pop2Piano are derived from T5 and
inherit its `tie_word_embeddings` quirk: the flag historically encoded "do not
scale the decoder outputs" (T5 1.1), not "do not share the embeddings". Every
official checkpoint sets it to `False` (`google/long-t5-local-base`,
`google/long-t5-tglobal-base`,
`Stancld/longt5-tglobal-large-16384-pubmed-3k_steps`, `sweetcocoa/pop2piano`)
while only storing `shared.weight`, so the aliased embeddings end up randomly
initialized.

Apply the same fix T5/MT5/UMT5 already have: force `tie_word_embeddings=True`
and move the scaling to its own `scale_decoder_outputs` flag.

Before, on `google/long-t5-local-base`:

    encoder.embed_tokens.weight | MISSING
    decoder.embed_tokens.weight | MISSING
    lm_head.weight              | MISSING
    shared std 10.09 vs embed std 1.00

Pop2Piano is affected too, though only latently: the hub ships a `.bin`, which
preserves shared storage, but a safetensors round-trip stores `shared.weight`
alone and both `embed_tokens` come back randomly initialized.
@jiqing-feng

Copy link
Copy Markdown
Contributor Author

Thanks for the review @ArthurZucker — reverted the modeling_utils.py change, get_expanded_tied_weights_keys and the fsmt/mbart/t5 tests are back to main.

Digging further, the bug turned out to be a regression from #42815, not a framework issue. Details below.

The suggested fix doesn't apply

you just set {"encoder.embed_tokens.weight": "shared.weight"} as the keys

Pop2PianoForConditionalGeneration already has exactly that (no lm_head entry) and is still broken: tie_word_embeddings=False drops the whole dict, so trimming the keys can't help.

Root cause: #42815 missed two models

tie_word_embeddings=False historically meant "don't scale decoder outputs" (T5 1.1), not "don't share embeddings". #42815 handled this for t5/mt5/umt5/udop/vilt by forcing the flag True and moving the scaling to scale_decoder_outputs.

Two models were missed:

  • Pop2Piano — the PR removed its self.tie_encoder_decoder = True # forcing it without adding a replacement.
  • LongT5 — only its modeling_*.py was cleaned up; the config was never touched.

Impact

All official checkpoints set the flag to False while storing only shared.weight, so the aliases are randomly initialized. On google/long-t5-local-base:

encoder.embed_tokens.weight | MISSING
decoder.embed_tokens.weight | MISSING
lm_head.weight              | MISSING
shared std 10.09 vs embed std 1.00

Also affects google/long-t5-tglobal-base, Stancld/longt5-tglobal-large-16384-pubmed-3k_steps and sweetcocoa/pop2piano.

Pop2Piano is latent: the Hub ships a .bin and pickle preserves shared storage, but a safetensors round-trip breaks it.

The fix

Same two lines t5/mt5/umt5 already have, applied to both configs, plus a test_tie_word_embeddings each. Net diff vs main is 6 files, +73/-4; modeling_utils.py untouched.

Checks

  • tests/models/{longt5,pop2piano,t5} pass (738 passed, 5668 subtests).
  • Config round-trip is stable: a v1.1 config reloads as tie=True, scale=False.
  • Forcing the tie doesn't wrongly tie lm_head: on google/flan-t5-small (which stores it separately) tie_weights sees the conflict, warns, and leaves it untied.
  • Scanned all models whose _tied_weights_keys contains embed_tokens aliases and whose config allows False — 24 classes, mostly Bart-family where the flag genuinely means "separate embeddings". No other official checkpoint is affected.

Open question

This leaves the underlying wart: one bool gating the entire _tied_weights_keys dict, with an overloaded meaning in the T5 family. #42815 already missed two models because of it. Happy to do the tie_word_embeddings → tie_weights rename separately if you want it.

Should I retitle the PR to "Always tie embeddings for LongT5 and Pop2Piano"?

@ArthurZucker ArthurZucker left a comment •

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.

Yes, this is much better. Please cleanup the PR desc to be simpler :)

Comment on lines +87 to +90
# Same quirk as T5, since LongT5 is derived from it: the original T5 always scaled the decoder outputs,
# but the 1.1v does not. The model code was relying on saved configs where `tie_word_embeddings` is set
# to `False` in 1.1v and using it as indicator of whether to scale or not. But in fact we tie weights
# always, as the checkpoints only store `shared.weight`, so we force it to be `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.

this is too verbose

@jiqing-feng jiqing-feng Aug 17, 2026 •

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.

Fixed

@ArthurZucker

Copy link
Copy Markdown
Collaborator

run-slow: longt5, pop2piano

@github-actions

Copy link
Copy Markdown
Contributor

Workflow Run ⚙️

This comment contains run-slow, running the specified jobs:

models: ["models/longt5", "models/pop2piano"]
quantizations: []

@jiqing-feng jiqing-feng changed the title Always tie structural weights, regardless of tie_word_embeddings Always tie embeddings for LongT5 and Pop2Piano Aug 17, 2026
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@github-actions

Copy link
Copy Markdown
Contributor

CI Results

Workflow Run ⚙️

Commit Info

Context Commit Description
RUN 7c040047 workflow commit (merge commit)
PR dedf76dc branch commit (from PR)
main e378772d base commit (on main)

✅ No failing test specific to this PR 🎉 👏 !

@github-actions

Copy link
Copy Markdown
Contributor

[For maintainers] Suggested jobs to run (before merge)

run-slow: longt5, pop2piano

@github-actions

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 32013448354:2
Result: success | Jobs: 6 | Tests: 1,250 | Failures: 0 | Duration: 2m 12s

Comment on lines +87 to +91
# Same quirk as T5: `tie_word_embeddings=False` indicates the 1.1v (no decoder output scaling), but the
# checkpoints only store `shared.weight` so weights are always tied.
self.scale_decoder_outputs = self.tie_word_embeddings
self.tie_word_embeddings = True

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yes, this is exactly what I meant!

@zucchini-nlp
zucchini-nlp enabled auto-merge August 20, 2026 09:54
@github-actions

Copy link
Copy Markdown
Contributor

Workflow Run ⚙️

This comment contains run-slow, running the specified jobs:

models: ["models/longt5", "models/pop2piano"]
quantizations: []

@zucchini-nlp
zucchini-nlp added this pull request to the merge queue Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

CI Results

Workflow Run ⚙️

Commit Info

Context Commit Description
RUN 6e123479 workflow commit (merge commit)
PR 4bf574d3 branch commit (from PR)
main 94f09cfe base commit (on main)

✅ No failing test specific to this PR 🎉 👏 !

Merged via the queue into huggingface:main with commit 5909a46 Aug 20, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants