.net core底层入门学习笔记(十三-JIT后台阶段与机器码生成)

.net core底层入门学习笔记(十三)

本篇主要记录,基于前面流程分析,变量版本标记等过程后的一些优化操作



一、赋值传播

赋值传播阶段,会替换变量到其他拥有相同值(VN)的变量以减少赋值次数删除多余的本地变量。
RyuJIT实现赋值传播的流程:1.为每个本地变量分配一个栈结构,根据支配顺序枚举基础块,遇到变量赋值往栈结构推入新版本;遇到本地变量使用,则查找栈机构中最新的SSA版本的VN与当前的VN比较是否相同,如果相同则替换该本地变量。赋值传播中的VN是保守主义VN。

二、公共子表达式消除

简称CSE阶段,合并拥有相同值的表达式,使其只用评价一次,首次评价表达式的结果会存储在一个本地变量中,在后面重复使用。
不是所有公共子表达式都可以合并,有时候评价表达式本身比存取本地变量的成本更低。CSE阶段会根据之前在评价顺序定义阶段计算的成本信息来判断是否需要合并。
RyuJIT基于VN实现的CSE,不仅可以合并内容相同的表达式,也可以合并值相同的表达式。
编译器会构建一个键为VN,值是语法树节点的列表;枚举所有基础块中的语法树节点循环CSE候选,如果一个语法树节点满足则会加入到列表中:评价节点不会有副作用;评价节点成本高于一定值;节点带有值,且不是struct类型或浮点数类型;节点值不依赖循环;节点及子节点类型比较简单,算术操作、比较操作、读取本地变量或成员对象等。
JIT会为这些进入列表中的CSE候选,添加CSE赋值与CSE使用标记,如果表达式在当前路径中首次出现,则标记为CSE赋值,其他都是CSE使用。标记这些候选,基于基础块顺序,而非支配。之后JIT编译器检查CSE候选的成本,通过计算原有成本,与优化后成本进行比较(使用成本与赋值成本,依赖于本地变量是否可存放于寄存器中)。如果优化成本更小,则创建一个本地变量保存CSE候选的值,修改标记为CSE赋值的后续表达式,使用逗号表达式,让表达式评价后的值保存于这个本地变量,修改其他CSE使用表达式为对这个变量的使用。

三、断言传播

JIT编译器根据语法树节点与控制流程生成并传播断言,断言可以指示:

  • 某个值(VN)不等于null
  • 某个值不等于null
  • 某个值等于某个常量
  • 某个值不等于某个常量
  • 某个值的具体类型
  • 某个值的类型继承于某个类型
  • 某个值的可取范围
  • 某个数组的长度

断言可用于替换节点到常量、消除多余null检查、类型检查与数组边界检查等。

四、边界消除检查

JIT编译器尝试计算访问数组时使用的索引值可取范围,根据数组长度的断言,判断是否可以消除数组边界检查。主要通过本地变量版本标记,判定当前本地变量版本来源,确定正在使用的本地变量的取值范围,再与断言之后的数组长度进行比较,如果在范围内,则此次数组访问,不需要数组边界检查。

五、合理化

合理化是JIT编译器后端部分的第一阶段,用于转换HIR结构到LIR结构,LIR主要由基础块与语法树节点组成。合理化阶段会处理依赖于上下文的节点,例如LCL_VAR节点,可能用于读取值,也可能用于修改值,合理化阶段会将其意义明确。

1.转换HIR到LIR

HIR中各个节点的评价顺序,根据语句的顺序(控制基础块顺序)、节点结构(控制子节点读取顺序)、评价顺序定义(控制基础块内的顺序)而定。LIR中各个节点按照评价顺序连成一个列表,语句节点会变为IL_OFFSET节点,添加到属于该语句的节点前,IL_OFFSET节点用于指示哪些汇编指令根据哪条IL指令生成,生成除错信息(帮助实现调试功能)。

2.转换LCL_VAR节点

为了让节点含义明确化,不依赖上下文分析,合理化阶段使用一个新节点STORE_LCL_VAR表示修改本地变量的值,LCL_VAR只表示读取值。如ASG与LCL_VAR组成的赋值节点,会替换为STORE_LCL_VAR节点。

3.转换ADDR与IND节点

ADDR节点用于获取一个值所在的内存地址,IND节点用于获取一个内存地址指向的值。合理化阶段会将ADDR节点与子节点合并,并修改节点类型(例如:ADDR与LCL_VAR节点,合并为LCL_VAR_ADDR,即读取一个值所在内存地址);IND节点表示修改值的节点会修改为STOREIND(如ASG节点与IND节点,替换为STOREIND节点,表示修改一个内存指向地址的值),剩余的IND节点只表示读取内存指向值。

4.删除COMMA节点

在LIR中COMMA节点没有意义,因此会修改整个COMMA节点,变为先评价左边节点,再评价右边节点与赋值。COMMA节点本身会在这个阶段删除。

六、低级化

主要内容:转换LIR节点使其更加接近目标平台汇编指令,标记带有值的节点被包含节点与节点使用时是否需要先加载到CPU寄存器。

1. 分割long类型操作

如果目标平台是32位,分割long操作为针对两个int的操作。栈上变量仍是一个8字节数值,但是读取与运算会分割为两个节点。

2.转换算术运算到地址模式

如果目标平台支持lea指令(X86或X86-64),尝试转换算术运算到地址模式,可只用一条指令计算类似(x+y*n+z)的复杂算术运算。

3.转换除法运算和求余运算

JIT编译器尝试将除法与求余运算为成本更低的二进制运算。例如x/2转换成x>>1,x%2转换为x&1等。

4.转换switch节点

尝试分析case值,如果case比较少,则转换为多个if连续(貌似在生成IL代码编译器就已经优化过了);如果case是连续常量,则创建一个跳转表,根据值来跳转目标代码,多个if连续与跳转表可以混合使用。

5.针对函数调用添加PUTARG_REG与PUTARG_STK节点

调用函数传递参数会使用CPU寄存器或栈空间,使用寄存器还是栈空间由当前平台的调用规范制定。低级化阶段针对函数调用添加明确的PUTARG_REG与PUTARG_STK节点,明确指示参数应该存放在寄存器还是栈空间。

6.转换CALL节点

CALL节点可以代表多种不同的调用方式:普通函数调用,JIT帮助函数调用,委托调用,虚方法调用,接口方法调用。低级化阶段JIT编译器会明确如果处理这些调用。

(1)普通函数调用:查找函数入口点地址设置到CALL节点中
(2) JIT帮助函数调用:JIT编译器根据帮助函数的类型,查找具体的函数地址设置到CALL节点中
(3)委托调用
委托调用比较复杂,因为委托本身是一个对象,它的第一个字段是调用目标,第二个字段是函数信息,第三个字段是函数地址,第四个字段是辅助函数地址。其中第三个字段与第四个字段,具体的地址指向的内容,根据不同委托类型有不同的意义。例如静态函数的开放委托,第三个字段是调整参数用的代码地址,第四个字段才是真正的原始函数地址。
JIT编译器添加获取委托对象的第一个字段与第三个字段的节点,第一个字段值作为调用函数的第一个参数,第三个字段的值,作为函数地址。
第三字段保存的函数地址,不一定是创建委托时传入的原始函数地址。如果委托是静态开放委托,函数地址会指向一个调整参数用的代码地址,因为静态函数没有this指针,但是调用委托时会在第一个参数传入委托对象,这时调用静态函数需要的参数位置发生了改变(没有this,他需要的函数参数本来是第一个,变成了实际上第二个,因为委托对象占用了第一个,其他函数没有问题,因为其他函数第一个本身就需要this类型的对象),因为需要调用调整参数的代码地址,用于将参数位置还原。

(4)虚方法调用
因为地址不能在编译时确定,JIT编译器会添加运行时从类型信息的虚方法获取函数地址的节点。分三次获取到真实的函数地址,第一次从对象地址+0读取类型信息,第二次从类型信息+72读取虚方法表,第三次从虚方法表+32读取真实方法的入口点地址。
(5)接口方法调用
JIT编译器会为接口方法调用生成桩(Stub,与函数入口点的桩不是一个概念),先找到对应的实现,执行时比较对象是否一致,如果一致则使用此实现,如果不一致则根据类型重新解决,如果不一致次数超过一定值,桩假设方法是多态的,修改桩地址指向使用效率更高的解决方式的代码。

7.标记节点是否为被包含节点

被包含节点指那些带有值且值会嵌入到汇编指令的节点。例如CNS_INT节点 经常包含在STORE_LCL_VAR节点生成的汇编指令中:mov dword ptr[rbp+偏移值],常量值。其中常量值就是CNS_INT节点提供。低级化阶段会找到这些被包含节点,添加标记,后面扫描寄存器分配阶段看到此标记则不会为其分配寄存器,汇编指令生成阶段,会生成带立即数的指令。

8.标记节点被使用时是否需要先加载到CPU寄存器

默认情况下,如果一个节点的值被其他节点使用,并且节点不是被包含节点,那么节点的值需要先加载到CPU寄存器中。部分节点的值被使用时,无需先加载到寄存器中(如比较两个变量节点,第二个节点可以直接比较,寄存器中的值,和内存中的值(无需进入寄存器,再从寄存器取出值比较两个寄存器中的值))。这种被使用的节点标记为寄存器需求可选。扫描寄存器分配阶段会根据情况判断此节点是否需要分配寄存器。

七、线性扫描寄存器分配

1.寄存器分配

寄存器分配阶段会标记LIR中各个节点使用什么CPU寄存器,可分配的寄存器依赖于当前的目标平台。主要目标:1.给必须使用寄存器的操作分配寄存器;2.使用空余寄存器以提高执行性能。部分变量整个生命周期都可以放入到寄存器中,称为寄存器变量。注意地址暴露变量(此变量被使用时会访问地址的)不能称为寄存器变量,每次修改必须写入栈空间使得通过内存地址访问时获得正确的值。

2.线性扫描寄存器分配

主流的寄存器分配算法有图着色算法和线性扫描算法。图着色算法生产更好的结果,但需要更多编译时间,线性扫描算法分配结果较差,但编译时间更短。RyuJIT中使用的算法基于线性扫描算法。
JIT编译器按反向后序的顺序生成基础块列表。
再按照基础块列表与语法树节点生成以下数据结构:
1.位置信息
位置信息给基础块列表中的各个基础块与语法树节点分配一个自增数值,每次增加2
2.引用位置
引用位置标记哪些位置访问了变量或需要使用固定的寄存器。不仅包括本地变量,还包括函数的参数,返回结果等内部产生的变量。
引用位置包含了以下类型,并会根据类型关联基础块、语法树节点和使用期间。
BB:标记该位置是基础块的开始,关联基础块
ParamDef:定义了函数传入参数,关联使用期间
Def:标记写入变量,关联语法树与使用期间
Use:标记该位置读取变量,关联语法树与使用期间
FixedReg:标记该位置需要使用固定寄存器
Kill:让指定寄存器原有内容失效,通常用于标记函数调用后,调用者保存寄存器(调用函数后可能会被更改的寄存器)得内容可能会被覆盖。

3.使用期间

指定变量会在哪个位置范围中使用,会关联一个变量与多个引用位置,并指向寄存器。每次位置信息增加2,可分离读取与写入位置,读取类型引用会指向语法树节点对应的位置,写入类型会指向语法树节点的位置+1.

创建以上数据结构后,JIT编译器会按顺序枚举引用位置根据类型处理他们。

  • 对于类型是Use引用位置:
    如果关联的使用期间是活跃期间,那么值可从寄存器读取,不需要分配寄存器
    如果关联的使用期间是非活跃期间,尝试分配一个寄存器
    如果引用位置是使用期间中的最后一个,下一轮处理中设置使用期间为非活跃
  • 对于类型是Def的引用位置:
    如果关联的使用期间是活跃期间,那么值可以写入寄存器,不需要分配寄存器
    如果关联的使用期间是非活跃期间,尝试分配一个寄存器
  • 对于类型是FixedReg或Kill的引用位置:
    查找指向目标寄存器的活跃期间,如果存在则设置为非活跃(把寄存器的值保存在栈上然后释放寄存器)

给使用期间分配寄存器,有两轮处理。第一轮处理:尝试查找一个空余寄存器并使用,第二轮处理尝试把现有活跃期间期间设置为非活跃然后使用释放出来的寄存器。如果引用位置要求固定寄存器,那么分配时就只会使用改寄存器;如果不要求固定的寄存器,优先使用被调用者保存寄存器。

结论:如果一个函数包含大量计算,手动减少函数中的本地变量的数量,把频繁访问的变量定义在前面,可以带来更好的性能。

八、汇编指令生成

1.计算帧布局

汇编指令生成阶段在生成汇编指令之前,需要先计算帧布局。帧布局指函数运行时需要在栈空间上分配多大空间、以及本地变量与通过栈传入的参数在什么位置的信息。
(1)通过栈传入的参数
通过栈传入的参数在本地变量表中,所占的空间属于调用来源函数的帧,通过[rbp+偏移值]或[rsp-偏移值]访问
(2)影子空间
影子空间指64位Windows调用规范要求预留的空间,需要进入函数时分配,大小为4个指针
(3)返回地址
返回地址指返回上一个函数的地址,执行call指令时会自动添加。
(4)进入函数时帧寄存器值
如果使用帧寄存器,进入函数时需要通过push rbp指令备份帧寄存器的原始值。
(5)进入函数时被调用者保存寄存器的值
如果函数修改了被调用者保存寄存器,那么进入函数时需要通过push寄存器指令备份修改过的寄存器原始值
(6)内部变量
内部变量指.net运行时使用的变量,它们在本地变量表中,需要在进入函数时分配空间,通过rbp与rsp访问。
(7)普通本地变量
托管代码使用的变量,它们在本地变量表中,需要进入函数时分配空间,通过rbp与rsp访问。
(8)临时变量
线性扫描寄存器分配阶段计算出来的,需要暂存到栈空间但不在本地变量表的变量,需要进入函数时分配,通过rbp与rsp访问。
(9)PSPSym变量
异常处理小函数访问主函数本地变量时使用的变量,需要在进入函数时分配。
(10)通过栈传出的参数
调用其他函数时通过栈传出的参数,需要进入函数时分配,通过mov指令设置。

JIT编译器在计算帧布局时会根据当前平台计算需要哪些项目,为本地变量表中的参数、内部变量与普通本地变量分配偏移值,再计算进入函数时需要分配多大的空间。
计算帧布局需要注意是否使用帧寄存器,访问变量使用的方式也会有不同,一个是ebp一个是rsp寄存器。

2.生成汇编指令

JIT根据LIR生成汇编指令,并以指令对象与指令组的方式保存在JIT编译器内部。指令对象与目标平台上汇编指令是一对一关系。每个指令组中只有第一个指令对象可以作为跳转目标。LIR中语法树节点与汇编指令结构相似,但不是1对1关系,部分节点与其他节点合并为一条指令,部分节点会生成多条指令,部分节点不生成指令。
生成的指令组中,第一个指令组保存函数固定的开始部分:

  1. (如果使用帧寄存器)备份进入函数时,帧寄存器的值
  2. (如果使用帧寄存器)设置帧寄存器等于栈寄存器的值
  3. 备份函数可能修改的被调用者保存寄存器的值到栈空间
  4. 从栈空间一次性分配本地变量、通过栈传出参数、影子空间的合计大小。
  5. 清零本地变量值
  6. 设置内部变量初始值

最后一个指令组保存函数固定的结束部分:

  1. 恢复栈寄存器的值到进入函数时的状态(如果不使用帧寄存器,则直接添加预先计算好的大小)
  2. 恢复被调用者保存的寄存器的值到进入函数时状态
  3. 从函数返回

JIT编译器在开始生成汇编指令时,后续指令还没有生成,跳转指令偏移值无法计算,但可以计算出每个指令组的大小,得出指令组的偏移值,因此只有每个指令组的第一条指令可以作为跳转目标。

3.包含异常处理小函数的汇编

异常处理小函数的汇编生成比较复杂,主要是利用寄存器与PSPSym变量,来跟踪多层异常处理对应的每一层异常(每一层异常函数都有PSPSym变量), 同时需要保存主函数rbp值,用于异常处理小函数访问主函数本地变量值。

九、机器代码生成

1.生成机器码与元数据

