Skip to content

Commit 0eabc57

Browse files
authored
Fix output spec adjustment for fixed qparams ops (#22)
- Update documentation
1 parent 256d4c4 commit 0eabc57

10 files changed

Lines changed: 582 additions & 87 deletions

File tree

changelog.d/180525445.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix setting of qscheme and float_range for fixed output range ops

docs/src/quantization/advanced.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,28 @@ The `qscheme` controls how these bins are distributed around zero, by determinin
166166

167167
## Quantization Defaults for Known-Range Activations
168168

169-
In graph mode, certain ops have known output ranges. For these ops, the user's `qscheme` setting is not respected — the activation is always treated as asymmetric or symmetric depending on the op, regardless of what the user configured. The treatment also differs between `relu` and the `sigmoid` / `tanh` family. For `relu`, only `qscheme` is overridden; `dtype`, scale, and zero point are still derived from the user's spec and calibration data. For `sigmoid` and `tanh`, scale, zero point, **and** `dtype` are pinned to fixed values (always `torch.uint8`, ignoring whatever `dtype` the user configured).
169+
In graph mode, certain activation ops have analytically known output ranges. For these ops, the quantizer overrides the `qscheme` and `float_range` of the qparams calculator at prepare time, regardless of what the user configured. The user's `dtype` is always preserved — these adjustments do not change the number of bits or the signed/unsigned choice.
170170

171-
| Op | Output range | Always treated as | Scale | Zero point |
172-
| --------- | ------------ | ----------------- | --------- | ---------- |
173-
| `relu` | \[0, ∞) | asymmetric | dynamic | dynamic |
174-
| `sigmoid` | [0, 1] | asymmetric | `1 / 256` | `0` |
175-
| `tanh` | [-1, 1] | symmetric | `2 / 256` | `128` |
171+
The scale and zero point values in the table below assume the default `int8` dtype. For other dtypes, the same formulas apply with the appropriate `quant_min` / `quant_max`.
176172

177-
**Relu**: Treated as asymmetric. The user's `qscheme` is ignored, but `dtype`, scale, and zero point are still derived from the user's spec and calibration data. The zero point follows `zero_point = quant_min - round(min_val / scale)`. Since `relu`'s observed min is always `0`, the zero point very commonly ends up near `quant_min` (e.g., `-128` for `int8`).
173+
| Op | Output range | `qscheme` | `float_range` | Scale (int8) | Zero point (int8) |
174+
| ------------- | ------------------- | ---------- | ------------- | ------------ | ----------------- |
175+
| `hardsigmoid` | [0, 1] | asymmetric | (0, 1) | 1 / 255 | −128 |
176+
| `hardtanh` | Depends (see below) | Depends | Depends | Depends | Depends |
177+
| `relu` | \[0, ∞) | asymmetric | (0, None) | dynamic | −128 |
178+
| `relu6` | [0, 6] | asymmetric | (0, 6) | 6 / 255 | −128 |
179+
| `sigmoid` | [0, 1] | asymmetric | (0, 1) | 1 / 255 | −128 |
180+
| `tanh` | [−1, 1] | symmetric | (−1, 1) | 2 / 255 | 0 |
178181

179-
> **Motivation for asymmetric `relu` and `sigmoid`**: Both ops produce non-negative outputs. With symmetric quantization, the zero point sits at the center of the quantized range, placing half the bins in negative territory that these ops never produce. Those bins are effectively wasted — no floating-point value will ever map to them, reducing quantization resolution by half. Asymmetric treatment shifts the zero point toward the edge of the range so all bins cover values the op actually produces.
182+
**Relu**: The lower bound of `float_range` is pinned to 0 and `qscheme` is set to asymmetric. Because the observed minimum is always 0, the zero point is fixed at `quant_min` (−128 for int8) and stays there regardless of calibration data. The upper bound remains `None` (data-driven), so the scale continues to update during calibration.
183+
184+
**Sigmoid and hardsigmoid**: Both `qscheme` and `float_range` are fully pinned. Scale and zero point are entirely determined by the dtype and the fixed output range — calibration data has no effect on them.
185+
186+
**Tanh**: `qscheme` (symmetric) and `float_range` (−1, 1) are fully pinned. Scale and zero point are entirely determined by the dtype and range.
187+
188+
**Hardtanh**: Bounds are read from the op's node arguments at prepare time, so the effective range and qscheme depend on how the op was configured. If `min_val == −max_val` the range is symmetric around zero and `qscheme` is set to symmetric; otherwise `qscheme` is set to asymmetric. Both ends of `float_range` are pinned to the configured bounds. `relu6` is a special case of `hardtanh(0, 6)` and is handled identically.
189+
190+
> **Motivation for asymmetric treatment**: Symmetric quantization places the zero point at the center of the quantized range. For `relu`, `sigmoid`, and `hardsigmoid`, whose outputs are always non-negative, symmetric quantization places half the bins in negative territory that the op never produces — wasting half the available resolution. Asymmetric quantization shifts the zero point to the edge of the range so that all bins cover values the op actually generates. For `tanh` and symmetric `hardtanh`, the output is centered at zero so both halves of the range are used equally, and symmetric quantization is appropriate.
180191
181192
Eager mode does not perform these adjustments — all activations are quantized uniformly using the user-configured spec.
182193

src/coreai_opt/quantization/_graph/_annotation_utils.py

Lines changed: 102 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from torch.fx.passes.utils.source_matcher_utils import SourcePartition
2020
from torchao.quantization.pt2e import WrapperModule, find_sequential_partitions
2121
from torchao.quantization.pt2e.quantizer import (
22-
FixedQParamsQuantizationSpec,
2322
QuantizationAnnotation,
2423
QuantizationSpec as TorchAOQuantizationSpec,
2524
SharedQuantizationSpec as _SharedQuantizationSpec,
@@ -46,7 +45,11 @@
4645
_ACTIVATION_SPEC_DICT,
4746
_STATE_SPEC_DICT,
4847
)
49-
from coreai_opt.quantization.spec import QuantizationSpec
48+
from coreai_opt.quantization.spec import (
49+
QuantizationComponentFactory,
50+
QuantizationScheme,
51+
QuantizationSpec,
52+
)
5053

5154
from ._annotation_config import AnnotationConfig, AnnotationContext
5255

@@ -56,6 +59,27 @@
5659
INPUT_NODE_PREFIX = "input::"
5760
PARAM_NODE_PREFIX = "param::"
5861

62+
# Ops that are transparent to quantization range propagation: they don't alter
63+
# the numeric range of their inputs, so we traverse through them when propagating
64+
# adjusted qspecs to child nodes.
65+
_PASSTHROUGH_OP_OVERLOADS: frozenset = frozenset(
66+
{
67+
torch.ops.aten.clone,
68+
torch.ops.aten.dropout,
69+
torch.ops.aten.expand,
70+
torch.ops.aten.feature_dropout,
71+
torch.ops.aten.permute,
72+
torch.ops.aten.reshape,
73+
torch.ops.aten.select,
74+
torch.ops.aten.slice,
75+
torch.ops.aten.squeeze,
76+
torch.ops.aten.t,
77+
torch.ops.aten.transpose,
78+
torch.ops.aten.unsqueeze,
79+
torch.ops.aten.view,
80+
}
81+
)
82+
5983

6084
def _get_aten_graph_module_for_pattern(
6185
pattern: Callable,
@@ -131,38 +155,30 @@ class OpsListPattern:
131155
F.hardsigmoid,
132156
)
133157

134-
_tanh_qspec = FixedQParamsQuantizationSpec(
135-
dtype=torch.uint8,
136-
scale=2.0 / 256.0,
137-
zero_point=128,
138-
quant_min=0,
139-
quant_max=255,
140-
qscheme=torch.per_tensor_symmetric,
141-
)
142-
143-
_sigmoid_qspec = FixedQParamsQuantizationSpec(
144-
dtype=torch.uint8,
145-
scale=1.0 / 256.0,
146-
zero_point=0,
147-
quant_min=0,
148-
quant_max=255,
149-
qscheme=torch.per_tensor_affine,
150-
)
151-
158+
# Dictionary mapping ops with known output bounds to (qscheme, float_range).
159+
# float_range elements may be None to leave that side data-driven.
152160
_fixed_q_params_ops = {
153-
torch.ops.aten.tanh.default: _tanh_qspec,
154-
torch.ops.aten.tanh_.default: _tanh_qspec,
155-
torch.ops.aten.sigmoid.default: _sigmoid_qspec,
156-
torch.ops.aten.sigmoid_.default: _sigmoid_qspec,
157-
torch.ops.aten.hardsigmoid.default: _sigmoid_qspec,
158-
torch.ops.aten.hardsigmoid_.default: _sigmoid_qspec,
161+
# tanh: bounded to [-1, 1]
162+
torch.ops.aten.tanh.default: (QuantizationScheme.SYMMETRIC, (-1.0, 1.0)),
163+
torch.ops.aten.tanh_.default: (QuantizationScheme.SYMMETRIC, (-1.0, 1.0)),
164+
# sigmoid: bounded to [0, 1]
165+
torch.ops.aten.sigmoid.default: (QuantizationScheme.ASYMMETRIC, (0.0, 1.0)),
166+
torch.ops.aten.sigmoid_.default: (QuantizationScheme.ASYMMETRIC, (0.0, 1.0)),
167+
# hardsigmoid: bounded to [0, 1]
168+
torch.ops.aten.hardsigmoid.default: (QuantizationScheme.ASYMMETRIC, (0.0, 1.0)),
169+
torch.ops.aten.hardsigmoid_.default: (QuantizationScheme.ASYMMETRIC, (0.0, 1.0)),
170+
# relu: always >= 0, upper bound is data-driven
171+
torch.ops.aten.relu.default: (QuantizationScheme.ASYMMETRIC, (0.0, None)),
172+
torch.ops.aten.relu_.default: (QuantizationScheme.ASYMMETRIC, (0.0, None)),
173+
# relu6: clipped to [0, 6]
174+
torch.ops.aten.relu6.default: (QuantizationScheme.ASYMMETRIC, (0.0, 6.0)),
175+
torch.ops.aten.relu6_.default: (QuantizationScheme.ASYMMETRIC, (0.0, 6.0)),
159176
}
160177

161-
_always_affine_ops = (
162-
torch.ops.aten.relu.default,
163-
torch.ops.aten.relu_.default,
164-
torch.ops.aten.relu6.default,
165-
torch.ops.aten.relu6_.default,
178+
# hardtanh bounds are configurable via node arguments; handled separately.
179+
_hardtanh_ops = (
180+
torch.ops.aten.hardtanh.default,
181+
torch.ops.aten.hardtanh_.default,
166182
)
167183

168184

@@ -207,19 +223,34 @@ def mark_nodes_as_annotated(nodes: Iterable[Node]) -> None:
207223
node.meta[Q_ANNOTATION_KEY]._annotated = True
208224

209225

210-
def _propagate_qscheme_to_child_nodes(
226+
def _propagate_adjusted_spec_to_child_nodes(
211227
root_node: torch.fx.Node,
212-
qscheme: torch.qscheme,
213-
shared_observer_nodes: set[torch.fx.Node] | None = None,
228+
qscheme: QuantizationScheme | None,
229+
float_range: tuple[float, float] | None,
230+
shared_observer_nodes: set[torch.fx.Node],
214231
) -> None:
215232
"""
216-
Given a qscheme, propagate the qscheme to all applicable children. Any input qspecs
217-
which are not shared qspecs will have qschemes updated. The propagation logic
233+
Given a qscheme or float_range, propagate the info to all applicable children. Any input qspecs
234+
which are not shared qspecs will have specs updated. The propagation logic
218235
continues downwards through the graph until we encounter a non-shared observer op.
219236
"""
237+
# Set of op types for which we want to propagate the updated spec through, even though they
238+
# are not registered ops with quantizers themselves.
239+
# This is a temporary solution. Adding them as SharedObserverPatterns may make sense, but
240+
# additional consideration is needed as to whether it makes sense to have quantizers in between
241+
# multiple shared observer ops.
242+
# To minimize the impact of this change to quantization behavior as a whole, use the below
243+
# set to skip these ops while continuing to traverse through the graph.
220244
nodes_to_propagate = [(root_node, user) for user in root_node.users.keys()]
221245
while nodes_to_propagate:
222246
parent, curr_node = nodes_to_propagate.pop(0)
247+
if (
248+
curr_node.op == "call_function"
249+
and getattr(curr_node.target, "overloadpacket", None) in _PASSTHROUGH_OP_OVERLOADS
250+
):
251+
assert curr_node not in shared_observer_nodes
252+
nodes_to_propagate.extend([(curr_node, user) for user in curr_node.users.keys()])
253+
continue
223254
if not is_node_annotated(curr_node):
224255
continue
225256
curr_input_qspec = curr_node.meta[Q_ANNOTATION_KEY].input_qspec_map.get(parent)
@@ -236,10 +267,22 @@ def _propagate_qscheme_to_child_nodes(
236267
# dequantize ops inserted.
237268
continue
238269
if not isinstance(curr_input_qspec, _SharedQuantizationSpec):
270+
ctr = curr_input_qspec.observer_or_fake_quant_ctr
271+
kwargs = {}
272+
if qscheme is not None:
273+
kwargs["qscheme"] = qscheme
274+
if float_range is not None:
275+
kwargs["float_range"] = float_range
276+
if kwargs:
277+
ctr = QuantizationComponentFactory.reconstruct_partial_qparams_calculator(
278+
ctr, **kwargs
279+
)
280+
281+
# qscheme in TorchAOQuantizationSpec is not read by coreai-opt later on so we omit it.
282+
# Only the qscheme contained within observer_or_fake_quant_ctr matters.
239283
adjusted_qspec = TorchAOQuantizationSpec(
240-
observer_or_fake_quant_ctr=curr_input_qspec.observer_or_fake_quant_ctr,
284+
observer_or_fake_quant_ctr=ctr,
241285
dtype=curr_input_qspec.dtype,
242-
qscheme=qscheme,
243286
quant_min=curr_input_qspec.quant_min,
244287
quant_max=curr_input_qspec.quant_max,
245288
)
@@ -270,39 +313,30 @@ def adjust_output_qspec_for_qscheme_and_propagate(
270313
if qspec is None:
271314
return
272315

273-
# ReLU6 activation maps to torch.ops.aten.hardtanh.default with
274-
# min_val = 0 and max_val = 6
275-
is_always_affine_op = node.target in _always_affine_ops or (
276-
node.target in [torch.ops.aten.hardtanh.default, torch.ops.aten.hardtanh_.default]
277-
and node.args[1] == 0 # min_val, corresponding to ReLU6
278-
and node.args[2] == 6 # max_val, corresponding to ReLU6
279-
)
280-
281-
adjusted_qspec = None
282316
if node.target in _fixed_q_params_ops:
283-
adjusted_qspec = TorchAOQuantizationSpec(
284-
observer_or_fake_quant_ctr=qspec.observer_or_fake_quant_ctr,
285-
dtype=qspec.dtype,
286-
qscheme=_fixed_q_params_ops[node.target].qscheme,
287-
quant_min=qspec.quant_min,
288-
quant_max=qspec.quant_max,
289-
)
290-
# FIXME: Because of a bug in PyTorch in function _create_obs_or_fq_from_qspec
291-
# in module torch/ao/quantization/fx/prepare.py which creates a
292-
# FixedQParamsFakeQuantize partial, instead of an instance, we cannot
293-
# actually create FixedQParamsQuantizationSpec
294-
elif is_always_affine_op:
295-
adjusted_qspec = TorchAOQuantizationSpec(
296-
observer_or_fake_quant_ctr=qspec.observer_or_fake_quant_ctr,
297-
dtype=qspec.dtype,
298-
qscheme=torch.per_tensor_affine,
299-
quant_min=qspec.quant_min,
300-
quant_max=qspec.quant_max,
317+
qscheme, float_range = _fixed_q_params_ops[node.target]
318+
elif node.target in _hardtanh_ops:
319+
min_val, max_val = node.args[1], node.args[2]
320+
float_range = (min_val, max_val)
321+
qscheme = (
322+
QuantizationScheme.SYMMETRIC if min_val == -max_val else QuantizationScheme.ASYMMETRIC
301323
)
324+
else:
325+
return
302326

303-
if adjusted_qspec is not None:
304-
node.meta[Q_ANNOTATION_KEY].output_qspec = adjusted_qspec
305-
_propagate_qscheme_to_child_nodes(node, adjusted_qspec.qscheme, shared_observer_nodes)
327+
ctr = QuantizationComponentFactory.reconstruct_partial_qparams_calculator(
328+
qspec.observer_or_fake_quant_ctr, qscheme=qscheme, float_range=float_range
329+
)
330+
331+
# qscheme in TorchAOQuantizationSpec is not read by coreai-opt later on so we omit it.
332+
# Only the qscheme contained within observer_or_fake_quant_ctr matters.
333+
node.meta[Q_ANNOTATION_KEY].output_qspec = TorchAOQuantizationSpec(
334+
observer_or_fake_quant_ctr=ctr,
335+
dtype=qspec.dtype,
336+
quant_min=qspec.quant_min,
337+
quant_max=qspec.quant_max,
338+
)
339+
_propagate_adjusted_spec_to_child_nodes(node, qscheme, float_range, shared_observer_nodes)
306340

307341

308342
def _get_weighted_mod_pattern(

src/coreai_opt/quantization/spec/factory.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from __future__ import annotations
77

8+
from typing import Any
9+
810
from coreai_opt._utils.spec_utils import PartialConstructor as _PartialConstructor
911
from coreai_opt.config.spec import (
1012
CompressionComponentFactoryBase,
@@ -194,7 +196,6 @@ def create_fake_quantizer(
194196
# Standard arguments that all fake quantizers need
195197
common_args = {
196198
"dtype": spec.dtype,
197-
"qscheme": spec.qscheme,
198199
"qformulation": spec.qformulation,
199200
"granularity": spec.granularity,
200201
"target_dtype": spec.target_dtype,
@@ -211,6 +212,47 @@ def create_fake_quantizer(
211212
# Create instance with all arguments
212213
return spec.fake_quantize_cls(**common_args, **extra_args)
213214

215+
@classmethod
216+
def reconstruct_partial_qparams_calculator(
217+
cls,
218+
partial_ctr: _PartialConstructor,
219+
**kwargs: Any,
220+
) -> _PartialConstructor:
221+
"""Return a new PartialConstructor whose qparams_calculator has attributes overridden.
222+
223+
The replacement wraps the existing ``qparams_calculator`` callable arg so that
224+
each freshly constructed calculator has the given attributes set before it is
225+
returned. All overridden attributes (e.g. ``float_range``, ``qscheme``) are
226+
plain instance attributes on ``QParamsCalculatorBase`` that are read lazily
227+
during ``forward()``, so post-construction mutation is safe as long as no
228+
forward pass has run yet.
229+
230+
Args:
231+
partial_ctr: The existing fake-quantizer partial to update.
232+
**kwargs: Attribute name/value pairs to set on the calculator instance.
233+
234+
Returns:
235+
A new PartialConstructor whose qparams_calculator factory applies the
236+
overrides.
237+
"""
238+
old_factory = partial_ctr.callable_args.get("qparams_calculator")
239+
if old_factory is None:
240+
return partial_ctr
241+
242+
def _new_factory():
243+
calculator: QParamsCalculatorBase = old_factory()
244+
for attr, value in kwargs.items():
245+
if not hasattr(calculator, attr):
246+
msg = (
247+
f"Cannot override unknown attribute '{attr}' on "
248+
f"{type(calculator).__name__}; expected an existing calculator attribute."
249+
)
250+
raise AttributeError(msg)
251+
setattr(calculator, attr, value)
252+
return calculator
253+
254+
return partial_ctr.with_callable_args(qparams_calculator=_new_factory)
255+
214256
@classmethod
215257
def create_fake_quantizer_partial(
216258
cls, spec: QuantizationSpec, quantization_target: CompressionTargetTensor
@@ -236,7 +278,6 @@ def create_fake_quantizer_partial(
236278
# (excluding qparams_calculator)
237279
common_args = {
238280
"dtype": spec.dtype,
239-
"qscheme": spec.qscheme,
240281
"qformulation": spec.qformulation,
241282
"granularity": spec.granularity,
242283
"target_dtype": spec.target_dtype,

src/coreai_opt/quantization/spec/fake_quantize.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@
2828
from coreai_opt.config.spec import CompressionSimulatorBase, CompressionTargetTensor
2929
from coreai_opt.quantization._utils import get_quantization_shapes as _get_quantization_shapes
3030
from coreai_opt.quantization.spec.errors import _BlockSizeMismatchError
31+
from coreai_opt.quantization.spec.qscheme import QuantizationScheme
3132

3233
from .granularity import QuantizationGranularity
3334
from .qformulation import QuantizationFormulation
3435
from .qparams_calculator import QParamsCalculatorBase, StatelessQParamsCalculatorBase
35-
from .qscheme import QuantizationScheme
3636

3737
__all__ = ["FakeQuantizeImplBase"]
3838

@@ -47,7 +47,6 @@ class FakeQuantizeImplBase(CompressionSimulatorBase, FakeQuantizeBase):
4747
def __init__(
4848
self,
4949
dtype: torch.dtype,
50-
qscheme: QuantizationScheme,
5150
qformulation: QuantizationFormulation,
5251
granularity: QuantizationGranularity,
5352
target_dtype: torch.dtype,
@@ -60,7 +59,6 @@ def __init__(
6059
):
6160
super().__init__()
6261
self.dtype = dtype
63-
self.qscheme = qscheme
6462
self.qformulation = qformulation
6563
self._granularity = granularity
6664
self.target_dtype = target_dtype
@@ -75,6 +73,11 @@ def __init__(
7573
n_bits = _get_n_bits_from_dtype(dtype)
7674
self.n_bits = n_bits
7775

76+
@property
77+
def qscheme(self) -> QuantizationScheme:
78+
"""The quantization scheme, delegated to the qparams_calculator."""
79+
return self.qparams_calculator.qscheme
80+
7881
@property
7982
def granularity(self) -> QuantizationGranularity:
8083
"""Getter for granularity."""

0 commit comments

Comments
 (0)