Skip to content

[Apple Silicon] Add a custom Metal RMSNorm kernel - #30163

Draft
SasankYadati wants to merge 6 commits into
sgl-project:mainfrom
SasankYadati:metal-rmsnorm
Draft

SasankYadati wants to merge 6 commits into
sgl-project:mainfrom
SasankYadati:metal-rmsnorm

Conversation

@SasankYadati

@SasankYadati SasankYadati commented Jul 5, 2026

Copy link
Copy Markdown

What this adds

This adds a custom Metal RMSNorm kernel for the Apple Silicon MLX backend. It uses the same compiled ahead of time build path as the merged RoPE kernel (#22868 on the #23449 infra). The kernel is off by default. You turn it on with an environment variable, and it falls back to mx.fast.rms_norm when it is off or when a call is not supported.

Code

Kernel and build:

  • sgl-kernel/csrc/metal/rms_norm.metal: the kernel. It computes y = x * rsqrt(mean(x^2, axis=-1) + eps) * w. It accumulates in fp32, upcasts the weight to fp32, and casts back to the input dtype on store. It loads four elements at a time when the hidden size is a multiple of 4, and uses a scalar path otherwise.
  • sgl-kernel/csrc/metal/rms_norm.cpp: the MLX Primitive, the dispatch, and the Python entry point.
  • sgl-kernel/csrc/metal/metal_common.h: a small shared header for the dtype suffix helper.
  • sgl-kernel/setup_metal.py: registers the two new source files.
  • sgl-kernel/python/sgl_kernel/metal.py: the Python wrapper.

Backend integration:

  • python/sglang/srt/hardware_backend/mlx/norm_wrapper.py: a wrapper that replaces each plain nn.RMSNorm module and calls the kernel, plus patch_model_norms which installs it. It is gated by SGLANG_MLX_USE_CUSTOM_RMSNORM.
  • The wrapper is installed in model_runner.py right after the attention wrapper.

Correctness

  • sgl-kernel/tests/test_metal_norm.py: 99 cases pass. 96 of them compare the kernel to an fp32 reference across the same sweep as test_norm.py: batch sizes {1, 19, 99, 989}, hidden sizes {111, 500, 1024, 3072, 3584, 4096, 8192, 16384}, dtypes {f16, bf16, f32}. The other 3 check input validation. Tolerances match test_norm.py: 1e-3 for f16, 2e-2 for bf16, 1e-4 for f32. Worst observed max difference: 3.9e-3 for f16, 3.1e-2 for bf16, 1.9e-6 for f32. The hidden size list covers both the vectorized path (multiples of 4) and the scalar path (111, 500). The test is skipped when the Metal extension is not built, so it is a no-op off Apple Silicon.
  • End to end on Qwen3-0.6B, checked two ways. Through the server, the greedy output_ids are identical with the flag on and off. In a standalone model-level check that swaps only the norm implementation, the argmax token matches at every prompt position. The last-token logits differ by up to 0.34 in absolute value, which is accumulated rounding across 113 low precision norms and does not change any greedy token.

Performance

Measured with sgl-kernel/benchmark/bench_metal_rmsnorm.py against mx.fast.rms_norm on an M4 Pro (48 GB), mlx 0.31.2, macOS 26. The harness uses a pool of distinct inputs, takes the minimum over 7 repeats, and clears the MLX cache between configs. Numbers are microseconds per call, amortized inside one lazy evaluation. Speedup above 1.0 means the custom kernel is faster.

image

Summary:

  1. Ahead at prefill-like shapes with small hidden sizes: up to 1.6x at batch 512 with hidden 896 to 1024, and 1.1x to 1.4x at batch 128 with hidden up to 3072 (f16).
  2. At parity at large batch: 0.96x to 1.08x at batch 2048 across all hidden sizes.
  3. Behind at small batches, which is low concurrency decode: 0.7x to 0.9x at batch 1 to 32. The absolute gap is small (the calls are 5 to 12 microseconds, launch overhead dominated), but it is consistent.
  4. Slightly behind at medium batch with large hidden: 0.85x to 0.99x at batch 128 to 512 with hidden 2048 and up. mx.fast.rms_norm is notably strong for bf16 here.

The fuller 84-config sweep is below. Given this profile, I would keep the kernel opt-in rather than on by default for now. The standalone op wins in a region, and the case for default-on is potentially the fused add + norm follow up, which removes a memory round trip. An alternative is to route by shape in the wrapper (e.g., small row counts go to mx.fast.rms_norm), but that threshold would be tuned on one chip only.

Open to hear feedback.

Full sweep (84 configs, f16 and bf16, batch 1 to 2048, hidden 896 to 8192)
dtype      batch  hidden   metal_us  mxfast_us  speedup
-------------------------------------------------------
float16        1     896       7.62       6.07    0.80x
float16        1    1024       8.24       5.71    0.69x
float16        1    2048       8.29       5.44    0.66x
float16        1    3072       7.60       5.85    0.77x
float16        1    4096       7.35       6.45    0.88x
float16        1    5120       7.05       5.34    0.76x
float16        1    8192       8.07       5.82    0.72x
float16        8     896       7.44       8.08    1.09x
float16        8    1024       7.67       7.96    1.04x
float16        8    2048       7.80       7.73    0.99x
float16        8    3072       7.74       7.51    0.97x
float16        8    4096       8.18       7.48    0.91x
float16        8    5120       8.60       8.30    0.97x
float16        8    8192       9.70       8.77    0.90x
float16       32     896       9.25       9.85    1.06x
float16       32    1024       8.91      10.05    1.13x
float16       32    2048       9.55      10.70    1.12x
float16       32    3072      10.51      10.43    0.99x
float16       32    4096      11.59      11.60    1.00x
float16       32    5120      11.60      14.78    1.27x
float16       32    8192      11.58      10.48    0.90x
float16      128     896       9.46      11.97    1.26x
float16      128    1024      13.01      18.63    1.43x
float16      128    2048      15.82      18.40    1.16x
float16      128    3072      18.33      20.28    1.11x
float16      128    4096      15.82      13.68    0.87x
float16      128    5120      17.92      16.82    0.94x
float16      128    8192      25.17      23.96    0.95x
float16      512     896      14.89      23.78    1.60x
float16      512    1024      15.57      24.79    1.59x
float16      512    2048      24.84      23.48    0.95x
float16      512    3072      33.63      32.05    0.95x
float16      512    4096      43.77      42.21    0.96x
float16      512    5120      54.53      53.92    0.99x
float16      512    8192      88.60      85.83    0.97x
float16     2048     896      37.16      37.40    1.01x
float16     2048    1024      41.32      43.57    1.05x
float16     2048    2048      81.87      78.70    0.96x
float16     2048    3072     126.68     124.84    0.99x
float16     2048    4096     168.30     182.59    1.08x
float16     2048    5120     205.48     212.73    1.04x
float16     2048    8192     337.77     344.80    1.02x
bfloat16       1     896       6.60       4.70    0.71x
bfloat16       1    1024       6.50       4.63    0.71x
bfloat16       1    2048       5.60       3.72    0.66x
bfloat16       1    3072       5.42       3.89    0.72x
bfloat16       1    4096       5.57       3.69    0.66x
bfloat16       1    5120       6.29       4.74    0.75x
bfloat16       1    8192       6.54       4.74    0.73x
bfloat16       8     896       6.25       5.56    0.89x
bfloat16       8    1024       6.65       6.49    0.98x
bfloat16       8    2048       6.82       5.58    0.82x
bfloat16       8    3072       7.11       5.87    0.83x
bfloat16       8    4096       7.19       5.78    0.80x
bfloat16       8    5120       7.24       6.03    0.83x
bfloat16       8    8192       7.20       5.98    0.83x
bfloat16      32     896       7.67       6.27    0.82x
bfloat16      32    1024       7.35       7.02    0.95x
bfloat16      32    2048       7.38       6.24    0.85x
bfloat16      32    3072       8.00       6.42    0.80x
bfloat16      32    4096       7.16       5.99    0.84x
bfloat16      32    5120       7.65       6.27    0.82x
bfloat16      32    8192      10.08       8.18    0.81x
bfloat16     128     896       7.95       8.76    1.10x
bfloat16     128    1024       8.31       8.85    1.06x
bfloat16     128    2048      10.94       9.70    0.89x
bfloat16     128    3072      13.06      11.10    0.85x
bfloat16     128    4096      15.45      13.72    0.89x
bfloat16     128    5120      17.80      15.76    0.88x
bfloat16     128    8192      25.67      22.73    0.89x
bfloat16     512     896      14.90      19.44    1.30x
bfloat16     512    1024      15.37      19.85    1.29x
bfloat16     512    2048      24.11      22.68    0.94x
bfloat16     512    3072      33.48      31.54    0.94x
bfloat16     512    4096      43.58      42.51    0.98x
bfloat16     512    5120      55.05      53.24    0.97x
bfloat16     512    8192      88.94      86.54    0.97x
bfloat16    2048     896      37.14      37.70    1.01x
bfloat16    2048    1024      41.37      43.77    1.06x
bfloat16    2048    2048      83.09      79.58    0.96x
bfloat16    2048    3072     127.03     124.38    0.98x
bfloat16    2048    4096     171.90     184.03    1.07x
bfloat16    2048    5120     213.26     217.09    1.02x
bfloat16    2048    8192     331.82     330.54    1.00x

The batch 1, hidden 896, f16 cell reads 0.35x when it is the first config a fresh process runs, which looks like GPU clock ramp up. The value above is from a warm re-run. All other cells are stable across repeated runs.

How to test

  • Build: python sgl-kernel/setup_metal.py install on Apple Silicon with Python 3.11.
  • Run the tests: python -m pytest sgl-kernel/tests/test_metal_norm.py.
  • Enable it in the server with SGLANG_MLX_USE_CUSTOM_RMSNORM=1 alongside SGLANG_USE_MLX=1.

CI States

Latest PR Test (Base): ❌ Run #30717815848
Latest PR Test (Extra): ❌ Run #30717815724

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a custom Metal RMSNorm kernel for Apple Silicon (MLX backend) to optimize performance, including the Metal shader, C++ bindings, Python wrappers, model patching integration, correctness tests, and benchmarks. The review feedback identifies a critical memory leak in the C++ nanobind wrapper due to improper placement new usage, the need for __getattr__ forwarding in the model wrapper, and missing row-contiguity checks on inputs and weights to prevent undefined behavior or crashes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread sgl-kernel/csrc/metal/rms_norm.cpp Outdated
Comment on lines +123 to +125
auto* dst = nb::inst_ptr<array>(py_obj);
new (dst) array(std::move(outs[0]));
nb::inst_mark_ready(py_obj);

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.

critical

Using placement new on dst (which points to an already fully constructed array object created by py_array_type(0)) without calling its destructor will leak the resources of the old array (such as its internal shared pointers). Since this is called on every forward pass of every layer, it will cause a severe memory leak during inference. Using standard move assignment is much safer and avoids any memory leaks.

Suggested change
auto* dst = nb::inst_ptr<array>(py_obj);
new (dst) array(std::move(outs[0]));
nb::inst_mark_ready(py_obj);
auto* dst = nb::inst_ptr<array>(py_obj);
*dst = std::move(outs[0]);

Comment on lines +57 to +62
def __init__(self, inner: nn.Module, kernel: Callable):
super().__init__()
# Bypass nn.Module.__setattr__ so these are plain attributes, not
# re-registered submodules/params (mirrors MLXAttentionWrapper).
object.__setattr__(self, "_inner", inner)
object.__setattr__(self, "_kernel", kernel)

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.

high

MLXRMSNormWrapper wraps nn.RMSNorm but does not forward attribute lookups (such as weight or eps) to the wrapped module. This can cause AttributeError if other parts of the model or SGLang attempt to access attributes of the norm module. Implementing __getattr__ to forward lookups to self._inner makes the wrapper transparent.

Suggested change
def __init__(self, inner: nn.Module, kernel: Callable):
super().__init__()
# Bypass nn.Module.__setattr__ so these are plain attributes, not
# re-registered submodules/params (mirrors MLXAttentionWrapper).
object.__setattr__(self, "_inner", inner)
object.__setattr__(self, "_kernel", kernel)
def __init__(self, inner: nn.Module, kernel: Callable):
super().__init__()
# Bypass nn.Module.__setattr__ so these are plain attributes, not
# re-registered submodules/params (mirrors MLXAttentionWrapper).
object.__setattr__(self, "_inner", inner)
object.__setattr__(self, "_kernel", kernel)
def __getattr__(self, name: str) -> Any:
return getattr(self._inner, name)

Comment on lines +64 to +72
def _supported(self, x: mx.array) -> bool:
"""Whether the custom kernel can handle this call; else we fall back."""
w = self._inner.weight
return (
x.dtype in (mx.float16, mx.bfloat16, mx.float32)
and w.dtype == x.dtype
and w.ndim == 1
and x.shape[-1] == w.shape[0]
)

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.

high

The custom Metal kernel assumes that the input array x is row-contiguous and the weight array w is contiguous. If x or w is non-contiguous (e.g., due to slicing or transposing), the kernel will read incorrect memory, leading to silent correctness bugs or crashes. We should check x.flags.row_contiguous and w.flags.row_contiguous in _supported to safely fall back to mx.fast.rms_norm for non-contiguous inputs.

Suggested change
def _supported(self, x: mx.array) -> bool:
"""Whether the custom kernel can handle this call; else we fall back."""
w = self._inner.weight
return (
x.dtype in (mx.float16, mx.bfloat16, mx.float32)
and w.dtype == x.dtype
and w.ndim == 1
and x.shape[-1] == w.shape[0]
)
def _supported(self, x: mx.array) -> bool:
"""Whether the custom kernel can handle this call; else we fall back."""
w = self._inner.weight
return (
x.dtype in (mx.float16, mx.bfloat16, mx.float32)
and w.dtype == x.dtype
and w.ndim == 1
and x.shape[-1] == w.shape[0]
and x.flags.row_contiguous
and w.flags.row_contiguous
)

Comment thread sgl-kernel/python/sgl_kernel/metal.py Outdated
Comment on lines +141 to +144
if x.dtype != w.dtype:
raise ValueError(
f"rms_norm x/w dtypes must match, got {x.dtype} vs {w.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.

high

If the Python API sgl_kernel.metal.rms_norm is called directly with non-contiguous arrays, it will bypass the wrapper's safety checks and call the C++ kernel directly, leading to undefined behavior or crashes. We should add checks for x.flags.row_contiguous and w.flags.row_contiguous here as well.

Suggested change
if x.dtype != w.dtype:
raise ValueError(
f"rms_norm x/w dtypes must match, got {x.dtype} vs {w.dtype}"
)
if x.dtype != w.dtype:
raise ValueError(
f"rms_norm x/w dtypes must match, got {x.dtype} vs {w.dtype}"
)
if not x.flags.row_contiguous:
raise ValueError("rms_norm expects x to be row-contiguous")
if not w.flags.row_contiguous:
raise ValueError("rms_norm expects w to be row-contiguous")

@jlee5814 jlee5814 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.

Please run pre-commit run --all-files for the lint failure, and add a test_norm_patching.py (mirroring test_attention_patching.py) covering patch idempotency, the Gemma rejection, and the unsupported-shape fallback. The kernel's tested but the wrapper isn't.

SasankYadati and others added 4 commits July 31, 2026 09:54
AOT Metal rms_norm kernel (f16/bf16/f32) mirroring the merged
rope_pool_fused path: kernel + Primitive in sgl-kernel/csrc/metal/
(rms_norm.metal, rms_norm.cpp, shared metal_common.h), registered in
setup_metal.py, Python wrapper in sgl_kernel/metal.py. Vectorized
(vec<T,4>) loads; at parity-or-better vs mx.fast.rms_norm across the
hidden sizes Apple hardware runs, for f16 and bf16.

SRT integration behind SGLANG_MLX_USE_CUSTOM_RMSNORM: a norm wrapper and
patch_model_norms pass (srt/hardware_backend/mlx/norm_wrapper.py) with
mx.fast.rms_norm fallback, hooked in model_runner after
patch_model_attention. Tests in sgl-kernel/tests/test_metal_norm.py,
benchmark in sgl-kernel/benchmark/bench_metal_rmsnorm.py.
Cache eps, weight metadata, and the contiguous weight at wrap time
instead of recomputing per call; refresh the cache only when the
module's weight array is rebound (identity check, so update_weights
still takes effect on the next call); call the nanobind kernel entry
directly, skipping duplicate validation in sgl_kernel.metal.

3-D serving-path dispatch overhead drops from ~2.6us to ~1.7us per
call; e2e Qwen3-0.6B decode b1 regression halves (+2.9% -> +1.4%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
clang-format rewrites rms_norm.cpp (include order, indentation);
black collapses two statements in metal.py and test_metal_norm.py;
trailing newline in rms_norm.metal; whitespace cleanups. Fixes the
lint job on sgl-project#30163.
- metal.py: unquote mx.array annotations (file already uses
  from __future__ import annotations + TYPE_CHECKING import).
- bench_metal_rmsnorm.py: remove dead bench()/bench_chain() and wire
  --warmup/--repeats through to bench_pool (--iters was parsed but
  ignored).
- Update run commands in docstrings for the python/sglang/kernels/aot/
  tree location.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants