Uber如何处理大型iOS应用程序

2021-02-27 05:03:34

Uber的适用于Rider,Driver和Eats的iOS移动应用程序很大。选择Swift作为我们的主要编程语言,快速的开发环境和功能添加,分层的软件及其依赖项以及静态链接的平台库会导致大型应用程序二进制文件。减小应用程序大小对于我们的客户体验至关重要。此外,苹果公司对应用程序下载大小的限制禁止通过空中下载大量应用程序。

应用程序下载大小的限制意味着初次使用的用户无法在最需要的时候下载该应用程序,而Uber无法在没有Wi-Fi的情况下向现有用户提供功能,促销或安全更新。我们在Uber Rider应用程序大小和客户参与度之间建立了关联-当应用程序大小超过下载大小限制时,将导致应用程序安装量减少10%,注册量减少12%,首次购买量减少20%预订,导致收入损失。在过去的三年中,Uber Rider应用程序的大小经常接近App Store的空中下载限制,因此,将其限制在明显的优先位置。

在下一篇文章中,我们将介绍如何使用高级编译器技术将Uber的iOS Rider应用程序的代码大小减少23%。本文讨论的想法在Uber Driver和Uber Eats iOS应用程序中分别节省了17%和19%的代码大小。

将大小调整到App Store下载限制以内-越小越好

选择减小尺寸的优化,以随着应用程序的发展在可预见的未来不断产生影响

保持透明,这样应用程序开发人员就不会被要求将精力转移到减小尺寸上

Uber Rider应用程序是使用Swift和Objective-C编程语言混合编写的。该应用程序包含数百万行代码,其中绝大部分是Swift。源代码包含大约500个swift模块,其中包括第三方库。 Driver和Rider应用程序具有相似但略有不同的特征。我们将以Uber Rider应用程序为例。

图1描述了此处所述工作之前,iOS应用程序(包括Uber Rider应用程序)使用的默认构建管道。工作流程涉及编译模块中的所有源文件以生成ARM64目标文件。几个这样的模块是独立编译的。由于Uber Rider应用程序是多语言的,因此它还可以将Objective-C文件分别编译为目标文件。所有目标文件(包括任何预构建的二进制文件)都通过系统链接器(ld64)链接到最终的二进制文件中。该应用程序本身可能会打包其他资源。使用Swift编译器中的整个模块优化来编译单个模块,该编译器在模块内执行过程间优化。我们使用-Osize标志来生成大小优化的二进制文件。

我们采用了几种禁止规则,以防止二进制大小爆炸:包括避免大值类型(例如struct和enum),将访问控制级别限制到最低(例如,尽可能避免公共和开放访问),避免过度使用泛型,并使用最终属性。我们采用了几种内部静态分析工具来删除死代码和资源,并禁用反射元数据以减小二进制大小。

尽管这些技术共同降低了应用程序的大小,但我们快速增长的代码库在整体上超过了它们。跨模块优化的机会仍然未被开发,因此是本文的重点。

机器指令超过了Uber Rider应用程序二进制文件的75%。我们系统地研究了这些机器指令的模式,发现大量机器指令序列经常重复。

单指令副本在任何二进制文件中都很丰富,但是不能在诸如ARM64之类的固定指令宽度体系结构(RISC)上被有利可图地替换。替换指令克隆的成本高于保留原始指令的成本。

另一方面,长度为两个或两个以上的指令模式可以被有利地“概述”。也就是说,我们可以将序列替换为较短的序列,通常将单个调用或无条件分支指令替换为单个出现的模式。这需要将控制权转移到概述的指令序列,该指令序列可有效执行原始指令序列,然后在紧跟原始序列的指令处继续执行。

图2显示了在Uber Rider应用程序中发现的高度重复的指令序列的示例。该序列首先通过与零寄存器$ xzr的按位“或”操作将通用CPU寄存器$ x20的内容复制到寄存器$ x0中。下一条指令调用“ swift_release”函数。 $ x20中的值是一个需要释放其引用的对象。

这两个指令可以用对新创建的outlined_function的调用指令代替; outlined_function执行前缀指令,最后尾调用原始函数swift_release。

如果这样的2指令模式出现1百万次(对于32位大小的指令,则为800万字节),则转换会将这些2指令序列削减为1指令(总共为400万字节) ),并在outlined_function中增加了2条指令-节省了近50%。这种以电话或回程指令结尾的模式是最常见的,占所有重复候选人的67%,我们可以在Uber Rider应用程序中对其进行有利可图的编辑。

图3绘制了覆盖序列长度(红线)的机器代码序列(蓝线)中的重复频率。 x轴表示每个模式的唯一ID,其中最高的出现模式被赋予id 1,下一个最高的模式被赋予id 2,依此类推。它是一个对数-对数图。某些模式会非常频繁地重复,但是模式的尾部也很长,每个模式逐渐重复的次数更少,因此服从幂律(y = ax b),置信度为99.4%。

图4显示了与图3相同的红线,但是x轴不在对数刻度上。红线显示重复的分形图案-经常出现的图案具有很短的序列长度(左侧);随着重复频率的降低,序列长度的多样性增加(右侧)。 X轴上从一个尖峰到下一个尖峰的数据点代表一组重复相同次数的模式。在每个簇中,几乎没有冗长的序列,但是随着序列长度的减少,出现越来越多的模式。最后,将左侧的一个群集(较高的重复频率)与右侧的另一个群集(较低的重复频率)进行比较,很明显,随着重复频率的降低,模式的多样性(水平步长)和序列长度(尖峰的高度)增加。

虽然幂定律和分形模式已在几种物理,生物和人为现象中展现出来,但据我们所知,我们是第一个在计算机可执行代码的机器代码序列中识别它们的存在的设备。据推测,机器代码是人对计算机指令的一种表达,并且众所周知,所有人类语言在词频上都显示出幂律。

图5概述了下一个最有利可图的模式(x轴),得出了可能的累积尺寸节省。需要勾勒出许多图案(> 10 5),以提取大部分(> 90%)可能的尺寸增益。人们不能“硬编码”一些模式,并希望获得重大收益。

与引用计数和内存分配相关的高级语言和运行时功能是最频繁重复模式的常见原因。

清单1-6中最常见的几种模式都与语言和运行时规范有关,即Swift和Objective-C的引用计数和内存分配。

由于Swift和Objective-C都对引用进行计数,因此增加引用(swift_retain和objc_retain)和减少引用(swift_release和objc_release)的指令非常频繁。以清单1为例:通过对零寄存器$ xzr执行按位或运算(ORR指令),第一条指令将寄存器$ x20中的值移动到寄存器$ x0中。第二条指令(BL)调用swift_release,这会减少参数$ x0中保存的堆对象的引用计数。在此示例中,指向堆对象的指针最初位于$ x20(源寄存器)中,但必须将其移至$ x0(目标寄存器)中,以满足调用约定,该约定要求$ x0​​中的第一个参数。

寄存器分配的选择可能导致许多重复的模式-例如,清单1和2仅在其源寄存器上有所不同。在整个程序二进制文件中,这些模式可能发生多次。函数调用指令有许多可能的目标,因此每个目标都有助于唯一的2指令模式。最后,被调用方可以期望一个以上的参数(例如,清单3中的swift_allocObject期望有3个参数);因此,目标寄存器也可以是不同的,并且可以由指令调度程序重新排序,这也有助于实现几种2指令模式。

2.大量使用新颖的高级语言功能及其相应的代码会导致某些特定的,非常长的和令人讨厌的重复模式。我们通过两个示例对此进行详细说明。

一种。泛型函数和闭包专业化:Swift支持泛型函数和闭包。专门用于其调用位置的泛型函数实例化和闭包会导致非常相似的长机器指令序列。

清单7:Swift中的一个典型习语,通过从JSON反序列化来构造对象。 try表达式可能会引发错误。

上面的清单7显示了Swift建议的一种常见用法,它使用try表达式对JSON数据进行反序列化并分配给类的属性。在此示例中,类MyClass包含118个属性,这些属性是从JSON对象初始化的。初始化是通过try表达式进行的,如果在传入的JSON对象中找不到该属性,则该表达式将引发Error。如果任何一个try表达式失败,则必须释放所有先前创建的属性。当将此代码放到LLVM IR中,然后放到机器代码中时,它导致进入N个代码块,其中第N个块和第N-1个块具有N-1个相同的指令,第N-1个和第N-2个块具有N-2个相同的指令,依此类推-这是O(N 2)复制的代码。

显然,指令序列会重复执行,而与原因无关。我们利用机器代码序列的幂律性质来帮助减少代码大小。原则上,可以通过将每个重复位置处的执行重定向到单个实例来替换任何重复序列。

因此,可以通过编译时转换用函数调用替换同一序列的许多实例,从而应用上述“概述”技术来节省大小。实际上,机器代码概述是LLVM中可用的一种转换,并且如果代码是按大小编译的,则最新的Swift编译器版本也会启用它。

但是,我们发现仅使用机器概述并不是十分有益。在默认的iOS构建管道中,每个模块都转换为机器代码。在这种设置下,如果我们在每个模块级别执行机器代码概述,那么各个模块之间仍然存在副本,此外,我们将失去寻找跨越我们500个模块的副本的机会。

在Uber,我们开发了一个编译管道,可以使机器概述在整个程序级别提供收益。我们进一步确定了机器概述在错过机会方面的局限性,并开发了重复的机器概述以进一步减少代码大小。结果是,Uber Rider(23%),Uber Driver(17%)和Uber Eats(19%)应用程序的代码大小显着减少,没有统计上的显着性能下降,并且功能团队开发人员的参与度为零。

新的流水线为每个模块生成LLVM IR,而不是直接生成机器代码。然后,它使用llvm-link将所有LLVM-IR文件合并为一个大的IR文件。随后,它使用opt在此单个IR文件上执行所有LLVM-IR级别的优化。然后,我们将优化的IR送入llc,从而将IR降低至目标机器代码。在此阶段,我们对整个程序启用了机器概述。这样可以确保:

没有概述的功能是另一个概述的功能的克隆,如果仅执行每个模块的机器概述,这将很常见

机器代码最后与任何预编译的机器代码一起馈送到系统链接器,以生成最终的二进制映像。

Warning: Can only detect less than 5000 characters

我们的新管道为在连续开发环境中减小二进制大小找到了更多机会。图8显示了所有优化对应用程序代码字节的影响。在此图中,基线(蓝色)代码已经针对大小进行了优化,但是它使用了每个模块的优化,并且没有重复的机器概述(代表默认的iOS管道)。总体而言,我们发现尺寸减小了23%。

拟合线性回归线的基线的代码大小增长的斜率为2.7(置信度为96%)。通过我们的优化(红线),代码大小增长的斜率为1.37(置信度为98%)。因此,我们将代码大小的增长减少了约2倍。我们认为,这种“终身”的代码大小影响是我们开发的优化程序的显着优势。

在图9中,标记为“无”的x轴是通过禁用机器轮廓线生成的,但是在LLVM中启用了所有其他减小尺寸的优化。沿x轴的后续点逐渐增加了机器轮廓的轮次。

首先,将整个二进制文件大小(顶部的两行)与代码大小(底部的两行)进行比较,结果表明,由于重复概述,应用程序二进制文件的大小与代码段的大小成比例地减小。新构建管道中的五轮机器概述产生了120.1MB的二进制文件,与默认管道的145.7MB相比,二进制文件的大小减少了17.6%。相同的代码段产生了88.4MB,与默认管道中的114.5MB相比,减小了22.8%。在节省22.8%的代码大小中,有27%(7%的点数)来自重复的机器概述。

其次,随着机器轮廓轮数的增加,尺寸不断减小(但减小)。此外,模块内概述平台的收益要早于模块间概述

......