Skip to content

Commit 3e6bc24

Browse files
3l1facebook-github-bot
authored andcommitted
Add quantized_div op (#21294)
Summary: Adds a `quantized_div` operator to the ExecuTorch Cortex-M (CMSIS-NN) backend, giving int8 and int16 elementwise division alongside the existing `quantized_add`/`quantized_mul` ops. CMSIS-NN has no integer elementwise-division primitive, so the quotient of the zero-point-corrected operands is evaluated in float and rescaled by the effective scale `scale_a / (scale_b * scale_out)`. Because there is no fixed-point path to feed (unlike `quantized_mul`/`quantized_add`), that scale is computed AoT and carried directly as a `float output_scale` in the op schema rather than as a `multiplier`/`shift` pair. The kernel does the rounding, zero-point add, and int8/int16 saturation in float, casting to the output type only after clamping, so a near-zero denominator cannot overflow int32. A zero denominator maps to a 0 quotient in both the kernel and the reference. Division is not commutative, so the operand-swap trick that lets `quantized_mul` support channel broadcasting does not apply here; the op and its quantizer pattern check (`CortexMDivCheck`) currently require identically shaped per-tensor inputs and reject any broadcasting. int8 and int16 activations are both supported. The kernel dispatches on the output dtype (`Char`/`Short`) into a templated loop, and the composite reference clamps to the dtype range. A new `INT16_PER_TENSOR_CONFIG` (symmetric int16 activations) is added, `CortexMDivCheck` accepts int8 or int16, and `CortexMQuantizer` takes an optional, documented `per_tensor_config` so callers can opt into int16. Wiring: `operators.py` (schema + reference impl), `op_quantized_div.cpp` (kernel), `operators.yaml`/`targets.bzl`/`CMakeLists.txt` (registration and build), `aten_to_cortex_m_pass.py` (`aten.div.Tensor` lowering), `quantization_configs.py` (int16 config), `pattern_checkers.py` + `quantizer.py` (int16 support). The Buck target that runs these tests is added in the stacked diff D113492101. All changes are mirrored across the fbcode and xplat trees. Authored with Claude Code. Reviewed By: rascani Differential Revision: D113440315
1 parent 85a204a commit 3e6bc24

11 files changed

Lines changed: 487 additions & 2 deletions

File tree

backends/cortex_m/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ if(EXECUTORCH_BUILD_CORTEX_M)
106106
${CMAKE_CURRENT_SOURCE_DIR}/ops/op_quantized_batch_matmul.cpp
107107
${CMAKE_CURRENT_SOURCE_DIR}/ops/op_quantized_conv2d.cpp
108108
${CMAKE_CURRENT_SOURCE_DIR}/ops/op_quantized_depthwise_conv2d.cpp
109+
${CMAKE_CURRENT_SOURCE_DIR}/ops/op_quantized_div.cpp
109110
${CMAKE_CURRENT_SOURCE_DIR}/ops/op_quantized_linear.cpp
110111
${CMAKE_CURRENT_SOURCE_DIR}/ops/op_quantized_max_pool2d.cpp
111112
${CMAKE_CURRENT_SOURCE_DIR}/ops/op_quantized_mul.cpp
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
#include <algorithm>
10+
#include <cmath>
11+
12+
#include "cortex_m_ops_common.h"
13+
14+
namespace cortex_m {
15+
namespace native {
16+
namespace {
17+
18+
template <typename T>
19+
void quantized_div_typed(
20+
const Tensor& input1,
21+
const int32_t zp1,
22+
const Tensor& input2,
23+
const int32_t zp2,
24+
const int32_t out_zp,
25+
const float effective_scale,
26+
Tensor& out) {
27+
const T* input1_ptr = input1.data_ptr<T>();
28+
const T* input2_ptr = input2.data_ptr<T>();
29+
T* out_ptr = out.mutable_data_ptr<T>();
30+
31+
// Saturation bounds kept in float: a denominator quantized to a single step
32+
// off its zero point yields a very large quotient, so rounding and clamping
33+
// in float avoids overflowing int32 before the saturating cast below.
34+
constexpr float kActivationMin =
35+
static_cast<float>(std::numeric_limits<T>::min());
36+
constexpr float kActivationMax =
37+
static_cast<float>(std::numeric_limits<T>::max());
38+
39+
const int64_t num_elements = out.numel();
40+
for (int64_t i = 0; i < num_elements; ++i) {
41+
const int32_t numerator = static_cast<int32_t>(input1_ptr[i]) - zp1;
42+
const int32_t denominator = static_cast<int32_t>(input2_ptr[i]) - zp2;
43+
44+
// A zero-point-corrected denominator of 0 has no representable reciprocal;
45+
// emit a 0 quotient so the op stays total (callers keep divisors off the
46+
// zero point).
47+
const float quotient = (denominator != 0)
48+
? static_cast<float>(numerator) / static_cast<float>(denominator)
49+
: 0.0f;
50+
51+
const float scaled =
52+
std::round(quotient * effective_scale) + static_cast<float>(out_zp);
53+
const float clamped =
54+
std::max(kActivationMin, std::min(kActivationMax, scaled));
55+
out_ptr[i] = static_cast<T>(clamped);
56+
}
57+
}
58+
59+
} // namespace
60+
61+
using KernelRuntimeContext = torch::executor::KernelRuntimeContext;
62+
63+
// CMSIS-NN has no integer elementwise-division primitive, so the quotient is
64+
// evaluated in float. Unlike quantized_mul/add there is no fixed-point path to
65+
// feed, so the effective scale (scale_in1 / (scale_in2 * scale_out)) is
66+
// computed AoT and carried directly as a float rather than as a
67+
// multiplier/shift pair. Both int8 and int16 activations are supported.
68+
// cppcheck-suppress unusedFunction
69+
Tensor& quantized_div_out(
70+
KernelRuntimeContext& context,
71+
const Tensor& input1,
72+
const int64_t input1_zero_point,
73+
const Tensor& input2,
74+
const int64_t input2_zero_point,
75+
const int64_t output_zero_point,
76+
const double output_scale,
77+
Tensor& out) {
78+
const ScalarType dtype = out.scalar_type();
79+
if (dtype != ScalarType::Char && dtype != ScalarType::Short) {
80+
ET_LOG(
81+
Error,
82+
"quantized_div: only int8 and int16 are supported, got %d",
83+
static_cast<int>(dtype));
84+
context.fail(Error::InvalidArgument);
85+
return out;
86+
}
87+
88+
// Division is not commutative, so channel broadcasting (which relies on
89+
// operand swapping in quantized_mul) is unsupported: require equal shapes.
90+
validate_cmsis_nn_tensor_requirements(
91+
input1,
92+
input2,
93+
out,
94+
dtype,
95+
/*require_channels_last=*/false,
96+
/*require_same_sizes=*/true);
97+
98+
// The rescale is carried entirely by effective_scale (float), so the shared
99+
// validator only needs to sanity-check the three zero points; pass identity
100+
// multiplier/shift for each operand.
101+
const int32_t kIdentityMultiplier(/*value=*/1);
102+
const int32_t kZeroShift(/*value=*/0);
103+
validate_quantization_params(
104+
input1_zero_point,
105+
kIdentityMultiplier,
106+
kZeroShift,
107+
input2_zero_point,
108+
kIdentityMultiplier,
109+
kZeroShift,
110+
output_zero_point,
111+
kIdentityMultiplier,
112+
kZeroShift);
113+
114+
const int32_t zp1 = static_cast<int32_t>(input1_zero_point);
115+
const int32_t zp2 = static_cast<int32_t>(input2_zero_point);
116+
const int32_t out_zp = static_cast<int32_t>(output_zero_point);
117+
118+
const float effective_scale = static_cast<float>(output_scale);
119+
120+
if (dtype == ScalarType::Char) {
121+
quantized_div_typed<int8_t>(
122+
input1, zp1, input2, zp2, out_zp, effective_scale, out);
123+
} else {
124+
quantized_div_typed<int16_t>(
125+
input1, zp1, input2, zp2, out_zp, effective_scale, out);
126+
}
127+
128+
return out;
129+
}
130+
131+
} // namespace native
132+
} // namespace cortex_m

backends/cortex_m/ops/operators.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,71 @@ def quantized_mul_impl(
264264
return result
265265

266266

267+
# ===================================================================
268+
# QUANTIZED DIV OPERATION DEFINITION
269+
# ===================================================================
270+
lib.define(
271+
"quantized_div("
272+
"Tensor self, int self_zero_point, "
273+
"Tensor other, int other_zero_point, "
274+
"int output_zero_point, float output_scale) -> Tensor"
275+
)
276+
lib.define(
277+
"quantized_div.out("
278+
"Tensor self, int self_zero_point, "
279+
"Tensor other, int other_zero_point, "
280+
"int output_zero_point, float output_scale, "
281+
"*, Tensor(a!) out) -> Tensor(a!)"
282+
)
283+
284+
285+
@register_fake("cortex_m::quantized_div") # type: ignore[misc]
286+
def quantized_div_meta(
287+
self: torch.Tensor,
288+
self_zero_point: int,
289+
other: torch.Tensor,
290+
other_zero_point: int,
291+
output_zero_point: int,
292+
output_scale: float,
293+
) -> torch.Tensor:
294+
# Division is not commutative, so broadcasting (handled via operand swaps in
295+
# quantized_mul) is not supported: require identical shapes.
296+
assert self.shape == other.shape, (
297+
"Cortex-M quantized_div: broadcasting is not supported — "
298+
f"got self.shape={self.shape}, other.shape={other.shape}"
299+
)
300+
return torch.empty_like(self)
301+
302+
303+
@impl(lib, "quantized_div", "CompositeExplicitAutograd") # type: ignore[misc]
304+
def quantized_div_impl(
305+
self: torch.Tensor,
306+
self_zero_point: int,
307+
other: torch.Tensor,
308+
other_zero_point: int,
309+
output_zero_point: int,
310+
output_scale: float,
311+
) -> torch.Tensor:
312+
# Mirror the kernel: the quotient of the zero-point-corrected int8/int16
313+
# operands is evaluated in float and rescaled by the effective scale
314+
# (scale_in1 / (scale_in2 * scale_out)) that the AoT pass carries directly.
315+
assert self.shape == other.shape, (
316+
"Cortex-M quantized_div: broadcasting is not supported — "
317+
f"got self.shape={self.shape}, other.shape={other.shape}"
318+
)
319+
if self.dtype not in (torch.int8, torch.int16):
320+
raise TypeError(
321+
f"cortex_m.quantized_div: expected int8 or int16 inputs, got {self.dtype}"
322+
)
323+
self_fp = (self.to(torch.int32) - self_zero_point).to(torch.float32)
324+
other_fp = (other.to(torch.int32) - other_zero_point).to(torch.float32)
325+
326+
quotient = torch.where(other_fp != 0, self_fp / other_fp, torch.zeros_like(self_fp))
327+
result = torch.round(quotient * output_scale) + output_zero_point
328+
dtype_info = torch.iinfo(self.dtype)
329+
return torch.clamp(result, dtype_info.min, dtype_info.max).to(self.dtype)
330+
331+
267332
# ===================================================================
268333
# QUANTIZED ACTIVATION (LUT) OPERATION DEFINITION
269334
# ===================================================================

backends/cortex_m/ops/operators.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
- arg_meta: null
3030
kernel_name: cortex_m::quantized_mul_out
3131

32+
- func: cortex_m::quantized_div.out(Tensor self, int self_zero_point, Tensor other, int other_zero_point, int output_zero_point, float output_scale, *, Tensor(a!) out) -> Tensor(a!)
33+
variants: function
34+
kernels:
35+
- arg_meta: null
36+
kernel_name: cortex_m::quantized_div_out
37+
3238
- func: cortex_m::quantized_activation.out(Tensor input, Tensor lut, *, Tensor(a!) out) -> Tensor(a!)
3339
variants: function
3440
kernels:

backends/cortex_m/ops/targets.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ OPERATORS = [
5858
"dequantize_per_tensor",
5959
"quantized_add",
6060
"quantized_mul",
61+
"quantized_div",
6162
"minimum",
6263
"maximum",
6364
"quantized_linear",

backends/cortex_m/passes/aten_to_cortex_m_pass.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,36 @@ def _get_mul_replacement(
960960
return DialectNodeSpec(exir_ops.edge.cortex_m.quantized_mul.default, args)
961961

962962

963+
@AtenToCortexMPass.register_dialect_substitution(exir_ops.edge.aten.div.Tensor)
964+
def _get_div_replacement(
965+
node: Node, dialect_pass: AtenToDialectPass
966+
) -> DialectNodeSpec | None:
967+
del dialect_pass
968+
if not _has_qparams(node):
969+
return None
970+
971+
scale1 = node.meta["input_qparams"][0].scale
972+
zero_point1 = node.meta["input_qparams"][0].zp
973+
scale2 = node.meta["input_qparams"][1].scale
974+
zero_point2 = node.meta["input_qparams"][1].zp
975+
output_scale = node.meta["output_qparams"][0].scale
976+
output_zero_point = node.meta["output_qparams"][0].zp
977+
978+
# No CMSIS fixed-point div path exists, so the kernel divides in float;
979+
# carry the effective scale directly rather than encoding it as a
980+
# multiplier/shift pair (as quantized_mul/add do).
981+
effective_scale = float(scale1 / (scale2 * output_scale))
982+
args = (
983+
node.args[0],
984+
zero_point1,
985+
node.args[1],
986+
zero_point2,
987+
output_zero_point,
988+
effective_scale,
989+
)
990+
return DialectNodeSpec(exir_ops.edge.cortex_m.quantized_div.default, args)
991+
992+
963993
@AtenToCortexMPass.register_dialect_substitution(exir_ops.edge.aten._softmax.default)
964994
def _get_softmax_replacement(
965995
node: Node, dialect_pass: AtenToDialectPass

backends/cortex_m/quantizer/pattern_checkers.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,48 @@ def check_quantization_config(
5656
return is_per_tensor and is_int8
5757

5858

59+
class CortexMDivCheck(PatternCheck):
60+
61+
@classmethod
62+
def check_pattern(cls, pattern):
63+
"""
64+
Reject any broadcasting. Division is not commutative, so the operand
65+
swapping used to support channel broadcast in add/mul does not apply;
66+
only identically shaped inputs are supported.
67+
"""
68+
for node in pattern:
69+
if len(node.all_input_nodes) == 2:
70+
t1 = get_first_fake_tensor(node.all_input_nodes[0])
71+
t2 = get_first_fake_tensor(node.all_input_nodes[1])
72+
if t1.shape != t2.shape:
73+
return False
74+
75+
return True
76+
77+
@classmethod
78+
def check_quantization_config(
79+
cls, pattern: list[Node], quantization_config: QuantizationConfig
80+
) -> bool:
81+
"""
82+
Checks that the quantization config uses per-tensor int8 or int16
83+
quantization (the div kernel supports both).
84+
"""
85+
input_qspec = quantization_config.get_input_act_qspec()
86+
output_qspec = quantization_config.get_output_act_qspec()
87+
is_per_tensor = PatternCheck.is_per_tensor(
88+
input_qspec
89+
) and PatternCheck.is_per_tensor(output_qspec)
90+
allowed_dtypes = (torch.int8, torch.int16)
91+
is_valid_dtype = (
92+
isinstance(input_qspec, QuantizationSpec)
93+
and isinstance(output_qspec, QuantizationSpec)
94+
and input_qspec.dtype in allowed_dtypes
95+
and output_qspec.dtype in allowed_dtypes
96+
and input_qspec.dtype == output_qspec.dtype
97+
)
98+
return is_per_tensor and is_valid_dtype
99+
100+
59101
class CortexMConv2DCheck(PatternCheck):
60102
@classmethod
61103
def check_pattern(cls, pattern):

backends/cortex_m/quantizer/quantization_configs.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@
5959
ch_axis=0,
6060
)
6161

62+
# 16-bit activation spec. Symmetric (zero point 0) with the -32767 quant_min
63+
# used by the Arm a16w8 config, so the range is symmetric about zero.
64+
INT16_ACTIVATION_PER_TENSOR_QSPEC = QuantizationSpec(
65+
dtype=torch.int16,
66+
observer_or_fake_quant_ctr=MinMaxObserver,
67+
qscheme=torch.per_tensor_symmetric,
68+
quant_min=-32767,
69+
quant_max=32767,
70+
)
71+
6272
# Constants shared by Cortex-M quantized operators.
6373
CMSIS_SOFTMAX_SCALE: float = 1.0 / 256.0
6474
CMSIS_SOFTMAX_ZERO_POINT: int = -128
@@ -196,6 +206,17 @@ def get_bias_qspec(
196206
)
197207

198208

209+
# int16 activations (weight/bias qspecs are unused by weightless elementwise
210+
# ops such as quantized_div; they carry the int8/int32 defaults).
211+
INT16_PER_TENSOR_CONFIG = CortexMQuantizationConfig(
212+
INT16_ACTIVATION_PER_TENSOR_QSPEC,
213+
INT16_ACTIVATION_PER_TENSOR_QSPEC,
214+
INT8_WEIGHT_PER_TENSOR_QSPEC,
215+
_get_int32_bias_qspec,
216+
f"{__name__}.INT16_PER_TENSOR_CONFIG",
217+
)
218+
219+
199220
INT8_PER_CHANNEL_CONFIG = CortexMQuantizationConfig(
200221
INT8_ACTIVATION_PER_TENSOR_QSPEC,
201222
INT8_ACTIVATION_PER_TENSOR_QSPEC,

backends/cortex_m/quantizer/quantizer.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
PatternQuantizer,
1313
SharedQspecQuantizer,
1414
)
15+
from executorch.backends.arm.quantizer.quantization_config import QuantizationConfig
1516
from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager
1617
from executorch.backends.cortex_m.quantizer.node_finders import (
1718
GlobalNodeFinder,
@@ -45,7 +46,20 @@ def mark_node_as_annotated(
4546

4647
class CortexMQuantizer(ComposableQuantizer):
4748

48-
def __init__(self) -> None:
49+
def __init__(self, per_tensor_config: Optional[QuantizationConfig] = None) -> None:
50+
"""Cortex-M PT2E quantizer.
51+
52+
Args:
53+
per_tensor_config: Per-tensor activation config applied to the
54+
non-conv elementwise ops (div/mul/add/...) that
55+
``GlobalNodeFinder`` matches anywhere in the graph. Convolutions
56+
are always quantized with the per-channel config. Defaults to
57+
``INT8_PER_TENSOR_CONFIG``; pass ``INT16_PER_TENSOR_CONFIG`` to
58+
quantize the ops that support it (e.g. ``quantized_div``) with
59+
int16 activations.
60+
"""
61+
per_tensor_config = per_tensor_config or INT8_PER_TENSOR_CONFIG
62+
4963
conv_targets: set[OpOverload] = set()
5064
for key in CONV_OP_PATTERNS.keys() | CONV_TRANSPOSE_OP_PATTERNS.keys():
5165
conv_targets.update(key)
@@ -67,7 +81,7 @@ def __init__(self) -> None:
6781
pattern_matcher=pattern_matcher,
6882
),
6983
PatternQuantizer(
70-
INT8_PER_TENSOR_CONFIG,
84+
per_tensor_config,
7185
node_finder=GlobalNodeFinder(),
7286
pattern_matcher=pattern_matcher,
7387
),

0 commit comments

Comments
 (0)