MLIR 里大部分优化 pass(包括 Triton 的很多变换)都基于这套机制,面试几乎必问。这一篇以"BiasAdd + ReLU 融合"为例,讲清楚 pattern、rewriter、driver 三者的分工。API 以 LLVM 20 为准,示例中的 op 名是示意。
一、一句话定位
MLIR 中大多数变换都是"找到一小片符合条件的 op,换成另一片"。PatternRewriter 这套设施把它拆成两件事:
- pattern 只管:匹配什么、改成什么;
- driver 负责:怎么遍历、何时应用、何时停。
写 pattern 的人不需要关心遍历顺序,这是它最核心的设计。
二、三个角色
1. Pattern
继承 OpRewritePattern<根Op>,实现 matchAndRewrite:
cpp
struct FuseBiasRelu : public OpRewritePattern<ReluOp> {
using OpRewritePattern<ReluOp>::OpRewritePattern; // 继承构造函数
LogicalResult matchAndRewrite(ReluOp relu,
PatternRewriter &rewriter) const override {
// 匹配:沿 use-def 链往上找
auto biasAdd = relu.getInput().getDefiningOp<BiasAddOp>();
if (!biasAdd)
return rewriter.notifyMatchFailure(relu, "input is not bias_add");
// 合法性:中间结果不能有别的使用者
if (!biasAdd.getResult().hasOneUse())
return rewriter.notifyMatchFailure(relu, "bias_add has other users");
// 改写:一切修改都经过 rewriter
rewriter.replaceOpWithNewOp<BiasReluOp>(
relu, relu.getType(), biasAdd.getInput(), biasAdd.getBias());
return success();
}
};
2. PatternRewriter
继承自 OpBuilder,所以能建 op;另外提供 replaceOp、eraseOp、replaceOpWithNewOp、modifyOpInPlace。所有对 IR 的修改都必须经过它,因为 driver 靠它的 listener 得知哪些 op 发生了变化。
3. Driver
最常用的是贪心 driver:
cpp
RewritePatternSet patterns(&getContext());
patterns.add<FuseBiasRelu>(&getContext());
if (failed(applyPatternsGreedily(getOperation(), std::move(patterns))))
signalPassFailure();
旧名字 applyPatternsAndFoldGreedily 在老代码和教程中仍很常见。
三、贪心 driver 做了什么
它维护一个 worklist,初始放入范围内所有 op,然后循环:
- 取出一个 op;若它无使用者且无副作用,直接删除;
- 尝试
fold; - 依次尝试以它为根的 pattern;
- IR 一旦改变,listener 把受影响的 op 重新加入 worklist:新建的 op、被替换值的使用者、被删 op 的操作数的定义者。
直到 worklist 为空,即达到不动点。迭代次数有上限,超过仍未收敛则返回 failure。
这解释了上面代码的一个细节:我没有手动删除 bias_add 。它失去唯一使用者后成为死代码,driver 会自动清除------前提是这个 op 声明了无副作用(Pure 等 trait)。没标的话它会一直留在 IR 里。
四、容易被追问的点
为什么根选 relu 而不是 bias_add? 匹配沿 use-def 链往上走:一个操作数只有一个定义者,往上是确定的;一个结果可以有很多使用者,往下要枚举。所以根通常选 DAG 最下游的 op。
返回值契约。 success() 表示 IR 已修改,failure() 表示完全没动过。改了却返回 failure,driver 不会重新处理;没改却返回 success,会空转到迭代上限。编译 LLVM 时打开 MLIR_ENABLE_EXPENSIVE_PATTERN_API_CHECKS 可以检出这类错误。
不能绕过 rewriter。 直接调 op->erase() 或 value.replaceAllUsesWith(),worklist 里会留下悬空指针或漏掉应重新访问的 op。
不收敛。 两个 pattern 互相撤销(A→B、B→A),或一个 pattern 生成的 IR 能再次匹配自己,都会跑到上限。
pattern 必须无状态。 matchAndRewrite 是 const 成员函数,不要在 pattern 对象里存跨调用的信息。
五、和相邻概念的区别
| 做什么 | 关键限制 | |
|---|---|---|
fold |
把 op 化简为已有的值或常量 | 不能新建 op,因此可在任何地方安全调用 |
| RewritePattern + 贪心 driver | 同一抽象层内的优化、规范化 | 跑到不动点,没有"合法性"概念 |
ConversionPattern + applyPartialConversion |
跨 dialect 的 lowering | 由 ConversionTarget 定义合法 op,目标是消灭非法 op,还要处理类型转换 |
面试常问"为什么融合 pass 和 lowering pass 用不同的 driver",答案就在最后一列。下一篇详细讲第三行。
此外,简单的一对一替换也可以用 TableGen 的 DRR(Pat<>)或 PDL 声明式地写,带复杂条件的仍用 C++。Triton 仓库中 lib/Dialect/Triton/Transforms/ 下的 Combine.td 与 Combine.cpp 就是两种写法混用的真实例子。
六、调试
使用带断言的 LLVM 构建,加 -debug-only=greedy-rewriter,可以看到每个 op 尝试了哪些 pattern、为什么失败。notifyMatchFailure 里的字符串就显示在这里,这是推荐用它而不是直接 return failure() 的原因。
思考题与参考答案
1. 去掉 hasOneUse 检查会怎样?
若 bias_add 的结果还有其他使用者,融合后 bias_add 不会被删除(仍有人用),而新的 bias_relu 又重新计算了一遍加偏置。语义仍然正确,但产生了重复计算和额外访存,性能反而变差。
2. pattern 返回 success 后,哪些 op 会被重新放入 worklist?
新建的 bias_relu;原 relu 结果的使用者(如 return);被删除的 relu 的操作数的定义者,即 bias_add。bias_add 此时已无使用者,若无副作用就会被 driver 删除。
3. 什么时候写 fold,什么时候写 canonicalization pattern?
结果能直接是一个已有的值或常量(如 x + 0 → x),写 fold;需要创建新 op 或做更复杂的结构调整,写 canonicalization pattern。