Kernel Dev 训练营/ 提示与讲解
提示与讲解

Metal / MLX 与 CUDA / Triton:从硬件到 kernel 的对比

同一个 kernel 写在 NVIDIA GPU 和 Apple GPU 上,为什么写法、框架和性能都不一样?这一页按“硬件 → 软件设计 → 硬件差异怎么塑造了软件 → 框架与性能”的顺序对比,配上本仓库在 RTX 5090 和 M2 上同一类 kernel 的实测数字,以及 C1/T1/X1、C2/X2、C4/T4/X3 这几组题目的代码对照。

怎么读这一页

你在这套题里已经在两边都写过 kernel:Track C(CUDA C++)、Track T(Triton)和 Track X(MLX 的 Metal kernel)。这一页把它们放在一起比较。硬件参数是公开的规格,标了“约”;性能数字只要本仓库测过,就用实测(RTX 5090 + FlashInfer 0.7.2、Apple M2 24 GB + MLX 0.32),并注明来自哪一题。

① 一张图看懂最大的区别

两种机器的整体结构悬停节点查看连接
NVIDIA 是“CPU 和 GPU 各有各的内存,中间隔一条 PCIe”;Apple 是“CPU 和 GPU 在同一块芯片上,读同一块内存”。几乎所有后面的差异都能追溯到这一点,以及带宽和算力差了一个数量级以上。
类比 · 独立厨房 vs 开放式厨房

NVIDIA 的机器像一家餐厅:前厅(CPU)和后厨(GPU)是两个房间,后厨有自己超大的冷库(显存)和极快的传菜通道(HBM / GDDR7),但前厅和后厨之间只有一个小窗口(PCIe),东西递来递去很费时间。Apple 的机器像一个开放式厨房:做饭的和点菜的在同一个房间、共用一个冰箱(统一内存),拿东西不用经过窗口,冰箱也可以很大(最多几百 GB),但冰箱门比餐厅冷库的门窄得多(带宽低一个数量级),厨师也少得多(算力低)。

② 硬件对比

规格

NVIDIA RTX 5090 NVIDIA H100 SXM Apple M2(本仓库实测机) Apple M4 Max / M3 Ultra
内存 32 GB GDDR7(独立) 80 GB HBM3(独立) 24 GB 统一内存 最多 128 GB / 512 GB 统一内存
带宽 约 1.8 TB/s 约 3.35 TB/s 约 100 GB/s 约 546 GB/s / 约 819 GB/s
矩阵单元 Tensor Core(FP16/BF16/FP8/FP4) Tensor Core(+ TMA、wgmma) simdgroup_matrix(8×8,在普通 ALU 上) 同左;M5 起 GPU 核心里加入了 neural accelerator
本仓库实测的 GEMM fp16 约 218 TFLOP/s(T4) — fp32 约 2.6 TFLOP/s(X3,MLX 自带) —
功耗量级 约 575 W(整卡) 约 700 W 整机几十瓦 整机一百多瓦

编程模型里能看到的硬件

概念 NVIDIA Apple
一组同步执行的线程 warp,32 个 simdgroup,32 个(Apple GPU 上)
一个 block 能用的片上共享内存 __shared__,几十到两百多 KB(H100 每 SM 228 KB) threadgroup 内存,最多 32 KB
warp 内交换数据 __shfl_*_sync、__reduce_*_sync simd_shuffle_*、simd_sum、simd_max
矩阵乘指令 mma.sync(16×8×16 等)、Hopper wgmma(64 行起) simdgroup_multiply_accumulate(8×8×8)
异步拷贝 cp.async(Ampere 起)、TMA(Hopper 起) 没有对应的公开指令
低精度 FP8(Hopper / Ada 起)、FP4(Blackwell) half / bfloat;没有 FP8 矩阵单元
多卡 NVLink、NCCL 单芯片;多台 Mac 之间走网络(MLX 有 distributed)
编程模型的层次对照悬停节点查看连接
四层结构几乎一一对应,所以 CUDA 的思路可以直接搬到 Metal。真正的区别在细节:grid 的单位、片上内存的大小、矩阵指令的大小,以及有没有异步拷贝。X1 讲解第 ③ 节专门讲了 grid 单位的坑。

③ 软件栈对比

两边的软件栈悬停节点查看连接
CUDA 这边每一层都有好几个成熟的选择;Apple 这边更集中,LLM 方向基本围绕 MLX(以及 llama.cpp 的 Metal 后端)。Triton 目前没有官方的 Metal 后端,所以在 Mac 上写自定义 kernel 的主要方式就是 Metal Shading Language。
CUDA C++ Triton Metal / MLX
你写的是 一个线程的代码 一个 program(block)的代码 一个线程的代码(和 CUDA 一样)
线程 / 共享内存 / 流水线 全部手动 编译器负责 全部手动
语言 C++ 方言 Python 子集 C++14 方言(MSL)
编译 nvcc 提前编译,或 NVRTC 运行时编译 首次调用时 JIT MLX 内置 kernel 预编译成 metallib;metal_kernel 首次调用时 JIT
函数签名 自己写 自己写 MLX 自动生成,你只写函数体(X1)
启动 <<<blocks, threads, smem, stream>>> kernel[grid](...) 由 MLX 编码进 command buffer
调试与 profile Nsight Systems / Compute Triton 解释器(TRITON_INTERPRET)+ Nsight Xcode Metal debugger、Instruments

④ 硬件差异怎么塑造了软件设计

这是这一页的重点:两边的软件长得不一样,大多不是“谁的设计更好”,而是硬件不一样,最优的设计就不一样。

硬件差异 → 软件设计悬停节点查看连接
左边是 Apple GPU 和 NVIDIA GPU 的硬件差异,右边是 MLX / Metal kernel 因此做出的设计选择。下面逐条展开。

1. 统一内存 → 数组不属于任何设备

CUDA 程序的一大块工作是管理两块内存:cudaMalloc、cudaMemcpy、tensor.to("cuda"),以及想办法让拷贝和计算重叠。Apple silicon 上 CPU 和 GPU 读的是同一块物理内存,所以 MLX 的设计是:数组不分设备,你选的是在哪个 stream 上计算。

a = mx.random.normal((4096,))           # 不用说放在哪
mx.add(a, a, stream=mx.cpu)             # 在 CPU 上算
mx.add(a, a, stream=mx.gpu)             # 在 GPU 上算,同一块数据,没有拷贝

源码:MLX 分配的每一块内存都是 mlxMTL::ResourceStorageModeShared(CPU 和 GPU 都能直接访问);文档 mlxunified_memory.rst。

代价:没有独立显存,“模型能不能放下”由整机内存决定;好处也在这里:一台 128 GB 的 Mac 能放下一张 32 GB 显卡放不下的模型(第 ⑥ 节)。

类比 · 搬家 vs 换房间

CUDA 里,数据要从 CPU 用到 GPU,像把家具从一栋楼搬到另一栋楼(PCIe 拷贝),所以大家花很多心思安排什么时候搬、边搬边干活。统一内存像一套大房子,家具一直放在客厅,谁要用就走过去用(选 stream)。房子的大门不宽(带宽低),但至少不用搬家。

2. 带宽低一个数量级 → 量化是一等公民

LLM decode 每生成一个 token 都要把全部权重读一遍,速度 ≈ 带宽 ÷ 模型字节数(L2 讲过)。M2 的 100 GB/s 只有 RTX 5090 的约 1/18。在 Mac 上提升 decode 速度最直接的办法,就是让每个权重更小。

所以 MLX 把 4-bit / 8-bit 分组量化做成了一等公民:mx.quantize、QuantizedLinear,以及专门的 Metal kernel:decode 用的矩阵乘向量 mlxqmv_fast_impl、prefill 用的 mlxqmm_t_impl,反量化在 mlxdequantize 里和乘加融合。CUDA 这边当然也有量化(M3 的 FP8 block scaling),但数据中心 GPU 上的主要动机是算力(FP8 Tensor Core 快一倍),而 Mac 上的主要动机是带宽。

3. threadgroup 内存小、没有异步拷贝 → 更小的 tile、更简单的流水线

CUDA 上高性能 GEMM / attention 的套路(C4、T4、FlashAttention-3)是:大 tile(128×128 以上)放进上百 KB 的 shared memory,cp.async 或 TMA 异步预取下一块,Hopper 上再用 warp specialization 让一部分 warp 专门搬数据。

Apple GPU 上:

4. 矩阵单元是 8×8 → 用 simdgroup_matrix 拼 GEMM

CUDA(T4 / C4 之后) Metal(X3)
一条矩阵指令 mma.sync m16n8k16,Hopper wgmma 64×N×16 simdgroup_multiply_accumulate 8×8×8
在哪执行 独立的 Tensor Core GPU 的普通 ALU(M5 之前)
写法 Triton 一行 tl.dot;CUDA 里 mma / CuTe 手动 simdgroup_load、累加、simdgroup_store

X3 讲解里画过:一个 simdgroup 持有 2×2 个 8×8 累加器,做 2×2 的“块外积”。这和 C4 里一个线程持有 4×4 个标量做外积是同一个思想,只是粒度从标量变成了 8×8 的小矩阵。

5. 一个芯片、command buffer → 惰性求值 + 批量提交

CUDA 里每次 kernel launch 有几微秒的固定开销,decode 时大量小 kernel 的开销加起来很可观,所以有 CUDA graph(F1、F3 讲过)。Metal 的工作单位是 command buffer:把很多 kernel 编码进同一个 buffer,一次提交给 GPU。

MLX 用惰性求值配合这一点:你写 c = a + b 时什么都没算,只是记下了计算图;直到 mx.eval(c)(或需要结果时)才把整张图编码成 command buffer 提交。一个 command buffer 里攒多少个 op 有阈值:mlxneeds_commit:超过 max_ops 或 max_mb 才提交。文档:mlxlazy_evaluation.rst。mx.compile 还能把一串逐元素操作融合成一个 kernel(X1 的“测完之后”提到过)。

类比 · 拼单发货

CUDA 的普通 launch 像每做好一样东西就叫一次快递,每单都有起步价(launch 开销)。CUDA graph 是把一整套流程录成模板,以后一键重放。MLX 的惰性求值像网购的购物车:你一直往里加东西(记录计算图),结账(eval)时才打包成一个大包裹(command buffer)一起发。两者解决的是同一个问题:别让固定开销淹没小 kernel。

⑤ 同一个 kernel,三种写法

逐元素:add + GELU(C1 / T1 / X1)

CUDA(C1,简化)
__global__ void k(const float* x, float* y,
                  int64_t n) {
  int64_t i = (int64_t)blockIdx.x * blockDim.x
              + threadIdx.x;
  if (i < n) y[i] = gelu_tanh(x[i]);
}
k<<<(n + 255) / 256, 256, 0, stream>>>(x, y, n);
Triton(T1,简化)
@triton.jit
def k(x_ptr, y_ptr, out_ptr, n, BLOCK: tl.constexpr):
    offs = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
    mask = offs < n
    x = tl.load(x_ptr + offs, mask=mask)
    ...
    tl.store(out_ptr + offs, out, mask=mask)
k[(triton.cdiv(n, 1024),)](x, y, out, n, BLOCK=1024)
Metal / MLX(X1,简化)
// 只写函数体,签名由 MLX 生成
uint i = thread_position_in_grid.x;
if (i < n) {
  out[i] = T(gelu_tanh(float(x[i]) + float(y[i])));
}
// Python:grid 是线程总数,不是 block 数
// kernel(inputs=[x, y, n], grid=(n,1,1),
//        threadgroup=(256,1,1), ...)

