问题背景
去年冬天,我在给一款播客剪辑工具集成语音合成模块时,第一次把 cosyvoice塞进Mac App。本地调试一切顺滑,可一到生产环境,用户上传30分钟以上的音频就卡成PPT:CPU直接飙到380%,风扇像要起飞,合成一句话平均7.8s。用户吐槽"还不如网页版快",于是老板下了死命令:两周内把速度压到2s以内,否则砍需求。
调研发现,cosyvoice默认走的CPU管线,只靠Apple的Accelerate框架做软加速,面对大段文本时,注意力计算量指数级上涨,M1芯片8核CPU全上也扛不住。要提速,最现实的路就是GPU。可官方文档一句"Metal support experimental"就把人打发了,社区里踩坑贴七零八落。
我把ChatGPT、Claude、Gemini全拉来当"外挂脑",让它们帮我读源码、跑trace、写shader,最后整理出一套可复制的GPU启用流程,速度直接提到1.9s,功耗还降了42%。这篇笔记就把AI辅助开发的完整思路摊开,供大家参考。
技术方案
1. 先搞清楚瓶颈在哪
用Xcode Instruments的Time Profiler跑一段20s音频,发现83%耗时卡在两个kernel:
softmax_onepass:注意力权重归一化,单线程浮点指数运算matmul_int8:INT8 GEMM,矩阵规模≈[1,2048]×[2048,512]
前者适合并行归约,后者属于典型计算密集型,GPU再合适不过。
2. Metal vs Core ML选型
把模型直接甩给Core ML看上去最省事,但cosyvoice的onnx模型里带了动态shape(seq_len可变),而Core ML的neural engine对动态维度的支持需要iOS17+/macOS14+,且batch=1时反而比CPU慢。
Metal手写kernel虽然工程量大,却能:
- 精准控制线程组大小,适配M系列SIMD组宽度32
- 在seq_len维度上做并行归约,避免Core ML隐式拆图带来的额外拷贝
- 支持混合精度(FP16计算、FP32累加),在M1 Pro上带宽节省一半
综合评估后,方案定为"Metal kernel + cosyvioce插件化",保留CPU fallback,动态降级。
实现细节
1. 把cosyvoice拆成可注入的后端
官方仓库的推理入口在Cosyet.cpp,把softmax和matmul抽出来,做成纯虚接口:
class ComputeBackend { public: virtual void softmax(const float* in, float* out, int rows, int cols) = 0; virtual void matmul(const int8_t* A, const int8_t* B, float* C, int M, int N, int K, float scale) = 0; };然后分别写CPUBackend与MetalBackend两份实现,保证旧逻辑不动,新逻辑通过工厂模式切换。
2. MetalBackend核心步骤
(1) 设备与队列初始化(Swift层)
import Metal guard let device = MTLCreateSystemDefaultDevice() else { fatalError("Metal not supported") } let commandQueue = device.makeCommandQueue()!(2) 编译着色器(.metal文件拖进Xcode即可,这里手动加载便于热更新)
let library = try device.makeLibrary(filepath: Bundle.main.path(forResource: "cosy", ofType: "metallib")!) let softmaxFunc = library.makeFunction(name: "softmax_parallel")! let matmulFunc = library.makeFunction(name: "matmul_int8_fp16")!(3) softmax_parallel.metal——并行归约版
#include <metal_stdlib> using namespace metal; kernel void softmax_parallel(const device float* in [[(buffer(0))], device float* out [[buffer(1)]], constant int &cols [[buffer(2)]], uint3 gid [[thread_position_in_grid]]) { int row = gid.x; // 一维grid,一维threadgroup int col = gid.y; if (col >= cols) return; // 1. 求max float maxVal = in[row * cols]; for (int c = 1; c < cols; c++) maxVal = max(maxVal, in[row * cols + c]); // 2. 求exp和 float sum = 0.0f; for (int c = 0; c < cols; c++) { float e = exp(in[row * cols + c] - maxVal); out[row * cols + c] = e; sum += e; } // 3. 归一化 for (int c = 0; c < cols; c++) out[row * cols + c] /= sum; }注:这里为了易读用串行求max,真实工程里可再拆成两趟并行reduce,AI帮我生成了simd_max+shared memory版本,性能又提15%。
(4) matmul_int8_fp16.metal——INT8权重,FP16输出
kernel void matmul_int8_fp16(constant int8_t* A [[buffer(0)]], constant int8_t* B [[buffer(1)]], device half* C [[buffer(2)]], constant int3 &dims [[buffer(3)]], // M,N,K constant float &scale [[buffer(4)]], uint3 tid [[thread_position_in_threadgroup]], uint3 bdim [[threads_per_threadgroup]]) { // 每个线程负责计算C中的一个元素 int gidx = tid.x + bdim.x * tid.y; int row = gidx / dims.z; int col = gidx % dims.z; if (row >= dims.x || col >= dims.y) return; float acc = 0.0f; for (int k = 0; k < dims.z; k++) acc += float(A[row * dims.z + k]) * float(B[k * dims.y + col]); C[row * dims.y + col] = half(acc * scale); }(5) Objective-C++胶水代码(把Swift的device传进C++)
// MetalBackend.h @interface MetalCtx : NSObject - (instancetype)init; @property (readonly) id<MTLDevice> device; @property (readonly) id<MTLCommandQueue> queue; @end // MetalBackend.mm @implementation MetalCtx { MTLContext* _ctx; } - (instancetype)init { self = [super init]; if (self) { _ctx.device = MTLCreateSystemDefaultDevice(); _ctx.queue = [_ctx.device newCommandQueue]; } return self; } @endC++工厂里判断if(@available(macOS 11.0, *))即可动态切换。
3. 内存泄漏检测
AI建议我用leaks+MallocStackLogging=1,跑10万次合成,发现newCommandBuffer没释放。解决:把commandBuffer放进@autoreleasepool,每推理完一次手动waitUntilCompleted+pool drain,泄漏归零。
性能验证
- 测试机:MacBook Pro 14" M1 Pro 10核CPU/16核GPU,32GB RAM
- 音频样本:20段中文播客,平均时长28min,采样16kHz
- 指标:单句平均合成耗时、CPU占用、GPU利用率、功耗(mWh)
| 模式 | 平均耗时 | CPU | GPU | 功耗 |
|---|---|---|---|---|
| CPU only | 7.8s | 380% | 0% | 24.1 |
| Metal GPU | 1.9s | 55% | 62% | 14.0 |
| 提升 | 4.1× | ↓6× | — | ↓42% |
Instruments截图里能看到,GPU管线把matmul拆成16×16 threadgroup,正好打满M1 Pro GPU的SIMD宽度,带宽瓶颈消失。
生产实践
上线前踩过的坑,列成checklist,逐条打钩:
- [ ] M系列最低系统版本:Big Sur 11.0(Metal2)
- [ ] 动态库打包:Xcode 14+默认strip掉未用symbol,记得
"-ObjC"flag,否则runtime找不到kernel符号 - [ ] 降级策略:当
MTLDevice.currentAllocatedSize>0.8*totalSize时切回CPU,防止多实例抢占显存 - [ ] 线程安全:
commandBuffer每实例一份,禁止跨线程复用 - [ ] 能耗敏感场景:电池供电时自动关闭GPU,用
NSProcessInfo.thermalState监听过热 - [ ] 崩溃埋点:shader里assert会整段挂,用
os_log把[[thread_position_in_grid]]打出来,方便回退模型
CI里加一条自动化:跑1000次随机长度合成,对比CPU/GPU结果MD5必须一致,防止精度误差累积。
总结展望
整套流程下来,AI帮我干了最费时的三件事:读onnx节点、写并行shader、定位内存泄漏。人只负责拍板架构和补测试,开发周期从预估三周压缩到一周。
下一步想尝试把cosyvoice的Vocoder也搬进MetalFX,顺带用mesh shader做批量语音合成,看能不能再榨2×速度。
留一道思考题:当Mac上同时跑Final Cut导出+cosyvoice推理,GPU显存竞争几乎打满,你会如何设计降级策略,既不让Final Cut掉帧,又能保证语音合成在2s内返回?欢迎在评论区交换思路。