# 前言

本篇是学习 MLIR 中的 Transform Dialect 的第一篇,主要讲解 MLIR 为何及如何保持计算结构。内容来自 MLIR 官方教程 《Chapter 0: A Primer on “Structured” Linalg Operations》
相关链接: LLVM ProjectMLIR 官方文档MLIR 官网教程《Chapter 0: A Primer on “Structured” Linalg Operations》
作为初学者,错误在所难免,还望不吝赐教。

# 基本简介

本章节主要内容是在阐述 : 为什么编译器不直接把高级计算(如矩阵乘)转成低级循环,而是要先用 “结构化” 操作(如 linalg.generic)来表示它?
答案是:为了保留 “结构” 信息,给编译器更大的优化空间和灵活性。低级循环(如 scf.for)是一盘散沙,而结构化操作是一块块清晰的乐高积木,方便我们进行各种重组和优化。

这里讲的结构化,指的是:它们拥有清晰、固定的语义,这与 “非结构化” 的、包含任意控制流的通用循环(如 scf.for)形成对比。具体体现在:

  • 操作有明确名称:如 linalg.matmul(矩阵乘)、linalg.conv(卷积)、linalg.generic(通用形式)。

  • 数据访问模式可预测:操作明确声明哪些张量是输入、哪些是输出,以及如何访问它们(如读取、写入、归约)。

  • 便于分析和转换:因为结构规整,编译器能更容易地进行循环重排、分块(tiling)、向量化等优化,而无需进行复杂的数据流分析。

接下来教程从简单到复杂,一步步阐述了保持结构的思想是如何体现的。

# 统一标量拓展

考虑 MLIR 中一个简单的标量算术加法操作,该操作在大多数支持浮点运算的架构上直接映射为机器指令:

%2 = arith.addf %0, %1 : f32

这个结构非常稳固,直接对应一条硬件指令。它本身就是一个最小、最清晰的 “结构单元”。编译器无需任何额外信息,就能完美地处理它。

现在将此操作拓展,均匀地应用于一维向量的各个元素:

%2 = arith.addf %0, %1 : vector<8xf32>

当前操作的核心语义(元素对元素的加法)被完整地保留下来了。它没有被拆解成 8 个独立的标量加法,而是作为一个整体操作存在。编译器看到了这个清晰的 “元素级并行” 结构后,就有了选择的自由:如果目标硬件有支持 vector<8xf32 > 加法的 SIMD 指令,编译器可以直接映射过去,获得高性能;如果硬件不支持,编译器也可以选择把它 “降级” 成循环或标量指令;重点在于,这个决策权(何时、如何实现)被保留在了编译流程中,而不是在编写代码时就固定死。

目前仅有少数现代指令集提供了对二维或更高维向量的指令支持。然而,在 MLIR 中,可以透明地将统一的逐元素操作扩展到任意秩的向量。

%2 = arith.addf %0, %1 : vector<8x4xf32> 
%5 = arith.addf %3, %4 : vector<2x2x2x2x2x2x2xf32>

如您所见,MLIR 对向量的算术操作保留了元素逐个统一应用的结构。编译器可以利用这一结构,例如生成目标硬件上可用的低秩操作,或在支持融合指令的情况下将乘法和加法合并(当连续一百次乘法后接一百次加法时,这种合并会变得复杂)。

# 归约 (Reduction)

MLIR 的 Vector 方言显式的表达了在一个 vector 中发生的规约操作。

%1 = vector.reduction <add>, %0 : vector<8xf32> into f32

这个操作清晰地表达了 “归约” 的意图。编译器可以灵活选择实现方式:是使用专用硬件指令,还是展开成一个循环,甚至是用相邻加法指令组合实现。

在没有特别支持的情况下,该操作可以转换为循环:

%c0 = arith.constant 0 : index
%c1 = arith.constant 1 : index
%c8 = arith.constant 8 : index
%init = arith.constant 0.0 : f32
%result = scf.for %i = %c0 to %c8 step %c1 iter_args(%partial = %init) -> (f32) {
  %element = vector.extract %0[%i] : f32 into vector<8xf32>
  %updated = arith.addf %partial, %element : f32
  scf.yield %updated : f32
}

