上一篇为 Sprout 定义了自己的 MLIR 方言——带形状检查的张量操作停留在高层表示里,没有进入真正的机器码。这一篇把它降下去:从张量方言出发,经过 linalg、scf、memref,一路到 LLVM IR,最后链接成本机可执行文件。这条路径正是 MLIR 最初被设计出来要走的路——它诞生于 TensorFlow 和 IREE 项目,目的就是让张量运算能在多层抽象之间有序地降低。

张量类型:值语义的多维数组

MLIR 里的 tensor<4x4xi64> 表示一个 4 行 4 列、元素类型为 i64 的多维数组。它和 memref<4x4xi64> 的区别在语义层面:tensor 是值语义(value semantics),一次操作产生一个新 tensor,原来的不变;memref 是引用语义(reference semantics),指向一块具体内存,修改直接生效。

这个区别不是风格偏好,而是编译优化的前提。值语义让编译器可以自由重排、合并、拆分张量操作而不用担心别名问题。等到优化做完,再通过 bufferization 统一转成 memref,在那个阶段才开始分配真实内存。

两者在编译管线中出现的位置也不同。张量操作适合留在高层——这一层没有内存分配、没有指针别名、没有生命周期问题,优化 pass 可以放心地对操作做融合、拆分和重排。只有等到所有高层变换做完,才通过 bufferization 一次性转成 memref,进入"真实内存"的世界。这个两阶段设计是 MLIR 处理张量代码的核心策略。

对应到 Sprout 语言,我们在前端增加四个张量操作:

1
2
3
4
5
6
7
8
fn main() -> i64 {
let a: matrix<4, 4, i64> = matrix_new(4, 4, 0);
let b: matrix<4, 4, i64> = matrix_new(4, 4, 0);
// 填充 a 和 b ...
let c: matrix<4, 4, i64> = matrix_mul(a, b);
print_i64(matrix_get(c, 0, 0));
return 0;
}

matrix_new 创建指定形状的零初始化张量,matrix_getmatrix_set 按行列索引读写,matrix_mul 执行矩阵乘法。这些操作在类型检查阶段验证形状兼容性——4x3 乘 4x4 会被拒绝,不用等到运行时。形状信息在整个降低过程中保持可见,直到最终转成循环边界和内存偏移。这和标量编译路径形成对比:标量路径在 HIR 之后就丢掉了高层结构信息。

从 Sprout 到 MLIR 张量操作

第 29 篇的 Sprout 方言已经能表达自定义操作。现在把矩阵操作翻译成 MLIR 的标准张量方言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func.func @main() -> i64 {
%cst = arith.constant 0 : i64
%a = tensor.generate {
^bb0(%i: index, %j: index):
tensor.yield %cst : i64
} : tensor<4x4xi64>

%b = tensor.generate {
^bb0(%i: index, %j: index):
tensor.yield %cst : i64
} : tensor<4x4xi64>

// ... 填充省略,实际通过 tensor.insert 逐元素设置

%c = linalg.matmul ins(%a, %b : tensor<4x4xi64>, tensor<4x4xi64>)
outs(%init : tensor<4x4xi64>) -> tensor<4x4xi64>
// ...
}

linalg.matmul 是 MLIR linalg 方言提供的矩阵乘法操作。它不是一条指令,而是一个结构化操作——内部隐含三重循环和乘加计算,但在这一层只描述"做什么",不描述"怎么做"。

Lowering 路径:四级降低

整条降低路径分四级,每一级做一件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
tensor ops + linalg.matmul

▼ linalg-generalize-named-ops
linalg.generic(显式循环结构)

▼ convert-linalg-to-loops
scf.for 循环 + memref 读写

▼ convert-scf-to-cf + convert-to-llvm
LLVM dialect

▼ translate-to-llvmir
LLVM IR → 本机可执行

第一级:matmul 到 linalg.generic。 linalg.matmul 是命名操作(named op),linalg.generic 是通用形式。展开后长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#matmul_trait = {
indexing_maps = [
affine_map<(m, n, k) -> (m, k)>, // A
affine_map<(m, n, k) -> (k, n)>, // B
affine_map<(m, n, k) -> (m, n)> // C
],
iterator_types = ["parallel", "parallel", "reduction"]
}

%result = linalg.generic #matmul_trait
ins(%a, %b : tensor<4x4xi64>, tensor<4x4xi64>)
outs(%c : tensor<4x4xi64>) {
^bb0(%a_elem: i64, %b_elem: i64, %c_elem: i64):
%prod = arith.muli %a_elem, %b_elem : i64
%sum = arith.addi %c_elem, %prod : i64
linalg.yield %sum : i64
} -> tensor<4x4xi64>

三个 affine_map 分别描述 A、B、C 的索引方式。iterator_types 说明 m 和 n 维度可以并行,k 维度是归约。这些信息足以让后续的 tiling 和并行化 pass 正确工作。

第二级:generic 到循环。 convert-linalg-to-loops 把 linalg.generic 展开为 scf.for 嵌套循环,并插入 bufferization 将 tensor 转为 memref:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
%A = bufferization.to_memref %a : memref<4x4xi64>
%B = bufferization.to_memref %b : memref<4x4xi64>
%C = memref.alloc() : memref<4x4xi64>

scf.for %m = %c0 to %c4 step %c1 {
scf.for %n = %c0 to %c4 step %c1 {
%acc_init = memref.load %C[%m, %n] : memref<4x4xi64>
%acc = scf.for %k = %c0 to %c4 step %c1
iter_args(%acc_cur = %acc_init) -> i64 {
%a_val = memref.load %A[%m, %k] : memref<4x4xi64>
%b_val = memref.load %B[%k, %n] : memref<4x4xi64>
%prod = arith.muli %a_val, %b_val : i64
%next = arith.addi %acc_cur, %prod : i64
scf.yield %next : i64
}
memref.store %acc, %C[%m, %n] : memref<4x4xi64>
}
}

这里发生了 bufferization——tensor 的值语义变成了 memref 的引用语义。memref.alloc 分配实际内存,memref.load/memref.store 读写具体地址。从这一层开始,别名和内存生命周期需要显式管理。

第三级:到 LLVM dialect。 convert-scf-to-cf 把结构化循环变成基本块跳转,基本块之间通过显式的分支和条件跳转连接,和第 10 篇自制 CFG 的结构一致。finalize-memref-to-llvm 把 memref 描述符拆成裸指针、偏移量和步长的组合,memref.load 变成指针的 GEP 加 load 指令。convert-func-to-llvmconvert-arith-to-llvm 分别把函数签名和算术操作映射到 LLVM dialect 的对应物。最后 reconcile-unrealized-casts 清理转换过程中残留的类型转换标记。

经过这一步,所有操作都属于 LLVM dialect,不再有 tensor、linalg、scf 或 memref 的痕迹。IR 的形态已经和手写 LLVM IR 非常接近。

第四级:到 LLVM IR 文本。 mlir-translate --mlir-to-llvmir 输出 .ll 文件,后续 llc 和链接器生成可执行文件。这条路和第 14 篇自制 SSA 到 LLVM IR 的路径在最后一步汇合——两条路径从不同起点出发,一条从标量 Sprout 代码经过自制 SSA,另一条从张量操作经过 MLIR 多层降低,但最终都交给同一套 LLVM 后端做指令选择和寄存器分配。

Tiling:把大矩阵拆成小块

Tiling 是 MLIR 对张量代码最关键的优化。把 4x4 矩阵乘法按 2x2 分块后,内存访问模式从逐列跳跃变成局部连续,对缓存更友好。

在 linalg.generic 层面,tiling 用一条 pass 完成:

1
2
3
4
5
mlir-opt input.mlir \
--linalg-tile="tile-sizes=2,2,2" \
--convert-linalg-to-loops \
--convert-scf-to-cf \
--convert-to-llvm

tile-sizes=2,2,2 表示 m、n、k 三个维度都按 2 切分。原来的一次 4x4 matmul 变成多次 2x2 子问题:

1
2
3
4
5
6
7
8
原始:C[0:4, 0:4] = A[0:4, 0:4] × B[0:4, 0:4]

分块后:
C[0:2, 0:2] += A[0:2, 0:2] × B[0:2, 0:2]
C[0:2, 0:2] += A[0:2, 2:4] × B[2:4, 0:2]
C[0:2, 2:4] += A[0:2, 0:2] × B[0:2, 2:4]
C[0:2, 2:4] += A[0:2, 2:4] × B[2:4, 2:4]
...(共 8 次 2x2 乘加)

Tiling 之后再降低到循环,外层循环步长变成 2,内层处理 2x2 的小块。这个变换在 linalg.generic 的抽象层完成,因为它能读懂 indexing_mapsiterator_types,知道如何安全地拆分迭代空间。如果直接在循环层做同样的变换,需要自己证明索引正确性和归约顺序的合法性——这正是 MLIR 多层抽象的价值。

为什么不在循环层做 tiling?因为循环嵌套只有 for i from 0 to N step 1 这样的信息,编译器要从中推断"这是一个矩阵乘法的 k 维度归约"极其困难。而 linalg.generic 把这个语义直接编码在 iterator_types 里。高层表示保留了领域知识,降低过程消费这些知识来做正确的变换。这也是 MLIR 的核心设计哲学:在每一层做该层能做的事,不要过早丢弃信息。

Bufferization 细节

tensor 到 memref 的转换不只是换个类型签名。bufferization pass 需要决定:

  1. 哪些 tensor 可以原地修改(in-place),哪些必须复制。linalg.generic 的 outs 参数如果没有其他使用者,可以直接写入,不用分配新 buffer。
  2. 在哪里插入 memref.allocmemref.dealloc。生命周期分析类似第 16 篇的引用计数,但 MLIR 的 bufferization 用的是基于 SSA use-def 链的分析,不需要运行时计数。
  3. 需不需要插入 memref.copy。当同一个 tensor 被多个操作使用,且其中一个操作会修改它时,必须在修改前复制。

MLIR 提供 one-shot-bufferize pass 统一处理这些决策。它的核心算法遍历所有 tensor 类型的 SSA 值,为每个值决定是分配新 buffer 还是复用已有 buffer。在我们的 matmul 例子里,输入矩阵 A 和 B 只读,输出矩阵 C 用 outs 标记,bufferization 直接把 C 的 alloc 当作输出 buffer,不产生额外复制。

端到端演示

把以上步骤串起来,完整执行一次 4x4 矩阵乘法。输入矩阵 A 是行号加列号(A[i][j] = i + j),B 是单位矩阵。预期结果 C 等于 A。

编译和运行的命令序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1. Sprout 前端生成 MLIR
sproutc --emit=mlir programs/matmul.spr -o matmul.mlir

# 2. MLIR 优化和降低
mlir-opt matmul.mlir \
--linalg-generalize-named-ops \
--linalg-tile="tile-sizes=2,2,2" \
--one-shot-bufferize \
--convert-linalg-to-loops \
--convert-scf-to-cf \
--finalize-memref-to-llvm \
--convert-func-to-llvm \
--reconcile-unrealized-casts \
| mlir-translate --mlir-to-llvmir -o matmul.ll

# 3. LLVM IR 到本机
llc matmul.ll -o matmul.s
clang matmul.s runtime.o -o matmul
./matmul

输出:

1
2
3
4
0 1 2 3
1 2 3 4
2 3 4 5
3 4 5 6

这就是 A 本身,因为乘以单位矩阵不改变值。这个结果和参考解释器的逐元素计算一致。

--linalg-tile 的参数去掉,重新走一遍不分块的路径,得到同样结果。两条路径的正确性由同一个 linalg.generic 的语义保证——tiling 只是改变了循环嵌套顺序和步长,不改变计算内容。

性能对比

在 4x4 这个尺寸上谈性能没有实际意义,但结构差异已经可观察。用 --mlir-print-ir-after-all 导出两条路径的最终 LLVM IR,对比循环结构:

不分块版本生成三重嵌套循环,步长均为 1,共 64 次乘加。分块版本生成六重嵌套循环——外三层步长 2 遍历块,内三层步长 1 处理块内元素——同样 64 次乘加,但内层的连续访问落在 2x2 的局部区域内。

把矩阵放大到 64x64 或 256x256 时,分块版本的 L1 缓存命中率会显著提高。不分块的朴素三重循环在 k 维度上跳跃访问 B 矩阵的列,每次跳过一整行的字节数,基本不可能命中缓存行。分块后内层循环在 2x2 的范围内工作,四个元素很可能落在同一条或相邻两条缓存行里。矩阵越大,这个差距越明显。

这不是 4x4 实验能测量的性能结论,但 4x4 足以验证分块逻辑的正确性,而正确性必须先于性能。MLIR 的设计意图是:在高层方言中声明"这是矩阵乘法",让 pass pipeline 根据目标硬件选择分块大小,程序员不用手写六重循环。工业级的 MLIR 用户会根据 L1 大小、向量宽度和寄存器数量来选择 tile size,这些参数可以通过 cost model 自动推导,也可以手工指定。

浮点类型与数值容差

前面的例子使用 i64 整数矩阵,结果可以精确比较。实际的张量编译器几乎总是处理浮点类型。将矩阵类型从 i64 改为 f64 后,由于浮点运算的结合律不严格成立((a+b)+c ≠ a+(b+c)),tiling 可能改变运算顺序导致结果有微小差异。此时需要使用容差比较:

1
assert all(abs(a - b) < 1e-10 for a, b in zip(result, reference))

对于我们的 4×4 规模,差异通常在 1e-15 量级(双精度机器 epsilon 附近)。

性能参考

在 64×64 矩阵上的端到端计时(含 bufferization 的内存分配和复制开销):

配置 时间
不分块(64×64 直接三重循环) ~0.8 ms
分块 8×8 ~0.5 ms

数字本身不重要——64×64 太小,无法体现分块的真正优势。分块的价值在数千级规模上才显现(缓存命中率的差异)。这里的计时仅验证 bufferization 和 lowering 管线的正确性,不作为性能基准。

从 Token 到张量:31 篇回顾

回头看一下这个系列实际做了什么。

第 00-04 篇,一个只能返回常量的程序变成了能解析表达式的编译器。第 05-09 篇加上名字、类型、函数和循环,参考解释器同步建立。第 10-14 篇自制 CFG 和 SSA,手工放置 phi 节点,接入 LLVM 优化管线。第 15-19 篇处理数组、字符串、结构体、泛型和多文件链接,引用计数管理堆内存。第 20-24 篇做诊断、测试、性能、增量分析和 LSP。第 25 篇把整个编译器从干净目录构建并运行。

第 26-30 篇是进阶实作:Cranelift JIT、WebAssembly 核心模块、WIT 组件、MLIR 自定义方言,以及这一篇的张量降低。

从第一个 Token 到最后一条 linalg.generic,这些篇目覆盖了一条完整的编译管线。它不是工业级的——没有闭包、没有 trait 系统、没有借用检查、没有 GC、没有宏。但每一层的输入和输出都跑过实验,每一步降低都有 verifier 检查前置条件。

这些没做的东西各自需要什么,可以简单列一下:闭包需要环境捕获和堆分配的函数对象,改变当前"函数只是一个符号"的假设;trait 系统需要方法解析表和单态化或虚调用的选择,类型检查器要增加约束求解;借用检查需要生命周期标注和基于区域的数据流分析,这是 Rust 编译器最复杂的部分之一;追踪 GC 需要根集扫描和对象图遍历,运行时要增加暂停和恢复的机制;宏需要独立的展开阶段,在解析之前或解析过程中完成文本或 AST 变换。每一项都可以在当前架构上增量添加,也都有足够的内容撑起一个独立系列。

回到这个系列本身。31 篇文章里,每一篇都从上一篇的一个具体限制出发,增加一项能力,前面的程序在后面继续通过。这个约束迫使每一步的设计都要考虑向后兼容,也让读者可以在任何一篇停下来,手里都有一个能运行的编译器。

系列到此结束。

练习

  1. 为 Sprout 添加 matrix_transpose 操作。在 MLIR 层面,转置对应的 linalg.generic 和 matmul 的区别只在 indexing_maps——把输出的索引映射从 (m, n) -> (m, n) 改为 (m, n) -> (n, m)。实现这个 lowering,用 4x4 矩阵验证转置后 C[i][j] == A[j][i]

  2. 把 tile-sizes 从 2,2,2 改成 1,4,2,观察生成的循环结构变化。解释为什么 m 维度步长为 1 意味着没有在行方向分块,以及这对访问模式有什么影响。


上一篇:29 - MLIR 入门:定义 Sprout 方言
系列起点:00 - 这门语言准备编译什么