[Model][LoRA] Enable LoRA support for BERT and RoBERTa embedding models by hoaivoke · Pull Request #61033 · vllm-project/vllm · GitHub
Skip to content

[Model][LoRA] Enable LoRA support for BERT and RoBERTa embedding models - #61033

Merged
noooop merged 2 commits into
vllm-project:mainfrom
hoaivoke:bert-embedding-lora
Oct 12, 2026
Merged

noooop merged 2 commits into
vllm-project:mainfrom
hoaivoke:bert-embedding-lora

Conversation

@hoaivoke

Copy link
Copy Markdown
Contributor

Overview

Enable LoRA adapters for BertModel, RobertaModel and XLMRobertaModel embedding models. All three load through BertEmbeddingModel, which RobertaEmbeddingModel subclasses. Covered models include BAAI/bge-*, sentence-transformers/all-MiniLM-* and intfloat/multilingual-e5-*. Follows #57148 (ModernBERT) and #58884 (RobertaForSequenceClassification).

Related: #40581 adds LoRA to BertWithRope models and XLMRobertaModel, but not BertModel. See Details for the overlap.

Claims

  • --enable-lora works for these models instead of failing with BertEmbeddingModel does not support LoRA yet.
  • PEFT adapters trained on the HF backbone apply to the correct modules, and the outputs match HF + PEFT. This holds whether the adapter's weight names have no prefix or start with bert. / roberta. (e.g. trained on *ForMaskedLM).

Validation

Tesla T4, main @ 2d69083.

  • Adapter: random PEFT adapter (r=8, init_lora_weights=False, targets encoder query|key|value|dense).
  • Comparison: vLLM fp16 vs HF + PEFT (merged) in fp32 on CPU, 4 sentences, L2-normalized embeddings.
  • Prefixed adapters: copies of the plain adapter with the weight names rewritten. They're compared against the same HF reference.
Model Arch / pooling Adapter names vLLM base vs HF base (min cos) vLLM+LoRA vs HF+LoRA (min cos) vLLM+LoRA vs vLLM base (max cos)
sentence-transformers/all-MiniLM-L6-v2 BERT / MEAN plain 0.999999 0.999998 0.130
sentence-transformers/all-MiniLM-L6-v2 BERT / MEAN bert. 0.999999 0.999998 0.130
BAAI/bge-small-en-v1.5 BERT / CLS plain 0.999999 0.999999 0.321
sentence-transformers/all-distilroberta-v1 RoBERTa / MEAN plain 0.999999 0.999997 0.118
sentence-transformers/all-distilroberta-v1 RoBERTa / MEAN roberta. 0.999999 0.999997 0.118
intfloat/multilingual-e5-base XLM-RoBERTa / MEAN plain 0.999999 1.000000 0.611
intfloat/multilingual-e5-base XLM-RoBERTa / MEAN roberta. 0.999999 1.000000 0.611

The last column shows the adapter is actually applied, not silently ignored.

Before this change, the BERT and RoBERTa models fail at startup with ValueError: BertEmbeddingModel does not support LoRA yet.

pytest tests/models/language/pooling/test_embedding.py tests/models/language/pooling/test_truncation_control.py -k 'not mistral and not gemma2 and not Qwen2': 11 passed both before and after.

LoRA only runs in fp16 here because the LoRA shrink kernel requires fp16/bf16 and the T4 has no bf16.

Validation script
"""Check vLLM LoRA embeddings for a BERT-family model against HF + PEFT.

Usage:
  python validate_bert_lora.py make-adapter MODEL OUT_DIR
  python validate_bert_lora.py prefix-adapter SRC_DIR OUT_DIR PREFIX
  python validate_bert_lora.py check MODEL ADAPTER_DIR [--hf-adapter DIR]
                                     [--expect-unsupported]
"""

import json
import sys

import torch
import torch.nn.functional as F

TEXTS = [
    "The quick brown fox jumps over the lazy dog.",
    "vLLM serves embedding models with paged attention.",
    "LoRA adds a low-rank update to frozen weights.",
    "Blacksburg is home to Virginia Tech.",
]


def make_adapter(model_name: str, out_dir: str) -> None:
    from peft import LoraConfig, get_peft_model
    from transformers import AutoModel

    torch.manual_seed(0)
    base = AutoModel.from_pretrained(model_name, torch_dtype=torch.float32)
    config = LoraConfig(
        r=8,
        lora_alpha=16,
        # Encoder projections only; HF's unused `pooler.dense` is left out.
        target_modules=r".*encoder.*\.(query|key|value|dense)$",
        init_lora_weights=False,  # random A and B, so the adapter has an effect
    )
    peft_model = get_peft_model(base, config)
    peft_model.save_pretrained(out_dir)
    from safetensors import safe_open

    with safe_open(f"{out_dir}/adapter_model.safetensors", "pt") as f:
        names = sorted(f.keys())
    print(f"saved adapter with {len(names)} tensors, e.g. {names[:2]}")


def prefix_adapter(src: str, out: str, prefix: str) -> None:
    """Copy an adapter, renaming `base_model.model.X` to `base_model.model.<prefix>X`.

    Mimics an adapter trained on e.g. `BertForMaskedLM` (weights under `bert.`).
    """
    import shutil

    from safetensors.torch import load_file, save_file

    shutil.copytree(src, out, dirs_exist_ok=True)
    tensors = load_file(f"{src}/adapter_model.safetensors")
    head = "base_model.model."
    renamed = {head + prefix + k[len(head) :]: v for k, v in tensors.items()}
    save_file(renamed, f"{out}/adapter_model.safetensors")
    print(f"prefixed {len(renamed)} tensors, e.g. {sorted(renamed)[0]}")


def hf_embeddings(model_name, adapter_dir, pooling, normalize):
    from peft import PeftModel
    from transformers import AutoModel, AutoTokenizer

    tok = AutoTokenizer.from_pretrained(model_name)
    model = AutoModel.from_pretrained(model_name, torch_dtype=torch.float32).eval()
    if adapter_dir:
        model = PeftModel.from_pretrained(model, adapter_dir).merge_and_unload()
    out = []
    with torch.inference_mode():
        for text in TEXTS:
            enc = tok(text, return_tensors="pt")
            hidden = model(**enc).last_hidden_state[0]
            vec = hidden[0] if pooling == "CLS" else hidden.mean(dim=0)
            out.append(F.normalize(vec, dim=-1) if normalize else vec)
    return torch.stack(out)


def check(model_name, adapter_dir, hf_adapter_dir, expect_unsupported) -> int:
    from vllm import LLM
    from vllm.lora.request import LoRARequest

    result = {"model": model_name, "adapter": adapter_dir}
    try:
        llm = LLM(
            model=model_name,
            runner="pooling",
            dtype="float16",  # LoRA shrink kernel needs fp16/bf16; T4 has no bf16
            enforce_eager=True,
            max_model_len=256,
            enable_lora=True,
            max_lora_rank=8,
            gpu_memory_utilization=0.5,
        )
    except Exception as e:  # noqa: BLE001
        msg = f"{type(e).__name__}: {e}"
        result.update(status="lora_rejected", error=msg[:400])
        print("RESULT", json.dumps(result))
        return 0 if expect_unsupported else 1

    if expect_unsupported:
        result["status"] = "unexpectedly_accepted"
        print("RESULT", json.dumps(result))
        return 1

    pooler = llm.llm_engine.vllm_config.model_config.pooler_config
    pooling = pooler.seq_pooling_type
    lora = LoRARequest("random-adapter", 1, adapter_dir)
    v_base = torch.tensor([o.outputs.embedding for o in llm.embed(TEXTS)])
    v_lora = torch.tensor(
        [o.outputs.embedding for o in llm.embed(TEXTS, lora_request=lora)]
    )
    h_base = hf_embeddings(model_name, None, pooling, normalize=True)
    h_lora = hf_embeddings(model_name, hf_adapter_dir, pooling, normalize=True)

    cos = lambda a, b: F.cosine_similarity(a, b, dim=-1)  # noqa: E731
    base_match = cos(v_base, h_base).min().item()
    lora_match = cos(v_lora, h_lora).min().item()
    lora_effect = cos(v_lora, v_base).max().item()
    lora_vs_hf_base = cos(v_lora, h_base).max().item()
    result.update(
        pooling=pooling,
        min_cos_vllm_base_vs_hf_base=round(base_match, 6),
        min_cos_vllm_lora_vs_hf_lora=round(lora_match, 6),
        max_cos_vllm_lora_vs_vllm_base=round(lora_effect, 6),
        max_cos_vllm_lora_vs_hf_base=round(lora_vs_hf_base, 6),
    )
    ok = base_match > 0.995 and lora_match > 0.995 and lora_effect < 0.99
    result["status"] = "pass" if ok else "fail"
    print("RESULT", json.dumps(result))
    return 0 if ok else 1


if __name__ == "__main__":
    cmd = sys.argv[1]
    if cmd == "make-adapter":
        make_adapter(sys.argv[2], sys.argv[3])
    elif cmd == "prefix-adapter":
        prefix_adapter(sys.argv[2], sys.argv[3], sys.argv[4])
    elif cmd == "check":
        args = sys.argv
        hf_dir = args[args.index("--hf-adapter") + 1] if "--hf-adapter" in args else args[3]
        sys.exit(check(args[2], args[3], hf_dir, "--expect-unsupported" in args))
    else:
        raise SystemExit(__doc__)

Details

  • packed_modules_mapping maps HF's separate query/key/value LoRA weights onto the fused qkv_proj. The inner BertModel already declares it; the LoRA manager reads it from the top-level model.
  • hf_to_vllm_mapper places adapter weights under vLLM's model. backbone prefix. BertEmbeddingModel previously did this renaming only inside load_weights, which the LoRA loader does not use. LoRAModel.from_local_checkpoint validates only the last name component, so without the mapper an adapter loads but is never applied.
  • self.config is required by the LoRA manager, as in [Model] Enable LoRA support for RobertaForSequenceClassification #58884.
  • Side effect: the class-level mapper is also applied to quantization-config ignore lists (model_loader/utils.py), mapping them onto the real model.-prefixed module names.
  • Not validated:
    • BgeM3EmbeddingModel, which also inherits the change. Its docs row is left unchanged.
    • The rotary JinaRobertaModel path used by jinaai/jina-embeddings-v3, which merges its own LoRA weights at load time.
  • Overlap with [Model] Add LoRA support for GteNewModel, NomicBertModel, and XLMRobertaModel #40581: it adds the same SupportsLoRA/packing to RobertaEmbeddingModel. If that PR lands, its class attributes override the inherited ones; the files don't conflict. cc @yuancu
  • CI gap: no new test. LoRA correctness needs a GPU and an adapter checkpoint. Happy to add a small tests/lora test if reviewers want one.

Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

Add SupportsLoRA to BertEmbeddingModel, which RobertaEmbeddingModel
inherits. Declare the fused qkv_proj packed mapping and a name mapper
that places PEFT adapter weights (optionally under `bert.`/`roberta.`)
under the `model.` backbone prefix, so adapters are not silently ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vy3qjpG3jvqpRrUuRFDVF
Signed-off-by: hoaivoke <vokehoai@gmail.com>
@hoaivoke
hoaivoke requested a review from noooop as a code owner October 11, 2026 00:20

@claude claude Bot 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify

mergify Bot commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--61033.org.readthedocs.build/en/61033/

@mergify mergify Bot added documentation Improvements or additions to documentation pooling labels Oct 11, 2026
@hoaivoke hoaivoke changed the title <!-- Title: [Model][LoRA] Enable LoRA support for BERT and RoBERTa embedding models --> [Model][LoRA] Enable LoRA support for BERT and RoBERTa embedding models Oct 11, 2026
@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@linitra24 linitra24 self-assigned this Oct 11, 2026

@linitra24 linitra24 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@noooop noooop added the verified Run pre-commit for new contributors without triggering other tests label Oct 12, 2026
@noooop
noooop enabled auto-merge (squash) October 12, 2026 09:04
@github-actions github-actions Bot added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 12, 2026
@noooop

noooop commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

/ci run

@github-actions

Copy link
Copy Markdown

❌ This PR is 1 commit behind upstream main. Your branch must contain every commit currently on upstream main. No new CI build was started. Merge or rebase onto the latest main, then rerun /ci run. To test this branch at your own risk, use /ci run --allow-stale.

@noooop

noooop commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

/ci run --allow-stale

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #94159 for commit a4928eeb50af.

⚠️ This PR is 1 commit behind upstream main. Running CI at your own risk because --allow-stale was requested; outdated CI configuration may cause failures. Before merging, merge or rebase onto the latest main, then rerun /ci run on the latest PR commit.

@vllm-agent

Copy link
Copy Markdown
Collaborator

CI selector (shadow): 15 test steps (39 jobs) instead of 60 (92 jobs)

Shadow mode: this changes nothing about what CI runs. It shows what the evidence-based selector would pick for this PR, next to today's rules. How it works.

Feedback welcome: reply here if it would skip a step this change needs, or runs something unrelated.

steps (jobs) Today's rules Selector Would skip Would add
NVIDIA, CPU and others 60 (92) 15 (39) 50 (63) 5 (10)
AMD mirrors 58 (85) 11 (28) 50 (60) 3 (3)
Selector would run (15)
  • arm-cpu-test ×3
  • basic-models-tests-extra-initialization ×14
  • basic-models-tests-initialization
  • cpu-language-generation-and-pooling-model-tests ×3
  • cpu-multi-modal-model-tests-n ×4
  • entrypoints-integration-api-server ×4
  • entrypoints-integration-pooling
  • entrypoints-unit-tests
  • examples
  • kernels-root-misc-test-b200
  • language-models-tests-extra-standard ×2
  • language-models-tests-standard
  • model-executor
  • plugin-tests-2-gpus
  • v1-logits-oracle
