Skip to content

Commit 39f096f

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. 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. Reviewed By: rascani Differential Revision: D113440315
1 parent 85a204a commit 39f096f

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)