[Feat] Add Support for BertForMaskedLM to vLLM (#48463)

Signed-off-by: atalhens <sneh.lata@nutanix.com>

Snehlata committed Jul 14, 2026 at 02:26 UTC 7738ef35b8c62070773ef3cfd53f2a59d94626b1
4 files changed +158 -2
tests/models/language/pooling/test_token_classification.py
+54 -1
@@ -3,7 +3,7 @@
3
4 import pytest
5 import torch
6 -from transformers import AutoModelForTokenClassification
6 +from transformers import AutoModelForMaskedLM, AutoModelForTokenClassification
7
8 from tests.models.registry import HF_EXAMPLE_MODELS
9 from tests.models.utils import softmax
@@ -200,3 +200,56 @@ def test_auto_conversion(
200 hf_output = hf_output.detach().clone().cpu().float()
201 vllm_output = vllm_output.detach().clone().cpu().float()
202 assert torch.allclose(hf_output, vllm_output, atol=1e-2)
203 +
204 +
205 +@pytest.mark.parametrize(
206 + "model",
207 + [
208 + # Original Google checkpoint: legacy `gamma`/`beta` LayerNorm names, an
209 + # NSP head (`cls.seq_relationship.*`) and a decoder tied to the input
210 + # embeddings (no explicit decoder weight in the checkpoint).
211 + "google-bert/bert-base-uncased",
212 + ],
213 +)
214 +@pytest.mark.parametrize("dtype", ["float"])
215 +@torch.inference_mode
216 +def test_bert_for_masked_lm(
217 + hf_runner,
218 + vllm_runner,
219 + example_prompts,
220 + model: str,
221 + dtype: str,
222 +) -> None:
223 + # BertForMaskedLM exposes its MLM head as a token-level pooling task; the
224 + # head applies softmax over the vocabulary, so each output row is a
225 + # distribution (matching HF's softmax(logits) below).
226 + with vllm_runner(model, max_model_len=None, dtype=dtype) as vllm_model:
227 + vllm_outputs = vllm_model.token_classify(example_prompts)
228 +
229 + # Use eager attention on ROCm to avoid HF Transformers flash attention
230 + # accuracy issues: https://github.com/vllm-project/vllm/issues/30167
231 + hf_model_kwargs = {}
232 + if current_platform.is_rocm():
233 + hf_model_kwargs["attn_implementation"] = "eager"
234 +
235 + with hf_runner(
236 + model,
237 + dtype=dtype,
238 + auto_cls=AutoModelForMaskedLM,
239 + model_kwargs=hf_model_kwargs,
240 + ) as hf_model:
241 + tokenizer = hf_model.tokenizer
242 + hf_outputs = []
243 + for prompt in example_prompts:
244 + inputs = tokenizer([prompt], return_tensors="pt")
245 + inputs = hf_model.wrap_device(inputs)
246 + output = hf_model.model(**inputs)
247 + hf_outputs.append(softmax(output.logits[0]))
248 +
249 + # Compare the per-token vocabulary distributions position by position.
250 + for hf_output, vllm_output in zip(hf_outputs, vllm_outputs):
251 + hf_output = hf_output.detach().clone().cpu().float()
252 + vllm_output = vllm_output.detach().clone().cpu().float()
253 + assert hf_output.shape == vllm_output.shape
254 + assert torch.equal(hf_output.argmax(dim=-1), vllm_output.argmax(dim=-1))
255 + torch.testing.assert_close(hf_output, vllm_output, atol=3.2e-2, rtol=1e-3)
tests/models/registry.py
+1
@@ -576,6 +576,7 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
576 _EMBEDDING_EXAMPLE_MODELS = {
577 # [Text-only]
578 "BertModel": _HfExamplesInfo("BAAI/bge-base-en-v1.5"),
579 + "BertForMaskedLM": _HfExamplesInfo("google-bert/bert-base-uncased"),
580 "BertSpladeSparseEmbeddingModel": _HfExamplesInfo(
581 "naver/splade-v3",
582 hf_overrides={"architectures": ["BertSpladeSparseEmbeddingModel"]},
vllm/model_executor/models/bert.py
+102 -1
@@ -370,11 +370,17 @@ class BertModel(nn.Module, SupportsQuant):
370 packed_modules_mapping = {"qkv_proj": ["query", "key", "value"]}
371
372 hf_to_vllm_mapper = WeightsMapper(
373 + # Original google-bert checkpoints use the legacy `gamma`/`beta`
374 + # LayerNorm names; rename to vLLM's `weight`/`bias`.
375 + orig_to_new_substr={
376 + "LayerNorm.gamma": "LayerNorm.weight",
377 + "LayerNorm.beta": "LayerNorm.bias",
378 + },
379 orig_to_new_stacked={
380 ".self.query": (".self.qkv_proj", "q"),
381 ".self.key": (".self.qkv_proj", "k"),
382 ".self.value": (".self.qkv_proj", "v"),
377 - }
383 + },
384 )
385
386 def __init__(
@@ -877,3 +883,98 @@ class BertForTokenClassification(nn.Module):
883
884 hidden_states = hidden_states.to(self.head_dtype)
885 return self.classifier(hidden_states)
886 +
887 +
888 +@attn_type("encoder_only")
889 +@default_pooling_type(tok_pooling_type="ALL")
890 +class BertForMaskedLM(nn.Module):
891 + """Bert with a masked-language-modeling head on top of ``BertModel``.
892 +
893 + Produces per-token logits over the vocabulary. In vLLM terms this is a
894 + token-level pooling model (``tok_pooling_type="ALL"``): the encoder output
895 + is projected by the MLM head to ``vocab_size`` logits for every position,
896 + and the token pooler returns one vector per token.
897 + """
898 +
899 + is_pooling_model = True
900 +
901 + # Map the HF ``cls.predictions.*`` checkpoint names onto our ``mlm_head.*``
902 + # submodule. Order matters: the ``None`` (drop) rules and the more specific
903 + # names are listed before the broader ``cls.predictions.decoder`` rule so
904 + # that substring replacement doesn't rewrite them first.
905 + hf_to_vllm_mapper = WeightsMapper(
906 + orig_to_new_substr={
907 + # Next-sentence-prediction head: not part of masked LM.
908 + "cls.seq_relationship": None,
909 + # Some checkpoints ship an explicit (tied) decoder bias; we load the
910 + # canonical ``cls.predictions.bias`` instead, so drop the duplicate.
911 + "cls.predictions.decoder.bias": None,
912 + # Legacy LayerNorm affine names in the MLM head transform.
913 + "cls.predictions.transform.LayerNorm.gamma": "mlm_head.layer_norm.weight",
914 + "cls.predictions.transform.LayerNorm.beta": "mlm_head.layer_norm.bias",
915 + "cls.predictions.transform.LayerNorm": "mlm_head.layer_norm",
916 + "cls.predictions.transform.dense": "mlm_head.dense",
917 + "cls.predictions.decoder": "mlm_head.decoder",
918 + # In HF ``cls.predictions.bias`` *is* the decoder bias.
919 + "cls.predictions.bias": "mlm_head.decoder.bias",
920 + }
921 + )
922 +
923 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
924 + super().__init__()
925 + config = vllm_config.model_config.hf_config
926 + self.bert = BertModel(
927 + vllm_config=vllm_config,
928 + prefix=maybe_prefix(prefix, "bert"),
929 + embedding_class=BertEmbedding,
930 + )
931 + self.mlm_head = BertMLMHead(
932 + hidden_size=config.hidden_size,
933 + vocab_size=config.vocab_size,
934 + layer_norm_eps=getattr(config, "layer_norm_eps", 1e-12),
935 + )
936 +
937 + pooler_config = vllm_config.model_config.pooler_config
938 + assert pooler_config is not None
939 +
940 + self.pooler = pooler_for_token_classify(pooler_config)
941 +
942 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
943 + return self.bert.embed_input_ids(input_ids)
944 +
945 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
946 + loader = AutoWeightsLoader(self)
947 + loaded = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
948 +
949 + # The MLM decoder shares its weight with the input embeddings. When the
950 + # checkpoint relies on `tie_word_embeddings` (e.g. google-bert/*) it
951 + # doesn't ship an explicit decoder weight, so tie it here. Copy the
952 + # first `vocab_size` rows since VocabParallelEmbedding may pad the vocab.
953 + if "mlm_head.decoder.weight" not in loaded:
954 + emb = self.bert.embeddings.word_embeddings.weight
955 + decoder = self.mlm_head.decoder.weight
956 + decoder.data.copy_(emb.data[: decoder.shape[0]])
957 + loaded.add("mlm_head.decoder.weight")
958 + return loaded
959 +
960 + def forward(
961 + self,
962 + input_ids: torch.Tensor | None,
963 + positions: torch.Tensor,
964 + intermediate_tensors: IntermediateTensors | None = None,
965 + inputs_embeds: torch.Tensor | None = None,
966 + token_type_ids: torch.Tensor | None = None,
967 + ) -> torch.Tensor:
968 + if token_type_ids is not None:
969 + assert self.bert.config.vocab_size < (1 << TOKEN_TYPE_SHIFT)
970 + assert input_ids is not None
971 + _encode_token_type_ids(input_ids, token_type_ids)
972 +
973 + hidden_states = self.bert(
974 + input_ids=input_ids,
975 + positions=positions,
976 + inputs_embeds=inputs_embeds,
977 + intermediate_tensors=intermediate_tensors,
978 + )
979 +
980 + return self.mlm_head(hidden_states)
vllm/model_executor/models/registry.py
+1
@@ -210,6 +210,7 @@ _TEXT_GENERATION_MODELS = {
210 _EMBEDDING_MODELS = {
211 # [Text-only]
212 "BertModel": ("bert", "BertEmbeddingModel"),
213 + "BertForMaskedLM": ("bert", "BertForMaskedLM"),
214 "BertSpladeSparseEmbeddingModel": ("bert", "BertSpladeSparseEmbeddingModel"),
215 "BgeM3EmbeddingModel": ("roberta", "BgeM3EmbeddingModel"),
216 "DeciLMForCausalLM": ("nemotron_nas", "DeciLMForCausalLM"),