WWDC Quick Look 💓 By SwiftGGTeam
Explore Swift performance

Explore Swift performance

观看原视频

Highlight

Swift 的性能直觉不如 C 那么直接。C 到机器码的翻译几乎是字面的——局部变量在栈上,堆分配只有你显式调用 malloc 才会发生。Swift 引入了安全性和大量抽象工具(闭包、泛型等),这些抽象的实现成本不像 malloc 那样一目了然。


核心内容

写 C 的时候,你对性能有天然的直觉:局部变量在栈上,malloc 是唯一的堆分配点,编译器做的事几乎可以一一对应到机器码。Swift 打破了这个直觉——安全性要求引入了隐式检查,泛型、闭包、协议等抽象工具的实现成本散落在各个角落,不像 malloc 那样一眼可见。

John McCall 把底层性能问题归纳为四个维度:函数调用开销、数据表示效率、内存分配耗时、值的拷贝和销毁成本。每个 Swift 特性都或多或少影响其中一两个维度。好消息是 Swift 优化器很强,能消除大量你看不到的抽象开销;坏消息是优化器有极限,你写代码的方式直接决定了它能做多少。

这引出了一个务实的建议:当性能对项目重要时,必须定期监控它。在自顶向下的调查中定位热点后,想办法测量它们,然后把测量自动化到开发流程中。无论你搞坏了优化器的假设,还是不小心引入了二次方算法,自动化回归测试都能帮你及时捕获。


详细内容

函数调用的四种成本

函数调用有四项开销:设置参数、解析函数地址、分配局部状态空间、以及对优化的阻碍。前三项是你做了什么,第四项是你因此做不了什么(04:39)。

其中参数传递有两层:底层是调用约定的寄存器搬运,现代处理器基本能隐藏;高层的所有权约定可能导致额外的 retain/release,这在 profile 里经常出现。

静态派发 vs 动态派发 是关键分野。静态派发让编译器可以看到函数定义,从而做内联和泛型特化;动态派发支持多态,但牺牲了优化机会。在 Swift 里,protocol requirement 用动态派发,protocol extension method 用静态派发(06:40):

protocol DataModel {
    func update(from source: DataSource)
}

extension DataModel {
    func update(from source: DataSource) {
        self.update(from: source, quickly: true)
    }
}
  • protocol DataModel 主体里声明的 update(from:) 是 requirement,调用时走动态派发
  • extension DataModel 里声明的 update(from:) 是 extension method,调用时走静态派发
  • 两者语义不同:requirement 遵循动态多态,extension method 基于编译期已知类型
  • 性能关键路径上优先使用 extension method,但需要理解语义变化

内存分配:全局、栈、堆三种来源

全局内存在程序加载时分配,几乎免费,但只能用于生命周期贯穿整个程序的固定大小数据。栈分配通过移动栈指针完成,同样极快,但要求内存在当前函数作用域内有明确的生命周期终点。堆分配最灵活,分配和释放的开销也最大(08:29)。

函数的 CallFrame 就在栈上分配。编译器无论如何都要做一次栈指针减法来保存返回地址,多减几个字节不花额外时间,所以局部变量放在 CallFrame 里几乎是免费的(07:18):

