Skip to content

[Apple Silicon] Optimize MoE performance #22283

Open

Description

@jonahbernard

This is a subissue of #19137 dedicated to discussing optimizing MoE performance on Apple Silicon devices.

@jlee5814 is leading this effort.

Activity

  1. jonahbernard commented on Apr 7, 2026

    @jonahbernard
    ContributorAuthor

    On NVIDIA and AMD devices, this is the architecture of the MoE computation flow (#8715):

    [input_hidden_states]
              |
              v
         TopK.forward -> `select_experts` / `triton_kernels.routing` / bypass
              |
              V
         [TopKOutput]
              |
              v
       FusedMoE.forward -> Dispatcher.dispatch -> DeepEP / PPLX / bypass
              |                     |
              |                     v
              |              [DispatchOutput]
              |                     |
              |                     v
              |             quant_mothod.apply -> MoeRunner.forward -
              |                     |                                |
              |                     |                                v
              |                     |            pre-permute + grouped_gemm + post-permute
              |                     |                                |
              |                     |--------------------------------
              |                     v
              |               [CombineInput]
              |                     |
              |                     v
              |            Dispatcher.combine -> DeepEP / PPLX / bypass
              |                     |
              |---------------------                  
              v
    [final_hidden_states]

    There are a few custom kernels to make this run very fast. To concretely show the computations and time breakdown, I profiled a forward pass of Qwen3-30B-A3B on an NVIDIA B300 using this command to launch the server and the next command to send a request to it:

    CUDA_VISIBLE_DEVICES=7 PYTHONPATH=/my-sglang/sglang/python nsys profile -t cuda,nvtx -o triton_moe_server_profile \
        python3 -m sglang.launch_server \
        --model-path Qwen/Qwen3-30B-A3B \
        --moe-runner-backend triton \
        --disable-cuda-graph \
        --disable-piecewise-cuda-graph \
        --port 30004
    
    curl http://localhost:30004/v1/completions \
      -H "Content-Type: application/json" \
      -d '{
        "model": "Qwen/Qwen3-30B-A3B",
        "prompt": "The best way to profile a GPU kernel is",
        "max_tokens": 128,
        "temperature": 0.7
      }'

    Here is the full trace file. Please unzip it and open it with Nsight Systems.

    nsys-report-bac2.nsys-rep.zip

    I added NVTX markers around the 3 main MoE components (router, topk, and fused_moe):

    Image

    Here are the 3 NVTX markers that I added to get these labels:

    Image Image Image

    In these 3 components, we see that 9 kernels are launched (8 unique kernels are used):

    Router:

    Call to nvjet_sm103_tst_32x64_64x16_4x1_v_bz_splitK_TNN
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+3.609 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 4.606 μs→
    Correlation ID: 491515
    
    Call to splitKreduce_kernel
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+3.169 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 4.610 μs→
    Correlation ID: 491517

    Top-K:

    Call to topkGatingSoftmax
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+3.971 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 4.883 μs→
    Correlation ID: 491545

    Fused_MoE:

    Call to moe_align_block_size_kernel
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+4.120 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 5.041 μs→
    Correlation ID: 491607
    
    Call to count_and_sort_expert_tokens_kernel
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+2.949 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 4.138 μs→
    Correlation ID: 491609
    
    Call to fused_moe_kernel
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+3.660 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 5.313 μs→
    Correlation ID: 491619
    
    Call to act_and_mul_kernel
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+3.600 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 10.764 μs→
    Correlation ID: 491623
    
    Call to fused_moe_kernel
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+3.529 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 4.887 μs→
    Correlation ID: 491635
    
    Call to triton_per_fused_copy__mul_sum_0
    ■ Kernel launcher
    Begins: 198.812s
    Ends: 198.812s (+4.419 μs)
    Return value: 0
    GPU: 0000:0b:00.0 - NVIDIA B300 SXM6 AC
    Stream: 13
    Latency: 5.196 μs→
    Correlation ID: 491644

    Out of these 8 unique kernels, only 5 of them are custom kernels written by the SGLang team for fast inference:

    topkGatingSoftmax
    Count_and_sort_expert_tokens_kernel
    Fused_moe_kernel
    Moe_align_block_size_kernel
    Act_and_mul_kernel

    The other 3 are auto-generated kernels at inference time:

    nvjet_sm103_tst_32x64_64x16_4x1_v_bz_splitK_TNN
    splitKreduce_kernel
    triton_per_fused_copy__mul_sum_0

    Let's analyze the custom kernels. The first custom kernel that is launched is topkGatingSoftmax. This custom kernel was written to fuse 3 separate operations:

    1. scores = router(token)

    2. probabilities = softmax(scores)

    3. top_weights, top_indices = topk(probabilities, k=some integer)

    The second custom kernel that is launched is Count_and_sort_expert_tokens_kernel. This kernel has a very simple task: sort an integer array by each element's integer value. The input to this kernel an integer array (technically a Torch.Tensor) and the output is the sorted integer array. The input array is the top_indices from the topkGatingSoftmax step. These indices are the expert ID that each token is sent to. Thus, if the config sets topk=4 (meaning each token is sent to 4 experts), then top_indices will have shape (num_tokens, 4) because there are 4 expert IDs assigned for each token. Count_and_sort_expert_tokens_kernel will then flatten top_indices into an array of shape num_tokens x 4, sort the array so tokens that are sent to the same expert are next to each other. An example output is:

    topk=3
    sorted_token_indices: [0, 1, 2, 0, 1, 2, 3, 0, 1, 2, 3, 3]
    sorted_expert_ids: [0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3]
    expert_counts: [3, 4, 4, 1]

    In plain English:

    Token 0 is sent to Expert 0, Token 1 is sent to Expert 0, etc.

    The third custom kernel that is launched is Fused_moe_kernel. This kernel is launched twice: once for the gate_up_proj and once for the down_proj. In between each launch, Act_and_mul_kernel is launched and applied to the intermediate tensor.

    Now, let's explore how the MoE layer is computed in SGLang currently (it just uses MLX-LM under the hood), which is our baseline that we are attempting to optimize to achieve higher performance.

    I profiled mlx-community/Qwen1.5-MoE-A2.7B-4bit using this command on my MacBook Pro M1, 16Gb RAM, Sequoia 15.6:

    xctrace record --template "Metal System Trace" --launch -- /usr/bin/env SGLANG_USE_MLX=1 MLX_MAX_MB_PER_BUFFER=10000 MLX_MAX_OPS_PER_BUFFER=10000 $(which python) -m sglang.bench_one_batch \
      --model-path mlx-community/Qwen1.5-MoE-A2.7B-4bit \
      --trust-remote-code \
      --disable-radix-cache \
      --disable-cuda-graph \
      --tp-size 1 \
      --batch-size 1 \
      --input-len 60 \
      --output-len 10 \
      --port 43440 \
      --profile-start-step 0 \
      --profile-steps 10

    This profile took ~40 minutes to complete.

    Before analyzing the trace, I looked through the MLX-LM and MLX repo to understand the code and know what to look for. For Qwen1.5-MoE-A2.7B, MLX-LM uses a class called SwitchGLU for the MoE layer.

    Image Image

    Here is the implementation of SwitchLinear in MLX-LM:

    Image

    The main expert computation (equivalent of Fused_moe) is the MLX kernel gather_mm.

    Now analyzing the trace:

    We can see the MoE computations:
    affine_qmv_float16_t_gs_64_b_4_batch_0 (scores = router(token)) -> block_softmax_precise_float16 (probabilities = softmax(scores)) -> carg_block_sort_float16_uint32_bn32_tn4 (top_weights, top_indices = topk(probabilities, k=4) -> and then the expert computation, activation, and expert computation.

    Image

    To optimize further, we need to 1) fuse kernels 2) optimize kernels. Thus, now that we know what kernels are used, we need to study the implementation of the kernels and figure out if there are any optimizations that can be done inside the kernel implementation to make it faster and if we can fuse any kernels to make inference faster.

  2. jlee5814 commented on Apr 14, 2026

    @jlee5814
    Contributor

    Update on #22283:

    Profiled Qwen1.5 MoE A2.7B-4bit decode on M4 Pro. MoE cost splits are as follows:

    Xcode GPU performance trace shown by cost

    Findings:

    K=1408 fails the K%512 check in gather_qmv (quantized.cpp:984), so the
    routed down projection falls to the slower kernel variant.

    Wrote a custom mx.fast.metal_kernel for the non-fast case. Output matches
    MLX's quantized_matmul on production shapes (K=1408, N=2048, 60 experts, top-k 4).

    Next: benchmark, fp16, integrate into the MLX MoE forward.

  3. github-actions commented on Jun 14, 2026

    @github-actions
    Contributor

    This issue has been automatically closed due to inactivity. Please feel free to reopen it if needed.

  4. jlee5814 commented on Jun 16, 2026

    @jlee5814
    Contributor
  5. jlee5814 commented on Aug 4, 2026

    @jlee5814
    Contributor

    quantized.cpp gates the qmv and gather_qmv fast paths on K % 512, but qmv_fast_impl derives its block size from bits, which is 256 at 6 and 8 bits. The gate has been stricter than the kernel since March 2024. fp_qmv_fast_impl shares it, so MXFP8 at 8 bits hit the same wall.

    ml-explore/mlx#3965 derives the alignment from bits. 186 correctness cases pass, Metal shader validation is clean, gather_qmm_bench is unchanged. On an M4 Pro, 8 bit K=768 gains about 18% and K=1280 about 10%, with every 4 bit control flat.

    Fixed: Qwen1.5-MoE-A2.7B K=1408 and Qwen3-30B-A3B K=768, at 6 and 8 bits.

    Still broken: 4 bit, where the kernel really does need 512 alignment. Most Apple Silicon serving is 4 bit, so both models stay on the slow gather_qmv path. Keeping this open.

    Three options for 4 bit: a custom mx.fast.metal_kernel for the unaligned case, padding K, or relaxing the kernel to handle a tail. My prototype for the first is correct but 3.3x slower than the slow path it replaces.

  6. jlee5814 commented on Aug 10, 2026

    @jlee5814
    Contributor

    ml-explore/mlx#3965 merged on 8/7; it was still open when the previous comment posted.

    K=1408 eligibility by bit width, from the merged qmv_fast_k_alignment gate, confirmed on an M4 Pro by direct dispatch readout rather than timing (GEMV timing at small K is too variant to carry a conclusion, per the merging maintainer on ml-explore/mlx#3965):

    bits alignment required K=1408 eligible
    2 512 no
    3 512 no
    4 512 no
    5 512 no
    6 256 no
    8 256 no

    1408 is 128 x 11; 128 falls below both alignments.

    Correction: the previous comment listed Qwen1.5-MoE-A2.7B (K=1408) alongside Qwen3-30B-A3B (K=768) as fixed at 6 and 8 bits. Only K=768 is fixed; K=1280 also benefits, verified on the pre merge branch. K=1408 was never eligible, before or after the merge; the merged build's dispatch, including gather_qmv, confirms it.

    Qwen1.5-MoE-A2.7B keeps paying the slow gather_qmv path at every bit width and still needs a dedicated fix: a custom kernel, K padding, or a kernel that tolerates a tail.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions