.net core底层入门学习笔记(十二-JIT详细流程IL变形)

本文详细介绍了.NET Core JIT编译器在优化阶段的IR变形和流程分析过程,包括添加内部代码、提升构造体、标记暴露地址的本地变量、基础块节点变形等IR变形技术,以及计算前任与后任基础块、插入GC检测点等流程分析步骤。此外,还讨论了本地变量排序、评价顺序定义、SSA和VN构建、循环优化等重要概念,展示了JIT如何提升代码性能。
摘要由CSDN通过智能技术生成

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

本篇主要记录IR变形与流程分析过程


<font color=#999AAA


一、IR变形

IR变形阶段包含了大量针对HIR结构的处理,这个阶段的目标是对HIR结构进行正规化处理,让HIR结构更加面向目标平台,为接下来的优化阶段做准备。书里只介绍了一些重要的部分,整个过程还有很多零碎处理。

1.添加内部代码

JIT编译器会为函数添加需要的内部代码,例如C#中提供的功能:Synchronized属性标签。JIT编译器会为函数出入try块,进入函数位置,调用内部函数JIT_MonEnterWorker(this)语句用于获得线程锁,同时也添加对应的内部释放线程锁函数JIT_MonExitWorker(this)。

2.提升构造体

提升构造体尝试把值类型的本地变量(栈上分配)的每个字段当作是独立的本地变量。在本地变量表为每个值类型字段创建一个新的项目,HIR中访问值类型本地变量字段的节点,替换为访问本地变量的节点。注意,本地变量表中,代表这个值类型的变量,也会保留在本地变量表中,部分语法树节点需要访问整个值类型对象进行处理,不能删除。
提升构造体,转换为本地变量后,许多针对本地变量的优化可以值类型字段进行优化,同时本地变量可以存储在CPU寄存器中,比栈拥有更快的访问速度。提升构造体,需要满足一定条件:编译时的优化选项,是否包含SIMD指令,构造体HFA类型,构造体大小,构造体字段个数,公用字段,包含非基元类型,特殊对齐字段等。上面这些条件都会影响是否提升构造体的判定。

3.标记暴露地址的本地变量

有时候需要获取本地变量的内存地址,需要获取内存地址的变量会被标记为暴露地址变量。标记了的变量需要保存在栈上不能只保存在寄存器中,用于获取内存地址,而不是寄存器地址。
当本地变量为构造体,且被提升了,这标记为依赖式提升,它的字段转换为本地变量时,也不能只保存在寄存器中,但他们依然可以作为本地变量参与优化流程。

4.对基础块中各个节点进行变形操作

IR变形阶段会遍历每个语法树节点,做出需要的变形。

  • 转换目标平台不支持的运算操作到内部函数:如果运算操作在目标平台不支持,无法生成对应的机器码,则JIT编译时,将其转换为内部函数的调用,以模拟支持对应的运算。
  • 为隐式异常抛出添加基础块:部分操作会隐式抛出异常,在IL中不会体现这些隐式的异常抛出指令,这对这些隐式的操作,JIT编译器会插入新的基础块,如果一个函数中有多个隐式异常,则使用相同一个基础块。这里仅添加抛出异常代码,检查是否抛出的代码,在生成汇编时添加。
  • 转换字符串常量节点:导入IL时,字符串常量作为一个普通常量节点导入。实际应该为一个对象,JIT编译器需要转换字符串常量节点到获取字符串对象节点。如果字符串常量不在抛出异常的基础块中,则字符串对象立刻在字符串池中获取或构建,节点替换为对保存字符串对象的内存地址访问。如果在抛出异常基础块中,节点替换为JIT帮助函数JIT_StrCNS的调用,实际需要才构建(因为判定为使用频率低)
  • 转换字段访问到指针操作:替换访问字段节点为指针操作节点,实际访问就是:对象地址+类型信息+(根据字段位置,前一个字段大小,对齐等)。
  • 转换到等效形式:部分语法树可以转换到它的等效形式,能够带来更好的性能(如 x>=1 变为x>0)
  • 消除三元条件运算节点:三元运算节点,会导致基础块内部出现流程控制,IR变形将其拆解为普通结构,即if-else结构结构,并添加基础块。如果QMARK节点被使用,则生成一个临时变量。

二、流程分析

1.计算前任基础块与后任基础块

