Skip to content

Qualcomm AI Engine Direct - Fold BatchNorm into the preceding conv - #22994

Merged
psiddh merged 7 commits into
pytorch:mainfrom
msluszniak:ms/qnn-fuse-bn-with-conv
Sep 25, 2026
Merged

psiddh merged 7 commits into
pytorch:mainfrom
msluszniak:ms/qnn-fuse-bn-with-conv

Conversation

@msluszniak

@msluszniak msluszniak commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fold BatchNorm into the preceding conv for QNN, by running the shared backends/transforms pass in the to_edge pipeline. In HTP fp16 a standalone BatchNorm amplifies the conv's rounding error by its scale (~100 after depthwise convs): EfficientNetV2-S fp16 on SM8850 goes from 0.1% to 74.25% top-1 (fp32: 74.40%) and runs 30% faster. Quantized graphs are unaffected.

Test plan

tests/rework/passes/test.py::test_fuse_batch_norm_with_conv, test_qnn_delegate.py::test_qnn_conv1d_batch_norm; EfficientNetV2-S fp16 on a Galaxy S26 Ultra (SM8850), 2000 ImageNetV2 images.

cc @cbilgin @psiddh

In fp16 on HTP a standalone BatchNorm multiplies the conv's fp16 rounding
error by its per-channel scale, which reaches ~100 after depthwise convs.
EfficientNetV2-S fp16 on SM8850 drops to 0.1% top-1 (NaNs on some inputs);
with the fold it is 74.25% (fp32: 74.40%) and 30% faster. Quantized graphs
are unaffected, since PTQ already folds these pairs.
@pytorch-bot

pytorch-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

⏳ No Failures, 1 Pending

