iOS16 和 Xcode14 如何改进 App 大小和运行时性能

2022-06-26 18:24:18 浏览数 (2)

概要:

本文主要介绍苹果在 Xcode14 和 iOS 16 上,如何从编译层面和运行时层面,优化 Swift 和 Objective-C runtime, 来让 app 二进制体积更小,运行更快,启动更快。当你使用 Xcode 14 构建应用程序时,你将会了解到如何访问高效的协议检查,更小消耗的消息发送调用,以及优化后的 ARC。下面我们深入探讨这几个方面的优化。

当我们用 Swift 或者 OC 进行编码时,实际上是跟两个主要的部分打交道。第一,用 Xcode 进行 build 时,其实是 Swift 和 Clang 编译器在工作,而当你 run app 时,很多繁重的工作是由 Swift 和 Objective-C 运行时完成。runtime 作为系统特性,嵌入在苹果各个平台的操作系统中(iOS,watchOS, tvOS, macOS,iPadOS)。编译器在 build 期间无法完成的事情,运行时可以在运行期间完成。

这次苹果的优化没有新的 api, 新的语法,以及新的构建设置(build settings)。我们项目不需要修改代码。

本次具体优化的四个方面是:

  • 高效的协议检查
  • Objective-C 消息发送调用开销更小
  • retain/release 调用开销更小
  • Autorelease elision 自动释放省略更快,更小

下面具体探讨这四个点。

协议检查(Protocol checks)

先来看一个例子,这个例子主要说明在编译期间编译器无法完全判定某个值是否遵循某个协议。

例子定义 CustomLoggable 协议,该协议有一个只读属性 customLogString。同时定义 Event 类型, Event 实现 customLogString 属性的 getter 方法,遵守 CustomLoggable 协议。另外再定义 log 函数用于输出自定义的日志。

代码语言:Swift复制
protocol CustomLoggable {
    var customLogString: String { get }        
}

func log(value: Any) {
    if let value = value as? CustomLoggable {
        debugPrint(value.customLogString)        
    } else {
        ...        
    }       
}

struct Event: CustomLoggable {
    var name: String
    var date: String
    
    var customLogString: String {
        return "(self.name), on (self.date)"    
    }        
}

当调用 log 函数时,由于 log 函数参数 value 类型是 Any,log 函数在每次被调用时,需要检查传入的 value 参数是否遵循 CustomLoggable 协议,这里使用 as? 操作符(你也可以使用 is 操作符来匹配)。

编译器会尽量在 build 期间完成as? CustomLoggable 检查。但是编译器不一定能拿到足够的协议元数据信息来完成检查。这里并不知道每次传入的 Any 类型是哪个确定类型,也就无法确定是否遵循 CustomLoggable协议。所以,这种检查常常发生运行时,借助计算好的协议检查元数据(protocol check metadata),运行库知道这个特殊对象是否符合协议,并且检查成功。

协议检查元数据可以理解为协议的底部组成,比如协议的底部结构有 Metadata, heapObject 等,这些基本构成信息标识不同的协议。

部分元数据是在编译期间构建的,但是很多元数据只能在启动时构建生成,尤其是当使用泛型Generics 时。

当你使用很多协议时,协议检查耗时可能会累积到数百毫秒。在实际 app 中,这个耗时会占用启动时间的一半左右。使用新推出的 Swift runtime, 会提前计算协议元数据,这个计算操作放在 app 可执行文件和启动时使用的任何动态库的 dyld 闭包的一部分。现在在 iOS 16、tvOS 16或 watchOS 9上运行的现有应用程序,也会启用此功能。只要升级系统就能享受该功能。

那为什么能节省启动时间呢?我们来看下 dyld 的启动流程。

苹果现在使用的 dyld 版本是 dyld3 架构。dyld3 中的 dyld closure 发生在 out-of-process 中,也就是应用安装到启动之间的过程。再直接点就是应用下载或者更新时已经对协议检查元数据计算好了,启动应用只用读取 dyld closure, 而非在启动时 runtime 需要重新计算。

dyld2 执行过程是 in-process,也就是在程序进程内执行的,也就是说只有当应用程序被启动的时候,dyld2 才能开始执行任务;

dyld3 则是部分 out-of-process,部分 in-process。例如右图虚线之上的部分是 out-of-process 的,在 App下载安装和版本更新的时候就会执行,其实可以看出苹果把能缓存先在启动前缓存好。这个过程包括:

  • 分析 mach-o Headers
  • 分析依赖的动态库
  • 执行符号查找(需要 Rebase 和 Bind)
  • 把上述结果全部写入缓存

很明显,以前需要在启动之后做的协议元数据计算(closure 之后),现在只用在 app 下载或更新完成就随之完成,所以在启动时,就可以直接从本地系统缓存中读取这些已经计算的元数据,加快启动。这正是协议检查优化为什么让启动加快的原因。而且在纯 Swift 项目中,协议无处不在,这种优化相对更明显。

思考:在手机储存越来越大的环境下,苹果一招空间换时间,把启动分为下载前和下载后,预缓存 runtime 需要计算的数据到本地存储中,然后在启动前的阶段继续略施小计,把协议检查元数据放入启动前,持续优化启动。牺牲的是手机内存,换来的是更强劲的用户体验。后续估计还会在预存储 runtime 计算数据这块有动作。

如果想了解更多 dyld 和启动知识,可以观看:

App Startup Time: Past, Present and Future

总结协议检查优化:

  1. 协议检查元数据花费启动时间
  2. 现在作为 dyld 闭包的一部分进行预运算(原来放在启动后,现在放在启动前)
  3. 运行在 iOS 16,tcOS 16, watchOS 9 的应用都能享受该优化
  4. Swift 优化

消息发送(Message send)

消息发送是针对 Objective-C runtime 的优化。使用 Xcode 14 上的编译器和链接器,可以让 ARM64 上的消息发送调用从 12 字节降低到 8 字节。消息发送无处不在,这个优化可以让二进制文件的代码大小降低 2%左右。使用 Xcode14 会自动启用此功能,即使选用较旧的 iOS 版本作为部署目标(target development)。Xcode 默认会平衡代码大小和性能,但是开发者可以选择使用objc_stubs_small链接器标志来选择仅仅优化代码大小。

下面举个例子来看看苹果是如何优化的:

下面例子是从会议的开始日期创建一个 NSDate 实例。开始先创建一个 NaCalendar,然后使用 NSDateComponents 实例来生成 NSDate 实例, 最后返回该实例。

代码语言:Swift复制
// Method calls using objc_msgSend
Nacalendar *cal = [self makeCalendar];

NSDateComponents *dateComponents = [[NSDateComponents alloc] init];
dateComponents.year = 2022;
dateComponents.month = 2022;
dateComponents.day = 2022;

NSDate *theDate = [cal dateFromComponents: dateComponents];
return theDate;

下面看看编译器为程序生成的代码集合有哪些。由于是 Objective-C 中消息发送机制,每个方法调用都走 _objc_msgSend 方法:

代码语言:Swift复制
// Method calls using objc_msgSend                                    // What the compiler emits
Nacalendar *cal = [self makeCalendar];                                bl _objc_msgSend

NSDateComponents *dateComponents = [[NSDateComponents alloc]          bl _objc_msgSend
                                                       init];         bl _objc_msgSend
dateComponents.year = 2022;                                           bl _objc_msgSend
dateComponents.month = 2022;                                          bl _objc_msgSend
dateComponents.day = 2022;                                            bl _objc_msgSend

NSDate *theDate = [cal dateFromComponents: dateComponents];           bl _objc_msgSend
return theDate;

上面这段代码表面会调用 7 次 objc_msgSend(这里忽略方法里调其他方法),这些都是编译器调用,并不需要开发者去显式调用。其实可以看到这里几乎每一行都要一条指令来调用 objc_msgSend, 即使是对日期的属性访问(属性的 setter 方法)。这是因为在编译期间,我们不知道调用哪个方法,只有 objc 运行时通过方法查找,才知道调用的具体方法。所以我们使用 objc_msgSend 来调用运行时,要求它找到正确的方法。

objc_msgSend 方法是汇编实现的,它的函数定义是

Id objc_msgSend(id self, SEL _cmd, ...) : id 表示当前对象,sel 表示这个对象的所有方法。每个类都有一张方法列表来存储这个类的方法列表,当调用 objc_msgSend 时,就会通过参数去这个对应对应的类的方法列表中查找,先在cache 中查找,然后在方法列表中查找,如果当前类找不到,就往上找父类,如果没有找到,进入消息状态机制。

我们来看看其中一个调用。前面已经提到调用 objc_msgSend 需要指令(例如上图中,使用 bl 汇编指令跳转 _objc_msgSend 函数)。

代码语言:Swift复制
NSDate *theDate = [cal dateFromComponents: dateComponents]; // bl _objc_msgSend

为了告诉 runtime 调用哪个方法,我们需要传一个 selector 给这些 objc_msgSend 调用,这个需要更多的指令来准备 selector。比如上述调用会转化类似这样: objc_msgSend(cal, @selector(dateFromComponents))

当我们查看二进制文件时,这些指令的每一条都会耗费一些空间,在 ARM64上,每个指令占用 4 bytes, 所以每个 objc_msgSend 调用,我们需要3*4=12 bytes。

代码语言:Swift复制
                                                            // adrp x1, [selector "dateFromComponents"]                                                                                                                                                                                                                                                                                                                 
                                                            // ldr  x1, [x1, selector "dateFromComponents"]
NSDate *theDate = [cal dateFromComponents: dateComponents]; // bl _objc_msgSend

可以理解为指令 adrp 和 ldr 是准备 selector, bl 指令执行 _objc_msgSend 函数跳转。

当最后编译器提交时,每条调用占用的大小是 3*4 = 12 字节。

代码语言:Swift复制
// Method calls using objc_msgSend                                    // What the compiler emits
Nacalendar *cal = [self makeCalendar];                                12 bytes

NSDateComponents *dateComponents = [[NSDateComponents alloc]          12 bytes
                                                       init];         12 bytes
dateComponents.year = 2022;                                           12 bytes
dateComponents.month = 2022;                                          12 bytes
dateComponents.day = 2022;                                            12 bytes

NSDate *theDate = [cal dateFromComponents: dateComponents];           12 bytes
return theDate;

正如前面看到的,其中8个字节用于准备 selector, 前 8 个字节是:

代码语言:Swift复制
adrp x1, [selector "dateFromComponents"]                                                                                                                                                                                                                                                                                                                 
ldr x1, [x1, selector "dateFromComponents"]
// bl _objc_msgSend

并且,对于任何给定的 selector, 前 8 个字节代码都相同(用于准备 objc_msgSend 指令跳转)。那么我们能不能优化这些重复的8个字节指令呢?答案是可以的,这就是本次优化的点。因为这里存在相同的代码,我们可以考虑共享它,并且只在每个 selector 中发出它一次,而不是每次发送消息时都生成这段指令代码。所以我们可以把这部分相同代码提取出来,放到一个小助手函数中(helper function), 并调用该函数。通过使用同一 selector 进行多次调用(通过传递参数不同,内部指令是相同的,现在封装成一个存根函数,以前是散落在各个 _objc_msgSend 调用处),我们可以保存所有这些指令字节。

代码语言:Swift复制
adrp x1, [selector "dateFromComponents"]                                                                                                                                                                                                                                                                                                                 
ldr x1, [x1, selector "dateFromComponents"]
bl _objc_msgSend$dateFromComponents

// Selector stub
_objc_msgSend$dateFromComponents:
adrp x1, [selector "dateFromComponents"]                                                                                                                                                                                                                                                                                                                 
ldr  x1, [x1, selector "dateFromComponents"]
b    _objc_msgSend

我们把此助手函数称为选择器存根函数(selector stub)。不过我们仍然需要调用真正的 objc_msgSend函数。

同样,objc_msgSend有另一个不同的间接方式来加载函数本身的地址并调用它。

代码语言:Swift复制
adrp x1, [selector "dateFromComponents"]                                                                                                                                                                                                                                                                                                                 
ldr x1, [x1, selector "dateFromComponents"]
b1 _objc_msgSend$dateFromComponents

// Selector stub
_objc_msgSend$dateFromComponents:
adrp x1, [selector "dateFromComponents"]                                                                                                                                                                                                                                                                                                                 
ldr  x1, [x1, selector "dateFromComponents"]
b    _objc_msgSend

// Call stub
_objc_msgSend:
adrp ...
ldr  ...
br   ...

注意,这里是可以选择所需模式的地方。前面提到了要平衡性能还是只考虑包大小。我们可以把这两个小存根函数分开,如上面代码。我们可以共享最多的代码,并让这些函数尽可能小(函数最小功能化)。但带来的负面影响是这将连续进行两次调用(_objc_msgSend$dateFromComponents 和 _objc_msgSend),这对性能来说并不理想。

代码语言:Swift复制
// Seperate selector and symbol stubs
// Optimize for Size
// Enable using -Wl, -objc_stubs_small
bl _objc_msgSend$dateFromComponents

// Selector stub
_objc_msgSend$dateFromComponents:
adrp x1, [selector "dateFromComponents"]                                                                                                                                                                                                                                                                                                                 
ldr  x1, [x1, selector "dateFromComponents"]
b    _objc_msgSend

// Call stub
_objc_msgSend:
adrp ...
ldr  ...
br   ...

因此,我们可以使用下面这种方法来提升这一点。我们可以把前面调用的两个存根函数合并成一个,这样,我们可以使代码更紧凑,不需要那么多调用。

代码语言:Swift复制
// Combined selector and symbol stubs
// balanced Size/Performance
// Enabled by default
bl _objc_msgSend$dateFromComponents

// Selector stub
_objc_msgSend$dateFromComponents:
adrp x1, [selector "dateFromComponents"]                                                                                                                                                                                                                                                                                                                 
ldr  x1, [x1, selector "dateFromComponents"]
adrp ...
ldr  ...
br   ...

所以这里有2个选择。你可以选择是否仅针对大小进行优化,来获取最大的包体积节省。在 Xcode14 可以用 _objc_stubs_small 链接器标识启动这个功能。或者也可以使用默认代码生成的方式,来提供大小优势,同时也保持最佳性能。除非你的 app 体积受到严重限制,否则不要轻易开启第一种链接优化。

两种选择模式,左边是开启 _objc_stubs_small 链接选项,右边未开启两种选择模式,左边是开启 _objc_stubs_small 链接选项,右边未开启

这就是使用存根函数让消息发送开销更小。

总结消息发送优化:

  1. Meesage send 占用从 12 bytes 降低到 8 bytes, 使用消息存根函数封装相同指令,消除冗余指令调用
  2. 二进制下降约 2%
  3. 使用 Xcode 14 构建
    1. 默认平衡性能和体积大小
    2. 如果不考虑性能,仅仅考虑体积大小(使用 -Wl, --objc_stubs_small

思考: App Clips 今年提升到了 15M 的限制,对于有体积要求的开发者,是否可以为 Clips target 开启该链接选项?我想一般场景下开发者不会开启链接优化

Retain and release

这小节优化是让 retain/release 调用开销更小。在 Xcode14 编译器上,retain/release 在 ARM64 上调用从 8 个字节降到 4 个字节。就像上一章节中消息发送一样,retain/release 也是无处不在。这个优化可以让二进制大小降低 2%。迁移到iOS 16、tvOS 16或watchOS 9的部署目标时,会自动获得该支持。

还是看上面这个例子。对于 ARC,我们也会遇到编译器插入的许多 retain/release 函数调用。在高代码层面(比如业务层面,应用层),当我们复制指向某个对象的指针时,需要增加对象的引用计数,让该对象保持活跃的引用状态(copy方法就会做这个事)。在这个例子中,变量 cal、dateComponent和theDate就是这种情况。内部通过使用 objc_retain 调用运行时来达到保持引用的效果,当变量超过了它的作用域,又需要使用 objc_release 来减小引用计数。当然,ARC这部分工作基本由编译器完成。但是就算编译器再强大,我们仍然经常需要这些调用,比如在这个例子中,我们最后需要释放 cal 和 dateComponents 的本地副本.

代码语言:Swift复制
// Retain/release calls inserted by ARC
Nacalendar *cal = [self makeCalendar]; // bl    _objc_retain

NSDateComponents *dateComponents = [[NSDateComponents alloc] init]; // bl    _objc_retain
dateComponents.year = 2022; 
dateComponents.month = 2022;
dateComponents.day = 2022;

NSDate *theDate = [cal dateFromComponents: dateComponents]; // bl    _objc_retain
return theDate;
// bl    _objc_release 
// bl    _objc_release 
// bl    _objc_release 

在上面例子的底层代码中,这些 objc_retain/release 函数只是普通的 C 函数,他们带一个参数,这个参数就是将要释放的对象。因此,对于 ARC,编译器会插入对这些 C 函数的调用,并传递适当的对象指针。这些调用也必须遵守由平台应用程序二进制接口(Application binary interface, ABI) 定义的 C 调用约定。具体来说,我们需要更多代码来执行这些调用,才能让指针传递到正确的寄存器里。所以最后我们给出了一些额外的“move”指令来做这个事,这恰恰是下面新优化的用武之地。

代码语言:Swift复制
// Retain/release calls inserted by ARC
Nacalendar *cal = [self makeCalendar]; // mov  x19, <cal>

NSDateComponents *dateComponents = [[NSDateComponents alloc] init]; // mov x20, <dateComponents>
dateComponents.year = 2022; 
dateComponents.month = 2022;
dateComponents.day = 2022;

NSDate *theDate = [cal dateFromComponents: dateComponents];

objc_release(dateComponents); 
                              // mov  x0, x20                                                                                                                                                                              
                              // bl    _objc_release
objc_release(cal);            // mov  x0, x19
                              // bl    _objc_release

return theDate;

通过自定义调用重新约定 retain/release 接口,我们可以根据对象指针的位置,适当的使用正确的变量,这样就可以不用移动它。简单的说,就是修改了底层 ABI。

具体的说,原来 objc_release 操作需要先执行 mov 把副本地址存到寄存器 x0,然后在执行 bl 指令跳转执行 _objc_release 函数,占用 4*2 个字节;现在修改 ABI 之后,我们去掉 mov 指令调用,重新约定在 objc_release 执行时直接执行新指令跳转 _objc_release_x20,在底层 ABI 里去做 mov 指令调用,现在仅占用 4*1 个字节;

代码语言:Swift复制
// Retain/release calls inserted by ARC
Nacalendar *cal = [self makeCalendar]; // mov  x19, <cal>

NSDateComponents *dateComponents = [[NSDateComponents alloc] init]; // mov x20, <dateComponents>
dateComponents.year = 2022; 
dateComponents.month = 2022;
dateComponents.day = 2022;

NSDate *theDate = [cal dateFromComponents: dateComponents];

objc_release(dateComponents); 
                              // mov  x0, x20                                                                                                                                                                              
                              // bl    _objc_release_x20
objc_release(cal);            // mov  x0, x19
                              // bl   _objc_release_x19

return theDate;

这意味着,我们为所有这些调用去掉一大推冗余代码,虽然对于这些微不足道的小指令来说,似乎不算多,但在整个应用层面上,这个作用是巨大的。这就是降低 retain/release 操作成本的方法。

总结 retain/release 优化:

  1. Retain and release 调用占用从 8 字节降低到 4 字节(具体来说是 release 调用, 但这是方法对,缺一不可)
  2. 二进制降低约 2% 或更多
  3. 迁移到iOS 16、tvOS 16或watchOS 9的部署目标,将会获得该优化
  4. ABI 接口修改,去除冗余 mov 指令调用,下层到 ABI。由于 ABI 是内嵌系统,这里新增 mov 指令占用可以忽略不计

思考:苹果为用户体验修改底层 ABI,下了血本!

Autorelease elision(自动释放省略)

什么是 Autorelease elision? 看一个例子:自动释放返回值(Return Value Autoreleases)。例子中,创建一个临时对象(theDate),并将其返回给调用方(event)。那么它是如何工作的。getWWDCDate() 方法中返回临时的 theDate,然后调用完成(返回 theDate 之后,getWWDCDate 就调用完成)。这时调用方(event)将其保存到自己的变量中(theWWDCDate 中)。

代码语言:Swift复制
// Return Value Autoreleases 

theWWDCDate = [event getWWDCDate];

-(NSDate*)getWWDCDate {
    ...
    return theDate;
}

那么这是如何与 ARC 一起工作的?ARC 在调用者(event getWWDCDate)中插入 retain, 在被调用函数中插入 release. 当我们返回临时对象 theDate 时,我们首先需要在被调用函数中 release 它,因为它超过了作用域。但是我们不能这么做,因为到现在它没有其他任何引用。如果我们在这里真的 release theDate(也就是执行 theDate release),theDate 会在函数 return 之前被销毁,这里调用方拿到的结果将会出现异常(这里会出现什么异常,可以尝试下代码)。

代码语言:Swift复制
// Return Value Autoreleases 

theWWDCDate = [[event getWWDCDate] retain];

-(NSDate*)getWWDCDate {
    ...
    return [theDate release]; // ❌,会出现异常
}

因此,为了解决上述问题,需要使用一个特殊的约定用来返回这个临时返回值。我们在返回之前 autorelease 它,这样调用者能够 retain 它。autorelease 在这里保证在调用方可以正常返回该值,而不被提前释放,延长释放生命周期)你之前可能看到过 autorelease 和 autoreleasePools:这只是一种将 release 操作推迟到稍后某个时间的方法。

代码语言:Swift复制
// Return Value Autoreleases 

theWWDCDate = [[event getWWDCDate] retain];

-(NSDate*)getWWDCDate {
    ...
    return [theDate autorelease]; 
}

Runtime 并不能真正保证 release 的时间,但只要它不在 return 的时候释放(不在 return 的时候被释放,而是在return之后的某个时间点被释放,还是保证在 return 之后再 release),就好办,因为它允许我们先返回这个临时对象。但是现在的问题是执行自动释放会带来一些开销。这就是 Autorelease elision 的作用所在(减少这些开销)。

代码语言:Swift复制
// Return Value Autoreleases                     // What the compiler emits
                                                bl    _getWWDCDate
theWWDCDate = [[event getWWDCDate] retain];     mov   x29, x29
                                                bl    _objc_retainAutoreleasedReturnValue
-(NSDate*)getWWDCDate {
    ...
    return [theDate autorelease];               b    _objc_autoreleaseReturnValue
}

为了理解他是如何工作的,让我们看看程序集并回顾这个返回。当我们调用 autorelease 时,它会进入 objc runtime(跳转调用_objc_autoreleaseReturnValue )。runtime 尝试识别发生了什么:我们正在返回一个 autoreleased value.(流程:业务代码执行return theDate -> 编译器插入 -> autorelease -> theDate autorelease 进入 runtime)

代码语言:Swift复制
// What the compiler emits
 bl    _getWWDCDate 
 mov   x29, x29
 bl    _objc_retainAutoreleasedReturnValue

 b    _objc_autoreleaseReturnValue   // autorelease -> runtime -> _objc_autoreleaseReturnValue

为了解决这个问题(解决当前的问题:返回 autorelease 的值,编译器再后面怎么做到自动释放),编译器会发出一个特殊的标记(mov x29, x29,这个值的地址),这个标记在其他情况下永远不会被使用(唯一的地址)。这个标记告诉 runtime, 这里符合 autorelease elision(自动释放省略)的条件,紧随其后操作的是 retain(_objc_retainAutoreleasedReturnValue), 稍后会执行它。但现在,我们仍在自动释放过程中,当我们这样做时,运行时会加载特殊标记指令作为二进制数据流(0xAA1D03FD),并对其进行比较,以查看是否是它所期望的特殊标记值,如果是,这意味着编译器告诉runtime, 我们将返回一个将立即被持有(retain) 的临时变量(theWWDCDate),这让我们可以省略或删除正在匹配的 autorelease 和 retain 调用。这就是 autorelease elision.