前任基础块表示可能会在自身之前执行的基础块,后任则是可能会在自身之后执行的基础块。一个基础块可以有0到多个前任,和0到多个后任,后任的数量根据基础块的结束类型而定。各个基础块的结束类型与后任基础块信息已经在构建HIR时生成,流程图分析阶段会根据后任基础块信息,生成前任基础块列表,并记录到各个基础块关联的数据结构中。
前任基础块需要基于后任基础块决定,而后任基础块又基于基础块结束类型决定。基础块结束类型:

  • 默认,基础块执行完毕后,执行下一个相邻的基础块,只有一个后任
  • 无条件跳转,执行完后无条件跳转到另一个基础块,只有一个后任
  • 条件跳转,最后一条语句是条件跳转语句,条件成立跳转目标基础块,条件不成立执行相邻下一个基础块,有两个后任
  • switch,最后一条语句是switch语句,跳转到哪个基础块根据switch值而定,后任数量基于case数量
  • leave,执行完后无条件跳转到另外一个基础块,根据IL中leave指令生成,只用在try或catch结尾,只有一个后任
  • endfinally,执行完后无条件跳转到另外一个基础块,根据IL中endfinally指令生成,只用在finally范围最后,只有一个后任
  • throw,最后一条语句显示抛出异常,没有后任
  • return,最后一条语句从函数返回,没有后任

2.计算边缘权重

边缘指的是基础块之间到路径,流程分析阶段会计算各个边缘到权重,找出哪些路径执行次数最多,利用权重信息调整基础块顺序。

3.调整基础块顺序

计算完边缘权重后,JIT编译器可以预计哪些基础块最有可能被频繁执行,把不频繁执行到基础块移动到基础块列表到最后,把频繁执行到基础块在内存中,可以提升CPU缓存命中率。此外JIT编译器支持将一个函数到代码分别保存在不同到内存区域,这样多个函数频繁执行到代码可以更加家中。

4.计算可到达的基础块

JIT编译器会计算可以到达到各个基础块集合,然后找出哪些基础块不可到达,函数中一定不会进入到基础块,删除这些不会执行的基础块。

5.计算支配与支配边界

支配基础块指的是一定在自身之前执行的基础块,支配边界指是支配停止的基础块。具体可分为支配(包含自身),严格支配(不包含自身),直接支配(不经过其他基础块)。JIT编译器会计算各个基础块到直接支配基础块,并保存到关联到数据结构中。

6.插入GC检测点

GC执行过程中,需要停止线程,前面有记录到地址劫持技术,即不在GC安全点的函数执行,需要将函数的返回地址替换为内部代码,然后等待GC结束。如果函数运行时间过长,则会导致GC整体时间变长。为了解决这个问题,流程分析会找出所有向前跳转到基础块(通常是循环到开始或末尾),在这些基础块最后添加对内部函数JIT_PollGC的调用,内部函数会检查是否需要停止线程。

7.添加小函数

JIT编译器会在x86 32位windows以外到平台上为catch,when,fianlly中到代码生成小函数。让运行时内部处理异常到代码更加简洁与容易维护。进入JIT后端时,针对这些小函数生成汇编函数头与函数尾。.net运行时,捕捉到异常并且找到处理器后,会调用处理器对应到小函数,传入异常对象与处理器所在主函数信息。这样异常捕捉与处理,就能统一封装起来到函数处理。

三、本地变量排序

1.根据引用计数排序本地变量

本地变量排序阶段,会枚举各个语法树节点,统计本地变量到引用次数。根据引用次数生成排序后到本地变量列表,再根据排序决定哪些变量可以被跟踪,哪些变量可以作为寄存器候选变量。
标记为被跟踪到变量,可以在之后参与使用SSA与VN的优化,标记为寄存器候选变量,可以在之后参与寄存器分配。这个阶段到目的,针对频繁使用到本地变量使用更多到资源优化。地址暴露的变量,都不会标记为被跟踪与寄存器候选变量。

四、评价顺序定义

1.决定语法树节点到评价顺序

根据语法树节点的体积成本与运行成本,决定语法树节点的评价顺序。对于评价顺序不重要到节点,如果节点本身计算无副作用,则把成本更高到节点调整为先评价。主流CPU使用流水线机制可同时执行处理多条指令(查看汇编那一节到CPU流水线记录),如果将成本更高到指令放在前面,可能会缩短整体执行时间。这个阶段只会计算成本、交换节点或是设置反序评价标记(部分节点到要求先评价右边节点,如赋值节点)。

