Highlight
Nate Cook 用一个 QOI 图片解析器演示了五轮优化,配合 Swift 6.2 的 InlineArray 和 Span,让解析速度比初版快了 700 倍,且全程不使用 unsafe 代码。
核心内容
Nate Cook 在 WWDC25 现场打开了自己写的 QOI 图片查看器。点开一个几 KB 的小图标,秒开。换成一张稍大的鸟类照片,等了好几秒才出来。这就是性能问题登场的方式:在测试数据上一切正常,遇到真实数据就卡。
他没有掏出宏大的架构,而是把 Instruments 跑起来,从一个工具找问题,再回到代码改一行。整场 session 围绕同一个 QOI 解析器做了五轮迭代——修算法、删分配、消独占性检查、把堆内存搬到栈上、用 Span 干掉引用计数。每一步都先在 Instruments 里看到症状(platform_memmove 占满火焰图、近百万次 transient 分配、swift_beginAccess 频繁出现、swift_retain/swift_release 各占 7%),再用 Swift 6.2 引入的 InlineArray、RawSpan、OutputSpan 把这些症状解掉。最终,原版的二次复杂度变成线性,再叠加内存管理优化,整体快了 700 倍。
详细内容
第一轮:算法错误(07:01)。 火焰图上 platform_memmove 占了绝大多数采样。原因在 readByte() 用 Data.dropFirst() 然后再用 Data.init 包一次,每读一个字节就把剩余 data 整体复制一份。改成 popFirst():
import Foundation
extension Data {
/// Consume a single byte from the start of this data.
mutating func readByte() -> UInt8? {
guard !isEmpty else { return nil }
return self.popFirst()
}
}
关键点:
popFirst()是Collection的方法,从前端弹出一个元素并把起始位置后移一字节,O(1)。Data本身设计成可以从两端缩,因此这里不会触发整体复制。- 这一行的修改让解析时间从二次复杂度降回线性。
第二轮:消除中间分配(12:53)。 Allocations 工具显示一张图触发了近百万次短命分配。源头是这条链式调用:readEncodedPixels(...).flatMap { decodePixels(...) }.prefix(...).flatMap { $0.data(channels:) }。每个像素都要 new 一个 3 或 4 元素的小数组、再 flatten、再拷进 Data。改写思路是先按最终大小一次性分配 Data,再逐像素写入:
extension QOIParser {
/// Parses an image from the given QOI data.
func parseQOI(from input: inout Data) -> QOI? {
guard let header = QOI.Header(parsing: &input) else { return nil }
let totalBytes = header.pixelCount * Int(header.channels.rawValue)
var pixelData = Data(repeating: 0, count: totalBytes)
var offset = 0
while offset < totalBytes {
guard let nextPixel = parsePixel(from: &input) else { break }
switch nextPixel {
case .run(let count):
for _ in 0..<count {
state.previousPixel
.write(to: &pixelData, at: &offset, channels: header.channels)
}
default:
decodeSinglePixel(from: nextPixel)
.write(to: &pixelData, at: &offset, channels: header.channels)
}
}
return QOI(header: header, data: pixelData)
}
}
关键点:
totalBytes = pixelCount * channels.rawValue:根据 header 直接算出最终大小,不靠运行时增长。Data(repeating: 0, count: totalBytes):一次性分配,后续不再 realloc。offset变量手动跟踪写入位置,每次write(to:at:channels:)把数据原地写进 buffer。- 改写后总分配数从近百万降到个位数,执行时间再砍一半多。
第三轮:消除 exclusivity 检查(16:53 起)。 由于改进太明显,Nate 把测试循环 50 次以增加采样。filter swift_beginAccess 后看到 previousPixel 和 pixelCache 两个属性触发了运行时独占性检查。原因是这两个属性放在一个 class State 里,class 的属性需要运行时 enforce exclusivity。把它们直接挪到 parser struct 上后,同时把方法标 mutating,swift_beginAccess 从火焰图里完全消失。
第四轮:InlineArray 替换固定大小数组(19:47)。 pixelCache 是一个 64 元素、永远不变长的数组。Swift 6.2 的 InlineArray 用 value generics 把大小写进类型:
var array: InlineArray<3, Int> = [1, 2, 3]
array[0] = 4
// array == [4, 2, 3]
// Can't append or remove elements
array.append(4)
// error: Value of type 'InlineArray<3, Int>' has no member 'append'
// Can only assign to a same-sized inline array
let bigger: InlineArray<6, Int> = array
// error: Cannot assign value of type 'InlineArray<3, Int>' to type 'InlineArray<6, Int>'
关键点:
- 类型签名
InlineArray<3, Int>:3是 value generic 参数,编译期就知道大小。 - 不支持
append/remove,赋值给不同 size 的 InlineArray 会编译报错。 - 元素直接内联存储(栈上或 struct 内部),没有独立堆分配,没有引用计数,没有 copy-on-write,每次赋值真复制。
- 适合「定长、原地修改、不共享」的场景,不适合需要互相共享/copy-on-write 的场景。
第五轮:RawSpan + OutputSpan 干掉引用计数(26:27)。 profile 里 swift_retain 7%、swift_release 7%、唯一性检查 3%,全都来自 Data 在 tight loop 里的引用计数。Swift 6.2 引入 Span 家族(Span、RawSpan、MutableSpan、OutputSpan、UTF8Span),核心特性是 non-escapable:编译器把 span 的生命周期绑定到底层 collection,无法逃出作用域,因此运行时不需要 retain/release。
@available(macOS 16.0, *)
extension RawSpan {
mutating func readByte() -> UInt8? {
guard !isEmpty else { return nil }
let value = unsafeLoadUnaligned(as: UInt8.self)
self = self._extracting(droppingFirst: 1)
return value
}
}
关键点:
RawSpan是 non-escapable,类型本身就拒绝被返回到外层作用域。unsafeLoadUnaligned(as:)名字带 unsafe 是因为加载某些类型可能不安全;加载UInt8这样的内置整数永远安全。_extracting(droppingFirst: 1)返回一个新的RawSpan,不复制数据,只移动起始位置。- 调用方用
data.bytes拿到RawSpan后传进解析方法,全程零 retain/release。
最终版用 Data(rawCapacity:) 配合 OutputSpan 完成写入:
/// Parses an image from the given QOI data.
mutating func parseQOI(from input: inout RawSpan) -> QOI? {
guard let header = QOI.Header(parsing: &input) else { return nil }
let totalBytes = header.pixelCount * Int(header.channels.rawValue)
let pixelData = Data(rawCapacity: totalBytes) { outputSpan in
while outputSpan.count < totalBytes {
guard let nextPixel = parsePixel(from: &input) else { break }
switch nextPixel {
case .run(let count):
for _ in 0..<count {
previousPixel
.write(to: &outputSpan, channels: header.channels)
}
default:
decodeSinglePixel(from: nextPixel)
.write(to: &outputSpan, channels: header.channels)
}
}
}
return QOI(header: header, data: pixelData)
}
关键点:
Data(rawCapacity:)是 Swift 6.2 新初始化器,提供一个OutputRawSpan闭包用于逐步填入未初始化内存。outputSpan.count取代了上一版手写的offset变量,由OutputSpan自身记录写入进度。previousPixel.write(to: &outputSpan, channels:)直接append到 output span,无需经过Data中间层。- 配合
[InlineArray](https://github.com/apple/swift-binary-parsing)的 pixel cache,整段解析里swift_retain/swift_release完全消失。
28:51 处给出最终对比:内存管理优化让解析快了 6 倍;叠加前面的算法修复,比原版快 700 倍,且没有任何 unsafe 代码。Nate 还顺手开源了 Swift Binary Parsing,里面的 ParserSpan 就是基于这套机制。
核心启发
-
做什么:把性能瓶颈搜索路径标准化为「Time Profiler 找热点 → Allocations 看分配源 → Reveal in Xcode 跳到代码」。
- 为什么值得做:session 里这套流程跑了五次,每次都在 30 秒内定位到具体一行代码。把它做成肌肉记忆,后续遇到真实数据卡顿不会再靠猜。
- 怎么开始:右键 test 选 Profile,在 Instruments 里加 Time Profiler + Allocations 两个工具,开 Invert Call Tree,先看自己代码里第一个出现的符号。
-
做什么:审计自己代码里所有定长容器,能换 InlineArray 的换掉。
- 为什么值得做:固定大小的 lookup table、缓存、ring buffer 用普通
Array会带来引用计数、唯一性检查、堆分配三重开销。换成InlineArray<N, T>编译期就锁死,运行时全没。 - 怎么开始:grep 自己工程里
Array(repeating:count:)、[T](repeating:count:)这类初始化,逐个判断是否定长、是否需要共享,符合条件的换InlineArray。
- 为什么值得做:固定大小的 lookup table、缓存、ring buffer 用普通
-
做什么:解析二进制格式时用
RawSpan/OutputSpan替换Data作为中间层。- 为什么值得做:
Data在 tight loop 里的 retain/release 占了本场 14% CPU。Span 家族用 non-escapable 把生命周期绑定到底层 collection,无 retain/release,编译器还会替你拒绝危险的返回。 - 怎么开始:parser 入口仍然收
Data,进函数后立刻data.bytes取RawSpan传到底层;写出用Data(rawCapacity:)给的OutputSpan。
- 为什么值得做:
-
做什么:把跨方法共享的「状态变量」从 class 移到包含它们的 struct。
- 为什么值得做:class 属性的访问需要运行时 exclusivity 检查(
swift_beginAccess/swift_endAccess),在循环里成本不低;struct 的属性访问大多在编译期就能 enforce。 - 怎么开始:找出 parser/encoder 之类对象里只为「分组属性」存在的内嵌 class,把属性提到外层 struct,并把读写它们的方法标
mutating。
- 为什么值得做:class 属性的访问需要运行时 exclusivity 检查(
-
做什么:性能 profile 时把目标代码循环 50 次以上。
- 为什么值得做:优化到一定程度后单次执行太快,Instruments 采样不足,火焰图会失真。Nate 在第三轮就遇到这个问题,加了 50 次循环后才看清
swift_beginAccess的来源。 - 怎么开始:在 XCTest 里写一个 perf 专用 case,外层包
for _ in 0..<50 { ... },profile 这个 case 而非生产 entry。
- 为什么值得做:优化到一定程度后单次执行太快,Instruments 采样不足,火焰图会失真。Nate 在第三轮就遇到这个问题,加了 50 次循环后才看清
关联 Session
- Embracing Swift concurrency — Swift 并发模型与本场内存优化思路同源,关注 task 生命周期与隔离。
- Optimize SwiftUI performance with Instruments — 同样基于 Instruments,但专攻 SwiftUI 视图更新与 diff 性能。
- Explore Swift and Java interoperability — Swift 6.2 跨语言互操作,里面也用到了 value generics 与 non-escapable。
- Code-along: Cook up a rich text experience in SwiftUI with AttributedString —
AttributedString内部对 storage 的处理思路,可印证 Span 的应用场景。
评论
GitHub Issues · utterances