WWDC Quick Look 💓 By SwiftGGTeam
Improve memory usage and performance with Swift

Improve memory usage and performance with Swift

观看原视频

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 后看到 previousPixelpixelCache 两个属性触发了运行时独占性检查。原因是这两个属性放在一个 class State 里,class 的属性需要运行时 enforce exclusivity。把它们直接挪到 parser struct 上后,同时把方法标 mutatingswift_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 家族(SpanRawSpanMutableSpanOutputSpanUTF8Span),核心特性是 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
  • 做什么:解析二进制格式时用 RawSpan/OutputSpan 替换 Data 作为中间层

    • 为什么值得做:Data 在 tight loop 里的 retain/release 占了本场 14% CPU。Span 家族用 non-escapable 把生命周期绑定到底层 collection,无 retain/release,编译器还会替你拒绝危险的返回。
    • 怎么开始:parser 入口仍然收 Data,进函数后立刻 data.bytesRawSpan 传到底层;写出用 Data(rawCapacity:) 给的 OutputSpan
  • 做什么:把跨方法共享的「状态变量」从 class 移到包含它们的 struct

    • 为什么值得做:class 属性的访问需要运行时 exclusivity 检查(swift_beginAccess/swift_endAccess),在循环里成本不低;struct 的属性访问大多在编译期就能 enforce。
    • 怎么开始:找出 parser/encoder 之类对象里只为「分组属性」存在的内嵌 class,把属性提到外层 struct,并把读写它们的方法标 mutating
  • 做什么:性能 profile 时把目标代码循环 50 次以上

    • 为什么值得做:优化到一定程度后单次执行太快,Instruments 采样不足,火焰图会失真。Nate 在第三轮就遇到这个问题,加了 50 次循环后才看清 swift_beginAccess 的来源。
    • 怎么开始:在 XCTest 里写一个 perf 专用 case,外层包 for _ in 0..<50 { ... },profile 这个 case 而非生产 entry。

关联 Session

评论

GitHub Issues · utterances