Skip to content

Commit 47819b6

Browse files
pssrawatfacebook-github-bot
authored andcommitted
Per-channel int8 quantized linear
Summary: `cortex_m::quantized_linear` supports only per-tensor weights: it calls `arm_fully_connected_s8` with a single requantization multiplier and shift. This adds per-channel linear weights. With `CortexMQuantizer.set_node_target` (previous diff), `set_node_target(torch.ops.aten.linear.default, INT8_PER_CHANNEL_CONFIG)` gives per-channel int8 linear weights; without it, linear layers keep `per_tensor_config`, so existing exports are unchanged. `CortexMLinearCheck` rejects per-channel weights that are not quantized per output feature (axis 0, or unset as the convolution check allows), and the lowering checks that it gets one weight scale per output feature, since the kernel indexes its multipliers by output feature. Per-channel linear weights lower to a new `cortex_m::quantized_linear_per_channel`. It matches `quantized_linear` but takes int32 multiplier and shift tensors, as the convolution ops do, and calls `arm_fully_connected_per_channel_s8`. CMSIS-NN takes int32 arrays, so tensors avoid any runtime conversion. `int[]` lists would also cost about 20 bytes per output feature in the program, where a tensor costs 4. The two C++ entry points share one implementation that takes the CMSIS-NN call; `quantized_linear` is unchanged. The Python reference requantizes each output feature with its own parameters. This diff was authored with Claude Code. Differential Revision: D126929702
1 parent 2e24f0f commit 47819b6

7 files changed

Lines changed: 319 additions & 31 deletions

File tree

‎backends/cortex_m/ops/op_quantized_linear.cpp‎

Lines changed: 97 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ namespace cortex_m {
1313
namespace native {
1414
using KernelRuntimeContext = torch::executor::KernelRuntimeContext;
1515

16-
// cppcheck-suppress unusedFunction
17-
Tensor& quantized_linear_out(
16+
namespace {
17+
// Runs `fully_connected`, a CMSIS-NN fully connected call bound to its
18+
// requantization parameters, on the op's tensors.
19+
template <typename FullyConnected>
20+
Tensor& quantized_linear_impl(
21+
const char* op_name,
1822
KernelRuntimeContext& context,
1923
const Tensor& input,
2024
const Tensor& weights,
@@ -23,12 +27,11 @@ Tensor& quantized_linear_out(
2327
const int64_t input_offset,
2428
const int64_t filter_offset,
2529
const int64_t output_offset,
26-
const Int64ArrayRef requantize_multipliers,
27-
const Int64ArrayRef requantize_shifts,
2830
const int64_t activation_max,
2931
const int64_t activation_min,
32+
FullyConnected fully_connected,
3033
Tensor& out) {
31-
ET_LOG(Debug, "quantized_linear_out: called");
34+
ET_LOG(Debug, "%s: called", op_name);
3235

3336
const int8_t* input_data = input.const_data_ptr<int8_t>();
3437
const int8_t* weight_data = weights.const_data_ptr<int8_t>();
@@ -50,11 +53,6 @@ Tensor& quantized_linear_out(
5053
fc_params.activation.min = static_cast<int32_t>(activation_min);
5154
fc_params.activation.max = static_cast<int32_t>(activation_max);
5255

53-
cmsis_nn_per_tensor_quant_params per_tensor_quant_params;
54-
per_tensor_quant_params.multiplier =
55-
static_cast<int32_t>(requantize_multipliers.at(0));
56-
per_tensor_quant_params.shift = static_cast<int32_t>(requantize_shifts.at(0));
57-
5856
auto in_feat = input.size(input.dim() - 1);
5957
auto out_feat = out.size(out.dim() - 1);
6058
auto batches = 1;
@@ -78,10 +76,9 @@ Tensor& quantized_linear_out(
7876
cmsis_nn_dims bias_dims = {1, 1, 1, out_feat};
7977
cmsis_nn_dims output_dims = {batches, 1, 1, out_feat};
8078

81-
arm_cmsis_nn_status status = arm_fully_connected_s8(
79+
const arm_cmsis_nn_status status = fully_connected(
8280
&ctx,
8381
&fc_params,
84-
&per_tensor_quant_params,
8582
&input_dims,
8683
input_data,
8784
&filter_dims,
@@ -94,14 +91,101 @@ Tensor& quantized_linear_out(
9491
if (status != ARM_CMSIS_NN_SUCCESS) {
9592
ET_LOG(
9693
Error,
97-
"quantized_linear_out: CMSIS-NN failed with status [%d]",
94+
"%s: CMSIS-NN failed with status [%d]",
95+
op_name,
9896
status);
9997
context.fail(Error::Internal);
10098
return out;
10199
}
102100

103101
return out;
104102
}
103+
} // namespace
104+
105+
// cppcheck-suppress unusedFunction
106+
Tensor& quantized_linear_out(
107+
KernelRuntimeContext& context,
108+
const Tensor& input,
109+
const Tensor& weights,
110+
const std::optional<Tensor>& bias,
111+
const std::optional<Tensor>& kernel_sum,
112+
const int64_t input_offset,
113+
const int64_t filter_offset,
114+
const int64_t output_offset,
115+
const Int64ArrayRef requantize_multipliers,
116+
const Int64ArrayRef requantize_shifts,
117+
const int64_t activation_max,
118+
const int64_t activation_min,
119+
Tensor& out) {
120+
const cmsis_nn_per_tensor_quant_params quant_params{
121+
static_cast<int32_t>(requantize_multipliers.at(0)),
122+
static_cast<int32_t>(requantize_shifts.at(0))};
123+
return quantized_linear_impl(
124+
"quantized_linear_out",
125+
context,
126+
input,
127+
weights,
128+
bias,
129+
kernel_sum,
130+
input_offset,
131+
filter_offset,
132+
output_offset,
133+
activation_max,
134+
activation_min,
135+
[&quant_params](auto ctx, auto fc_params, auto... args) {
136+
return arm_fully_connected_s8(ctx, fc_params, &quant_params, args...);
137+
},
138+
out);
139+
}
140+
141+
// cppcheck-suppress unusedFunction
142+
Tensor& quantized_linear_per_channel_out(
143+
KernelRuntimeContext& context,
144+
const Tensor& input,
145+
const Tensor& weights,
146+
const std::optional<Tensor>& bias,
147+
const std::optional<Tensor>& kernel_sum,
148+
const int64_t input_offset,
149+
const int64_t filter_offset,
150+
const int64_t output_offset,
151+
const Tensor& requantize_multipliers,
152+
const Tensor& requantize_shifts,
153+
const int64_t activation_max,
154+
const int64_t activation_min,
155+
Tensor& out) {
156+
const int64_t out_features = out.size(out.dim() - 1);
157+
if (requantize_multipliers.scalar_type() != ScalarType::Int ||
158+
requantize_shifts.scalar_type() != ScalarType::Int ||
159+
requantize_multipliers.numel() != out_features ||
160+
requantize_shifts.numel() != out_features) {
161+
ET_LOG(
162+
Error,
163+
"quantized_linear_per_channel_out: requantization parameters must be int32 with one per output feature (%d)",
164+
static_cast<int>(out_features));
165+
context.fail(Error::InvalidArgument);
166+
return out;
167+
}
168+
const cmsis_nn_per_channel_quant_params quant_params{
169+
requantize_multipliers.mutable_data_ptr<int32_t>(),
170+
requantize_shifts.mutable_data_ptr<int32_t>()};
171+
return quantized_linear_impl(
172+
"quantized_linear_per_channel_out",
173+
context,
174+
input,
175+
weights,
176+
bias,
177+
kernel_sum,
178+
input_offset,
179+
filter_offset,
180+
output_offset,
181+
activation_max,
182+
activation_min,
183+
[&quant_params](auto ctx, auto fc_params, auto... args) {
184+
return arm_fully_connected_per_channel_s8(
185+
ctx, fc_params, &quant_params, args...);
186+
},
187+
out);
188+
}
105189

106190
} // namespace native
107191
} // namespace cortex_m

‎backends/cortex_m/ops/operators.py‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -580,16 +580,46 @@ def quantized_linear_impl(
580580
output_shape = (*input.shape[:-1], output.shape[-1])
581581
output_reshaped = output.reshape(output_shape)
582582

583-
output = requantize_cmsis(
584-
output_reshaped,
585-
int(requantize_multipliers[0]),
586-
int(requantize_shifts[0]),
587-
)
583+
if len(requantize_multipliers) == 1:
584+
output = requantize_cmsis(
585+
output_reshaped,
586+
int(requantize_multipliers[0]),
587+
int(requantize_shifts[0]),
588+
)
589+
else:
590+
output = torch.stack(
591+
[
592+
requantize_cmsis(output_reshaped[..., channel], int(mult), int(shift))
593+
for channel, (mult, shift) in enumerate(
594+
zip(requantize_multipliers, requantize_shifts)
595+
)
596+
],
597+
dim=-1,
598+
)
588599
output += output_offset
589600
output = torch.clamp(output, activation_min, activation_max).to(torch.int8)
590601
return output
591602

592603

604+
# Same as quantized_linear, with one int32 requantization multiplier and shift
605+
# per output feature.
606+
_LINEAR_PER_CHANNEL_ARGS = (
607+
"Tensor input, Tensor weights, Tensor? bias, Tensor? kernel_sum, "
608+
"int input_offset, int filter_offset, int output_offset, "
609+
"Tensor requantize_multipliers, Tensor requantize_shifts, "
610+
"int activation_max, int activation_min"
611+
)
612+
lib.define(f"quantized_linear_per_channel({_LINEAR_PER_CHANNEL_ARGS}) -> Tensor")
613+
lib.define(
614+
f"quantized_linear_per_channel.out({_LINEAR_PER_CHANNEL_ARGS}, "
615+
"*, Tensor(a!) out) -> Tensor(a!)"
616+
)
617+
register_fake("cortex_m::quantized_linear_per_channel")(quantized_linear_meta)
618+
impl(lib, "quantized_linear_per_channel", "CompositeExplicitAutograd")(
619+
quantized_linear_impl
620+
)
621+
622+
593623
# ===================================================================
594624
# SOFTMAX OPERATION DEFINITION
595625
# ===================================================================

‎backends/cortex_m/ops/operators.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@
5959
- arg_meta: null
6060
kernel_name: cortex_m::quantized_linear_out
6161

62+
- func: cortex_m::quantized_linear_per_channel.out(Tensor input, Tensor weights, Tensor? bias, Tensor? kernel_sum, int input_offset, int filter_offset, int output_offset, Tensor requantize_multipliers, Tensor requantize_shifts, int activation_max, int activation_min, *, Tensor(a!) out) -> Tensor(a!)
63+
variants: function
64+
kernels:
65+
- arg_meta: null
66+
kernel_name: cortex_m::quantized_linear_per_channel_out
67+
6268
- func: cortex_m::softmax.out(Tensor input, int dim, int input_zero_point, int output_zero_point, int input_multiplier, int input_shift, int diff_min, *, Tensor(a!) out) -> Tensor(a!)
6369
variants: function
6470
kernels:

‎backends/cortex_m/passes/aten_to_cortex_m_pass.py‎

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -360,21 +360,28 @@ def _get_linear_replacement(
360360

361361
input_scale = node.meta["input_qparams"][0].scale
362362
input_zp = node.meta["input_qparams"][0].zp
363-
weight_scale = node.meta["input_qparams"][1].scale
364-
weight_zp = node.meta["input_qparams"][1].zp
363+
weight_qparams = node.meta["input_qparams"][1]
365364
output_scale = node.meta["output_qparams"][0].scale
366365
output_zp = node.meta["output_qparams"][0].zp
367366
output_min = node.meta["output_qparams"][0].qmin
368367
output_max = node.meta["output_qparams"][0].qmax
369368

370-
if weight_zp != 0:
369+
# Per-channel weights carry one scale and zero point per output feature.
370+
weight_scales = weight_qparams.scale
371+
weight_zps = weight_qparams.zp
372+
if not isinstance(weight_scales, list):
373+
weight_scales, weight_zps = [weight_scales], [weight_zps]
374+
if any(zp != 0 for zp in weight_zps):
371375
raise NotImplementedError(
372376
f"cortex_m::quantized_linear assumes symmetric weight "
373-
f"quantization (weight_zp == 0); got weight_zp={weight_zp}"
377+
f"quantization (weight_zp == 0); got weight_zp={weight_qparams.zp}"
374378
)
375379

376-
quantized_multiplier, quantized_shift = quantize_multiplier_aot(
377-
(input_scale * weight_scale) / output_scale
380+
quantized_multipliers, quantized_shifts = zip(
381+
*(
382+
quantize_multiplier_aot((input_scale * weight_scale) / output_scale)
383+
for weight_scale in weight_scales
384+
)
378385
)
379386

380387
# CMSIS-NN's MVE `arm_fully_connected_s8` path reads a precomputed
@@ -386,14 +393,19 @@ def _get_linear_replacement(
386393
linear_args = node.args
387394
weights = cast(Node, linear_args[1])
388395
weights_tensor = get_param_tensor(exported_program, weights)
396+
if len(weight_scales) not in (1, weights_tensor.shape[0]):
397+
raise ValueError(
398+
f"cortex_m::quantized_linear expects one weight scale per output "
399+
f"feature ({weights_tensor.shape[0]}); got {len(weight_scales)}"
400+
)
389401
bias_node = cast(Node | None, linear_args[2]) if len(linear_args) > 2 else None
390402
bias_tensor = (
391403
get_param_tensor(exported_program, bias_node) if bias_node is not None else None
392404
)
393405

394406
if target_config.backend == cmsis_nn.Backend.MVE:
395407
kernel_sum_tensor = _compute_kernel_sum(
396-
weights_tensor, bias_tensor, -input_zp, -weight_zp
408+
weights_tensor, bias_tensor, -input_zp, 0
397409
)
398410
with node.graph.inserting_after(weights):
399411
kernel_sum_arg = create_constant_placeholder(
@@ -408,21 +420,41 @@ def _get_linear_replacement(
408420
kernel_sum_arg = None
409421
bias_arg = bias_node
410422

423+
if len(weight_scales) == 1:
424+
target = exir_ops.edge.cortex_m.quantized_linear.default
425+
multipliers_arg, shifts_arg = list(quantized_multipliers), list(quantized_shifts)
426+
else:
427+
target = exir_ops.edge.cortex_m.quantized_linear_per_channel.default
428+
with node.graph.inserting_after(weights):
429+
multipliers_arg, shifts_arg = (
430+
create_constant_placeholder(
431+
exported_program,
432+
node.graph,
433+
node.name + suffix,
434+
InputKind.PARAMETER,
435+
torch.tensor(values, dtype=torch.int32),
436+
)
437+
for suffix, values in (
438+
("_quantized_multiplier", quantized_multipliers),
439+
("_quantized_shift", quantized_shifts),
440+
)
441+
)
442+
411443
args = (
412444
linear_args[0],
413445
weights,
414446
bias_arg,
415447
kernel_sum_arg,
416448
-input_zp,
417-
-weight_zp,
449+
0,
418450
output_zp,
419-
[quantized_multiplier],
420-
[quantized_shift],
451+
multipliers_arg,
452+
shifts_arg,
421453
output_max,
422454
output_min,
423455
)
424456

425-
return DialectNodeSpec(exir_ops.edge.cortex_m.quantized_linear.default, args)
457+
return DialectNodeSpec(target, args)
426458

427459

428460
@AtenToCortexMPass.register_dialect_substitution(

‎backends/cortex_m/quantizer/pattern_checkers.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,15 @@ def check_quantization_config(
149149
cls, pattern: list[Node], quantization_config: QuantizationConfig
150150
) -> bool:
151151
"""
152-
Checks that the quantization config uses per-tensor int8 quantization.
152+
Checks that the quantization config uses int8 activations, and that
153+
per-channel weights are quantized per output feature (axis 0).
153154
"""
154155
is_int8 = cls.is_int8_activations(quantization_config)
156+
weight_qspec = quantization_config.get_weight_qspec(
157+
pattern[0] if pattern else None
158+
)
159+
if cls.is_per_channel(weight_qspec):
160+
return is_int8 and weight_qspec.ch_axis in (0, None)
155161
return is_int8
156162

157163

‎backends/cortex_m/test/build_test_runner.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ ops_list=(
6767
cortex_m::minimum.out
6868
cortex_m::maximum.out
6969
cortex_m::quantized_linear.out
70+
cortex_m::quantized_linear_per_channel.out
7071
cortex_m::softmax.out
7172
cortex_m::transpose.out
7273
cortex_m::pad.out

0 commit comments

Comments
 (0)