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/
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
2023-03-21 AI大模型网络高性能计算分析
2022-03-21 小芯片chiplet UCIe技术
2020-03-21 CVPR2020论文解析:视频分类Video Classification