Skip to content

Metal backend: honor channels-last strides in conv2d - #22952

Open
abdelaziz-mahdy wants to merge 6 commits into
pytorch:mainfrom
abdelaziz-mahdy:fix/metal-conv2d-channels-last
Open

abdelaziz-mahdy wants to merge 6 commits into
pytorch:mainfrom
abdelaziz-mahdy:fix/metal-conv2d-channels-last

Conversation

@abdelaziz-mahdy

Copy link
Copy Markdown
Contributor

Summary

Fixes #22951

aoti_torch_mps_convolution read its operands as contiguous NCHW / OIHW regardless of their strides. Inductor's layout optimization gives conv2d channels-last input and weight and expects a channels-last output, so every conv2d reaching the kernel returned wrong values without raising an error (MobileNetV3: top-1 logit 3094 instead of 8.146). 1x1 convs were unaffected only because inductor lowers them to mm.

The kernel now classifies the input and the weight from their strides (contiguous or channels-last, ignoring size-1 dimensions, whose stride carries no information). Channels-last buffers are declared to MPSGraph in their physical NHWC / OHWI shape and reordered inside the graph, the convolution itself is still described as NCHW / OIHW, and the result is written in channels-last order with matching strides when either operand is channels-last, as ATen does. The two layout flags are part of the graph cache key. Any other stride pattern now returns InvalidArgument instead of computing garbage, matching how aoti_torch_mps_bmm_out treats layouts it does not support.

Contiguous operands, conv1d and unbatched 3D input take the same path as before.

Test plan

Added five conv2d modules to MODULE_REGISTRY in backends/apple/metal/tests/test_modules.py (plain, bias with batch 2, stride 2 on a non-square input, depthwise, and two stacked convolutions so a channels-last output feeds the next conv). Each runs in float32 and bfloat16.

backends/apple/metal/tests/run_metal_test.sh --build
python -m unittest backends.apple.metal.tests.test_modules.TestMetalBackendModules
  • Before the change: the 10 new conv2d_*_output_consistency tests fail with max_atol between 1.6 and 2.7; everything else passes.
  • After: Ran 134 tests ... OK.
  • The previously wrong MobileNetV3-small .pte (exported by the 1.5.0 release, layout optimization on) now matches eager through executor_runner on an all-ones input: first logits -0.0298, -0.1163, 0.2346, -0.1151, 0.2841, 1.3327, -1.2021, -0.4182 on both sides.
  • lintrunner clean on the touched files.

aoti_torch_mps_convolution read its operands as contiguous NCHW / OIHW regardless of their strides. Inductor's layout optimization gives conv2d channels-last input and weight and expects a channels-last output, so every conv2d reaching the kernel returned wrong values without raising an error. 1x1 convs were unaffected only because inductor lowers them to mm.

Classify the input and the weight from their strides, declare channels-last buffers to MPSGraph in their physical NHWC / OHWI shape and reorder them inside the graph, and write the result channels-last when either operand is, as ATen does. Other stride patterns now return InvalidArgument instead of computing garbage.

Adds conv2d modules (plain, bias, strided, depthwise, stacked) to the Metal module tests, which covered conv1d only.
Copilot AI lite review requested due to automatic review settings September 20, 2026 01:23
@pytorch-bot

pytorch-bot Bot commented Sep 20, 2026

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❗ 1 Active SEVs

There are 1 currently active SEVs. If your PR is affected, please view them below:

❌ 1 Awaiting Approval, 1 New Failure, 1 Pending

As of commit 4bf45ef with merge base 11120c8 (image):

AWAITING APPROVAL - The following workflow needs approval before CI can run:

NEW FAILURE - The following job has failed:

  • Cadence Build & Test / Resolve CI docker image / resolve (gh)
    ##[error]Refusing to check out fork pull request code from a 'pull_request_target' workflow. This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner access. Fetching and executing a fork's code in that trusted context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' on the actions/checkout step.

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 20, 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.

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Unresolved critical output-boundary and bias-cache issues block approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity

Open (2)
What changed in this PR

This PR updates Metal AOTI conv2d to honor channels-last layouts and adds regression coverage.

Changes:

  • Detects supported tensor layouts and reorders channels-last operands for MPSGraph.
  • Preserves channels-last outputs and extends graph cache keys.
  • Adds float32/bfloat16 tests for bias, stride, depthwise, and stacked convolutions.
File Summary
backends/​apple/​metal/​tests/​test_modules.py Adds five Conv2d regression modules and consistency tests.
backends/​apple/​metal/​runtime/​ops/​op_convolution.mm Implements layout-aware convolution handling. Findings remain: two critical issues involving output copying and bias cache-key handling, one moderate ambiguous-layout issue, and two nit findings about truncated stride diagnostics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread backends/apple/metal/runtime/ops/op_convolution.mm Outdated
Comment thread backends/apple/metal/runtime/ops/op_convolution.mm
A graph built without a bias placeholder could be reused for a same-shaped convolution that has one, which would silently drop the bias (and the reverse would run a graph whose bias placeholder is never fed). Inductor currently adds the bias outside the kernel for MPS, so this was latent.
Copilot AI review requested due to automatic review settings September 20, 2026 01:51

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The convolution graph cache key omits output_padding, risking reuse of incompatible transposed-convolution graphs.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (2)

Comment thread backends/apple/metal/runtime/ops/op_convolution.mm Outdated
Output padding changes a transposed convolution's descriptor, so two transposed convolutions that differ only in output padding must not share a cached graph.
Copilot AI review requested due to automatic review settings September 20, 2026 02:54

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Singleton-channel inputs can still produce incorrect output layout metadata and need a regression test.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread backends/apple/metal/runtime/ops/op_convolution.mm Outdated
With one channel, contiguous and channels-last strides describe the same memory, so the kernel reports such a tensor as contiguous. Inductor does the same: it leaves a single-channel conv input contiguous and expects a contiguous result, and it expects a channels-last single-channel result when the input was channels-last. Pin both down.
Copilot AI review requested due to automatic review settings September 20, 2026 03:10

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The bias path can produce incorrect results because the rank-1 bias broadcasts along the width dimension instead of channels.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread backends/apple/metal/runtime/ops/op_convolution.mm
The kernel added the rank-1 [C_out] bias straight to the NCHW result. MPSGraph broadcasts from the trailing dimension, so the bias lined up with W: when C_out happened to equal W every channel got the wrong offsets, and otherwise MPSGraph aborted on the shape mismatch. Reshape the bias to [1, C_out, 1, 1] first.

Latent so far, because inductor adds the bias itself for MPS and always calls the kernel with bias=nullptr.
Copilot AI review requested due to automatic review settings September 20, 2026 03:25

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

The backend layout and graph-cache changes warrant final human validation.

Review effort: Lite
Findings: None

Resolved since last review (1)

@mergennachin

Copy link
Copy Markdown
Contributor

Found an additional conv2d case this fix still misses at bc18dfcbfe930a5e0b54df1563e10b48d41dfe52: a pointwise convolution producing one channel, followed by a spatial convolution.

torch.manual_seed(2026)
dtype = torch.float32  # also reproduces with torch.bfloat16
model = torch.nn.Sequential(
    torch.nn.Conv2d(3, 1, 1),
    torch.nn.Conv2d(1, 4, 3, padding=1),
).eval().to(dtype)
inputs = (torch.ones(2, 3, 7, 9, dtype=dtype),)

Exporting with the existing export_model_to_pte helper and running through executor_runner gives:

dtype Max absolute error, default layout optimization With layout optimization disabled
float32 1.3043177 5.96e-8
bfloat16 1.30078125 0.00390625

The disabled-layout controls pass the module test tolerances. The baseline has the same failure, so this is an incomplete fix rather than a new regression. All 12 added module/dtype cases pass on this PR.

The 1×1 convolution lowers to addmm. For the second convolution, the generated wrapper expects channels-last output strides {252,1,36,4}, but the shim returns contiguous strides {252,63,9,1}. Running with AOTI_RUNTIME_CHECK_INPUTS=1 confirms:

expected size 4==4, stride 63==1 at dim=1;
expected size 7==7, stride 9==36 at dim=2;
expected size 9==9, stride 1==4 at dim=3
Error in op: torch.ops.aten.convolution.default

get_conv_memory_format ignores singleton dimensions and prefers contiguous when both layouts fit. With a one-channel input and weight, that makes output_channels_last false even though this wrapper expects channels-last. Please add this chain as a regression test and preserve the expected output layout through the ambiguous case. Simply forcing channels-last for every one-channel input is insufficient: a diagnostic change fixed this case but broke another valid convolution chain expecting contiguous output.

Validation performed with Codex on Apple M1 Pro, macOS 26.6.2, PyTorch 2.14.0, using the PR's native convolution implementation.

A 1x1 conv producing one channel lowers to addmm, and its result reaches the
next conv as a {2, 1, 7, 9} tensor with channels-last strides {63, 1, 9, 1}.
Those strides describe the same memory as contiguous ones, so the kernel
treated the input as contiguous and returned a contiguous output, while the
generated wrapper expects channels-last: max abs error 1.3 on the chain
Conv2d(3, 1, 1) -> Conv2d(1, 4, 3, padding=1).

Two things were needed:

- The kernel now resolves the ambiguous case the way PyTorch's MPS rule does
  (suggest_memory_format of input or weight is channels-last), with a port of
  c10's is_channels_last_strides_2d, which looks at the actual stride values.

- Those values did not survive tensor creation. make_tensor_ptr derives the
  strides again from a dim order it gets by sorting them, and a size-1
  dimension ties with its neighbour, so {63, 1, 9, 1} came back as
  {63, 9, 9, 1}. The Metal shims now pass an explicit dim order that puts the
  size-1 dimension last among equal strides, which gives back the strides
  inductor asked for.

Adds the chain as conv2d_pointwise_to_single_channel.
Copilot AI review requested due to automatic review settings September 21, 2026 22:28

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

The reviewed changes address the layout issue and include comprehensive regression coverage.

Review effort: Lite
Findings: None

@abdelaziz-mahdy

Copy link
Copy Markdown
Contributor Author

Reproduced, including the AOTI_RUNTIME_CHECK_INPUTS stride error. Fixed in 4bf45ef. There were two causes:

  1. The kernel ignored size-1 dimensions and preferred contiguous when both layouts fit. PyTorch's MPS rule is suggest_memory_format(input or weight) == channels_last, which reads the actual stride values, so the ambiguous case now goes through a port of c10's is_channels_last_strides_2d.
  2. That alone did not help (the chain still failed, max abs error 1.17), because the stride values never reached the kernel. make_tensor_ptr derives the strides again from a dim order it gets by sorting them, and a size-1 dimension ties with its neighbour, so the {63, 1, 9, 1} the wrapper passes for the {2, 1, 7, 9} input came back as {63, 9, 9, 1}. The Metal shims now build their tensors with an explicit dim order that puts the size-1 dimension last among equal strides, which gives back the strides inductor asked for. This is the part that keeps contiguous chains contiguous: the decision follows the strides rather than the channel count.

Your chain is added as conv2d_pointwise_to_single_channel. float32 and bfloat16 pass, the exported model passes AOTI_RUNTIME_CHECK_INPUTS=1, and all 28 conv2d module tests pass. lintrunner is clean.

One thing unrelated to this PR: on my machine (macOS 27.0) the 10 int4 module tests fail with and without this change, because the Metal compiler rejects the 4-bit shader source: constructor for 'BlockMMA<...>' must explicitly initialize the member 'Atile' which does not have a default constructor. Everything else in the suite passes (132 of 142). I can open a separate issue for that.

@mergennachin

Copy link
Copy Markdown
Contributor

Re-reviewed 4bf45ef518804ce31925f3b8701d324fbc2c897a: the earlier convolution repro is fixed, but the shared make_strided_tensor change introduces a regression in a previously working BMM model. Please address this before merging.

The constant below has shape (2, 1, 4) and strides (4, 1, 1). Before this PR, tensor construction normalized those strides to (4, 4, 1). The new helper preserves (4, 1, 1), and aoti_torch_mps_bmm_out rejects it even though both layouts describe the same dense row-major data: the differing dimension has size one.

Repro using the existing module-test export helper:

from pathlib import Path
import torch
from executorch.backends.apple.metal.tests.test_modules import export_model_to_pte

class Repro(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.register_buffer(
            "weight",
            torch.arange(8).reshape(2, 4, 1).float().transpose(1, 2),
        )

    def forward(self, x):
        return torch.bmm(self.weight, x)

out = Path("singleton-bmm-repro")
out.mkdir(exist_ok=True)
for dtype in (torch.float32, torch.bfloat16):
    model = Repro().eval().to(dtype)
    inputs = (torch.ones(2, 4, 3, dtype=dtype),)
    name = f"repro_{str(dtype).split('.')[-1]}"
    pte, expected = export_model_to_pte(model, inputs, out, name)
    print(pte, expected)

Run the same exported PTE with the baseline and PR runtimes, e.g.:

executor_runner --model_path singleton-bmm-repro/repro_float32.pte --output_file singleton-bmm-repro/output
Runtime Float32 Bfloat16
Baseline without this PR Exit 0; exact eager match Exit 0; exact eager match
Current PR BMM error; runner aborts BMM error; runner aborts
Local diagnostic adjustment Exit 0; exact eager match Exit 0; exact eager match

The PR reports:

aoti_torch_mps_bmm_out: self tensor must be contiguous.
Expected strides=[4,4,1] for shape=[2,1,4], got strides=[4,1,1].
Error: aoti_torch_mps_bmm_out(buf0, weight, arg1_1) API call failed

There is a second instance of the same problem in aoti_torch_copy_: creating a float32 MPS source with sizes {2,1,4}, strides {4,1,1}, and a CPU destination with sizes {2,1,4}, strides {4,4,1} via aoti_torch_empty_strided, then calling aoti_torch_copy_(destination, source, 0), succeeds on the baseline but returns NotImplemented / Layout conversion not supported on this PR. This copy finding is from a native shim test; I have not reproduced it through an exported model.

A local diagnostic build keeps the convolution stride-preservation fix and makes BMM's contiguity checks ignore size-one axes, while the copy check ignores a stride difference only when both tensors have size one on that axis. Both native repros and both exported BMM cases then pass, and all 18 previously tested convolution cases still pass, including the original single-channel chain.

Please add regression coverage and update these consumers alongside the shared tensor-construction change, or land the consumer updates as a prerequisite. The strict checks predate this PR, but changing their inputs makes this a new regression for an existing model.

Validation performed with Codex on Apple M1 Pro, macOS 26.6.2, PyTorch 2.14.0, using the current PR's rebuilt native runtime.

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. module: metal Issues related to the AOTI Metal backend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Metal backend: conv2d returns wrong results (aoti_torch_mps_convolution ignores tensor strides)

4 participants