Skip to content

Validate layout copies on qparams, not fake-tensor dtype (#24071) - #24071

Open
pssrawat wants to merge 1 commit into
pytorch:mainfrom
pssrawat:export-D126708891
Open

pssrawat wants to merge 1 commit into
pytorch:mainfrom
pssrawat:export-D126708891

Conversation

@pssrawat

@pssrawat pssrawat commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

ValidateCortexMExplicitLayoutPass requires every NHWC layout copy to move
an int8 tensor, and reads node.meta["val"].dtype to decide. In
explicit_layout_pass_list that validator runs before AtenToCortexMPass,
so at validation time only the activations that ActivationFusionPass has
already folded into the convolution clamp -- relu, clamp, hardtanh -- carry
an int8 fake tensor. The LUT activations (tanh, sigmoid, which become
cortex_m::quantized_activation) and softmax (which becomes
cortex_m::softmax) are still aten.* with an fp32 fake tensor, even
though FoldAndAnnotateQParamsPass has already attached their int8 qparams.
That fp32 propagates into any convolution fed by one, and the layout copy on
its output trips the assertion.

The explicit-layout path validates rather than falling back, so this is a
hard abort, not a coverage loss -- and explicit layout is the only mode that
supports Conv1d. A two-node graph reproduces it: Conv1d(relu(x)) lowers,
Conv1d(tanh(x)) raises

RuntimeError: Cortex-M layout copy channels_last_permute_copy_default
must move an int8 tensor.

out of _CortexMLoweringPass. LutActivationThenConv1d in
test_explicit_layout_pipeline.py is that graph.

Judge the condition the kernels will actually see. produces_int8 accepts
an int8 fake tensor as before, and otherwise reads the folded
output_qparams[0], which is in place well before this point. Graphs that
were rejected lower unchanged: on a 31-convolution ECAPA-TDNN speaker model,
0 of 31 convolutions reached a CMSIS-NN kernel before and 31 of 31 do after,
with 0.000e+00 max absolute difference against the quantized eager module.

Folded qparams on their own are the wrong bar, though, and this is what the
first version of the diff got wrong. They say what dtype a node was
annotated for, not that anything acts on the annotation, and a node nothing
rewrites keeps an fp32 kernel in the middle of an int8 chain. So
produces_int8 also asks whether a rewrite is coming: either
AtenToCortexMPass has a substitution registered for the target, or
QuantizedClampActivationPass has already put the node on int8 terms. It
lives in aten_to_cortex_m_pass.py, which owns the substitution registry
and is therefore the module that can answer the question.

QuantizedClampActivationPass is the pass that makes the second arm
necessary, and it had the same premature read. It quantizes the bounds of a
clamp that will see int8, and decided that from the producer's fake tensor,
so a clamp behind a LUT activation was skipped -- harmless while the
validator rejected those graphs outright, silently wrong once it stopped.
sigmoid -> relu -> Conv1d left aten.relu clamping at 0 instead of the
zero point, erasing everything below it, where the relu should have been a
no-op; tanh -> clamp(-0.5, 0.5) -> Conv2d kept fp32 limits and an fp32
value on the tensor cortex_m::quantized_conv2d_nhwc reads as int8. Both
fail an output comparison against the quantized eager module, so this was
wrong numerics, not a wrong annotation. It now asks produces_int8, seven
passes ahead of the validator, and records the clamps it quantized.

That record is a node predicate in cortex_m/utils.py, alongside
skips_quantized_max_pool2d, which uses the same meta["custom"]
convention. A clamp never becomes a cortex_m:: kernel -- it stays on the
portable one and reads int8 only because its bounds were rewritten into
codes -- so no other pass can re-derive the decision, and a clamp the pass
declined (bounds that are not compile-time scalars, output qparams that are
not per-tensor) is indistinguishable from one it never saw. With the
decision recorded, a declined clamp is rejected by the validator instead of
being lowered.

has_qparams moves to cortex_m/utils.py as part of this. It was private
to aten_to_cortex_m_pass; utils is already where this backend keeps its
node predicates. Its ten existing call sites change in name only.

The error message now also names the producing node. The layout copy is
synthesized by this pass and appears in no graph the caller holds, so its
own name gives nothing to go on.

Differential Revision: D126708891

@pssrawat
pssrawat requested a review from rascani as a code owner October 21, 2026 20:35
@pytorch-bot

pytorch-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24071

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit 8ba8560 with merge base 703a411 (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 21, 2026
@meta-codesync

meta-codesync Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

@pssrawat has exported this pull request. If you are a Meta employee, you can view the originating Diff in D126708891.

@pssrawat pssrawat added the release notes: none Do not include this in the release notes label Oct 21, 2026
@meta-codesync meta-codesync Bot changed the title Validate layout copies on qparams, not fake-tensor dtype Validate layout copies on qparams, not fake-tensor dtype (#24071) Oct 21, 2026
pssrawat added a commit to pssrawat/executorch that referenced this pull request Oct 21, 2026
Summary:

`ValidateCortexMExplicitLayoutPass` requires every NHWC layout copy to move
an int8 tensor, and reads `node.meta["val"].dtype` to decide. In
`explicit_layout_pass_list` that validator runs before `AtenToCortexMPass`,
so at validation time only the activations that `ActivationFusionPass` has
already folded into the convolution clamp -- relu, clamp, hardtanh -- carry
an int8 fake tensor. The LUT activations (tanh, sigmoid, which become
`cortex_m::quantized_activation`) and softmax (which becomes
`cortex_m::softmax`) are still `aten.*` with an fp32 fake tensor, even
though `FoldAndAnnotateQParamsPass` has already attached their int8 qparams.
That fp32 propagates into any convolution fed by one, and the layout copy on
its output trips the assertion.

The explicit-layout path validates rather than falling back, so this is a
hard abort, not a coverage loss -- and explicit layout is the only mode that
supports Conv1d. A two-node graph reproduces it: `Conv1d(relu(x))` lowers,
`Conv1d(tanh(x))` raises

    RuntimeError: Cortex-M layout copy channels_last_permute_copy_default
    must move an int8 tensor.

out of `_CortexMLoweringPass`. `LutActivationThenConv1d` in
`test_explicit_layout_pipeline.py` is that graph.

Judge the condition the kernels will actually see. `_produces_int8` accepts
an int8 fake tensor as before, and otherwise asks `has_qparams`, which is
what `AtenToCortexMPass` reads before it will rewrite a node at all, plus an
int8 `output_qparams[0]`. Both annotations are in place by the time this
pass runs. Anything looser accepts a node the dialect goes on to decline,
which really does leave a portable fp32 kernel feeding an int8 transpose.
Graphs that were rejected lower unchanged: on a 31-convolution ECAPA-TDNN
speaker model, 0 of 31 convolutions reached a CMSIS-NN kernel before and 31
of 31 do after, with 0.000e+00 max absolute difference against the
quantized eager module.

`has_qparams` moves to `cortex_m/utils.py` to make that sharing possible.
It was private to `aten_to_cortex_m_pass`, and a pass that has to predict a
later one should not be reaching into it for the rule; utils is already
where this backend keeps its node predicates. Its ten existing call sites
change in name only.

The error message now also names the producing node. The layout copy is
synthesized by this pass and appears in no graph the caller holds, so its
own name gives nothing to go on.

Differential Revision: D126708891

@rascani rascani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude review this PR

Comment thread backends/cortex_m/passes/explicit_layout_pass.py Outdated
pssrawat added a commit to pssrawat/executorch that referenced this pull request Oct 23, 2026
Summary:

`ValidateCortexMExplicitLayoutPass` requires every NHWC layout copy to move
an int8 tensor, and reads `node.meta["val"].dtype` to decide. In
`explicit_layout_pass_list` that validator runs before `AtenToCortexMPass`,
so at validation time only the activations that `ActivationFusionPass` has
already folded into the convolution clamp -- relu, clamp, hardtanh -- carry
an int8 fake tensor. The LUT activations (tanh, sigmoid, which become
`cortex_m::quantized_activation`) and softmax (which becomes
`cortex_m::softmax`) are still `aten.*` with an fp32 fake tensor, even
though `FoldAndAnnotateQParamsPass` has already attached their int8 qparams.
That fp32 propagates into any convolution fed by one, and the layout copy on
its output trips the assertion.

The explicit-layout path validates rather than falling back, so this is a
hard abort, not a coverage loss -- and explicit layout is the only mode that
supports Conv1d. A two-node graph reproduces it: `Conv1d(relu(x))` lowers,
`Conv1d(tanh(x))` raises

    RuntimeError: Cortex-M layout copy channels_last_permute_copy_default
    must move an int8 tensor.

out of `_CortexMLoweringPass`. `LutActivationThenConv1d` in
`test_explicit_layout_pipeline.py` is that graph.

Judge the condition the kernels will actually see. `produces_int8` accepts
an int8 fake tensor as before, and otherwise reads the folded
`output_qparams[0]`, which is in place well before this point. Graphs that
were rejected lower unchanged: on a 31-convolution ECAPA-TDNN speaker model,
0 of 31 convolutions reached a CMSIS-NN kernel before and 31 of 31 do after,
with 0.000e+00 max absolute difference against the quantized eager module.

Folded qparams on their own are the wrong bar, though, and this is what the
first version of the diff got wrong. They say what dtype a node was
annotated for, not that anything acts on the annotation, and a node nothing
rewrites keeps an fp32 kernel in the middle of an int8 chain. So
`produces_int8` also asks whether a rewrite is coming: either
`AtenToCortexMPass` has a substitution registered for the target, or
`QuantizedClampActivationPass` has already put the node on int8 terms. It
lives in `aten_to_cortex_m_pass.py`, which owns the substitution registry
and is therefore the module that can answer the question.

`QuantizedClampActivationPass` is the pass that makes the second arm
necessary, and it had the same premature read. It quantizes the bounds of a
clamp that will see int8, and decided that from the producer's fake tensor,
so a clamp behind a LUT activation was skipped -- harmless while the
validator rejected those graphs outright, silently wrong once it stopped.
`sigmoid -> relu -> Conv1d` left `aten.relu` clamping at 0 instead of the
zero point, erasing everything below it, where the relu should have been a
no-op; `tanh -> clamp(-0.5, 0.5) -> Conv2d` kept fp32 limits and an fp32
value on the tensor `cortex_m::quantized_conv2d_nhwc` reads as int8. Both
fail an output comparison against the quantized eager module, so this was
wrong numerics, not a wrong annotation. It now asks `produces_int8`, seven
passes ahead of the validator, and records the clamps it quantized.

That record is a node predicate in `cortex_m/utils.py`, alongside
`skips_quantized_max_pool2d`, which uses the same `meta["custom"]`
convention. A clamp never becomes a `cortex_m::` kernel -- it stays on the
portable one and reads int8 only because its bounds were rewritten into
codes -- so no other pass can re-derive the decision, and a clamp the pass
declined (bounds that are not compile-time scalars, output qparams that are
not per-tensor) is indistinguishable from one it never saw. With the
decision recorded, a declined clamp is rejected by the validator instead of
being lowered.

`has_qparams` moves to `cortex_m/utils.py` as part of this. It was private
to `aten_to_cortex_m_pass`; utils is already where this backend keeps its
node predicates. Its ten existing call sites change in name only.

The error message now also names the producing node. The layout copy is
synthesized by this pass and appears in no graph the caller holds, so its
own name gives nothing to go on.

Differential Revision: D126708891
Summary:

`ValidateCortexMExplicitLayoutPass` requires every NHWC layout copy to move
an int8 tensor, and reads `node.meta["val"].dtype` to decide. In
`explicit_layout_pass_list` that validator runs before `AtenToCortexMPass`,
so at validation time only the activations that `ActivationFusionPass` has
already folded into the convolution clamp -- relu, clamp, hardtanh -- carry
an int8 fake tensor. The LUT activations (tanh, sigmoid, which become
`cortex_m::quantized_activation`) and softmax (which becomes
`cortex_m::softmax`) are still `aten.*` with an fp32 fake tensor, even
though `FoldAndAnnotateQParamsPass` has already attached their int8 qparams.
That fp32 propagates into any convolution fed by one, and the layout copy on
its output trips the assertion.

The explicit-layout path validates rather than falling back, so this is a
hard abort, not a coverage loss -- and explicit layout is the only mode that
supports Conv1d. A two-node graph reproduces it: `Conv1d(relu(x))` lowers,
`Conv1d(tanh(x))` raises

    RuntimeError: Cortex-M layout copy channels_last_permute_copy_default
    must move an int8 tensor.

out of `_CortexMLoweringPass`. `LutActivationThenConv1d` in
`test_explicit_layout_pipeline.py` is that graph.

Judge the condition the kernels will actually see. `produces_int8` accepts
an int8 fake tensor as before, and otherwise reads the folded
`output_qparams[0]`, which is in place well before this point. Graphs that
were rejected lower unchanged: on a 31-convolution ECAPA-TDNN speaker model,
0 of 31 convolutions reached a CMSIS-NN kernel before and 31 of 31 do after,
with 0.000e+00 max absolute difference against the quantized eager module.

Folded qparams on their own are the wrong bar, though, and this is what the
first version of the diff got wrong. They say what dtype a node was
annotated for, not that anything acts on the annotation, and a node nothing
rewrites keeps an fp32 kernel in the middle of an int8 chain. So
`produces_int8` also asks whether a rewrite is coming: either
`AtenToCortexMPass` has a substitution registered for the target, or
`QuantizedClampActivationPass` has already put the node on int8 terms. It
lives in `aten_to_cortex_m_pass.py`, which owns the substitution registry
and is therefore the module that can answer the question.

`QuantizedClampActivationPass` is the pass that makes the second arm
necessary, and it had the same premature read. It quantizes the bounds of a
clamp that will see int8, and decided that from the producer's fake tensor,
so a clamp behind a LUT activation was skipped -- harmless while the
validator rejected those graphs outright, silently wrong once it stopped.
`sigmoid -> relu -> Conv1d` left `aten.relu` clamping at 0 instead of the
zero point, erasing everything below it, where the relu should have been a
no-op; `tanh -> clamp(-0.5, 0.5) -> Conv2d` kept fp32 limits and an fp32
value on the tensor `cortex_m::quantized_conv2d_nhwc` reads as int8. Both
fail an output comparison against the quantized eager module, so this was
wrong numerics, not a wrong annotation. It now asks `produces_int8`, seven
passes ahead of the validator, and records the clamps it quantized.

That record is a node predicate in `cortex_m/utils.py`, alongside
`skips_quantized_max_pool2d`, which uses the same `meta["custom"]`
convention. A clamp never becomes a `cortex_m::` kernel -- it stays on the
portable one and reads int8 only because its bounds were rewritten into
codes -- so no other pass can re-derive the decision, and a clamp the pass
declined (bounds that are not compile-time scalars, output qparams that are
not per-tensor) is indistinguishable from one it never saw. With the
decision recorded, a declined clamp is rejected by the validator instead of
being lowered.

`has_qparams` moves to `cortex_m/utils.py` as part of this. It was private
to `aten_to_cortex_m_pass`; utils is already where this backend keeps its
node predicates. Its ten existing call sites change in name only.

The error message now also names the producing node. The layout copy is
synthesized by this pass and appears in no graph the caller holds, so its
own name gives nothing to go on.

Differential Revision: D126708891
pssrawat added a commit to pssrawat/executorch that referenced this pull request Oct 23, 2026
Summary:

`ValidateCortexMExplicitLayoutPass` requires every NHWC layout copy to move
an int8 tensor, and reads `node.meta["val"].dtype` to decide. In
`explicit_layout_pass_list` that validator runs before `AtenToCortexMPass`,
so at validation time only the activations that `ActivationFusionPass` has
already folded into the convolution clamp -- relu, clamp, hardtanh -- carry
an int8 fake tensor. The LUT activations (tanh, sigmoid, which become
`cortex_m::quantized_activation`) and softmax (which becomes
`cortex_m::softmax`) are still `aten.*` with an fp32 fake tensor, even
though `FoldAndAnnotateQParamsPass` has already attached their int8 qparams.
That fp32 propagates into any convolution fed by one, and the layout copy on
its output trips the assertion.

The explicit-layout path validates rather than falling back, so this is a
hard abort, not a coverage loss -- and explicit layout is the only mode that
supports Conv1d. A two-node graph reproduces it: `Conv1d(relu(x))` lowers,
`Conv1d(tanh(x))` raises

    RuntimeError: Cortex-M layout copy channels_last_permute_copy_default
    must move an int8 tensor.

out of `_CortexMLoweringPass`. `LutActivationThenConv1d` in
`test_explicit_layout_pipeline.py` is that graph.

Judge the condition the kernels will actually see. `produces_int8` accepts
an int8 fake tensor as before, and otherwise reads the folded
`output_qparams[0]`, which is in place well before this point. Graphs that
were rejected lower unchanged: on a 31-convolution ECAPA-TDNN speaker model,
0 of 31 convolutions reached a CMSIS-NN kernel before and 31 of 31 do after,
with 0.000e+00 max absolute difference against the quantized eager module.

Folded qparams on their own are the wrong bar, though, and this is what the
first version of the diff got wrong. They say what dtype a node was
annotated for, not that anything acts on the annotation, and a node nothing
rewrites keeps an fp32 kernel in the middle of an int8 chain. So
`produces_int8` also asks whether a rewrite is coming: either
`AtenToCortexMPass` has a substitution registered for the target, or
`QuantizedClampActivationPass` has already put the node on int8 terms. It
lives in `aten_to_cortex_m_pass.py`, which owns the substitution registry
and is therefore the module that can answer the question.

`QuantizedClampActivationPass` is the pass that makes the second arm
necessary, and it had the same premature read. It quantizes the bounds of a
clamp that will see int8, and decided that from the producer's fake tensor,
so a clamp behind a LUT activation was skipped -- harmless while the
validator rejected those graphs outright, silently wrong once it stopped.
`sigmoid -> relu -> Conv1d` left `aten.relu` clamping at 0 instead of the
zero point, erasing everything below it, where the relu should have been a
no-op; `tanh -> clamp(-0.5, 0.5) -> Conv2d` kept fp32 limits and an fp32
value on the tensor `cortex_m::quantized_conv2d_nhwc` reads as int8. Both
fail an output comparison against the quantized eager module, so this was
wrong numerics, not a wrong annotation. It now asks `produces_int8`, seven
passes ahead of the validator, and records the clamps it quantized.

That record is a node predicate in `cortex_m/utils.py`, alongside
`skips_quantized_max_pool2d`, which uses the same `meta["custom"]`
convention. A clamp never becomes a `cortex_m::` kernel -- it stays on the
portable one and reads int8 only because its bounds were rewritten into
codes -- so no other pass can re-derive the decision, and a clamp the pass
declined (bounds that are not compile-time scalars, output qparams that are
not per-tensor) is indistinguishable from one it never saw. With the
decision recorded, a declined clamp is rejected by the validator instead of
being lowered.

`has_qparams` moves to `cortex_m/utils.py` as part of this. It was private
to `aten_to_cortex_m_pass`; utils is already where this backend keeps its
node predicates. Its ten existing call sites change in name only.

The error message now also names the producing node. The layout copy is
synthesized by this pass and appears in no graph the caller holds, so its
own name gives nothing to go on.

Differential Revision: D126708891
@rascani
rascani requested a review from AdrianLundell October 26, 2026 19:01
tester.run_method_and_compare_outputs(inputs=inputs, qtol=1)


def test_explicit_layout_accepts_conv_fed_by_view_of_lut_activation():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please extend coverage to (1, 8, 16) input through tanh → Conv1d(8,8,1) → view(1,4,32) → Conv1d(4,4,1).

This still fails validation: canonicalization merges the annotated reshape into a synthetic view without qparams, and the helper falls back to its stale float32 fake tensor. Removing only validation produces two int8 convolutions and matching outputs.

Assert that the normal pipeline lowers both convolutions and matches the quantized reference. Pair that with a negative test for the explicit-float32 full_like case above, so validation both accepts valid chains and rejects float inputs to int8 kernels.

not isinstance(input_value, torch.Tensor)
or input_value.dtype != torch.int8
):
input_dtype = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please separate early layout-eligibility checks from dtype validation, and validate actual kernel inputs after AtenToCortexMPass has retraced the graph.

Conv1d(full_like(x, 0.5, dtype=torch.float32)) now lowers and serializes with a float32 tensor feeding quantized_conv2d_nhwc. Its folded qparams never change the producer’s explicit dtype.

The final check needs to inspect activation inputs to int8-only Cortex-M kernels. Checking the convolution’s output or its substitution registration misses this case because its output really does become int8. Moving this validator unchanged also won’t suffice: the channels_last.* targets disappear during lowering.

Keep this graph rejected unless the required quantization conversion is preserved.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported release notes: none Do not include this in the release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants