[Transformers backend] Replace vocab embeddings in `recursive_replace` (#54760)

Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Harry Mellor committed Sep 1, 2026 at 16:35 UTC f1e5fdd7f2f4381cd3bd4e4b821fad4604bd72ab
3 files changed +128 -70
tests/models/transformers/test_backend.py
+89 -37
@@ -10,7 +10,7 @@ from typing import Any
10 import pytest
11 import torch
12 import torch.nn as nn
13 -from transformers import AutoConfig, AutoModel
13 +from transformers import AutoConfig, AutoModel, PretrainedConfig
14
15 from vllm.config import ModelConfig, VllmConfig
16 from vllm.model_executor.models.interfaces import SupportsMultiModal
@@ -316,6 +316,8 @@ def test_pooling(
316
317
318 VOCAB_SIZE = 64
319 +PER_LAYER_VOCAB_SIZE = 32
320 +NUM_POSITIONS = 16
321 HIDDEN_SIZE = 8
322 EMBED_SCALE = 3.0
323
@@ -397,10 +399,10 @@ def replace(embedding):
399 return new_embedding
400
401
400 -def assert_scaled(vpe, module, embedding=None):
401 - """`module`'s output is `embedding`'s unscaled output times `EMBED_SCALE`."""
402 +def assert_scaled(vpe, module):
403 + """`module`'s output is its own unscaled output times `EMBED_SCALE`."""
404 input_ids = torch.arange(VOCAB_SIZE)
403 - unscaled = vpe.forward(embedding if embedding is not None else module, input_ids)
405 + unscaled = vpe.forward(module, input_ids)
406 torch.testing.assert_close(module(input_ids), unscaled * EMBED_SCALE)
407
408
@@ -434,56 +436,106 @@ def test_replace_inherited_embedding(vpe):
436 assert_scaled(vpe, new_embedding)
437
438
437 -def test_replace_composed_embedding(vpe):
438 - """Wrappers are left alone; only the `nn.Embedding` they hold is replaced."""
439 - embedding = ComposedWordEmbedding(VOCAB_SIZE, HIDDEN_SIZE, embed_scale=EMBED_SCALE)
440 - new_embedding = replace(embedding)
439 +def test_replace_is_idempotent(tp_init):
440 + """A tied table is reached twice by the recursion, and must not be rebased twice."""
441 + from vllm.model_executor.models.transformers.utils import replace_embedding_class
442 +
443 + new_embedding = replace(ScaledWordEmbedding(VOCAB_SIZE, HIDDEN_SIZE, embed_scale=2))
444 + again = replace_embedding_class(new_embedding)
445 +
446 + assert again is new_embedding
447 + assert type(again) is type(new_embedding)
448 +
449 +
450 +class FakeVocabModel(nn.Module):
451 + """The embedding tables a model can hold, only one of which is a vocab table.
452 +
453 + `embed_tokens_per_layer` mirrors Gemma 3n, whose per-layer table is sized by
454 + `vocab_size_per_layer_input` rather than `vocab_size`.
455 + """
456 +
457 + def __init__(self, per_layer: bool = False, composed: bool = False):
458 + super().__init__()
459 + embed_cls = ComposedWordEmbedding if composed else ScaledWordEmbedding
460 + self.embed_tokens = embed_cls(VOCAB_SIZE, HIDDEN_SIZE, embed_scale=EMBED_SCALE)
461 + if per_layer:
462 + self.embed_tokens_per_layer = ScaledWordEmbedding(
463 + PER_LAYER_VOCAB_SIZE, HIDDEN_SIZE, embed_scale=EMBED_SCALE
464 + )
465 + # Not a vocab table: indexed by position, and read as a whole by models
466 + # which interpolate it (e.g. `SiglipVisionEmbeddings`)
467 + self.position_embedding = nn.Embedding(NUM_POSITIONS, HIDDEN_SIZE)
468 +
469 + def get_input_embeddings(self):
470 + return self.embed_tokens
471
442 - assert new_embedding is embedding
443 - assert type(embedding.embed) is vpe
444 - assert_scaled(vpe, new_embedding, embedding.embed)
472
473 +def replace_vocab_embeddings(model, **config_kwargs):
474 + """Replace `model`'s vocab tables as `recursive_replace` would, and return them."""
475 + from vllm.model_executor.models.transformers.utils import attrsetter
476
447 -def test_replace_nested_embedding(vpe):
448 - """The composed `nn.Embedding` is found and set however deeply it is nested."""
449 - wrapper = nn.Module()
450 - wrapper.add_module("inner", nn.Module())
451 - wrapper.inner.add_module(
452 - "embed", ScaledWordEmbedding(VOCAB_SIZE, HIDDEN_SIZE, embed_scale=EMBED_SCALE)
477 + stub = nn.Module()
478 + stub.model = model
479 + stub.config = PretrainedConfig(
480 + vocab_size=VOCAB_SIZE, num_positions=NUM_POSITIONS, **config_kwargs
481 )
454 - replace(wrapper)
482 + ids = Base._vocab_embedding_ids(stub)
483 +
484 + replaced = []
485 + for name, module in list(model.named_modules()):
486 + if id(module) in ids:
487 + replaced.append(replace(module))
488 + attrsetter(name)(model, replaced[-1])
489 + return replaced
490 +
491 +
492 +def test_only_vocab_tables_are_replaced(vpe):
493 + """Position embeddings share the `nn.Embedding` type, but not the treatment."""
494 + model = FakeVocabModel()
495
456 - assert isinstance(wrapper.inner.embed, vpe)
457 - assert_scaled(vpe, wrapper.inner.embed)
496 + assert replace_vocab_embeddings(model) == [model.embed_tokens]
497 + assert isinstance(model.embed_tokens, vpe)
498 + assert type(model.position_embedding) is nn.Embedding
499
500
460 -@pytest.mark.parametrize("num_embeddings", [0, 2])
461 -def test_replace_ambiguous_embedding(tp_init, num_embeddings):
462 - """Composing anything but one `nn.Embedding` is an error, not a silent guess."""
463 - wrapper = nn.Module()
464 - for i in range(num_embeddings):
465 - wrapper.add_module(f"embed_{i}", nn.Embedding(VOCAB_SIZE, HIDDEN_SIZE))
501 +def test_per_layer_vocab_table_is_replaced(vpe):
502 + """A second vocab table is found by its size, without a bespoke accessor."""
503 + model = FakeVocabModel(per_layer=True)
504 + replaced = replace_vocab_embeddings(
505 + model, vocab_size_per_layer_input=PER_LAYER_VOCAB_SIZE
506 + )
507
467 - with pytest.raises(ValueError, match=f"found {num_embeddings}"):
468 - replace(wrapper)
508 + assert replaced == [model.embed_tokens, model.embed_tokens_per_layer]
509 + assert all(isinstance(module, vpe) for module in replaced)
510
511
471 -def test_replaced_embedding_exposes_one_vpe(vpe):
472 - """`CausalMixin` ties `lm_head` to the one `VocabParallelEmbedding` it can find.
512 +def test_composed_input_embeddings_are_replaced(vpe):
513 + """A wrapper is left alone; only the `nn.Embedding` it composes is replaced.
514
474 - `tie_weights` reads `.weight`, which a composing module does not have, so it must
475 - be handed the composed embedding instead.
515 + `CausalMixin` ties `lm_head` to whichever `VocabParallelEmbedding` it finds under
516 + `get_input_embeddings()`, reading a `.weight` the wrapper does not have.
517 """
518 from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
519
479 - inherited = replace(ScaledWordEmbedding(VOCAB_SIZE, HIDDEN_SIZE))
480 - composed = replace(ComposedWordEmbedding(VOCAB_SIZE, HIDDEN_SIZE, EMBED_SCALE))
520 + model = FakeVocabModel(composed=True)
521 + wrapper = model.embed_tokens
522 + replaced = replace_vocab_embeddings(model)
523
482 - assert [m for m in inherited.modules() if isinstance(m, vpe)] == [inherited]
483 - assert [m for m in composed.modules() if isinstance(m, vpe)] == [composed.embed]
524 + assert model.embed_tokens is wrapper
525 + assert replaced == [m for m in wrapper.modules() if isinstance(m, vpe)]
526
527 lm_head = ParallelLMHead(VOCAB_SIZE, HIDDEN_SIZE)
486 - assert lm_head.tie_weights(composed.embed).weight is composed.embed.weight
528 + assert lm_head.tie_weights(replaced[0]).weight is replaced[0].weight
529 +
530 +
531 +def test_missing_input_embeddings_are_skipped(tp_init):
532 + """Pipeline ranks without the embeddings have a `PPMissingLayer` in their place."""
533 + from vllm.model_executor.models.utils import PPMissingLayer
534 +
535 + model = FakeVocabModel()
536 + model.embed_tokens = PPMissingLayer()
537 +
538 + assert replace_vocab_embeddings(model) == []
539
540
541 MULTIMODAL_MODEL = "llava-hf/llava-onevision-qwen2-0.5b-ov-hf"
vllm/model_executor/models/transformers/base.py
+33 -8
@@ -29,7 +29,7 @@ import torch
29 import transformers
30 from packaging.version import Version
31 from torch import nn
32 -from transformers import AutoModel
32 +from transformers import AutoModel, PretrainedConfig
33 from transformers.conversion_mapping import (
34 WeightRenaming,
35 get_model_conversion_mapping,
@@ -169,13 +169,6 @@ class Base(
169 # Create attention instances for KV cache allocation
170 self.attention_instances = self.create_attention_instances()
171
172 - # Input embeddings
173 - input_embeddings = self.model.get_input_embeddings()
174 - if not isinstance(input_embeddings, PPMissingLayer):
175 - self.model.set_input_embeddings(
176 - replace_embedding_class(input_embeddings, self.quant_config)
177 - )
178 -
172 # Initialize any parameters that have not had their modules replaced
173 self.init_parameters(self.model)
174
@@ -423,6 +416,31 @@ class Base(
416 # attrsetter in case the module is nested (e.g. "text_model.norm")
417 attrsetter(name)(module, PPMissingLayer())
418
419 + def _vocab_embedding_ids(self) -> set[int]:
420 + """ids of the `nn.Embedding`s in `self.model` which hold a vocab table."""
421 +
422 + def vocab_sizes(config: PretrainedConfig):
423 + for key, value in vars(config).items():
424 + if isinstance(value, PretrainedConfig):
425 + yield from vocab_sizes(value)
426 + elif "vocab_size" in key and isinstance(value, int):
427 + yield value
428 +
429 + sizes = set(vocab_sizes(self.config))
430 + # `get_input_embeddings` may return a module which composes the `nn.Embedding`,
431 + # or a `PPMissingLayer` if the embeddings are not on this pipeline stage
432 + ids = {
433 + id(module)
434 + for module in self.model.get_input_embeddings().modules()
435 + if isinstance(module, nn.Embedding)
436 + }
437 + ids.update(
438 + id(module)
439 + for module in self.model.modules()
440 + if isinstance(module, nn.Embedding) and module.num_embeddings in sizes
441 + )
442 + return ids
443 +
444 def recursive_replace(self):
445 """Recursively replace modules in the model as needed.
446
@@ -432,6 +450,7 @@ class Base(
450 - Attention QKV projections with a fused `QKVParallelLinear` + split
451 - `nn.Linear` with vLLM's tensor parallel linear classes
452 - `nn.Conv2d` / `nn.Conv3d` with vLLM's `Conv2d` / `Conv3d`
453 + - Vocab `nn.Embedding`s with vLLM's `VocabParallelEmbedding`
454 - RMSNorm (detected from their dataflow) with vLLM's `RMSNorm`or `GemmaRMSNorm`
455 """
456 tp_plan = self.model.tp_plan or {}
@@ -454,6 +473,8 @@ class Base(
473 # Detect fusable patterns once per module class (cached, so this is cheap)
474 fusers = Fusers(self.model, self.vllm_config)
475
476 + vocab_embedding_ids = self._vocab_embedding_ids()
477 +
478 def register_fusion(fuser: BaseFuser, prefix: str):
479 """Register a fused layer's mappings just before it is built."""
480 self.fusers[prefix] = fuser
@@ -503,6 +524,10 @@ class Base(
524 )
525 elif isinstance(child_module, (nn.Conv2d, nn.Conv3d)):
526 new_module = replace_conv_class(child_module)
527 + elif id(child_module) in vocab_embedding_ids:
528 + new_module = replace_embedding_class(
529 + child_module, self.quant_config, prefix=qual_name
530 + )
531 elif (fuser := fusers[child_module]) is not None:
532 register_fusion(fuser, qual_name)
533 new_module = fuser.fuse(child_module, qual_name, self.vllm_config)
vllm/model_executor/models/transformers/utils.py
+6 -25
@@ -224,41 +224,22 @@ def _rebase_on_vocab_parallel(cls: type[nn.Embedding]) -> type[VocabParallelEmbe
224
225
226 def replace_embedding_class(
227 - embedding: nn.Module,
227 + embedding: nn.Embedding,
228 quant_config: "QuantizationConfig | None" = None,
229 *,
230 prefix: str = "",
231 ) -> nn.Module:
232 - """Replace the `nn.Embedding` in `embedding` with `VocabParallelEmbedding`.
232 + """Replace `nn.Embedding` with `VocabParallelEmbedding`.
233
234 Args:
235 - embedding: The module returned by `model.get_input_embeddings()`.
235 + embedding: The `nn.Embedding` holding a vocab table.
236 quant_config: Quantization config for the new embedding.
237 prefix: Qualname of `embedding`, used to look up its quantization method.
238 Returns:
239 - The module to install with `model.set_input_embeddings()`. Composing and
240 - inheriting modules are mutated in place and returned as-is.
241 - Raises:
242 - ValueError: If `embedding` composes anything other than one `nn.Embedding`,
243 - which would leave the input embedding weights ambiguous.
239 + The module to install in `embedding`'s place.
240 """
245 - # If `embedding` composes its `nn.Embedding`, recurse into it
246 - if not isinstance(embedding, nn.Embedding):
247 - composed = [
248 - (name, module)
249 - for name, module in embedding.named_modules()
250 - if isinstance(module, nn.Embedding)
251 - ]
252 - if len(composed) != 1:
253 - raise ValueError(
254 - f"Expected {type(embedding).__name__} to be an `nn.Embedding` or to "
255 - f"compose exactly one, but found {len(composed)}."
256 - )
257 - name, module = composed[0]
258 - new_embedding = replace_embedding_class(
259 - module, quant_config, prefix=maybe_prefix(prefix, name)
260 - )
261 - attrsetter(name)(embedding, new_embedding)
241 + # Tied tables are reached more than once, so don't rebase an already new embedding
242 + if isinstance(embedding, VocabParallelEmbedding):
243 return embedding
244
245 kwargs = dict(