# 收缩 (Contraction)

Contraction(收缩)是一种 “带归约的乘法”。它描述了一个非常通用的计算模式:从两个数组中取出元素相乘,然后将乘积结果累加起来。

MLIR 专门提供了收缩操作的表示:

// 1. 初始化累加器为 0.0
%init  = arith.constant 0.0 : f32
// 2. 创建一个全为 1.0 的向量,作为乘法的中性元素(这里是为了演示,实际点积会用两个数据向量)
%ones = arith.constant dense<1.0> : vector<8xf32>
// 3. 核心收缩操作
%result = vector.contract {
  indexing_maps = [affine_map<(i) -> (i)>,   // 第一个输入 %0 的索引方式
                   affine_map<(i) -> (i)>,   // 第二个输入 % ones 的索引方式
                   affine_map<(i) -> ()>],    // 输出 % init 的索引方式(空括号表示标量)
  iterator_types = ["reduction"]              // 迭代维度 i 是一个归约维度
} %0, %ones, %init : vector<8xf32>, vector<8xf32> into f32

上述收缩表示等同于下方伪代码:

for i in 0 to 8:
  init += p0[i] * ones[i]

不仅一维数据,教程又给了个经典的二维例子:二维矩阵乘法 (Matrix Multiplication)

%result = vector.contract {
  indexing_maps = [affine_map<(i, j, k) -> (i, k)>,   // 左矩阵 A [i, k]
                   affine_map<(i, j, k) -> (k, j)>,   // 右矩阵 B [k, j]
                   affine_map<(i, j, k) -> (i, j)>],  // 结果矩阵 C [i, j]
  iterator_types = ["parallel", "parallel", "reduction"] //i 并行,j 并行,k 归约
} %lhs, %rhs, %init: vector<8x10xf32>, vector<10x16xf32> into vector<8x16xf32>

iterator_types :明确告诉编译器, ij 是并行维度(结果矩阵的每个元素可以独立计算),而 k 是归约维度(需要累加)
indexing_maps :精确描述了数据如何被索引。比如 (i, j, k) -> (i, k) 表示在访问左矩阵时,行索引由 i 决定,列索引由 k 决定。
它的伪代码等同于:

for i in 0 to 8:
  for j in 0 to 16:
    for k in 0 to 10:
      init[i, j] += lhs[i, k] * rhs[k, j]

明确的并行性:编译器一眼就能看出 i 和 j 维度是完全并行的,这为后续的自动并行化(如多线程、GPU 映射)提供了最直接的信息。

可识别的模式:这个结构清晰地表明这是一个矩阵乘法。编译器可以利用这个信息,去调用经过高度优化的、手写的矩阵乘库(如 BLAS 库),或者使用特定的硬件加速器指令。这比让编译器从一堆循环中去 “猜” 这是矩阵乘要可靠和高效得多。

# 内存上的通用操作 (Generic Operation on Memory)

前面讲了 vector 方言在虚拟寄存器上表示的缩进操作,其实再内存上也有类似缩进的操作:

linalg.generic {
  indexing_maps = [affine_map<(i, j, k) -> (i, k)>,
                   affine_map<(i, j, k) -> (k, j)>,
                   affine_map<(i, j, k) -> (i, j)>],
  iterator_types = ["parallel", "parallel", "reduction"]
} ins(%lhs, %rhs : memref<8x10xf32>, memref<10x16xf32>)
  outs(%init : memref<8x16xf32>) {
^bb0(%lhs_one: f32, %rhs_one: f32, %init_one: f32):
  %0 = arith.mulf %lhs_one, %rhs_one : f32
  %1 = arith.addf %init_one, %0 : f32
  linalg.yield %1 : f32
}

之前 (vector.contract):操作在虚拟寄存器中的向量上,数据是只读的,操作产生新向量,像函数式编程。

现在 (linalg.generic):操作在内存中的缓冲区(memref)上,数据是 “就地” 读写的,更接近底层硬件的工作方式。
ins (inputs):是只读的输入缓冲区,就像仓库 A 和 B,里面的原料(lhs, rhs)在整个计算过程中不会被修改。

outs (outputs):是既读取又更新的输出缓冲区,就像成品仓库 init。计算开始时,它里面可能有初始值(比如全 0),计算过程中会不断从中读取旧值、写入新值。

{
^bb0(%lhs_one: f32, %rhs_one: f32, %init_one: f32):
  %0 = arith.mulf %lhs_one, %rhs_one : f32
  %1 = arith.addf %init_one, %0 : f32
  linalg.yield %1 : f32
}

以上是 linalg.generic 最核心、也最 “结构化” 的部分。它提供了一个代码区域(region)
linalg.generic 的 “布局图” 规定了它要遍历所有工作点,在每个点上,它会从 insouts 指定的缓冲区中,根据 indexing_maps 取出对应的一个元素,作为参数传给 ^bb0
参数顺序:前两个 (%lhs_one, %rhs_one) 对应 ins 列表,最后一个 (%init_one) 对应 outs 列表。
操作体拿到这三个元素(两个输入,一个初始 / 旧输出)后,按照手册操作:先乘后加。

linalg.yield %1 :完成操作后,必须通过 linalg.yield 把结果 %1 交出来。这个结果会被写回到 outs 缓冲区中对应的位置(在这里就是 %init(i, j) 位置)。

从这里能够看到 linalg.generic 的强大之处:
1. 它声明了 “是什么”:通过 indexing_maps 和 iterator_types,它精确无误地声明了这是一个带归约的、三维的、数据访问模式明确的计算。这比散落的循环嵌套更具信息量。
2. 它分离了 “控制流” 和 “计算”:循环的遍历方式(控制流)由操作属性控制,而每个点上具体的乘加计算(计算)被封装在 region 里。
3. 它为优化打开了大门:因为结构如此清晰,编译器可以轻松地对它进行分块(Tiling)、融合(Fusion)、并行化等高级转换,而不需要费力的分析。

# 循环融合 (“Loop” Fusion)

这一小节讲的是 linalg.generic 用单个操作实现 “循环融合(Loop Fusion)。
传统循环中,如果做两个循环,一般是先算一个,再算另一个,那么就有两个独立的循环:

// 循环 1:计算中间结果
for (i = 0; i < N; i++) {
    temp[i] = input[i] * 2.0;
}
// 循环 2:使用中间结果
for (i = 0; i < N; i++) {
    output[i] = temp[i] + 1.0;
}

循环融合就是把这两个循环融合成一个:

for (i = 0; i < N; i++) {
    temp = input[i] * 2.0;
    output[i] = temp + 1.0;
}

好处是:消除临时缓冲区,减少内存访问,提高缓存利用率。传统编译器做循环融合很困难,因为要分析两个循环的迭代空间是否兼容;分析数据依赖;处理迭代空间不匹配的情况。这些分析复杂且容易出错。
linalg.generic 能够天然地实现融合,因为 linalg.generic 的 region 里可以写任意多条操作,所以 “融合” 就变成了 “在同一个 region 里多写几行代码” 这么简单。
比如下面地 Relu 操作:

linalg.generic {
  indexing_maps [affine_map<(i) -> (i)>, affine_map<(i) -> (i)>],
  iterator_types = ["parallel"]
} ins(%in : memref<?xf32>) outs(%out : memref<?xf32>) {
^bb0(%in_one : f32, %out_one : f32):
  %c0 = arith.constant 0.0 : f32
  %0 = arith.cmpf ogt %in_one, %c0 : f32     // 比较:in > 0 ?
  %1 = arith.select %0, %in_one, %c0 : f32      // 选择:如果是,取 in;否则取 0
  linalg.yield %1 : f32
}

如果没有融合,ReLU 可能需要两个操作:一个操作做比较,产生一个布尔结果(需要一个临时缓冲区)。

另一个操作根据布尔结果做选择。
举个例子:
传统循环:就像给厨师两张分开的菜谱 ——“先切菜,切完放盘子里;再炒菜,从盘子里拿切好的菜”。中间那个 “盘子” 就是临时缓冲区。

