Skip to content

[Model][Quant] Fused WNA16 GEMM for tied quantized lm_head logits - #48870

Draft
KKothuri wants to merge 8 commits into
vllm-project:mainfrom
KKothuri:embed-quant-fused-logits
Draft

KKothuri wants to merge 8 commits into
vllm-project:mainfrom
KKothuri:embed-quant-fused-logits

Conversation

@KKothuri

@KKothuri KKothuri commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Purpose

Follow-up to #45535 (compressed-tensors WNA16 input embeddings + tied lm_head). This PR speeds up the logits matmul when a quantized embedding is reused as a tied lm_head.

In #45535, CompressedTensorsEmbeddingWNA16Int.apply() dequantizes the entire packed [vocab, hidden] table into a dense weight on every call and runs F.linear. This PR routes that matmul through vLLM's existing WNA16 Linear kernel (Marlin/Machete) instead — a fused dequant-GEMM that never materializes the dense weight.

Key insight: the embedding's packed weight is already in the compressed-tensors layout those kernels expect (packed along the input/hidden dim, identical to a WNA16 Linear), so process_weights_after_loading sets up the fused kernel directly. It's set up only for tied embeddings (flagged in tie_weights), keeping a gather-format copy for the input-lookup path. When no fused kernel is available it falls back to a full-table dequant (no index tensor) + F.linear.

Note

Stacked on #45535 — the tied-embedding apply() this optimizes only exists there, so the net-new change here is compressed_tensors_embedding.py; the rest of the diff belongs to #45535. Please review/merge after #45535. Kept as a separate PR to avoid growing the (already large) plumbing PR.

Not a duplicate: quantized-lm_head-via-Marlin already exists for the untied / separately quantized case — a ParallelLMHead independently quantized as a GPTQ/AWQ/compressed-tensors Linear already dispatches to Marlin (cf. #40999). This PR covers the distinct tied case, where the embedding is the lm_head and its quant method is the embedding method (CompressedTensorsEmbeddingWNA16Int), not a Linear method. The open ModelOpt lm_head/embedding PRs (#35660, #42791, #44671, #41000) target a different backend (NVFP4/FP8 in modelopt.py) and address loading/dispatch, not this fused tied-logits path. No open PR touches compressed_tensors_embedding.py.

Correctness

The fused path matches the dequant reference numerically (max relative diff 0.025%, fp16). The existing tied fixture test (tests/quantization/test_quantized_embedding.py::test_tied_quantized_embedding) exercises the fused Marlin path end-to-end and generates coherently.

Perf evals

RTX 5080 (sm120, CUDA 13), apply() microbenchmark, µs/call (lower is better):

dims M current (dequant + F.linear) no-gather dequant fused (Marlin) fused vs current
vocab 151936 × 1024, W4-g64 1 1329 1317 240 5.5×
32 1703 2395 619 2.8×
256 3247 2460 2266 1.4×
vocab 151936 × 1024, W8-g128 1 2859 1374 329 8.7×
32 1442 575 575 2.5×
256 2971 2088 2088 1.4×
vocab 50304 × 512, W4-g64 1 141 142 19 7.6×
32 192 190 123 1.6×

Biggest win at decode (M=1), the common serving case for logits. The no-gather dequant (kept as the fallback path) is marginal versus the current dequant, as expected — the fused GEMM is the real lever.

Test Plan

pytest tests/quantization/test_quantized_embedding.py

Test Result

  • Both fixture tests pass on an RTX 5080; the tied test confirms dispatch to MarlinLinearKernel for the logits path (verified logits_kernel is set and gather copies are preserved).
  • pre-commit (ruff + mypy) clean on the changed file.

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

@mergify mergify Bot added deepseek Related to DeepSeek models llama Related to Llama models mistral Related to Mistral models qwen Related to Qwen models gpt-oss Related to GPT-OSS models speculative-decoding labels Jul 16, 2026
@mergify mergify Bot added the quantization label Jul 23, 2026
@mergify

mergify Bot commented Jul 25, 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 25, 2026
KKothuri and others added 8 commits July 24, 2026 21:44
…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>
When a compressed-tensors WNA16 embedding is reused as a tied lm_head, apply()
previously dequantized the entire packed table into a dense weight every call
and ran F.linear. Reuse vLLM's existing WNA16 Linear kernel (Marlin/Machete)
for the logits matmul instead: the embedding's packed weight is already in the
compressed-tensors layout those kernels expect, so process_weights_after_loading
sets up the fused dequant-GEMM (only for tied embeddings, flagged in
tie_weights) while keeping a gather-format copy for the input-lookup path. Falls
back to a full-table dequant (no index tensor) + F.linear when no fused kernel
is available.

Numerically matches the dequant reference (max rel diff 0.025%, fp16). Perf on
an RTX 5080 (sm120), Qwen3-scale vocab 151936 x 1024 W4-g64, us/call:

  M=1    current 1329  no-gather 1317  fused(Marlin)  240   -> 5.5x
  M=32   current 1703  no-gather 2395  fused(Marlin)  619   -> 2.8x
  M=256  current 3247  no-gather 2460  fused(Marlin) 2266   -> 1.4x

Biggest win at decode (M=1), the common serving case for logits.

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
@KKothuri
KKothuri force-pushed the embed-quant-fused-logits branch from 8d45037 to 09f35c2 Compare July 25, 2026 05:25
@mergify mergify Bot removed the needs-rebase label Jul 25, 2026
@sgurwinderr

Copy link
Copy Markdown

Drive-by data from an unrelated Turing (sm_75) experiment on the same tied-lm_head path — two things that may be useful here.

1. M=1 is purely bandwidth-bound. On an RTX 2060 Mobile (250 GB/s achievable, 336 GB/s spec) the fp16 logits GEMV for Qwen2.5-Coder-3B (151936 × 2048, tied) runs 622 MB / 2.51 ms = 248 GB/s, 99% of roofline. Quantizing the weight to int8 gave 2.88 → 1.42 ms = 2.03x, i.e. 98% of the half-bandwidth floor. So at M=1 the only lever is bytes moved, and 2x is the ceiling for a W8 path.

That makes the 8.7x in your W8-g128 M=1 row look like it's mostly removing a redundant pass (dequantize-to-dense then read) rather than kernel speed. Worth saying explicitly — it sets the expectation that the fused path is now at the memory floor.

2. Max-relative-diff understates argmax movement, and there's a nearly-free fix. Greedy decoding only cares about argmax, and one flip changes every later token. On 2632 captured decode states, int8 per-row symmetric gave 99.658% top-1 agreement (~1 flip per 292 tokens). Caveat: at 646 states I measured 646/646 and nearly called it lossless — the rate only shows up at a few thousand states.

Cheap fix, applicable to any weight-only lm_head scheme: take top-K from the quantized logits, recompute those K rows exactly, scatter back. 32 KB at K=8 next to a 311 MB weight — no measurable throughput change.

  • int8 alone: 99.658%
  • int8 + exact top-8 rescoring: 99.848%

Residual disagreements are all cases where the fp16 reference's own top1–top2 margin is ~0. K > 4 buys nothing.

3. Methodology note: a fp16-vs-fp16 control (same build, seed, max_num_seqs=1, temp 0) diverged from itself on 2/6 prompts, 2 flips / 2625 tokens. Bit-exact token comparison isn't a usable gate for long generations.

Context: this came from adding runtime int8 quantization of an unquantized tied lm_head on 6 GB Turing (Marlin needs ≥ 80), +10.2% end-to-end decode. Not proposing it upstream — happy to share the microbenchmark or argmax-agreement harness if useful.

@mergify mergify Bot added the kimi label Jul 27, 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

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 glm gpt-oss Related to GPT-OSS models kimi llama Related to Llama models 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.

2 participants