Skip to content

Commit a168321

Browse files
authored
Per-channel int8 quantized linear (#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 Pull Request resolved: #24397
1 parent b3e2457 commit a168321

8 files changed

Lines changed: 336 additions & 48 deletions

File tree

‎backends/cortex_m/ops/op_quantized_linear.cpp‎

Lines changed: 103 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ 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+
template <typename QuantParams, typename FullyConnectedFn>
18+
Tensor& quantized_linear_impl(
19+
FullyConnectedFn fully_connected,
20+
const char* op_name,
1821
KernelRuntimeContext& context,
1922
const Tensor& input,
2023
const Tensor& weights,
@@ -23,12 +26,11 @@ Tensor& quantized_linear_out(
2326
const int64_t input_offset,
2427
const int64_t filter_offset,
2528
const int64_t output_offset,
26-
const Int64ArrayRef requantize_multipliers,
27-
const Int64ArrayRef requantize_shifts,
2829
const int64_t activation_max,
2930
const int64_t activation_min,
31+
const QuantParams& quant_params,
3032
Tensor& out) {
31-
ET_LOG(Debug, "quantized_linear_out: called");
33+
ET_LOG(Debug, "%s: called", op_name);
3234

3335
const int8_t* input_data = input.const_data_ptr<int8_t>();
3436
const int8_t* weight_data = weights.const_data_ptr<int8_t>();
@@ -50,11 +52,6 @@ Tensor& quantized_linear_out(
5052
fc_params.activation.min = static_cast<int32_t>(activation_min);
5153
fc_params.activation.max = static_cast<int32_t>(activation_max);
5254

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-
5855
auto in_feat = input.size(input.dim() - 1);
5956
auto out_feat = out.size(out.dim() - 1);
6057
auto batches = 1;
@@ -78,10 +75,10 @@ Tensor& quantized_linear_out(
7875
cmsis_nn_dims bias_dims = {1, 1, 1, out_feat};
7976
cmsis_nn_dims output_dims = {batches, 1, 1, out_feat};
8077

81-
arm_cmsis_nn_status status = arm_fully_connected_s8(
78+
const arm_cmsis_nn_status status = fully_connected(
8279
&ctx,
8380
&fc_params,
84-
&per_tensor_quant_params,
81+
&quant_params,
8582
&input_dims,
8683
input_data,
8784
&filter_dims,
@@ -92,16 +89,106 @@ 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+
arm_fully_connected_s8,
121+
"quantized_linear_out",
122+
context,
123+
input,
124+
weights,
125+
bias,
126+
kernel_sum,
127+
input_offset,
128+
filter_offset,
129+
output_offset,
130+
activation_max,
131+
activation_min,
132+
quant_params,
133+
out);
134+
}
135+
136+
// cppcheck-suppress unusedFunction
137+
Tensor& quantized_linear_per_channel_out(
138+
KernelRuntimeContext& context,
139+
const Tensor& input,
140+
const Tensor& weights,
141+
const std::optional<Tensor>& bias,
142+
const std::optional<Tensor>& kernel_sum,
143+
const int64_t input_offset,
144+
const int64_t filter_offset,
145+
const int64_t output_offset,
146+
const Tensor& requantize_multipliers,
147+
const Tensor& requantize_shifts,
148+
const int64_t activation_max,
149+
const int64_t activation_min,
150+
Tensor& out) {
151+
const int64_t out_features = out.size(out.dim() - 1);
152+
if (requantize_multipliers.scalar_type() != ScalarType::Int ||
153+
requantize_shifts.scalar_type() != ScalarType::Int ||
154+
requantize_multipliers.dim() != 1 || requantize_shifts.dim() != 1 ||
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 1-D int32 with one per output feature (%d)",
160+
static_cast<int>(out_features));
161+
context.fail(Error::InvalidArgument);
162+
return out;
163+
}
164+
// CMSIS-NN's MVE per-channel kernel applies one multiplier to four rows
165+
// when filter_offset is non-zero.
166+
if (filter_offset != 0) {
167+
ET_LOG(
168+
Error,
169+
"quantized_linear_per_channel_out: filter_offset must be 0 (symmetric weights)");
170+
context.fail(Error::InvalidArgument);
171+
return out;
172+
}
173+
const cmsis_nn_per_channel_quant_params quant_params{
174+
requantize_multipliers.mutable_data_ptr<int32_t>(),
175+
requantize_shifts.mutable_data_ptr<int32_t>()};
176+
return quantized_linear_impl(
177+
arm_fully_connected_per_channel_s8,
178+
"quantized_linear_per_channel_out",
179+
context,
180+
input,
181+
weights,
182+
bias,
183+
kernel_sum,
184+
input_offset,
185+
filter_offset,
186+
output_offset,
187+
activation_max,
188+
activation_min,
189+
quant_params,
190+
out);
191+
}
105192

106193
} // namespace native
107194
} // namespace cortex_m

‎backends/cortex_m/ops/operators.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -581,15 +581,32 @@ def quantized_linear_impl(
581581
output_reshaped = output.reshape(output_shape)
582582

583583
output = requantize_cmsis(
584-
output_reshaped,
585-
int(requantize_multipliers[0]),
586-
int(requantize_shifts[0]),
584+
output_reshaped, requantize_multipliers, requantize_shifts
587585
)
588586
output += output_offset
589587
output = torch.clamp(output, activation_min, activation_max).to(torch.int8)
590588
return output
591589

592590

591+
# Same as quantized_linear, with one int32 requantization multiplier and shift
592+
# per output feature.
593+
_LINEAR_PER_CHANNEL_ARGS = (
594+
"Tensor input, Tensor weights, Tensor? bias, Tensor? kernel_sum, "
595+
"int input_offset, int filter_offset, int output_offset, "
596+
"Tensor requantize_multipliers, Tensor requantize_shifts, "
597+
"int activation_max, int activation_min"
598+
)
599+
lib.define(f"quantized_linear_per_channel({_LINEAR_PER_CHANNEL_ARGS}) -> Tensor")
600+
lib.define(
601+
f"quantized_linear_per_channel.out({_LINEAR_PER_CHANNEL_ARGS}, "
602+
"*, Tensor(a!) out) -> Tensor(a!)"
603+
)
604+
register_fake("cortex_m::quantized_linear_per_channel")(quantized_linear_meta)
605+
impl(lib, "quantized_linear_per_channel", "CompositeExplicitAutograd")(
606+
quantized_linear_impl
607+
)
608+
609+
593610
# ===================================================================
594611
# SOFTMAX OPERATION DEFINITION
595612
# ===================================================================

‎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: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -360,21 +360,32 @@ 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+
assert (
372+
weight_qparams.axis == 0
373+
), f"Per-channel linear weights must be quantized on axis 0; got {weight_qparams.axis}"
374+
weight_scales = weight_qparams.get_scale_per_channel()
375+
weight_zps = weight_qparams.get_zp_per_channel()
376+
else:
377+
weight_scales, weight_zps = [weight_qparams.scale], [weight_qparams.zp]
378+
if any(zp != 0 for zp in weight_zps):
371379
raise NotImplementedError(
372380
f"cortex_m::quantized_linear assumes symmetric weight "
373-
f"quantization (weight_zp == 0); got weight_zp={weight_zp}"
381+
f"quantization (weight_zp == 0); got weight_zp={weight_qparams.zp}"
374382
)
375383

376-
quantized_multiplier, quantized_shift = quantize_multiplier_aot(
377-
(input_scale * weight_scale) / output_scale
384+
quantized_multipliers, quantized_shifts = zip(
385+
*(
386+
quantize_multiplier_aot((input_scale * weight_scale) / output_scale)
387+
for weight_scale in weight_scales
388+
)
378389
)
379390

380391
# CMSIS-NN's MVE `arm_fully_connected_s8` path reads a precomputed
@@ -393,7 +404,7 @@ def _get_linear_replacement(
393404

394405
if target_config.backend == cmsis_nn.Backend.MVE:
395406
kernel_sum_tensor = _compute_kernel_sum(
396-
weights_tensor, bias_tensor, -input_zp, -weight_zp
407+
weights_tensor, bias_tensor, -input_zp, -weight_zps[0]
397408
)
398409
with node.graph.inserting_after(weights):
399410
kernel_sum_arg = create_constant_placeholder(
@@ -408,21 +419,45 @@ def _get_linear_replacement(
408419
kernel_sum_arg = None
409420
bias_arg = bias_node
410421

422+
multipliers_arg: Argument
423+
shifts_arg: Argument
424+
if len(weight_scales) == 1:
425+
target = exir_ops.edge.cortex_m.quantized_linear.default
426+
multipliers_arg = list(quantized_multipliers)
427+
shifts_arg = list(quantized_shifts)
428+
else:
429+
target = exir_ops.edge.cortex_m.quantized_linear_per_channel.default
430+
with node.graph.inserting_after(weights):
431+
multipliers_arg = create_constant_placeholder(
432+
exported_program,
433+
node.graph,
434+
node.name + "_quantized_multiplier",
435+
InputKind.PARAMETER,
436+
torch.tensor(quantized_multipliers, dtype=torch.int32),
437+
)
438+
shifts_arg = create_constant_placeholder(
439+
exported_program,
440+
node.graph,
441+
node.name + "_quantized_shift",
442+
InputKind.PARAMETER,
443+
torch.tensor(quantized_shifts, dtype=torch.int32),
444+
)
445+
411446
args = (
412447
linear_args[0],
413448
weights,
414449
bias_arg,
415450
kernel_sum_arg,
416451
-input_zp,
417-
-weight_zp,
452+
-weight_zps[0],
418453
output_zp,
419-
[quantized_multiplier],
420-
[quantized_shift],
454+
multipliers_arg,
455+
shifts_arg,
421456
output_max,
422457
output_min,
423458
)
424459

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

427462

428463
@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 == 0
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
@@ -68,6 +68,7 @@ ops_list=(
6868
cortex_m::minimum.out
6969
cortex_m::maximum.out
7070
cortex_m::quantized_linear.out
71+
cortex_m::quantized_linear_per_channel.out
7172
cortex_m::softmax.out
7273
cortex_m::transpose.out
7374
cortex_m::pad.out

0 commit comments

Comments
 (0)