# Copyright (C) 2026 Embedl AB
"""High-level quantization entry points.
Convenience wrappers that chain configuration, precision-state setup, Q/DQ stub
insertion, and — for PTQ — calibration into single calls (the ``configure`` ->
``prepare_state`` -> ``prepare_stubs`` -> calibration flow).
"""
import copy
import re
import warnings
from collections.abc import Callable
from typing import Any
from torch import fx, nn
from embedl_deploy._internal.core.modules import FusedModule
from embedl_deploy._internal.core.plan import propagate_shapes
from embedl_deploy._internal.core.quantize.calibrate import (
calibrate_qdq,
calibrate_smooth_quant,
)
from embedl_deploy._internal.core.quantize.config import (
Precision,
QuantConfig,
SkipEntry,
)
from embedl_deploy._internal.core.quantize.prepare import (
prepare_state,
prepare_stubs,
)
from embedl_deploy._internal.core.quantize.stubs import QuantStub
from embedl_deploy._internal.core.quantize.utils import (
get_model_quants,
resolve_consumer,
)
def _no_skip_match(
mods: set[nn.Module],
skip_set: set[SkipEntry],
) -> bool:
"""Return ``True`` if no child of any module in `mods` matches `skip_set`."""
types = tuple(s for s in skip_set if isinstance(s, type))
instances = {s for s in skip_set if isinstance(s, nn.Module)}
patterns = [s for s in skip_set if isinstance(s, re.Pattern)]
for mod in mods:
for child in mod.children():
if child in instances:
return False
if types and isinstance(child, types):
return False
if patterns:
qname = getattr(child, "qualified_name", None)
if qname is not None and any(
p.fullmatch(qname) for p in patterns
):
return False
return True
def _configure_stub(
stub: QuantStub,
consumer: FusedModule,
config: QuantConfig,
skip_set: set[SkipEntry],
) -> None:
"""Configure a single stub and settle its consumer's precision state."""
consumer.precision_deferred.upstream_skip = False
stub.enabled = _no_skip_match(stub.consumers, skip_set)
if stub.enabled:
if not stub.fixed_parameters:
stub.config = copy.copy(config.activation)
if consumer.precision_synced.output:
consumer.output_precision = stub.config.precision
elif consumer.precision_synced.output:
consumer.output_precision = Precision.UNQUANTIZED
consumer.precision_deferred.upstream_skip = True
def configure(
model: fx.GraphModule,
config: QuantConfig,
) -> None:
"""Configure quantization and per-module precision state, in place.
Walks every quantizer collected from `model` and:
* Enables each
:class:`~embedl_deploy._internal.core.quantize.stubs.QuantStub` not
excluded by ``config.skip`` and copies ``config.activation`` onto it
(stubs with ``fixed_parameters`` keep their own config).
* Enables each ``weight_fake_quant`` not excluded by ``config.skip`` and
copies ``config.weight`` onto it.
* Enables each ``smooth_quant_observer`` not excluded by ``config.skip``
and copies ``config.smooth_quant`` onto it.
A module's stubs and weight form a lock-stepped set: when its weight is
synced (``precision_synced.weight``), a skip on either ``config.skip.stub``
or ``config.skip.weight`` demotes the whole set together, keeping it
uniform. A module with an independent weight skips each side on its own.
A synced-output module's ``output_precision`` is set to track its input
stubs — the stubs' precision when enabled, ``Precision.UNQUANTIZED`` when a
skip disables them. A module whose output is not synced
(``precision_synced.output`` ``False``) is left untouched, so an output it
set for itself in its constructor survives a skip.
When a skip demotes a synced output, ``precision_deferred.upstream_skip``
is set so the upstream sweep treats that output as an intentional user
decision and does not cascade demotion through it to downstream neighbors.
A structural fp output is not marked because the skip did not cause it.
Only module state is touched: no Q/DQ nodes are inserted and no other graph
surgery is run.
:param model:
A ``GraphModule`` produced by the fusion step. Modified in-place.
:param config:
The :class:`~embedl_deploy._internal.core.quantize.config.QuantConfig`
controlling precision and which module types to skip.
"""
mq = get_model_quants(model, include_enabled=True, include_disabled=True)
for stub in mq.stubs:
consumer = resolve_consumer(stub)
skip_set = set(config.skip.stub)
if consumer.precision_synced.weight:
skip_set |= config.skip.weight
_configure_stub(stub, consumer, config, skip_set)
for wfq in mq.weight:
consumer = resolve_consumer(wfq)
skip_set = set(config.skip.weight)
if consumer.precision_synced.weight:
skip_set |= config.skip.stub
wfq.enabled = _no_skip_match(wfq.consumers, skip_set)
if wfq.enabled:
wfq.config = copy.copy(config.weight)
for obs in mq.smooth:
obs.enabled = _no_skip_match(obs.consumers, config.skip.smooth)
if obs.enabled:
obs.config = copy.copy(config.smooth_quant)
[docs]
def quantize(
model: fx.GraphModule,
args: tuple[Any, ...],
config: QuantConfig | None = None,
*,
forward_loop: Callable[[fx.GraphModule], None] | None = None,
freeze_weights: bool = False,
) -> fx.GraphModule:
"""Configure, insert Q/DQ stubs, optimize, and optionally calibrate.
Chains :func:`~embedl_deploy._internal.core.quantize.main.configure` →
:func:`~embedl_deploy._internal.core.quantize.prepare.prepare_state` →
:func:`~embedl_deploy._internal.core.quantize.prepare.prepare_stubs`, then
— when `forward_loop` is given —
:func:`~embedl_deploy._internal.core.quantize.calibrate.calibrate_smooth_quant`
→ :func:`~embedl_deploy._internal.core.quantize.calibrate.calibrate_qdq`.
With a `forward_loop` this is the PTQ entry point: the stubs are calibrated
from representative data. Without one it is the QAT entry point: the stubs
are inserted and configured but left uncalibrated for a subsequent training
loop (call :func:`~embedl_deploy._internal.core.quantize.qat.prepare_qat`
next).
:param model:
A ``GraphModule`` produced by the fusion step.
:param args:
The arguments to use for shape propagation necessary to get tensor meta
data required for calibration.
:param config:
Optional :class:`~embedl_deploy._internal.core.quantize.config.QuantConfig`.
Defaults to 8-bit symmetric.
:param forward_loop:
Optional ``(model) -> None`` callable that runs representative data
through the model. The caller controls batch size, device placement,
and iteration count. When omitted, calibration is skipped (QAT).
:param freeze_weights:
When ``True``, weight scales are computed from the current weights
and stored as constant buffers after calibration. This is required
for ONNX/TensorRT export (PTQ workflow). Defaults to ``False`` to
preserve the original behavior (dynamic on-the-fly scale computation,
suitable for QAT). Call
:func:`~embedl_deploy._internal.core.quantize.main.freeze_weight_quantization`
before export once training is complete.
:returns:
The quantized ``GraphModule``, with calibrated stubs when a
`forward_loop` was supplied.
"""
propagate_shapes(model, args)
config = config or QuantConfig()
configure(model, config)
prepare_state(model)
prepare_stubs(model)
if forward_loop is not None:
calibrate_smooth_quant(model, forward_loop)
calibrate_qdq(model, forward_loop)
if freeze_weights:
freeze_weight_quantization(model)
return model
[docs]
def freeze_weight_quantization(model: fx.GraphModule) -> None:
"""Freeze all ``WeightFakeQuantize`` scale/zero_point buffers.
After calibration (or QAT training) the weights are fixed for export, so we
compute the scale once and store it as a constant buffer. This ensures ONNX
export emits a ``Constant`` node for the scale rather than the dynamic
``Abs → ReduceMax → Div`` arithmetic, which TensorRT requires for
explicit-quantization Q/DQ fusion.
This is called automatically by
:func:`~embedl_deploy._internal.core.quantize.main.quantize` unless
``freeze_weights=False`` is passed. QAT users should call this explicitly
before ONNX export once training is complete.
"""
mq = get_model_quants(model)
for wfq in mq.weight:
mod = next(iter(wfq.consumers))
if not isinstance(mod, FusedModule):
continue
weight = mod.quantized_weight
if weight is not None:
wfq.freeze(weight)
else:
warnings.warn(
f"{type(mod).__name__} has a WeightFakeQuantize but "
f"quantized_weight returns None — override the property",
stacklevel=1,
)