代码语言:Swift复制
// What the compiler emits
 bl    _getWWDCDate 
 mov   x29, x29   // ->  0xAA1D03FD
 bl    _objc_retainAutoreleasedReturnValue

 b    _objc_autoreleaseReturnValue   // autorelease -> runtime -> _objc_autoreleaseReturnValue

但是,这个操作也会带来开销:以二进制数据流形式加载代码并不是非常常见的事,因此在 CPU 上也不是最优的。再来回顾下上面的返回序列。这次使用新的方法。从 autorelease 开始,仍然还是会进入 Objective-C 的 runtime.在这个点: theDate autorelease -> _objc_autoreleaseReturnValue这里其实已经有了有用的信息:返回地址。( mov x29, x29)它告诉我们在函数完成执行后需要返回到哪(bl指令执行会返回继续往下执行)。所以我们可以继续跟踪它,谢天谢地,获取返回地址成本很低,因为该地址是一个指针,我们可以先把它存到一边(后面需要做对比)。

代码语言:Swift复制
// Return Value Autoreleases                     // What the compiler emits
                                                bl    _getWWDCDate
theWWDCDate = [[event getWWDCDate] retain];     mov   x29, x29 // 返回地址
                                                bl    _objc_retainAutoreleasedReturnValue
-(NSDate*)getWWDCDate {
    ...
    return [theDate autorelease];               b    _objc_autoreleaseReturnValue
}

接下来离开 runtime autorelease 调用。我们返回调用者(现在的调用者是:event getWWDCDate,而非 event),当执行 retain 操作时,我们又重新进入 runtime(跳转执行 _objc_retainAutoreleasedReturnValue 函数).

代码语言:Swift复制
// Return Value Autoreleases                     // What the compiler emits
                                                bl    _getWWDCDate
theWWDCDate = [[event getWWDCDate] retain];     mov   x29, x29 // <-----
                                                bl    _objc_retainAutoreleasedReturnValue
-(NSDate*)getWWDCDate {
    ...
    return [theDate autorelease];               b    _objc_autoreleaseReturnValue
}

此时,我们可以查看我们所在的位置,并获得指向当前返回地址的指针,运行时会把当前的指针和在 autorelease 步骤保存的指针进行比较(绿色和黄色的地址指针做对比)。

代码语言:Swift复制
 // What the compiler emits
 bl    _getWWDCDate
 mov   x29, x29 // <----- 被调用者中的地址
 bl    _objc_retainAutoreleasedReturnValue    <-----我们所在的位置,并获得指向当前返回地址的指针
 
 
 b    _objc_autoreleaseReturnValue

因为仅仅是比较两个指针,这个代价相当小。我们不需要进行昂贵的内存访问。一旦对比操作完成,我们知道可以省略 autorelease/retain 方法对调用,这就是提高性能的地方。

最重要的是,现在我们不必将这个特殊的标记指令作为二进制数据流进行对比,我们此时不需要它,可以删除它。这让我们节省了一些代码大小。这就是我们如何让 autorelease elision 更快更小的原因。

代码语言:Swift复制
 // What the compiler emits
 
 bl    _getWWDCDate
 mov   x29, x29
 bl    _objc_retainAutoreleasedReturnValue 
 
 
 b    _objc_autoreleaseReturnValue

总结自动释放省略优化:

  1. 更快
    1. 把自动释放省略流程中对内存地址的比对修改为对指向该内存地址的指针比对,减少内存地址访问
    2. 已存在的 app 升级到新的操作系统可享受该优化 iOS 16、tvOS 16或watchOS 9
  2. 更小的二进制
    1. 部署目标迁移新的系统
    2. 移除自动释放省略中的 mov 指令,大小降低 4 bytes, 二进制大小降低预计 2%

参考:

session:Improve app size and runtime performance

0 人点赞