8555419db145356e3c4bf8226befea05
Session 409 What’s New in Clang and LLVM

Session Link:What's New in Clang and LLVM

难度:中

阅读时长:30分钟

微博:NinthDay

1. 新平台支持

2015年4月24日,第一代 Apple Watch 推出至今已有四年之久,去年发布的 Series 4 更是升级至 64 位处理器,而 AppStore 上很多 Watch 应用仍然是32位架构的(TODO:存疑),但依旧能在 Series 4 设备上运行和之前基本没有什么差别,这不得不归功于 Bitcode,接下来先通过一张图介绍下什么是 LLVM Bitcode:

左侧是我们的源文件,LLVM支持.c.m.swift.cc 等多种语言,编译器统一编译成 .bc 中间代码(bc为 bitcode缩写)。.bc 文件记录了源文件一系列的中间编译状态,之前上传bitcode到AppStore后再根据32位平台和64位平台进行分发,如下图:

但是这样做有个问题,编译器并不知道你最终想要运行在32位还是64位设备,因此很多编译优化选项可能达不到预期。换句话说:为不同平台进行"量身定做",意味着有更多优化空间。

2. 代码瘦身(code size optimizations)

Note: 这里谈及的是代码瘦身,而非应用包瘦身!

代码瘦身是当下开发者比较关注的问题之一,我们不断地进行优化已达到苹果官方设定的流量下载阈值包大小值。代码瘦身带来的好处不言而喻,更小的包体积意味着更快地下载包,带来用户流量等。下面将从低级语言层面(Low-level)和高级语言层面介绍 LLVM 为 code size 做了哪些优化。

2.1 低级语言层面的代码瘦身

Xcode11 编译器引入了 -Oz 优化选项,这里读作"zed"。该优化选项做了哪些工作,又起到了什么实质性的帮助。回答这些问题之前,我们先简单过下编译的编译过程,以冰雹猜想为例,定义如下函数:

int collatz(int Num) {
    if (Num % 2 == 0)
            return Num / 2; 
  return Num * 3 + 1;
}

编译器前端工作流程:宏替换,Lexical 词法分析,语法分析,语义分析,生成中间代码(IR),ps:这个过程中会进行类型检查。上述源码生成的IR如下:

name:                 collatz
body:                   |
  bb.0:
    %0:gpr32 = COPY $w0
    %5:gpr32 = MOVi32imm 2
    %6:gpr32 = SDIVWr %0, %5
    %7:gpr32 = MOVi32imm 3
    %8:gpr32common = MADDWrrr %0, %7, $wzr
    %10:gpr32 = CSELWr %6, %9, 1, implicit $nzcv
    RET_ReallyLR implicit $w0

上面的中间代码初看可能有点晦涩难懂,不过这不是本节的重点,所以不必在意。

接着编译器后端将 IR 中间语言做进一步处理,生成 MIR (Machine Intermediate representation) ,更加贴近机器语言:

name:             collatz
body:                   |
  bb.0:
    $w8 = MOVZWi 2, 0 $w9 = MOVZWi 3, 0
    $w8 = SDIVWr $w0, $w8
    $w9 = MADDWrrr $w0, $w9, $wzr
    $w9 = ADDWri $w9, 1, 0
    $w0 = CSELWr $w8, $w9, 1, $nzcv
    RET $lr, $w0

相对应的汇编语言是这样的,通过对比可以看到两者非常相似:

collatz:
  mov w8, #2
  mov w9, #3
  sdiv w8, w0, w8 
  mul w9, w0, w9 
  add w9, w9, #1
  csel w0, w8, w9, ne
  ret

简单了解了编译器的工作过程,回归正题,-Oz 编译优化选项究竟是如何从低级语言层面进行代码瘦身的呢?答案是:Function Outlining,你可以理解为将重复代码进行提取,封装成一个新的中间函数,然后原函数间接调用该中间函数。我们通过一个例子来直观的理解何为 Function Outlining。

// These two functions share some instructions
hasse:
  ...
  ldr w0, [sp, #16]
  mul w0, w1, w2
  add 
  ret

kakutani:
  ...
  ldr w0, [sp, #16]
  mul w0, w1, w2
  add 
  ret

hassekakutani 具有相同的指令代码,因此我们可以将其提取出来,命名为OUTLINED_FUNCTION_0:

OUTLINED_FUNCTION_0
    ldr w0, [sp, #16]
  mul w0, w1, w2
  add 
  ret

hassekakutani 也要稍作改变:

hasse:
  ...
  b OUTLINED_FUNCTION_0

kakutani:
  ...
  b OUTLINED_FUNCTION_0

那么问题来了,这样的优化方式到底能带来多少收益呢???这可能是大家最关心的问题。

看到这个数字我觉得大部分开发者在震惊的同时更多是问号脸,理由很简单:不可否认编码过程中存在一些重复代码,但是远远达不到 25% 这个比值,那么这25%的代码瘦身来自于哪里呢?🤔

OK,我们将以一个例子来揭秘这25%的来源:

// 源码
int ulam(int Num, int NumIters) {
  // TODO: Does this always converge?
  while (Num != 1) {
    Num = collatz(Num);
    ++NumIters;
  }
  return NumIters;
}

// 对应的汇编代码
ulam:
  stp x20, x19, [sp, #-32]!
  stp x29, x30, [sp, #16]
  add x29, sp, #16
  b LBB1_2
LBB1_1:
  bl collatz
  add w19, w19, #1
LBB1_2:
  cmp w0, #1
  b.ne LBB1_1
  mov x0, x19
  ldp x29, x30, [sp, #16]
  ldp x20, x19, [sp], #32
  ret

为何我们要以汇编方式来呈现代码,是不是内含玄机?Bingo!

ulam:
-------------Prologue----------------
  stp x20, x19, [sp, #-32]!
  stp x29, x30, [sp, #16]
-------------------------------------
  add x29, sp, #16
  b LBB1_2
LBB1_1:
  bl collatz
  add w19, w19, #1
LBB1_2:
  cmp w0, #1
  b.ne LBB1_1
  mov x0, x19
-------------Epilogue----------------
  ldp x29, x30, [sp, #16]
  ldp x20, x19, [sp], #32
  ret
-------------------------------------

注意我用 ——— 标出的 Prologue 和 Epilogue 这两部分代码,函数调用入口和返回值处对 sp 栈指针的处理基本一致,所以这部分指令提取出来,封装成一个中间函数进行间接调用,那么 25% 的节省值就说的通了。

当然 Function Outlining 在提取封装中间函数进行调用的同时,显然也改变了函数的程序控制流程:

正如上图显示的那样,控制流程变更, Backtraces 也必然会受到影响:

当然有人盘算下代码瘦身带来的收益,觉得这些问题就不值一提拉。但是我必须得告诉你:Outlining 还有一个致命的问题,那就是会增加代码执行时间。没办法,调用就是会带来额外的执行时间开销,这个是无法避免的,毕竟鱼和熊掌不可兼得,

你需要斟酌下:使用 -Oz 编译选项,不惜一切代价达到代码瘦身的目的,但为此牺牲了性能,这真是你想要的吗?当然我们也不能完全否定 -Oz 优化选项,而是应该借助 Instrument 具体情况,具体分析,下面给出几个主要优化项在速度和代码大小两个维度的比较。

横坐标是包大小,纵坐标是运行速度,非常直观看到 -Oz 在代码瘦身上的优势,当然劣势也非常明显。

那么相对来说,哪个优化项是最合适的呢?答案是-Os

关于上面提到的几个优化项在平常工程配置的时候也不太会更改,经过本节的介绍想必你也已经跃跃欲试,如果你的App可能在性能方面不是很苛刻,但又想减少代码提及,不妨试试最新的优化项吧。

关于其他优化项这里就一笔带过,更多关于这些优化项的具体作用,不妨前往查看 WWDC 2016 的 What’s New in LLVM Session,可能对你会有所帮助。

  1. PGO — Profile-guided optimization
  2. LTO — Link-time optimization
  3. 组合项:LTO+PGO+-O3

优化等级的设置路径 Target-> BuildSettings -> Apple Cang -Code Generation -> Optimization Level ,最直接的就是搜索关键字。

现在Xcode11还支持对每一个文件的编译优化等级进行定制化,路径 Build Phases > Compile Sources > Compiler Flags,填写 -O2,-Oz-Os 等选项即可生效。

本节的最后再来介绍一个新的命令:size,帮助你查看可执行文件可段所占大小。

$ size ~/Library/Developer/Xcode/.../Sniffo.app/Contents/MacOS/Sniffo

当然这里的 __TEXT 段包括了很多:可执行指令(Executable instructions)、C 字符串、Unwind info、常量、Stubs 以及 Stub helper,这里涉及到 Mach-o 文件,不妨看下瓜的 Mach-O 文件格式探索

如果我们只想看可执行文件指令大小,那么加两个选项即可:

$ size -l -m ~/Library/Developer/Xcode/.../Sniffo.app/Contents/MacOS/Sniffo

2.2 高级语言层面的代码瘦身

2.2.1 合并冗余的代码块以及 block metadata 表示方式

Ok,-Oz优化方式 outline function 的思想让人眼前一亮,它所做的是在靠近机器码层面进行提取重复指令部分,然后封装成新的中间函数调用,那么在高级语言层面,我们是否也能借鉴这种方式呢?譬如我们有两个调用Block的方法:

- (void)neutron:(id)particle {
  [self fuseWithCallbackBlock:^ (id nuclei) {
    [nuclei collide:particle];
    [self upDownDown];
  }];
}

- (void)proton:(id)particle chargeQuantity:(double)charge {
  [self fuseWithCallbackBlock:^ (id nuclei) {
    [self checkCoulombForce:charge];
    [particle collide:nuclei];
  }];
}

注意Block实现两者还是有区别的,Xcode 11 中LLVM为 block 定义了 metadata 元数据结构,每个 block 代码块都是用这个元数据结构来表示:

struct Metadata {
  unsigned long reserved, block_size;
  void *copy_helper;
  void *destroy_helper;
  const char *block_method_signature;
     uintptr_t block_layout_info;
};

// 上面两个方法中 Block 最终也是用这个结构体来描述的
// 最后呈现的Block变量内容如下
static const char *__block_method_signature_v16_0_8 = "v16@?0@8";

static const struct { // neutron block metadata 
      unsigned long reserved = 0, block_size = 48;   <-- 注意这里!!!
    void *copy_helper = ___copy_helper_block_ea8_32s40s;
    void *destroy_helper = ___destroy_helper_block_ea8_32s40s;
    const char *block_method_signature = __block_method_signature_v16_0_8;
    uintptr_t block_layout_info = 512;
} ___block_descriptor_48_ea8_32s40s_e8_v16?08l;

static const struct { // proton block metadata
    unsigned long reserved = 0, block_size = 52;   <-- 注意这里!!!
    void *copy_helper = ___copy_helper_block_ea8_32s40s;
    void *destroy_helper = ___destroy_helper_block_ea8_32s40s;
    const char *block_method_signature = __block_method_signature_v16_0_8;
    uintptr_t block_layout_info = 512;
} ___block_descriptor_52_ea8_32s40s_e8_v16?08l;

可以看到两者的 block_size 其实并不相等,所以这里是不能进行merge操作的。不过注意到 copy_helperdestroy_helper 指针分别指向了同一个:

static void ___copy_helper_block_ea8_32s40s(void *block) {
    objc_retain(*(id*)(((char*)block) + 32));
    objc_retain(*(id*)(((char*)block) + 40));
}
static void ___destroy_helper_block_ea8_32s40s(void *block) {
    objc_release(*(id*)(((char*)block) + 32));
    objc_release(*(id*)(((char*)block) + 40));
}

据 JF Bastien 称用上述方式优化可以减少 2 - 7 % 的代码大小。但是我用 clang -rewrite-objc main.m 在 Xcode10.2.1和 Xcode11 上测试时候发现生成的 .cpp 文件内容似乎没有什么改变,至于这样做能减少代码大小存疑下。

2.2.2 Xcode 11 中对于直接继承NSObject类的实例变量访问方式变更

Xcode 11 中对于直接继承 NSObject 类的子类,访问实例变量的方式已变更硬编码偏移量的方式(也就是写死了偏移量)。通过一个简单的Demo来展示:

现在我们在代码某处访问 name 属性:

Xcode11 之前是这样访问实例变量的,而Xcode11中就需要这么麻烦了,直接硬编码变量在内存中的偏移量即可:

其中 0x10 就是 name 实例变量在内存中的偏移量 16。

这样优化一般情况下能缩减2%的代码大小。

2.2.3 提高c++类型的可调试性以及缩减部分代码大小

笔者是一枚 c++ 小白,本节可能可能存在理解偏差的地方,希望大家谅解。JF Bastien 举了如下c++demo,然后用 lldb 调试:

```c++
// Debugging around standard library code — print.cc

include

include

include

include

int main(int argc, char** argv) {
std::vector args(argv + 1, argv + argc);
std::vector numbers;
numbers.reserve(args.size());

for (std::string const& arg : args) {
int n = std::atoi(arg.c_str());
numbers.push_back(n);
}

for (int i : numbers)

top Created with Sketch.