Skip to content

[Feature] Add layerwise hidden-state QAD for quantized block reconstruction - #3029

Open
BeichenHuang wants to merge 2 commits into
vllm-project:mainfrom
BeichenHuang:layerwise-qad
Open

[Feature] Add layerwise hidden-state QAD for quantized block reconstruction#3029
BeichenHuang wants to merge 2 commits into
vllm-project:mainfrom
BeichenHuang:layerwise-qad

Conversation

@BeichenHuang

Copy link
Copy Markdown

SUMMARY:

This PR introduces LayerwiseQADModifier, an experimental block-local
quantization-aware distillation method for decoder-only language models.

Motivation

GPTQ provides a strong quantized initialization, but independently reconstructed
blocks may not remain optimal under accumulated quantization error. Conventional
end-to-end QAD requires complete teacher and student forwards and a full student
backward graph.

Layerwise QAD instead optimizes one decoder block at a time inside LLM
Compressor's sequential calibration pipeline.

Method

For each decoder block:

  1. GPTQ initializes the quantized student block.
  2. The full-precision teacher block and fake-quantized student block receive the
    same input activation.
  3. The student minimizes token-masked hidden-state MSE against the teacher.
  4. Only quantized Linear master weights in the active block are updated.
  5. Quantization scales, zero points, the teacher, and all other student
    parameters remain frozen.
  6. The best held-out validation checkpoint is restored.
  7. The optimized quantized output is propagated to the next block.

This is layerwise hidden-state distillation / quantization-aware block
reconstruction, not end-to-end final-logit KL distillation.

Implementation

The PR adds:

  • LayerwiseQADModifier;
  • oneshot(distill_teacher=...) teacher-model wiring;
  • GPTQ + Layerwise QAD composition in the sequential pipeline;
  • deterministic train/validation splitting;
  • per-block validation-best checkpoint restoration;
  • early stopping with cumulative relative improvement;
  • gradient accumulation and clipping;
  • token-masked hidden-state MSE;
  • streaming teacher targets computed per microbatch;
  • GPTQ hook isolation during QAD forwards;
  • propagation of optimized quantized outputs between blocks;
  • a user-facing example and focused tests.

Layerwise QAD requires:

pipeline                        = sequential
propagate_error                 = true
sequential_targets_per_subgraph = 1

Memory behavior

Teacher targets are generated on demand and released after each microbatch, so
target memory does not scale with the total calibration dataset.

The current implementation still caches block inputs for the calibration
dataset. Chunked or disk-backed input replay is left as future work.

Validation

Validated on Llama 3.1 8B Instruct with NVFP4 W4A4 and a 512x2048 public-six
calibration dataset:

  • all 32 decoder blocks completed;
  • validation MSE improved for 31/32 blocks;
  • training completed on one H200 in 2:34:43;
  • peak GPU memory was approximately 7 GiB.

Three-seed downstream results:

Task BF16 GPTQ Public-Six QAD
GSM8K 86.63% 85.33% 84.95%
MMLU CoT 72.67% 71.12% 71.36%
MMLU-Pro 46.71% 44.65% 45.13%
IFEval 82.13% 81.41% 81.14%
MATH-500 48.20% 44.67% 45.33%
Five-Task Average 67.27% 65.44% 65.58%

Known limitations

  • The objective is local hidden-state MSE, which is not guaranteed to improve
    every downstream task.
  • Teacher and student decoder-block structures must match.
  • Quantization parameters are fixed rather than learned.
  • Block inputs are still cached for the complete calibration dataset.
  • Training is currently single-GPU.

TEST PLAN:

  • pytest -q tests/llmcompressor/modifiers/layerwise_qad/test_base.py
    • 14 tests passed.
  • Ruff lint and formatting checks passed for all changed Python files.
  • Tested GPTQ and Layerwise QAD on the same block.
  • Verified that QAD forwards do not accumulate additional GPTQ Hessian state.
  • Verified masked MSE, partial gradient accumulation, deterministic validation
    splitting, early stopping, and best-weight restoration.
  • Completed a full 32-block Llama 3.1 8B NVFP4 export and downstream evaluation.

beichen added 2 commits August 14, 2026 20:17
Implement GPTQ-initialized block-local hidden-state distillation with streaming teacher targets, validation-best restoration, and sequential error propagation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e29d330c-b320-4c9e-8e2b-17e2f355fe0e
Keep the upstream change focused on implementation, tests, and the user-facing example; design context will be included in the pull request description.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e29d330c-b320-4c9e-8e2b-17e2f355fe0e
@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to llm-compressor. Please add the ready label when the PR is ready for review.

Note: This is required to complete the testing suite, please only add the label once the PR is code complete and local testing has been performed.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4741b998-9659-47e8-86c8-0cd64a854393

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mergify

mergify Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

The quality checks have failed. Please run make style and make quality under
the root directory to adddress the lint failures. You will need to install the
dev optional install to get the required linting packages:
https://github.com/vllm-project/llm-compressor/blob/main/CONTRIBUTING.md

@mergify

mergify Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 1 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 Require one maintainer review 👀 reviews

🔴 Require one maintainer review

Waiting for any of

  • approved-reviews-by=HDCharles
  • approved-reviews-by=brian-dellabetta
  • approved-reviews-by=dsikka
  • approved-reviews-by=kylesayrs
  • approved-reviews-by=yiliu30
This rule is failing.

All PRs must have at least one approving review from a maintainer before merging.

  • any of:
    • approved-reviews-by=HDCharles
    • approved-reviews-by=brian-dellabetta
    • approved-reviews-by=dsikka
    • approved-reviews-by=kylesayrs
    • approved-reviews-by=yiliu30
  • #changes-requested-reviews-by = 0

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces the LayerwiseQADModifier to perform block-local quantization-aware distillation (QAD) against a full-precision teacher model using hidden-state mean squared error. It adds support for loading and initializing a teacher model (distill_teacher) via the oneshot entrypoint, configures sequential pipeline propagation rules, and includes comprehensive unit tests. The review feedback highlights several robustness improvements for the new modifier, including handling fully-padded microbatches gracefully to avoid division-by-zero crashes, preserving namedtuple types during recursive tensor mapping, and implementing a fallback training mechanism when there are fewer than two calibration batches.

Comment on lines +85 to +88
denominator = mask.sum()
if denominator.item() == 0:
raise ValueError("Loss mask does not contain any valid tokens")
return (per_token_loss * mask).sum() / denominator

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.

high

If a microbatch consists entirely of padding tokens (which is highly likely when using small batch sizes like 1 or 2 on padded datasets), denominator.item() == 0 will be true. Raising a ValueError here will crash the entire calibration and quantization pipeline, which could be hours into a run.

Instead of crashing, we can gracefully return a zero loss that is still connected to the computation graph (e.g., prediction.sum() * 0.0). This ensures that the fully-masked batch contributes zero gradients and training continues safely.

Suggested change
denominator = mask.sum()
if denominator.item() == 0:
raise ValueError("Loss mask does not contain any valid tokens")
return (per_token_loss * mask).sum() / denominator
denominator = mask.sum()
if denominator.item() == 0:
return prediction.sum() * 0.0
return (per_token_loss * mask).sum() / denominator

Comment on lines +30 to +39
def _map_tensors(value: Any, transform):
if isinstance(value, torch.Tensor):
return transform(value)
if isinstance(value, tuple):
return tuple(_map_tensors(item, transform) for item in value)
if isinstance(value, list):
return [_map_tensors(item, transform) for item in value]
if isinstance(value, dict):
return {key: _map_tensors(item, transform) for key, item in value.items()}
return value

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.

medium

The recursive _map_tensors helper converts any tuple subclass (including namedtuple) into a plain tuple. If a model's block forward pass expects a namedtuple or custom tuple subclass as an input argument, this conversion will strip the named fields and custom attributes, potentially causing AttributeError or other runtime failures.

We should check if the tuple is a namedtuple (or custom subclass) and preserve its type during mapping.

Suggested change
def _map_tensors(value: Any, transform):
if isinstance(value, torch.Tensor):
return transform(value)
if isinstance(value, tuple):
return tuple(_map_tensors(item, transform) for item in value)
if isinstance(value, list):
return [_map_tensors(item, transform) for item in value]
if isinstance(value, dict):
return {key: _map_tensors(item, transform) for key, item in value.items()}
return value
def _map_tensors(value: Any, transform):
if isinstance(value, torch.Tensor):
return transform(value)
if isinstance(value, tuple):
if hasattr(value, "_fields"): # Preserve namedtuples
return type(value)(*(_map_tensors(item, transform) for item in value))
return tuple(_map_tensors(item, transform) for item in value)
if isinstance(value, list):
return [_map_tensors(item, transform) for item in value]
if isinstance(value, dict):
return {key: _map_tensors(item, transform) for key, item in value.items()}
return value

