原文:Whole-Module Optimization in Swift 3
Whole-module optimization (以下简称WMO) 是 Swift 编译优化的一种模式。WMO 带来的性能提升主要取决于项目本身,但它能提升2-5倍。
WMO 通过打开编译 flag -whole-module-optimization
或 -wmo
来启用,在 Xcode 8 中新建项目时是默认开启的(注:只在release模式下开启)。同时 Swift Package Manager 在 release 模式下也开启了 WMO 。
WMO 是什么?让我们先看下没有开启 WMO 时编译器是如何工作的吧。
Modules 以及如何编译它们
Modules 是一系列 Swift 文件的集合。每一个 module 被编译为一个分发单元 - framework 或可执行文件。没有开启 WMO 时,Swift 编译器对为 module 中每个文件进行单独编译,这也是默认的做法。用户没有必要手动来做管理这些,编译器或者 Xcode 构建系统会自动处理。
读取和解析源文件之后(还有一些其他工作,如类型检查),编译器优化 Swift 代码,生成机器代码,创建二进制 .o 文件,最后链接器把所有的 .o 文件结合起来生成动态库或可执行文件。
在单个文件的编译过程中,编译器的优化只能集中在单个文件上。这样把跨函数优化(比如函数内联 function inlining、泛型专门化 generic specialization)限制在了那些本文件内定义或调用的函数上。
让我们来看一个例子。假设我们的 module 有一个 utils.swift 文件,里面定义了一个泛型数据结构 Container<T>
, 该结构体有 getElement
方法,这个方法在整个 module 里被多处调用,比如 main.swift 里。
main.swift:
func add (c1: Container<Int>, c2: Container<Int>) -> Int {
return c1.getElement() + c2.getElement()
}
utils.swift:
struct Container<T> {
var element: T
func getElement() -> T {
return element
}
}
当编译器优化 main.swift 时它不知道 getElement
是如何实现的,只知道这个函数的存在,所以编译器会生成一个指向 getElement
的调用。另一方面,编译器优化 utils.swift 时,它也不知道 getElement
函数会返回哪种具体类型,所以它只能生成该函数的泛型版本,这样比生成一个返回具体类型的函数要慢很多。
即使是 getElement
中简单的一句return
,都需要查询类型的元数据来推断出如何复制该元素。它可能只是很简单的 Int
类型,但也可能是更复杂的类型,可能还需要进行引用计数的相关操作。编译器不知道采用何种办法。
Whole-module optimization
如果开启了 WMO ,那么编译器能做的更好。这时编译器会把整个 module 优化成一个文件。
这样有两个优势。第一,编译器知道 module 里所有函数的具体实现,它可以进行函数内联,函数专门化等优化。函数专门化的意思是编译器创建一个为某个特定的调用环境优化过的新版本。例如,编译器能为某个泛型函数生成一个有具体类型的版本。
在我们的示例中,编译器生成了泛型结构体 Container
的 Int
类型版本。
struct Container {
var element: Int
func getElement() -> Int {
return element
}
}
编译器把专门化的 getElement
函数内联到 add
方法中。
func add (c1: Container<Int>, c2: Container<Int>) -> Int {
return c1.element + c2.element
}
这样编译时可以减少几条机器指令。而单文件编码则会调用泛型函数 getElement
两次。
跨文件的函数专门化和内联只是 WMO 优化的两个方面。即使编译器不进行函数内联,如果能够了解函数的具体实现也会有很多帮助。例如它可以根据引用计数推断接下来的行为。编译器能够移除一个函数多余的引用计数。
WMO 优化的第二个好处是编译器可以推断出所有非公开的函数的使用情况。非公开函数只能在 module 内调用,所以编译器能推断出非公开函数的所有引用。有了这些信息,编译器能做什么呢?
一个基本的优化是减少死函数和死方法(没有被调用过的函数和方法)。在 WMO 下,编译器知道如果一个非公开函数或方法没有被调用过就可以删除它。那么为什么编程人员会编写一个从来不被调用的函数呢?嗯。。这并不是死函数和死方法产生的主要原因。通常是因为其他编译优化导致某些函数变成死函数的。
假设 add
函数只被 Container.getElement
调用。把 getElement
内联之后,add
就不会被调用了,add
会被移除。即使编译器不内联 getElement
,编译器也可以移除 getElement
的泛型版本,因为 add
只在 getElement
的专门化版本中被调用。
编译时间
单文件编译时,编译器并行的在单独的进程中编译每一个文件。同时,没有改动的文件无需再次编译(假设所有依赖文件也没有被修改)。这就是增量编译。这样节省了很多编译时间,特别是改动很小的情况下。在 WMO 下怎样进行增量编译呢?让我们更详细的了解一下 WMO 模式下编译器是如何工作的吧。
编译器有几个运行阶段:语法分析,类型检查,SIL 优化,LLVM 后端处理。
大多数情况下,语法分析和类型检查都非常快,而且在接下来发布的 Swift 版本中它们还会更快。SIL(Swift Intermediate Language)优化负责所有重要的 Swift 语言的具体优化,比如函数专门化,函数内联等。这一阶段占用大概三分之一的编译时间。大部分的编译时间都是 LLVM 后端在运行底层优化和代码生成。
开启 WMO 优化后,在 SIL 优化阶段,module 再次被分割成多个部分。LLVM 后端在多个线程中处理这些分割的部分。如果该部分距离上次编译没有改动则不会被重新编译。所以即使开启了 WMO 优化,编译器也能够并行(多线程)地和增量地进行大部分的编译工作。
结论
WMO 优化是一种很好的方式,它有最好的性能表现,并且不用担心在 module 的多个文件之间如何分发 Swift 代码。以上这些优化,在某些特殊的代码环境下,会比单文件优化的性能好上5倍。相比整个项目优化,WMO 有更好的性能和更少的编译时间。
PS:渣翻,翻译有不合适的地方还望指正。编译原理可以参考这篇文章大前端开发者需要了解的基础编译原理和语言知识。
网友评论