专注、坚持

Swift 拾遗 - 内联函数

2020.07.26 by kingcos
Date Notes
2020-07-26 首次提交

Preface

《Swift 拾遗》是一个关于 Swift 的新文章专辑,这个系列的文章将不会涉及基本语法的层面,而是尝试从底层「拾」起之前所忽视的内容。今天我们将一起简单探究 Swift 中的内联函数。

编译器优化等级

由于内联函数会将函数调用展开为函数体,因此当编译器内联某一函数时,该函数本身将不再会被调用。而在 Debug 模式下,由于我们经常会使用打断点等调试手段,如果此时内联将不利于我们排查问题。因此在 Debug 模式下,编译器默认将不进行内联。

控制编译器优化等级的设置位于:Xcode - TARGETS - Build Settings - Swift Compiler - Optimization Level,其中便会影响 Swift 函数是否内联:

1

Swift 编译器所支持的优化等级具体如下:

Level Part
[-Onone] 无优化(Debug 模式默认)
[-O] 速度优先(Release 模式默认)
[-Osize] 体积优先

经过实际测试,如下 foo 函数在 [-O][-Osize] 等级下均被优化:

func foo() {
    print("foo") // BREAKPOINT 2 🔴

foo() // BREAKPOINT 1 🔴

[-Onone] 等级的汇编如下(Xcode Menu - Debug - Debug Workflow - Always Show Disassembly):

; [-Onone]

demo`main:
    0x100000e50 <+0>:  pushq  %rbp
    0x100000e51 <+1>:  movq   %rsp, %rbp
    0x100000e54 <+4>:  subq   $0x10, %rsp
    0x100000e58 <+8>:  movl   %edi, -0x4(%rbp)
    0x100000e5b <+11>: movq   %rsi, -0x10(%rbp)
    ; 断点 1 ⬇:函数调用(内联后则不存在该行汇编指令)
->  0x100000e5f <+15>: callq  0x100000e70               ; demo.foo() -> () at main.swift:11
    0x100000e64 <+20>: xorl   %eax, %eax
    0x100000e66 <+22>: addq   $0x10, %rsp
    0x100000e6a <+26>: popq   %rbp
    0x100000e6b <+27>: retq

demo`foo():
    0x100000e70 <+0>:   pushq  %rbp
    0x100000e71 <+1>:   movq   %rsp, %rbp
    0x100000e74 <+4>:   subq   $0x30, %rsp
    ; 断点 2 ⬇:作用域在 foo() 内
->  0x100000e78 <+8>:   movq   0x189(%rip), %rax         ; (void *)0x00007fff8b1517e0: type metadata for Any
    ; ...
    0x100000f17 <+167>: retq

[-O] 等级的汇编如下:

; [-O]

demo`main:
    ; ...
    ; 断点 2 ⬇:作用域在 main 内
->  0x100000e32 <+50>:  movq   0x1c7(%rip), %rax         ; (void *)0x00007fff8b149910: type metadata for Swift.String
    ; ...
    0x100000e82 <+130>: retq

[-Osize] 等级的汇编如下:

; [-Osize]

demo`main:
    ; ...
    ; 断点 2 ⬇:作用域在 main 内
->  0x100000e3e <+46>:  movq   0x1bb(%rip), %rax         ; (void *)0x00007fff8b149910: type metadata for Swift.String
    ; ...
    0x100000e8a <+122>: retq

[-O][-Osize] 的结果类似,即通过内联使得断点 1 不再执行;而断点 2 虽然得到执行,但通过查看汇编可以看出,断点 2 处的代码作用域已变为 main,且无 call 调用相关函数的指令,证明函数已经内联。

内联条件

在开启编译器优化后,Swift 编译器便会针对函数进行内联优化,但并非所有函数都支持内联。

  • 函数体过长的函数将不支持内联;
// [-O]

func foo() {
    print(#function)
    print(#function)
    print(#function)
}

foo() // BREAKPOINT 🔴

由于过长的函数体内联反而会导致实际代码行数增多,可能会影响性能,因此如上,当函数体内的 print 语句超过两句时,内联就会失效。我们可以从函数调用处的断点或者汇编来得出结论:

demo`main:
    0x100000da0 <+0>:  pushq  %rbp
    0x100000da1 <+1>:  movq   %rsp, %rbp
->  0x100000da4 <+4>:  callq  0x100000db0               ; demo.foo() -> () at main.swift:11
    0x100000da9 <+9>:  xorl   %eax, %eax
    0x100000dab <+11>: popq   %rbp
    0x100000dac <+12>: retq
  • 包含递归调用的函数将不支持内联;
// [-O]

func foo(_ i: Int) -> Int {
    if i <= 1 {
        return i
    }

    var result = foo(i - 1)
    result += 1

    return result
}

_ = foo(5)

由于包含递归的函数体需要在函数内部调用自身,此时若使用内联便会造成无限展开。因此如上,当函数体内的存在自身的递归时,内联就会失效。我们可以从函数调用处的断点或者汇编来得出结论:

demo`main:
    0x100000f70 <+0>:  pushq  %rbp
    0x100000f71 <+1>:  movq   %rsp, %rbp
->  0x100000f74 <+4>:  movl   $0x5, %edi
    0x100000f79 <+9>:  callq  0x100000f90               ; demo.foo(Swift.Int) -> Swift.Int at main.swift:41
    0x100000f7e <+14>: xorl   %eax, %eax
    0x100000f80 <+16>: popq   %rbp
    0x100000f81 <+17>: retq

尾递归

对于递归函数需要注意的是,编译器会对尾递归(Tail Recursion,即递归位于函数末尾)进行额外优化,即尾递归优化(Tail Call Optimization)。如下一段求阶乘的代码:

// [-O]

func foo(_ i: Int) -> Int {
    if i <= 1 {
        return i
    }
    return foo(i - 1)
}

_ = foo(5)

将其转换为汇编如下:

demo`main:
    0x100000f90 <+0>: pushq  %rbp
    0x100000f91 <+1>: movq   %rsp, %rbp
->  0x100000f94 <+4>: xorl   %eax, %eax
    0x100000f96 <+6>: popq   %rbp
    0x100000f97 <+7>: retq

本文暂时不会涉及过多关于尾递归的内容。

  • 包含动态派发的代码将不支持内联;
class People {
    var name: String = "Name"

    func bar() {}

    func foo() {
        // NOT Inline
        var p: People = Student()
        p = Teacher()

        p.bar()

        // Inline
        let q = Student()

        q.baz() // b -r Student.baz 🔴
    }
}

class Teacher: People {
    override func bar() {
        print("Teacher - \(name)")
    }
}

class Student: People {
    override func bar() {
        print("Student - \(name)")
    }

    func baz() {}
}

People().foo()

由于包含动态派发的函数只有在运行时才是确定的。因此如上,当使用多态时,内联就会失效。我们可以从函数调用处的断点或者汇编来得出结论:

demo`main:
    ; ...
    0x100001524 <+212>: movq   0x18(%rbx), %rdi
    0x100001528 <+216>: callq  0x100001be2               ; symbol stub for: swift_bridgeObjectRelease
->  0x10000152d <+221>: callq  0x100001960               ; demo.Teacher.bar() -> ()
    0x100001532 <+226>: movq   %r13, %rdi
    0x100001535 <+229>: callq  0x100001c0c               ; symbol stub for: swift_release
    ; ...

@inline

我们可以使用 @inline 来建议编译器去禁止(never)或在条件允许的情况下(除递归、动态派发等)总是(__always)内联,其优先级高于编译器优化。

@inline(never)

@inline(never) 将使得原本在开启编译器优化时会被内联的函数不再内联:

// [-O] / [-Osize]

@inline(never) func bar() {
    print("bar")
}

bar() // BREAKPOINT 🔴

此时函数调用处的断点将有效,且转换为汇编也将显示存在 call 指令:

; [-O] / [-Osize]

->  0x100000e74 <+4>:  callq  0x100000e80               ; demo.bar() -> () at main.swift:1

@inline(__always)

需要注意的是,__always 只在开启编译器优化时几乎总是内联,未开启时将不会被内联。

@inline(__always) func baz() {
    print("baz")
}

baz() // BREAKPOINT 🔴

如上代码即使在 [-Onone] 等级下我们也可以通过断点和汇编看出其本质并没有被内联:

; [-Onone]

->  0x100000e5f <+15>: callq  0x100000e70               ; demo.baz() -> () at main.swift:3

而在 [-O][-Osize] 下使用 @inline(__always) 则可以将原本因行数过多的函数进行内联:

// [-O] / [-Osize]

@inline(__always) func baz() {
    print(#function)
    print(#function)
    print(#function)
    print(#function)
    print(#function)
}

baz() // BREAKPOINT 🔴

@inline(__always) 虽然能够「控制」一部分函数进行内联,但其本质是对编译器行为的建议而非强制,因为有些函数并不能够被内联,比如上面递归中的案例。这里我们只需要了解其用处,而在实际开发中很少会用到。