Source code for embedl_deploy._internal.core.quantize.config

# Copyright (C) 2026 Embedl AB

"""Quantization configuration.

:class:`~embedl_deploy._internal.core.quantize.config.QuantConfig` bundles the
settings that control how activations and weights are quantized — precision,
symmetric vs. asymmetric range, and per-channel granularity — separately for
each tensor class.

The config is passed to ``configure()``, which copies the relevant sub-config
onto each activation stub and weight fake-quantize module found on the fused
modules.

Example::

    config = QuantConfig(
        activation=TensorQuantConfig(symmetric=True),
        weight=TensorQuantConfig(symmetric=True, per_channel=True),
    )
    configure(fused_model, config)
"""

import enum
import re
from dataclasses import dataclass, field
from typing import TypeAlias

from torch import nn
from torch.ao.quantization.observer import (
    HistogramObserver,
    MinMaxObserver,
    MovingAverageMinMaxObserver,
)

#: Union of ``torch.ao`` observer types used for activation calibration.
Observer: TypeAlias = (
    MinMaxObserver | MovingAverageMinMaxObserver | HistogramObserver
)


[docs] class Precision(enum.Enum): """A quantized tensor format: signed bit width, negative for float. The magnitude of ``value`` is the bit width and the sign encodes the format (negative = floating point), so ``INT8`` and ``FP8`` share ``n_bits == 8``. Width and format are read only through the :attr:`n_bits` / :attr:`is_float` properties — a plain ``Enum`` defines no ordering, so comparisons like ``FP8 < INT4`` raise ``TypeError`` instead of silently comparing the underlying ints. Extend this enum to add a format (e.g. ``FP16 = -16``) when demand appears; do not introduce a parallel bit-width scheme. """ #: No quantized domain; format-agnostic (not pinned to fp16/fp32). UNQUANTIZED = 0 #: 8-bit floating point. FP8 = -8 #: 4-bit signed integer. INT4 = 4 #: 8-bit signed integer. INT8 = 8 #: 16-bit signed integer. INT16 = 16 @property def n_bits(self) -> int: """Bit width of the format (``INT8`` and ``FP8`` both report 8).""" return abs(self.value) @property def is_float(self) -> bool: """``True`` for floating-point formats (encoded as a negative value).""" return self.value < 0
[docs] class CalibrationMethod(enum.Enum): """Algorithm for collecting activation statistics during PTQ calibration. Each member's value is the ``torch.ao`` observer class instantiated and attached to a :class:`~embedl_deploy._internal.core.quantize.stubs.QuantStub` on the first calibration forward pass. """ #: Track the global minimum and maximum (default, fastest). MINMAX = MinMaxObserver #: Exponential moving average of min/max — less sensitive to outliers. MOVING_AVERAGE_MINMAX = MovingAverageMinMaxObserver #: Build a histogram and search for the optimal quantization range. HISTOGRAM = HistogramObserver
[docs] @dataclass(frozen=True) class TensorQuantConfig: """Quantization settings for a single tensor class (activation or weight). :param precision: The quantized format as a :class:`~embedl_deploy._internal.core.quantize.config.Precision` member. Must be a quantized integer format (``INT4``/``INT8``); ``UNQUANTIZED`` and floating-point formats are rejected at construction. :param symmetric: When ``True`` the quantized range is centered on zero (``zero_point = 0``). When ``False`` an asymmetric range is used, allowing better coverage of distributions that are not centered on zero. :param per_channel: When ``True`` *and* when used for weight quantization, a separate scale/zero-point is computed along the output-channel axis (axis 0). Defaults to ``True`` for production quantization of Conv/Linear weights. """ #: Quantized format (integer width; floating-point not yet supported). precision: Precision = Precision.INT8 #: Symmetric (zero_point = 0) or asymmetric range. symmetric: bool = True #: Per-channel quantization (weight only). per_channel: bool = True #: Calibration algorithm used by #: :class:`~embedl_deploy._internal.core.quantize.stubs.QuantStub` to #: collect activation statistics. Only relevant for activation configs; #: weight quantization computes scale/zero-point on-the-fly. calibration_method: CalibrationMethod = CalibrationMethod.MINMAX def __post_init__(self) -> None: if self.precision is Precision.UNQUANTIZED: raise ValueError( "precision must be a quantized format, got UNQUANTIZED" ) if self.precision.is_float: raise NotImplementedError( f"float precision {self.precision.name} is not yet supported" ) @property def _half_range(self) -> int: """Half the quantized integer range.""" half_range: int = 2 ** (self.precision.n_bits - 1) return half_range @property def quant_min(self) -> int: """Minimum representable integer value.""" if self.symmetric: return -self._half_range return 0 @property def quant_max(self) -> int: """Maximum representable integer value.""" if self.symmetric: return self._half_range - 1 return 2 * self._half_range - 1
[docs] @dataclass(frozen=True) class SmoothQuantConfig: """SmoothQuant migration settings. Controls the per-channel weight/activation redistribution applied by :func:`~embedl_deploy._internal.core.quantize.calibrate.calibrate_smooth_quant`. :param alpha: Migration strength in ``[0, 1]``. ``0`` keeps all difficulty on activations; ``1`` pushes it entirely to weights. """ #: Migration strength in ``[0, 1]``. alpha: float = 0.5 def __post_init__(self) -> None: if not 0.0 <= self.alpha <= 1.0: raise ValueError(f"alpha must be in [0, 1], got {self.alpha}")
SkipEntry: TypeAlias = type[nn.Module] | nn.Module | re.Pattern[str]
[docs] @dataclass(frozen=True) class ModulesToSkip: """Specifies which modules or module types to leave disabled during configure. Each set accepts three kinds of entries: - A **module type** (e.g. ``nn.Conv2d``) — matches any module of that type. - A **module instance** — matches that exact module. - A **compiled regex** (``re.Pattern[str]``) — matched via ``re.fullmatch`` against the original qualified name of each child module (set during recomposition from ``nn_module_stack``). """ #: Modules to leave stub quantization disabled for. stub: set[SkipEntry] = field(default_factory=set) #: Modules to leave weight quantization disabled for. weight: set[SkipEntry] = field(default_factory=set) #: Modules to leave smooth quantization disabled for. smooth: set[SkipEntry] = field(default_factory=set)
[docs] @dataclass(frozen=True) class QuantConfig: """Top-level quantization configuration. Bundles separate :class:`~embedl_deploy._internal.core.quantize.config.TensorQuantConfig` instances for activations and weights so that each can be configured independently. :param activation: Settings for activation (inter-layer) quantization. :param weight: Settings for weight quantization (Conv kernels only; bias is always left in floating-point). :param skip: Specifies which modules or module types to leave disabled during configure. See :class:`~embedl_deploy._internal.core.quantize.config.ModulesToSkip`. """ #: Settings for activation quantization. activation: TensorQuantConfig = field(default_factory=TensorQuantConfig) #: Settings for weight quantization. weight: TensorQuantConfig = field(default_factory=TensorQuantConfig) #: SmoothQuant settings applied during calibration. smooth_quant: SmoothQuantConfig = field(default_factory=SmoothQuantConfig) #: Modules or module types to leave disabled during configure. skip: ModulesToSkip = field(default_factory=ModulesToSkip)