Comment on lines +309 to +361
train_indices, validation_indices = self._split_batch_indices(len(batches))
initial_train_loss = self._evaluate_indices(
student_block,
teacher_block,
batches,
train_indices,
device,
teacher_dtype,
)
initial_validation_loss = self._evaluate_indices(
student_block,
teacher_block,
batches,
validation_indices,
device,
teacher_dtype,
)
self._release_device_cache()
self._log_device_memory(name, "after initial evaluation")
optimizer = torch.optim.AdamW(
trainable,
lr=self.learning_rate,
weight_decay=self.weight_decay,
)
optimizer_steps, epochs_completed, best_validation_loss = (
self._train_with_validation(
student_block,
teacher_block,
batches,
optimizer,
trainable,
train_indices,
validation_indices,
device,
teacher_dtype,
)
)
final_train_loss = self._evaluate_indices(
student_block,
teacher_block,
batches,
train_indices,
device,
teacher_dtype,
)
final_validation_loss = self._evaluate_indices(
student_block,
teacher_block,
batches,
validation_indices,
device,
teacher_dtype,
)

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.

medium

If the calibration dataset is very small or the batch size is large, the total number of captured batches (len(batches)) might be less than 2. In this case, _split_batch_indices will raise a ValueError and crash the run.

Since LayerwiseQADModifier already implements a non-validation _train method, we can gracefully fallback to training on all batches without validation/early stopping when len(batches) < 2, rather than crashing.

        try:
            if len(batches) < 2:
                logger.warning(
                    f"Block `{name}` has only {len(batches)} calibration batch(es). "
                    "Skipping validation and training on all batches."
                )
                optimizer = torch.optim.AdamW(
                    trainable,
                    lr=self.learning_rate,
                    weight_decay=self.weight_decay,
                )
                optimizer_steps = self._train(
                    student_block,
                    teacher_block,
                    batches,
                    optimizer,
                    trainable,
                    device,
                    teacher_dtype,
                )
                epochs_completed = self.num_epochs
                best_validation_loss = float("nan")
                initial_train_loss = float("nan")
                final_train_loss = float("nan")
                initial_validation_loss = float("nan")
                final_validation_loss = float("nan")
            else:
                train_indices, validation_indices = self._split_batch_indices(len(batches))
                initial_train_loss = self._evaluate_indices(
                    student_block,
                    teacher_block,
                    batches,
                    train_indices,
                    device,
                    teacher_dtype,
                )
                initial_validation_loss = self._evaluate_indices(
                    student_block,
                    teacher_block,
                    batches,
                    validation_indices,
                    device,
                    teacher_dtype,
                )
                self._release_device_cache()
                self._log_device_memory(name, "after initial evaluation")
                optimizer = torch.optim.AdamW(
                    trainable,
                    lr=self.learning_rate,
                    weight_decay=self.weight_decay,
                )
                optimizer_steps, epochs_completed, best_validation_loss = (
                    self._train_with_validation(
                        student_block,
                        teacher_block,
                        batches,
                        optimizer,
                        trainable,
                        train_indices,
                        validation_indices,
                        device,
                        teacher_dtype,
                    )
                )
                final_train_loss = self._evaluate_indices(
                    student_block,
                    teacher_block,
                    batches,
                    train_indices,
                    device,
                    teacher_dtype,
                )
                final_validation_loss = self._evaluate_indices(
                    student_block,
                    teacher_block,
                    batches,
                    validation_indices,
                    device,
                    teacher_dtype,
                )


model = oneshot(
model="student-model-or-path",
distill_teacher="full-precision-teacher-model-or-path",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

don't think we need this

__all__ = ["SequentialPipeline"]


def _configure_modifier_pipeline(modifiers, dataset_args):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i'm unsure why these pipeline changes are necessary can you explain?

@@ -0,0 +1,53 @@
# Layerwise MSE QAD

`LayerwiseQADModifier` performs block-local quantization-aware distillation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

maybe just QAD modifier, this makes it sound like it works on one linear layer at a time, (ideally this would work on a whole subgraph forward/backward)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants