C程序引申到编译器的过程

C程序引申到编译器的过程

MLIR与编译

主要内容:

MLIR

控制流图(CFG)

静态单一分配(SSA)

数据流分析

汇编

mruby是用C编写的,因此每个操作码背后的逻辑都是用C实现的。为了从字节码编译Ruby程序,可以使用mruby C API的等价C程序。

某些操作码具有直接的API对应项,例如,OP_LOADI等效于mrb_value mrb_fixnum_value(mrb_int i);。然而,大多数操作码都内联在vm.c中的巨大调度循环中。然而,可以将这些实现提取到单独的函数中,并从c中调用它们。

以下Ruby程序:

puts 42

及其字节码:

OP_LOADSELF R1

OP_LOADI    R2  42

OP_SEND     R1  :puts 1

OP_RETURN   R1

OP_STOP

等效的C程序如下所示:

mrb_state *mrb = mrb_open();

mrb_value receiver = fs_load_self();

mrb_value number = mrb_fixnum_value(42);

mrb_funcall(mrb, receiver, "puts", 1, &number);

mrb_close(mrb);

fs_load_self是一个自定义运行时函数,因为OP_LOADSELF没有C API对应函数。

在这个小示例中忽略了OP_RETURN。

要从字节码编译Ruby程序,只需要生成等效的C程序。

然而,在某种程度上,执行工作变得令人生畏。当生成一个C程序时,很难对C代码进行一些自定义分析或优化。在生成C代码之前,开始添加辅助数据结构(实际上,只是成对和元组的哈希映射的哈希映射数组)。

MLIR的一个关键特性是能够定义称为方言的自定义中间表示。MLIR提供了一个基础设施来混合和匹配不同的方言,并对它们进行分析或转换。此外,方言可以被降低为机器代码(例如,用于CPU或GPU)。

 MLIR方言

需要定义一个自定义方言,使MLIR适用于我的用例。称之为“Rite”。方言需要对每个RiteVM操作码和一些RiteVM类型进行操作。

以下是从上面编译代码示例所需的最低值(puts 42)。

def Rite_Dialect : Dialect {

  let name = "rite";

  let summary = "A one-to-one mapping from mruby RITE VM bytecode to MLIR";

 

  let cppNamespace = "rite";

}

 

class RiteType<string name> : TypeDef<Rite_Dialect, name> {

  let summary = name;

  let mnemonic = name;

}

 

def ValueType : RiteType<"value"> {}

def StateType : RiteType<"state"> {}

 

class Rite_Op<string mnemonic, list<Trait> traits = []> :

    Op<Rite_Dialect, mnemonic, traits>;

 

// OPCODE(LOADSELF, B) /* R(a) = self */

def LoadSelfOp : Rite_Op<"OP_LOADSELF"> {

  let summary = "OP_LOADSELF";

  let results = (outs ValueType);

}

 

// OPCODE(LOADI, BB) /* R(a) = mrb_int(b) */

def LoadIOp : Rite_Op<"OP_LOADI"> {

  let summary = "OP_LOADI";

  let arguments = (ins SI64Attr:$value);

  let results = (outs ValueType);

}

 

// OPCODE(SEND, BBB) /* R(a) = call(R(a),Syms(b),R(a+1),...,R(a+c)) */

def SendOp : Rite_Op<"OP_SEND"> {

  let summary = "OP_SEND";

  let arguments = (ins ValueType:$receiver, StringAttr:$symbol, UI32Attr:$argc, Variadic<ValueType>:$argv);

  let results = (outs ValueType);

}

 

// OPCODE(RETURN, B) /* return R(a) (normal) */

def ReturnOp : Rite_Op<"OP_RETURN", [Terminator]> {

  let summary = "OP_RETURN";

  let arguments = (ins ValueType:$src);

  let results = (outs ValueType);

}

定义了方言、所需的类型和操作。一些实体来自MLIR的预定义方言(StringAttr、UI32Attr、Variadic<…>、Terminator)。定义其余部分。

每个操作可能需要零个或多个参数,但也可能产生零个或更多结果。与“典型”的编程语言不同,MLIR方言定义了一个图(正如输入和输出所暗示的那样)。方言也有其他一些特性,但一步一个脚印。

有了方言,可以生成一个MLIR程序,大致相当于上面的C程序:

注:为了简洁起见,省略了一些细节。

module @"test.rb" {

  func @top(%arg0: !rite.state, %arg1: !rite.value) -> !rite.value {

    %0 = rite.OP_LOADSELF() : () -> !rite.value

    %1 = rite.OP_LOADI() {value = 42 : si64} : () -> !rite.value

    %2 = rite.OP_SEND(%0, %1) {argc = 1 : ui32, symbol = "puts"} : (!rite.value, !rite.value) -> !rite.value

    %3 = rite.OP_RETURN(%2) : (!rite.value) -> !rite.value

  }

}

在这里,生成了一个MLIR模块,该模块包含一个函数(顶部),其中有四个操作对应于每个字节码操作。

详细了解一个操作:

%2 = rite.OP_SEND(%0, %1) {argc = 1 : ui32, symbol = "puts"} : (!rite.value, !rite.value) -> !rite.value

此片段定义了一个名为%2的值,该值采用另外两个值(%0和%1)。在MLIR中,常量被定义为属性,在这种情况下是argc=1:ui32和symbol=put。下面是操作签名(!rite.value, !rite.value) -> !rite.value。该操作返回rite.value并接受几个参数:%0是接收器,%1是Variadic<ValueType>:$argv的一部分。

MLIR采用声明性方言定义,并从中生成C++代码。C++代码用作生成MLIR模块的编程API。

模块生成后,可以对其进行分析和转换。下一步是直接将Rite方言转换为LLVM方言,并将其降为LLVM IR。

从那时起,可以发出一个对象文件(机器代码),并将其与mruby运行时链接。

静态单一分配(SSA)

虚拟堆栈是必不可少的,但在C和MLIR程序中,使用局部变量而不是堆栈。这是怎么回事?

答案很简单——MLIR对其所有表示都使用静态单一赋值形式。

作为提醒,SSA意味着每个变量只能定义一次。

注意事项:变量应该被称为值,因为它们不能变化。

这是一个无效的SSA表格:

int x = 42;

x = 55; // SSA中不允许重定义

print(x);

这里是SSA表单中的相同代码:

int x = 42;

int x1 = 55; // 重定义生成新值

print(x1);

必须将寄存器转换为SSA值,以满足SSA形式的MLIR要求。

乍一看,这个问题微不足道。可以在每个时间点维护每个寄存器的定义映射。例如,对于以下字节码:

OP_LOADSELF R1    // #1

OP_LOADI    R2 10 // #2

OP_LOADI    R3 20 // #3

OP_LOADI    R3 30 // #4

OP_ADD      R2 R3 // #5

OP_RETURN   R2    // #6

映射更改如下:

Step #1: { empty }

Step #2: {

  R1 defined by #1

}

Step #3: {

  R1 defined by #1

  R2 defined by #2

}

Step #4: {

  R1 defined by #1

  R2 defined by #2

  R3 defined by #3

}

Step #5: {

  R1 defined by #1

  R2 defined by #2

  R3 defined by #4 // R3 redefined at #4

}

Step #5: {

  R1 defined by #1

  R2 defined by #5 // OP_ADD stores the result in the first operand

  R3 defined by #4

}

有了这个映射,就可以准确地知道当操作使用寄存器时,寄存器是在哪里定义的。

因此MLIR版本将如下所示:

// OP_LOADSELF R1

%0 = rite.OP_LOADSELF() : () -> !rite.value

// OP_LOADI    R2 10

%1 = rite.OP_LOADI() {value = 10 : si64} : () -> !rite.value

// OP_LOADI    R3 20

%2 = rite.OP_LOADI() {value = 20 : si64} : () -> !rite.value

// OP_LOADI    R3 30

%3 = rite.OP_LOADI() {value = 30 : si64} : () -> !rite.value

// OP_ADD      R2 R3

%4 = rite.OP_ADD(%1, %3) : (!rite.value, !rite.value) -> !rite.value

// OP_RETURN   R2

%5 = rite.OP_RETURN(%4) : (!rite.value) -> !rite.value

附带说明:%0和%2从不使用,可以消除(如果OP_LOADSELF/OP_LOADI没有副作用)。

在代码有分支(如if/else、循环或异常)之前,这种解决方案是令人愉快的。

考虑以下非SSA示例:

x = 10;

if (something) {

  x = 20;

} else {

  x = 30;

}

print(x); // Where x is defined?

经典SSA通过人工phi节点解决了这个问题:

x1 = 10;

if (something) {

  x2 = 20;

} else {

  x3 = 30;

}

x4 = phi(x2, x3); // Will magically resolve to the right x depending on where it comes from

print(x4);

MLIR通过块参数以不同的方式处理这一问题

但首先来谈谈控制流图。

控制流程图(CFG)

控制流图是一种中间表示形式,它以图的形式维护程序,其中操作基于执行(或控制)流相互连接。

考虑以下字节码(左边的数字是操作地址):

001: OP_LOADT R1      // puts "true" in R1

002: OP_LOADI R2 42

003: OP_JMPIF R1 006  // jump to 006 if R1 contains "true"

                      // otherwise implicitly falls through to 004

004: OP_LOADI R3 20

005: OP_JMP 007       // jump to 007 unconditionally

006: OP_LOADI R3 30

007: OP_ADD R2 R3     // R3 may be either 20 or 30, depending on the branching

图形形式的相同程序:

 

 这个CFG可以进一步优化:可以合并所有后续节点,除非节点有多个传入边或多个传出边。

合并后的节点称为基本块:

 

 

更多关于完整性的术语:

开始执行函数的第一个基本块称为入口

类似地,最后一个基本块被称为出口

前面的(传入的、以前的)基本块称为前置块。入口块没有前置项。

后继的(传出的,下一个)基本块称为后继块。退出块没有后续块。

基本块中的最后一个操作称为终止符

基于最后一张图片:

B1:入口块

B4:单出口闭塞。可能有几个出口块,但总是可以添加一个空块作为出口块的后续块,使其只有一个出口块。

B1:前置:[],后续:[B2,B3],终止符:OP_JMPIF

B2:前序:[B1],后序:[B4],终止符:OP_JMP

B3:前序:[B1],后序:[B4],终止符:OP_LOADI

B4:前序:[B2,B3],后序:[],终止符:OP_ADD

MLIR中的CFG

现在可以从MLIR的角度来看CFG。如果熟悉LLVM中的CFG,那么重要的区别在于,在MLIR中,所有基本块都可能有自变量。事实上,函数参数是来自入口块的块参数。例如,这是一个函数的更准确表示:

func @top() -> !rite.value {

^bb0(%arg0: !rite.state, %arg1: !rite.value):

  %0 = rite.OP_LOADSELF() : () -> !rite.value

  %1 = rite.OP_LOADI() {value = 42 : si64} : () -> !rite.value

  %2 = rite.OP_SEND(%0, %1) {argc = 1 : ui32, symbol = "puts"} : (!rite.value, !rite.value) -> !rite.value

  %3 = rite.OP_RETURN(%2) : (!rite.value) -> !rite.value

}

注意,^bbX表示基本块。

要转换以下字节码:

001: OP_LOADT R1      // puts "true" in R1

002: OP_LOADI R2 42

003: OP_JMPIF R1 006  // jump to 006 if R1 contains "true"

                      // otherwise implicitly falls through to 004

004: OP_LOADI R3 20

005: OP_JMP 007       // jump to 007 unconditionally

006: OP_LOADI R3 30

007: OP_ADD R2 R3     // R3 may be either 20 or 30, depending on the branching

需要采取几个步骤:

为所有可寻址操作添加一个地址属性(它们可能是跳转目标)

将targets属性添加到所有跳跃中,包括隐含的贯穿跳跃

添加一个显式跳转来代替隐式跳转

为所有跳转指令添加后续块

将所有操作放在一个条目基本块中

func @top(%arg0: !rite.state, %arg1: !rite.value) -> !rite.value {

  %0 = rite.PhonyValue() : () -> !rite.value

  %1 = rite.OP_LOADT() { address = 001 } : () -> !rite.value

  %2 = rite.OP_LOADI() { address = 002, value = 42 } : () -> !rite.value

  rite.OP_JMPIF(%0)[^bb1, ^bb1] { address = 003, targets = [006, 004] }

  %3 = rite.OP_LOADI() { address = 004, value = 20 } : () -> !rite.value

  rite.OP_JMP()[^bb1] { address = 005, targets = [007] }

  %4 = rite.OP_LOADI() { address = 006, value = 30 } : () -> !rite.value

  rite.FallthroughJump()[^bb1]

  %5 = rite.OP_ADD(%0, %0) { address = 007 } : () -> !rite.value

^bb1:

}

注意:为了简洁起见,省略了文本表示中的一些细节。

注意,在这里,添加了一个伪值作为SSA值的占位符,因为还不能构造正确的SSA。将在下一节中删除它们。

此外,添加了一个虚假的基本块,作为跳跃目标的占位符继承者。

现在,最后的步骤是:

在每次跳跃目标操作之前,通过切割切入基本块来分割切入基本块

重新连接跳跃,指向正确的目标基本块

删除用作占位符的伪基本块

最后的CFG如下所示:

func @top(%arg0: !rite.state, %arg1: !rite.value) -> !rite.value {

  %0 = rite.PhonyValue() : () -> !rite.value

  %1 = rite.OP_LOADT() { address = 001 } : () -> !rite.value

  %2 = rite.OP_LOADI() { address = 002, value = 42 } : () -> !rite.value

  rite.OP_JMPIF(%0)[^bb1, ^bb2] { address = 003, targets = [006, 004] }

^bb1: // pred: ^bb0

  %3 = rite.OP_LOADI() { address = 004, value = 20 } : () -> !rite.value

  rite.OP_JMP()[^bb3] { address = 005, targets = [007] }

^bb2: // pred: ^bb0

  %4 = rite.OP_LOADI() { address = 006, value = 30 } : () -> !rite.value

  rite.FallthroughJump()[^bb3]

^bb3: // pred: ^bb1, ^bb2

  %5 = rite.OP_ADD(%0, %0) { address = 007 } : () -> !rite.value

}

对应于上面的最后一张图片,只是现在有了一个明确的显示rite.FallthroughJump()。

有了CFG,可以解决SSA问题并消除仪式。rite.PhonyValue()占位符。

MLIR中的SSA

作为提醒,以下是有问题程序的CFG:

 

 

在MLIR形式中,不再有来自虚拟堆栈的寄存器。只有%2、%3、%4等值。棘手的部分是007:OP_ADD R2 R3操作-R3来自哪里?是%3还是%4?

为了回答这个问题,可以使用数据流分析。

数据流分析用于推导有关程序的具体事实。分析是一个迭代过程:首先,收集每个基本块的基本事实,然后针对每个基本块,更新事实,将其与继承者或前任的事实相结合。由于为基本块更新的事实可能会影响继承者/前任的事实,因此该过程应迭代运行,直到没有派生出新的事实为止。

对事实的一个关键要求——它们应该是单调的。一旦知道了这个事实,它就不能消失。这样,迭代过程最终会停止,因为在最坏的情况下,分析将导出关于程序的所有事实,并且无法再导出。

需要推导的事实是:每个操作都需要哪些值/寄存器。

以下是一个简单的算法:

在每个时间点,都有一个迄今为止定义的值的映射

如果操作使用的是未定义的值,则该值是必需的

所需的值将成为块参数,并且必须来自前置参数

必需的前辈的终结符现在使用继任者所需的值

在下一次迭代中,块参数定义了以前所需的值

该过程反复运行,直到没有新的必需值出现。

入口基本块的一个重要细节是,由于没有前置块,所以所有必需的值都必须来自虚拟堆栈。

再看一次字节码示例:

001: OP_LOADT R1

002: OP_LOADI R2 42

003: OP_JMPIF R1 006

004: OP_LOADI R3 20

005: OP_JMP   007

006: OP_LOADI R3 30

007: OP_ADD   R2 R3

这是数据流分析的初始状态。上面的注释包含有关给定时间点的定义值的信息。每个操作侧面的注释告诉操作本身:

func @top(%arg0: !rite.state, %arg1: !rite.value) -> !rite.value {

  // defined: []

  %0 = rite.PhonyValue() : () -> !rite.value   // defines: [], uses: []

  // defined: []

  %1 = rite.OP_LOADT() : () -> !rite.value     // defines: [R1], uses: []

  // defined: [R1]

  %2 = rite.OP_LOADI(42) : () -> !rite.value   // defines: [R2], uses: []

  // defined: [R1, R2]

  rite.OP_JMPIF(%0)[^bb1, ^bb2]                // defines: [], uses: [R1]

 

^bb1: // pred: ^bb0                            // defines: [], uses: []

  // defined: []

  %3 = rite.OP_LOADI(20) : () -> !rite.value   // defines: [R3], uses: []

  // defined: [R3]

  rite.OP_JMP()[^bb3]                          // defines: [], uses: []

 

^bb2: // pred: ^bb0                            // defines: [], uses: []

  // defined: []

  %4 = rite.OP_LOADI(30) : () -> !rite.value   // defines: [R3], uses: []

  // defined: [R3]

  rite.FallthroughJump()[^bb3]                 // defines: [], uses: []

 

^bb3: // pred: ^bb1, ^bb2                      // defines: [], uses: []

  // defined: []

  %5 = rite.OP_ADD(%0, %0) : () -> !rite.value // defines: [R2], uses: [R2, R3]

}

最后一个操作使用未定义的值。因此R2和R3是必需的,并且必须来自前代。

更新前置任务并重新运行分析。

注意:使用%RX_Y名称来将它们与原始数值名称区分开来。X是寄存器号,Y是基本块号。

func @top(%arg0: !rite.state, %arg1: !rite.value) -> !rite.value {

  // defined: []

  %0 = rite.PhonyValue() : () -> !rite.value   // defines: [], uses: []

  // defined: []

  %1 = rite.OP_LOADT() : () -> !rite.value     // defines: [R1], uses: []

  // defined: [R1]

  %2 = rite.OP_LOADI(42) : () -> !rite.value   // defines: [R2], uses: []

  // defined: [R1, R2]

  rite.OP_JMPIF(%0)[^bb1, ^bb2]                // defines: [], uses: [R1]

 

^bb1: // pred: ^bb0                            // defines: [], uses: []

  // defined: []

  %3 = rite.OP_LOADI(20) : () -> !rite.value   // defines: [R3], uses: []

  // defined: [R3]

  rite.OP_JMP(%0, %0)[^bb3]                    // defines: [], uses: [R2, R3]

 

^bb2: // pred: ^bb0                            // defines: [], uses: []

  // defined: []

  %4 = rite.OP_LOADI(30) : () -> !rite.value   // defines: [R3], uses: []

  // defined: [R3]

  rite.FallthroughJump(%0, %0)[^bb3]           // defines: [], uses: [R2, R3]

 

^bb3(%R2_3, %R3_3): // pred: ^bb1, ^bb2        // defines: [R2, R3], uses: []

  // defined: [R2, R3]

  %5 = rite.OP_ADD(%0, %0) : () -> !rite.value // defines: [R2], uses: [R2, R3]

}

基本块^bb3现在有两个块参数。其前身(^bb1和^bb2)的终结符现在使用未定义的值R2。现在需要R2。必须将其添加为块参数,并将其传播到前辈的终结符。

重新运行分析:

func @top(%arg0: !rite.state, %arg1: !rite.value) -> !rite.value {

  // defined: []

  %0 = rite.PhonyValue() : () -> !rite.value   // defines: [], uses: []

  // defined: []

  %1 = rite.OP_LOADT() : () -> !rite.value     // defines: [R1], uses: []

  // defined: [R1]

  %2 = rite.OP_LOADI(42) : () -> !rite.value   // defines: [R2], uses: []

  // defined: [R1, R2]

  rite.OP_JMPIF(%0, %0, %0)[^bb1, ^bb2]        // defines: [], uses: [R1, R2, R2]

 

^bb1(%R2_1): // pred: ^bb0                     // defines: [R2], uses: []

  // defined: [R2]

  %3 = rite.OP_LOADI(20) : () -> !rite.value   // defines: [R3], uses: []

  // defined: [R2, R3]

  rite.OP_JMP(%0, %0)[^bb3]                    // defines: [], uses: [R2, R3]

 

^bb2(%R2_2): // pred: ^bb0                     // defines: [R2], uses: []

  // defined: [R2]

  %4 = rite.OP_LOADI(30) : () -> !rite.value   // defines: [R3], uses: []

  // defined: [R2, R3]

  rite.FallthroughJump(%0, %0)[^bb3]           // defines: [], uses: [R2, R3]

 

^bb3(%R2_3, %R3_3): // pred: ^bb1, ^bb2        // defines: [R2, R3], uses: []

  // defined: [R2, R3]

  %5 = rite.OP_ADD(%0, %0) : () -> !rite.value // defines: [R2], uses: [R2, R3]

}

可以再运行一次分析,但它不会改变任何事情,所以这将结束分析,应该拥有用正确的值替换虚假值所需的所有信息。

此外,现在可以将自定义跳转操作替换为MLIR中的内置操作,因此最终函数如下所示:

func @top(%arg0: !rite.state, %arg1: !rite.value) -> !rite.value {

  %1 = rite.OP_LOADT() : () -> !rite.value

  %2 = rite.OP_LOADI(42) : () -> !rite.value

  cond_br %1, ^bb1(%2), ^bb2(%2)

^bb1(%R2_1): // pred: ^bb0

  %3 = rite.OP_LOADI(20) : () -> !rite.value

  br ^bb3(%R2_1, %3)

^bb2(%R2_2): // pred: ^bb0

  %4 = rite.OP_LOADI(30) : () -> !rite.value

  br ^bb3(%R2_2, %4)

^bb3(%R2_3, %R3_3): // pred: ^bb1, ^bb2

  %5 = rite.OP_ADD(%R2_3, %R3_3) : () -> !rite.value

}

 

参考文献链接

https://lowlevelbits.org/compiling-ruby-part-3/

posted @ 2024-03-21 04:30  吴建明wujianming  阅读(6)  评论(0编辑  收藏  举报