WWDC2022召开也一段时间了,里面宣布XCode14版本和iOS16系统即将发布,除开一些新特性外,对现有APP的包体积大小和运行时性能也有了一定优化。
当我们使用Swift或Objective-C编写代码时,总是在与两个主要组件进行交互。首先使用Xcode构建,这会使用到Swift和Clang的编译器。但当我们运行应用程序时,很多繁重的工作都是在Swift和Objective-C运行时完成的。
运行时嵌入到我们所有平台的操作系统中。编译器在构建时无法完成的事情,运行时在运行时完成。我们将看看在编译器和运行时方面所做的一些改进。有点不寻常的是没有新的API、语言更改或新的生成设置。我们不必更改代码,因此所有这些改进对开发人员来说都是透明的。深入查看后我们将看到四个改进。
1. Swift中的协议检查更加有效
2. 使Objective-C messageSend调用占用更少开销
3. 保留和释放占用更少开销
4. 自动释放省略更快占用更少开销
- 让我们从Swift中的协议检查开始。
这里我们有一个自定义日志协议。它有一个只读的计算属性customLogString,我们可以在日志函数中使用它,该函数对CustomLoggable对象有着特殊处理。稍后,我们将使用名称定义一个事件类型和日期字段。并且通过定义customLogString这个属性的getter方法我们将遵循CustomLoggable这个Protocol。
让我们传递Event这个对象到我们“log”这个函数。当我们执行此代码时,“log”函数需要检查我们传递的值是否遵循协议。它使用“as”操作符来实现这一点。你可能还看到了“is”操作符。
在有可能的情况下,此检查将能够在构建时处于编译器中进行优化。然而,我们并不总是有足够的信息。因此,这通常需要在运行时发生,借助于我们之前计算的协议检查元数据。使用此元数据,Runtime知道此特定对象是否确实符合协议,并且检成功检查。
部分元数据是在编译时构建的,但很多元数据只能在启动时构建,尤其是在使用泛型时。
当你使用许多协议时,这可能会累积到数百毫秒。在现实世界中的应用程序上,我们已经看到这占用了多达一半的启动时间。有了新的Swift运行时,我们现在可以提前对其进行预计算,作为dyld启动闭包的一部分为app可执行文件和它使用的任何动态库在启动的时候做的优化。最重要的是,即使是在iOS 16、tvOS 16或watchOS 9上运行的现有应用程序,也会启用此功能。
- 接下来讨论消息发送。
使用Xcode 14中的新编译器和链接器,我们将使ARM64上的消息发送调用从12字节减少到8字节。正如我们稍后将看到的,消息发送确实无处不在,所以这加起来,我们已经看到二进制文件的代码大小有2%的提升。使用Xcode 14构建时会自动启用此功能,即使您使用较旧的OS版本作为部署目标。默认情况下,它会平衡大小和性能,但您可以选择仅针对大小进行优化,使用objc_stubs_small这个linker flag。
现在让我们看看是什么发生了变化。让我们从一个例子开始。
在这里,我们试图为会议的开始日期确定一个NSDate。我们首先制作一个NSCalendar,然后填写NSDateComponents,然后用它制作一个日期,最后返回它。现在让我们看看编译器生成的汇编指令。
汇编的细节并不是特别重要。编译器的开发人员整天盯着它看,这样我们也就没必要特别去注意。重要的是,这里的几乎每一行最终都需要一条指令来调用objc_msgSend,即使像我们对日期组件的属性进行访问也是如此。这是因为在编译时,我们不知道调用哪个方法,只有objc运行时才知道。因此,我们使用objc_msgSend调用运行时,要求它找到正确的方法。
让我们关注其中一个调用。我们已经提到了调用objc_msgSend的指令。但是还会有更多指令要发出。要告诉运行时调用哪个方法,我们必须向这些objc_msgSend调用传递选择器。这同时也需要更多的指令来准备选择器。当我们查看二进制文件时,这些指令中的每一条都会占用一点空间。在ARM64上,每个指令为4个字节。因此,所有这些objc_msgSend调用,我们已经使用了了12个字节,并且我们需要为每一个调用像这样的调用使用这么多字节;这加起来要占用不少的空间。让我们看看能做些什么来改善。
现在,正如我们之前看到的,其中8个字节专门用于准备选择器。有趣的是,对于任何给定的选择器,它总是相同的代码。这就是我们可以进行优化的地方。由于这始终是相同的代码,我们可以共享它,并且每个选择器只发出一次而不是每次发送消息时都去做一遍相同的操作。我们可以将其取出并放入一个小助手函数中,然后通过调用该函数代替之前的操作。通过使用同一选择器进行多次调用,我们可以保存所有这些指令所需要占用的字节数。
我们将此助手函数称为“选择器存根”不过,我们仍然需要调用真正的objc_msgSend函数,所以我们继续讨论这个问题。同样,它有另一个不同的间接方式来加载函数本身的地址并调用它。细节并不重要,但重要的是我们还需要几个字节的代码来完成这项工作。
正如前面提到的,这是我们可以选择所需模式的地方。我们可以将这两个小存根函数分开,就像在这里所做的那样。这样可以共享最多的代码,并使这些函数尽可能小。
但不幸的是,这最终将进行两次调用,这对于程序性能来说并不是那么理想。因此,我们可以使用另一个版本进一步改进这一点。我们可以将我们创建的这两个存根函数合并为一个。这样,我们可以使代码更紧密地联系在一起,不需要那么多调用。
这是两种选择。你可以选择是否仅针对文件大小进行优化,并获得最大的可用大小节约。使用-objc_ stubs_small linker flag启用该功能,也可以选择即提供文件大小优化的同时又保持最佳性能的代码生成。除非说我们文件的尺寸受到严重限制的情况下,否则是建议使用这个选项,这也为什么它在xcode里面是默认设置的。也就是使用存根发送的较小消息。
保留和释放的优化(retain & release)
编译器优化的另一项改进是降低retain/release的成本。使用Xcode 14中的新编译器,retain/release调用现在在ARM64结构下调用消耗将从原先的8个字节减少至4个字节。
正如我们将看到的,就像消息发送一样,retain/release也无处不在。这样一来,我们看到二进制文件的代码大小有了2%的提升。现在,与消息发送存根不同的是这需要runtime的支持,因此当你迁移项目到iOS 16、tvOS 16或watchOS 9作为部署平台时,你将自动获得此支持。
现在让我们看看发生了什么变化。让我们回到我们的例子。我们讨论了msgSend调用,但对于自动引用计数或ARC,我们最终也会遇到编译器插入的许多retain/release调用。
每当我们复制指向对象的指针时,我们都需要增加其保留计数以保持其存在。在这里,我们的变量cal、dateComponent和theDate都会发生这种情况。我们通过使用objc_retain调用runtime来实现这一点。
当变量超出区间范围时,我们需要使用objc_release减少retain计数。当然,ARC的一部分好处是由于编译器的魔力,它消除了许多这样的调用,使它们保持在最低限度的存在。稍后我们将讨论其中一个技巧。但即使有这么多方式,我们仍然经常需要这些调用。在本例中,我们最终需要释放日历和日期组件的本地副本。
在底层,这些objc_retain/release函数只是取一个即要释放的对象当作参数的普通C函数,。因此对于ARC来讲,编译器会插入对这些C函数的调用并传递适当的对象指针。因此,这些调用必须遵守由平台应用程序二进制接口(ABI)定义的C调用约定。
具体来讲,这意味着我们需要更多的代码来执行这些调用,以便将指针传递到正确的寄存器中。
所以我们最后会给出一些额外的“移动”指令。这也正是新优化的用武之地。通过一个自定义调用约定的特殊retain/release调用,我们可以根据对象指针的位置适时地使用正确的变量并且不需要移动它。这意味着最终为所有这些调用去掉了一堆冗余代码。虽然对于这些小指令对于我们整个app来讲是微不足道的,但是它们最终会堆积起来,那还是有不少的代码的。这就是新编译器让retain/release开销成本更小的方法。
更快的自动释放
通过对objc运行时的更改,可以使它更快地对对象进行自动释放。当在新的操作系统版本上运行现有的应用程序时,这个优化则会自动发生。在此基础上,通过对编译器进行额外的更改,我们还将使代码变得更小。当把项目构建目标迁移到iOS 16、tvOS 16或watchOS 9作为部署目标时,将自动获得这种大小优化的优势。
首先什么是autorelease elision?让我们回到我们的例子。之前提到,ARC已经为我们提供了很多编译器魔法来优化retain和release。所以让我们来关注一个案例:自动释放返回值。在本例中,我们创建了一个临时对象,并将其返回给调用者。让我们看看它是如何工作的。所以我们有了临时的theDate,我们返回它并且在调用完成后调用者将其保存到自己的变量中。
让我们看看ARC是怎么工作的。ARC在调用者中插入retain,在被调用函数中插入release。在这里,当我们返回临时对象时,我们需要首先在函数中释放它,因为它超出了范围。但我们现在还不能这么做,因为它还没有任何引用。如果我们真的释放了它,它会在我们返回之前就被摧毁,这是不对的。
因此需要使用一个特殊的约定去返回临时变量。我们在返回之前自动释放临时变量以便调用方可以保留它。你可能以前见过autorelease和autoreleasepools:那只是一种将推迟释放到稍后某个时间的方法。Runtime并不会在对象释放的时候做任何保障,但是在这里就不对了,这里我们只是要便捷的返回这个临时变量。但是现在是有一些而外的开支在做自动释放这件事的。
这也正是autorelease elision开始起作用的地方。
为了理解它是如何工作的,让我们看看汇编并追溯这个返回值。当我们调用autorelease时,它会进入objc运行时,这也是有趣的事情开始发生的地方。运行时试图识别发生了什么:我们正在返回一个自动释放的值。为了解决这个问题,编译器会发出一个特殊的标记,我们在其他情况下永远不会自己使用的一种。
它告诉运行时这个变量符合autorelease elision的条件,它在过一段时间之后就会接收到一个retain的指令。但现在我们仍在自动释放中,当我们这样做时,运行时会加载特殊标记指令作为数据,并对其进行比较,以查看它是否是它所期望的特殊标记值。如果是,这意味着编译器告诉运行时,我们将返回一个将立即retain的临时变量。这样则可以省略或移除匹配的autorelease和retain调用,这也就是autorelease elision是怎么工作的。
当然这也不是免费的。以数据形式加载代码并不是非常常见的事情,因此在CPU执行效率上也不是最优的。
还有更好的做法。让我们再次回溯到返回序列,这次使用新的方法。让我们从自动释放开始。这仍然会进入Objective-C运行时。在这个时候我们实际上已经有了有价值的信息:接收返回值的地址。
它告诉我们在该函数完成执行后临时变量需要返回到哪里。所以我们可以跟踪它而且获得接收返回值的地址非常简单,并且这样做对机器性能的开销很小。它只是一个指针,我们这个时候可以先将它存储起来。
等下我们将留下runtime autorelease的调用,然后将返回临时变量给调用者,并且在执行retain的时候我们将重新进入runtime。这就是新的魔法发生的地方。在这个时候,我们可以查看我们所在的位置,并获得指向当前返回地址的指针。在runtime里面,我们可以将执行retain时刚得到的指针与之前执行自动释放时保存的指针进行比较。由于只是简单的比较两个指针,也不需要进行内存的访问,不会占用机器很多性能。如果比较成功,我们知道可以省略autorelease/retain这一对应代码,并且可以提高一些性能。
最重要的是,现在我们不再需要将这个特殊的标记指令作为数据进行比较,所以也可以删除它了。这也让我们节省了一些代码大小。这就是如何使自动释放省略更快更小的原因。
总结
由于runtimes的改进,当我们执行app在新的OS下时,swift protocol的检查会更高效,每次我们做自动释放省略执行也会更快。
由于Xcode14新的编译器和连接器的存在以及消息发送存根的存在,如果我们重新编译我们的app将获得大约2%大小的优化;而如果我们将app的deployment target设置到iOS 16,tvOS 16或者watchOS 9平台的时候,由于更小的自动释放省略序列我们还将再获得2%,甚至更多的文件大小优化,由于更小的retain\release指令产生.