用 Swift 训练大语言模型,第一部分:将矩阵乘法从 Gflop/s 提升到 Tflop/s
摘要
作者详细介绍了在 Apple Silicon 上优化 Swift 自定义矩阵乘法内核以训练大语言模型的过程,旨在通过利用 CPU、SIMD、AMX 和 GPU 能力,实现超越 C 实现的性能。
暂无内容
查看缓存全文
缓存时间: 2026/05/11 15:51
# 在 Swift 中训练 LLM,第 1 部分:将矩阵乘法从 Gflop/s 提升到 Tflop/s | Cocoa with Love
来源:https://www.cocoawithlove.com/blog/matrix-multiplications-swift.html
在本文中,我将尝试让手写的矩阵乘法代码在 Swift 中以最快的速度运行,以便在 Apple Silicon 上训练一个大语言模型(LLM)。目的是为了深入了解优化数学代码的关键步骤。我也希望这些示例能让读者对 Apple Silicon 上不同单元(CPU、SIMD、AMX 和 GPU)的性能有一个直观的感受。
这是关于在 Apple Silicon 上用 Swift 训练神经网络系列的第一篇。后续文章会介绍苹果为 Mac 提供的机器学习框架——那些框架*可能有点多*。对于矩阵乘法和机器学习,你真正应该使用的是那些成熟的框架(它们在内核优化上花的时间比我多得多)。但在那之前,我正乐在其中地用“无框架、无库”的纯代码方式自己实现一切。我不只是写矩阵乘法内核,示例应用还会把这些内核用于一个完整的 LLM 实现,而我所引用的数字也将来自完整的正向和反向训练迭代。
本系列的参考实现是 Andrej Karpathy 的 [llm.c](https://github.com/karpathy/llm.c)(一个兼容 GPT2 模型的纯 C 实现)。这虽然是一个相当基础的模型,但它包含了所有必要的组件,并且能够代表真实的工作负载。这意味着,是时候来玩我最喜欢的游戏了:把 Swift 优化到比 C 还快。
## 目录
- [背景故事](#backstory)
- [llm.c](#llmc)
- [基本 Swift](#basic-swift)
- [Span、Egg 和 Span](#span-egg-and-span)
- [放松氛围](#chill-vibes)
- [他们看到我在滚动](#they-see-me-rollin)
- [多线程](#multi-threaded)
- [绝密黑客手法](#top-secret-hacks)
- [最大功耗](#maximum-power-draw)
- [测试工具](#test-harness)
- [结语](#conclusion)
## 背景故事 {#backstory}
大约两年前,我翻出了自己 21 世纪初的工程毕业论文。那是一个用 C++ 编写的图像识别器,利用神经网络对图像进行分类。我本想重新跑起这些老代码,但已经很久没有碰过 ML 代码了。过程很令人恼火,最后我放弃了。
尽管 2024 年初大家都在讨论 LLM,但感觉没有人在 Mac 上*训练*神经网络,至少不用 Swift 这类语言。我试过 PyTorch 和 TensorFlow 等 Python 库,但 Python 本身从不亲自计算——它更像是幕后计算引擎的调度器——这种分离让我觉得自己失去了掌控。
一个月后,Andrej Karpathy 发布了 [llm.c](https://github.com/karpathy/llm.c)。它触动我的方式和其他机器学习内容不同,因为其中毫无隐藏。它大约 1000 行纯 C 代码,虽然变量名有些晦涩,但相对容易读懂。于是,很自然地,我立刻用 Swift 把它重写了一遍。玩起来非常有趣。
当然,要让代码跑得快,还需要做些工作。先剧透一下:最初的 Swift 实现真的超级慢。但优化是一个持续的过程:总有更多的东西可以尝试。这最终引出了这篇文章:我将逐一讲述我当时(以及上周补充的几个)所做的各种探索,目的是让 LLM 训练变得*相当快*,而**无需**依赖任何库。大部分代码会用 Swift 编写(最后会展示一个 Metal 实现)。
顺便说一句,**我不会解释神经网络或 LLM 是如何工作的**。如果你感兴趣,Karpathy 的视频 [Let’s build GPT: from scratch, in code, spelled out.](https://www.youtube.com/watch?v=kCc8FmEb1nY) 几乎是学习 GPT 类 LLM 工作原理的权威指南,而他更早的系列视频从 [The spelled-out intro to language modeling: building makemore](https://youtu.be/PaCmpygFfXo?si=SQmmakwYc2B3BfMv) 开始,用 5 个视频介绍了许多基础概念,如果你想要更入门的内容。当然,两者都是 Python,所以等你准备好了解如何在 Swift 中实现时,再回到这里。
## llm.c {#llmc}
机器学习本质上是对输入数据应用模型权重(称为前向传播,又称推理),接着计算误差梯度并更新这些权重(反向传播)。我们通常把一系列计算打包在一起,并使其运行得尽可能快。这些操作包可能被称为“线性张量投影”、“矩阵乘法”,甚至是“向量点积”(取决于你拆分工作单元的大小)。归根结底,它是一个循环,多次执行 `z += x * y`。
由于矩阵乘法在机器学习中占如此大的比重,我将重点介绍执行此操作的代码。我会同步更新其他部分的实现,但只使用与矩阵乘法相同的优化方法。
先来看看 llm.c 中的 `matmul_forward`,这是前向传播中使用的核心矩阵乘法。它遍历输入(`inp`),乘以模型权重(`weight`),并将结果累加到求和变量(`val`)中。
```c
void matmul_forward(float* out, const float* inp, const float* weight, const float* bias, int B, int T, int C, int OC) {
for (int b = 0; b < B; b++) {
for (int t = 0; t < T; t++) {
int bt = b * T + t;
for (int o = 0; o < OC; o++) {
float val = (bias != NULL) ? bias[o] : 0.0f;
for (int i = 0; i < C; i++) {
val += inp[bt * C + i] * weight[o*C + i];
}
out[bt * OC + o] = val;
}
}
}
}
```
四层循环增加了一些视觉复杂性,但实际上,`val += inp[bt * C + i] * weight[o*C + i];` 这行才是神经网络的核心。就像我说的:`z += x * y`,很多次。有多少次?`val` 这行包含 2 次浮点运算,但 Karpathy 说一次完整训练迭代的浮点运算次数大约为 `6 x N x D`,其中 `N` 是模型权重数量(本例为 124,439,808),`D` 是 `B * T = 4 * 64 = 256`(针对我们的应用)。所以,我们讨论的是 6 x 124,439,808 x 256 ≈ 1.911×10¹¹ ≈ 0.2 万亿次浮点运算,每次训练迭代。因此必须运行得很快。
纯 C 代码很容易在 Swift Package 中运行。我已经将 C 实现固定为始终以 `-O3` 优化级别编译(不管 Xcode 设置如何)。即使在这个优化级别下,C 实现每次训练迭代也需 7 秒,推理速度不到每秒 1 个 token。这只能算一个优秀的概念验证,但比能用的程度慢了 10 倍。
## 基本 Swift {#basic-swift}
我尽可能让基本 Swift 版本保持与 C 版本一致:
```swift
static func matmul_forward(out: inout [Float], inp: [Float], weight: [Float], bias: [Float]?, B: Int, T: Int, C: Int, OC: Int) {
for b in 0..<B {
for t in 0..<T {
let bt = b * T + t
for o in 0..<OC {
var val = bias?[o] ?? 0.0
for i in 0..<C {
val += inp[bt * C + i] * weight[o*C + i]
}
out[bt * OC + o] = val
}
}
}
}
```
**不要在 Debug 模式下运行。** 我只会引用 Release 配置下的运行数据。虽然我曾在 Debug 下运行过*部分*代码,但我从没等过完整的 20 轮训练在 Debug 下完成。我通常把 Xcode 的 Scheme 设置为“Release”——即使在调试时。
如果读过背景故事,我已经提过:这“极其缓慢”。Swift 代码慢了 15 到 20 倍。这意味着 LLM 每 19 秒才产生 1 个 token。在这个引擎上跑 20 个训练迭代需要近 30 分钟。究竟发生了什么?
> 这个性能大约为 2.8 Gflop/s。1999 年苹果为 PowerMac G4 做广告,声称其 1 Gflop/s 的能力使其成为美国军方眼中的武器([链接](https://www.youtube.com/watch?v=OoxvLq0dFvw))。如今 2.8 Gflop/s 完全不能接受。
## Span、Egg 和 Span {#span-egg-and-span}
用 Instruments 检查后发现,上次运行中最大的性能耗费是 `_ArrayBuffer.beginCOWMutation()`。Swift 认为其他人可能也在使用我们的 `Array`,即便它们是唯一的(所以我们并没有发生数组拷贝),但仅唯一性检查本身就造成了最大的开销。
> **哈?** 有时你会遇到可能只是 bug 的问题。这可能就是一个。我在 2024 年第一次编写这份代码时,记得这并非问题。我不知道是否出现了性能回退,还是某个安全漏洞被堵上了,导致 `_ArrayBuffer.beginCOWMutation()` 阻塞了性能。当我用 `inline(none)` 禁用函数内联时,这个问题也会转移位置,感觉像是优化器无法正常工作。无论如何,我们不能用 `Array` 来获得所需性能。
幸运的是,Swift 6.2 提供了一个可靠的几乎零开销的解决方案:`MutableSpan`。
```swift
static func matmul_forward(out: inout [Float], inp: [Float], weight: [Float], bias: [Float]?, B: Int, T: Int, C: Int, OC: Int) {
var out = out.mutableSpan
for b in 0..<B {
for t in 0..<T {
let bt = b * T + t
for o in 0..<OC {
var val = bias?[o] ?? 0.0
for i in 0..<C {
val += inp[bt * C + i] * weight[o*C + i]
}
out[bt * OC + o] = val
}
}
}
}
```
代码几乎相同。唯一的区别是 `var out = out.mutableSpan`,它返回一个 `MutableSpan<Float>`,可以完全取代 `Array`。事实上,在许多情况下,`MutableSpan` 的行为类似 `Array`,但它并不提供写时复制(COW)语义,因此我们为回避该特性所做的所有工作都将获得回报。
这导致了 4.5 倍的性能提升。仍然只是 4.5 Gflop/s,但现在至少比 1999 年的武器还要快那么一点点了。
## 放松氛围 {#chill-vibes}
有时你会看着反汇编代码,感觉自己仿佛走进了西部荒野。
**有趣的事实**:我们生活在未来,所以如果有些像汇编语言的东西让你摸不着头脑,你可以把它丢给你最喜欢的 LLM,它会为你解释清楚。Swift 里没有 `-ffast-math`,所以我们看到的是分开的乘法和加法指令。
```asm
+0x164 fmul.4s v1, v1, v5
+0x168 mov s5, v1[3]
+0x16c mov s17, v1[2]
+0x170 mov s18, v1[1]
+0x174 fmul.4s v2, v2, v6
+0x178 mov s6, v2[3]
/*...*/
+0x1d0 fadd s0, s0, s7
+0x1d4 fadd s0, s0, s4
+0x1d8 fadd s0, s0, s24
+0x1dc fadd s0, s0, s23
+0x1e0 fadd s0, s0, s16
```
Swift 试图进行 4 倍循环展开——那个 `fmul.4s` 是用于执行 4 次乘法的 SIMD 操作——但那些 `mov` 指令以及末尾的分散加法都在拖慢我们。我们需要像 C 那样使用融合乘加(FMA)。幸运的是,我们有 [Swift-Numerics](https://github.com/apple/swift-numerics),它提供了 `Relaxed` 模块,让我们拥有自己的“快速数学”。就像《银河系漫游指南》里的“别慌”一样,我猜“Relax”这个词就是让我们更镇静,享受轻松的氛围。或者它允许我们放宽舍入结果的规则,从而使 FMA 成为可能。
所有 `a += b * c` 和 `x = y + z` 操作都可以用 `Relaxed.multiplyAdd` 和 `Relaxed.sum` 来改进,但我会特别避免在 `gelu_backward` 函数上应用这些函数,因为 C 实现明确在该函数周围禁用了 `-ffast-math`。
```swift
static func matmul_forward(out: inout [Float], inp: [Float], weight: [Float], bias: [Float]?, B: Int, T: Int, C: Int, OC: Int) {
var out = out.mutableSpan
for b in 0..<B {
for t in 0..<T {
let bt = b * T + t
for o in 0..<OC {
var val = bias?[o] ?? 0.0
for i in 0..<C {
val = Relaxed.multiplyAdd(inp[bt * C + i], weight[o*C + i], val)
}
out[bt * OC + o] = val
}
}
}
}
```
这带来了 1.8 倍的提升。运行效果开始像有了点动力。
## 他们看到我在滚动 {#they-see-me-rollin}
我以前在 C 级性能对比中看到过这个把戏,但一直没能找到一种不用手动展开就能使其工作的方式。通常我们期望编译器能为我们进行循环展开,但检查汇编代码后,我发现它并没有。在 `Relaxed.multiplyAdd` 内部的跳跃指令(`b.ne`)是一个太大的开销。在 2024 年的实现中,我只能手动将循环展开 8 次(读起来相当丑陋)。不过,Swift 6.2 为我们提供了另一个有用的特性:`InlineArray`,终于匹配了 C 的栈分配数组。
```swift
for obt in stride(from: 0, to: BT, by: LOOP_UNROLL) {
for o in 0..<OC {
var vals = InlineArray<Float, LOOP_UNROLL>(repeating: bias?[o] ?? 0)
let bt = inp.span.extracting(droppingFirst: obt * C)
let w = weight.span.extracting(droppingFirst: o * C)
for i in 0..<C {
for idx in 0..<LOOP_UNROLL {
vals[idx] = Relaxed.multiplyAdd(bt[obt + idx * C + i], w[i], vals[idx])
}
}
for idx in 0..<LOOP_UNROLL {
out[obt + idx * OC + o] = vals[idx]
}
}
}
```
哦,我还把最外层的循环改成了以 `LOOP_UNROLL` 为步长的 `stride`,这样每次迭代计算多个 `obt`。
再获得 2 倍的提升。此时我们终于达到了与 C 相当的性能:每次训练迭代不到 7.5 秒。
## 多线程 {#multi-threaded}
虽然我们赶上了 C 的单线程性能,但 C 在 llm.c 中是用 OpenMP 进行多线程的。我们也要多线程。Swift 有 GCD(Grand Central Dispatch),它提供的 `DispatchQueue.concurrentPerform` 非常适合这种利落小队式的工作。
```swift
DispatchQueue.concurrentPerform(iterations: threadCount) { threadIndex in
// ... threadIndex 贴出了本次迭代处理的 ob 范围
}
```
这几乎是一个线性的性能提升,乘以 14(我的 M1 Max 上有 14 个能用的核心吗?是 8 个性能核心,再加上其他一些……总之大约 14)。不错。再获得 14 倍的提升。现在比 C 快 1.15 倍。
## 绝密黑客手法 {#top-secret-hacks}
**注意**:我在这里玩得很开心,但应该清楚:**不要直接使用 Apple 的 AMX 指令。通过 Accelerate 框架来使用**,为你自己的应用着想。Apple 保持这些“未公开”的指令,是为了可以随时破坏二进制兼容性。Accelerate 框架会继续工作,但这段代码会失效。而且下一篇文章的剧透:Apple 的实现比我的大约快 20%。
我们需要的核心指令是 `AMX_MATFP`。该指令将一个 16 元素向量的每个元素与另一个 16 元素向量的每个元素相乘,生成一个 16 x 16 的小块,并将其累加到输出小块中。通过对适当的输入向量执行 16 次此操作,可以乘以整个 16 x 16 矩阵。我们需要加载输入,因此还有 `AMX_LDX`、`AMX_LDY`,最后我们用 `AMX_STZ` 发出累加后的 16 x 16 小块。配合 `AMX_LDZ`(用于将累加器块重置为预定义的零块),就能处理分块矩阵乘法的内层循环。
这意味着该算法并未展示整个循环:它只是内层循环,需要外层循环来准备数据并统一结果。我只展示内层循环:
```c
private static func amxF32_16x64(
outTiles: UnsafeMutablePointer<[Float]>,
lhsPanel: UnsafePointer<Float>,
rhsPanels: UnsafePointer<Float>,
innerCount: Int
) {
zeroTileRow.withUnsafeBufferPointer { zeroBuffer in
guard let zeroBase = zeroBuffer.baseAddress else { return }
for tile in 0..<accumulatorCount {
amx_ldz(zeroBase.amxZOperand(row: UInt32(tile)))
}
for i in 0..<innerCount {
for tile in 0..<accumulatorCount {
amx_ldx(lhsPanel.amxXOperand(row: UInt32(tile * 16)))
amx_ldy(rhsPanels.amxYOperand(row: UInt32(tile * 16)))
amx_matfp(lhsPanel.amxXOperand(row: UInt32(tile * 16)), rhsPanels.amxYOperand(row: UInt32(tile * 16)))
}
}
for tile in 0..<accumulatorCount {
let tileBase = outTiles.advanced(by: tile * tileRows * tileCols)
for row in 0..<tileRows {
amx_stz(tileBase.amxZOperand(row: UInt32(tile + (row * accumulatorCount))))
}
}
}
}
```
再强调一次,它在形状上与那个被我称为“快速 Swift”的 `LOOP_UNROLL` 实现没有*那么*大的不同。训练速度又快了 1.67 倍。分块需要大量的数据打包和散布,才能将行主序矩阵转换成所需的分块形式,我确信自己在这方面可以做得更好。如果更高效,很容易能再快 2 倍以上。
## 最大功耗 {#maximum-power-draw}
> 本实现中的 Metal 代码**源自 James Thompson 在 [llm.metal](https://github.com/regrettable-username/llm.metal) 中的实现**。不过,他们使用了矩阵乘法库,所以我写了属于自己的 Metal 矩阵乘法代码,以保持无框架的方式。
在上一节中,我小心地表述为“Apple Silicon 上用于矩阵乘法的最快 *CPU* 指令”,因为当然,我们还有 GPU。Metal 代码中的矩阵乘法长什么样?与 C 和 Swift 代码不同,它分为两部分:内部内核(用 Metal/C++ 编写)和外部调用机制(留在 Swift 侧)。首先是内部内核:
```cpp
kernel void matmul_forward_kernel(
device float* out [[buffer(0)]],
const device float* inp [[buffer(1)]],
const device float* weight [[buffer(2)]],
const device float* bias [[buffer(3)]],
constant uint& BT [[buffer(4)]],
constant uint& C [[buffer(5)]],
constant uint& OC [[buffer(6)]],
uint2 gid [[thread_position_in_grid]]
) {
uint oc = gid.x;
uint bt = gid.y;
if (bt >= BT || oc >= OC) {
return;
}
float sum = bias[oc];
for (uint i = 0; i < C; i++) {
sum += inp[bt * C + i] * weight[oc * C + i];
}
out[bt * OC + oc] = sum;
}
```
先跳过略显繁重的参数块,你会发现这实际上只包含了 C 和基本 Swift 版 `matmul_forward` 四层循环中的最内层循环。这里我们不再循环 `B`、`T`、`OC` 和 `C`,而只保留了对 `C` 的循环。遍历 `B * T` 和 `OC` 的任务则交给了外部调用机制:
```swift
func matmul_forward(
out: MTLBuffer,
inp: MTLBuffer,
weight: MTLBuffer,
bias: MTLBuffer,
B: Int,
T: Int,
C: Int,
OC: Int,
ctx: MetalCommandContext
) {
ctx.compute.compute2D(
pipelines.matmulForward,
width: OC,
height: B * T,
threadsPerThreadgroup: MTLSize(width: 1,
相似文章
从零开始使用MLX构建大语言模型
一份关于使用Apple的MLX框架从零开始构建大语言模型的指南。
@QingQ77: 纯 Swift 写的 Apple Silicon LLM 推理服务器,不用 Python,低内存 Mac 也能跑大模型。 https://github.com/SharpAI/SwiftLM SwiftLM 是个 Swift 原生的推理服…
SwiftLM is a Swift-native LLM inference server for Apple Silicon that runs large models without Python, using SSD streaming to load MoE weights and enabling 122B models on 64 GB Macs.
Metal-Sci:用于 Apple Silicon 上 LLM 驱动演化内核搜索的科学计算基准
Metal-Sci 推出了一项包含 10 个任务的基准测试,用于优化 Apple Silicon 上的科学计算内核,并配套了由大语言模型驱动的演化搜索框架。该研究评估了 Claude Opus 4.7、Gemini 3.1 Pro 和 GPT 5.5 等模型,在实现显著加速的同时,利用分布外测试来捕获静默的性能退化问题。
@ActuallyIsaak:这是一个实际运行的端到端过程,从训练到在LM Studio中使用训练好的LLM,由@lmstudio的MLX-LoRA-Studio提供
MLX-LoRA-Studio 是一款原生的macOS应用,用于在Apple Silicon上微调LLM,提供用户友好的界面,支持多种训练算法,包括SFT、DPO和QAT。它完全开源,允许本地私有微调,无需依赖云端。
@DanKornas:微调本地大语言模型不应需要租用云端GPU。Silicon Studio 是一款开源桌面应用,适用于本地大语言模型的……
Silicon Studio 是一款开源桌面应用,支持在 Apple Silicon Mac 上使用 MLX 进行本地大语言模型微调和推理,具备数据准备、模型管理和可视化配置等功能。