Skip to content

Commit 6bd0355

Browse files
committed
Preserve BatchNorm running state in composable quantizer
Summary: Quantization-aware training with the composable `PatternQuantizer` incorrectly observed BatchNorm affine parameters and running-state buffers as activation inputs. The inserted fake-quant nodes broke the connection between `aten.batch_norm.default` and the registered `running_mean` and `running_var` buffers, so training used batch statistics while evaluation consumed stale running statistics. Keep `aten.batch_norm.default` arguments 1-4 directly connected to registered state by excluding them from activation observation while retaining activation and output quantization. Add Arm and Sleep/ModAI regression tests that verify the state operands remain registered and the running statistics update during QAT. Differential Revision: D112989631
1 parent cd380e7 commit 6bd0355

2 files changed

Lines changed: 84 additions & 0 deletions

File tree

backends/arm/quantizer/arm_quantizer_utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838

3939
logger = logging.getLogger(__name__)
4040

41+
NodeTarget = Callable[..., Any] | str
42+
4143
if TYPE_CHECKING:
4244
from executorch.backends.cortex_m.quantizer.pattern_matcher import PatternMatcher
4345

@@ -255,6 +257,13 @@ class PatternQuantizer(Quantizer, QuantizerReporterUser):
255257
torch.ops.aten.conv_transpose2d.input,
256258
}
257259

260+
# These operands are parameters or mutable state, not activation inputs.
261+
# Observing mutable state would redirect in-place updates to the observer
262+
# output instead of the registered buffer.
263+
UNOBSERVED_INPUT_INDICES: dict[NodeTarget, tuple[int, ...]] = {
264+
torch.ops.aten.batch_norm.default: (1, 2, 3, 4),
265+
}
266+
258267
def __init__(
259268
self,
260269
quantization_config: QuantizationConfig | None,
@@ -319,13 +328,35 @@ def is_bias(self, node: Node) -> bool:
319328

320329
return True
321330

331+
def is_unobserved_input(self, node: Node, input_node: Node) -> bool:
332+
"""Return whether an input must remain directly connected to state.
333+
334+
Args:
335+
node: Consumer node whose positional inputs are inspected.
336+
input_node: Input node being considered for quantization annotation.
337+
338+
Returns:
339+
True when the input occupies an unobserved position for the target.
340+
341+
"""
342+
return any(
343+
index < len(node.args) and input_node is node.args[index]
344+
for index in self.UNOBSERVED_INPUT_INDICES.get(node.target, ())
345+
)
346+
322347
def annotate_match(
323348
self,
324349
match: list[Node],
325350
config: QuantizationConfig | None,
326351
) -> None:
327352
"""Annotates a matched pattern according to the given quantization
328353
config.
354+
355+
Args:
356+
match: Nodes in the accepted pattern to annotate.
357+
config: Quantization configuration for the pattern, or None to mark
358+
its nodes as unquantized.
359+
329360
"""
330361

331362
for node in match:
@@ -335,6 +366,8 @@ def annotate_match(
335366
for input_node in node.all_input_nodes:
336367
if not has_float_output(input_node):
337368
continue
369+
if self.is_unobserved_input(node, input_node):
370+
continue
338371
if self.is_weight(input_node):
339372
input_qspec_map[input_node] = (
340373
config.get_weight_qspec(node) if config else None

backends/arm/test/misc/test_bn_relu_folding_qat.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
from executorch.backends.test.harness.tester import Quantize
1818
from torch import nn
19+
from torch.fx import Node
20+
from torchao.quantization.pt2e.quantize_pt2e import prepare_qat_pt2e
1921

2022

2123
input_t1 = Tuple[torch.Tensor] # Input x
@@ -122,3 +124,52 @@ def test_bn_relu_folding_qat_tosa_INT(test_data):
122124
),
123125
)
124126
pipeline.run()
127+
128+
129+
def test_qat_batch_norm_updates_registered_running_stats():
130+
class ConvBatchNorm(torch.nn.Module):
131+
def __init__(self) -> None:
132+
super().__init__()
133+
self.conv = nn.Conv1d(2, 2, 1, bias=False)
134+
self.bn = nn.BatchNorm1d(2)
135+
with torch.no_grad():
136+
self.conv.weight.copy_(torch.eye(2).unsqueeze(-1))
137+
138+
def forward(self, x: torch.Tensor) -> torch.Tensor:
139+
return self.bn(self.conv(x))
140+
141+
sample = torch.stack((torch.full((4, 8), 3.0), torch.full((4, 8), -2.0)), dim=1)
142+
model = torch.export.export(
143+
ConvBatchNorm().train(), (sample,), strict=True
144+
).module()
145+
quantizer = TOSAQuantizer(
146+
TosaSpecification.create_from_string("TOSA-1.0+INT"),
147+
use_composable_quantizer=True,
148+
).set_global(get_symmetric_quantization_config(is_qat=True, is_per_channel=False))
149+
150+
prepared = prepare_qat_pt2e(model, quantizer)
151+
batch_norm_nodes = [
152+
node
153+
for node in prepared.graph.nodes
154+
if node.target == torch.ops.aten.batch_norm.default
155+
]
156+
assert len(batch_norm_nodes) == 1
157+
batch_norm_node = batch_norm_nodes[0]
158+
159+
for state_node in batch_norm_node.args[1:5]:
160+
assert isinstance(state_node, Node)
161+
assert state_node.op == "get_attr"
162+
163+
running_mean_node = batch_norm_node.args[3]
164+
running_var_node = batch_norm_node.args[4]
165+
assert isinstance(running_mean_node, Node)
166+
assert isinstance(running_var_node, Node)
167+
running_mean = prepared.get_buffer(str(running_mean_node.target))
168+
running_var = prepared.get_buffer(str(running_var_node.target))
169+
initial_running_mean = running_mean.clone()
170+
initial_running_var = running_var.clone()
171+
172+
prepared(sample)
173+
174+
assert not torch.equal(running_mean, initial_running_mean)
175+
assert not torch.equal(running_var, initial_running_var)

0 commit comments

Comments
 (0)