五、变量版本标记

1.SSA

静态单赋值形式,要求每个本地变量只能被赋值一次。.NET中RyuJIT将引用本地变量到节点标记变量的版本,同一个版本只能赋值一次。针对同一个变量的第二次赋值会生成一个新到版本号。同一个版本到本地变量,JIT可以知道他们到值肯定是相同。
SSA形式中有一个特殊函数φ,可以合并来自不同分支的多个变量,在IR中利用φ函数可以创建一个新的变量版本并标记他们来源于哪些版本。φ语句实际不会运行,只是用于提供给编译器内部分析代码使用。
具体可查看文章:静态分析之数据流分析与 SSA 入门

2.构建SSA

变量版本标记阶段,会给每个引用本地变量的节点。
分为三种:赋值,使用,使用并赋值。
具体步骤:

(1). 计算基于基础块到本地变量生命周期信息

计算内容:

  • 哪些本地变量在基础块中第一次引用是使用,要求本地变量的值在进入此基础块前已经定义
  • 哪些本地变量在基础块中第一个引用是赋值,不要求本地变量的值在进入前已经定义
  • 哪些本地变量进入基础块时是存活的,即会被当前基础块使用
  • 哪些本地变量离开基础块是存活的,即会被当前基础块可达后任基础块使用

(2)添加φ函数

基于支配边界与生命周期信息,插入φ语句。即为那些要求进入当前块已定义,但是不明确在哪个基础块定义的本地变量插入φ语句,此语句总是在基础块的顶部。

(3)分配SSA版本

JIT编译器为每个本地变量关联一个计数器与版本堆栈。按基础块顺序,从第一个基础块枚举所有基础块,处理基础块中所有引用本地变量到节点。碰到赋值的节点,计数器生成一个新到版本并且推入版本堆栈;如果碰到使用节点,则使用版本堆栈中最后一个版本;如果碰到又使用,又赋值的节点,则使用版本堆栈中最后一个版本,同时又生成一个新到版本推入版本堆栈。基础块处理完毕后,如果后任中有φ语句,则对应到变量在版本堆栈中到最后一个版本会添加到φ节点下。

(4)VN

VN又称为值编号,是SSA到扩展。SSA只关联到访问本地变量到节点,VN会关联到IR中每个拥有值的节点。两个VN值相同,那么节点值一定相同。VN的作用可以找出哪些表达式到的值相同(尽管表达式可能不同)。VN可以用于实现替换拥有相同值到本地变量,让那些具有相同值到表达式只评价一次和传播常量等优化。

2.构建VN

编译器在计算SSA之后计算VN,JIT编译器中有两种VN。自由主义VN:假设堆中保存的变量只会在线程同步点改变;保守主义VN:假设堆中保存的变量可以在任意两次访问之间改变。HIR节点会关联到以上两种类型到VN,自由主义VN用于公共子表达式消除优化中,保守主义VN会用在断言传播优化中。
JIT编译器在计算VN时,会使用编号仓库机制,在编号仓库中表达式作为一个键来索引到具体到VN值。例如 b = a;b+1;c =a ;c+1;此时到VN值有,a关联VN值,b与c关联到VN值与a相同,1的表达式VN值相同(在第一次引用常量时分配了),这样b+1与c+1 的VN表达式也相同,则他们到VN值相同。
自由主义与保守主义区别在于第二次从堆上变量读取值,自由主义如果此时没有遇到同步点,则使用第一次读取创建的VN,保守主义总是会创建新的VN。
区分保守主义与自由主义的意义在于,保守主义VN可以防止断言传播优化后,.net在多线程环境下没有做好同步出现到问题(因为每次都会分配新的VN,会判定他们不相同);自由主义可在不涉及底层与NULL到数组边界检查带来更好到效果(后面会记录到)。

3.CSSA 与 TSSA

.net的RyuJIT使用给原有变量标记版本的方式实现SSA。另外一种方式,使用重命名本地变量来实现SSA。每次对变量赋值后,产生新到变量,而之后到优化阶段,可能会让同一本地变量衍生到变量出现生命周期重合。如果生命周期重合,意味着最后无法合并到一个变量中,称为TSSA,如果衍生变量在各自生命周期中没有重合的,即可以合并到原来的变量中,则称为CSSA。.net编译器达到到效果就是CSSA。

五、循环优化

1.循环的结构

循环优化阶段,首先根据流程分析找到HIR中循环,循环组成部分:

  • HEAD:跳转到ENTRY基础块的基础块,在循环体之前,且并不属于循环体
  • FIRST:循环体中的第一个基础块
  • TOP:每次循环开始的基础块,即从BOTTOM基础块向前跳转的基础块,通畅与FIRST相同
  • BOTTOM:循环体最后一个基础块,跳转到TOP的基础块
  • EXIT:离开循环体最后一个基础块
  • ENTRY:第一次进入循环体基础块,不一定是TOP或BOTTOM,但只能有一个

注意:部分基础块可能为同一个基础块,例如FIRST与TOP,BOTTOM、EXIT、ENTRY可能为一个基础块。
在RyuJIT中,循环通常以条件判断在底部的形式构建,可以减少每次循环中跳转的指令数量。如果在顶部,则需要进行一次无条件跳转(从底转到顶,用于条件判断),外加一次不成立的有条件跳转(跳出循环)。判断条件在底部,只需要进行一次有条件跳转(条件成立跳转到循环体FIRST)。
条件成立时,两种方式跳转次数相同,减少跳转指令,能够提升CPU分支预测的准确率(避免浪费了CPU记录分支预测的缓冲区容量)。

2.循环反转

循环反转是一项编译器优化技术,用于把while形式的循环包含在if中,再转换成do while的形式循环。JIT编译器会将判断语句赋值一份,反转其中的条件,插入到HEAD基础块的最后(不生成新的基础块),修改HEAD基础块从无条件跳转换成有条件跳转。
循环反转的主要意义在于循环初始条件为真时,可减少两次跳转,(即一次从HEAD无条件跳转,和一次条件成立时,从ENTRY到FIRST的跳转)。且能够提升CPU预测准确性(分离了初始条件与继续条件,真假的模式更为有规律,从而提升了预测准确性)

3.循环克隆

循环克隆,又称为判断循环外提,是一项编译器优化技术,用于把循环中的判断条件移动到循环外。并把循环体复制一份分别到这个判断为真与为假时的情况。
循环克隆用于消除针对数组边界检查,将绝对不会超出边界的数组循环(提前对边界进行检查),放到不会检查数组边界检查的分支中,能够提升性能。

4.循环展开

循环展开是一项编译器优化技术,用于复制循环内容以减少跳转指令数量。但是使用循环展开,会大幅增大代码体积,反而会降低CPU缓存命中率影响性能。所以循环展开,对循环内容的大小与展示次数有一定限制。
循环展开分为完全展开,与部分展开。完全展开,会按循环次数,将循环内容复制,删除循环判断代码,要求循环次数编译时已知,且循环次数有限制。
部分展开,将循环内容复制一定的次数,由编译器决定,修改循环条件变动量,乘以展开次数,最后在循环最后插入判断剩余次数的代码(总循环次数%展开次数),不要求总次数在编译时已知,对总次数也没有限制,但实现相对复杂。.net 2.1中只支持完全展开,在.net 5中已经优化。

5.循环不变代码外提

编译器优化技术,把循环中与循环无关并且没有副作用的表达式移动到循环外,使其只用评价一次,与循环无关的表达式:常量,SSA版本在循环外定义的本地变量,没有副作用的操作(不检查溢出的加减乘除等)。循环不变代码外提,通常与循环反转一起使用,使其至少执行一次循环时被评价。
RyuJIT,会在循环体之前创建一个新的基础块,作为HEAD基础块,重定向原有基础块到这个新的HEAD基础块,将循环体无关表达式复制到这个新的基础块中,后面的公共子表达式阶段,会为复制的表达式分配一个临时变量,然后根据运行路径,将后面相同的表达式替换为这个临时变量的引用,极大减少了指令操作。

总结

本篇主要记录JIT优化中的一些重要基础流程:IR变形,流程分析,本地变量排序,评价顺序定义,版本变量标记,循环优化。
其中比较重要的且是其他优化基础的流程,流程分析(构建基础块,调整基础块顺序),版本变量标记(构建SSA与VN,用于后续优化)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值