Skip to content

Commit 519f21c

Browse files
u-simhaUtkarsh Simha
andauthored
Enable weight fake quantization in calibration mode (#25)
* feat(quantization): keep weight fake quantization enabled during calibration mode Quantizer.calibration_mode() previously disabled fake quantization on both weights and activations. It now only disables activation fake quantization, so activation observers see the effect of quantized weights when computing activation ranges. Adds enable_weight_fake_quant/disable_activation_fake_quant helpers used by both the eager and graph quantizers, and updates docs/tests accordingly. * fix(quantization): address review feedback on weight FQ calibration change - Move enable_weight_fake_quant/disable_activation_fake_quant into a new _fake_quant_utils module so they can import FakeQuantizeImplBase and CompressionTargetTensor at module scope instead of via local imports, keeping _utils.py free of dependencies on other quantization modules. - Use basic_config (weight + activation quantization) instead of input_activation_only_config in test_calibration_mode so the weight-FQ branch is actually exercised; only assert scale drift for activation modules, since weight ranges are fixed at prepare time. - Drop the changelog.d entry. --------- Co-authored-by: Utkarsh Simha <u_simha@apple.com>
1 parent 4968b10 commit 519f21c

9 files changed

Lines changed: 130 additions & 32 deletions

File tree

docs/src/examples/resnet50.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ prepared_model = quantizer.prepare(example_inputs)
5353

5454
Calibration is necessary when activation quantization is enabled. In order to determine proper quantization parameters for activation quantizers, representative data must be passed through the prepared model in the `calibration_mode()` context.
5555

56-
Inside `calibration_mode()`, fake quantization is disabled and observers track tensor ranges seen at each activation quantizer.
57-
Each forward pass updates the activation scales without introducing quantization noise into the output.
58-
On exit, observers are disabled and fake quantization is re-enabled.
56+
Inside `calibration_mode()`, activation fake quantization is disabled while weight fake quantization stays on, and observers track tensor ranges seen at each activation quantizer.
57+
Each forward pass updates the activation scales using activations produced with quantized weights upstream, without injecting activation quantization noise into the observed values.
58+
On exit, observers are disabled and activation fake quantization is re-enabled.
5959

6060
```python
6161
with quantizer.calibration_mode():

docs/src/quantization/overview.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@ Right after `prepare()`, the activation scales come only from `example_inputs`,
111111
This is what the context manager handles:
112112

113113
- Inside the context:
114-
- fake-quantization is turned **off**: forward pass gives the same output as the unquantized model. Hence without distorting the outputs, the quantization params can be computed.
115-
- range observers are turned **on**: this means that each forward pass updates the observed activation ranges, and hence the activation quantization scales.
116-
- After exiting the context manager, observers are turned back off and fake-quantization back on, leaving the model ready for evaluation.
114+
- activation fake-quantization is turned **off**: activation observers see undistorted activation values, so the observed ranges (and resulting scales) reflect the true distribution rather than already-quantized values.
115+
- weight fake-quantization stays **on**: activations flowing into each observer are produced with quantized weights upstream, matching what the deployed model will actually see.
116+
- range observers are turned **on**: each forward pass updates the observed activation ranges, and hence the activation quantization scales.
117+
- After exiting the context manager, observers are turned back off and activation fake-quantization back on, leaving the model ready for evaluation.
117118

118119
A small amount of representative data is typically enough.
119120

docs/src/tutorials/mnist_quantization.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,7 @@
748748
"source": [
749749
"We now call `calibration_mode()` and feed it representative inputs to populate scale and zero-point value.\n",
750750
"\n",
751-
"The `calibration_mode()` context manager enables range observers (to collect activation statistics) while disabling fake quantization (so the forward pass is numerically identical to the unquantized model). After exiting the context, observers are frozen and fake quantization is re-enabled."
751+
"The `calibration_mode()` context manager enables range observers (to collect activation statistics) and disables activation fake quantization (so the observed ranges reflect undistorted activation values), while leaving weight fake quantization enabled (so activations flowing into each observer are produced with quantized weights upstream). After exiting the context, observers are frozen and activation fake quantization is re-enabled."
752752
]
753753
},
754754
{

src/coreai_opt/quantization/_eager/quantizer.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
apply_weight_axis_defaults_eager as _apply_weight_axis_defaults,
4040
validate_activation_axes as _validate_activation_axes,
4141
)
42+
from coreai_opt.quantization._fake_quant_utils import (
43+
disable_activation_fake_quant as _disable_activation_fake_quant,
44+
enable_weight_fake_quant as _enable_weight_fake_quant,
45+
)
4246
from coreai_opt.quantization.base_quantizer import _BaseQuantizer
4347
from coreai_opt.quantization.config import (
4448
ModuleQuantizerConfig,
@@ -291,8 +295,10 @@ def finalize(
291295
def calibration_mode(self, model: nn.Module | None = None) -> Generator:
292296
"""Context manager for calibration phase.
293297
294-
Enables observers and disables fake quantization for calibration
295-
data collection.
298+
Enables observers and disables activation fake quantization for
299+
calibration data collection. Weight fake quantization stays enabled so
300+
that activation observers see the effect of quantized weights when
301+
computing activation ranges.
296302
297303
Args:
298304
model: Model to calibrate (uses internal model if None)
@@ -309,7 +315,8 @@ def calibration_mode(self, model: nn.Module | None = None) -> Generator:
309315
)
310316
with move_model_to_eval(self._model):
311317
self._model.apply(enable_observer)
312-
self._model.apply(disable_fake_quant)
318+
self._model.apply(_enable_weight_fake_quant)
319+
self._model.apply(_disable_activation_fake_quant)
313320
try:
314321
yield
315322
finally:
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright 2026 Apple Inc.
2+
#
3+
# Use of this source code is governed by a BSD-3-Clause license that can
4+
# be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
5+
6+
"""Helpers for toggling fake quantization by quantization target."""
7+
8+
import torch
9+
10+
from coreai_opt.config.spec import CompressionTargetTensor
11+
from coreai_opt.quantization.spec.fake_quantize import FakeQuantizeImplBase
12+
13+
14+
def disable_activation_fake_quant(module: torch.nn.Module) -> None:
15+
"""Disable fake quantization on activation FakeQuantize modules only.
16+
17+
Mirrors ``torchao.quantization.pt2e.fake_quantize.disable_fake_quant`` but
18+
skips weight FQ modules. Used by ``calibration_mode`` so activation
19+
observers see the effect of quantized weights when collecting statistics.
20+
21+
Args:
22+
module (torch.nn.Module): Module to (possibly) toggle. No-op for any
23+
module that is not a ``FakeQuantizeImplBase`` whose
24+
``quantization_target`` is ``ACTIVATION``.
25+
"""
26+
if (
27+
isinstance(module, FakeQuantizeImplBase)
28+
and module.quantization_target == CompressionTargetTensor.ACTIVATION
29+
):
30+
module.disable_fake_quant()
31+
32+
33+
def enable_weight_fake_quant(module: torch.nn.Module) -> None:
34+
"""Enable fake quantization on weight FakeQuantize modules only.
35+
36+
Mirrors ``torchao.quantization.pt2e.fake_quantize.enable_fake_quant`` but
37+
skips activation FQ modules. Companion to
38+
:func:`disable_activation_fake_quant` used by ``calibration_mode``.
39+
40+
Args:
41+
module (torch.nn.Module): Module to (possibly) toggle. No-op for any
42+
module that is not a ``FakeQuantizeImplBase`` whose
43+
``quantization_target`` is ``WEIGHT``.
44+
"""
45+
if (
46+
isinstance(module, FakeQuantizeImplBase)
47+
and module.quantization_target == CompressionTargetTensor.WEIGHT
48+
):
49+
module.enable_fake_quant()

src/coreai_opt/quantization/_graph/quantizer.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
apply_weight_axis_defaults_graph as _apply_weight_axis_defaults,
6161
validate_activation_axes as _validate_activation_axes,
6262
)
63+
from coreai_opt.quantization._fake_quant_utils import (
64+
disable_activation_fake_quant as _disable_activation_fake_quant,
65+
enable_weight_fake_quant as _enable_weight_fake_quant,
66+
)
6367
from coreai_opt.quantization.base_quantizer import _BaseQuantizer
6468
from coreai_opt.quantization.config import (
6569
KVCacheQuantConfig,
@@ -1128,9 +1132,11 @@ def calibration_mode(self, model: torch.fx.GraphModule | None = None):
11281132
Context manager for calibration-based post-training quantization.
11291133
11301134
When entering this context, observers are enabled to collect statistics
1131-
from calibration data, and fake quantization is disabled to get accurate
1132-
statistics. When exiting, observers are disabled and fake quantization
1133-
is re-enabled for evaluation.
1135+
from calibration data. Weight fake quantization stays enabled, while
1136+
activation fake quantization is disabled so that activation observers
1137+
see the effect of quantized weights when computing activation ranges.
1138+
When exiting, observers are disabled and fake quantization is
1139+
re-enabled on both weights and activations for evaluation.
11341140
11351141
**When to use:**
11361142
- Required for activation quantization to achieve good accuracy
@@ -1164,9 +1170,11 @@ def calibration_mode(self, model: torch.fx.GraphModule | None = None):
11641170
"Model must be prepared before entering calibration mode. Call prepare() first."
11651171
)
11661172

1167-
# Enable observers and disable fake quantization for calibration
1173+
# Enable observers; keep weight FQ on, disable activation FQ so observers
1174+
# see the effect of quantized weights on activation ranges.
11681175
self._model.apply(enable_observer)
1169-
self._model.apply(disable_fake_quant)
1176+
self._model.apply(_enable_weight_fake_quant)
1177+
self._model.apply(_disable_activation_fake_quant)
11701178

11711179
# Move model to eval mode and save original state
11721180
with move_model_to_eval(self._model):

src/coreai_opt/quantization/quantizer.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -488,9 +488,11 @@ def calibration_mode(self, model: nn.Module | fx.GraphModule | None = None):
488488
Context manager for calibration-based post-training quantization.
489489
490490
When entering this context, observers are enabled to collect statistics
491-
from calibration data, and fake quantization is disabled to get accurate
492-
statistics. When exiting, observers are disabled and fake quantization
493-
is re-enabled for evaluation.
491+
from calibration data. Weight fake quantization stays enabled, while
492+
activation fake quantization is disabled so that activation observers
493+
see the effect of quantized weights when computing activation ranges.
494+
When exiting, observers are disabled and fake quantization is
495+
re-enabled on both weights and activations for evaluation.
494496
495497
**When to use:**
496498

tests/quantization/test_eager_quant.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
FunctionRegisteredOptimizers,
2121
RegisteredOptimizersTracker,
2222
)
23+
from coreai_opt.config.spec import CompressionTargetTensor
2324
from coreai_opt.quantization import (
2425
ModuleQuantizerConfig,
2526
QuantizationSpec,
@@ -529,23 +530,30 @@ def test_finalize_state_dict_safetensors_roundtrip(self, basic_config, tmp_path)
529530

530531
assert torch.equal(out_before_roundtrip, out_after_roundtrip)
531532

532-
def test_calibration_mode(self, simple_model, input_activation_only_config, example_input):
533+
def test_calibration_mode(self, simple_model, basic_config, example_input):
533534
"""
534535
Test that calibration mode works as expected, and scales are getting updated
535536
"""
536-
quantizer = Quantizer(simple_model, input_activation_only_config)
537+
quantizer = Quantizer(simple_model, basic_config)
537538
simple_model.eval()
538539
prepared_model = quantizer.prepare((example_input,))
539540

540541
fake_quant_modules = [
541542
m for m in prepared_model.modules() if isinstance(m, FakeQuantizeImplBase)
542543
]
544+
activation_fake_quant_modules = [
545+
m
546+
for m in fake_quant_modules
547+
if m.quantization_target == CompressionTargetTensor.ACTIVATION
548+
]
543549

544550
for module in fake_quant_modules:
545551
assert module.observer_enabled.item() == 0
546552
assert module.fake_quant_enabled.item() == 1
547553

548-
pre_calibration_scales = [mod.calculate_qparams()[0].clone() for mod in fake_quant_modules]
554+
pre_calibration_scales = [
555+
mod.calculate_qparams()[0].clone() for mod in activation_fake_quant_modules
556+
]
549557

550558
with quantizer.calibration_mode():
551559
simple_model.eval()
@@ -554,10 +562,20 @@ def test_calibration_mode(self, simple_model, input_activation_only_config, exam
554562

555563
for module in fake_quant_modules:
556564
assert module.observer_enabled.item() == 1
557-
assert module.fake_quant_enabled.item() == 0
565+
# Weight FQ stays on so activation observers see quantized weights;
566+
# activation FQ is off so observers collect statistics on the raw
567+
# (post-weight-quant) activations.
568+
expected_fq = (
569+
1 if module.quantization_target == CompressionTargetTensor.WEIGHT else 0
570+
)
571+
assert module.fake_quant_enabled.item() == expected_fq
558572

559-
post_calibration_scales = [mod.calculate_qparams()[0].clone() for mod in fake_quant_modules]
573+
post_calibration_scales = [
574+
mod.calculate_qparams()[0].clone() for mod in activation_fake_quant_modules
575+
]
560576

577+
# Only activation scales are expected to move here: weight ranges are
578+
# fixed at prepare time and don't depend on calibration data.
561579
for pre_scale, post_scale in zip(
562580
pre_calibration_scales, post_calibration_scales, strict=True
563581
):
@@ -1732,8 +1750,10 @@ def test_e2e_workflow(self, basic_config, simple_model, example_input):
17321750
with quantizer.calibration_mode():
17331751
prepared_out = prepared_model(example_input_2)
17341752
original_out = base_model(example_input_2)
1735-
# prepare model output should match base model, since fake quant is disabled
1736-
assert torch.equal(prepared_out, original_out)
1753+
# prepared model output should NOT match base model: weight fake
1754+
# quant stays on during calibration so activation observers see the
1755+
# effect of quantized weights. Only activation FQ is disabled.
1756+
assert not torch.equal(prepared_out, original_out)
17371757

17381758
pre_finalize_out = prepared_model(example_input_3)
17391759

tests/quantization/test_graph_mode_quantizer.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from coreai_opt._utils.metadata_utils import (
2222
STATE_DICT_METADATA_BUFFER_PREFIX as _COREML_BUFFER_PREFIX,
2323
)
24+
from coreai_opt.config.spec import CompressionTargetTensor
2425
from coreai_opt.quantization import (
2526
ModuleQuantizerConfig,
2627
QuantizationSpec,
@@ -374,11 +375,16 @@ def test_observer_fake_quant_status(
374375

375376
# Test entering calibration mode
376377
with quantizer.calibration_mode():
377-
# Verify that observers are enabled and fake quant is disabled
378+
# Verify that observers are enabled. Weight FQ stays on so
379+
# activation observers see the effect of quantized weights;
380+
# activation FQ is disabled so observers collect raw stats.
378381
for _name, module in prepared_model.named_modules():
379382
if isinstance(module, FakeQuantizeImplBase):
380383
assert module.observer_enabled.item() == 1
381-
assert module.fake_quant_enabled.item() == 0
384+
expected_fq = (
385+
1 if module.quantization_target == CompressionTargetTensor.WEIGHT else 0
386+
)
387+
assert module.fake_quant_enabled.item() == expected_fq
382388

383389
# # Verify that observers are disabled and fake quant is enabled on exit
384390
for _name, module in prepared_model.named_modules():
@@ -424,12 +430,15 @@ def test_calibration_mode_with_external_model(
424430

425431
# Test calibration_mode with external prepared model
426432
with quantizer.calibration_mode(other_prepared_model):
427-
# Verify that observers are enabled and fake quant is disabled
428-
# on the external model
433+
# Verify that observers are enabled on the external model. Weight FQ
434+
# stays on; activation FQ is off.
429435
for _name, module in other_prepared_model.named_modules():
430436
if isinstance(module, FakeQuantizeImplBase):
431437
assert module.observer_enabled.item() == 1
432-
assert module.fake_quant_enabled.item() == 0
438+
expected_fq = (
439+
1 if module.quantization_target == CompressionTargetTensor.WEIGHT else 0
440+
)
441+
assert module.fake_quant_enabled.item() == expected_fq
433442

434443
# The quantizer's internal model should be updated to the provided model
435444
assert quantizer._model is other_prepared_model
@@ -460,8 +469,10 @@ def test_end_to_end_workflow(self, simple_conv_linear_model, basic_config, simpl
460469
with quantizer.calibration_mode():
461470
prepared_out = prepared_model(simple_model_input_2)
462471
original_out = simple_conv_linear_model(simple_model_input_2)
463-
# prepare model output should match base model, since fake quant is disabled
464-
assert torch.equal(prepared_out, original_out)
472+
# prepared model output should NOT match base model: weight fake
473+
# quant stays on during calibration so activation observers see the
474+
# effect of quantized weights. Only activation FQ is disabled.
475+
assert not torch.equal(prepared_out, original_out)
465476

466477
pre_finalize_out = prepared_model(simple_model_input_3)
467478

0 commit comments

Comments
 (0)