Would skip (today's rules run them) (50)
  • amd-lm-eval-small-models-harness
  • amd-qwen3-next-mtp-async-eplb-accuracy
  • ascend-npu-test
  • async-engine-inputs-utils-worker
  • basic-correctness ×2
  • basic-correctness-cpu-offload
  • basic-correctness-cumem
  • basic-correctness-prefetch-offload
  • basic-correctness-sleep-mode
  • basic-models-test-other-cpu
  • basic-models-tests-other
  • benchmarks-cli-test
  • distributed-model-tests-2-gpus ×3
  • entrypoints-integration-api-server-generate
  • entrypoints-integration-api-server-openai-chat_completion
  • entrypoints-integration-api-server-openai-completion
  • entrypoints-integration-llm
  • entrypoints-integration-multimodal
  • entrypoints-integration-responses-api
  • entrypoints-integration-speech_to_text
  • fusion-e2e-quick-h100
  • fusion-e2e-tp2-b200
  • fusion-e2e-tp2-quick-h100
  • kernels-fla-ops-test-b200
  • kernels-mhc-test-b200
  • language-models-tests-granite-l4-compatibility
  • language-models-tests-hybrid ×2
  • metrics-tracing-2-gpus
  • multi-modal-models-standard-1-qwen2
  • multi-modal-models-standard-2-qwen3-gemma
  • multi-modal-models-standard-3-llava-qwen2-vl
  • multi-modal-models-standard-4-other-whisper
  • multi-modal-processor ×4
  • multi-modal-processor-cpu ×4
  • pipeline-context-parallelism-4-gpus
  • pytorch-compilation-dynamic-shapes
  • pytorch-compilation-passes-unit-tests
  • pytorch-compilation-unit-tests
  • pytorch-compilation-unit-tests-h100
  • pytorch-fullgraph-cudagraph-l4-compatibility
  • pytorch-fullgraph-test
  • regression
  • rl-entrypoints-tests
  • v1-core
  • v1-executor-worker
  • v1-kv-connectors ×4
  • v1-kv-offload
  • v1-metrics-lmeval
  • v1-sample
  • v1-spec-decode
Would add (today's rules do not run them) (5)
  • arm-cpu-test ×3 (code map)
  • cpu-multi-modal-model-tests-n ×4 (code map)
  • entrypoints-unit-tests (code map)
  • examples (code map)
  • plugin-tests-2-gpus (code map)
AMD mirrors: would skip (50)
  • async-engine-inputs-utils-worker
  • basic-correctness ×2
  • basic-correctness-cpu-offload
  • basic-correctness-cumem
  • basic-correctness-prefetch-offload
  • basic-correctness-sleep-mode
  • basic-models-tests-other
  • benchmarks-cli-test
  • distributed-model-tests-2-gpus ×3
  • entrypoints-integration-api-server-generate
  • entrypoints-integration-api-server-openai-chat_completion
  • entrypoints-integration-api-server-openai-completion
  • entrypoints-integration-llm
  • entrypoints-integration-multimodal
  • entrypoints-integration-responses-api
  • entrypoints-integration-speech_to_text
  • fusion-e2e-quick-h100
  • fusion-e2e-tp2-ar-rms-config-sweep-h100
  • fusion-e2e-tp2-b200
  • fusion-e2e-tp2-quick-h100
  • kernels-fla-ops-test-b200
  • kernels-mhc-test-b200
  • language-models-tests-granite-l4-compatibility
  • language-models-tests-hybrid ×2
  • lm-eval-small-models
  • metrics-tracing-2-gpus
  • model-executor
  • mrcr-eval-small-models
  • multi-modal-models-standard-1-qwen2
  • multi-modal-models-standard-2-qwen3-gemma
  • multi-modal-models-standard-3-llava-qwen2-vl
  • multi-modal-models-standard-4-other-whisper
  • multi-modal-processor ×4
  • pipeline-context-parallelism-4-gpus
  • platform-tests
  • pytorch-compilation-dynamic-shapes
  • pytorch-compilation-passes-unit-tests
  • pytorch-compilation-unit-tests
  • pytorch-compilation-unit-tests-h100
  • pytorch-fullgraph-cudagraph-l4-compatibility
  • pytorch-fullgraph-test
  • regression
  • rl-entrypoints-tests
  • v1-core
  • v1-executor-worker
  • v1-kv-connectors ×4
  • v1-kv-offload
  • v1-metrics-lmeval
  • v1-sample
  • v1-spec-decode
AMD mirrors: would add (3)
  • entrypoints-unit-tests (code map)
  • examples (code map)
  • plugin-tests-2-gpus (code map)

2 changed files · base e05201aa2e · head a4928eeb50 · Python record: build 93039 at 1e5d0ea888 · kernel record: table f26d9f4 (build 94096), map f26d9f4 · not counted: 11 build steps, 5 A100 steps the generator no longer emits, 5 optional steps the selector would also run

@hoaivoke

Copy link
Copy Markdown
Contributor Author

/ci retry

@github-actions

Copy link
Copy Markdown

@noooop
noooop merged commit f3cb10b into vllm-project:main Oct 12, 2026
154 checks passed
michael-gpu pushed a commit to practical-inference/vllm-ampere-extended that referenced this pull request Oct 20, 2026
…ls (vllm-project#61033)

Signed-off-by: hoaivoke <vokehoai@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation pooling ready ONLY add when PR is ready to merge/full CI is needed verified Run pre-commit for new contributors without triggering other tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants