Skip to content

Commit 0a17bf5

Browse files
authored
Fix silent embedding corruption in add_new_tokens on padded-vocab mod (#923)
1 parent 8bc15db commit 0a17bf5

2 files changed

Lines changed: 162 additions & 11 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# SPDX-License-Identifier: AGPL-3.0-only
2+
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
3+
4+
"""Regression test for #121: add_new_tokens must not shrink a padded embedding.
5+
6+
Some models ship an embedding padded LARGER than the tokenizer vocab (Gemma3:
7+
262208 rows vs 262145 tokens). The old code resized to len(tokenizer), which
8+
SHRANK the matrix and silently destroyed the already-trained rows past the
9+
tokenizer length (no exception raised). The fix never resizes below the existing
10+
embedding, fills only the genuinely-new rows with the trained mean, keeps tied
11+
weights tied, and keeps config.vocab_size equal to the real matrix row count so
12+
the model still round-trips through save_pretrained/from_pretrained.
13+
14+
Fully synthetic (tiny tied Llama, ~100-token tokenizer), CPU only, no download.
15+
"""
16+
17+
import tempfile
18+
19+
import pytest
20+
import torch
21+
22+
transformers = pytest.importorskip("transformers")
23+
pytest.importorskip("tokenizers")
24+
25+
from tokenizers import Tokenizer
26+
from tokenizers.models import WordLevel
27+
from tokenizers.pre_tokenizers import Whitespace
28+
from transformers import LlamaConfig, LlamaForCausalLM, PreTrainedTokenizerFast
29+
30+
from unsloth_zoo.tokenizer_utils import add_new_tokens
31+
32+
VOCAB_TOK = 100 # real tokens in the tokenizer
33+
PADDED = 128 # embedding rows shipped by the model (padding = [100, 128))
34+
35+
36+
def _build(vocab_tok, padded):
37+
torch.manual_seed(0)
38+
vocab = {f"tok{i}": i for i in range(vocab_tok)}
39+
backend = Tokenizer(WordLevel(vocab=vocab, unk_token="tok0"))
40+
backend.pre_tokenizer = Whitespace()
41+
tokenizer = PreTrainedTokenizerFast(
42+
tokenizer_object=backend, unk_token="tok0", pad_token="tok1",
43+
)
44+
cfg = LlamaConfig(
45+
vocab_size=padded, hidden_size=16, intermediate_size=32,
46+
num_hidden_layers=1, num_attention_heads=2, num_key_value_heads=2,
47+
tie_word_embeddings=True,
48+
)
49+
model = LlamaForCausalLM(cfg)
50+
model.eval()
51+
return model, tokenizer
52+
53+
54+
def test_padded_embedding_preserves_trained_rows_and_places_new_tokens():
55+
model, tokenizer = _build(VOCAB_TOK, PADDED)
56+
emb = model.get_input_embeddings().weight
57+
58+
# Snapshot everything the fix must preserve, and mark the alignment padding
59+
# rows [VOCAB_TOK, PADDED) with a distinctive sentinel so we can tell if a
60+
# new token wrongly resolves to a leftover padding row.
61+
trained_rows = emb.detach()[:VOCAB_TOK].clone()
62+
with torch.no_grad():
63+
emb[VOCAB_TOK:PADDED] = 7.0
64+
sentinel_rows = emb.detach()[VOCAB_TOK:PADDED].clone()
65+
assert emb.data_ptr() == model.get_output_embeddings().weight.data_ptr()
66+
67+
new_tokens = ["<newA>", "<newB>"]
68+
add_new_tokens(model, tokenizer, new_tokens=new_tokens)
69+
70+
emb2 = model.get_input_embeddings().weight
71+
head2 = model.get_output_embeddings().weight
72+
73+
# 1. No shrink: all trained rows survive byte-identical.
74+
assert emb2.shape[0] == PADDED, "padded embedding must not shrink"
75+
assert torch.equal(emb2[:VOCAB_TOK], trained_rows), "trained rows corrupted"
76+
77+
# 2. New tokens land at their real tokenizer IDs, each pointing at its OWN
78+
# mean-initialised row (not a leftover padding row).
79+
new_len = len(tokenizer)
80+
for offset, tok in enumerate(new_tokens):
81+
tid = tokenizer.convert_tokens_to_ids(tok)
82+
assert tid == VOCAB_TOK + offset
83+
assert tokenizer(tok, add_special_tokens=False).input_ids == [tid]
84+
row = emb2[tid]
85+
assert not torch.allclose(row, torch.full_like(row, 7.0)), \
86+
"new token resolved to a leftover padding row"
87+
# Row is real (finite, non-zero), i.e. mean-initialised.
88+
assert torch.isfinite(row).all() and row.abs().sum() > 0
89+
90+
# 3. mean-init touched ONLY the genuinely-new rows: the leftover padding
91+
# beyond the new tokens is exactly as shipped.
92+
assert torch.equal(emb2[new_len:PADDED], sentinel_rows[new_len - VOCAB_TOK:]), \
93+
"alignment padding rows were overwritten"
94+
95+
# 4. Tied weights stay tied, and share storage at a new-token row.
96+
assert emb2.data_ptr() == head2.data_ptr(), "tie broken by resize"
97+
assert torch.equal(emb2[VOCAB_TOK], head2[VOCAB_TOK])
98+
99+
# 5. config.vocab_size tracks the matrix, so the model round-trips.
100+
assert model.config.vocab_size == PADDED
101+
with tempfile.TemporaryDirectory() as d:
102+
model.save_pretrained(d)
103+
reloaded = LlamaForCausalLM.from_pretrained(d)
104+
assert reloaded.get_input_embeddings().weight.shape[0] == PADDED
105+
assert reloaded.config.vocab_size == PADDED
106+
107+
# 6. Forward pass over the new IDs works.
108+
with torch.no_grad():
109+
logits = model(torch.tensor([[VOCAB_TOK, VOCAB_TOK + 1, 3, 4]])).logits
110+
assert logits.shape[-1] == PADDED
111+
112+
113+
def test_non_padded_embedding_still_grows_normally():
114+
# embedding == tokenizer length: the ordinary case must be unaffected.
115+
model, tokenizer = _build(VOCAB_TOK, VOCAB_TOK)
116+
trained_rows = model.get_input_embeddings().weight.detach()[:VOCAB_TOK].clone()
117+
118+
add_new_tokens(model, tokenizer, new_tokens=["<newA>", "<newB>"])
119+
120+
emb = model.get_input_embeddings().weight
121+
head = model.get_output_embeddings().weight
122+
assert emb.shape[0] == VOCAB_TOK + 2, "non-padded model must grow to fit"
123+
assert torch.equal(emb[:VOCAB_TOK], trained_rows), "trained rows corrupted"
124+
assert model.config.vocab_size == VOCAB_TOK + 2
125+
assert emb.data_ptr() == head.data_ptr()
126+
for offset in range(2):
127+
row = emb[VOCAB_TOK + offset]
128+
assert torch.isfinite(row).all() and row.abs().sum() > 0
129+
130+
131+
if __name__ == "__main__":
132+
test_padded_embedding_preserves_trained_rows_and_places_new_tokens()
133+
test_non_padded_embedding_still_grows_normally()
134+
print("ok")

unsloth_zoo/tokenizer_utils.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,24 +138,35 @@ def add_new_tokens(
138138
old_length = len(tokenizer)
139139
tokenizer.add_tokens(new_tokens)
140140
new_vocab_length = len(tokenizer)
141+
# Some models ship an embedding that is padded LARGER than the tokenizer
142+
# vocab (e.g. Gemma3: 262208 embedding rows vs 262145 tokens). Resizing to
143+
# new_vocab_length would then SHRINK the matrix and silently destroy the
144+
# already-trained rows past the tokenizer length. Never resize below the
145+
# existing embedding: grow only when the new tokens overflow the padding.
146+
# new_vocab_length already includes the freshly added tokens, so do NOT add
147+
# their count again here.
148+
resize_target = max(old_input_length, old_output_length, new_vocab_length)
141149
# Also resizes lm_head as well!
142-
model.resize_token_embeddings(new_vocab_length)
150+
model.resize_token_embeddings(resize_target)
143151

144152
# If we use interpolation, we interpolate between the mean embeddings and
145153
# the Word2Vec sum of the other vectors
146154
embedding_matrix = model.get_input_embeddings ().weight
147155
lm_head_matrix = model.get_output_embeddings().weight
148156

149-
# Confirm sizes are correct
150-
if embedding_matrix.shape[0] != new_vocab_length:
157+
# Confirm sizes are correct. Compare against resize_target (not
158+
# new_vocab_length): for padded embeddings the matrices keep their larger
159+
# row count. The old check compared against new_vocab_length, so on a padded
160+
# model it either never fired (masking the silent shrink) or fired spuriously.
161+
if embedding_matrix.shape[0] != resize_target:
151162
raise RuntimeError(
152163
"Unsloth: Embedding matrix size did not get resized properly. Please file a bug report!"
153164
)
154-
if lm_head_matrix.shape[0] != new_vocab_length:
165+
if lm_head_matrix.shape[0] != resize_target:
155166
raise RuntimeError(
156167
"Unsloth: LM Head matrix size did not get resized properly. Please file a bug report!"
157168
)
158-
if model.config.vocab_size != new_vocab_length:
169+
if model.config.vocab_size != resize_target:
159170
raise RuntimeError(
160171
"Unsloth: Model's config vocab_size did not get resized properly. Please file a bug report!"
161172
)
@@ -181,10 +192,13 @@ def add_new_tokens(
181192
lm_head_matrix [old_length+j] = mean_lm_head_token
182193
pass
183194
else:
184-
# Now set the new tokens to the mean!
195+
# Now set the new tokens to the mean! Only touch the genuinely-new rows
196+
# [old_length:new_vocab_length]; on a padded embedding the rows past
197+
# new_vocab_length are the model's original alignment padding and must be
198+
# left as shipped (the old [old_length:] slice overwrote those too).
185199
with torch.no_grad():
186-
embedding_matrix[old_length:] = mean_embedding
187-
lm_head_matrix [old_length:] = mean_lm_head
200+
embedding_matrix[old_length:new_vocab_length] = mean_embedding
201+
lm_head_matrix [old_length:new_vocab_length] = mean_lm_head
188202
pass
189203

190204
# We set a flag to say we need to train embeddings
@@ -195,15 +209,18 @@ def add_new_tokens(
195209
pass
196210
internal_model._need_to_train_embeddings = True
197211

198-
# Fix up all vocab sizes
212+
# Fix up all vocab sizes. Use resize_target, not len(tokenizer): config
213+
# vocab_size must equal the actual embedding / lm_head row count or the model
214+
# cannot round-trip through save_pretrained/from_pretrained (state_dict shape
215+
# mismatch). For a padded embedding these differ.
199216
current_model = model
200217
while hasattr(current_model, "model") and hasattr(current_model, "config"):
201218
if hasattr(current_model.config, "vocab_size"):
202-
current_model.config.update({"vocab_size" : len(tokenizer)})
219+
current_model.config.update({"vocab_size" : resize_target})
203220
current_model = current_model.model
204221
if hasattr(current_model, "model") and hasattr(current_model, "config"):
205222
if hasattr(current_model.config, "vocab_size"):
206-
current_model.config.update({"vocab_size" : len(tokenizer)})
223+
current_model.config.update({"vocab_size" : resize_target})
207224
pass
208225

209226
# Must tie lm_head and embed_tokens if they are tied!

0 commit comments

Comments
 (0)