Source code for embedl_deploy._internal.lattice.modules.quant

# Copyright (C) 2026 Embedl AB

"""Lattice-specific activation quantization module."""

from collections.abc import Callable
from typing import Protocol, cast

import torch
from torch import nn

from embedl_deploy._internal.core.quantize.config import (
    CalibrationMethod,
    Precision,
)
from embedl_deploy._internal.core.quantize.stubs import QuantStub


class _SymbolicValue(Protocol):
    """Protocol for symbolic values used by ONNX export graph builders."""

    def setType(self, tensor_type: object) -> None:  # noqa: N802
        """Set tensor type metadata on the symbolic value."""
        ...

    def type(self) -> object:
        """Return symbolic tensor type metadata."""
        ...


class _SymbolicGraph(Protocol):
    """Protocol for ONNX symbolic graph builders used by ``symbolic``."""

    def op(
        self,
        name: str,
        *args: object,
        **kwargs: object,
    ) -> _SymbolicValue:
        """Create a symbolic operation node and return its output value."""
        ...


class _LatticeQuantFunction(torch.autograd.Function):
    """Autograd bridge for runtime fake-quant math and ONNX export.

    The ONNX node has 8 tensor inputs ``(x + 7 params)``. We also pass
    duplicated Python scalars to ``symbolic`` so the same values can be emitted
    as ONNX attributes (``*_f`` and ``*_i``), matching the backend contract.

    In ``backward``, autograd expects one returned entry per ``forward`` input.
    Only ``x`` is differentiable here, so the implementation returns
    ``grad_output`` for ``x`` and ``None`` for every parameter/attribute input.
    """

    @staticmethod
    def forward(  # noqa: PLR0913
        ctx: object,
        x: torch.Tensor,
        min_rng: torch.Tensor,
        max_rng: torch.Tensor,
        num_bits: torch.Tensor,
        resolution: torch.Tensor,
        q_step_size: torch.Tensor,
        is_lsq: torch.Tensor,
        is_signed: torch.Tensor,
        min_rng_f: float,
        max_rng_f: float,
        num_bits_i: int,
        resolution_f: float,
        q_step_size_f: float,
        is_signed_b: bool,
    ) -> torch.Tensor:
        """Run runtime fake-quant math while carrying export-only args.

        The scalar duplicates are needed only by ``symbolic`` during ONNX
        export. They are intentionally ignored in eager execution.
        """
        del (
            ctx,
            num_bits,
            resolution,
            is_lsq,
            is_signed,
            min_rng_f,
            max_rng_f,
            num_bits_i,
            resolution_f,
            q_step_size_f,
            is_signed_b,
        )
        q = torch.round(x / q_step_size)
        q = torch.clamp(q, min=min_rng, max=max_rng)
        return q * q_step_size

    @staticmethod
    def backward(
        ctx: object,
        *grad_outputs: object,
    ) -> object:
        del ctx
        grad_output = cast(torch.Tensor, grad_outputs[0])
        # Straight-through estimator for QAT-style gradient flow.
        return (
            grad_output,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        )

    @staticmethod
    def symbolic(  # noqa: PLR0913
        g: _SymbolicGraph,
        x: _SymbolicValue,
        min_rng: _SymbolicValue,
        max_rng: _SymbolicValue,
        num_bits: _SymbolicValue,
        resolution: _SymbolicValue,
        q_step_size: _SymbolicValue,
        is_lsq: _SymbolicValue,
        is_signed: _SymbolicValue,
        min_rng_f: float,
        max_rng_f: float,
        num_bits_i: int,
        resolution_f: float,
        q_step_size_f: float,
        is_signed_b: bool,
    ) -> _SymbolicValue:
        """Emit a single contrib op with 8 tensor inputs and scalar attrs.

        Input count and op count are different concepts: this emits one
        ``ai.onnx.contrib::LatticeQuant`` node that consumes 8 inputs. The
        extra scalar arguments become ONNX attributes, not extra inputs.
        """
        output = g.op(
            "ai.onnx.contrib::LatticeQuant",
            x,
            min_rng,
            max_rng,
            num_bits,
            resolution,
            q_step_size,
            is_lsq,
            is_signed,
            is_lsq_i=1,
            is_signed_i=int(is_signed_b),
            max_rng_f=max_rng_f,
            min_rng_f=min_rng_f,
            num_bits_i=num_bits_i,
            q_step_size_f=q_step_size_f,
            resolution_f=resolution_f,
        )
        output.setType(x.type())
        return output


[docs] class LatticeQuant(QuantStub): """Quantize activations and keep Lattice export identity metadata. Extends ``QuantStub`` with two Lattice-specific capabilities: - Tracks per-channel calibration maxima from observed activations. - Stores stable identity strings used when remapping metadata to ONNX node names during export. :param consumers: Set of modules that consume this quantizer's output. :param precision: Quantized format. :param symmetric: Whether quantization is symmetric. :param calibration_method: Activation calibration algorithm. :param channel_axis: Channel axis used when tracking per-channel calibration maxima. :param fixed_calibration: Optional fixed ``(scale, zero_point)`` pair. :param canonical_name: Optional canonical metadata key for this quantizer. :param producer_qualified_name: Optional qualified name of the producer module. """ def __init__( self, consumers: set[nn.Module], precision: Precision = Precision.INT8, symmetric: bool = True, calibration_method: CalibrationMethod = CalibrationMethod.MINMAX, *, channel_axis: int = 1, fixed_calibration: tuple[float, int] | None = None, canonical_name: str | None = None, producer_qualified_name: str | None = None, ) -> None: super().__init__( consumers, precision=precision, symmetric=symmetric, calibration_method=calibration_method, fixed_calibration=fixed_calibration, ) self.channel_axis = channel_axis self.canonical_name = canonical_name self.producer_qualified_name = producer_qualified_name self._calibration_max: torch.Tensor | None = None self._calibration_min_scalar: torch.Tensor | None = None self._calibration_max_scalar: torch.Tensor | None = None num_bits = self.config.precision.n_bits self.register_buffer( "num_bits", torch.tensor(num_bits, dtype=torch.int64), ) self.register_buffer( "resolution", torch.tensor(2.0 ** float(num_bits), dtype=torch.float32), ) self.register_buffer( "q_step_size", torch.tensor(1.0, dtype=torch.float32), ) self.register_buffer( "min_rng", torch.tensor(float(self.config.quant_min), dtype=torch.float32), ) self.register_buffer( "max_rng", torch.tensor(float(self.config.quant_max), dtype=torch.float32), ) self.register_buffer( "is_lsq", torch.tensor([True], dtype=torch.bool), ) self.register_buffer( "is_signed", torch.tensor(self.config.quant_min < 0, dtype=torch.bool), ) @property def calibration_max(self) -> torch.Tensor | None: """Return tracked per-channel calibration maxima, if available.""" if self._calibration_max is None: return None return self._calibration_max.clone()
[docs] def reset_calibration_max(self) -> None: """Reset tracked per-channel calibration maxima.""" self._calibration_max = None
[docs] def set_export_identity( self, canonical_name: str, producer_qualified_name: str | None = None, ) -> None: """Set canonical identity strings used during metadata export. :param canonical_name: Canonical key used for export metadata maps. :param producer_qualified_name: Optional fully-qualified producer module name. """ self.canonical_name = canonical_name self.producer_qualified_name = producer_qualified_name
def _observe_calibration_max(self, x: torch.Tensor) -> None: """Update per-channel calibration maxima from activation tensor `x`.""" x_abs = x.detach().abs() if x_abs.ndim <= 1: current = x_abs.amax().reshape(1) else: if self.channel_axis < 0: axis = x_abs.ndim + self.channel_axis else: axis = self.channel_axis if axis < 0 or axis >= x_abs.ndim: raise ValueError( "channel_axis is out of bounds for activation tensor" ) reduce_dims = tuple(i for i in range(x_abs.ndim) if i != axis) current = x_abs.amax(dim=reduce_dims) if self._calibration_max is None: self._calibration_max = current else: self._calibration_max = torch.max(self._calibration_max, current) def _use_native_calibration(self) -> bool: """Return whether calibration should bypass ``torch.ao`` observers.""" return self.config.precision.n_bits > 8 def _observe_calibration_range(self, x: torch.Tensor) -> None: """Track scalar min/max needed to compute fake-quant parameters.""" x_detached = x.detach() current_min = x_detached.amin() current_max = x_detached.amax() if self._calibration_min_scalar is None: self._calibration_min_scalar = current_min else: self._calibration_min_scalar = torch.minimum( self._calibration_min_scalar, current_min, ) if self._calibration_max_scalar is None: self._calibration_max_scalar = current_max else: self._calibration_max_scalar = torch.maximum( self._calibration_max_scalar, current_max, ) def _compute_native_parameters(self) -> tuple[torch.Tensor, torch.Tensor]: """Derive scale and zero-point without ``torch.ao`` observers.""" if ( self._calibration_min_scalar is None or self._calibration_max_scalar is None ): raise RuntimeError( "QuantStub did not observe valid data during calibration" ) min_val = self._calibration_min_scalar max_val = self._calibration_max_scalar if ( not torch.isfinite(min_val).item() or not torch.isfinite(max_val).item() ): raise RuntimeError( "QuantStub did not observe valid data during calibration" ) q_min = self.config.quant_min q_max = self.config.quant_max if self.config.symmetric: max_abs = torch.max(min_val.abs(), max_val.abs()) max_abs = torch.clamp(max_abs, min=1e-8) scale = max_abs / ((q_max - q_min) / 2) zero_point = torch.zeros_like(scale, dtype=torch.int32) return scale, zero_point zeros = torch.zeros_like(min_val) min_val = torch.minimum(min_val, zeros) max_val = torch.maximum(max_val, zeros) max_val = torch.maximum(max_val, min_val + 1e-8) scale = (max_val - min_val) / (q_max - q_min) zero_point = ( torch.round(-min_val / scale).clamp(q_min, q_max).to(torch.int32) ) return scale, zero_point
[docs] def compute_parameters(self) -> None: """Compute quant parameters and synchronize Lattice export buffers. :raises ValueError: If the computed zero-point is nonzero. Lattice hardware does not support asymmetric quantization with a nonzero zero-point; the eager and ONNX export paths both assume zero-point is zero. """ if self._use_native_calibration(): scale, zero_point = self._compute_native_parameters() self.scale.copy_(scale.squeeze()) self.zero_point.copy_(zero_point.squeeze()) else: super().compute_parameters() if self.zero_point.item() != 0: raise ValueError( "LatticeQuant does not support nonzero zero_point. " "Use symmetric=True (signed range) for activations that " "can be negative (e.g. post-Conv, post-LeakyReLU), or " "symmetric=False (unsigned range) only for non-negative " "activations (e.g. post-ReLU)." ) # Note: symmetric=False (unsigned) is valid for non-negative # activations at any bit width, including INT16. Post-ReLU data # has min ≈ 0, so the asymmetric calibration path naturally # produces zero_point ≈ 0 and passes the check above. The # STATE_PREP patterns ensure symmetric is only set to False when # the fused module's activation guarantees non-negative output. scale = torch.as_tensor(self.scale, dtype=torch.float32) with torch.no_grad(): q_step = cast(torch.Tensor, self.q_step_size) q_step[...] = scale
def _forward_eager_quant(self, x: torch.Tensor) -> torch.Tensor: """Apply Lattice fake-quant math in eager mode (no ONNX export).""" q_step_size = cast(torch.Tensor, self.q_step_size) min_rng = cast(torch.Tensor, self.min_rng) max_rng = cast(torch.Tensor, self.max_rng) q = torch.round(x / q_step_size) q = torch.clamp(q, min=min_rng, max=max_rng) return q * q_step_size def _forward_lattice_quant(self, x: torch.Tensor) -> torch.Tensor: """Apply Lattice quantization op using export-compatible parameters. This path goes through ``_LatticeQuantFunction`` specifically to trigger ONNX export's ``symbolic()`` method. Do not use it for ordinary eager execution. """ apply_fn = cast( Callable[..., torch.Tensor], _LatticeQuantFunction.apply ) result = apply_fn( x, cast(torch.Tensor, self.min_rng), cast(torch.Tensor, self.max_rng), cast(torch.Tensor, self.num_bits), cast(torch.Tensor, self.resolution), cast(torch.Tensor, self.q_step_size), cast(torch.Tensor, self.is_lsq), cast(torch.Tensor, self.is_signed), float(cast(torch.Tensor, self.min_rng)), float(cast(torch.Tensor, self.max_rng)), int(cast(torch.Tensor, self.num_bits)), float(cast(torch.Tensor, self.resolution)), float(cast(torch.Tensor, self.q_step_size)), bool(cast(torch.Tensor, self.is_signed)), ) return result
[docs] def forward(self, x: torch.Tensor) -> torch.Tensor: use_native_calibration = self._use_native_calibration() if self.calibrating: self._observe_calibration_max(x) if use_native_calibration: self._observe_calibration_range(x) if not self.enabled: if use_native_calibration: return x return super().forward(x) # Keep current Embedl runtime behavior outside ONNX export while # emitting ai.onnx.contrib::LatticeQuant when exporting. if torch.onnx.is_in_onnx_export(): return self._forward_lattice_quant(x) if use_native_calibration: return self._forward_eager_quant(x) return super().forward(x)