|
| 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") |
0 commit comments