You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

18 KiB

37 | 从AST到IR体会数据流和控制流思维

你好,我是宫文学。

在上一节课我们已经初步认识了基于图的IR。那接下来我们就直接动手来实现它这需要我们修改之前的编译程序基于AST来生成IR然后再基于IR生成汇编代码。

过去我们语言的编译器只有前端和后端。加上这种中间的IR来过渡以后我们就可以基于这个IR添加很多优化算法形成编译器的中端。这样我们编译器的结构也就更加完整了。

今天这节课我先带你熟悉这个IR让你能够以数据流和控制流的思维模式来理解程序的运行逻辑。之后我还会带你设计IR的数据结构并介绍HIR、MIR和LIR的概念。最后我们再来讨论如何基于AST生成IR从而为基于IR做优化、生成汇编代码做好铺垫。

首先我还是以上一节课的示例程序为础介绍一下程序是如何基于这个IR来运行的加深你对控制流和数据流的理解。

理解基于图的运行逻辑

下面是上节课用到的示例程序一个带有if语句的函数它能够比较充分地展示数据流和控制流的特点

  function foo(a:number, b:number):number{
    let x:number;
    if (a>10){
      x = a + b;
    }
    else{
      x = a - b;
    }
    return x;
  }

我们把这个程序转化成图,是这样的:

图片

我们之前说了,这个图能够忠实地反映源代码的逻辑。那如果程序是基于这个图来解释执行的,它应该如何运行呢?我们来分析一下。

第1步从start节点进入程序。

第2步程序顺着控制流遇到if节点并且要在if节点这里产生分支。但为了确定如何产生分支if节点需要从数据流中获取一个值这个值是由“>”运算符节点提供的。所以“a>10”这个表达式必须要在if节点之前运行完毕来产生if节点需要的值。

第3步我们假设a>10返回的是true那么控制流就会走最左边的分支也就是if块直到这个块运行结束。而如果返回的是false那么就走右边的分支也就是else块直到这个块运行结束。这里if块和else块都是以Begin节点开始以End节点结束。如果块中有if或for循环这样导致控制流变化的语句那么它们对应的控制流就会出现在Begin和End之间作为子图。

第4步在if块或else执行结束后控制流又会汇聚到一起。所以图中这里就出现了一个Merge节点。这个节点把两个分支的End节点作为输入这样我们就能知道实际程序执行的时候是从哪个分支过来的。

第5步控制流到达Return节点。Return节点需要返回x的值所以这就要求数据流必须在Return之前把x的值提供出来。那到底是x1的值还是x2的值呢这需要由Phi节点来确定。而Phi节点会从控制流的Merge节点获取控制流路径的信息决定到底采用x1还是x2。

最后return语句会把所获取的x值返回程序结束。

在我这个叙述过程中,你有没有发现一个重要的特点,就是程序的控制流和数据流是相对独立的,只是在个别地方有交互。这跟我们平常写程序的思维方式是很不一样的。在写程序的时候,我们是把数据流与控制流混合在一起的,不加以区分。

比如针对当前我们的示例程序我们的源代码里一个if语句然后在if块和else块中分别写一些代码。这似乎意味着只能在进入if块的时候才运行x1=a+b的代码而在进入else块的时候才可以运行x2=a-b的逻辑。

但如果你把数据流和控制流分开来思考你会发现其实我们在任何时候都可以去计算x1和x2的值只要在return语句之前计算完就行。比如说你可以把x1和x2的计算挪到if语句前面去相当于把程序改成下面的样子

function foo(a:number, b:number):number{
  x1 = a + b;
  x2 = a - b;
  if (a>10){
    x = x1;
  }
  else{
    x = x2;
  }
  return x;
}

当然针对我们现在的例子把x1和x2提前计算并没有什么好处反倒增加了计算量。我的用意在于说明其实数据流和控制流之间可以不必耦合得那么紧,可以相对独立。

我们可以用这种思想再来分析下我们上节课提到的几个优化技术。

