Skip to content

Commit bd55896

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 dadc829 commit bd55896

8 files changed

Lines changed: 317 additions & 48 deletions

File tree

‎backends/cortex_m/ops/op_quantized_linear.cpp‎

Lines changed: 88 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,93 @@ 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+
return quantized_linear_impl(
117+
"quantized_linear_out",
118+
context,
119+
input,
120+
weights,
121+
bias,
122+
kernel_sum,
123+
input_offset,
124+
filter_offset,
125+
output_offset,
126+
activation_max,
127+
activation_min,
128+
cmsis_nn_quant_params{&multiplier, &shift, /*is_per_channel=*/0},
129+
out);
130+
}
131+
132+
// cppcheck-suppress unusedFunction
133+
Tensor& quantized_linear_per_channel_out(
134+
KernelRuntimeContext& context,
135+
const Tensor& input,
136+
const Tensor& weights,
137+
const std::optional<Tensor>& bias,
138+
const std::optional<Tensor>& kernel_sum,
139+
const int64_t input_offset,
140+
const int64_t filter_offset,
141+
const int64_t output_offset,
142+
const Tensor& requantize_multipliers,
143+
const Tensor& requantize_shifts,
144+
const int64_t activation_max,
145+
const int64_t activation_min,
146+
Tensor& out) {
147+
const int64_t out_features = out.size(out.dim() - 1);
148+
if (requantize_multipliers.scalar_type() != ScalarType::Int ||
149+
requantize_shifts.scalar_type() != ScalarType::Int ||
150+
requantize_multipliers.numel() != out_features ||
151+
requantize_shifts.numel() != out_features) {
152+
ET_LOG(
153+
Error,
154+
"quantized_linear_per_channel_out: requantization parameters must be int32 with one per output feature (%d)",
155+
static_cast<int>(out_features));
156+
context.fail(Error::InvalidArgument);
157+
return out;
158+
}
159+
return quantized_linear_impl(
160+
"quantized_linear_per_channel_out",
161+
context,
162+
input,
163+
weights,
164+
bias,
165+
kernel_sum,
166+
input_offset,
167+
filter_offset,
168+
output_offset,
169+
activation_max,
170+
activation_min,
171+
cmsis_nn_quant_params{
172+
requantize_multipliers.mutable_data_ptr<int32_t>(),
173+
requantize_shifts.mutable_data_ptr<int32_t>(),
174+
/*is_per_channel=*/1},
175+
out);
176+
}
105177

106178
} // namespace native
107179
} // 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: 43 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
@@ -393,7 +401,7 @@ def _get_linear_replacement(
393401

394402
if target_config.backend == cmsis_nn.Backend.MVE:
395403
kernel_sum_tensor = _compute_kernel_sum(
396-
weights_tensor, bias_tensor, -input_zp, -weight_zp
404+
weights_tensor, bias_tensor, -input_zp, -weight_zps[0]
397405
)
398406
with node.graph.inserting_after(weights):
399407
kernel_sum_arg = create_constant_placeholder(
@@ -408,21 +416,45 @@ def _get_linear_replacement(
408416
kernel_sum_arg = None
409417
bias_arg = bias_node
410418

419+
multipliers_arg: Argument
420+
shifts_arg: Argument
421+
if len(weight_scales) == 1:
422+
target = exir_ops.edge.cortex_m.quantized_linear.default
423+
multipliers_arg = list(quantized_multipliers)
424+
shifts_arg = list(quantized_shifts)
425+
else:
426+
target = exir_ops.edge.cortex_m.quantized_linear_per_channel.default
427+
with node.graph.inserting_after(weights):
428+
multipliers_arg = create_constant_placeholder(
429+
exported_program,
430+
node.graph,
431+
node.name + "_quantized_multiplier",
432+
InputKind.PARAMETER,
433+
torch.tensor(quantized_multipliers, dtype=torch.int32),
434+
)
435+
shifts_arg = create_constant_placeholder(
436+
exported_program,
437+
node.graph,
438+
node.name + "_quantized_shift",
439+
InputKind.PARAMETER,
440+
torch.tensor(quantized_shifts, dtype=torch.int32),
441+
)
442+
411443
args = (
412444
linear_args[0],
413445
weights,
414446
bias_arg,
415447
kernel_sum_arg,
416448
-input_zp,
417-
-weight_zp,
449+
-weight_zps[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: 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)