-
Notifications
You must be signed in to change notification settings - Fork 518
[OMNIML-4730] Support quantized nn.Embedding #1495
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6c6125d
bff26f8
7cb6358
43793ba
b728da5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Quantized Embedding.""" | ||
|
|
||
| import contextlib | ||
|
|
||
| import torch | ||
| import torch.nn as nn | ||
|
|
||
| from ...tensor_quant import QUANT_DESC_8BIT_PER_TENSOR | ||
| from ...utils import is_torch_export_mode | ||
| from .quant_module import QuantModule, QuantModuleRegistry | ||
| from .tensor_quantizer import HardDisabledTensorQuantizer, SequentialQuantizer, TensorQuantizer | ||
|
|
||
| __all__ = ["QuantEmbedding"] | ||
|
|
||
|
|
||
| @QuantModuleRegistry.register({nn.Embedding: "nn.Embedding"}) | ||
| class _QuantEmbedding(QuantModule): | ||
| """Quantized version of ``nn.Embedding``. | ||
|
|
||
| The literal input to ``nn.Embedding`` is integer indices, which cannot be | ||
| fake-quantized. The ``input_quantizer`` attribute is kept (for symmetry with | ||
| other quant modules and for introspection by export/calibration code) but is | ||
| a :class:`HardDisabledTensorQuantizer` — direct ``enable*()`` calls raise and | ||
| wildcard configs are silently absorbed-then-force-disabled. Only the embedding | ||
| table (weight) and the lookup output (an activation feeding downstream layers) | ||
| are quantizable. | ||
|
|
||
| Quantizer roles: | ||
| - ``weight_quantizer``: quantizes the embedding table (``self.weight``). | ||
| - ``input_quantizer``: permanently disabled placeholder | ||
| (:class:`HardDisabledTensorQuantizer`). | ||
| - ``output_quantizer``: optional activation quantizer for the lookup output, | ||
| disabled by default. | ||
| """ | ||
|
|
||
| weight_quantizer: TensorQuantizer | SequentialQuantizer | ||
| input_quantizer: HardDisabledTensorQuantizer | ||
| output_quantizer: TensorQuantizer | ||
| _enable_weight_quantization: bool | ||
| default_quant_desc_weight = QUANT_DESC_8BIT_PER_TENSOR | ||
| default_quant_desc_input = QUANT_DESC_8BIT_PER_TENSOR | ||
| default_quant_desc_output = QUANT_DESC_8BIT_PER_TENSOR | ||
|
|
||
| @contextlib.contextmanager | ||
| def quantize_weight(self): | ||
| """Context in which ``self.weight`` is quantized via the dynamic attribute.""" | ||
| self._enable_weight_quantization = True | ||
| try: | ||
| yield | ||
| finally: | ||
| self._enable_weight_quantization = False | ||
|
|
||
| @staticmethod | ||
| def _get_quantized_weight(module: "_QuantEmbedding", weight: torch.Tensor) -> torch.Tensor: | ||
| if module._enable_weight_quantization or is_torch_export_mode(): | ||
| return module.weight_quantizer(weight) | ||
| return weight | ||
|
|
||
| def _setup(self): | ||
| """Register weight, (locked) input, and output quantizers.""" | ||
| self._register_temp_attribute( | ||
| "weight_quantizer", TensorQuantizer(self.default_quant_desc_weight) | ||
| ) | ||
| # Build the input quantizer disabled. HardDisabledTensorQuantizer's mutators raise, | ||
| # so we disable it once at construction via direct attribute assignment. | ||
| input_quantizer = HardDisabledTensorQuantizer(self.default_quant_desc_input) | ||
| input_quantizer._disabled = True | ||
| self._register_temp_attribute("input_quantizer", input_quantizer) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could you help me understand: why we have an input_quantizer here? Isn't this a weight quantizer only?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right that only the weight is actually quantized. The slot exists so the wildcard-recipe pattern keeps working without special-casing
|
||
| self._register_temp_attribute( | ||
| "output_quantizer", TensorQuantizer(self.default_quant_desc_output) | ||
| ) | ||
| self.output_quantizer.disable() | ||
| self._register_temp_attribute("_enable_weight_quantization", False) | ||
| self._register_dynamic_attribute("weight", self._get_quantized_weight) | ||
|
|
||
| def forward(self, input, *args, **kwargs): | ||
| """Quantize the embedding table, look up, then optionally quantize the output. | ||
|
|
||
| ``input_quantizer`` is intentionally never invoked — embedding inputs are | ||
| integer indices. ``HardDisabledTensorQuantizer.set_from_attribute_config`` | ||
| keeps that quantizer disabled regardless of what configs target it, so a | ||
| runtime check in this hot path is unnecessary. | ||
| """ | ||
| if is_torch_export_mode(): | ||
| # quantize_weight()'s attribute write is not allowed under torch.export; | ||
| # weight quantization is still applied inline via _get_quantized_weight's | ||
| # is_torch_export_mode() branch. Apply output_quantizer in this path too | ||
| # so users who opt into output activation quantization don't silently | ||
| # lose it during export — matches QuantInputBase.forward's behavior. | ||
| output = super().forward(input, *args, **kwargs) | ||
| else: | ||
| with self.quantize_weight(): | ||
| output = super().forward(input, *args, **kwargs) | ||
| return self.output_quantizer(output) | ||
|
|
||
|
|
||
| # Public alias consistent with quant_linear / quant_conv naming. | ||
| QuantEmbedding = _QuantEmbedding | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -297,6 +297,11 @@ def is_quantized_linear(module): | |
| """Check if a module is a quantized linear module.""" | ||
| from ..nn import QuantModule, TensorQuantizer | ||
|
|
||
| # Embedding has a 2D weight but is not a GEMM op, so calibration passes that operate | ||
| # on linear activations (AWQ, SmoothQuant, SVDQuant) must skip it. | ||
| if isinstance(module, nn.Embedding): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need this?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without it, That would route the embedding into the linear-only calibration passes that consume The early-return keeps |
||
| return False | ||
|
|
||
| return ( | ||
| isinstance(module, QuantModule) | ||
| and isinstance(getattr(module, "input_quantizer", None), TensorQuantizer) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Export-path tests for QuantEmbedding (CUDA-only). | ||
|
|
||
| These tests drive ``_process_quantized_modules`` end-to-end. The export weight-packing | ||
| path bottoms out in ``torch.cuda.empty_cache()``, which raises on systems without an | ||
| NVIDIA driver, so the suite lives under ``tests/gpu/`` rather than ``tests/unit/``. | ||
| """ | ||
|
|
||
| import pytest | ||
| import torch | ||
| import torch.nn as nn | ||
|
|
||
| import modelopt.torch.quantization as mtq | ||
| from modelopt.torch.export.unified_export_hf import _process_quantized_modules | ||
| from modelopt.torch.quantization.utils import quantizer_attr_names | ||
|
|
||
| VOCAB_SIZE = 16 | ||
| EMBED_DIM = 32 # multiple of the NVFP4 block size (16) so export packs cleanly | ||
|
|
||
|
|
||
| def _embedding_nvfp4_cfg() -> dict: | ||
| """Stock-NVFP4-style cfg that opts the embedding's weight quantizer in.""" | ||
| nvfp4 = { | ||
| "num_bits": (2, 1), | ||
| "block_sizes": {-1: 16, "type": "dynamic", "scale_bits": (4, 3)}, | ||
| } | ||
| return { | ||
| "quant_cfg": [ | ||
| {"quantizer_name": "*", "enable": False}, | ||
| { | ||
| "parent_class": "nn.Embedding", | ||
| "quantizer_name": "*weight_quantizer", | ||
| "cfg": dict(nvfp4), | ||
| }, | ||
| ], | ||
| "algorithm": "max", | ||
| } | ||
|
|
||
|
|
||
| class _EmbeddingOnly(nn.Module): | ||
| """Single-embedding wrapper exposing forward + named_modules iteration.""" | ||
|
|
||
| def __init__(self): | ||
| """Build the lone embedding submodule.""" | ||
| super().__init__() | ||
| self.embedding = nn.Embedding(VOCAB_SIZE, EMBED_DIM) | ||
|
|
||
| def forward(self, ids): | ||
| """Look up embeddings for the given token IDs.""" | ||
| return self.embedding(ids) | ||
|
|
||
|
|
||
| class _TiedEmbeddingLM(nn.Module): | ||
| """Embedding + Linear lm_head with tied weights (lm_head.weight is embedding.weight).""" | ||
|
|
||
| def __init__(self): | ||
| """Build embedding + lm_head and tie their weight Parameters.""" | ||
| super().__init__() | ||
| self.embedding = nn.Embedding(VOCAB_SIZE, EMBED_DIM) | ||
| self.lm_head = nn.Linear(EMBED_DIM, VOCAB_SIZE, bias=False) | ||
| self.lm_head.weight = self.embedding.weight # Python-level tie | ||
|
|
||
| def forward(self, ids): | ||
| """Embed then project to vocab logits with the tied weight.""" | ||
| return self.lm_head(self.embedding(ids)) | ||
|
|
||
|
|
||
| class TestQuantEmbeddingExport: | ||
| """Export-path coverage: weight packing and tied-weight guard.""" | ||
|
|
||
| def test_quantized_weight_is_packed_and_scales_registered(self): | ||
| """End-to-end: _process_quantized_modules packs the embedding weight and | ||
| registers ``weight_scale`` + ``weight_scale_2`` buffers.""" | ||
| model = _EmbeddingOnly() | ||
| model = mtq.quantize( | ||
| model, _embedding_nvfp4_cfg(), lambda m: m(torch.randint(0, VOCAB_SIZE, (2, 4))) | ||
| ) | ||
| _process_quantized_modules(model, dtype=torch.float16) | ||
|
|
||
| attrs = quantizer_attr_names("weight") | ||
| assert model.embedding.weight.dtype == torch.uint8 | ||
| assert hasattr(model.embedding, attrs.weight_scale) | ||
| assert hasattr(model.embedding, attrs.weight_scale_2) | ||
| # input_scale is not registered (input_quantizer is permanently disabled). | ||
| assert not hasattr(model.embedding, attrs.input_scale) | ||
|
|
||
| def test_tied_embedding_export_skips_packing(self): | ||
| """When the embedding weight is shared with lm_head, packing is skipped | ||
| with a warning so the tie survives the export.""" | ||
| model = _TiedEmbeddingLM() | ||
| assert model.lm_head.weight is model.embedding.weight # sanity | ||
|
|
||
| model = mtq.quantize( | ||
| model, _embedding_nvfp4_cfg(), lambda m: m(torch.randint(0, VOCAB_SIZE, (2, 4))) | ||
| ) | ||
| orig_dtype = model.embedding.weight.dtype | ||
| with pytest.warns(UserWarning, match="tied"): | ||
| _process_quantized_modules(model, dtype=torch.float16) | ||
|
|
||
| # Weight Parameter unchanged (not packed to uint8) and still tied. | ||
| assert model.embedding.weight.dtype == orig_dtype | ||
| assert model.lm_head.weight is model.embedding.weight |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hard disabled TensorQuantizer might not be preserved during restore;
Could you add a unittest which do quantize -> save -> restore?
See:
Model-Optimizer/tests/unit/torch/quantization/test_quantize_cpu.py
Line 143 in 9ced018
you would need to add the restore support here -
Model-Optimizer/modelopt/torch/quantization/conversion.py
Line 134 in 9ced018
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if you have bandwidth would be great if you could add a registry for TensorQuantizers and avoid manual conditional branching in
restoremethodThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
TestQuantEmbeddingSaveRestorecovering the fullquantize → modelopt_state → restore → load_state_dictround-trip — asserts the restoredinput_quantizeris still aHardDisabledTensorQuantizer,enable*()still raises, and forward output matches.No
conversion.py:134-style branch was needed:_QuantEmbedding._setup()always creates theHardDisabledTensorQuantizer, andreplace_quant_moduleruns beforerestore_quantizer_state, so the right subclass is already in place by the time properties are restored.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I will do this as a part of a separate PR.