[MLIR] 转换流程详解(以Toy接入为例)

Posted 多一些不为什么的坚持

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[MLIR] 转换流程详解(以Toy接入为例)相关的知识,希望对你有一定的参考价值。

参考资料:

[MLIR] 转换流程详解(以Toy接入为例) - 知乎 (zhihu.com)

在本文中我们使用 toy 语言接入 MLIR,最终转化为 LLVM IR (或目标代码)为例,来讲解 MLIR 的转换流程。具体的流程如下:

.toy 源文件 → AST → MLIRGen(遍历AST生成MLIR表达式) → Transformation(变形消除冗余) → Lowering → LLVM IR / JIT 编译引擎

1. Toy接入MLIR

本节对应 Chapter 2: Emitting Basic MLIR - MLIR (llvm.org)

1.1 Toy源码和AST

def multiply_transpose(a, b)
    return transpose(a) * transpose(b);

def main() 
  var a<2, 3> = [[1, 2, 3], [4, 5, 6]];
  var b<2, 3> = [1, 2, 3, 4, 5, 6];
  var c = multiply_transpose(a, b);
  print(c);
原toy教程的第一节生成ast的指令如下:
cd llvm-project/build/bin
./toyc-ch1 ../../mlir/test/Examples/Toy/Ch1/ast.toy --emit=ast

编译得到的AST如下

Module:
  Function 
    Proto \'multiply_transpose\' @test/Examples/Toy/Ch1/ast.toy:4:1\'
    Params: [a, b]
    Block 
      Return
        BinOp: * @test/Examples/Toy/Ch1/ast.toy:5:25
          Call \'transpose\' [ @test/Examples/Toy/Ch1/ast.toy:5:10
            var: a @test/Examples/Toy/Ch1/ast.toy:5:20
          ]
          Call \'transpose\' [ @test/Examples/Toy/Ch1/ast.toy:5:25
            var: b @test/Examples/Toy/Ch1/ast.toy:5:35
          ]
     // Block
    ... // main函数的ast未写出

1.2 生成(未优化)MLIR表达式

MLIRGen 模块会遍历 AST ,递归调用子函数,构建 operation。operation 是 dialect 中重要的组成元素,用来表示 dialect 中的某个操作,一个 dialect 中可以有很多的 operation。

mlir::Value mlirGen(CallExperAST &call)

    llvm::StringRef callee = call.getCallee();
    auto location = loc(call.loc()); 

    SmallVector<mlir::Value, 4> operands;
    for(auto &expr:call.getArgs())
        auto arg = mlirGen(*expr); // 递归调用
        if(!arg)
            return nullptr;
        operands.push_back(arg);
    

    if(callee == "transpose")
        if(call.getArgs().size() != 1)
            emitError(location, "MLIR codegen encountered an error: toy.transpose does not accept multiple arguments");
            return nullptr;
        
        return bulider.creater<TransposeOp>(location, operands[0]);
    
    ...

创建好的节点 operation 还没有输入参数等定义,Toy Dialect 模块负责定义各种操作和分析。(Toy Dialect 继承自 mlir::Dialect,并注册了属性、操作和数据类型等)

 Toy Dialect 模块的创建 见 MLIR初识 —— Dialect及Operation详解的 "3. 创建新的dialect"

// TransposeOp
void TransposeOp::build(mlir::OpBuilder &builder, mlir::OperationState &state, mlir::Value value)
    state.addTypes(UnrankedTensorType::get(bulider.getF64Type()));
    state.addOperands(value);

根据 ast 中的节点,生成的一系列 operations 最终组成 MLIR 表达式。(去除了loc的信息)

原toy教程的第一节生成MLIR 表达式的指令如下:
cd llvm-project/build/bin
./toyc-ch2 ../../mlir/test/Examples/Toy/Ch2/codegen.toy -emit=mlir -mlir-print-debuginfo

# 由toy ast 生成 MLIR 表达式
module
  func @multiply_transpose(%arg0: tensor<*xf64>, %arg1: tensor<*xf64>) -> tensor<*xf64> 
    %0 = "toy.transpose"(%arg0): (tensor<*xf64>) -> tensor<*xf64>
    %1 = "toy.transpose"(%arg1): (tensor<*xf64>) -> tensor<*xf64>
    %2 = "toy.mul"(%0, %1): (tensor<*xf64>, tensor<*xf64>) -> tensor<*xf64>
    "toy.return"(%2): (tensor<*xf64>) -> ()
  
  func @main()
    %0 = "toy.constant"() value = dense<[[1.000000e+00, 2.000000e+00, 3.000000e+00], [4.000000e+00, 5.000000e+00, 6.000000e+00]]> : tensor<2x3xf64> : () -> tensor<2x3xf64> 
    %1 = "toy.reshape"(%0) : (tensor<2x3xf64>) -> tensor<2x3xf64> 
    %2 = "toy.constant"() value = dense<[1.000000e+00, 2.000000e+00, 3.000000e+00, 4.000000e+00, 5.000000e+00, 6.000000e+00]> : tensor<6xf64> : () -> tensor<6xf64> 
    %3 = "toy.reshape"(%2) : (tensor<6xf64>) -> tensor<2x3xf64> 
    %4 = "toy.generic_call"(%1, %3) callee = @multiply_transpose : (tensor<2x3xf64>, tensor<2x3xf64>) -> tensor<*xf64> 
    "toy.print"(%4) : (tensor<*x64>) -> ()
    "toy.return"() : () -> ()
  

2. MLIR 表达式变形

本节的 2.1、2.2部分对应 Chapter 3: High-level Language-Specific Analysis and Transformation - MLIR (llvm.org)
本节的2.3部分对应 Chapter 4: Enabling Generic Transformation with Interfaces - MLIR (llvm.org) 

我们发现生成的 MLIR 表达式往往存在冗余的操作,为了提升程序性能就需要对表达式进行转换变形(Transformation 后的 MLIR表达式又可称为 Toy Dialect IR。)。MLIR 提供以下两种方式进行模式匹配转换:

其一,使用 C++ 手动编写代码进行表达式的匹配与重写

其二,使用基于规则的模式匹配和重写的声明式重写规则(DRR)进行,但该方法要求使用ODS定义操作。

2.1 手动编写代码进行表达式的匹配与重写

对于同一个变量,连续进行多次转置操作,必然存在冗余操作。本节以 "消除两个具有相互抵消效果的转置序列" 为例,说明第一种模式匹配转换方法。(Optimize Transpose using C++ style pattern-match and rewrite)

// toy 代码
def transpose_transpose(x) 
  return transpose(transpose(x));

// 未引入优化生成的 MLIR 表达式
func @transpose_transpose(%arg0: tensor<*xf64>) -> tensor<*xf64> 
  %0 = toy.transpose(%arg0 : tensor<*xf64>) to tensor<*xf64>
  %1 = toy.transpose(%0 : tensor<*xf64>) to tensor<*xf64>
  toy.return %1 : tensor<*xf64>

1.第一步:直接使用 C++ 写出匹配和重写的代码

 下面这段代码位于在 ToyCombine.cpp 中,默认位置在 llvm-project/mlir/examples/toy/Ch3/mlir/ToyCombine.cpp

// Fold transpose(transpose(x)) -> x
struct SimplifyRedundantTranspose : public mlir::OpRewritePattern<TransposeOp> 
  // 匹配该IR中的所有 toy.transpose
  /// mlir使用"benefit"对patterns进行排序,并按profitability顺序处理
  SimplifyRedundantTranspose(mlir::MLIRContext *context)
      : OpRewritePattern<TransposeOp>(context, /*benefit=*/1) 

  // 尝试匹配并重写
  mlir::LogicalResult
  matchAndRewrite(TransposeOp op,
                  mlir::PatternRewriter &rewriter) const override 
    // 获取当前Op(一个TransposeOp)的操作数
    mlir::Value transposeInput = op.getOperand();
    // 获取当前Op的操作数对应的Op
    TransposeOp transposeInputOp = transposeInput.getDefiningOp<TransposeOp>();
    // 如果当前Op的操作数对应的Op不是Transpose,重写失败
    if (!transposeInputOp)
      return failure();

    // 反之,当前Op就是TransposeOp
    // transposeInputOp.getOperand()就是x
    rewriter.replaceOp(op, transposeInputOp.getOperand());
    return success();
  
;

 

2. 第二步:将自定义的匹配和重写模式登记为 canonicalization 模式,使得后续可以使用它 

下面这段代码位于 toyc.cpp 中,默认位置为 llvm-project/mlir/examples/toy/Ch3/mlir/ToyCombine.cpp

void TransposeOp::getCanonicalizationPatterns(OwningRewritePatternList &results,
                                              MLIRContext *context) 
  // SimplifyRedundantTranspose 就是第一步中定义的结构体(类)
  results.insert<SimplifyRedundantTranspose>(context);

 

3. 第三步:在Ops.td中设置相应选项

下面这段代码位于 Ops.td 中,默认位置为llvm-project/mlir/examples/toy/Ch3/include/toy/Ops.td

def TransposeOp : Toy_Op<"transpose", [NoSideEffect]> 
  // MLIR 在优化代码时较为保守,可能会保留一些无效操作
  // 设置[NoSideEffect] 可解决这一问题
...
  // 确保启用规范化框架,应用 canonicalization pass
  let hasCanonicalizer = 1;
...

 

 4. 第四步:更新主文件以添加 optimization pipeline

 下面这段代码位于 toyc.cpp 中,默认位置在 llvm-project/mlir/examples/toy/Ch3/toyc.cpp

if (enableOpt) // enableOpt 是从命令行输入的编译选项
  // 使用 PassManger 模块添加优化一道优化工艺
  mlir::PassManager pm(&context);
  applyPassManagerCLOptions(pm);
  // createCanonicalizerPass 创建并使用规范化框架
  pm.addNestedPass<mlir::FuncOp>(mlir::createCanonicalizerPass());
  // 运行定义好的 canonicalizer 来优化 MLIR 表达式
  if (mlir::failed(pm.run(*module)))
      return 4;

5. 最后执行 toyc-ch3 ../../test/Examples/Toy/Ch3/transpose_transpose.toy -emit=mlir -opt,得到优化后的 Toy Dialect IR (MLIR表达式)如下

toy.func @transpose_transpose(%arg0: tensor<*xf64>) -> tensor<*xf64> 
  %0 = toy.transpose(%arg0 : tensor<*xf64>) to tensor<*xf64>
  toy.return %arg0 : tensor<*xf64>

 

2.2 采用 DDR 自动生成匹配和重写函数

目前这个地方我认为太过于复杂

2.3 通用的转换接口

目前这个地方我认为太过于复杂

3. Lowering 过程

本节的3.1部分对应 Chapter 5: Partial Lowering to Lower-Level Dialects for Optimization - MLIR (llvm.org)
本节的3.2部分对应 Chapter 6: Lowering to LLVM and CodeGeneration - MLIR

在编译器一系列转换程序的过程中,越来越多的高层次的简明信息被打散,转换为低层次的细碎指令,这个过程被称为代码表示递降 lowerinng ,与之相反的过程被称为代码表示递升raising 。raising远比lowering困难,因为需要在庞杂的细节中找出宏观脉络。

lowering 过程中越晚执行的转换越有结构劣势,因为缺乏高层次信息。

lowering 主要是为了更贴近硬件做代码生成和做硬件相关的优化。

每次转换遍历(pass) 都需要保持原子性,在其内部可能会临时违反源程序语义,但在每个转换遍历之后,中间表示应该是正确的。编译器依赖每个遍历之后的中间表示验证 (validation) 来保证正确性。 在保证转换的正确性之后,才可进行优化。

3.1 从 MLIR 表达式进行部分 Lowering

MLIR 中有许多不同的 Dialect,lowering 过程其实就是在各种 Dialect 之间转化,而 MLIR 提供了一套统一的 DialectConversion 框架来实现不同 Dialect 之间的转化。

 1. 要使用 DialectConversion 框架需要 Three Components(组件)

  1. ConversionTarget ConversionTarget是DialectConversion框架的一个重要组件,它定义了需要转换的dialect,以及dialect之间的转换规则。通常,ConversionTarget会在编译器的初始化阶段进行创建,并在整个编译过程中使用。它包含了Dialect之间的转换规则,以及一些用于验证和调试的工具,如类型检查器、断言等。
  2. RewritePattern RewritePattern是一个用于匹配和重写操作的规则。它定义了在源dialect中匹配的操作,以及在目标dialect中的重写规则。每个RewritePattern通常由一个PatternMatcher和一个PatternRewriter组成。PatternMatcher定义了匹配操作的模式,而PatternRewriter定义了将匹配的操作重写为目标dialect中的操作的规则。
  3. TypeConverter TypeConverter是DialectConversion框架的另一个重要组件,它负责将源dialect中的类型映射到目标dialect中的类型。TypeConverter通常包含一组类型转换规则,用于将源dialect中的类型转换为目标dialect中的类型。在类型转换过程中,TypeConverter还可以执行一些其他的操作,如创建新类型、插入类型转换指令等。 这三个组件共同构成了DialectConversion框架,可以用于将不同dialect之间的操作进行转换。使用DialectConversion框架可以使编译器更加灵活,能够处理各种类型的dialect,并支持自定义的dialect之间的转换规则。

2. DialectConversion 框架的转换有 Tow Modes
(1)Partial: Not all input operations have to be legalized to the target 当前 Dialect 中某些 operation 在 lowering 中先进行保留(保留部分之前的信息)
(2)Full: All input operations have to be legalized to the target 当前 Dialect 中全部 operation 在 lowering 中全部去除(类似转换到 LLVM IR)

本节标题的部分lowering 意味着:从一个高抽象级别的 Dialect 到一个低抽象级别的 Dialect 过程中,可以只 lowering 其中一部分 operation,剩下的 operation 只需要升级与其他 operation 共存。现在以对 transformation 后的 MLIR 表达式进行 lowering为例:

// toy 源码
def multiply_transpose(a, b)
    return transpose(a) * transpose(b);

def main() 
  var a<2, 3> = [[1, 2, 3], [4, 5, 6]];
  var b<2, 3> = [1, 2, 3, 4, 5, 6];
  var c = multiply_transpose(a, b);
  print(c);

// transformation 后的 MLIR 表达式
toy.func @main() 
  %0 = toy.constant dense<[[1.000000e+00, 2.000000e+00, 3.000000e+00], [4.000000e+00, 5.000000e+00, 6.000000e+00]]> : tensor<2x3xf64>
  %1 = toy.transpose(%0 : tensor<2x3xf64>) to tensor<3x2xf64>
  %2 = toy.mul %1, %1 : tensor<3x2xf64>
  toy.print %2 : tensor<3x2xf64>
  toy.return

 

 后面的太复杂,稍后看.....

3.2 混合 Dialect 表达式 Lowering 到 LLVM IR

  后面的太复杂,稍后看.....

总结

 本文介绍的 Toy 接入 MLIR 流程本质上还是高级语言的转换流程,但目前 MLIR 在人工智能领域应用较热,二者的转换前端区别较大,一个是抽象语法树(AST),一个是计算图IR(Computation Graph IR)。下图是以 Tensorflow 为例的转换流程。具体的流程为可参考 Codegen Dialect Overview - MLIR - LLVM Discussion Forums

 

 

 

 

 

 

 

 

 

 

 

1111111111111111

以OneFlow为例探索MLIR的实际开发流程

撰文 | BBuf

原文首发于GiantPandaCV

目录

1、前言

2、OneFlow是如何和MLIR结合的?

3、OneFlow IR如何执行?

         4、总结


1、前言

最近在同事shenghang的帮助下做了一点OneFlow IR相关的开发,对MLIR执行部分有一些新的感受,所以尝试分享一下。我之前花了不少时间去理解OneFlow IR的整个架构(可以看我的Toy Tutorials系列),但对OneFloiw IR的JIT的执行这部分一直存疑。最近将OneFlow基于Job(OneFlow的作业函数,不考虑设备的话可以理解为一个计算图)接入MLIR工程实现部分重新进行了梳理,并在shenghang的指导下理解了整个流程。

所以这篇文档我将介绍一下OneFlow和MLIR是如何结合的,如何在OneFlow IR中新增一个图级别的Pass,OneFlow的Operation是如何自动变成MLIR 的Operation的以及为什么OneFlow IR能利用MLIR为计算带来加速等。我对MLIR的了解不算多,2个月前开始接触,有任何错误请大家批评斧正。

本文和 https://github.com/Oneflow-Inc/oneflow & https://github.com/BBuf/tvm_mlir_learn 有关,感兴趣可以star关注一下。

本文提到的Op和Operation是一回事,没有严格区分。

2、OneFlow是如何和MLIR结合的?

在OneFlow中引入MLIR作为OneFlow的IR有诸多优点,不仅可以取代OneFlow中需要通过C++手写的Operation定义减小开发难度,还可以降低Operation定义中一些容器相关的开销。另外我们还可以通过MLIR维护的基础设施(即多重Dialect)来完成对计算图计算的加速。

这里的计算图既可以是Eager的计算图,也可以是Lazy的计算图。由于基于Eager计算图使用MLIR进行加速的工作(即oneflow.jit.xxx)还没有正式开放,我这里仍然以Lazy计算图(Job)为例来讲解OneFlow和MLIR的结合过程。

首先我们需要编译好开启MLIR的OneFlow,编译命令如下:

git clone git@github.com:Oneflow-Inc/oneflow.git
cd oneflow && mkdir build && cd build
cmake-C ../cmake/caches/cn/fast/mlir-cuda-75.cmake -DBUILD_TESTING=ON .. && ninja

然后可以写一个例子进行测试:

os.environ["ONEFLOW_MLIR_ENABLE_ROUND_TRIP"] = '1'
os.environ["ONEFLOW_MLIR_ENABLE_CODEGEN_FUSERS"] = '1'

@flow.unittest.skip_unless_1n1d()
class TestFuseBiasAddGeLUCPUMLIR(oneflow.unittest.TestCase):
    def test_fused_bias_add_gelu_graph(test_case):
        data = np.random.randn(1, 2, 3)
        bias_data = np.random.randn(2)
        x = flow.tensor(data, dtype=flow.float32)
        bias = flow.tensor(bias_data, dtype=flow.float32)
        y_eager = flow.gelu(flow._C.bias_add(x, bias, axis=1))

        class FuseBiasAddGeLUGraph(flow.nn.Graph):
            def __init__(self):
                super().__init__()

            def build(self, x):
                return flow.gelu(flow._C.bias_add(x, bias, axis=1))

        bias_add_gelu = FuseBiasAddGeLUGraph()
        y_lazy = bias_add_gelu(x)
        test_case.assertTrue(np.array_equal(y_eager.numpy(), y_lazy.numpy()))

运行这个例子之后会在当前运行目录下生成一个log文件,里面有一个ir_pass 文件夹记录了经过OneFlow MLIR优化前后的计算图(.prototxt) 以及 MLIR的表达式(*.mlir),还有一个*.mlir.dot文件可以用graphviz打开来可视化MLIR表达式的计算图。

需要注意的是,如果OneFlow正在执行训练任务,这个log文件夹里不仅包含前向的计算图和MLIR表达式,也会生成后向的计算图和MLIR表达式。所以MLIR在整个神经网络的运行流程中均可以作用,这是区别于前向推理框架的重要一点,即训练也可以加速。

oneflow/api/python/ir.cpp 中有下面两行代码:

REGISTER_JOB_PASS("IRRoundTripBeforeAD", IRRoundTrip<kBeforeAD>);
REGISTER_JOB_PASS("IRRoundTrip", IRRoundTrip<kAfterAD>);

RoundTrip即往返的意思,BeforeAD可以理解为反向之前,kAfterAD 可以理解为反向之后,这里通过将OneFlow Job和MLIR的互转过程注册为OneFlow Job的一个Pass来建立OneFlow计算图和MLIR的联系。在执行OneFlow脚本时,如果想使能MLIR作用于OneFlow计算图,开启ONEFLOW_MLIR_ENABLE_ROUND_TRIP=1环境变量即可。

接下来,要将OneFlow的计算图和MLIR建立联系等价于将OneFlow计算图中的Operation和MLIR中的Operation进行一对一的转换。而MLIR的Operation定义在各级Dialect下,按照MLIR的通用接入原则,我们实现了一个OneFlow Dialect并在OneFlow Dialect上实现了OneFlow Operation到OneFlow Dialect下的Operation的一一映射。

如何定义OneFlow Dialect和Operation这里就不讲了,可以参考MLIR官方文档的Dialects和ODS一节(https://mlir.llvm.org/docs/OpDefinitions/)或者我之前的文章,它们都是基于TableGen规则来完成的。关于MLIR Operation的定义我之前结合OneFlow Dialect的Op定义总结了一个文档(https://github.com/BBuf/tvm_mlir_learn 中) 。

除了Dialect和Operation的定义还有一些其它需要定义的东西,比如OneFlow数据类型到MLIR数据类型映射的定义在oneflow/ir/include/OneFlow/OneFlowEnums.td ,OneFlow Dialect Operation的一些通用前端接口定义在oneflow/ir/include/OneFlow/OneFlowEnums.td。这里我们以Reshape Operation为例子来简单说明一下这个Operation有哪些组成部分:

def OneFlow_ReshapeOp : OneFlow_BaseOp<"reshape", [NoSideEffect, DeclareOpInterfaceMethods<UserOpCompatibleInterface>]> 
  let input = (ins
    AnyType:$in
  );
  let output = (outs
    AnyType:$out
  );
  let attrs = (ins
    AnyI64ElementsAttr:$shape
  );

OneFlow_ReshapeOp 这个名字下划线之前的是Dialect的名字,后面是这个Dialect下的Operation的名字。然后这个Operation继承了OneFlow_BaseOp基类,并声明了约束和前端接口,接下来定义了Operation的输入,输出和属性就结束了。

可以发现,OneFlow Dialect Operation的定义和OneFlow User Op是完全一致的,这保证了OneFlow和MLIR互转的合法性。OneFlow Reshape Operation的定义如下:

REGISTER_USER_OP("reshape")
    .Input("in")
    .Output("out")
    .Attr<Shape>("shape")
    ...

OneFlow Job和MLIR的互转实现在oneflow/ir/oneflow-translate,主要做的事情就是遍历Job的OpGraph,对节点和边分别进行处理最后转换成一个MLIR表达式,同时在计算完成后可以基于MLIR表达式重写Job。这里的整体逻辑偏复杂,因为要处理OneFlow Job OpGraph里面各种类型Operation和边的转化,这里不继续深入讲解,因为它也不是我这篇文章要讨论的点,感兴趣的可以直接阅读代码。

3、OneFlow IR如何执行?

在上面Operation定义时是举了一个Reshape的例子,浏览oneflow/ir/include/OneFlow/OneFlowOps.td容易发现这里还定义了一个OneFlow_MlirJitOp,这个自定义的Op就是用来执行MLIR表达式的,它里面实现了CPU和GPU的Kernel(源码在oneflow/ir/oneflow-extension/extension.cpp)用来加载MLIR提供的JIT执行引擎运行最终得到的LLVM IR。那么LLVM IR又是怎么来的呢?这是通过OneFlow MLIR表达式逐级下降之后得来的,具体下降过程如下:

void AddLowerToLinalgMemRefPasses(PassManager& pm) 
  pm.addPass(createLowerOneFlowToTosaPass());            // lower-oneflow-to-tosa
  pm.addPass(createCSEPass());                           // cse
  pm.addNestedPass<FuncOp>(tosa::createTosaToLinalg());  // tosa-to-linalg-on-tensors
  auto p = createLinalgElementwiseOpFusionPass();
  assert(p->initializeOptions("allow-folding-unit-dim-reshapes=true").succeeded());
  pm.addNestedPass<FuncOp>(std::move(p));                     // linalg-fuse-elementwise-ops
  pm.addNestedPass<FuncOp>(createLinalgBufferizePass());      // linalg-bufferize
  pm.addNestedPass<FuncOp>(createTensorBufferizePass());      // tensor-bufferize
  pm.addPass(createTensorConstantBufferizePass());            // tensor-constant-bufferize
  pm.addPass(createFuncBufferizePass());                      // func-bufferize
  pm.addPass(createBufferResultsToOutParamsPass());           // buffer-results-to-out-params
  pm.addPass(createCanonicalizerPass());                      // canonicalize
  pm.addNestedPass<FuncOp>(createFinalizingBufferizePass());  // finalizing-bufferize


LogicalResult LowerModuleToLLVM(mlir::MLIRContext* context, ModuleOp module) 
  mlir::PassManager pm(context);
  AddLowerToLinalgMemRefPasses(pm);
  pm.addNestedPass<FuncOp>(createConvertLinalgToLoopsPass());  // convert-linalg-to-loops
  pm.addNestedPass<FuncOp>(createLowerToCFGPass());            // convert-scf-to-std
  pm.addPass(createConvertLinalgToLLVMPass());                 // convert-linalg-to-llvm
  pm.addPass(createMemRefToLLVMPass());                        // convert-memref-to-llvm
  pm.addPass(createLowerToLLVMPass());                         // convert-std-to-llvm
  pm.addPass(createReconcileUnrealizedCastsPass());
  return pm.run(module);

可以看到OneFlow Dialect首先下降到Tosa Dialect,然后下降到Linalg Dialect,再然后是Loop Dialect,一直到最后的LLVM IR。在逐级下降的过程中,我们可以享受如Linalg Dialect带来的嵌套循环变换带来的优化机会以提升最终IR的性能。

这里的Lowering过程是在OneFlow调用MlirJitOp 的Kernel时触发的(oneflow/ir/oneflow-extension/extension.cpp ),调用也是作为一个MLIR的Pass被加入到了优化流程中。JIT调用流程Pass的实现可以精简为:

class OutlineJitFunctionPass : public OutlineJitFunctionPassBase<OutlineJitFunctionPass> 
  void runOnOperation() override 
    Operation* op = getOperation();
    RewritePatternSet patterns(op->getContext());
    oneflow::populateFuserPasses(patterns);
    (void)applyPatternsAndFoldGreedily(op, std::move(patterns));
  
;

std::unique_ptr<Pass> createOutlineJitFunctionPass() 
  return std::make_unique<OutlineJitFunctionPass>();


LogicalResult ApplyRoundTripPatterns(RoundTripOneFlowJobWrapperInterface& job_wrapper,
                                     MLIRContext* context, OwningModuleRef& module) 
  mlir::PassManager pm(context);
  pm.addNestedPass<mlir::FuncOp>(::mlir::createCanonicalizerPass());
  if (job_wrapper.IsLastIRPass() && std::getenv("ONEFLOW_MLIR_ENABLE_CODEGEN_FUSERS") != nullptr) 
    pm.addPass(oneflow::createOutlineJitFunctionPass());
  
  ...

但这套流程还存在两个问题需要解决:

  • 第一个问题是如何做Op融合。上面的JIT执行流程只考虑了不断Lowering,那么假如在OneFlow Dialect中有一些Operation是可以融合的,这个时候应该怎么做呢?很简单,我们沿用一下MLIR的DRR规则,还是用TableGen语法在oneflow/ir/include/OneFlow/OneFlowPatterns.td 中写一系列的Fuse Pattern即可,比如bias_add+gelu 这两个Op可以融合成OneFlow中的fused_bias_add_gelu Op,那么就可以写如下的规则。

def IsGPU: Constraint<CPred<"$0.getValue().equals(\\"gpu\\")">, "is GPU device">;
def FusedBiasAddGeluPattern : Pat<
  (
    OneFlow_GeluOp : $gelu_op
    (
      OneFlow_BiasAddOp
        $a,
        $b,
        $bias_add_op_name,
        $bias_add_device_tag,
        $bias_add_device_name,
        $bias_add_scope_symbol_id,
        $bias_add_hierarchy,
        $axis
    ),
    $gelu_op_name,
    $gelu_device_tag,
    $gelu_device_name,
    $gelu_scope_symbol_id,
    $gelu_hierarchy
  ),
  (OneFlow_FusedBiasAddGeluOp $a, $b,
    $gelu_op_name,
    $gelu_device_tag,
    $gelu_device_name,
    $gelu_scope_symbol_id,
    $gelu_hierarchy,
    $axis
  ),
  [
    (IsGPU $bias_add_device_tag),
    (IsGPU $gelu_device_tag)
  ]
>;

这里基于MLIR的DRR规则来做表达式匹配和重写,可以看到假如当前运行设备是GPU并且前后两个Op分别是gelubias_add 就将其进行融合为一个fused_bias_add_gelu_op,在CUDA上可以减少读写来提升执行效率。

  • 第二个问题是如何让OneFlow的一些Operation享受MLIR基础设施中的更多优化?在多级Dialect 逐层下降时可以看到OneFlow的MLIR表达式的每个子函数都会被Lower。第一次会将其Lower到Tosa Dialect,这个时候如果这个子函数中的某个Operation没有定义转换到Tosa Dialect的方法,那么就不能Lower到Tosa Dialect。自然也就不能进一步下降为Linalg Dialect,享受不到一些循环变化带来的优化(我感觉可以类比TVM的scheduler优化)。

    为了解决这种情况我们需要额外再定义一个Pass来将当前需要转换为Tosa的Op或者模式提取成一个函数,里面的oneflow op都能够lower到tosa,然后生成一个 oneflow mlir jit op 来 call 这个函数:

def IsNotNestedInJit: Constraint<CPred<"(!$0.getDefiningOp()->getParentOfType<::mlir::FuncOp>()->hasAttr(\\"llvm.emit_c_interface\\"))">, "">;
def OutlineMulCast : NativeCodeCall<"::mlir::oneflow::OutlineMulCast($_builder, $0, $1)">;
// TODO: remove attr binding if possible
def MulCastPattern : Pat<
  (
    OneFlow_ScalarMulByTensorOp : $mul_op
    (
      OneFlow_CastOp : $cast_op
        $cast_x,
        $cast_op_name,
        $cast_device_tag,
        $cast_device_name,
        $cast_scope_symbol_id,
        $cast_hierarchy,
        $cast_dtype
    ),
    $scalar,
    $mul_op_name,
    $mul_device_tag,
    $mul_device_name,
    $mul_scope_symbol_id,
    $mul_hierarchy
  ),
  (OutlineMulCast $mul_op, $cast_op),
  [
    (IsNotNestedInJit $mul_op)
  ]
>;

::llvm::SmallVector<::mlir::Value, 4> OutlineMulCast(::mlir::PatternRewriter& rewriter,
                                                     mlir::OpResult mul_res,
                                                     mlir::OpResult cast_res) 
  if (auto mul_op = llvm::dyn_cast<ScalarMulByTensorOp>(mul_res.getDefiningOp())) 
    if (auto cast_op = llvm::dyn_cast<CastOp>(cast_res.getDefiningOp())) 
      // TODO: extract a function to generate op name for jit op from ops being fused
      SmallString<64> op_name_storage;
      auto op_name =
          (cast_op.op_name() + "__FUSE__" + mul_op.op_name()).toStringRef(op_name_storage);
      SmallVector<::mlir::Value, 2> operands;
      operands.push_back(cast_op.in());
      operands.push_back(mul_op.scalar());
      SmallVector<::mlir::Value, 1> results;
      results.push_back(mul_op.y());
      NamedAttrList attributes =
          GetJitOpAttributes(rewriter, op_name, operands.size(), results.size(), mul_op);
      SmallVector<Operation*, 4> ops = cast_op, mul_op;
      auto function =
          GetOrInsertFuncOp(rewriter, mul_op->getLoc(), op_name, operands, results, ops);
      auto created = rewriter.create<MlirJitOp>(mul_op.getLoc(), function, attributes, operands);
      assert(DumpAssembly(rewriter, created).succeeded());
      cast_op->dropAllUses();
      cast_op.erase();
      return created->getResults();
    
  
  return ;


void populateFuserPasses(::mlir::RewritePatternSet& patterns) 
  patterns.add<MulCastPattern>(patterns.getContext());

这里就是将MulCast这个Pattern手动实现了从OneFlow Dialect到Tosa Dialect的转换,最后将这个Pass加到优化流程中即可完成MLIR表达式中的这个Pattern会经过Tosa和Linalg这两个层次的Dialect,获得一些优化机会。

4、总结

这里以OneFlow为例讲解了一些MLIR的真实运行流程,即是如何通过MLIR来执行深度学习框架的计算图并且为其加速的,目前理解难免有不到位的地方,欢迎大家批评指正。

其他人都在看

欢迎下载体验OneFlow新一代开源深度学习框架:GitHub - Oneflow-Inc/oneflow: OneFlow is a performance-centered and open-source deep learning framework.https://github.com/Oneflow-Inc/oneflow/

以上是关于[MLIR] 转换流程详解(以Toy接入为例)的主要内容,如果未能解决你的问题,请参考以下文章

以OneFlow为例探索MLIR的实际开发流程

从零开始学深度学习编译器十二,MLIR Toy Tutorials学习笔记一

从零开始学深度学习编译器十四,MLIR Toy Tutorials学习笔记之部分Lowering

从零开始学深度学习编译器十四,MLIR Toy Tutorials学习笔记之部分Lowering

从零开始学深度学习编译器十五,MLIR Toy Tutorials学习笔记之Lowering到LLVM IR

从零开始学深度学习编译器十五,MLIR Toy Tutorials学习笔记之Lowering到LLVM IR