比如我们上一节课曾经提到过“循环无关变量外提”的优化技术。而基于当前的IR我们马上就会识别出其实与这个变量有关的数据流是跟循环语句的控制流没有依赖关系的所以自然就可以提到外面去。

如果采用CFG的数据结构我们需要把代码从一个基本块挪到另一个基本块这个过程比较复杂。而采用基于图的IR我们只需要在生成代码的时候再决定把数据流对应的代码生成到哪个基本块里就好了。

其实虽然llvm采用了CFG表示大的控制流逻辑但它同时也采用了use-def链来表示程序中的数据流逻辑因为优化算法需要同时用到这两方面的信息。但相对来说我们现在的IR让控制流和数据流更大程度地解耦了带来了算法上的便利。

而且这个例子中的数据流节点并不受限于if语句的控制流在任何时候你都可以计算它可以灵活地调整执行的先后顺序这个时候我们说它们是浮动floating节点。它们的计算顺序只受输入关系的限制。

我们后面还会遇到一些情况,比如数据流的某些节点没有那么自由,它们不可以随意改变计算顺序,那我们说这些节点是固定的。

好了你现在已经对基于IR的运行逻辑有了一定的理解了。那接下来我们就开始动手做实现吧首先我们要用TypScript设计一些数据结构来表示这种基于图的IR就像我们之前设计了一些数据结构来表示AST那样。

设计IR的数据结构

要表达这种基于图的IR重点就是设计各种各样的节点。而节点之间的连线呢,则是通过节点之间的互相引用来表示的。

我先设计了一个叫IRNode的基类其他的节点都是从这个基类派生的。

//基类
export abstract class IRNode{
}

IRNode有两个直接的子类DataNode和ControlNode。

DataNode是所有数据节点的基类。DataNode可能从别的DataNode获得输入也会成为其他DataNode的输入这样就构成了use-def链。这个链是双向的连接DataNode的子类只需要维护自己的input的一边uses是被使用到它的其他节点在构造函数里自动维护的。

//数据流节点的基类
export abstract class DataNode extends IRNode{
    //该节点的输入
    abstract get inputs():DataNode[];

    //使用该节点的节点形成use-def链,自动维护
    uses:DataNode[] = []; 

    //数据类型
    theType:Type;   
}

DataNode的一个子类是二元运算节点。在这里你可以看看其中的uses是如何被自动维护的

//二元运算节点
export class BiOpNode extends DataNode{
    left:DataNode;
    right:DataNode;

    constructor(left:DataNode, right:DataNode, theType:Type){
        super(theType);
        this.left = left;
        this.right = right;

        //自动建立双向的use-def链
        left.uses.push(this);
        right.uses.push(this);
    }

    get inputs():DataNode[]{
        return [this.left, this.right];
    }
}

IRNode的另一个子类ControlNode是各种控制节点的共同基类。控制节点可能有多个后序节点但最多只能有一个前序节点。

//控制流节点的基类
export abstract class ControlNode extends IRNode{
     //后序节点列表
     abstract get successors():IRNode[];

     //前序节点列表,自动维护
     predecessors:IRNode[] = []; 
}

在ControlNode的子类中我们只需要维护自己的后序节点形成正向的链接就好了。而前序节点是被自动维护的形成反向的链接。这样前后两个节点之间就有了双向链接。

ControlNode的一个子类是IfNode。它有两个后序节点并且还需要一个来自DataNode的输入作为if的条件。

//if节点
export class IfNode extends ControlNode{
    thenBranch:Begin;
    elseBranch:Begin;
    condition:DataNode;  //If条件
    
    constructor(condition:DataNode, thenBranch:Begin, elseBranch:End){
        super();
        this.condition = condition;
        this.thenBranch = thenBranch;
        this.elseBranch = elseBranch;

        thenBranch.predecessors.push(this);
        elseBranch.predecessors.push(this);
    }

    get successors():IRNode[]{
        return [this.thenBranch, this.elseBranch];
    }
}

基于这个思路我们可以设计出目前需要的各种IR节点如下图所示

图片

这里的具体实现,你可以看ir.ts

你看到这里肯定会有很多的问题。你可能会问为什么要设计出这样的节点IR中包含哪些类型的节点有没有什么依据呀这些节点怎么跟AST差不多呀那我们就来分析一下这几个IR设计的问题。

HIR、MIR和LIR

其实我们目前设计的IR节点都是抽象度比较高的。换句话说它跟AST在语义上是差不多的只不过是换了一种表示方式而已。这种比较贴近源代码的、抽象层次比较高的IR被叫做HIR

与之相对应的另一端是比较贴近机器实现的、容易转化成机器码或者汇编码的IR叫做LIR

我们具体说说HIR和LIR的区别也就是说抽象层级的差别到底体现在哪里。

首先,是一些控制流节点的差别。在HIR里你会见到像if节点这样的元素显然这种元素来自源代码。在LIR里像if节点这样的节点会被类似跳转指令的节点所代替。它们都是实现控制流的管理的但一个抽象层级更高一个更底层。

**第二,在数据节点方面也有区别。**在HIR里我们用加减乘数这样的节点来表达运算。每个运算节点可以由两个节点来提供数据计算结果保存到另一个节点这样一共是3个地址。而在LIR里有的CPU架构和指令集比如X86的架构是不支持三地址运算的只能把一个数据加到另一个数据上所以还必须进行IR的转换。

**最后,在数据类型方面也有很大差别。**高级语言中有丰富的类型系统你可以在HIR中使用它们。但到了LIR层面你只能使用CPU可以识别的数据类型比如各种不同位数的整型和浮点型。但是对象、数组这些通通都消失不见了。

通过这样的对比你大概能够明白HIR和LIR的区别了。

那最后位于HIR和LIR之间还有一种叫做MIR。它既能让我们人类比较容易理解,又能够尽量保持对特定硬件平台的独立性。

在传统的编译器中我们需要分别设计HIR、MIR和LIR然后实现依次的转换。这个过程就叫做Lower的过程。

在前面的课程里我们曾经使用了一些内部的数据结构比如Inst和Oprand来表示汇编代码这就可以看做是很Low很Low的IR了因为它很贴近X86架构的实现没有什么跨硬件平台的能力。就算这样我们仍然从中分出了两个层次。像函数调用、浮点数字面量的实现我们一开始还是采用比较抽象的Oprand之后再Lower到跟汇编代码能够完全一一对应的Oprand。

这里我要说明一下基于图的IR还有另一个重要的优点就是我们可以用同一种数据结构来表示HIR、MIR和LIR。它们的区别只是体现在不同的节点类型上。在Lower的过程中你可以用低抽象度的节点替换高抽象度的节点就行了。

好了我们现在理解了IR设计中的抽象层次问题。那接下来我们就要把AST翻译成IR。

把AST翻译成IR

我们一开始只需要把AST翻译成HIR。因为这两者在语义上是比较相似的所以翻译的难度比较低。相比而言我们之前直接从AST翻译成汇编代码中间的跨度就有点大需要处理的细节就很多。

首先我们看最简单的情况也就是没有if语句、for循环语句这种程序分支的情况。比如下面的代码

function foo(a:number, b:number){
    let x:number = a + b;
    let y:number = a + b;
    let z:number = x + y + 1;
}

在这种情况下a和b被翻译成参数节点1被翻译成常量节点每个表达式都被翻译成了一个运算节点这些节点也是x、y和z三个本地变量的定义。

图片

这里你要注意几个点。首先本地变量都是被参数、常数和其他变量定义出来的是数据流的中间节点。而参数和常量节点才可以是叶子节点。这是IR跟AST的一个很大的不同因为AST中本地变量是可以作为叶子节点的。

第二一个运算节点可能跟多个变量相关联比如示例程序中的a + b就既代表了x变量又代表了y变量。

最后上面的示例程序中还有一个现象就是在变量z的定义中出现了连续的加法运算。这个时候中间的一个+号节点并不对应某个变量的符号,这时候它相当于一个临时变量。