linalg.generic 的融合:就像给厨师一张合并的菜谱 ——“切完直接炒,不用放盘子”。厨师也可以选择还是分两步做,但菜谱本身给了他一气呵成的可能。

# 张量的通用操作 (Generic Operation on Tensors)

该小节将 linalg.generic 从内存缓冲区(memref)提升到了张量(tensor)。
Buffer 版本:outs (% init) 就像一个 “草稿纸”,操作会直接在上面涂改,最后这张草稿纸本身就是结果。

Tensor 版本:outs (% init) 就像一份 “参考模板”,操作不会修改它,而是照着它生成一份全新的文档作为结果返回。

这就是 值语义(Value Semantics) 和 引用语义(Reference Semantics) 的区别。

%result = linalg.generic {
  indexing_maps = [affine_map<(i, j, k) -> (i, k)>,
                   affine_map<(i, j, k) -> (k, j)>,
                   affine_map<(i, j, k) -> (i, j)>],
  iterator_types = ["parallel", "parallel", "reduction"]
} ins(%lhs, %rhs : tensor<8x10xf32>,tensor<10x16xf32>)
  outs(%init :tensor<8x16xf32>) {
^bb0(%lhs_one: f32, %rhs_one: f32, %init_one: f32):
  %0 = arith.mulf %lhs_one, %rhs_one : f32
  %1 = arith.addf %init_one, %0 : f32
  linalg.yield %1 : f32
} -> tensor<8x16xf32>

在 Tensor 上的操作和上述在 Buffer 上的操作很相似,除了操作数类型外,主要区别在于该操作现在会生成一个新结果,而不是更新输出缓冲区。

# 分块与循环物化 (Tiling and Loop Materialization)

分块就是把一个大计算,切成许多小块来计算,让每一块的数据都能塞进缓存(Cache),从而避免频繁访问慢速的主存。
linalg.generic 分块非常简单。
1. 迭代空间是隐式的: linalg.generic 没有显式地写出 for i... for j... for k... 。它的迭代空间完全由操作数的形状(如 8x10、10x16)推导出来。

2. 分块 = 对数据切片做同样的操作:既然迭代空间是隐式的,那么 “分块” 就不需要去修改循环边界。你只需要从原数据中切出一小块(Slice),然后对这一小块应用完全相同的 linalg.generic 操作即可。

3. 无需依赖分析:因为 linalg.generic 明确规定 “对每个元素元组的执行顺序未指定”,所以各个分块之间天然就是可并行、可乱序的。编译器不需要做复杂的依赖分析,就能安全地重排分块顺序。

// A special "multi-for" loop that supports tensor-insertion semantics
// as opposed to implicit updates. The resulting 8x16 tensor will be produced
// by this loop.
// The trip count of iterators is computed dividing the original tensor size,
// 8x16, by the tile size, 2x8, to obtain 4x2.
// When tensor sizes are dynamic, the trip count computation is emitted as IR
// and is being computed at runtime.
%0 = scf.forall (%i, %j) in (4, 2) shared_outs(%shared = %init) -> (tensor<8x16xf32>) {  // 外层循环  (4,2) 循环的迭代次数
  // Scale the loop induction variables by the tile sizes.
  %3 = affine.apply affine_map<(d0) -> (d0 * 2)>(%i)  // 计算切片偏移
  %4 = affine.apply affine_map<(d0) -> (d0 * 8)>(%j)  // 循环变量 % i 和 % j 是分块的索引  % i*2:第 i 块在行方向的起始位置
  // Take slices of inputs and outputs. Only the "i" and "j" dimensions are sliced.
  %lhs_slice = tensor.extract_slice %lhs[%3, 0] [2, 10] [1, 1]  // 提取数据切片
             : tensor<8x10xf32> to tensor<2x10xf32>
  %rhs_slice = tensor.extract_slice %rhs[0, %4] [10, 8] [1, 1]  // 
             : tensor<10x16xf32> to tensor<10x8xf32>
  %result_slice = tensor.extract_slice %shared[%3, %4] [2, 8] [1, 1]
                : tensor<8x16xf32> to tensor<2x8xf32>
  // This is exactly the same operation as before, but now operating on smaller
  // slices of data.
  %partial =  linalg.generic {  // 核心计算
  indexing_maps = [affine_map<(i, j, k) -> (i, k)>,
                   affine_map<(i, j, k) -> (k, j)>,
                   affine_map<(i, j, k) -> (i, j)>],
  iterator_types = ["parallel", "parallel", "reduction"]
  } ins(%lhs_slice, %rhs_slice : tensor<2x10xf32>, tensor<10x8xf32>)
    outs(%result_slice : tensor<2x8xf32>) -> tensor<2x8xf32> {
  ^bb0(%lhs_one: f32, %rhs_one: f32, %init_one: f32):
    %0 = arith.mulf %lhs_one, %rhs_one : f32
    %1 = arith.addf %init_one, %0 : f32
    linalg.yield %1 : f32
  } : tensor<2x8xf32>
  // Terminator for the loop with tensor-insertion semantics. Inserts a slice
  // into a larger tensor, potentially in parallel.
  scf.forall.in_parallel {  // 结果插回
    tensor.parallel_insert_slice %partial into %shared[%3, %4] [2, 8] [1, 1]
        : tensor<2x8xf32> into tensor<8x16xf32>
  }
}

之前: linalg.generic 的循环是隐式的,你看不到 for 循环,只能从 indexing_maps 和 iterator_types 推导出来。

分块后:为了控制各个分块的执行顺序(比如先算哪一块、后算哪一块),编译器生成了显式的循环(这里是 scf.forall
分块的过程,同时也是把隐式循环 “物化”(Materialize)成显式循环的过程。

# 生产者 - 消费者融合和重物化 (Producer/Consumer Fusion and Rematerialization)

生产者和消费者,其实就是数据从 A 流向 B。比如矩阵乘 linalg.matmul,它产生了一个结果张量,它是生产者,之后一个逐元素平方操作,读取矩阵乘的结果,它是消费者。
传统循环融合(Loop Fusion)要求两个循环的迭代空间完全匹配才能融合。但结构化操作允许迭代空间不匹配也能融合。

// Same loop as before.
%0 = scf.forall (%i, %j) in (4, 2)  // 和前述同样的分块 (2*8)
     shared_outs(%shared = %init) -> (tensor<8x16xf32>, tensor<8x16xf32>) {  // 教程这里标记了两个输出,实际这段代码只有 % elemwise 一个输出,可能教程代码做了简化之类的
  // Scale the loop induction variables by the tile sizes.
  %1 = affine.apply affine_map<(d0) -> (d0 * 2)>(%i)
  %2 = affine.apply affine_map<(d0) -> (d0 * 8)>(%j)
  // Take slices of inputs and outputs. Only the "i" and "j" dimensions are sliced.
  %lhs_slice = tensor.extract_slice %lhs[%1, 0] [2, 10] [1, 1]
             : tensor<8x10xf32> to tensor<2x10xf32>
  %rhs_slice = tensor.extract_slice %rhs[0, %2] [10, 8] [1, 1]
             : tensor<10x16xf32> to tensor<10x8xf32>
  %result_slice = tensor.extract_slice %result[%1, %2] [2, 8] [1, 1]
                : tensor<8x16xf32> to tensor<2x8xf32>
  // This is exactly the same matmul slice as before. It replaces the slice
  // extraction for the generic operation below.
  %partial = linalg.generic {
    indexing_maps = [affine_map<(i, j, k) -> (i, k)>,
                     affine_map<(i, j, k) -> (k, j)>,
                     affine_map<(i, j, k) -> (i, j)>],
    iterator_types = ["parallel", "parallel", "reduction"]
  } ins(%lhs_slice, %rhs_slice : tensor<2x10xf32>, tensor<10x8xf32>)
   outs(%result_slice : tensor<2x8xf32>) {
  ^bb0(%lhs_one: f32, %rhs_one: f32, %init_one: f32):
    %5 = arith.mulf %lhs_one, %rhs_one : f32
    %6 = arith.addf %init_one, %5 : f32
    linalg.yield %6 : f32
  } -> tensor<2x8xf32>
  // Take the slice of the final result. Note that we don't need to take
  // the slice of the operand because the matmul operation above computes
  // it in-place.
  %shared_slice = tensor.extract_slice %shared[%1, %2] [2, 8] [1, 1]
                : tensor<8x16xf32> to tensor<2x8xf32>
  // The elementwise operation that we tiled.
  %elemwise = linalg.generic {
    indexing_maps = [affine_map<(i, j) -> (i, j)>,
                     affine_map<(i, j) -> (i, j)>],
    iterator_types = ["parallel", "parallel"]
  } ins(%partial : tensor<2x8xf32>)  // 读取的是前一操作的结果
   outs(%shared_slice : tensor<2x8xf32>) {  // 
  ^bb0(%in: f32, %out: f32):
    %5 = arith.mulf %in, %in : f32
    linalg.yield %5 : f32
  } -> tensor<2x8xf32>
  // Terminator for the loop with tensor-insertion semantics. Inserts a slice
  // into a larger tensor, potentially in parallel.
  scf.forall.in_parallel {  // 最终输出 % elemwise (scf.forall 的输出数量应该与 in_parallel 中的插入操作严格对应)
    tensor.parallel_insert_slice %elemwise into %shared[%1, %2] [2, 8] [1, 1]
        : tensor<2x8xf32> into tensor<8x16xf32>
  }
}

教程给的例子:Producer:矩阵乘,3D 迭代空间 (i, j, k)。Consumer:逐元素平方,2D 迭代空间 (i, j)
教程之所以能够 Tile 切块还能融合:是因为逆推 Shape。
分块是从 Consumer 开始的。我们先把 Consumer 的迭代空间切成 2x8 的小块,要计算出这个 2x8 的消费者小块,生产者需要提供哪一部分数据,逆推出生产者 Producer 的迭代空间。

# Shorthand “Named” Forms of Linalg Ops

前面我们一直在手写 linalg.generic,需要自己填 indexing_maps、iterator_types、region 里的乘加逻辑,比较麻烦,但对于常见计算(矩阵乘、点积、卷积等),这些 “模板” 是固定的,所以 Linalg 直接提供了一批命名操作(如 linalg.matmul、linalg.dot、linalg.conv),把那些固定内容预先填好了。它们在语义上完全等价于对应的 linalg.generic 。

之前的通用版矩阵乘:

%matmul = linalg.generic {
  indexing_maps = [affine_map<(i, j, k) -> (i, k)>,
                   affine_map<(i, j, k) -> (k, j)>,
                   affine_map<(i, j, k) -> (i, j)>],
  iterator_types = ["parallel", "parallel", "reduction"]
} ins(%lhs, %rhs : tensor<8x10xf32>, tensor<10x16xf32>)
  outs(%init : tensor<8x16xf32>) {
^bb0(%lhs_one: f32, %rhs_one: f32, %init_one: f32):
  %0 = arith.mulf %lhs_one, %rhs_one : f32
  %1 = arith.addf %init_one, %0 : f32
  linalg.yield %1 : f32
} -> tensor<8x16xf32>

等价于当前的:

%matmul = linalg.matmul ins(%lhs, %rhs: tensor<8x10xf32>, tensor<10x16xf32>)
                        outs(%init: tensor<8x10xf32xf32>) -> tensor<8x16xf32>

# 后记

一个在应对环境方面有异常困难的人在挣扎着,尘土飞扬。
我曾使用过这样一个形象:一条被鱼钩钩住的鱼。
在其他不了解这种情况的鱼看来,它旋转的样子一定很奇特;但它激起的水花并不是它的痛苦,而是摆脱痛苦的努力。
每个渔民都知道,这种努力很可能会成功。
                   —— 卡尔.A.梅宁格(Karl A .Menninger),1945

本博客目前以及可预期的将来都不会支持评论功能。各位大侠如若有指教和问题,可以在我的 github 项目 或随便一个项目下提出 issue,并指明哪一篇博客,看到一定及时回复!

Edited on

Give me a cup of [coffee]~( ̄▽ ̄)~*

XianMu WeChat Pay

WeChat Pay

XianMu Alipay

Alipay