归约:一行求和(C2 / T2 / X2)

CUDA(C2) Triton(T2) Metal(X2)
warp 内归约 for (o = 16; o > 0; o >>= 1) v += __shfl_down_sync(0xffffffff, v, o); tl.sum(x, axis=0) 一行,编译器生成 v = simd_sum(v); 一行,硬件做完
block 内 __shared__ + __syncthreads() 编译器生成 threadgroup + threadgroup_barrier
谁拿到结果 只有 lane 0(shfl_down) 广播给所有线程 每个 lane 都有(simd_sum)

Metal 在这一层其实比 CUDA 更方便:simd_sum / simd_max 一个函数就是整个 warp 的归约,结果每个 lane 都有,所以 X2 的 tg_reduce 让每个 simdgroup 都做第二轮,省掉一次广播(X2 讲解第 ③ 节)。源码:drillsC2 warp_reduce_sum · drillsX2 tg_reduce。

GEMM 的内层(C4 / T4 / X3)

CUDA C4 v2 Triton T4 Metal X3
内层 线程级 4×4 标量外积 acc = tl.dot(a, b, acc) simdgroup 级 2×2 个 8×8 块外积
用到的硬件 FP32 CUDA core Tensor Core(fp16) simdgroup_matrix(fp32)
片上 tile 64×64×16,shared memory autotune,128×256×64 也可以 32×32×16,threadgroup 内存
预取 没有 num_stages 软件流水线 没有

源码:drillsC4 外积 · drillsT4 主循环 · drillsX3 simdgroup MMA。

⑥ 性能对比

本仓库的实测:同一类 kernel

kernel RTX 5090 Apple M2 比例
逐元素(fp16,带宽) T1:1548 GB/s X1:46 GB/s(MLX 自带;同一时刻 x + 1 实测 59 GB/s,机器负载较高) 约 26–34 倍
行 softmax(N = 4096,fp16) T2:43.6 us · 1540 GB/s(M = 4096) X2:mx.softmax 716 us(M = 2048),约 47 GB/s 约 33 倍
GEMM FP32(大矩阵) C4:cuBLAS 65.4 TFLOP/s X3:MLX steel 2.62 TFLOP/s 约 25 倍
GEMM FP16 T4:217.8 TFLOP/s(8192³) — —

两点值得注意:

roofline 的拐点

计算峰值(实测) 带宽(实测) 拐点(FLOP / 字节)
RTX 5090,FP32 65.4 TFLOP/s(cuBLAS) 约 1.57 TB/s(C1) 约 42
RTX 5090,FP16 Tensor Core 217.8 TFLOP/s(T4) 约 1.57 TB/s 约 139
Apple M2,FP32 2.62 TFLOP/s(MLX steel) 约 59 GB/s(X1,负载较高) 约 44(按标称 100 GB/s 算约 26)

一个有意思的结论:FP32 下两台机器的拐点差不多,所以同一个算法在两边是 compute-bound 还是 memory-bound,结论基本一样,只是绝对速度差了一个数量级。真正拉开差距的是 Tensor Core:它把 5090 的拐点推高了三倍多,而 M2 没有对应的东西。

LLM 推理:速度和容量的取舍

decode 速度的上限 ≈ 带宽 ÷ 每个 token 要读的字节数(约等于模型权重大小)。用这个公式估一下(只是上限,不含 KV cache 和其他开销):

Llama-3 8B,4-bit(约 4.5 GB):decode 速度上限(token/s)
RTX 5090(1.8 TB/s)
约 400
M3 Ultra(819 GB/s)
约 182
M4 Max(546 GB/s)
约 121
M2(100 GB/s)
约 22
Llama-3 70B,4-bit(约 40 GB)
RTX 5090(32 GB)
放不下
M3 Ultra(819 GB/s)
约 20
M4 Max 128 GB(546 GB/s)
约 14
M2 24 GB
放不下

