C++ 基础补完,回到编译器主线。这一篇讲 Linalg 的结构化 op 怎么表达计算,以及 tiling(分块)到底切的是什么。我自己在这里踩了一个坑:一开始把"块大小"理解成了某个矩阵的形状。API 以 LLVM 20 为准,IR 为示意。
一、Linalg 在哪一层
高层 op(mic / torch 等) "做一个矩阵乘",没有任何细节
↓
linalg ← 本篇
↓
scf 循环 + memref 具体的 for 循环和内存读写
分块、融合、向量化这些优化如果放到循环层做,编译器得先分析循环才知道"哪些维度能并行、每个数组怎么被访问",这种分析难且容易失败。Linalg 的思路是在变成循环之前就把这些信息显式写在 op 上,优化直接读取。所以 Linalg 的 op 叫 structured op(结构化 op)。
二、读懂 linalg.generic
以 4×8 tensor 上的 relu 为例:
mlir
#id = affine_map<(d0, d1) -> (d0, d1)>
%init = tensor.empty() : tensor<4x8xf32>
%r = linalg.generic {
indexing_maps = [#id, #id],
iterator_types = ["parallel", "parallel"]
} ins(%x : tensor<4x8xf32>) outs(%init : tensor<4x8xf32>) {
^bb0(%in: f32, %out: f32):
%zero = arith.constant 0.0 : f32
%m = arith.maximumf %in, %zero : f32
linalg.yield %m : f32
} -> tensor<4x8xf32>
四个组成部分:
- 迭代空间 :
(d0, d1)表示两层循环,范围由 tensor 形状推出。 indexing_maps:每个操作数一个(先 ins 后 outs),说明在迭代点上取该操作数的哪个元素。iterator_types:每个维度是parallel(各点独立,可并行、可换序)还是reduction(累加到同一位置)。- region :对单个点的标量计算,参数依次是各操作数在该点的元素,
linalg.yield给出写回outs的值。
outs 是"结果写到哪里"。tensor 是不可修改的值,所以先用 tensor.empty 造一个占位,op 返回新的 tensor。这种把目的地当参数传入的写法叫 destination-passing style,后面 Bufferization 正是靠它判断能否原地写。
三、矩阵乘的 generic 写法
C[i][j] += A[i][k] * B[k][j]:
mlir
%r = linalg.generic {
indexing_maps = [
affine_map<(i, j, k) -> (i, k)>, // A
affine_map<(i, j, k) -> (k, j)>, // B
affine_map<(i, j, k) -> (i, j)> // C
],
iterator_types = ["parallel", "parallel", "reduction"]
} ins(%A, %B : tensor<128x128xf32>, tensor<128x128xf32>)
outs(%C : tensor<128x128xf32>) {
^bb0(%a: f32, %b: f32, %c: f32):
%p = arith.mulf %a, %b : f32
%s = arith.addf %c, %p : f32
linalg.yield %s : f32
} -> tensor<128x128xf32>
C 的映射里没有 k:不同的 k 写到同一个 C[i][j],所以 k 是 reduction。linalg.matmul 就是这段 generic 的具名简写。
再看一个归约例子,按行求和 out[i] = Σ_j A[i][j]:
mlir
%zero = arith.constant 0.0 : f32
%init = tensor.empty() : tensor<4xf32>
%acc = linalg.fill ins(%zero : f32) outs(%init : tensor<4xf32>) -> tensor<4xf32>
%r = linalg.generic {
indexing_maps = [
affine_map<(i, j) -> (i, j)>, // A
affine_map<(i, j) -> (i)> // out
],
iterator_types = ["parallel", "reduction"]
} ins(%A : tensor<4x8xf32>) outs(%acc : tensor<4xf32>) {
^bb0(%a: f32, %o: f32):
%s = arith.addf %o, %a : f32
linalg.yield %s : f32
} -> tensor<4xf32>
两个易错点:indexing_maps 别漏了输入的映射;归约是在 outs 原值上累加的,输出必须先用 linalg.fill 填 0 ,直接用 tensor.empty 结果是未定义的。
四、Tiling
为什么要切
128×128 的矩阵乘一次算完,所需数据放不进寄存器,也可能放不进缓存或 shared memory。切成小块后,每块的数据能放进快速存储反复使用;在 GPU 上,每个块还正好交给一个线程块。
切出来的 IR
块大小 [32, 32, 0] 下:
mlir
%res = scf.for %i = %c0 to %c128 step %c32 iter_args(%acc = %C) -> (tensor<128x128xf32>) {
%row = scf.for %j = %c0 to %c128 step %c32 iter_args(%acc2 = %acc) -> (tensor<128x128xf32>) {
%a = tensor.extract_slice %A[%i, 0] [32, 128] [1, 1] : ... to tensor<32x128xf32>
%b = tensor.extract_slice %B[0, %j] [128, 32] [1, 1] : ... to tensor<128x32xf32>
%c = tensor.extract_slice %acc2[%i, %j] [32, 32] [1, 1] : ... to tensor<32x32xf32>
%t = linalg.matmul ins(%a, %b : ...) outs(%c : tensor<32x32xf32>) -> tensor<32x32xf32>
%u = tensor.insert_slice %t into %acc2[%i, %j] [32, 32] [1, 1] : ...
scf.yield %u : tensor<128x128xf32>
}
scf.yield %row : tensor<128x128xf32>
}
extract_slice 的三组方括号依次是起点、大小、步长。结构是:外层 scf.for 遍历块,内层是同一个 linalg op 作用在小块上,因此可以继续多级切块或向量化。
iter_args 的存在是因为 tensor 不可修改,insert_slice 产生的是新 tensor,必须逐次传递。Bufferization 之后它们会变成同一块内存上原地写的 subview,并没有真正复制。
我踩的坑:块大小到底是谁的大小
我一开始以为 [32, 32, 0] 是在说"A 和 B 都切成 32×32",于是算出 B 的切片是 32×32,这是错的。
块大小是按迭代空间的循环维度给的 ,三个数依次对应 i、j、k。每个矩阵的切片形状要再经过映射才能得出:
i切 32,j切 32,k写 0 表示不切、走满 128;- A 映射
(i, k)→ 32×128;B 映射(k, j)→ 128×32;C 映射(i, j)→ 32×32。
更直观的画面:
- 矩阵乘的迭代空间是三维的 ,所有
(i, j, k)构成一个 128×128×128 的立方体,每个点是一次乘加; - 块大小切的是这个立方体 :
[32, 32, 0]切出 32×32×128 的长方体; - 映射把这个三维块投影到各矩阵上:A 看 i-k 平面、B 看 k-j 平面、C 看 i-j 平面,得到形状各异的二维切片。
这个画面对卷积这种六七维的迭代空间同样适用。
计算某个块读取哪一片的方法:先写出块的迭代范围,再逐个代入映射。例如 i = 64, j = 32 的块,范围是 i ∈ [64, 96)、j ∈ [32, 64)、k ∈ [0, 128),所以 B 的切片起点 [0, 32]、大小 128×32,C 的起点 [64, 32]、大小 32×32。
这也说明了 Linalg 的价值:tiling 逻辑对任何 generic 都一样,不需要知道这是矩阵乘还是卷积,按映射算切片即可。
切 k:分块矩阵乘法
块大小写成 [32, 32, 16],k 也被切了,多出一层 reduction 循环:
for i in 0..128 step 32: # parallel
for j in 0..128 step 32: # parallel
for k in 0..128 step 16: # reduction
C[i:i+32, j:j+32] += A[i:i+32, k:k+16] @ B[k:k+16, j:j+32]
这就是线性代数里的分块矩阵乘法。以 2×2 分块为例:
C11 = A11·B11 + A12·B21
把"行乘列再相加"里的每个数换成一个小块,k 被切成几段右边就有几项。和 [32, 32, 0] 相比结果完全一样,区别在于每一步只需要两个小块,更容易放进寄存器或 shared memory。实际的 GEMM kernel 几乎都切 k。
parallel 与 reduction 维度切起来不同
- 切
i、j得到的块互不相干,可以分给不同线程块并行; - 切
k得到的块都累加到同一个 C 块上,不能直接并行,只能在块内顺序执行。
如果确实需要在 k 上并行,可以用 split-K :把 k 分成几段,各段算出部分和写到临时缓冲,最后再相加(或用原子加)。当 M、N 很小而 K 很大、parallel 维度的块不足以填满 GPU 时会用到。
五、和 Triton 对照
python
pid_m = tl.program_id(0); pid_n = tl.program_id(1) # 第几个块
acc = tl.zeros((BLOCK_M, BLOCK_N), ...)
for k in range(0, K, BLOCK_K): # k 维顺序循环
a = tl.load(...); b = tl.load(...) # A、B 的切片
acc += tl.dot(a, b) # 块上的小 matmul
这正是 [BLOCK_M, BLOCK_N, BLOCK_K] 的 tiling 结果:i、j 的块循环变成 grid,k 在每个 program 内顺序累加。区别在于 Triton 中块是手动划分的,Linalg 中由编译器按块大小自动切出。
六、动手试:用 transform dialect 触发 tiling
Linalg op 实现了 TilingInterface,C++ 中可调用 scf::tileUsingSCF。更常见的是 transform dialect 脚本:
mlir
module attributes {transform.with_named_sequence} {
transform.named_sequence @__transform_main(%root: !transform.any_op {transform.readonly}) {
%mm = transform.structured.match ops{["linalg.matmul"]} in %root
: (!transform.any_op) -> !transform.any_op
%tiled, %loops:2 = transform.structured.tile_using_for %mm tile_sizes [32, 32, 0]
: (!transform.any_op) -> (!transform.any_op, !transform.any_op, !transform.any_op)
transform.yield
}
}
与 matmul 放在同一文件,用 mlir-opt --transform-interpreter 运行即可看到切块后的 IR。该语法在不同 LLVM 版本间有过变化,若跑不通可对照 LLVM 源码 mlir/test/Dialect/Linalg/ 下 tile 相关的测试调整。把块大小改成 [32, 32, 16] 再跑一次,对比多出来的那层循环。
小结
linalg.generic= 迭代空间 + 每个操作数的映射 + 维度类型 + 单点计算。- 块大小按循环维度给出;块是迭代空间中的三维块,矩阵切片是它经映射的投影。
- 不切
k:每个 C 块一次乘完;切k:分块矩阵乘法,逐步累加。 - parallel 维度的块可并行,reduction 维度的块顺序累加(或用 split-K)。
下一篇:向量化------切出来的小块怎样变成 SIMD 或 Tensor Core 指令。