简介
2014 年,苹果公司在 WWDC 上发布 Swift 这一新的编程语言。经过几年的发展,Swift 已经成为 iOS 开发语言的“中流砥柱”,Swift 提供了非常灵活的高级别特性,例如协议、闭包、泛型等,并且 Swift 还进一步开发了强大的 SIL(Swift Intermediate Language)用于对编译器进行优化,使得 Swift 相比 Objective-C 运行更快性能更优,Swift 内部如何实现性能的优化,我们本文就进行一下解读,希望能对大家有所启发和帮助。
针对 Swift 性能提升这一问题,我们可以从概念上拆分为两个部分:
编译器:Swift 编译器进行的性能优化,从阶段分为编译期和运行期,内容分为时间优化和空间优化。
开发者:通过使用合适的数据结构和关键字,帮助编译器获取更多信息,进行优化。
下面我们将从这两个角度切入,对 Swift 性能优化进行分析。通过了解编译器对不同数据结构处理的内部实现,来选择最合适的算法机制,并利用编译器的优化特性,编写高性能的程序。
理解 Swift 的性能
理解 Swift 的性能,首先要清楚 Swift 的数据结构,组件关系和编译运行方式。
数据结构
Swift 的数据结构可以大体拆分为:Class
,Struct
,Enum
。
组件关系
组件关系可以分为:inheritance
,protocols
,generics
。
方法分派方式
方法分派方式可以分为Static dispatch
和Dynamic dispatch
。
要在开发中提高 Swift 性能,需要开发者去了解这几种数据结构和组件关系以及它们的内部实现,从而通过选择最合适的抽象机制来提升性能。
首先我们对于性能标准进行一个概念陈述,性能标准涵盖三个标准:
性能指标
Allocation
Reference counting
Method dispatch
接下来,我们会分别对这几个指标进行说明。
Allocation
内存分配可以分为堆区栈区,在栈的内存分配速度要高于堆,结构体和类在堆栈分配是不同的。
Stack
基本数据类型和结构体默认在栈区,栈区内存是连续的,通过出栈入栈进行分配和销毁,速度很快,高于堆区。
我们通过一些例子进行说明:
结构体的内存分配
以上结构体的内存是在栈区分配的,内部的变量也是内联在栈区。将point1
赋值给point2
实际操作是在栈区进行了一份拷贝,产生了新的内存消耗point2
,这使得point1
和point2
是完全独立的两个实例,它们之间的操作互不影响。在使用point1
和point2
之后,会进行销毁。
Heap
高级的数据结构,比如类,分配在堆区。初始化时查找没有使用的内存块,销毁时再从内存块中清除。因为堆区可能存在多线程的操作问题,为了保证线程安全,需要进行加锁操作,因此也是一种性能消耗。
Class 实例内存分配
以上我们初始化了一个Class
类型,在栈区分配一块内存,但是和结构体直接在栈内存储数值不同,我们只在栈区存储了对象的指针,指针指向的对象的内存是分配在堆区的。需要注意的是,为了管理对象内存,在堆区初始化时,除了分配属性内存(这里是 Double 类型的 x,y),还会有额外的两个字段,分别是type
和refCount
,这个包含了type
,refCount
和实际属性的结构被称为blue box
。
内存分配总结
从初始化角度,Class
相比Struct
需要在堆区分配内存,进行内存管理,使用了指针,有更强大的特性,但是性能较低。
优化方式:
对于频繁操作(比如通信软件的内容气泡展示),尽量使用Struct
替代Class
,因为栈内存分配更快,更安全,操作更快。
Reference counting
Swift 通过引用计数管理堆对象内存,当引用计数为 0 时,Swift 确认没有对象再引用该内存,所以将内存释放。对于引用计数的管理是一个非常高频的间接操作,并且需要考虑线程安全,使得引用计数的操作需要较高的性能消耗。
对于基本数据类型的Struct
来说,没有堆内存分配和引用计数的管理,性能更高更安全,但是对于复杂的结构体,如:
结构体包含引用类型
这里看到,包含了引用的结构体相比Class
,需要管理双倍的引用计数。每次将结构体作为参数传递给方法或者进行直接拷贝时,都会出现多份引用计数。下图可以比较直观的理解:
备注:包含引用类型的结构体出现 Copy 的处理方式
Class 在拷贝时的处理方式:
引用计数总结
Class
在堆区分配内存,需要使用引用计数器进行内存管理。基本类型的
Struct
在栈区分配内存,无引用计数管理。包含强类型的
Struct
通过指针管理在堆区的属性,对结构体的拷贝会创建新的栈内存,创建多份引用的指针,Class
只会有一份。
优化方式
在使用结构体时:
通过使用精确类型,例如 UUID 替代 String(UUID 字节长度固定 128 字节,而不是 String 任意长度),这样就可以进行内存内联,在栈内存储 UUID,我们知道,栈内存管理更快更安全,并且不需要引用计数。
Enum 替代 String,在栈内管理内存,无引用计数,并且从语法上对于开发者更友好。
Method Dispatch
我们之前在Static dispatch VS Dynamic dispatch中提到过,能够在编译期确定执行方法的方式叫做静态分派 Static dispatch,无法在编译期确定,只能在运行时去确定执行方法的分派方式叫做动态分派 Dynamic dispatch。
Static dispatch
更快,而且静态分派可以进行 内联 等进一步的优化,使得执行更快速,性能更高。
但是对于多态的情况,我们不能在编译期确定最终的类型,这里就用到了Dynamic dispatch
动态分派。动态分派的实现是,每种类型都会创建一张表,表内是一个包含了方法指针的数组。动态分派更灵活,但是因为有查表和跳转的操作,并且因为很多特点对于编译器来说并不明确,所以相当于 block 了编译器的一些后期优化。所以速度慢于Static dispatch
。
下面看一段多态代码,以及分析实现方式:
引用语义多态的方法分派流程
Method Dispatch 总结
Class
默认使用Dynamic dispatch
,因为在编译期几乎每个环节的信息都无法确定,所以阻碍了编译器的优化,比如inline
和whole module inline
。
使用 Static dispatch 代替 Dynamic dispatch 提升性能
我们知道Static dispatch
快于Dynamic dispatch
,如何在开发中去尽可能使用Static dispatch
。
inheritance constraints
继承约束我们可以使用final
关键字去修饰Class
,以此生成的Final class
,使用Static dispatch
。access control
访问控制private
关键字修饰,使得方法或属性只对当前类可见。编译器会对方法进行Static dispatch
。
编译器可以通过whole module optimization
检查继承关系,对某些没有标记final
的类通过计算,如果能在编译期确定执行的方法,则使用Static dispatch
。
Struct
默认使用Static dispatch
。
Swift 快于 OC 的一个关键是可以消解动态分派。
总结
Swift 提供了更灵活的Struct
,用以在内存、引用计数、方法分派等角度去进行性能的优化,在正确的时机选择正确的数据结构,可以使我们的代码性能更快更安全。
延伸
你可能会问Struct
如何实现多态呢?答案是protocol oriented programming
。
以上分析了影响性能的几个标准,那么不同的算法机制Class
,Protocol Types
和Generic code
,它们在这三方面的表现如何,Protocol Type
和Generic code
分别是怎么实现的呢?我们带着这个问题看下去。
Protocol Type
这里我们会讨论 Protocol Type 如何存储和拷贝变量,以及方法分派是如何实现的。不通过继承或者引用语义的多态:
以上通过Protocol Type
实现多态,几个类之间没有继承关系,故不能按照惯例借助V-Table
实现动态分派。
如果想了解Vtable和Witness table实现,可以进行点击查看,这里不做细节说明。
因为 Point 和 Line 的尺寸不同,数组存储数据实现一致性存储,使用了Existential Container
。查找正确的执行方法则使用了 Protoloc Witness Table
。
Existential Container
Existential Container
是一种特殊的内存布局方式,用于管理遵守了相同协议的数据类型Protocol Type
,这些数据类型因为不共享同一继承关系(这是V-Table
实现的前提),并且内存空间尺寸不同,使用Existential Container
进行管理,使其具有存储的一致性。
Existential Container 的构成
结构如下:
三个词大小的 valueBuffer 这里介绍一下 valueBuffer 结构,valueBuffer 有三个词,每个词包含 8 个字节,存储的可能是值,也可能是对象的指针。对于 small value(空间小于 valueBuffer),直接存储在 valueBuffer 的地址内, inline valueBuffer,无额外堆内存初始化。当值的数量大于 3 个属性即 large value,或者总尺寸超过 valueBuffer 的占位,就会在堆区开辟内存,将其存储在堆区,valueBuffer 存储内存指针。
value witness table 的引用 因为
Protocol Type
的类型不同,内存空间,初始化方法等都不相同,为了对Protocol Type
生命周期进行专项管理,用到了Value Witness Table
。protocol witness table 的引用 管理
Protocol Type
的方法分派。
内存分布如下:
Protocol Witness Table(PWT)
为了实现Class
多态也就是引用语义多态,需要V-Table
来实现,但是V-Table
的前提是具有同一个父类即共享相同的继承关系,但是对于Protocol Type
来说,并不具备此特征,故为了支持Struct
的多态,需要用到protocol oriented programming
机制,也就是借助Protocol Witness Table
来实现(细节可以点击Vtable和witness table实现,每个结构体会创造PWT
表,内部包含指针,指向方法具体实现)。
Point and Line PWT
Value Witness Table(VWT)
用于管理任意值的初始化、拷贝、销毁。
VWT use existential container
Value Witness Table
的结构如上,是用于管理遵守了协议的Protocol Type
实例的初始化,拷贝,内存消减和销毁的。Value Witness Table
在SIL
中还可以拆分为%relative_vwtable
和%absolute_vwtable
,我们这里先不做展开。Value Witness Table
和Protocol Witness Table
通过分工,去管理Protocol Type
实例的内存管理(初始化,拷贝,销毁)和方法调用。
我们来借助具体的示例进行进一步了解:
在 Swift 编译器中,通过Existential Container
实现的伪代码如下:
Protocol Type 存储属性
我们知道,Swift 中Class
的实例和属性都存储在堆区,Struct
实例在栈区,如果包含指针属性则存储在堆区,Protocol Type
如何存储属性?Small Number 通过Existential Container
内联实现,大数存在堆区。如何处理 Copy 呢?
Protocol 大数的 Copy 优化
在出现 Copy 情况时:
Protocol Type Copy Large Number
会将新的Exsitential Container
的 valueBuffer 指向同一个 value 即创建指针引用,但是如果要改变值怎么办?我们知道Struct
值的修改和Class
不同,Copy 是不应该影响原实例的值的。
这里用到了一个技术叫做Indirect Storage With Copy-On-Write
,即优先使用内存指针。通过提高内存指针的使用,来降低堆区内存的初始化。降低内存消耗。在需要修改值的时候,会先检测引用计数检测,如果有大于 1 的引用计数,则开辟新内存,创建新的实例。在对内容进行变更的时候,会开启一块新的内存,伪代码如下:
这样实现的目的:通过多份指针去引用同一份地址的成本远远低于开辟多份堆内存。以下对比图:
堆拷贝
Indirect Storage
Protocol Type 多态总结
支持
Protocol Type
的动态多态(Dynamic Polymorphism
)行为。通过使用
Witness Table
和Existential Container
来实现。对于大数的拷贝可以通过
Indirect Storage
间接存储来进行优化。
说到动态多态Dynamic Polymorphism
,我们就要问了,什么是静态多态Static Polymorphism
,看看下面示例:
这种情况我们就可以用到泛型Generic code
来实现,进行进一步优化。
泛型
我们接下来会讨论泛型属性的存储方式和泛型方法是如何分派的。泛型和Protocol Type
的区别在于:
泛型支持的是静态多态。
每个调用上下文只有一种类型。
查看下面的示例,
foo
和bar
方法是同一种类型。在调用链中会通过类型降级进行类型取代。
对于以下示例:
分析方法foo
和bar
的调用过程:
泛型方法调用的具体实现为:
同一种类型的任何实例,都共享同样的实现,即使用同一个 Protocol Witness Table。
使用 Protocol/Value Witness Table。
每个调用上下文只有一种类型:这里没有使用
Existential Container
, 而是将Protocol/Value Witness Table
作为调用方的额外参数进行传递。变量初始化和方法调用,都使用传入的
VWT
和PWT
来执行。
看到这里,我们并不觉得泛型比Protocol Type
有什么更快的特性,泛型如何更快呢?静态多态前提下可以进行进一步的优化,称为特定泛型优化。
泛型特化
静态多态:在调用栈中只有一种类型。Swift 使用只有一种类型的特点,来进行类型降级取代。
类型降级后,产生特定类型的方法。
为泛型的每个类型创造对应的方法。这时候你可能会问,那每一种类型都产生一个新的方法,代码空间岂不爆炸?
静态多态下进行特定优化
specialization
。因为是静态多态。所以可以进行很强大的优化,比如进行内联实现,并且通过获取上下文来进行更进一步的优化。从而降低方法数量。优化后可以更精确和具体。
例如:
从普通的泛型展开如下,因为要支持所有类型的min
方法,所以需要对泛型类型进行计算,包括初始化地址、内存分配、生命周期管理等。除了对 value 的操作,还要对方法进行操作。这是一个非常复杂庞大的工程。
在确定入参类型时,比如 Int,编译器可以通过泛型特化,进行类型取代(Type Substitute),优化为:
泛型特化specilization
是何时发生的?
在使用特定优化时,调用方需要进行类型推断,这里需要知晓类型的上下文,例如类型的定义和内部方法实现。如果调用方和类型是单独编译的,就无法在调用方推断类型的内部实行,就无法使用特定优化,保证这些代码一起进行编译,这里就用到了whole module optimization
。而whole module optimization
是对于调用方和被调用方的方法在不同文件时,对其进行泛型特化优化的前提。
泛型进一步优化
特定泛型的进一步优化:
在用到多种泛型,且确定泛型类型不会在运行时修改时,就可以对成对泛型的使用进行进一步优化。
优化的方式是将泛型的内存分配由指针指定,变为内存内联,不再有额外的堆初始化消耗。请注意,因为进行了存储内联,已经确定了泛型特定类型的内存分布,泛型的内存内联不能存储不同类型。所以再次强调此种优化只适用于在运行时不会修改泛型类型,即不能同时支持一个方法中包含line
和point
两种类型。
whole module optimization
whole module optimization
是用于 Swift 编译器的优化机制。可以通过-whole-module-optimization
(或 -wmo
)进行打开。在 XCode 8 之后默认打开。 Swift Package Manager
在 release 模式默认使用whole module optimization
。module 是多个文件集合。
没有进行全模块优化
编译器在对源文件进行语法分析之后,会对其进行优化,生成机器码并输出目标文件,之后链接器联合所有的目标文件生成共享库或可执行文件。
whole module optimization
通过跨函数优化,可以进行内联等优化操作,对于泛型,可以通过获取类型的具体实现来进行推断优化,进行类型降级方法内联,删除多余方法等操作。
whole module optimizaiton
全模块优化的优势
编译器掌握所有方法的实现,可以进行内联和泛型特化等优化,通过计算所有方法的引用,移除多余的引用计数操作。
通过知晓所有的非公共方法,如果这写方法没有被使用,就可以对其进行消除。
如何降低编译时间
和全模块优化相反的是文件优化,即对单个文件进行编译。这样的好处在于可以并行执行,并且对于没有修改的文件不会再次编译。缺点在于编译器无法获知全貌,无法进行深度优化。下面我们分析下全模块优化如何避免没修改的文件再次编译。
避免 recompile
编译器内部运行过程分为:语法分析,类型检查,SIL
优化,LLVM
后端处理。
语法分析和类型检查一般很快,SIL
优化执行了重要的 Swift 特定优化,例如泛型特化和方法内联等,该过程大概占用整个编译时间的三分之一。LLVM
后端执行占用了大部分的编译时间,用于运行降级优化和生成代码。
进行全模块优化后,SIL
优化会将模块再次拆分为多个部分,LLVM
后端通过多线程对这些拆分模块进行处理,对于没有修改的部分,不会进行再处理。这样就避免了修改一小部分,整个大模块进行LLVM
后端的再次执行,除此外,使用多线程并行操作也会缩短处理时间。
扩展:Swift 的隐藏“Bug”
Swift 因为方法分派机制问题,所以在设计和优化后,会产生和我们常规理解不太一致的结果,这当然不能算 Bug。但是还是要单独进行说明,避免在开发过程中,因为对机制的掌握不足,造成预期和执行出入导致的问题。
Message dispatch
我们通过上面说明结合Static dispatch VS Dynamic dispatch对方法分派方式有了了解。这里需要对Objective-C
的方法分派方式进行说明。
熟悉 OC 的人都知道,OC 采用了运行时机制使用obj_msgSend
发送消息,runtime 非常的灵活,我们不仅可以对方法调用采用swizzling
,对于对象也可以通过isa-swizzling
来扩展功能,应用场景有我们常用的 hook 和大家熟知的KVO
。
大家在使用 Swift 进行开发时都会问,Swift 是否可以使用 OC 的运行时和消息转发机制呢?答案是可以。
Swift 可以通过关键字dynamic
对方法进行标记,这样就会告诉编译器,此方法使用的是 OC 的运行时机制。
注意:我们常见的关键字
@ObjC
并不会改变 Swift 原有的方法分派机制,关键字@ObjC
的作用只是告诉编译器,该段代码对于 OC 可见。
总结来说,Swift 通过dynamic
关键字的扩展后,一共包含三种方法分派方式:Static dispatch
,Table dispatch
和Message dispatch
。下表为不同的数据结构在不同情况下采取的分派方式:
Swift Dispatch Method
如果在开发过程中,错误的混合了这几种分派方式,就可能出现 Bug,以下我们对这些 Bug 进行分析:
此情况是在子类的 extension 中重载父类方法时,出现和预期不同的行为。
执行以下代码,直接调用没有问题:
间接调用结果和预期不同:
在Base.directProperty
前添加dynamic
关键字就可以获得”this is Sub”的结果。Swift 在extension 文档中说明,不能在 extension 中重载已经存在的方法。
“Extensions can add new functionality to a type, but they cannot override existing functionality.”
会出现警告:Cannot override a non-dynamic class declaration from an extension
。
Extension Override Warning
出现这个问题的原因是,NSObject 的 extension 是使用的Message dispatch
,而Initial Declaration
使用的是Table dispath
(查看上图 Swift Dispatch Method)。extension 重载的方法添加在了Message dispatch
内,没有修改虚函数表,虚函数表内还是父类的方法,故会执行父类方法。想在 extension 重载方法,需要标明dynamic
来使用Message dispatch
。
协议的扩展内实现的方法,无法被遵守类的子类重载:
现在定义一个遵守了协议的类Person
。遵守协议类的子类LoudPerson
:
执行下面代码结果为:
不符合预期的代码:
注意,在子类LoudPerson
中没有出现override
关键字。可以理解为LoudPerson
并没有成功注册Greetable
在Witness table
的方法。所以对于声明为Person
实际为LoudPerson
的实例,会在编译器通过Person
去查找,Person
没有实现协议方法,则不产生Witness table
,sayHi
方法是直接调用的。解决办法是在 base 类内实现协议方法,无需实现也要提供默认方法。或者将基类标记为final
来避免继承。
进一步通过示例去理解:
其他
我们知道 Class extension 使用的是 Static Dispatch:
以上代码会出现错误,提示Declarations in extensions can not be overridden yet
。
总结
影响程序的性能标准有三种:初始化方式, 引用指针和方法分派。
文中对比了两种数据结构:
Struct
和Class
的在不同标准下的性能表现。Swift 相比 OC 和其它语言强化了结构体的能力,所以在了解以上性能表现的前提下,通过利用结构体可以有效提升性能。在此基础上,我们还介绍了功能强大的结构体的类:
Protocol Type
和Generic
。并且介绍了它们如何支持多态以及通过使用有条件限制的泛型如何让程序更快。
参考资料
作者简介
亚男,美团点评 iOS 工程师。2017 年加入美团点评,负责专业版餐饮管家开发,研究编译器原理。目前正积极推动 Swift 组件化建设。
评论