.net core底层入门学习笔记(十一)
本篇开始记录JIT编译器实现
文章目录
前言
传统编译器会在程序执行前预先生成机器码,然后保存于程序文件中,执行时从文件加载机器码到内存然后指示CPU执行。JIT编译器支持在程序执行过程中生成机器码,程序文件只保存程序代码或中间代码。
.NET程序使用JIT编译器,文件中只包含C#,VB,F#等编程语言转换的中间代码即IL代码。.NET使用RyuJIT编译器,将X86平台中间代码转换到汇编代码。
一、JIT编译器介绍
JIT编译器最显著的特征是支持在运行过程中生成目标平台机器码的机制。JIT编译器编译的代码来源可以是程序源代码,也可以是由程序源代码经过处理的中间代码,如JVM的字节码,或MSIL中间代码。程序执行过程中,可以按需要调用JIT编译器,比如某个代码只调用一两次,那么可以执行它,而不调用JIT编译器(.net core暂时没有实现);如果频繁调用,可以重新调用JIT编译器并启用更高级的优化选项(即二次 编译,.net core已经实现);如果某一段代码从来都不调用,那么这段代码不调用编译器。
JIT编译器优点:
- 可以跨平台发布
- 减少编译时间
- 允许使用目标平台性能扩展指令
- JIT编译器更新,可以不动程序本身
- 支持动态代码生成,正则表达式、LinQ表达式和Emit接口
JIT编译器缺点:
- 逆向工程较为简单
- 用户环境需要安装对应运行时,否则需要分别发布
- 会增加执行负担
- 某些平台禁止使用JIT编译器
二、JIT编译流程
1.JIT编译触发
JIT通常在函数第一次调用触发,每个函数都有一个入口点,入口点地址在整个运行过程中不会改变,如果没有经过编译,这个入口点会指向跳转到JIT编译器的代码,触发JIT编译器,完成函数对应的MSIL代码编译为机器码之后,修改入口点指令为跳转到生成后的机器码指令。下次调用函数直接跳转到对应的机器码。这样的入口点在.NET内部称为前置码,调用JIT编译器的代码称为JIT桩。即编译前,函数指向JIT桩,JIT桩负责调用JIT编译器。
部分简单的函数,会被内联到调用端函数,减少调用函数开销,JIT会将这些函数作为调用函数一部分进行编译。
此外,如果多个线程同时调用一个没有被JIT编译过的函数,JIT编译器会根据函数信息获得一个关联的线程锁,保证只有一个线程可以执行编译处理,其他线程会等待编译完成再跳转到编译后的机器码。
2.分层编译
以往的.NET运行时,一个函数入口点对应一段机器码,并且对应关系确定后,不会再改变。.NET CORE 2.1以后,函数代码版本管理提供了一种标准的方法管理函数入口点与函数对应的机器码关系。使得函数对应的机器码可以在运行时替换。
分层编译机制用于平衡JIT编译时间与函数对应的机器码性能,第一次调用JIT编译不会启用优化,以减少JIT编译时间,如果程序调用超过一定程度(第一次编译100ms之后调用30次以上),触发第2次编译,会启动优化选项提升函数对应机器码性能。
3.JIT编译流程
JIT编译器分为前端与后端两大部分,两大部分包含多个步骤。
1.初始步骤:
- IL解析,读取IL指令并构建高级中间表现HIR
- 函数内联,分析当前调用的函数,嵌入可内联代码到HIR
- IR变形,对HIR进行变形以便后面优化与代码生成
- 流程图分析,分析HIR中各个基础块的流程
- 本地变量排序,统计本地变量引用计数并对他们进行排序
- 评价顺序定义,决定HIR中各个节点评价顺序
2.优化步骤
- 变量版本标记,计算本地变量引用对应的SSA版本与各个节点值编号
- 循环优化,对循环代码进行优化,如拆解循环
- 赋值传播,根据值标识,替换对本地函数的引用,移除多余的临时变量
- 公共子表达式消除,查找产生相同值且无副作用的子表达式,让他们只计算一次
- 断言传播,传播节点内容的断言
- 边界检查消除,根据断言消除非必要的数组边界检查
3.后端步骤
- 合理化,根据HIR生成LIR,并进行必要的变形
- 低级化,让LIR更接近汇编指令,明确各个节点对CPU寄存器的使用需求
- 线性扫描寄存器分配,对需要使用cpu寄存器的节点与本地变量分配寄存器
- 汇编指令生成,根据LIR生成汇编指令
- 机器码生成,根据汇编指令生成机器码
实际步骤与上述会有差异,但各个流程在.net中比较混乱,书里的内容会按照上面的步骤分开记录。
三、IR结构
1.HIR与LIR
IR是介于IL与汇编代码的结构,JIT编译器绝大部分会围绕IR进行,.net RyuJIT使用了两种IR,HIR与LIR。HIR在JIR编译前端使用,结构主要由树组成,与用户程序代码类似;LIR主要在JIT编译器后端使用,结构主要由列表组成,与执行机器码类似。
2.HIR结构
HIR由JIT前端部分使用,主要由基础块、语句、语法树节点组成。
1.基础块
RyuJIT使用基础块列表来表现一个函数的代码结构,其中基础块本身包含语句列表,语句按列表中的顺序执行,每个基础块只有最后一条语句可以是跳转、返回或显示抛出异常。基础块表示一段连续执行的代码,每个基础块执行完毕后,可以跳转到其他基础块、从函数返回或显示抛出异常。
可以跳转到当前基础块的上一个基础块称为当前基础块的前任,当前基础块可以跳转的下一个基础块称为后任。前任与后任的数量,可以有0到多个。JIT编译器会分析每个基础块的前任与后任,从而计算函数的控制流程图。
2.语句
每一条语句关联一颗语法树节点构成的语法树。内部语句属于语法树节点的一个子类型,语句节点只用于指示语法树的顺序与对应IL偏移值,不参与运算。
3.语法树节点
每个语法树节点代表一个值或一个操作,如果节点产生的值被其他节点使用,那么这个节点就是使用节点的子节点。
3.HIR例子
sing System;
using System.Runtime.InteropServices;
namespace ConsoleApplication
{
public class Program
{
public static void Main(string[] args)
{
for (int x = 0; x < 3; ++x)
{
Console.WriteLine(x);
}
}
}
}
对应的IL代码
IL to import:
IL_0000 00 nop
IL_0001 16 ldc.i4.0 ; 运行堆栈 [ 0 ]
IL_0002 0a stloc.0 ; 运行堆栈 [ ], 保存到本地变量0 (x = 0)
IL_0003 2b 0d br.s 13 (IL_0012) ; 跳转到IL_0012
IL_0005 00 nop
IL_0006 06 ldloc.0 ; 运行堆栈 [ x ]
IL_0007 28 0c 00 00 0a call 0xA00000C ; 运行堆栈 [ ], 调用Console.WriteLine, 这里的0xA00000C是token
IL_000c 00 nop
IL_000d 00 nop
IL_000e 06 ldloc.0 ; 运行堆栈 [ x ]
IL_000f 17 ldc.i4.1 ; 运行堆栈 [ x, 1 ]
IL_0010 58 add ; 运行堆栈 [ x+1 ]
IL_0011 0a stloc.0 ; 运行堆栈 [ ], 保存到本地变量0 (x = x + 1)
IL_0012 06 ldloc.0 ; 运行堆栈 [ x ]
IL_0013 19 ldc.i4.3 ; 运行堆栈 [ x, 3 ]
IL_0014 fe 04 clt ; 运行堆栈 [ x<3 ]
IL_0016 0b stloc.1 ; 运行堆栈 [ ], 保存到本地变量1 (tmp = x < 3)
IL_0017 07 ldloc.1 ; 运行堆栈 [ tmp ]
IL_0018 2d eb brtrue.s -21 (IL_0005); 运行堆栈 [ ], 如果tmp为true则跳转到IL_0005
IL_001a 2a ret ; 从函数返回
生成的HIR结构
Importing BB02 (PC=000) of 'ConsoleApplication.Program:Main(ref)'
[ 0] 0 (0x000) nop
[000004] ------------ * stmtExpr void (IL 0x000... ???)
[000003] ------------ \--* no_op void
[ 0] 1 (0x001) ldc.i4.0 0
[ 1] 2 (0x002) stloc.0
[000008] ------------ * stmtExpr void (IL 0x001... ???)
[000005] ------------ | /--* const int 0
[000007] -A---------- \--* = int
[000006] D------N---- \--* lclVar int V01 loc0
[ 0] 3 (0x003) br.s
[000010] ------------ * stmtExpr void (IL 0x003... ???)
[000009] ------------ \--* nop void
Importing BB03 (PC=005) of 'ConsoleApplication.Program:Main(ref)'
[ 0] 5 (0x005) nop
[000025] ------------ * stmtExpr void (IL 0x005... ???)
[000024] ------------ \--* no_op void
[ 0] 6 (0x006) ldloc.0
[ 1] 7 (0x007) call 0A00000C
[000029] ------------ * stmtExpr void (IL 0x006... ???)
[000027] --C-G------- \--* call void System.Console.WriteLine
[000026] ------------ arg0 \--* lclVar int V01 loc0
[ 0] 12 (0x00c) nop
[000031] ------------ * stmtExpr void (IL 0x00C... ???)
[000030] ------------ \--* no_op void
[ 0] 13 (0x00d) nop
[000033] ------------ * stmtExpr void (IL 0x00D... ???)
[000032] ------------ \--* no_op void
[ 0] 14 (0x00e) ldloc.0
[ 1] 15 (0x00f) ldc.i4.1 1
[ 2] 16 (0x010) add
[ 1] 17 (0x011) stloc.0
[000039] ------------ * stmtExpr void (IL 0x00E... ???)
[000035] ------------ | /--* const int 1
[000036] ------------ | /--* + int
[000034] ------------ | | \--* lclVar int V01 loc0
[000038] -A---------- \--* = int
[000037] D------N---- \--* lclVar int V01 loc0
Importing BB04 (PC=018) of 'ConsoleApplication.Program:Main(ref)'
[ 0] 18 (0x012) ldloc.0
[ 1] 19 (0x013) ldc.i4.3 3
[ 2] 20 (0x014) clt
[ 1] 22 (0x016) stloc.1
[000017] ------------ * stmtExpr void (IL 0x012... ???)
[000013] ------------ | /--* const int 3
[000014] ------------ | /--* < int
[000012] ------------ | | \--* lclVar int V01 loc0
[000016] -A---------- \--* = int
[000015] D------N---- \--* lclVar int V02 loc1
[ 0] 23 (0x017) ldloc.1
[ 1] 24 (0x018) brtrue.s
[000022] ------------ * stmtExpr void (IL 0x017... ???)
[000021] ------------ \--* jmpTrue void
[000019] ------------ | /--* const int 0
[000020] ------------ \--* != int
[000018] ------------ \--* lclVar int V02 loc1
Importing BB05 (PC=026) of 'ConsoleApplication.Program:Main(ref)'
[ 0] 26 (0x01a) ret
[000042] ------------ * stmtExpr void (IL 0x01A... ???)
[000041] ------------ \--* return void
具体的可以看文章:老农的博客,也就是.net core这本书的作者
4.LIR结构
1.基础块
LIR中基础块不再包含语句,包含执行顺序平坦化后的各个语法树节点。
2.语法树节点
依然表示一个值或一个操作,但节点函数被明确化,不再依赖上下文,不需要像HIR一样通过上下文查询父子节点确定当前节点的含义。
Trees after IR Rationalize
-------------------------------------------------------------------------------------------------------------------------------------
BBnum descAddr ref try hnd preds weight [IL range] [jump] [EH region] [flags]
-------------------------------------------------------------------------------------------------------------------------------------
BB01 [00000000024701F8] 1 1 [???..???) i internal label target LIR
BB02 [0000000002473350] 1 BB01 1 [???..???)-> BB04 ( cond ) internal LIR
BB03 [0000000002473460] 1 BB02 0.5 [???..???) internal LIR
BB04 [0000000002473240] 2 BB02,BB03 1 [???..???) i internal label target LIR
BB05 [0000000002470470] 1 BB04 1 [000..005)-> BB07 (always) i LIR
BB06 [0000000002470580] 1 BB07 1 [005..012) i label target gcsafe bwd LIR
BB07 [0000000002470690] 2 BB05,BB06 1 [012..01A)-> BB06 ( cond ) i label target bwd LIR
BB08 [00000000024707A0] 1 BB07 1 [01A..01B) (return) i LIR
-------------------------------------------------------------------------------------------------------------------------------------
------------ BB01 [???..???), preds={} succs={BB02}
N001 ( 0, 0) [000000] ------------ nop void
------------ BB02 [???..???) -> BB04 (cond), preds={BB01} succs={BB03,BB04}
N001 ( 3, 10) [000043] ------------ t43 = const(h) long 0x7f95ea870610 token
/--* t43 long
N002 ( 5, 12) [000044] ------------ t44 = * indir int
N003 ( 1, 1) [000045] ------------ t45 = const int 0
/--* t44 int
+--* t45 int
N004 ( 7, 14) [000046] J------N---- t46 = * == int
/--* t46 int
N005 ( 9, 16) [000054] ------------ * jmpTrue void
------------ BB03 [???..???), preds={BB02} succs={BB04}
N001 ( 14, 5) [000047] --C-G-?----- call help void HELPER.CORINFO_HELP_DBG_IS_JUST_MY_CODE
------------ BB04 [???..???), preds={BB02,BB03} succs={BB05}
------------ BB05 [000..005) -> BB07 (always), preds={BB04} succs={BB07}
( 1, 1) [000004] ------------ il_offset void IL offset: 0
N001 ( 1, 1) [000003] ------------ no_op void
( 1, 3) [000008] ------------ il_offset void IL offset: 1
N001 ( 1, 1) [000005] ------------ t5 = const int 0
/--* t5 int
N003 ( 1, 3) [000007] DA---------- * st.lclVar int V01 loc0
( 0, 0) [000010] ------------ il_offset void IL offset: 3
N001 ( 0, 0) [000009] ------------ nop void
------------ BB06 [005..012), preds={BB07} succs={BB07}
( 1, 1) [000025] ------------ il_offset void IL offset: 5
N001 ( 1, 1) [000024] ------------ no_op void
( 15, 7) [000029] ------------ il_offset void IL offset: 6
N003 ( 1, 1) [000026] ------------ t26 = lclVar int V01 loc0
/--* t26 int arg0 in rdi
N005 ( 15, 7) [000027] --C-G------- * call void System.Console.WriteLine
( 1, 1) [000031] ------------ il_offset void IL offset: 12
N001 ( 1, 1) [000030] ------------ no_op void
( 1, 1) [000033] ------------ il_offset void IL offset: 13
N001 ( 1, 1) [000032] ------------ no_op void
( 3, 3) [000039] ------------ il_offset void IL offset: 14
N001 ( 1, 1) [000034] ------------ t34 = lclVar int V01 loc0
N002 ( 1, 1) [000035] ------------ t35 = const int 1
/--* t34 int
+--* t35 int
N003 ( 3, 3) [000036] ------------ t36 = * + int
/--* t36 int
N005 ( 3, 3) [000038] DA---------- * st.lclVar int V01 loc0
------------ BB07 [012..01A) -> BB06 (cond), preds={BB05,BB06} succs={BB08,BB06}
( 10, 6) [000017] ------------ il_offset void IL offset: 18
N001 ( 1, 1) [000012] ------------ t12 = lclVar int V01 loc0
N002 ( 1, 1) [000013] ------------ t13 = const int 3
/--* t12 int
+--* t13 int
N003 ( 6, 3) [000014] ------------ t14 = * < int
/--* t14 int
N005 ( 10, 6) [000016] DA---------- * st.lclVar int V02 loc1
( 7, 6) [000022] ------------ il_offset void IL offset: 23
N001 ( 3, 2) [000018] ------------ t18 = lclVar int V02 loc1
N002 ( 1, 1) [000019] ------------ t19 = const int 0
/--* t18 int
+--* t19 int
N003 ( 5, 4) [000020] J------N---- t20 = * != int
/--* t20 int
N004 ( 7, 6) [000021] ------------ * jmpTrue void
------------ BB08 [01A..01B) (return), preds={BB07} succs={}
( 0, 0) [000042] ------------ il_offset void IL offset: 26
N001 ( 0, 0) [000041] ------------ return void
备注:LIR中基础块BB01-BB04由内部添加的基础块转换而来,即HIR中的BB01转换而来。
4.HIR常见结构
HIR刚建立的常见结构,后面会进行变形优化合理化等阶段后会发生变化。
1.算术操作-ADD:部分操作拥有两个子节点,如加减乘除等二元运算,部分只有1个节点,如取负,逻辑否等一元运算。
2.赋值操作-ASG:将右边子节点的值,赋值给左边子节点对应的变量或内存位置中。
3.调用方法-CALL:不同调用有不同的结构,静态调用,直接调用,子节点为参数;成员非虚方法调用,子节点包含参数(以及成员自己,理解为this对象);成员虚方法调用,子节点为参数(也包含成员自己);委托调用(子节点为参数,包含委托实例对象自己);JIT帮助函数调用。
4.访问字段-FIELD:如果是引用类型,子节点先获取变量,然后再直接是访问字段结构,如果是值类型,获取变量后,还需要获取指向值的内存地址,再才能访问字段结构。
5.获取指向值的内存地址-ADDR:可以将其看做为C语言中获取目标所在地址的“&”操作符,例如使用ref或out传递参数时,或者访问对象字段或数组元素。
6.获取地址指向的值-IND:可将其看做C语言中“*”操作符,例如读取传到函数里面的ref或out参数,指向的对象。
7.访问数组元素-INDEX:左边子节点是数组对象,右边子节点是访问的下标
8.抛出异常-CALL IL_Throw:子节点是异常对象,一般关联的语句是new 一个异常对象。抛出异常的指令只能在基础块的最后。
9.条件性跳转-JTRUE:条件成立则跳转到指定基础块,不成立则执行相邻下一个基础块。跳转指令也只能出现在基础块的最后。
10.无条件跳转:没有对应节点,跳转目标直接包含在来源基础块的属性中
11.从函数返回-RETURN:可以有0到1个子节点,0个子节点表示函数返回类型是void.
12.逗号表达式-COMMA:先评价左边节点,忽略左边节点的值,然后评价右边节点,将其作为自身的值,用于做一些检查操作。
13.三元条件运算符-QMARK:左子节点是条件节点,右子节点是COLON冒号节点,如果条件节点成立,则使用冒号节点的右子节点值,如果不成立,则使用冒号节点左子节点的值。
14.SWITCH语句-SWITCH:相邻的值会合并到一个跳转表避免执行多次比较(前面记录的IL代码中的SWITHC指令时,也有类似操作),不相邻的节点值,会单独生成比较语句。其中每个CASE会单独生成一个基础块,用于被跳转。
四、IL解析
1. 创建本地变量表
在解析IL之前,JIT编译器会创建一些相关的数据结构,例如本地变量表、基础块列表、异常处理表。
本地变量表是一个存储了各个本地变量信息的列表。
本地变量表种类:this参数,返回结构体类型的地址,参数数量(函数具有不定参),泛型类型信息,函数的参数,函数内部定义本地变量。
所有的本地变量都会用同一个本地变量表管理,进入函数时,从栈空间统一分配大小。本地变量的信息包含一些属性,例如:本地变量是否为参数(参数类型本地变量值,由调用端设置,非参数类型进入函数值清零),本地变量是否必须保存在栈空间(部分本地变量可一直保存于寄存器中,提升了访问速度)。
2. 创建基础块列表
JIT编译器会预先创建基础块列表,并划分IL指令,指定各个IL指令属于哪一个基础块。JIT划分指令时,会参考异常处理表与跳转流程。
如果IL指令是跳转指令,则下一条指令会划分到新的基础块,如果IL指令是其他IL指令的跳转目标,这一条指令会划分到一个新的基础块。
同样的,如果遇到try,catch,finally块中第一条指令,则这条指令划分到新的基础块,如果遇到最后一条指令,则下一条指令划分到新的块中。
此外,如果是显示抛出异常throw指令,或者从函数返回的ret指令,下一条指令也会划分到新的块。
3. 创建异常处理表
JIT编译器会基于IL元数据中的异常处理表,生成JIT编译器过程中使用的异常处理表,最后再生成原生代码使用的异常处理表。JIT编译器过程中使用的异常处理表,可以有0到多个异常处理项,每个异常处理项拥有属性:try区域,第一个基础块,最后一个基础块,开始的IL偏移值,结束的偏移值;处理区域(catch或fianlly),第一个基础块,最后一个基础块,开始的IL偏移值,结束的偏移值;过滤区域(when)第一个基础块,开始的IL偏移值;捕捉的异常类型。
4. 构造语法树
解析IL指令向对应的基础块添加语法树节点。
具体来说,根据不同的IL指令,构建不同类型(例如类型GenTreeLclVar)且标识不同(例如标识LCL_VAR)的语法树节点。根据不同的节点类型,会将这个节点从JIT编译器内部表示评价堆栈的堆栈结构中,进行存放与取出节点操作。
最终会将所有语法树节点连成语法树结构,如果一个基础块对应的IL构建的语法树节点完毕,则编译器会创建一个语句添加到关联的基础块中,并将生成的语法树节点与语句关联起来。至此,一个基础块的内容就填充好了,多个基础块以此类推,全部填充完毕,则HIR结构初始构建完毕。
五、函数内联
函数内联指编译时,把被调用的函数代码嵌入到调用函数中,并修改代码结构以适应调用函数处理。.NET函数内联发生在JIT或AOT编译时,实际不会生成内联后的C#或IL代码,只会生成内联后的汇编代码与机器码。
内联函数可以减少调用函数的开销:进出函数有一定的固有指令,传递参数会复制值到寄存器或栈中。
满足一定条件,内联会提升性能,如果不满足条件,内联会使得性能更差。如:调用函数过大,使得CPU缓存命中率下降,寄存器无法得到充分利用。
1. 内联的条件
.NET2.1中的条件,任一条件满足则不会进行内联。
- 没有启用代码优化
- 函数是尾调用
- 函数为CLR内部使用的帮助函数
- 通过内存地址间接调用(委托)
- 虚方法
- catch或when中调用
- 同步函数
- 包含try块
- 包含不定参数数量
- 普通本地变量超过32个
- 普通参数超过32个
- 函数体过大
- 调用了堆栈跟踪接口
- 调用了虚方法
备注:这些条件只是其中一部分判断,且会随着.NET升级不断变化,优化
尾调用:在返回语句中,调用自身的函数,属于特殊的递归调用。开启优化时,可能会转换为循环。转换不属于内联优化的内容,所以内联不负责处理尾调用函数。
2. 内联的处理
如果编译器决定内联一个函数调用,先创建一个子编译器处理被调用函数,用于解析被调用函数的IL与构建HIR结构,发现被调用函数的本地变量将本地变量添加到父编译器的本地变量表中。子编译器构建好HIR结构后,将访问函数参数的节点,替换为传入参数使用的表达式(如:xxx(a+b),fun xxx©,函数里面的c会换成a+b结构)。如果这个表达式有副作用,创建一个临时变量保存(此处可能会单独自己生成一个基础块),在父编译器中,调用函数的节点会替换为子编译器返回表达式。
如果子编译器HIR只有一个基础块,则掺入到父编译器HIR中调用函数的位置,如果有多个基础块,父编译器调用函数节点的基础块,分割为两个基础块,子编译器基础块插入到两个基础块中间。
完成后,子编译器退出,后续处理都在父编译器中进行。
总结
本节主要记录,JIT编译器的触发流程与机制,解释了IL结构,与JIT编译器中主要处理的IR结构(HIR,LIR),以及IL解析的过程,和函数内联的相关知识。
下一篇开始重点记录IR变形等后续JIT编译器处理的重要步骤,记录IL如果一步步变为可执行的汇编与机器码的过程。