接下来我们把这个例子再复杂化一点让变量x做了第二次赋值。这个时候我们需要把这两个x区分开从而让生成的IR保持SSA格式。

function foo(a:number, b:number){
    let x:number = a + b;
    let y:number = a + b;
    x = a - b;
    let z:number = x + y + 1;
}

把x分解成x1和x2以后这个示例程序就相当于变成了下面的样子

function foo(a:number, b:number){
    let x1:number = a + b;
    let y:number = a + b;
    let x2 = a - b;
    let z:number = x2 + y + 1;
}

其中变量z的定义引用的是x2跟x1没有关系。所以说在直线式运行的代码中我们能很容易地对同一个变量的多个分身进行区分。我们总是采用最后一个分身的值。

不过,当存在控制流的分支的时候,要确定采用哪个分身的值,就没这么简单了,这也是Phi运算要发挥作用的时候。

现在我们就来讨论一下如何把if语句转化成IR。我们还是用这节课一开头的例子改成SSA格式以后大约相当于下面的伪代码

function foo(a:number, b:number):number{
  let x1:number;
  let x2:number;
  if (a>10){
    x1 = a + b;
  }
  else{
    x2 = a - b;
  }
  let x = phi(which-if-branch,x1,x2);  //根据if分支来确定使用x1还是x2。
  return x;
}

这里我用x1和x2代替了原来的x在if语句之后用了一个Phi运算来得到最后x的值。

if语句的控制流部分和条件部分我们可以根据这节课一开头我们的分析生成相应的节点就好了。这里涉及的控制节点包括IfNode、BeginNode、EndNode和MergeNode。在IfNode和MergeNode这里要跟数据流建立连接。

这里的具体实现,你可以参考ir.ts中的源代码。另外你还可以运行node play example_ir.ts --dumpIR命令这会把ir输出成.dot文件。.dot文件可以用graphviz软件打开查看你能看到编译生成的ir图。这里其实还有更简单的办法就是直接在visual studio code中打开并用预览模式查看图形。

这样我们就把if语句分析完了。下一节我会继续带你分析一个更复杂一点的例子就是for循环语句带你更加深入的掌握生成IR的思路从而也能够更加洞察这种IR的内在逻辑。

课程小结

今天课程的新内容也不少。我梳理一下其中的要点,希望你能记住:

首先在基于图的IR中控制流和数据流是相对独立的耦合度较低。数据流节点往往是浮动的并不像源代码里那样被限制在某个基本块中。这个特征有利于代码在不同的基本块中的迁移实现一些优化效果。

第二IR的设计中数据节点要保存输入信息形成自己的定义。同时数据节点也会被自动维护该节点使用信息也就是自己构成了哪些其他变量的定义从而形成了双向的use-def链。而控制节点则要保存自己的后序节点信息它的前序节点会被自动维护这样也就构成了可以双向导航的链。在当前的设计方案中每个控制节点最多只能有一个前序节点。

第三IR可以划分为HIR、MIR和LIR它们的抽象层次越来越低从贴近高级语言逐步Lower到贴近CPU架构。抽象层次体现在使用的节点的类型和数据类型等方面。基于图的IR的有一个优点就是它能够用同一个数据结构承载不同抽象层次的IR只需要我们把节点逐步替换就行。

最后在把AST翻译成IR的过程中你要体会出AST和基于图的IR的不同之处。包括本地变量不会作为端点出现必然是被其他节点定义出来的。再比如一个节点可能对应AST中的两个变量。

思考题

我们今天讲到了HIR、MIR和LIR的区别。那么我这里有三个使用IR的场景你能帮我判断一下它应该属于哪类IR吗

  • 场景1访问对象mammal的属性weight
  • 场景2根据对象引用加上一个偏移量然后获取该地址的数值
  • 场景3根据对象在x86-64架构下的地址加上一个64位的偏移量获取这个地址下的双精度浮点数值。

欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。

资源链接

今天的示例代码目录在这里!