【infra之路】AI 编译器学习笔记(七):Linalg 与 Tiling——把迭代空间切成块

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>

四个组成部分:

  1. 迭代空间 :(d0, d1) 表示两层循环,范围由 tensor 形状推出。
  2. indexing_maps :每个操作数一个(先 ins 后 outs),说明在迭代点上取该操作数的哪个元素。
  3. iterator_types :每个维度是 parallel(各点独立,可并行、可换序)还是 reduction(累加到同一位置)。
  4. 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 指令。

相关推荐
高洁011 小时前
世界模型环境预测与内在表征基础原理
人工智能·深度学习·机器学习·transformer·tornado
沪上企服通1 小时前
税制变更前的影子账套回归:代账 pipeline 怎么在规则上线前先跑一遍
人工智能·数据挖掘·回归
赶路人儿1 小时前
简述LLM 中的GCG 攻击
人工智能
m0_587383001 小时前
碰碰好评卡功能落地实战 从需求梳理到上线部署全流程实操指南
java·linux·服务器·前端·javascript·vue.js·人工智能
承渊政道1 小时前
Linux网络学习【应用层自定义协议与序列化实战:从手写序列化与反序列化到深入理解进程间关系与守护进程】
linux·网络·学习·ubuntu·json·序列化与反序列化
半吊子全栈工匠1 小时前
构建企业级 Agentic AI的语义层
人工智能·语义层·本体驱动的ai大模型
李航19831 小时前
SketchUp国产平替来了,功能非常接近,还有AI版本自动建模
人工智能·python·计算机视觉·ai·ai编程
玩AI的奶茶1 小时前
什么情况适合租算力?一张场景对照表,加三个自测问题
大数据·linux·服务器·数据库·人工智能·python
你想知道什么?1 小时前
标准化与归一化-学习笔记
笔记·学习