Support per-channel weights end to end - #22227
Conversation
Summary:
Per-channel weight quantization went through `prepare_pt2`/`convert_pt2`
and then quietly stopped being quantized: fusion bailed on the
per-channel dequant, so the convs fell out to float `cadence.fully_connected`.
On HiFi it is worse than a fallback, because `op_quantized_linear_out`
reads `out_multiplier[0]` even in the tensor-qparam overload and would
requantize every channel with channel 0's scale, silently.
This makes the whole path work, AoT and kernels. Granularity is carried by
the existing overload split, with `.out` (tensor qparams) now being the
per-channel form and `.per_tensor_out` the per-tensor one. No new operator
names are introduced.
Only the generic kernels implement per-channel, so `operator_fallback.bzl`
routes every per-channel op there: the HiFi and TIE rows for the four
tensor-qparam ops are removed, and `quantized_depthwise_conv1d_{ncl,nlc}.out`
gain generic rows they never had. Backends fall back instead of failing, and
a follow-up can reclaim performance by adding a row back. NNLib's conv entry
points are already per-channel capable, so HiFi conv is the obvious first
candidate; its fully-connected and matmul entry points take a single
(multiplier, shift) pair and structurally cannot be.
The two new depthwise rows also need schemas in
`min_runtime/custom_ops.yaml`: that file is not OSS-only, the Jarvis xtensa
codegen resolves every selective-build op name through it and fails the
build outright on one it cannot find.
- The quantizer aligns a pattern's derived bias spec with the granularity
of its weight spec. Previously six patterns hardcoded `per_tensor_affine`
on the bias, so `bias_scale = act_scale * weight_scale` produced a vector
that a per-tensor spec could not hold (`ValueError: only one element
tensors can be converted to Python scalars`). `CadenceAtenQuantizer.annotate`
is the one place that sees both specs, so the alignment lives there.
- Fusion accepts a per-channel weight dequant and emits the tensor-qparam
overload, materializing bias_scale / out_multiplier / out_shift /
weight_zero_point as lifted constants. Reading the weight scale and
adding constants both need the ExportedProgram, which `PassBase.call`
does not get, so `QuantFusionPass` takes one and stashes it for the
duration of the pass rather than changing `fuse()` on ~20 patterns.
- Both conv and linear fuse per-channel. Linear is what fully-connected
models are made of, so leaving it out would have made this a no-op for
the target model. `fuse_linear` mirrors `fuse_conv`; `quantized_linear`
has no bias_scale operand, so the scale reaches the kernel only through
out_multiplier/out_shift. `AddmmPattern` still declines: its weight is
`[in_features, out_features]`, so an output-channel axis of 0 is
meaningless there, and linear is preserved from decomposition anyway.
- Depthwise selection now runs before the per-channel overload swap in
`fuse_conv`. It compared against the `.per_tensor` target after the swap
had already moved to `.default`, so a per-channel depthwise conv silently
stayed a dense conv. `quantized_depthwise_conv1d_ncl`/`_nlc` had no
`.default` overload at all; the schemas, metas and reference
implementations are added here.
- The conv lowering exits carry per-channel through.
`ReplaceConvWithChannelLastConvPass` picks the layout from the input rank
as before but takes the overload from the incoming node, instead of
hardcoding `.per_tensor`. `ReplaceConvWithIm2RowAndLinear` normally
recomputes out_multiplier/out_shift from `bias_scale / out_scale`, which
is scalar arithmetic; for per-channel it reuses the conv's existing
multiplier/shift nodes, which were derived from exactly that ratio at
fusion time.
- The generic conv kernels do per-channel requantization. The conv2d and
conv1d cores take weight_zero_point and bias_scale as a pointer plus a
stride, resolved once per output channel: stride 0 keeps every channel on
element 0 (per-tensor), stride 1 walks the channel axis. Existing callers
pass 0, so per-tensor behaviour and the vectorizable inner loops are
unchanged. The `.out` entry points previously read element 0 and applied
channel 0's scale to every output channel.
- The conv reference implementations do real per-channel requantization.
The four tensor-qparam variants used to call `.item()` on their qparams
and delegate to the per-tensor path, which raised on a vector.
`quantized_conv_per_tensor` becomes `quantized_conv_common` and broadcasts
weight_zero_point and bias_scale over the output-channel axis.
Differential Revision: D117164581
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22227
Note: Links to docs will display an error until the docs builds have been completed. ❌ 2 New Failures, 5 Unrelated FailuresAs of commit 9245a68 with merge base 35adf60 ( NEW FAILURES - The following jobs have failed:
FLAKY - The following jobs failed but were likely due to flakiness present on trunk:
BROKEN TRUNK - The following jobs failed but were present on the merge base:👉 Rebase onto the `viable/strict` branch to avoid these failures
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
@mcremon-meta has exported this pull request. If you are a Meta employee, you can view the originating Diff in D117164581. |
This PR needs a
|
Summary:
Per-channel weight quantization went through
prepare_pt2/convert_pt2and then quietly stopped being quantized: fusion bailed on the
per-channel dequant, so the convs fell out to float
cadence.fully_connected.On HiFi it is worse than a fallback, because
op_quantized_linear_outreads
out_multiplier[0]even in the tensor-qparam overload and wouldrequantize every channel with channel 0's scale, silently.
This makes the whole path work, AoT and kernels. Granularity is carried by
the existing overload split, with
.out(tensor qparams) now being theper-channel form and
.per_tensor_outthe per-tensor one. No new operatornames are introduced.
Only the generic kernels implement per-channel, so
operator_fallback.bzlroutes every per-channel op there: the HiFi and TIE rows for the four
tensor-qparam ops are removed, and
quantized_depthwise_conv1d_{ncl,nlc}.outgain generic rows they never had. Backends fall back instead of failing, and
a follow-up can reclaim performance by adding a row back. NNLib's conv entry
points are already per-channel capable, so HiFi conv is the obvious first
candidate; its fully-connected and matmul entry points take a single
(multiplier, shift) pair and structurally cannot be.
The two new depthwise rows also need schemas in
min_runtime/custom_ops.yaml: that file is not OSS-only, the Jarvis xtensacodegen resolves every selective-build op name through it and fails the
build outright on one it cannot find.
The quantizer aligns a pattern's derived bias spec with the granularity
of its weight spec. Previously six patterns hardcoded
per_tensor_affineon the bias, so
bias_scale = act_scale * weight_scaleproduced a vectorthat a per-tensor spec could not hold (
ValueError: only one element tensors can be converted to Python scalars).CadenceAtenQuantizer.annotateis the one place that sees both specs, so the alignment lives there.
Fusion accepts a per-channel weight dequant and emits the tensor-qparam
overload, materializing bias_scale / out_multiplier / out_shift /
weight_zero_point as lifted constants. Reading the weight scale and
adding constants both need the ExportedProgram, which
PassBase.calldoes not get, so
QuantFusionPasstakes one and stashes it for theduration of the pass rather than changing
fuse()on ~20 patterns.Both conv and linear fuse per-channel. Linear is what fully-connected
models are made of, so leaving it out would have made this a no-op for
the target model.
fuse_linearmirrorsfuse_conv;quantized_linearhas no bias_scale operand, so the scale reaches the kernel only through
out_multiplier/out_shift.
AddmmPatternstill declines: its weight is[in_features, out_features], so an output-channel axis of 0 ismeaningless there, and linear is preserved from decomposition anyway.
Depthwise selection now runs before the per-channel overload swap in
fuse_conv. It compared against the.per_tensortarget after the swaphad already moved to
.default, so a per-channel depthwise conv silentlystayed a dense conv.
quantized_depthwise_conv1d_ncl/_nlchad no.defaultoverload at all; the schemas, metas and referenceimplementations are added here.
The conv lowering exits carry per-channel through.
ReplaceConvWithChannelLastConvPasspicks the layout from the input rankas before but takes the overload from the incoming node, instead of
hardcoding
.per_tensor.ReplaceConvWithIm2RowAndLinearnormallyrecomputes out_multiplier/out_shift from
bias_scale / out_scale, whichis scalar arithmetic; for per-channel it reuses the conv's existing
multiplier/shift nodes, which were derived from exactly that ratio at
fusion time.
The generic conv kernels do per-channel requantization. The conv2d and
conv1d cores take weight_zero_point and bias_scale as a pointer plus a
stride, resolved once per output channel: stride 0 keeps every channel on
element 0 (per-tensor), stride 1 walks the channel axis. Existing callers
pass 0, so per-tensor behaviour and the vectorizable inner loops are
unchanged. The
.outentry points previously read element 0 and appliedchannel 0's scale to every output channel.
The conv reference implementations do real per-channel requantization.
The four tensor-qparam variants used to call
.item()on their qparamsand delegate to the per-tensor path, which raised on a vector.
quantized_conv_per_tensorbecomesquantized_conv_commonand broadcastsweight_zero_point and bias_scale over the output-channel axis.
Differential Revision: D117164581