As of commit 7d6b672 with merge base 8081eb8 (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 Sep 22, 2026
@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@msluszniak
msluszniak marked this pull request as ready for review September 22, 2026 13:56
@nil-is-all nil-is-all added the module: qnn Issues related to Qualcomm's QNN delegate and code under backends/qualcomm/ label Sep 22, 2026
@executorch-triage executorch-triage Bot added the community: contribution PRs coming from community (excluding hardware partners) label Sep 22, 2026

@qti-horodnic qti-horodnic 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.

Hi @msluszniak, thank you for your contribution. A few comments:

  1. This change breaks the CI, test_qnn_conv1d_batch_norm fails with Expect p_conv1d_fused_bn_weight to be parameter, buffer, get_attr, or lifted tensor constant. The fused placeholders are named conv1d_fused_bn_weight, but export renames them to p_... after decomposition, while the edge passes still look names up in the aten ExportedProgram signature so CanonicalizeConv's get_parameter misses. I suggest registering the fused tensors the way the other QNN passes do (register_buffer + graph.get_attr, cf. CanonicalizeConv/ConvertLinearToConv2d), which would also drop the BUCK change.

  2. Please add tests in the new rework pass test framework and the delegate tests. A test_fuse_batch_norm_with_conv in backends/qualcomm/tests/rework/passes/test.py would match the per-pass convention there, and an fp16 case in test_qnn_delegate.py (e.g. Conv1dBn / Conv2dBnHardtanhMean) asserting no batch_norm survives in the delegated graph would have caught the p_conv1d_fused_bn_weight failure. The standalone test in test_passes.py exercises the fold in isolation and passes regardless.

  3. This pass largely duplicates the generic backends/transforms/fuse_batch_norm_with_conv.py, which other backends reuse. Could we reuse it or at least add a comment on why a QNN-specific copy is needed? I assume because that one matches exir_ops.edge.aten.convolution post-decomposition, while this has to run pre-dispatch on conv1d/2d/3d in get_export_passes(). If a shared version isn't practical, note that the existing pass registers the fused tensors via register_parameter + graph.get_attr, which sidesteps the placeholder-renaming issue seen in the CI.

Comment thread backends/qualcomm/_passes/fuse_batch_norm_with_conv.py Outdated
Comment thread backends/qualcomm/_passes/fuse_batch_norm_with_conv.py Outdated
- Qualcomm copyright header, matching the other passes here.
- Collect the erased BatchNorm's former args instead of an explicit list, so
  any leftover constant input is deleted rather than surviving as a dead one.
- Name the fused placeholders "p_<key>" and register the state_dict key
  without the prefix. The to_edge passes resolve parameters against the
  pre-edge program by the node name they see in the edge graph, and to_edge
  renames a parameter placeholder to "p_" + its state_dict key; without the
  prefix the fused weight was renamed and CanonicalizeConv could no longer
  find it (test_qnn_conv1d_batch_norm).
@msluszniak
msluszniak force-pushed the ms/qnn-fuse-bn-with-conv branch from c6371f8 to c61df35 Compare September 23, 2026 06:58
Addresses review:

1. Drop backends/qualcomm/_passes/fuse_batch_norm_with_conv.py and run the
   shared backends/transforms FuseBatchNormWithConvPass from the to_edge
   pipeline instead of the export pipeline. It matches the edge dialect, which
   is why the copy existed; at that stage it folds conv1d/2d/3d, with and
   without bias. This also removes the placeholder-renaming problem that broke
   test_qnn_conv1d_batch_norm, since the shared pass registers the fused
   tensors with register_parameter + graph.get_attr. BUCK now depends on
   :fuse_batch_norm_with_conv rather than :utils.

   CanonicalizeConv assumed a conv weight is a placeholder (fp) or a dequant
   node (QDQ), and raised IndexError on a get_attr weight. Only conv1d and
   dilated transpose convs dereference it, so nothing hit this before; accept
   get_attr there too.

2. Tests: test_fuse_batch_norm_with_conv in the rework per-pass framework
   (conv1d with and without bias, depthwise conv2d, conv3d), and a delegated
   graph assertion that no batch_norm survives, on the fp16 Conv1dBn and
   Conv2dBnHardtanhMean cases. Drops the isolated test_passes.py test, which
   passed regardless of what the QNN pipeline did.
Making CanonicalizeConv depend on FuseBatchNormWithConvPass moved it after
LayoutTransform in the solved order. CanonicalizeConv rewrites conv1d into
conv2d and copies the original node meta, so the 4D convolution inherited the
3-element QCOM_AXIS_ORDER LayoutTransform had already written, and the squeeze
it inserts inherited it too. Lowering any conv1d followed by a batch norm then
failed in op_squeeze with

  permute(): input.dim() = 4 is not equal to len(dims) = 3

The old pass list satisfied this ordering only by accident; state it in the
dependency table.
assert_batch_norm_folded read the graph after partitioning, where nothing is
left but the delegate call, so it passed on a tree without the fold at all.
Run the same pipeline with no partitioner instead: on the parent commit the
conv1d cases now report the surviving native_batch_norm, and they pass here.

@qti-horodnic qti-horodnic 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.

Thanks for making the change, I took a deeper look in the shared pass and there is a minor bug there which is now exposed in QNN. I left a comment with more details; the fix is straightforward. PR should be GTG after that

AnnotateStack: [RemoveRedundancy],
AnnotateUnbind: [RemoveRedundancy],
CanonicalizeConv: [FoldQDQ],
CanonicalizeConv: [FoldQDQ, FuseBatchNormWithConvPass],

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.

FuseBatchNormWithConvPass doesn't look at transposed (conv.args[6]) and calls fuse_conv_bn_weights without transpose=, so ConvTranspose + BatchNorm folds against the wrong weight axis. On this branch ConvTranspose2d(8, 8, 3, bias=False) + BatchNorm2d(8) gives max abs diff 9.5 vs eager, and ConvTranspose2d(4, 8, ...) raises RuntimeError: The size of tensor a (4) must match the size of tensor b (8) at non-singleton dimension 0. This seems to actually be an existing issue in the shared pass, but QNN now hits it.

To keep this PR's scope, could we subclass it under backends/qualcomm/_passes/ and override can_fuse to return False when conv.args[ConvParamIdx.TRANSPOSED], so transposed convs keep their standalone BN as today? Something like:

class FuseBatchNormWithConv(FuseBatchNormWithConvPass):
    """Skips transposed convolutions.

    fuse_conv_bn_weights() scales dim 0 of the weight, but a transposed weight
    is [in, out/groups, *kernel], so folding there would be incorrect.
    """

    @staticmethod
    def can_fuse(conv, bn, program) -> bool:
        if conv.args[ConvParamIdx.TRANSPOSED]:
            return False
        return FuseBatchNormWithConvPass.can_fuse(conv, bn, program)

    ...

Alos, a ConvTranspose2d + BN test case in the new pattern.py set would lock that in since none of the existing test_qnn_backend_conv_transpose models have a BN after them, which is why CI stays green.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok, applied this one here and will create a separate PR for fix in shared passes.

@qti-horodnic qti-horodnic 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.

Thanks for making the changes, LGTM.
@psiddh Kindly take a look when you get the chance, change looks good to be merged to me.

Comment thread backends/qualcomm/_passes/canonicalize_conv.py Outdated
fuse_conv_bn_weights() scales dim 0 of the convolution weight. That is the
output channel for a regular conv, but a transposed weight is
[in, out/groups, *kernel], so the fold hits the wrong axis: it raises when
in != out, and silently returns wrong values when they match
(ConvTranspose2d(8, 8, 3) + BatchNorm2d(8) drifts by 12.7 against eager).

Subclass the shared pass under backends/qualcomm/_passes and decline
transposed convs in can_fuse, so they keep their standalone BatchNorm.

The existing conv_transpose models have no BatchNorm after them, which is why
CI stayed green; the new conv_transpose2d_bn_is_skipped case covers it and
fails with the original RuntimeError when the skip is removed.
@msluszniak
msluszniak force-pushed the ms/qnn-fuse-bn-with-conv branch from 8c49384 to 7d6b672 Compare September 24, 2026 08:49
@psiddh

psiddh commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

lgtm

@psiddh
psiddh merged commit e9e21b5 into pytorch:main Sep 25, 2026
208 checks passed
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. community: contribution PRs coming from community (excluding hardware partners) module: qnn Issues related to Qualcomm's QNN delegate and code under backends/qualcomm/

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants