[PR #270] Add Metal RoPE and AdaLN kernels for Apple GPUs (mps backend), based on main @ f8b7e97 (v0.2.40) #273

Open
opened 2026-10-26 22:22:20 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/Comfy-Org/comfy-kitchen/pull/270
Author: @LXD-8
Created: 10/22/2026
Status: 🔄 Open

Base: main ← Head: mps-rope-adaln


📝 Commits (10+)

  • 15dd533 Add Metal RoPE and AdaLN kernels for Apple GPUs (mps backend)
  • 79e38b2 mps: decline on a shader compile failure, and on a missing primary scale
  • 95f6f76 tests: count the shader compile attempts in the fallback test
  • e7f0778 tests: cover adaln and split-half in the mps fallback regressions
  • 0152510 README: list mps first in the backend priority order
  • 8a41306 README: describe the backend order as runtime-dependent, mps first when available
  • 01df9fb README: add mps to the backend list
  • a9a2f37 Docs: paired rope entries launch one Metal kernel per tensor
  • e40980a README: the 1024 head-dim limit applies to RoPE only
  • bd62ecc README: list every case the mps call rules send to eager

📊 Changes

8 files changed (+905 additions, -47 deletions)

View changed files

📝 README.md (+64 -47)
📝 comfy_kitchen/__init__.py (+9 -0)
➕ comfy_kitchen/backends/mps/__init__.py (+110 -0)
➕ comfy_kitchen/backends/mps/kernels.py (+314 -0)
➕ comfy_kitchen/backends/mps/norm.py (+22 -0)
➕ comfy_kitchen/backends/mps/rope.py (+142 -0)
➕ tests/test_mps_adaln.py (+51 -0)
➕ tests/test_mps_rope.py (+193 -0)

📄 Description

Based on main @ f8b7e97 (v0.2.40).

What

An mps backend that runs, on Apple GPUs, every RoPE entry point and both AdaLN variants as one Metal kernel per tensor (a single launch per call, two for the paired (xq, xk) entries):

  • apply_rope, apply_rope1, apply_rope_split_half, apply_rope_split_half1
  • rms_rope, rms_rope1, rms_rope_split_half (including rot_dim), rms_rope_split_half1
  • the in-place form of each of the above
  • adaln, rms_adaln

float32, float16 and bfloat16; any even head dim up to 1024. Same signatures as eager.

Why

On MPS these ops run on eager today, as chains of small ops (a norm, dtype casts, broadcasts, multiply-adds), each its own launch and full pass over memory. They sit on every transformer block of the DiTs that call comfy-kitchen, so on a Mac they are a visible share of each step.

Design

  • One kernel per variant family. A templated rope kernel takes norm / split_half / rot_dim as flags: one simdgroup per row, each lane owns the same pairs in every pass, so the in-place forms write into x safely. The adaln kernel does one threadgroup per row, two-pass mean/variance (or RMS) in fp32.
  • Strided inputs without copies. The leading dims are addressed through a small shape/stride table. Dims that every operand steps through as one are merged first, so contiguous inputs cost one multiply per row and the common cases (packed-qkv slices, (B, H, N, D), broadcast freqs_cis / scale / shift) need no .contiguous().
  • Eager's rounding points. Math is in fp32 and the RMSNorm result is rounded to the input dtype before the rotation, as F.rms_norm on that dtype does, so results differ from eager only by rounding (not bit-identical). trim_rope_freqs and check_rope_inplace are the shared helpers eager uses.
  • Declines go to eager through the registry. rope_unsupported / adaln_unsupported are the single source of truth for what the kernels take, and the call rules use them. Other devices, dtypes (including non-fp32 freqs_cis), a non-contiguous last dim, mixed-dtype scale/shift or more than four non-mergeable leading dims fall through to eager, so registering this backend never changes which calls succeed.
  • Registered only when MPS is available and torch.mps.compile_shader exists.

Relationship to #249

#249 adds an mps backend for the INT8 / W4A8 linears. This PR adds different functions to a backend of the same name and registers it the same way:

  • The comfy_kitchen/__init__.py block is the one from #249, line for line, except that its comment does not say "weight-only".
  • backends/mps/__init__.py has the same _build_constraints() / _register() shape.
  • The new modules (kernels.py, rope.py, norm.py) do not overlap with #249's files.

Whichever lands first, the other only needs to merge two capability dicts and the README matrix column. I'm happy to rebase onto #249 instead if you prefer that order. The two would also compose: #249's int8_linear runs the same blocks whose rope and norm calls this backend takes.

Measurements

MacBook Pro M5 Max (40-core GPU), 128 GB, macOS 27.2. bf16, median ms per call, eager backend against this backend:

Call eager mps speedup
adaln, x (1, 4139, 4096) 1.32 0.17 8.0x
rms_adaln, x (1, 4141, 3072) 1.86 0.11 17.3x
rms_rope q+k, (1, 4096, 32, 128) 3.76 0.53 7.1x
rms_rope_split_half q+k, packed qkv (1, 4141, 24, 128) 2.80 0.42 6.6x
apply_rope q+k, (1, 4096, 24, 128) 2.63 0.33 7.9x

That is PyTorch 2.14.1. PyTorch nightly 2.16.0.dev20261022 gives the same picture (6.6x to 19.9x).

End to end in ComfyUI 0.42.0 (PyTorch nightly, --use-pytorch-cross-attention), seconds per 1024x1024 image, this branch against main, nothing else changed:

Model main this PR
Qwen-Image 2.1 Turbo bf16, 8 steps 13.5 12.5
MOSS-Image SFT bf16, 10 steps, CFG 2.5 30.5 24.7

I only have an M5 Max. I have not measured M1 to M4. The kernels use plain simdgroup reductions and no M5-only features, so I expect them to run on any Apple GPU that compile_shader supports, but I can't vouch for the speedups there.

Verification

  • tests/test_mps_rope.py and tests/test_mps_adaln.py: 166 tests, skipped off MPS. Every variant across five layouts, four head dims and three dtypes, plus the in-place forms, partial rot_dim, a packed-qkv in-place call that must leave v untouched, and the cases that must fall back to eager (including a missing scale and a shader that fails to compile). The reference is eager in fp32 on the CPU. A result passes when it is no further from that reference than eager running on MPS in the same dtype (with a small floor where both are exact). For the rms_rope variants the kernel is in fact closer to fp32 than eager on MPS, whose bf16 rms_norm is less precise than the CPU's (the kernel matches eager on the CPU).
  • Breaking the kernel on purpose (swapping a rotation coefficient, or subtracting the mean in rms_adaln) fails 123 and 15 of these tests respectively.
  • Passes on PyTorch 2.14.1 and on nightly 2.16.0.dev20261022.
  • Full suite on this Mac: main 770 passed / 3568 skipped / 0 failed; this branch 936 / 3568 / 0, so nothing that passed before fails now.
  • ruff check clean on the changed files.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/Comfy-Org/comfy-kitchen/pull/270 **Author:** [@LXD-8](https://github.com/LXD-8) **Created:** 10/22/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `mps-rope-adaln` --- ### 📝 Commits (10+) - [`15dd533`](https://github.com/Comfy-Org/comfy-kitchen/commit/15dd533275add241957262a01ebfc230cd9c60ef) Add Metal RoPE and AdaLN kernels for Apple GPUs (mps backend) - [`79e38b2`](https://github.com/Comfy-Org/comfy-kitchen/commit/79e38b20c0ca94b0172196b8ffbf0c41577ff0da) mps: decline on a shader compile failure, and on a missing primary scale - [`95f6f76`](https://github.com/Comfy-Org/comfy-kitchen/commit/95f6f767e143ad9a74fd2c28ebd7ea602defd775) tests: count the shader compile attempts in the fallback test - [`e7f0778`](https://github.com/Comfy-Org/comfy-kitchen/commit/e7f07782227ac7c487d4bb09c5c8a1f1cf4ce0d8) tests: cover adaln and split-half in the mps fallback regressions - [`0152510`](https://github.com/Comfy-Org/comfy-kitchen/commit/015251007c95170db499ccf9f5627d2cf8d9fd31) README: list mps first in the backend priority order - [`8a41306`](https://github.com/Comfy-Org/comfy-kitchen/commit/8a413068f90e5cf7dec09179b9cfbbf42f3843e1) README: describe the backend order as runtime-dependent, mps first when available - [`01df9fb`](https://github.com/Comfy-Org/comfy-kitchen/commit/01df9fb86120c169ef07ea211e943a09d2ff9bf9) README: add mps to the backend list - [`a9a2f37`](https://github.com/Comfy-Org/comfy-kitchen/commit/a9a2f376a62094c6b2effbeb81efeb896b8cd300) Docs: paired rope entries launch one Metal kernel per tensor - [`e40980a`](https://github.com/Comfy-Org/comfy-kitchen/commit/e40980a1310cf2f6a35df18a61ac85ac7d9f0efc) README: the 1024 head-dim limit applies to RoPE only - [`bd62ecc`](https://github.com/Comfy-Org/comfy-kitchen/commit/bd62ecc4613730e80cc8cf70eb9ee7eb7c22f21a) README: list every case the mps call rules send to eager ### 📊 Changes **8 files changed** (+905 additions, -47 deletions) <details> <summary>View changed files</summary> 📝 `README.md` (+64 -47) 📝 `comfy_kitchen/__init__.py` (+9 -0) ➕ `comfy_kitchen/backends/mps/__init__.py` (+110 -0) ➕ `comfy_kitchen/backends/mps/kernels.py` (+314 -0) ➕ `comfy_kitchen/backends/mps/norm.py` (+22 -0) ➕ `comfy_kitchen/backends/mps/rope.py` (+142 -0) ➕ `tests/test_mps_adaln.py` (+51 -0) ➕ `tests/test_mps_rope.py` (+193 -0) </details> ### 📄 Description Based on `main` @ `f8b7e97` (v0.2.40). ### What An `mps` backend that runs, on Apple GPUs, every RoPE entry point and both AdaLN variants as one Metal kernel per tensor (a single launch per call, two for the paired `(xq, xk)` entries): - `apply_rope`, `apply_rope1`, `apply_rope_split_half`, `apply_rope_split_half1` - `rms_rope`, `rms_rope1`, `rms_rope_split_half` (including `rot_dim`), `rms_rope_split_half1` - the in-place form of each of the above - `adaln`, `rms_adaln` float32, float16 and bfloat16; any even head dim up to 1024. Same signatures as eager. ### Why On MPS these ops run on eager today, as chains of small ops (a norm, dtype casts, broadcasts, multiply-adds), each its own launch and full pass over memory. They sit on every transformer block of the DiTs that call comfy-kitchen, so on a Mac they are a visible share of each step. ### Design - **One kernel per variant family.** A templated rope kernel takes `norm` / `split_half` / `rot_dim` as flags: one simdgroup per row, each lane owns the same pairs in every pass, so the in-place forms write into `x` safely. The adaln kernel does one threadgroup per row, two-pass mean/variance (or RMS) in fp32. - **Strided inputs without copies.** The leading dims are addressed through a small shape/stride table. Dims that every operand steps through as one are merged first, so contiguous inputs cost one multiply per row and the common cases (packed-qkv slices, `(B, H, N, D)`, broadcast `freqs_cis` / `scale` / `shift`) need no `.contiguous()`. - **Eager's rounding points.** Math is in fp32 and the RMSNorm result is rounded to the input dtype before the rotation, as `F.rms_norm` on that dtype does, so results differ from eager only by rounding (not bit-identical). `trim_rope_freqs` and `check_rope_inplace` are the shared helpers eager uses. - **Declines go to eager through the registry.** `rope_unsupported` / `adaln_unsupported` are the single source of truth for what the kernels take, and the call rules use them. Other devices, dtypes (including non-fp32 `freqs_cis`), a non-contiguous last dim, mixed-dtype `scale`/`shift` or more than four non-mergeable leading dims fall through to eager, so registering this backend never changes which calls succeed. - Registered only when MPS is available and `torch.mps.compile_shader` exists. ### Relationship to #249 #249 adds an `mps` backend for the INT8 / W4A8 linears. This PR adds different functions to a backend of the same name and registers it the same way: - The `comfy_kitchen/__init__.py` block is the one from #249, line for line, except that its comment does not say "weight-only". - `backends/mps/__init__.py` has the same `_build_constraints()` / `_register()` shape. - The new modules (`kernels.py`, `rope.py`, `norm.py`) do not overlap with #249's files. Whichever lands first, the other only needs to merge two capability dicts and the README matrix column. I'm happy to rebase onto #249 instead if you prefer that order. The two would also compose: #249's `int8_linear` runs the same blocks whose rope and norm calls this backend takes. ### Measurements MacBook Pro M5 Max (40-core GPU), 128 GB, macOS 27.2. bf16, median ms per call, eager backend against this backend: | Call | eager | mps | speedup | |---|---|---|---| | `adaln`, x (1, 4139, 4096) | 1.32 | 0.17 | 8.0x | | `rms_adaln`, x (1, 4141, 3072) | 1.86 | 0.11 | 17.3x | | `rms_rope` q+k, (1, 4096, 32, 128) | 3.76 | 0.53 | 7.1x | | `rms_rope_split_half` q+k, packed qkv (1, 4141, 24, 128) | 2.80 | 0.42 | 6.6x | | `apply_rope` q+k, (1, 4096, 24, 128) | 2.63 | 0.33 | 7.9x | That is PyTorch 2.14.1. PyTorch nightly 2.16.0.dev20261022 gives the same picture (6.6x to 19.9x). End to end in ComfyUI 0.42.0 (PyTorch nightly, `--use-pytorch-cross-attention`), seconds per 1024x1024 image, this branch against `main`, nothing else changed: | Model | `main` | this PR | |---|---|---| | Qwen-Image 2.1 Turbo bf16, 8 steps | 13.5 | 12.5 | | MOSS-Image SFT bf16, 10 steps, CFG 2.5 | 30.5 | 24.7 | I only have an M5 Max. I have not measured M1 to M4. The kernels use plain simdgroup reductions and no M5-only features, so I expect them to run on any Apple GPU that `compile_shader` supports, but I can't vouch for the speedups there. ### Verification - `tests/test_mps_rope.py` and `tests/test_mps_adaln.py`: 166 tests, skipped off MPS. Every variant across five layouts, four head dims and three dtypes, plus the in-place forms, partial `rot_dim`, a packed-qkv in-place call that must leave `v` untouched, and the cases that must fall back to eager (including a missing scale and a shader that fails to compile). The reference is eager in fp32 on the CPU. A result passes when it is no further from that reference than eager running on MPS in the same dtype (with a small floor where both are exact). For the `rms_rope` variants the kernel is in fact closer to fp32 than eager on MPS, whose bf16 `rms_norm` is less precise than the CPU's (the kernel matches eager on the CPU). - Breaking the kernel on purpose (swapping a rotation coefficient, or subtracting the mean in `rms_adaln`) fails 123 and 15 of these tests respectively. - Passes on PyTorch 2.14.1 and on nightly 2.16.0.dev20261022. - Full suite on this Mac: `main` 770 passed / 3568 skipped / 0 failed; this branch 936 / 3568 / 0, so nothing that passed before fails now. - `ruff check` clean on the changed files. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Comfy-Org/comfy-kitchen#273
No description provided.