Skip to content

Commit 73f94ec

Browse files
pssrawatfacebook-github-bot
authored andcommitted
Per-channel int8 quantized linear (pytorch#24397)
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 97cc4fe commit 73f94ec

7 files changed

Lines changed: 330 additions & 34 deletions

File tree

‎backends/cortex_m/ops/op_quantized_linear.cpp‎

Lines changed: 96 additions & 16 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,
@@ -92,16 +89,99 @@ Tensor& quantized_linear_out(
9289
output_data);
9390

9491
if (status != ARM_CMSIS_NN_SUCCESS) {
95-
ET_LOG(
96-
Error,
97-
"quantized_linear_out: CMSIS-NN failed with status [%d]",
98-
status);
92+
ET_LOG(Error, "%s: CMSIS-NN failed with status [%d]", op_name, status);
9993
context.fail(Error::Internal);
10094
return out;
10195
}
10296

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

106186
} // namespace native
107187
} // 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: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -360,21 +360,29 @@ 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+
if weight_qparams.per_channel:
371+
weight_scales = weight_qparams.get_scale_per_channel()
372+
weight_zps = weight_qparams.get_zp_per_channel()
373+
else:
374+
weight_scales, weight_zps = [weight_qparams.scale], [weight_qparams.zp]
375+
if any(zp != 0 for zp in weight_zps):
371376
raise NotImplementedError(
372377
f"cortex_m::quantized_linear assumes symmetric weight "
373-
f"quantization (weight_zp == 0); got weight_zp={weight_zp}"
378+
f"quantization (weight_zp == 0); got weight_zp={weight_qparams.zp}"
374379
)
375380

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

380388
# CMSIS-NN's MVE `arm_fully_connected_s8` path reads a precomputed
@@ -386,14 +394,23 @@ def _get_linear_replacement(
386394
linear_args = node.args
387395
weights = cast(Node, linear_args[1])
388396
weights_tensor = get_param_tensor(exported_program, weights)
397+
if weights_tensor is None:
398+
raise RuntimeError(
399+
f"Expected linear weight parameter tensor for node {node.name}."
400+
)
401+
if len(weight_scales) not in (1, weights_tensor.shape[0]):
402+
raise ValueError(
403+
f"cortex_m::quantized_linear expects one weight scale per output "
404+
f"feature ({weights_tensor.shape[0]}); got {len(weight_scales)}"
405+
)
389406
bias_node = cast(Node | None, linear_args[2]) if len(linear_args) > 2 else None
390407
bias_tensor = (
391408
get_param_tensor(exported_program, bias_node) if bias_node is not None else None
392409
)
393410

394411
if target_config.backend == cmsis_nn.Backend.MVE:
395412
kernel_sum_tensor = _compute_kernel_sum(
396-
weights_tensor, bias_tensor, -input_zp, -weight_zp
413+
weights_tensor, bias_tensor, -input_zp, -weight_zps[0]
397414
)
398415
with node.graph.inserting_after(weights):
399416
kernel_sum_arg = create_constant_placeholder(
@@ -408,21 +425,44 @@ def _get_linear_replacement(
408425
kernel_sum_arg = None
409426
bias_arg = bias_node
410427

428+
multipliers_arg: Argument
429+
shifts_arg: Argument
430+
if len(weight_scales) == 1:
431+
target = exir_ops.edge.cortex_m.quantized_linear.default
432+
multipliers_arg = list(quantized_multipliers)
433+
shifts_arg = list(quantized_shifts)
434+
else:
435+
target = exir_ops.edge.cortex_m.quantized_linear_per_channel.default
436+
with node.graph.inserting_after(weights):
437+
multipliers_arg, shifts_arg = (
438+
create_constant_placeholder(
439+
exported_program,
440+
node.graph,
441+
node.name + suffix,
442+
InputKind.PARAMETER,
443+
torch.tensor(values, dtype=torch.int32),
444+
)
445+
for suffix, values in (
446+
("_quantized_multiplier", quantized_multipliers),
447+
("_quantized_shift", quantized_shifts),
448+
)
449+
)
450+
411451
args = (
412452
linear_args[0],
413453
weights,
414454
bias_arg,
415455
kernel_sum_arg,
416456
-input_zp,
417-
-weight_zp,
457+
-weight_zps[0],
418458
output_zp,
419-
[quantized_multiplier],
420-
[quantized_shift],
459+
multipliers_arg,
460+
shifts_arg,
421461
output_max,
422462
output_min,
423463
)
424464

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

427467

428468
@AtenToCortexMPass.register_dialect_substitution(

‎backends/cortex_m/quantizer/pattern_checkers.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,19 @@ 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 (
161+
is_int8
162+
and isinstance(weight_qspec, QuantizationSpec)
163+
and weight_qspec.ch_axis in (0, None)
164+
)
155165
return is_int8
156166

157167

‎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)