上一篇讲了对象生命周期和模板。这一篇讲 MLIR 代码里另外两处高频写法:
std::move(patterns)背后的移动语义 ,以及isa/cast/dyn_cast这套 LLVM 风格 RTTI。API 以 LLVM 20 为准。
一、移动语义
拷贝太贵
cpp
std::vector<int> a = /* 一百万个元素 */;
std::vector<int> b = a; // 申请新内存,把一百万个元素抄一遍
如果之后还要用 a,拷贝是必须的;如果 a 再也不用了,抄一遍就纯属浪费。
移动:不抄数据,直接过户
vector 对象本身很小,只有三样东西:指向堆上数据的指针、元素个数、容量。
cpp
std::vector<int> b = std::move(a);
移动前: a { 指针 ──→ [一百万个元素] }
移动后: a { 空 }
b { 指针 ──→ [一百万个元素] } ← 同一块内存,没有复制
拷贝是照着盖一栋一样的房子,移动是把房产证改个名字,不管房子多大,成本都只是改几个字段。
std::move 本身什么都不移动
它只是一个类型转换,给对象贴上"这个我不要了,可以拿走"的标签。真正干活的是接收方的移动构造函数:
cpp
vector(const vector &other); // 拷贝构造
vector(vector &&other); // 移动构造,&& 是右值引用
写了 std::move(a),编译器就选第二个。
面试标准答法:
std::move只是把左值转换成右值引用,不产生任何移动;移动发生在移动构造函数或移动赋值运算符里。
三条使用规则
- 移动后不要依赖原对象的内容。 被移动后的
a仍是合法对象,可以重新赋值或push_back,但不能假设它还有原来的数据。 - 返回局部变量不用写
std::move。 编译器会自动移动,甚至直接省略拷贝。所以返回SmallVector并不慢。 - 没有堆上资源的类型,移动等于拷贝。
int、指针,以及 MLIR 的Value、Type、Attribute、Op 句柄,内部只是一个指针,std::move它们毫无意义。哪怕是只有一两个元素的vector,拷贝也要申请一次堆内存,所以移动仍有明显收益。
只能移动、不能拷贝的类型
std::unique_ptr 表示独占所有权,不允许出现两份:
cpp
std::unique_ptr<int> p = std::make_unique<int>(5);
std::vector<std::unique_ptr<int>> vec;
vec.push_back(p); // 编译错误:试图拷贝
vec.push_back(std::move(p)); // 正确:所有权交给 vec,p 变空
vec.push_back(std::make_unique<int>(5)); // 临时对象,自动移动
对这类类型,std::move 不是优化,而是唯一的传递方式。
回到 MLIR
cpp
RewritePatternSet patterns(&getContext());
patterns.add<FuseBiasRelu>(&getContext());
applyPatternsGreedily(getOperation(), std::move(patterns));
RewritePatternSet 里存的是一组 unique_ptr<RewritePattern>,所以整体不可拷贝。driver 需要的是 FrozenRewritePatternSet(冻结后不能再加 pattern,内部做好了查找优化),它只接受被移动过来的 RewritePatternSet。这行代码的含义是:pattern 配置完毕,整个交给 driver,patterns 之后就是空壳。
二、LLVM 风格的类型转换
问题:向下转换
cpp
class Shape { public: virtual double area() const = 0; virtual ~Shape() = default; };
class Circle : public Shape {
public:
double area() const override { return 3.14 * r * r; }
double radius() const { return r; } // 只有 Circle 有
private:
double r = 1.0;
};
子类当基类用(向上转换)自动且安全。反方向就不行:手里只有 Shape *s,想调 radius(),得先确认它确实是 Circle。编译器里到处是这种需求:手里一个通用的 Operation *,先确认是不是 ReluOp,才能调它特有的函数。
标准 C++ 用 dynamic_cast,但它依赖编译器生成的 RTTI。LLVM 为了体积和速度关闭了 RTTI 和异常,自己实现了一套。
三个工具
| 写法 | 作用 | 类型不对时 |
|---|---|---|
isa<T>(x) |
问:x 是不是 T |
返回 false |
cast<T>(x) |
转:我确定它是 T |
断言失败崩溃(仅在开启断言的构建中检查,否则是未定义行为) |
dyn_cast<T>(x) |
试着转 | 返回空 |
选择原则:只想知道是不是 → isa;逻辑上一定是、错了就是 bug → cast;不确定、要分支处理 → dyn_cast,并且一定要检查结果。
dyn_cast 的固定搭配是把声明写进 if 条件:
cpp
// 不好:同一个检查做了两遍
if (isa<RankedTensorType>(type)) {
auto t = cast<RankedTensorType>(type);
use(t.getShape());
}
// 也不对:去掉了保护,type 不是 RankedTensorType 时 t 为空,直接崩
auto t = dyn_cast<RankedTensorType>(type);
use(t.getShape());
// 正确:判断和转换一次完成
if (auto t = dyn_cast<RankedTensorType>(type))
use(t.getShape());
实现原理(面试常问)
做法很朴素:基类存一个 kind 枚举,每个子类提供静态函数 classof 比对。
cpp
class Shape {
public:
enum Kind { K_Circle, K_Square };
Kind getKind() const { return kind; }
protected:
Shape(Kind k) : kind(k) {}
private:
Kind kind;
};
class Circle : public Shape {
public:
Circle() : Shape(K_Circle) {}
static bool classof(const Shape *s) { return s->getKind() == K_Circle; }
};
isa<Circle>(s) 内部调 Circle::classof(s),一次整数比较;dyn_cast 就是先 isa,是则转,否则返回空。
一句话:LLVM 风格 RTTI = kind 标记 +
classof,不依赖编译器 RTTI。
在 MLIR 中
cpp
if (auto relu = dyn_cast<ReluOp>(op)) { // 注意没有 *:ReluOp 是句柄类
Value in = relu.getInput();
}
对句柄类,dyn_cast 返回句柄本身,失败时是空句柄,放进 if 同样会被当作假。老教程里 type.dyn_cast<T>() 这种成员函数写法已经不推荐,统一用自由函数形式。
拆开 pattern 里最常见的一行
cpp
auto biasAdd = relu.getInput().getDefiningOp<BiasAddOp>();
// 等价于
Operation *def = relu.getInput().getDefiningOp(); // 可能为空
auto biasAdd = dyn_cast_or_null<BiasAddOp>(def); // 允许输入本身为空
它在两种情况下返回空:
- 输入由某个 op 产生,但那个 op 不是
BiasAddOp; - 输入根本不是 op 产生的,而是块参数 (比如函数参数)。这时
getDefiningOp()返回nullptr,这也是内部必须用_or_null版本的原因,普通dyn_cast传空进去会直接崩。
mlir
func.func @f(%x: tensor<4xf32>) -> tensor<4xf32> {
%y = "mic.relu"(%x) : (tensor<4xf32>) -> tensor<4xf32> // %x 是函数参数
return %y : tensor<4xf32>
}
对融合 pattern 来说,两种情况的处理方式相同:匹配失败。
小结
std::move只是贴标签,移动由移动构造完成;句柄类移动没有意义;unique_ptr只能移动。isa问、cast断言着转、dyn_cast试着转;dyn_cast永远配合if (auto x = ...)。getDefiningOp<T>()返回空有两种含义:定义者类型不符,或值是块参数。
下一篇进入 MLIR 本身:核心类的地图。