编译入门那些事儿(5):Jump Table 优化介绍

1. 基本概念

Jump Table即跳转表,它可以理解为一个数组,如下图所示,数组的每一项存储一个目标地址,外部代码通过不同的索引从跳转表获取对应表项,得到目标地址,实现一个间接跳转或者间接调用。跳表的应用非常广泛,比如在操作系统中用于存储系统调用和库函数,在一些计算机体系结构中也会采用跳表做中断处理。本文主要介绍跳转表在编译器中的应用。

image.png

2. 简单案例

在C语言中,最直观的使用跳转表的例子就是访问函数指针数组并调用对应函数。函数原型如下:

typedef void (*func_ptr) (void);
void func1();
void func2();
void func3();
void func4();
void test(int argv) {
  func_ptr jt[4] = {func1, func2, func3, func4};
  jt[argv]();
}

采用LLVM编译器指定AArch64架构O2编译以上代码,会生成如下汇编,其中 .L__const.test(int).jt 代表一个跳转表,它包含4个表项,每个表项用于存储目标函数地址。程序通过 ldr 指令访问跳转表,获取目标函数地址,并通过 br 指令实现函数的间接调用。

test(int):                               // @test(int)  
        adrp    x8, .L__const.test(int).jt  
        add     x8, x8, :lo12:.L__const.test(int).jt  
        ldr     x0, [x8, w0, sxtw #3]  
        br      x0  
.L__const.test(int).jt:  
        .xword  _Z5func1v  
        .xword  _Z5func2v  
        .xword  _Z5func3v  
        .xword  _Z5func4v  

3. 多路分支优化

3.1 优化简介

在LLVM编译器中,存在一种将switch多路分支控制结构转化为跳转表的技术。合适的条件下将多个比较跳转转化为跳转表可以提高运行效率,该优化默认开启。

int func(int a, int b) {  
  int c;  
  switch (a) {  
    case 1:  
      c = b + 1;  
      break;  
    case 2:  
      c = b - 2;  
      break;  
    case 3:  
      c = b | 3;  
      break;  
    case 4:  
      c = b & 4;  
      break;  
    default:  
      break;  
  }  
  return c;  
}  

以上面的代码为例,这是一个典型的switch多路分支控制结构。如果不启用jump table优化,编译器会将其转化为一系列比较和跳转指令。如果启用jump table优化,编译器会在函数内构造一个跳转表,通过变量a索引到目标机器代码块,其生成的关键汇编如下:

...
// %bb.1:                               // %entry
        adrp    x9, .LJTI0_0
        add     x9, x9, :lo12:.LJTI0_0
        ldr     x8, [x9, x8, lsl #3]
        br      x8
.LBB0_2:                                // %sw.bb
        ...
.LBB0_3:
        ...
.LBB0_4:                                // %sw.bb1
        ...
.LBB0_5:                                // %sw.bb2
        ...
.LBB0_6:                                // %sw.bb3
        ...
.LJTI0_0:
        .xword  .LBB0_2
        .xword  .LBB0_4
        .xword  .LBB0_5
        .xword  .LBB0_6

3.2 Jump Table识别

3.2.1 Switch群组类型

编译器前端将switch语句转化为LLVM IR SwitchInst,每一个SwitchInst包含比较变量、default BB、case value、Dest BB。如下所示:

switch i32 %6, label %22 [  
    i32 1, label %7  
    i32 2, label %10  
    i32 3, label %13  
    i32 4, label %16  
  ]  

在指令选择阶段构建SelectionDAG时,根据用户选项、case value分布、目标架构特性等因素对每一个SwitchInst做分析,最终将一个switch拆分成多个群组,这里将每一个群组称为一个cluster。cluster分为如下3类:

image.png

3.2.2 基本条件

多路分支结构转化为跳转表并不一定都会获得良好的效果。主要原因如下:

  • 跳转表将直接跳转转化为间接跳转,可能会导致branch miss率增高。除此之外还会增加访问跳转表的指令。因此在分支较少时,原本仅需要简单的比较指令和少量跳转指令即可到达目标地址,没有必要转化为跳转表。
  • 跳转表需要为case值范围内的所有值创建一个表项,如果case值很稀疏,则表中存放的大部分地址都是defaultBB,会造成CodeSize膨胀。

因此,一个cluster适合转化为CC_JumpTable必须同时满足如下条件:

  • 选项支持:用户没有开启 -fno-jump-tables 选项。
  • 后端架构支持:后端架构支持跳转表相关的SDNode。
  • 排除比特检测场景:如果目标BB数较少,且case value差值在机器位宽之内,则可以根据目标BB数和比较次数评估是否适合转化为比特检测模式。如果可以做成,则优先转化为比特检测类型。
  • clusters个数评估:如果clusters个数较少,则分支数较少,没有必要转化为跳转表,因此必须满足个数>=阈值。
  • case value密度评估:如果case value值密度太低,会导致跳转表中存储的大部分地址为default BB,利用率较低,产生不合理的空间开销。密度评估条件:

image.png

其中,CaseRange:case value之间的最大差值。

CaseNumber:case value个数。

MinDensity:密度阈值。Os/Oz优化等级下更关注CodeSize,密度要求更高。

3.2.3  算法实现

1、初始化clusters

首先定义一个vector clusters用于存储cluster群组,将每个case当成CC_Range类型存储到clusters中,每一个case value对应一个目标BB,同时记录该case在switch内部的Branch Probability。

将clusters按照case value值升序排序,如果存在连续多个case value映射到同一个目标BB,则将这几个cluster合并成一个CC_Range类的cluster。

2、剥离Branch Probability最大的case

选取Branch Probability最大的cluster,如果它的占比大于等于阈值,则将该cluster从switch中剥离出去。Branch Probability默认是控制流图静态分析结果,如果有PGO信息,会根据PGO信息计算。该优化有利于性能提升。

说明:Oz场景为了更小的CodeSize,不做该优化。

3、识别跳转表

如果满足3.2.2中描述的基本条件,则将clusters合并为一个CC_JumpTable类型的cluster,在function内创建一个跳转表,并创建MBB实现对跳转表的访问和间隔跳转。

如果上述条件不满足,在非O0优化等级下,尝试在clusters中寻找能够转化为跳转表的子集,也就是将clusters拆分成多个分区,每个分区包含一个或多个cluster。LLVM参考了《Correction to 'Producing Good Code for the Case Statement'》论文,通过时间复杂度O(N^2)的动态规划算法寻找到最优拆分法。下面简单介绍下该算法。

image.png

(1)数据结构

假设clusters包含N个元素,从N-1开始到0结束依次计算当前元素到最后一个元素之间的最小分区数,由于第i个元素对应的最小分区数依赖于其后面元素的计算结果,因此创建三个数组存储每次的计算结果:

  • MinPartitions[i]:存储Clusters[i..N-1]的最小分区数。
  • LastElement[i]:存储从i开始的分区的最后一个元素。
  • PartitionsScore[i]:存储该拆分法对应的评分。

(2)评估模型

  • 单个cluster:仅需要一个判断语句,开销小,评分最高,设为2;
  • 多个cluster:
  • cluster个数较少:仅需要较少的条件判断,评分设为1;
  • cluster个数略大,但是没有达到跳转表的个数阈值:需要较多的条件判断,评分最低,设为0;
  • cluster个数达到跳转表个数阈值:约等于少量条件判断,评分设为1。

(3)边界状态

  • MinPartitions[N-1] = 1
  • LastElement[N-1] = N - 1
  • PartitionsScore[N-1] = 2

(4)状态转移方程

假设将cluster[i...j]作为一个分区:

image.png

(5)整体流程

N-1的边界状态是一个不变量,因此外层循环将i从N-2遍历到0,每次迭代的目的是为clusters[i...N-1]寻找最小分区数并记录其分数。

最差的情况是将clusters[i]作为独立的分区,先将该分区法对应的结果保存到三个数组中。

内层循环将j从N-1遍历到i+1,每次迭代尝试将clusters[i...j]作为一个分区,评判该分区是否为当前最优拆分法。首先判断该分区是否满足跳转表的密度要求,如果不满足则表示该分区不可能转化为跳转表且无可拓展性,则跳过该拆分法。否则通过状态转移方程计算该拆分法对应的结果,最终得到从i到N-1的最优分区法:

  • 如果MinPartitions_tmp > MinPartitions[i],则跳过该拆分法;
  • 如果MinPartitions_tmp < MinPartitions[i],则更新结果到三个数组中;
  • 如果MinPartitions_tmp == MinPartitions[i],且PartitionsScore_tmp > PartitionsScore[i],则更新结果到三个数组中。

最终,从cluster[0]开始,根据LastElement的结果依次将能够做成跳转表的分区合并成CC_JumpTable类的cluster。

3.3 选项介绍

该优化过程中涉及到以下几个选项,用户可以通过调整这些选项,得到理想的性能和CodeSize需求。

image.png

4. 跳转表压缩

LLVM编译器针对跳转表定义了6种类型,该类型是指跳转表中每个entry所包含的symbol类型。

image.png

默认情况,non-pic模式下采用EK_BlockAddress类型。pic模式下如果架构支持gp relative32指令,则选择EK_GPRel32BlockAddress,否则选择EK_LabelDifference32。

后端架构也可以自定义其行为,比如Arm选择EK_Inline类型将跳转表inline到Block内。RISCV64在non-pic模式下small code model时采用EK_Custom32,用于减少CodeSize。

以EK_BlockAddress类型为例,在64架构下,跳转表的每个entry占据8byte。如果entry数量很大,跳转表会占据不小的内存空间,因此AArch64引入了压缩跳转表技术。该优化加在branch relaxation pass之后,尝试将BB绝对地址转化为Block之间的相对地址,在4字节对其模式下,将相对地址进一步压缩,最终可以实现entry大小为4byte/2byte/1byte,最理想情况下可以将跳转表压缩至原来的1/8,从而降低CodeSize,如下图所示。

image.png

5. 总结

本文简单介绍了Jump Table的基本概念,以及跳转表在编译器中的常见应用。重点介绍了LLVM中将switch语句转化为跳转表的优化技术,详细描述了该优化的基本条件和算法实现。在此基础上,介绍了编译器提供的一系列选项,用户可以通过调整这些选项得到理想收益。最后,简单介绍了跳转表中存储符号的类型以及LLVM中针对CodeSize优化的跳转表压缩技术。

6. 参考

  1. https://github.com/llvm/llvm-project
  2. https://courses.cs.washington.edu/courses/cse351/17sp/lectures/CSE351-L10-asm-IV_17sp-ink.pdf
  3. http://syrcose.ispras.ru/2007/files/2007_02_talk.pdf
  4. https://reviews.llvm.org/D32564
作者:朱春燕
文章来源:毕昇编译

推荐阅读

更多嵌入式AI干货请关注嵌入式AI专栏。欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。

推荐阅读
关注数
16883
内容数
1235
嵌入式端AI,包括AI算法在推理框架Tengine,MNN,NCNN,PaddlePaddle及相关芯片上的实现。欢迎加入微信交流群,微信号:aijishu20(备注:嵌入式)
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息