按“带宽 ÷ 权重字节数”估算的上限,实际会低一些。70B 那组的条长是相对 819 GB/s 画的。单张 5090 放不下 40 GB 的权重,需要多卡或把一部分放到 CPU 内存(那就受 PCIe 带宽限制了)。

类比 · 跑车和房车

RTX 5090 像跑车:极快(带宽、算力),但后备箱只有 32 GB,装不下大件行李(大模型)。高配 Mac 像房车:速度慢得多,但能把整个家(70B 模型)装进去开走。一张 H100 像大货车:又快又能装,但不是家用的价钱和功耗。选哪个,取决于你要拉什么、拉多远。

⑦ 框架和生态对比

CUDA 生态 Apple 生态
主力框架 PyTorch(+ torch.compile / Inductor 生成 Triton) MLX;PyTorch 也有 MPS 后端
写自定义 kernel CUDA C++、Triton、CuTe DSL、cuTile 等,多种选择 Metal Shading Language(mx.fast.metal_kernel 或 C++ 扩展)
attention / GEMM 库 FlashAttention、FlashInfer、cuBLAS、CUTLASS MLX 内置:mx.fast.scaled_dot_product_attention、steel GEMM、量化 matmul
LLM 推理引擎 vLLM、SGLang、TensorRT-LLM mlx-lm、llama.cpp(Metal 后端)
编译器自动生成 kernel Triton(Inductor)、XLA mx.compile 融合逐元素操作
多设备 NCCL、NVLink、张量并行 / 专家并行 mx.distributed,多台 Mac 之间
调试 / profile Nsight Systems、Nsight Compute、compute-sanitizer Xcode Metal debugger、Instruments、mx.metal.start_capture()

MLX 本身也在往跨平台走:它已经有一个 CUDA 后端(mlxmlx/backend/cuda/,GEMM 走 mlxcuBLAS),同一份 MLX 代码可以跑在 NVIDIA GPU 上。MLX 的“快速”算子清单在 mlxmlx/fast.h:rms_norm、layer_norm、rope、scaled_dot_product_attention,以及你在 X1–X3 用的 metal_kernel。

⑧ 一句话总结与选择建议

如果你要…… 更合适的选择 原因
训练、大规模在线服务、极致吞吐 NVIDIA + CUDA / Triton / FlashInfer Tensor Core 算力、HBM 带宽、NVLink 多卡、成熟的 serving 生态
本地跑大模型、单用户、对容量敏感 高配 Mac + MLX 统一内存容量大,量化 decode 速度可用,功耗低
学 GPU 编程的通用概念 两边都学 线程层次、合并访存、片上内存、归约、tiling、量化,概念完全通用;这套题就是这么设计的
写一次到处跑 Triton(NVIDIA / AMD)或 MLX(Apple / CUDA) 目前还没有同时原生覆盖两边的 kernel DSL

核心结论:两边的编程模型几乎一一对应(grid / block / warp / thread),所以你学的 CUDA 思路可以直接用在 Metal 上。真正的区别来自硬件:Apple 用统一内存换来了容量和省去拷贝,但带宽和矩阵算力低一个数量级,所以软件把重点放在量化、惰性求值和简单的 kernel 结构上;NVIDIA 有 Tensor Core、大片上内存和异步拷贝,所以软件把重点放在复杂的流水线、tile 调度和多卡扩展上。

源码与资料索引

本仓库的对照题:C1 / T1 / X1(逐元素)· C2 / T2 / X2(归约)· C4 / T4 / X3(GEMM)· M3(FP8 量化)· L2(decode 是 memory-bound)

MLX:mlxunified_memory.rst · mlxlazy_evaluation.rst · mlxcompile.rst · mlxcommand buffer 提交策略 · mlx共享内存分配 · mlx量化 qmv · mlxsteel BlockMMA · mlxsdpa_vector · mlxMLX CUDA 后端

文档:MLX 文档 · MLX · Custom Metal Kernels · Metal Shading Language Specification · CUDA Programming Guide · Triton 文档