根据上一个阶段创建的指令对象与指令组生成机器码与元数据,非动态函数:机器码会保存在当前AppDomain对应的托管函数代码堆,元数据保存在AppDomain对应的低频堆;动态函数:所属动态模块对应的托管函数代码堆与对应的低频堆。

汇编指令的编码方式根据目标平台而定,生成的机器代码可以被目标平台CPU直接执行。
生成机器码后,JIT编译器生成函数的元数据,包含函数头与函数索引。函数头包含.net运行时异常处理与GC等机制所需的信息。函数头索引指明内存中什么位置有托管函数的函数头。.NET运行时可以根据函数头索引与程序计数器找到某个函数的函数头,再获取函数头中的信息。

目前函数头包含:

  • 除错信息:用于实现程序调试,保存机器码偏移值到IL指令偏移值的索引及本地变量位置
  • 异常处理表:实现异常处理,保存什么异常在什么范围内抛出,应该由什么范围处理
  • GC信息:实现GC根对象扫描,保存什么时候什么寄存器与栈的什么位置有引用类型对象
  • 函数对象:保存了函数信息,用于获取函数名、函数签名和所在类等
  • 栈回滚信息:用于实现调用链跟踪,记录了栈寄存器信息和帧寄存器信息的变化。

十、函数头信息

1.除错信息结构

字节数组,真函数头指向这个数组指针,偏移值索引项可以有多项,关联机器码偏移值到IL偏移值,本地变量索引项,可以有多项,指示什么位置可以获取变量值。采用Nibble Stream格式编码,因为除错信息庞大,所以不会用固定大小来存储,使用这种方式可以降低内存消耗。

2.异常处理表结构

由异常处理项组成的数组,包括:处理项数量,类型,单个处理项字节数,标志,捕捉开始原生代码偏移值,结束原生代码偏移值,处理开始偏移值,处理结束偏移值,捕捉类型或过滤器位置。这些信息都是在抛出异常,处理异常需要的数据结构

3.GC信息结构

字节数组,保存格式很复杂,包含大量可选字段,以单位进行压缩,数值长度非固定。
包含:编码类型,标记,可选信息,函数调用位置数量,调用位置信息,可中断范围数量,可中断信息,栈上引用类型变量列表,寄存器引用类型变量列表,存活信息(块列表,存放各种变量的存活信息)

4.函数对象结构

在运行时内部关联托管函数的信息。运行时可通过函数对象获取函数名称,所在类以及从IL得到的元数据等。

5.栈回滚信息

包括主函数与小函数的信息,记录是否使用帧寄存器,对应哪个寄存器,帧寄存器的偏移值及栈寄存器的变化。
栈回滚信息可以用于实现调用链跟踪。

十一、AOT编译

.NET提供了AOT编译可以解决JIT启动每次重复编译的问题。
.NET提供了两个AOT编译工具:基于.NET Framework的Ngen工具以及基于.NET Core的CrossGen工具。他们的使用方式,镜像文件生成位置等,可以参考微软官方文档。

CrossGen生成的原生代码镜像保存在目标文件旁边,其生成的代码包含了IL与元数据,所以可以完全替代掉原有程序集文件来使用。
.net core 3.0以后,默认启用分层编译,有效改善了.net程序启动时间。

总结

.NET Core底层入门是一本很经典的书籍,这里我当了搬运工,把我自己学习到的知识点都搬家上来,有些东西我自己也不是很明白,留待以后再去查看。
本来有着雄心壮志,操起C++去看.net core源码,但现在微软更新速度太快了,且.net core(现在叫.net 6.0)实在博大精深,幸好有了这本书,可以从整体上了解.net core的全貌。
有人会疑问,感觉看了书之后没啥鸟用,确实在工作中基本用不到书中的90%内容,但是对底层了解,有助于理解.net的整个机制。只有对底层有了了解之后,再去从细枝末节去提升自己的代码能力,再来结合这些底层,才能发现:哦,原来如此。
建议有兴许的同学,都可以看看类似的书,以后兴许能自己开发一个类似的一整套解决方案呢,现在国外的公司确实牛逼,计算机这块还是老外的基础厚实,期待国人哪天也能搞出自己的来。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值