_$s4main9updateAll6models4fromySayAA9DataModel_pG_AA0F6SourceCtF:
    sub   sp, sp, #208
    stp   x29, x30, [sp, #192]

    ldp   x29, x30, [sp, 192]
    add   sp, sp, #208
    ret
  • sub sp, sp, #208:进入函数时,栈指针减 208 字节,分配 CallFrame
  • stp x29, x30, [sp, #192]:保存帧指针和返回地址
  • add sp, sp, #208:退出函数时,栈指针加回 208 字节,释放 CallFrame
  • 这 208 字节覆盖了所有局部状态,包括 modelssourcemodel、迭代器等

值的拷贝与所有权

Swift 的所有权系统是内存安全的核心。值的交互方式有三种:consume(转移所有权)、mutate(临时取得所有权再归还)、borrow(只读借用,阻止他人修改或销毁)(15:00)。

拷贝一个值意味着拷贝它的内联表示。拷贝 struct 是递归拷贝所有存储属性,拷贝 class 是 retain 引用。大 struct 的拷贝成本来自两方面:引用类型属性需要逐一 retain,每个拷贝副本还需要独立的存储空间。consume 操作符可以显式请求无拷贝转移(16:27):

func makeArray() {
    var array = [ 1.0, 2.0 ]
    var array2 = consume array
}
  • var array = [1.0, 2.0]:初始化,字面量产生独立值,直接转移所有权,无拷贝
  • var array2 = consume array:显式消费 array,将其所有权转移到 array2,零拷贝
  • consume 之后访问 array 会触发编译错误,因为值已被转移
  • 编译器在能证明没有后续使用时通常会自动做这个优化,consume 是给你手动控制的手段

传递参数时,Swift 需要证明没有同时的 mutate 或 consume 才能借用而非拷贝。类属性的存储位置让这个证明变难,可能导致防御性拷贝(18:10):

func makeArray(object: MyClass) {
    object.array = [ 1.0, 2.0 ]
    print(object.array)
}
  • object.array 的存储在类实例中,Swift 难以证明 print 期间属性不被修改
  • 编译器可能插入防御性拷贝(retain buffer),确保 print 读到稳定值
  • 这是 Swift 正在积极改进的领域,包括优化器增强和显式 borrow 特性

闭包:非逃逸 vs 逃逸

函数值在 Swift 里始终是一对:函数指针 + 上下文指针。非逃逸闭包的上下文可以分配在栈上,因为闭包不会在调用结束后被使用(28:47):

func sumTwice(f: () -> Int) -> Int {
  return f() + f()
}

func puzzle(n: Int) -> Int {
  return sumTwice { n + 1 }
}
  • sumTwice 接受非逃逸函数 f
  • 闭包 { n + 1 } 捕获 n,编译器生成一个栈上的上下文结构体存放 n
  • 调用 f() 时,上下文指针作为隐式参数传入,无需堆分配
  • 如果 nvar 且被闭包修改,非逃逸闭包只捕获指向变量的指针,变量本身不需要堆化

逃逸闭包则不同:上下文必须堆分配并用引用计数管理,本质上是匿名类的实例。被逃逸闭包捕获的 var 也会被堆化,因为闭包可能延长变量的生命周期(29:34)。

泛型 vs 协议类型(some vs any

泛型函数和协议类型函数看起来相似,运行时特征截然不同(31:50):

func updateAll<Model: DataModel>(models: [Model],
                            from source: DataSource) {
    for model in models {
        model.update(from: source)
    }
}

func updateAll(models: [any DataModel], from source: DataSource) {
    for model in models {
        model.update(from: source)
    }
}
  • 第一个(泛型):数组元素同质,内存紧凑排布;类型信息和 witness table 作为顶层参数传入一次;调用方知道具体类型时可以特化(32:57
  • 第二个(any):数组元素可异质,每个元素自带类型信息和 witness table;内联存储只有 3 个指针大小,超出则堆分配;特化极其困难
  • any DataModel 的内联表示包含值存储(3 指针)、类型元数据指针、witness table 指针

优化器可以特化泛型函数——当调用方传入已知类型 [MyDataModel] 时,编译器能生成只处理该类型的专门版本,消除所有抽象开销。

async 函数的内存模型

async 函数的局部状态不能放在 C 栈上,因为挂起时线程可能被释放。Swift 的方案是给每个 async task 分配独立的 slab 栈(25:02)。async 函数被拆分成多个 partial function,每个 partial function 在 C 栈上运行到下一个挂起点,然后 tail-call 下一个 partial function。挂起时直接返回,线程立即被复用。


核心启发

  • 做什么:用 some 替代 any 声明泛型约束

    • 为什么值得做:some 保留具体类型信息,编译器可以特化和内联;any 引入 existential container,每个元素自带 3 指针 + 元数据 + witness table,值超出 3 指针还会触发堆分配
    • 怎么开始:检查函数参数中的 [any Protocol],如果元素总是同质,改为 <T: Protocol>[some Protocol]
  • 做什么:区分 protocol requirement 和 extension method

    • 为什么值得做:requirement 走动态派发,编译器无法内联;extension method 走静态派发,可以内联和特化。性能关键路径上差别显著
    • 怎么开始:审视热路径上的协议方法调用,把性能关键的调用从 requirement 移到 extension,或将 protocol 改为泛型约束
  • 做什么:避免在类属性上直接传参,或用 borrow 显式标注

    • 为什么值得做:类属性的存储位置让编译器难以证明没有并发修改,可能插入防御性拷贝(额外的 retain/release)。Swift 正在改进,但目前仍需注意
    • 怎么开始:在 Instruments 中检查热路径的 retain/release 数量,发现异常多的拷贝时考虑用局部变量过渡或显式 borrow
  • 做什么:自动化性能回归测试

    • 为什么值得做:优化器很强但不是万能的,代码风格变化可能悄悄破坏优化假设。手动检查不可靠,只有自动化才能在每次提交时捕获回归
    • 怎么开始:用 Instruments 定位热点后,编写测量这些热点的 XCTest 性能测试,集成到 CI 流水线

关联 Session

评论

GitHub Issues · utterances