把Transformer模型的推理链路整体搬到苹果芯片的GPU上,用Metal从头组织计算图和算子,这件事我从去年底开始做,前后改了四版。最终效果是解码吞吐从CPU路线的个位数涨到了30+ tokens/s,同一个约1.1B参数的decoder-only模型,在M3 Pro的32G内存机器上,纯CPU推理只有4.9 tok/s,Metal整条链路优化后达到31.5 tok/s。这个提升不是简单把设备切到GPU就有,而是把张量布局、算子融合、缓冲复用、同步策略全都按Metal的脾气重新排了一遍。如果你正在做Mac上的本地模型推理、离线批量处理,或者准备把模型部署到Apple平台,这篇文章会把我在MPSGraph、手写Metal Kernel、混合精度、KV Cache这几条线里的选择和踩坑过程完整讲一遍,争取让你少折腾几个礼拜。1. 做这件事之前,先搞清楚Metal凭什么能快很多人拿到Mac的第一反应就是“它有GPU,那我直接把TensorFlow或者PyTorch的device切到mps不就完了”。我能理解这种冲动,但这么做通常只能拿到个位数的加速比,有时候甚至更慢。问题不在GPU,在于你没有搞清楚Metal的加速模型和传统独立显卡完全不同。1.1 统一内存架构带来的先行优势苹果芯片用的是统一内存架构(UMA),CPU和GPU共享同一块物理内存,数据不需要从系统内存拷贝到显存。传统PC上推理框架要把权重从内存拷到显存,算完再拷回来,这一步的带宽开销非常大。而你在Mac上写Metal,所有权重直接以普通Buffer的形式放在系统内存里,GPU通过地址直接访问,零拷贝。这一点是苹果芯片GPU推理的第一优势,也是后续所有优化的地基。但这里有个隐藏的门槛:Metal对Buffer的对齐和存储模式有要求。普通的Swift数组往MTLBuffer里塞,如果存储模式不对,GPU读取效率会明显下降。我的建议是权重和激活值都用storageModeShared,让CPU和GPU都能直接访问,避免storageModePrivate带来的不可见拷贝。另外,Buffer的大小尽量按页对齐,苹果的分配器不够聪明时,你手动对齐能省下不少隐式内存事务。1.2 Transformer推理的瓶颈到底在哪Transformer推理和训练不一样,训练是计算密集型,推理在解码阶段是带宽密集型。每生成一个token,要跑一次完整的forward,模型所有权重都要从内存过一遍。对于1.1B参数的FP16模型,权重就有2.2GB,每次decode都必须读取这2.2GB,这是绝对的内存带宽瓶颈。自注意力部分还有额外的几何开销:Q和K做矩阵乘,计算复杂度是O(L²d),序列越长越夸张。再加上KV Cache的存在,老token的中间结果要反复读取,内存带宽进一步吃紧。所以,做GPU加速时不能只盯FLOPs,关键是要减少内存事务、减少算子启动次数、合并可以融合的计算。Metal GPU在苹果芯片上的带宽表现不错,配合统一内存直读权重,理论上限挺高。但“理论”和“实际”之间有一道大的鸿沟,这个鸿沟就是算子调度方式。你如果每个小算子都单独启动一个kernel,哪怕带宽再高也救不回来。2. 两条技术路线,我为什么最终选了MPSGraph苹果生态里能跑GPU推理的路线主要有三条:直接用Metal Performance Shaders(MPS)的单个算子、用MPSGraph构建计算图、或者从零手写Metal Shading Language(MSL)Kernel。我最终是“MPSGraph主体+手写Kernel局部融合”的混合方案,先说说为什么这么选。2.1 PyTorch MPS后端和手写Metal Kernel的各自代价先提一下PyTorch的MPS后端,很多人的第一反应就是它。torch.device("mps")确实能用,但限制不少:部分算子不支持MPS实现时会悄悄回退CPU,这一回退就会导致GPU等待CPU算完再拿数据,单次看不出来,循环生成token的时候每步都卡一下,吞吐很难看。而且PyTorch MPS里的算子粒度是固定死的,你没法把一个Attention内部的QK乘法和mask、softmax合并成一个融合算子。手写MSL Kernel灵活性最高,你能精确控制每个线程组做什么。但代价是开发量巨大:一个融合Attention Kernel要处理矩阵分块、simdgroup调度、缓存控制、边界条件,光调试就要耗掉好几周。对个人项目来说,全部手写性价比不高。MPSGraph正好卡在中间。它是一个偏上层的计算图框架,苹果官方维护,矩阵乘、softmax、残差连接这些高频算子都有高度优化的底层实现,同时你可以把整个forward定义成一张图,让框架去安排执行顺序。我实测下来,相同结构的FP16推理,MPSGraph比逐算子调用MPS快30%上下,因为框架能合并部分中间结果,减少了很多不必要的buffer分配和kernel启动。2.2 混合精度策略:FP16还是INT8精度选择是整个改造中最先要做决定的事。FP16在GPU上的计算吞吐是FP32的两倍,内存占用减半,对带宽敏感的解码阶段是巨大的优势。我在模拟项目X里首先把权重和激活全部切到FP16,内存流量直接降一半,吞吐从12.6涨到20.1,这是第一波肉眼可见的收益。但FP16有坑:注意力分数在计算QK^T之后数值范围变大,超过FP16的有限精度后,长序列场景下会出现注意力分布轻微失真。我在1024上下文长度下做过对比,FP16的困惑度比FP32高约0.03,在生成任务里基本无感,但如果你做的是需要精确概率分布的任务(比如某些评估任务),建议保留一层scale修正或者局部用FP32累加。INT8量化我后来也做了。权重按per-channel量化成INT8,激活保持FP16,混合精度推理的吞吐进一步提升到34+ tok/s。但不是所有层都适合INT8:我测下来,输出层的敏感度很高,量化后有明显质量下降;Attention里的Score计算也不适合直接压低精度。最终只在FFN层的权重上用了INT8,其余保持FP16。这里记得用实际生成的文本质量去判断,不要只看困惑度数值。2.3 计算图折叠:能少启动一次kernel就少一次GPU推理有个铁律:kernel启动开销比你想象的大得多。一次小矩阵乘的计算时间可能只有几微秒,但启动kernel本身可能就有几十微秒的固定成本。所以优化核心思路之一,是把多个相邻计算融合成一个kernel。MPSGraph里可以做的折叠包括:Dense层的权重乘法与残差连接合并、RMSNorm/LayerNorm折叠进前一个算子、激活函数与矩阵乘融合。我在实现里把FFN的GeLU激活直接融合进第二层矩阵乘的epilogue里,这个操作单独就减少了约15%的解码耗时。折叠的另一个好处是减少中间张量的写出和读入,每次内存访问都可能是几十GB/s的带宽消耗,省一次是一次。3. 从零到跑通的完整流程:计算图、调度和缓冲复用理论说到这里,现在把实际操作过程完整写一遍。我的模拟项目X是一个约1.1B参数的decoder-only模型,上下文长度1024,硬件环境是某台M3 Pro芯片的MacBook Pro,内存32G。下面每一步都是我在项目里实际执行过的,你可以照着改。3.1 构建MPSGraph计算图先把整个Transformer的forward用MPSGraph表达出来。核心思路是:能并行的层尽量并行,但不要为了并行而牺牲内存局部性。import Metal import MetalPerformanceShadersGraph let device = MTLCreateSystemDefaultDevice()! let graph = MPSGraph() // 输入:batch=1, sequence=1024, hidden=768 let input = graph.placeholder( shape: [1, 1024, 768], dataType: .float16, name: "input" ) // 注意力头数=12,head_dim=64 let qW = graph.placeholder(shape: [768, 768], dataType: .float16, name: "qW") let kW = graph.placeholder(shape: [768, 768], dataType: .float16, name: "kW") let vW = graph.placeholder(shape: [768, 768], dataType: .float16, name: "vW") let q = graph.dense(input, weight: qW, name: "qProj") let k = graph.dense(input, weight: kW, name: "kProj") let v = graph.dense(input, weight: vW, name: "vProj") // reshape to [batch, heads, seq, head_dim] let qHeads = graph.reshape(q, shape: [1, 12, 1024, 64], name: "qHeads") let kHeads = graph.reshape(k, shape: [1, 12, 1024, 64], name: "kHeads") let vHeads = graph.reshape(v, shape: [1, 12, 1024, 64], name: "vHeads") // score = q * k^T / sqrt(d) let kT = graph.transposeTensor(kHeads, permutation: [0, 1, 3, 2], name: "kT") let scale = graph.constant(1.0 / sqrt(64.0), dataType: .float16) var score = graph.matrixMultiplication( primary: qHeads, secondary: kT, name: "score" ) score = graph.multiplication(score, scale, name: "scoreScaled") // 下三角mask,把未来位置换成 -inf let mask = graph.placeholder(shape: [1, 1, 1024, 1024], dataType: .float16, name: "mask") score = graph.addition(score, mask, name: "maskedScore") let probs = graph.softMax(score, axis: 3, name: "probs") let ctx = graph.matrixMultiplication( primary: probs, secondary: vHeads, name: "context" ) // 再reshape回 [1, 1024, 768],接输出投影 let context = graph.reshape(ctx, shape: [1, 1024, 768], name: "context") let oW = graph.placeholder(shape: [768, 768], dataType: .float16, name: "oW") var output = graph.dense(context, weight: oW, name: "outProj") // ... 后续FFN层同理这段代码只是示意,真实项目里还有残差连接、RMSNorm、以及FFN层。MPSGraph的API在不同SDK版本里有细微差异,建议你写的时候手边打开官方文档对照。重点是理解它的张量流:placeholder接收输入和权重,中间算子自动调度,最终得到输出张量。3.2 命令排布、同步与“GPU在等CPU”陷阱计算图构建完了,真正决定性能的是执行方式。我第一次跑通时,每生成一个token就调用一次commandBuffer.waitUntilCompleted(),这相当于每decode一步都把GPU流水线完全排空,然后等CPU准备好下一轮的输入再重新灌满。GPU的利用率大概只有20%,性能比CPU好不了多少。正确的做法是控制“在途执行”的帧数。Metal官方推荐的做法是维护3个in-flight缓冲,让CPU提交第N+2帧的时候GPU还在跑第N帧,把延迟藏起来。我最后用DispatchSemaphore实现了这个机制:let semaphore = DispatchSemaphore(value: 3) func decodeStep() { semaphore.wait() guard let commandBuffer = commandQueue.makeCommandBuffer() else { return } // 用MPSGraph对输入张量编码到commandBuffer graph.encode( to: commandBuffer, inputs: [inputTensor, weightTensor], outputs: [outputTensor], operations: nil ) commandBuffer.addCompletedHandler { _ in semaphore.signal() } commandBuffer.commit() }关键点是不要每步都创建新的MPSGraph对象或重新编译计算图。图的结构在一次decode循环里完全不变,变的只是输入数据。你应该把图的形状固定下来,输入Tensor的data每次更新内容,而不是重新建图。这一点是MPSGraph性能和易用性的核心分水岭。3.3 实测结果与各项配置基线到这里,你应该能看到像样的性能数字了。我整理了一张表,把整个优化过程的中间产物放在一起,数据是在M3 Pro 32G机器上、上下文1024、批量生成64个token的实测结果:方案平均解码速度(tok/s)相对CPU基线备注PyTorch CPU FP324.91.0x纯CPU,无优化PyTorch MPS FP1612.62.6x算子粒度受限Core ML(CPU+ANE混合)14.32.9x工具链封装较多MPSGraph FP1620.14.1x图融合生效MPSGraph FP16 + 融合Attention26.85.5x手写Kernel替代MPS算子再叠加KVCache预分配与缓冲复用31.56.4x同步开销压到最低混合精度(FP16+INT8权重)34.77.1x其他层敏感的用FP16这张表最值得关注的不只是最终值,而是每一步的增量来源。从12.6到20.1,靠的是计算图融合;从20.1到26.8,靠的是把Attention里的矩阵乘和mask/softmax融合成自定义Kernel;从26.8到31.5,靠的是把同步和缓冲逻辑理顺。每一步都是独立的优化点,你可以根据自己的情况挑选哪一步先做。4. 实测中踩过的坑,以及完整的排查思路如果不把踩坑过程写出来,这篇文章的价值要少一半。下面两个坑是我花时间最多的,排查过程也比故事本身值得复现。4.1 现象:前两轮极快,第三轮突然掉一半我完成第一版融合Kernel后跑了一下性能测试,前两个token的生成速度还能到28 tok/s,第三个token开始突然掉到14 tok/s左右,再往后一直稳定在低水平。当时我第一反应是自己写的Attention Kernel有线程组负载不均衡的问题。因为前两个token可能还在预热,第三轮开始所有block都在抢某个共享资源。但无论我怎么调整线程组尺寸,现象都没有消失。然后我开始怀疑是MPSGraph在encode的时候有隐藏的状态缓存,跑了几轮后缓存满了就触发重新分配。于是我在循环里把operations参数打印出来,发现每一轮的计算数相同,图结构也没变,排除了图重建的可能。最后我用Instruments里的Metal System Trace跑了一遍时间线,才看清楚问题:GPU每帧的工作负载本身没变化,但CPU提交下一帧的时间点越来越晚。前两帧因为系统缓冲区充裕,CPU可以提前提交;到第三帧开始,旧的commandBuffer还没有被GPU消费完,CPU端的信号量计数归零,decodeStep被block住,GPU完成一帧后要空闲等待CPU准备下一帧。这是典型的流水线没排满导致的“假性能回退”。4.2 从Metal System Trace到缓冲生命周期Metal System Trace里能看到完整的“CPU提交—GPU执行—CPU回收”时间线。我当时的截图里,GPU执行时间只有4ms,但CPU提交间隔有8ms,中间4ms全在等信号量。这就是问题核心:in-flight数量不足,或者提交线程被同步原语卡住了。解决方法是两个:第一,把信号量的初值从3提高到6。表面上只是数字变化,实际上给了CPU更多的提前量,能掩盖偶发的调度抖动。但6也不是越大越好,缓冲数量太多会占用内存。第二,检查commandBuffer里绑定Buffer的内存生命周期。我之前在循环里直接复用同一个MTLBuffer,没有考虑GPU可能还没读完,下一轮的数据就把内容覆盖了。这属于数据竞争,表现就是偶尔出NaN或者结果错乱。修复方案是分配一个能容纳3-6帧数据的循环缓冲池,每一帧用不同的偏移写入,确保GPU和CPU不会读写同一块区域。let ringBufferCount = 6 var currentFrame = 0 let bufferSize = hiddenSize * memoryLayout<Float16>.size * ringBufferCount let sharedBuffer = device.makeBuffer(length: bufferSize, options: .storageModeShared)! let stride = hiddenSize * memoryLayout<Float16>.size func getBufferForThisFrame() -> MTLBuffer { let offset = (currentFrame % ringBufferCount) * stride currentFrame += 1 return sharedBuffer }这段代码是简化版,真实项目中每个张量都要单独维护。核心思路是:用“帧索引模缓冲数量”来管理生命周期,而不是每帧临时分配新Buffer。4.3 手写MSL Kernel时被忽略的simdgroup并行度另一个大坑是手写Attention融合Kernel时的线程组织问题。苹果芯片GPU的并行单元是SIMD group,每个SIMD group里有一组线程并行执行。你在MSL里写的线程组(threadgroup)真正执行时会被拆成多个simdgroup。我一开始把threadgroup设成[1, 32]这种形状,每个线程处理一整行的score计算,结果每个simdgroup只覆盖了行方向的一小块,活跃度很低,带宽根本跑不满。后来参考了Apple官方矩阵乘法示例,改成用simdgroup_multiply_accumulate做8x8的矩阵分块。每个simdgroup负责一个8x8的矩阵块,多个simdgroup协作覆盖整块score矩阵。改造后的写入代码是:kernel void fused_attn_score( device const half* q, device const half* k, device const float scale, constant uint& seqLen, device half* score, uint simd_group_id [[simdgroup_index_in_threadgroup]], uint simd_local_id [[thread_index_in_simdgroup]]) { // 简化示意:每个simdgroup处理一个8x8分块 // 实际实现需要用 simdgroup_multiply_accumulate 做累加 uint blockRow = simd_group_id * 8; uint blockCol = 0; // 按需计算 half acc[8][8] = {}; // 遍历K矩阵的分块,与Q分块做矩阵乘 // acc = q_block * transposed(k_block) * scale // 写回score }这段代码刻意省略了细节,因为完整实现有一百多行。重点是理解这个模式:不要让每个线程独立处理一整行,而是用simdgroup协同做矩阵分块计算。这个改动让我的Attention Kernel从14 tok/s提到了23 tok/s,翻了一倍。线程组尺寸也要跟着调。我最后的配置是threadsPerThreadgroup = [64, 2, 1],覆盖4个8x8块,多个threadgroup分块遍历整个1024x1024的score矩阵。这里的数字要根据你的GPU核心数实测,不同芯片差异很大。5. 再进一步:多序列并行与把调度交给框架单序列的decode吞吐做到31.5 tok/s之后,我又做了一轮多序列并行优化。很多人的使用场景不只是单个对话,而是批量处理一批请求。GPU这个设备非常适合这种并行,但前提是你得正确组织数据。5.1 一次处理多个序列的吞吐差异我在模拟项目X里把batch从1调到4,发现整体吞吐不是线性增长,而是接近线性——4个序列同时解码时的总吞吐大约是单序列的3.4倍,平均到每个序列是0.85倍单序列速度。这说明批量解码能更好地利用GPU的并行能力,让内存带宽的单位成本分摊到更多token上。这里有个细节:不同序列的上下文长度可能不一样,需要padding到相同长度。padding本身浪费计算,但换来的是可以一把矩阵乘处理所有序列。在解码阶段,padding到8的倍数长度往往比自动对齐更划算,因为Metal的矩阵乘对对齐敏感。5.2 不是所有模型都适合盲目上GPU Metal我必须说一句泼冷水的话:不是所有Transformer都值得这么折腾。模型小于100M参数,或者上下文短于128,CPU推理已经很快,GPU的kernel启动开销反而占大头,你优化半天可能还倒退了。另外,有些算子在MPSGraph里没有对应实现,硬要用自定义Kernel重写,开发成本和维护成本都很高。我的原则是:能用官方算子解决的绝不自研,自研限于Attention这种确实卡脖子的部分。要保留一条纯CPU的fallback路径。GPU路径负责批量和高吞吐,CPU路径负责小批量和调试。两套路径共用同一套模型权重和超参数,切换只是一个开关的事。这个习惯在踩坑的时候救了我很多次,因为GPU路径出错时CPU路径可以提供参照输出,帮你定位问题出在算法逻辑还是设备特性。6. 这轮改造之后的一些个人体会整个改造做完后,我对苹果芯片GPU加速有了更实际的认知。统一内存确实是好东西,但它的优势需要你主动配合:数据要共享存储、buffer要循环复用、图要提前构建好。Metal不是魔法,它是一个更底层的工具箱,给你的不是现成的加速,而是让你能按GPU的真实工作方式去组织计算。如果你正在考虑在Mac上做Transformer推理优化,我建议先不要急着上手写Kernel。先用MPSGraph把你现有的模型完整跑通,测量每个算子的耗时占比,然后用Metal System Trace看GPU利用率。等到你确认瓶颈确实在Attention计算或者某些融合点上,再动手写自定义Kernel。这条路线让我少走了很多弯路。
把Transformer模型的推理链路整体搬到苹果芯片的GPU上,用Metal从头组织计算图和算子,这件事我从去年底开始做,前后改了四版。最终效果是解码吞吐从CPU路线的个位数涨到了30+ tokens/s,同一个约1.1B参数的decoder-only模型,在M3 Pro的32G内存机器上,纯CPU推理只有4.9 tok/s,Metal整条链路优化后达到31.5 tok/s。这个提升不是简单把设备切到GPU就有,而是把张量布局、算子融合、缓冲复用、同步策略全都按Metal的脾气重新排了一遍。如果你正在做Mac上的本地模型推理、离线批量处理,或者准备把模型部署到Apple平台,这篇文章会把我在MPSGraph、手写Metal Kernel、混合精度、KV Cache这几条线里的选择和踩坑过程完整讲一遍,争取让你少折腾几个礼拜。1. 做这件事之前,先搞清楚Metal凭什么能快很多人拿到Mac的第一反应就是“它有GPU,那我直接把TensorFlow或者PyTorch的device切到mps不就完了”。我能理解这种冲动,但这么做通常只能拿到个位数的加速比,有时候甚至更慢。问题不在GPU,在于你没有搞清楚Metal的加速模型和传统独立显卡完全不同。1.1 统一内存架构带来的先行优势苹果芯片用的是统一内存架构(UMA),CPU和GPU共享同一块物理内存,数据不需要从系统内存拷贝到显存。传统PC上推理框架要把权重从内存拷到显存,算完再拷回来,这一步的带宽开销非常大。而你在Mac上写Metal,所有权重直接以普通Buffer的形式放在系统内存里,GPU通过地址直接访问,零拷贝。这一点是苹果芯片GPU推理的第一优势,也是后续所有优化的地基。但这里有个隐藏的门槛:Metal对Buffer的对齐和存储模式有要求。普通的Swift数组往MTLBuffer里塞,如果存储模式不对,GPU读取效率会明显下降。我的建议是权重和激活值都用storageModeShared,让CPU和GPU都能直接访问,避免storageModePrivate带来的不可见拷贝。另外,Buffer的大小尽量按页对齐,苹果的分配器不够聪明时,你手动对齐能省下不少隐式内存事务。1.2 Transformer推理的瓶颈到底在哪Transformer推理和训练不一样,训练是计算密集型,推理在解码阶段是带宽密集型。每生成一个token,要跑一次完整的forward,模型所有权重都要从内存过一遍。对于1.1B参数的FP16模型,权重就有2.2GB,每次decode都必须读取这2.2GB,这是绝对的内存带宽瓶颈。自注意力部分还有额外的几何开销:Q和K做矩阵乘,计算复杂度是O(L²d),序列越长越夸张。再加上KV Cache的存在,老token的中间结果要反复读取,内存带宽进一步吃紧。所以,做GPU加速时不能只盯FLOPs,关键是要减少内存事务、减少算子启动次数、合并可以融合的计算。Metal GPU在苹果芯片上的带宽表现不错,配合统一内存直读权重,理论上限挺高。但“理论”和“实际”之间有一道大的鸿沟,这个鸿沟就是算子调度方式。你如果每个小算子都单独启动一个kernel,哪怕带宽再高也救不回来。2. 两条技术路线,我为什么最终选了MPSGraph苹果生态里能跑GPU推理的路线主要有三条:直接用Metal Performance Shaders(MPS)的单个算子、用MPSGraph构建计算图、或者从零手写Metal Shading Language(MSL)Kernel。我最终是“MPSGraph主体+手写Kernel局部融合”的混合方案,先说说为什么这么选。2.1 PyTorch MPS后端和手写Metal Kernel的各自代价先提一下PyTorch的MPS后端,很多人的第一反应就是它。torch.device("mps")确实能用,但限制不少:部分算子不支持MPS实现时会悄悄回退CPU,这一回退就会导致GPU等待CPU算完再拿数据,单次看不出来,循环生成token的时候每步都卡一下,吞吐很难看。而且PyTorch MPS里的算子粒度是固定死的,你没法把一个Attention内部的QK乘法和mask、softmax合并成一个融合算子。手写MSL Kernel灵活性最高,你能精确控制每个线程组做什么。但代价是开发量巨大:一个融合Attention Kernel要处理矩阵分块、simdgroup调度、缓存控制、边界条件,光调试就要耗掉好几周。对个人项目来说,全部手写性价比不高。MPSGraph正好卡在中间。它是一个偏上层的计算图框架,苹果官方维护,矩阵乘、softmax、残差连接这些高频算子都有高度优化的底层实现,同时你可以把整个forward定义成一张图,让框架去安排执行顺序。我实测下来,相同结构的FP16推理,MPSGraph比逐算子调用MPS快30%上下,因为框架能合并部分中间结果,减少了很多不必要的buffer分配和kernel启动。2.2 混合精度策略:FP16还是INT8精度选择是整个改造中最先要做决定的事。FP16在GPU上的计算吞吐是FP32的两倍,内存占用减半,对带宽敏感的解码阶段是巨大的优势。我在模拟项目X里首先把权重和激活全部切到FP16,内存流量直接降一半,吞吐从12.6涨到20.1,这是第一波肉眼可见的收益。但FP16有坑:注意力分数在计算QK^T之后数值范围变大,超过FP16的有限精度后,长序列场景下会出现注意力分布轻微失真。我在1024上下文长度下做过对比,FP16的困惑度比FP32高约0.03,在生成任务里基本无感,但如果你做的是需要精确概率分布的任务(比如某些评估任务),建议保留一层scale修正或者局部用FP32累加。INT8量化我后来也做了。权重按per-channel量化成INT8,激活保持FP16,混合精度推理的吞吐进一步提升到34+ tok/s。但不是所有层都适合INT8:我测下来,输出层的敏感度很高,量化后有明显质量下降;Attention里的Score计算也不适合直接压低精度。最终只在FFN层的权重上用了INT8,其余保持FP16。这里记得用实际生成的文本质量去判断,不要只看困惑度数值。2.3 计算图折叠:能少启动一次kernel就少一次GPU推理有个铁律:kernel启动开销比你想象的大得多。一次小矩阵乘的计算时间可能只有几微秒,但启动kernel本身可能就有几十微秒的固定成本。所以优化核心思路之一,是把多个相邻计算融合成一个kernel。MPSGraph里可以做的折叠包括:Dense层的权重乘法与残差连接合并、RMSNorm/LayerNorm折叠进前一个算子、激活函数与矩阵乘融合。我在实现里把FFN的GeLU激活直接融合进第二层矩阵乘的epilogue里,这个操作单独就减少了约15%的解码耗时。折叠的另一个好处是减少中间张量的写出和读入,每次内存访问都可能是几十GB/s的带宽消耗,省一次是一次。3. 从零到跑通的完整流程:计算图、调度和缓冲复用理论说到这里,现在把实际操作过程完整写一遍。我的模拟项目X是一个约1.1B参数的decoder-only模型,上下文长度1024,硬件环境是某台M3 Pro芯片的MacBook Pro,内存32G。下面每一步都是我在项目里实际执行过的,你可以照着改。3.1 构建MPSGraph计算图先把整个Transformer的forward用MPSGraph表达出来。核心思路是:能并行的层尽量并行,但不要为了并行而牺牲内存局部性。import Metal import MetalPerformanceShadersGraph let device = MTLCreateSystemDefaultDevice()! let graph = MPSGraph() // 输入:batch=1, sequence=1024, hidden=768 let input = graph.placeholder( shape: [1, 1024, 768], dataType: .float16, name: "input" ) // 注意力头数=12,head_dim=64 let qW = graph.placeholder(shape: [768, 768], dataType: .float16, name: "qW") let kW = graph.placeholder(shape: [768, 768], dataType: .float16, name: "kW") let vW = graph.placeholder(shape: [768, 768], dataType: .float16, name: "vW") let q = graph.dense(input, weight: qW, name: "qProj") let k = graph.dense(input, weight: kW, name: "kProj") let v = graph.dense(input, weight: vW, name: "vProj") // reshape to [batch, heads, seq, head_dim] let qHeads = graph.reshape(q, shape: [1, 12, 1024, 64], name: "qHeads") let kHeads = graph.reshape(k, shape: [1, 12, 1024, 64], name: "kHeads") let vHeads = graph.reshape(v, shape: [1, 12, 1024, 64], name: "vHeads") // score = q * k^T / sqrt(d) let kT = graph.transposeTensor(kHeads, permutation: [0, 1, 3, 2], name: "kT") let scale = graph.constant(1.0 / sqrt(64.0), dataType: .float16) var score = graph.matrixMultiplication( primary: qHeads, secondary: kT, name: "score" ) score = graph.multiplication(score, scale, name: "scoreScaled") // 下三角mask,把未来位置换成 -inf let mask = graph.placeholder(shape: [1, 1, 1024, 1024], dataType: .float16, name: "mask") score = graph.addition(score, mask, name: "maskedScore") let probs = graph.softMax(score, axis: 3, name: "probs") let ctx = graph.matrixMultiplication( primary: probs, secondary: vHeads, name: "context" ) // 再reshape回 [1, 1024, 768],接输出投影 let context = graph.reshape(ctx, shape: [1, 1024, 768], name: "context") let oW = graph.placeholder(shape: [768, 768], dataType: .float16, name: "oW") var output = graph.dense(context, weight: oW, name: "outProj") // ... 后续FFN层同理这段代码只是示意,真实项目里还有残差连接、RMSNorm、以及FFN层。MPSGraph的API在不同SDK版本里有细微差异,建议你写的时候手边打开官方文档对照。重点是理解它的张量流:placeholder接收输入和权重,中间算子自动调度,最终得到输出张量。3.2 命令排布、同步与“GPU在等CPU”陷阱计算图构建完了,真正决定性能的是执行方式。我第一次跑通时,每生成一个token就调用一次commandBuffer.waitUntilCompleted(),这相当于每decode一步都把GPU流水线完全排空,然后等CPU准备好下一轮的输入再重新灌满。GPU的利用率大概只有20%,性能比CPU好不了多少。正确的做法是控制“在途执行”的帧数。Metal官方推荐的做法是维护3个in-flight缓冲,让CPU提交第N+2帧的时候GPU还在跑第N帧,把延迟藏起来。我最后用DispatchSemaphore实现了这个机制:let semaphore = DispatchSemaphore(value: 3) func decodeStep() { semaphore.wait() guard let commandBuffer = commandQueue.makeCommandBuffer() else { return } // 用MPSGraph对输入张量编码到commandBuffer graph.encode( to: commandBuffer, inputs: [inputTensor, weightTensor], outputs: [outputTensor], operations: nil ) commandBuffer.addCompletedHandler { _ in semaphore.signal() } commandBuffer.commit() }关键点是不要每步都创建新的MPSGraph对象或重新编译计算图。图的结构在一次decode循环里完全不变,变的只是输入数据。你应该把图的形状固定下来,输入Tensor的data每次更新内容,而不是重新建图。这一点是MPSGraph性能和易用性的核心分水岭。3.3 实测结果与各项配置基线到这里,你应该能看到像样的性能数字了。我整理了一张表,把整个优化过程的中间产物放在一起,数据是在M3 Pro 32G机器上、上下文1024、批量生成64个token的实测结果:方案平均解码速度(tok/s)相对CPU基线备注PyTorch CPU FP324.91.0x纯CPU,无优化PyTorch MPS FP1612.62.6x算子粒度受限Core ML(CPU+ANE混合)14.32.9x工具链封装较多MPSGraph FP1620.14.1x图融合生效MPSGraph FP16 + 融合Attention26.85.5x手写Kernel替代MPS算子再叠加KVCache预分配与缓冲复用31.56.4x同步开销压到最低混合精度(FP16+INT8权重)34.77.1x其他层敏感的用FP16这张表最值得关注的不只是最终值,而是每一步的增量来源。从12.6到20.1,靠的是计算图融合;从20.1到26.8,靠的是把Attention里的矩阵乘和mask/softmax融合成自定义Kernel;从26.8到31.5,靠的是把同步和缓冲逻辑理顺。每一步都是独立的优化点,你可以根据自己的情况挑选哪一步先做。4. 实测中踩过的坑,以及完整的排查思路如果不把踩坑过程写出来,这篇文章的价值要少一半。下面两个坑是我花时间最多的,排查过程也比故事本身值得复现。4.1 现象:前两轮极快,第三轮突然掉一半我完成第一版融合Kernel后跑了一下性能测试,前两个token的生成速度还能到28 tok/s,第三个token开始突然掉到14 tok/s左右,再往后一直稳定在低水平。当时我第一反应是自己写的Attention Kernel有线程组负载不均衡的问题。因为前两个token可能还在预热,第三轮开始所有block都在抢某个共享资源。但无论我怎么调整线程组尺寸,现象都没有消失。然后我开始怀疑是MPSGraph在encode的时候有隐藏的状态缓存,跑了几轮后缓存满了就触发重新分配。于是我在循环里把operations参数打印出来,发现每一轮的计算数相同,图结构也没变,排除了图重建的可能。最后我用Instruments里的Metal System Trace跑了一遍时间线,才看清楚问题:GPU每帧的工作负载本身没变化,但CPU提交下一帧的时间点越来越晚。前两帧因为系统缓冲区充裕,CPU可以提前提交;到第三帧开始,旧的commandBuffer还没有被GPU消费完,CPU端的信号量计数归零,decodeStep被block住,GPU完成一帧后要空闲等待CPU准备下一帧。这是典型的流水线没排满导致的“假性能回退”。4.2 从Metal System Trace到缓冲生命周期Metal System Trace里能看到完整的“CPU提交—GPU执行—CPU回收”时间线。我当时的截图里,GPU执行时间只有4ms,但CPU提交间隔有8ms,中间4ms全在等信号量。这就是问题核心:in-flight数量不足,或者提交线程被同步原语卡住了。解决方法是两个:第一,把信号量的初值从3提高到6。表面上只是数字变化,实际上给了CPU更多的提前量,能掩盖偶发的调度抖动。但6也不是越大越好,缓冲数量太多会占用内存。第二,检查commandBuffer里绑定Buffer的内存生命周期。我之前在循环里直接复用同一个MTLBuffer,没有考虑GPU可能还没读完,下一轮的数据就把内容覆盖了。这属于数据竞争,表现就是偶尔出NaN或者结果错乱。修复方案是分配一个能容纳3-6帧数据的循环缓冲池,每一帧用不同的偏移写入,确保GPU和CPU不会读写同一块区域。let ringBufferCount = 6 var currentFrame = 0 let bufferSize = hiddenSize * memoryLayout<Float16>.size * ringBufferCount let sharedBuffer = device.makeBuffer(length: bufferSize, options: .storageModeShared)! let stride = hiddenSize * memoryLayout<Float16>.size func getBufferForThisFrame() -> MTLBuffer { let offset = (currentFrame % ringBufferCount) * stride currentFrame += 1 return sharedBuffer }这段代码是简化版,真实项目中每个张量都要单独维护。核心思路是:用“帧索引模缓冲数量”来管理生命周期,而不是每帧临时分配新Buffer。4.3 手写MSL Kernel时被忽略的simdgroup并行度另一个大坑是手写Attention融合Kernel时的线程组织问题。苹果芯片GPU的并行单元是SIMD group,每个SIMD group里有一组线程并行执行。你在MSL里写的线程组(threadgroup)真正执行时会被拆成多个simdgroup。我一开始把threadgroup设成[1, 32]这种形状,每个线程处理一整行的score计算,结果每个simdgroup只覆盖了行方向的一小块,活跃度很低,带宽根本跑不满。后来参考了Apple官方矩阵乘法示例,改成用simdgroup_multiply_accumulate做8x8的矩阵分块。每个simdgroup负责一个8x8的矩阵块,多个simdgroup协作覆盖整块score矩阵。改造后的写入代码是:kernel void fused_attn_score( device const half* q, device const half* k, device const float scale, constant uint& seqLen, device half* score, uint simd_group_id [[simdgroup_index_in_threadgroup]], uint simd_local_id [[thread_index_in_simdgroup]]) { // 简化示意:每个simdgroup处理一个8x8分块 // 实际实现需要用 simdgroup_multiply_accumulate 做累加 uint blockRow = simd_group_id * 8; uint blockCol = 0; // 按需计算 half acc[8][8] = {}; // 遍历K矩阵的分块,与Q分块做矩阵乘 // acc = q_block * transposed(k_block) * scale // 写回score }这段代码刻意省略了细节,因为完整实现有一百多行。重点是理解这个模式:不要让每个线程独立处理一整行,而是用simdgroup协同做矩阵分块计算。这个改动让我的Attention Kernel从14 tok/s提到了23 tok/s,翻了一倍。线程组尺寸也要跟着调。我最后的配置是threadsPerThreadgroup = [64, 2, 1],覆盖4个8x8块,多个threadgroup分块遍历整个1024x1024的score矩阵。这里的数字要根据你的GPU核心数实测,不同芯片差异很大。5. 再进一步:多序列并行与把调度交给框架单序列的decode吞吐做到31.5 tok/s之后,我又做了一轮多序列并行优化。很多人的使用场景不只是单个对话,而是批量处理一批请求。GPU这个设备非常适合这种并行,但前提是你得正确组织数据。5.1 一次处理多个序列的吞吐差异我在模拟项目X里把batch从1调到4,发现整体吞吐不是线性增长,而是接近线性——4个序列同时解码时的总吞吐大约是单序列的3.4倍,平均到每个序列是0.85倍单序列速度。这说明批量解码能更好地利用GPU的并行能力,让内存带宽的单位成本分摊到更多token上。这里有个细节:不同序列的上下文长度可能不一样,需要padding到相同长度。padding本身浪费计算,但换来的是可以一把矩阵乘处理所有序列。在解码阶段,padding到8的倍数长度往往比自动对齐更划算,因为Metal的矩阵乘对对齐敏感。5.2 不是所有模型都适合盲目上GPU Metal我必须说一句泼冷水的话:不是所有Transformer都值得这么折腾。模型小于100M参数,或者上下文短于128,CPU推理已经很快,GPU的kernel启动开销反而占大头,你优化半天可能还倒退了。另外,有些算子在MPSGraph里没有对应实现,硬要用自定义Kernel重写,开发成本和维护成本都很高。我的原则是:能用官方算子解决的绝不自研,自研限于Attention这种确实卡脖子的部分。要保留一条纯CPU的fallback路径。GPU路径负责批量和高吞吐,CPU路径负责小批量和调试。两套路径共用同一套模型权重和超参数,切换只是一个开关的事。这个习惯在踩坑的时候救了我很多次,因为GPU路径出错时CPU路径可以提供参照输出,帮你定位问题出在算法逻辑还是设备特性。6. 这轮改造之后的一些个人体会整个改造做完后,我对苹果芯片GPU加速有了更实际的认知。统一内存确实是好东西,但它的优势需要你主动配合:数据要共享存储、buffer要循环复用、图要提前构建好。Metal不是魔法,它是一个更底层的工具箱,给你的不是现成的加速,而是让你能按GPU的真实工作方式去组织计算。如果你正在考虑在Mac上做Transformer推理优化,我建议先不要急着上手写Kernel。先用MPSGraph把你现有的模型完整跑通,测量每个算子的耗时占比,然后用Metal System Trace看GPU利用率。等到你确认瓶颈确实在Attention计算或者某些融合点上,再动手写自定义Kernel。这条路线让我少走了很多弯路。