Skip to content

Commit 3fc45c1

Browse files
pssrawatfacebook-github-bot
authored andcommitted
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
1 parent 1db3a7a commit 3fc45c1

8 files changed

Lines changed: 333 additions & 48 deletions

File tree

‎backends/cortex_m/ops/op_quantized_linear.cpp‎

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ 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+
Tensor& quantized_linear_impl(
18+
const char* op_name,
1819
KernelRuntimeContext& context,
1920
const Tensor& input,
2021
const Tensor& weights,
@@ -23,12 +24,11 @@ Tensor& quantized_linear_out(
2324
const int64_t input_offset,
2425
const int64_t filter_offset,
2526
const int64_t output_offset,
26-
const Int64ArrayRef requantize_multipliers,
27-
const Int64ArrayRef requantize_shifts,
2827
const int64_t activation_max,
2928
const int64_t activation_min,
29+
const cmsis_nn_quant_params& quant_params,
3030
Tensor& out) {
31-
ET_LOG(Debug, "quantized_linear_out: called");
31+
ET_LOG(Debug, "%s: called", op_name);
3232

3333
const int8_t* input_data = input.const_data_ptr<int8_t>();
3434
const int8_t* weight_data = weights.const_data_ptr<int8_t>();
@@ -50,11 +50,6 @@ Tensor& quantized_linear_out(
5050
fc_params.activation.min = static_cast<int32_t>(activation_min);
5151
fc_params.activation.max = static_cast<int32_t>(activation_max);
5252

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-
5853
auto in_feat = input.size(input.dim() - 1);
5954
auto out_feat = out.size(out.dim() - 1);
6055
auto batches = 1;
@@ -78,10 +73,10 @@ Tensor& quantized_linear_out(
7873
cmsis_nn_dims bias_dims = {1, 1, 1, out_feat};
7974
cmsis_nn_dims output_dims = {batches, 1, 1, out_feat};
8075

81-
arm_cmsis_nn_status status = arm_fully_connected_s8(
76+
const arm_cmsis_nn_status status = arm_fully_connected_wrapper_s8(
8277
&ctx,
8378
&fc_params,
84-
&per_tensor_quant_params,
79+
&quant_params,
8580
&input_dims,
8681
input_data,
8782
&filter_dims,
@@ -92,16 +87,105 @@ Tensor& quantized_linear_out(
9287
output_data);
9388

9489
if (status != ARM_CMSIS_NN_SUCCESS) {
95-
ET_LOG(
96-
Error,
97-
"quantized_linear_out: CMSIS-NN failed with status [%d]",
98-
status);
90+
ET_LOG(Error, "%s: CMSIS-NN failed with status [%d]", op_name, status);
9991
context.fail(Error::Internal);
10092
return out;
10193
}
10294

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

106190
} // namespace native
107191
} // 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
@@ -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)