MLIR - Sparse Vectorization
MLIR Sparse Compiler 里对循环做向量化的方式是靠 Transform OP 的代码重写。 向量化的初步实现实现位于 lib/Dialect/SparseTensor/Transforms/SparseVectorization.cpp 里。
Transform 选项
Sparse Vectorization transform 的操作支持三个自定义选项,
通过 sparse_tensor::VL 结构体传递。
struct VL {
unsigned vectorLength;
bool enableVLAVectorization;
bool enableSIMDIndex32;
};
vectorLength:用来指定向量化时,一个向量的元素数量。 比如vector<16xf32>有 16 个元素。enableVLAVectorization:用来指定是否启用可变长向量, 启用则提供对 ARMSVE 这类的架构的支持。enableSIMDIndex32:用来指定是否对gather/scatter操作使用 32 bit 的索引。
VL 结构体会被用在很多地方,比如用来构建 VectorType:
/// Constructs vector type for element type.
static VectorType vectorType(VL vl, Type etp) {
return VectorType::get(vl.vectorLength, etp, vl.enableVLAVectorization);
}
VectorType 是 MLIR 里用来表达多维的 SIMD 向量的高抽象层级的类型。
在 VectorType::get 里,vectorLength 用于指定 vector 的 shape,
因为传递进的是一个 unsigned int,所以这里会构造一个 rank-1,size-1,值为 vectorLength 的 llvm::ArrayRef,
用来指明 VectorType 的 shape。
etp 是 Element Type 的缩写,顾名思义用来指定 vector 元素的类型。
而 enableVLAVectorization 值会传递进第三个参数 numScalableDims 里。
numScalableDims 是个 unsigned int 的参数,默认值是 0。
传递 enableVLAVectorization 这个 boolean 值进入 numScalableDims 会出现一个 implicit cast,
把这个 field 的值设置为 1,指明可拓展的维度有 1 个。
Sparse vectorization rewrite
在 mlir::populateSparseVectorizationPatterns 会为 RewritePatternSet 加入两个 Rewriter,
分别是 ForOpRewriter 和 ReducChainRewriter,由这两个 Rewriter 来负责初步的向量化实现。
ForOpRewriter
首先先看 ForOpRewriter, 其支持上述的三个自定义选项,
主要负责用于向量化对 tensor 的 for 操作。
在 ForOpRewriter 里,matchAndRewrite 成员函数负责代码重写和生成。
其只负责对仅有一个 block 的,步长为 1 的,且由 Sparse Compiler 生成的 for 循环实现向量化。
// ForOpRewriter::matchAndRewrite(...)
if (!op.getRegion().hasOneBlock() || !isConstantIntValue(op.getStep(), 1) ||
!op->hasAttr(LoopEmitter::getLoopEmitterLoopAttrName()))
return failure();
isConstantIntValue只有在第一个参数是常量,且值等于第二个参数的时候返回true。 在此处表示,只有 for 循环的 step 是常量 1 时返回true。
LoopEmitter是一个用来管理 sparse tensors 并帮助生成循环操作的一个类。 此处用来帮助判断该循环是由 Sparse Compiler 生成的。
区域条件判定结束之后,matchAndRewrite 会调用两次 vectorizeStmt 函数,
一次传递 false 给 vectorizeStmt 函数的 codegen 参数,用来对原代码做 Analyze;
只有第一次 IR analyze 成功了,才会第二次调用并传递 true 以开始对 IR 做向量化。
if (vectorizeStmt(rewriter, op, vl, /*codegen=*/false) &&
vectorizeStmt(rewriter, op, vl, /*codegen=*/true))
return success();
vectorizeStmt
vectorizeStmt 是对传入的 for 操作实际进行 IR 重写(向量化) 的函数。
为了避免有任何歧义,下面用 ForOp 来指代被向量化的 for 循环操作。
C++ 里的 `ForOp` class 和 MLIR 是什么关系?
一个典型的
ForOp通常用来描述 MLIR 里一整个scf.for的操作 block。%c0 = arith.constant 0 : index %c1 = arith.constant 1 : index %c8 = arith.constant 8 : index scf.for %i = %c0 to %c8 step %c1 { // ... } %0 = arith.constant 0.0 : f64 %1 = scf.for %i = %c0 to %c8 step %c1 iter_args(%vin = %0) -> f64 { // ... %vout = // operation to %vin scf.yield %vout : f64 }其带有三个操作数:循环上限,循环下限和步进值,和一个循环内操作区域。
scf.for的操作区域有且只能有一个,而且需要scf.yield来声明操作区结束了。scf.yield可以不带操作数,如果需要在循环内迭代值,则可以加上操作数作为单次计算返回值。 如果某个操作区内没有写scf.yield,IR Rewriter 会自动往里插入一个不带操作数的scf.yield。
因为在调用函数前已经检查过了 ForOp 里的 block 数量,
所以此处只拿出 ForOp 的第一块 block 进行操作。
Block &block = forOp.getRegion().front();
如果这块 block 里只有不超过一个的操作,那么只有可能是最后的 yield 操作。
这类 ForOp 不会被向量化。
if (block.getOperations().size() <= 1)
return false;
之后会生成一些之后要用到的变量:
// 当前 For 操作的位置
Location loc = forOp.getLoc();
// 将 for 操作 block 内最后一个操作 cast 成 scf.yield
scf::YieldOp yield = cast<scf::YieldOp>(block.getTerminator());
// 拿到 yield 操作的前一个操作(倒数第二个操作)
auto &last = *++block.rbegin();
接下来是生成 vmask。这部分代码只有第二次调用,也就是设置 codegen 为 true 时调用。
首先会用上述的 vectorLength 选项,先创建一个 step 常量的 IR。
接下来,如果启用了对 ARMSVE 平台的编译支持,就会启用上述 enableVLAVectorization 变量,
然后在当前操作位置创建 VectorScaleOp 操作,并用 arith.muli 操作将 step 和 vscale 的值相加。
总结一下,不开 vla:
step = vlen,开 vla:step = vlen + vscale
Value vmask;
if (codegen) {
Value step = constantIndex(rewriter, loc, vl.vectorLength);
if (vl.enableVLAVectorization) {
Value vscale =
rewriter.create<vector::VectorScaleOp>(loc, rewriter.getIndexType());
step = rewriter.create<arith::MulIOp>(loc, vscale, step);
// ...
上面的 cpp 代码在传递 vl=4 和 enable-vla-vectorization=true 给 sparse-vectorization pipeline 后
的重写 MLIR 里长这样:
%c4 = arith.constant 4 : index
%vscale = vector.vscale
%step = arith.muli %vscale, %c4 : index
接下来会根据 ForOp 的两个可能的操作目的进行两种不同的 for 操作重写。
在 vectorizeStmt 里分成了两类,一类是 reduction 另一类是 parallel。
判断是 reduction 类型还是 parallel 类型的方式很简单,只是判断 scf.yield 是否有操作数 (operand)。
如果有操作数,那么就是 reduction 类型。
Reduction loop? Parallel loop?
Reduction 类型指某种依赖于遍历时创建的状态,而最终构造出的一个新的返回值的循环类型。
而在 reduction loop 里,如果某一层循环创造的状态被下一层循环依赖,则这个循环的状态被称作 Loop-carried dependencies。
一个典型的带有循环状态依赖的遍历:
for i = 0..N for j = 0..M for k = 0..M A(i+1, j, k) = A(i, j+1, k+1) + A(i, j-1, k-1) - A(i,i-1,k+1) - A(i, i+1, k-1)具体细看最后的操作数,你会发现想要计算出 A(i+1, ..), 需要 A(i, ..) 的值。 即在第 i 次循环得到的结果被第 i+1 次循环依赖。像这类循环操作很难被并行化。
但并不是说所有 Reduction loop 都没法被并行,也有完全不依赖状态的 reduction loop:
sum = 0 for i = 0..N sum += f(i)像上述这类操作就可以被优化成并行操作。
除此之外,如果某个循环完全不创造任何的新值,那么也可以被并行。比如:
for i = 0..N for j = 0..M a(i) = b(i) + c(j)
结构上长这样:
// if codegen block 上面准备的变量
scf::YieldOp yield = cast<scf::YieldOp>(block.getTerminator());
// if (codegen) ...
if (!yield.getResults().empty()) {
// IR rewrite for reduction
} else {
// IR rewrite for parallel
}
- 对于 reduction 类型的重写,
vectorizeStmt会为其生成一个新的循环操作 (forOpNew)
scf::ForOp forOpNew;
然后先把旧 ForOp 的一些状态和设定都复制到新 ForOp 上,然后修改其迭代的步进值为上述设置的 step。
// if (codegen)
// ...
Value init = forOp.getInitArgs()[0];
VectorType vtp = vectorType(vl, init.getType());
Value vinit = genVectorReducInit(rewriter, loc, yield->getOperand(0),
forOp.getRegionIterArg(0), init, vtp);
forOpNew = rewriter.create<scf::ForOp>(
loc, forOp.getLowerBound(), forOp.getUpperBound(), step, vinit);
forOpNew->setAttr(
LoopEmitter::getLoopEmitterLoopAttrName(),
forOp->getAttr(LoopEmitter::getLoopEmitterLoopAttrName()));
rewriter.setInsertionPointToStart(forOpNew.getBody());
其中前三个函数调用和 forOpNew->setAttr(...) 将原来的 ForOp 的内容设置到新的 ForOp 上,
而第四个操作 rewriter.create 则在创建新函数是,把步进的值改为新的 step 值。
最后将后续操作的插入点设置到了新 ForOp 的 body 位置。
- 对于 parallel 类型的重写,
vectorizeStmt只会调整其步进的值 (stride) 和 insert 的位置。
rewriter.updateRootInPlace(forOp, [&]() { forOp.setStep(step); });
rewriter.setInsertionPoint(yield);
第一个函数调用修改了 ForOp 的步进值。
第二个函数调用将后续 IR 的插入点设置在原来 for 循环的 yield 操作之前。
genVectorMask
初步调整 ForOp 之后,就需要使用 ForOp 的参数生成 vmask 了。
vmask 的值由 genVectorMask 函数生成,
这个函数用到了 ForOp 的 induction variable,
循环的上下限和步进值 step。
Induction variable
scf.for %i = %lo to %hi在 MLIR 的
scf.for操作里,induction variable 通常指当前 index%i。
在 genVectorMask 函数里,
首先会创建一个 1 bit 的 conditional mask vector 类型。
VectorType mtp = vectorType(vl, rewriter.getI1Type());
// Equals to `vector<4xi1>` when vlen = 4 in MLIR
然后判断传入的 ForOp 的上下限和步进值的关系。
- 关系满足
如果上下限的差值可以被步进值整除(((hi - lo) % step) == 0),
则上面创建的 mask vector 会被全设置为 true。
其中 matchPattern 还会再一次检查上下限和步进值 step 是不是都是常量。
IntegerAttr loInt, hiInt, stepInt;
if (matchPattern(lo, m_Constant(&loInt)) &&
matchPattern(hi, m_Constant(&hiInt)) &&
matchPattern(step, m_Constant(&stepInt))) {
if (((hiInt.getInt() - loInt.getInt()) % stepInt.getInt()) == 0) {
Value trueVal = constantI1(rewriter, loc, true);
return rewriter.create<vector::BroadcastOp>(loc, mtp, trueVal);
}
}
结合上一层的 vmask = genVectorMask(..) 调用,这一段 ForOp 改写后生成的 MLIR 类似于:
// vlen = 8
// Value trueVal = constantI1(...);
%true = arith.constant 1 : i1
// VectorType mtp = vectorType(vl, I1Type) => vector<8xi1>
// vmask = rewriter.create<vector::BroadcastOp>(loc, mtp, trueVal);
%vmask = vector.broadcast %true : i1 to vector<8xi1>
// %vmask is has value ( 1, 1, 1, 1, 1, 1, 1, 1 )
vector.print %vmask : vector<8xi1>
- 关系不满足
如果迭代的长度不能被步进的量整除,比如遍历下限 0 上限 128,但是每次步进量为 3, 那么就要生成另一种 mask 对余数做处理,防止下一次步进会越过 for 循环的上限。
在 genVectorMask 里,首先生成了一个新的 AffineMap:
auto min = AffineMap::get(
/*dimCount=*/2, /*symbolCount=*/1,
{rewriter.getAffineSymbolExpr(0),
rewriter.getAffineDimExpr(0) - rewriter.getAffineDimExpr(1)},
rewriter.getContext());
这一段生成出来的 MLIR 代码等价于:
#min = affine_map<(d0, d1)[s0] -> (s0, d0 - d1)>
接下来会把 ForOp 的上限,induction variable 和步进值做成 ValueRange,
把它和上面的 AffineMap 作为参数传入 affine.min 操作中。
Value end = rewriter.createOrFold<affine::AffineMinOp>(
loc, min, ValueRange{hi, iv, step});
这里只是看 C++ 代码可能稍微有点难懂,
在这里,rewriter 会创建(或者直接 inline 成值)affine.min 操作,
生成类似于以下的代码:
scf.for %iv = %lo to %hi step %step {
%end = affine.min #map (%hi, %iv)[%step]
根据上述 #map 的定义,
这一个 affine.min 操作会变成,
取 ForOp 的上限 %hi 减去 induction variable %iv 的差值,和步进值 %step 相比,
返回这两个值中间的最小值。
# Pseudo Python code
min( step, (hi - iv) )
最后传入 VectorType, end 到 vector.create_mask 里生成 vmask 值。
// VectorType mtp = vectorType(vl, I1Type) => vector<vl x i1>
return rewriter.create<vector::CreateMaskOp>(loc, mtp, end);
这个操作最后会返回一个 \([0, end]\) 都设置为 1 的 vector mask。
// 假设 vl = 8,affine.min 返回 6
%vmask = vector.create_mask %end : vector<8xi1>
vector.print %vmask : vector<8xi1>
// [ 1, 1, 1, 1, 1, 1, 1, 0 ]
vectorizeStmt
回到 vectorizeStmt, vmask 和 ForOp 的处理结束之后,就到了实际向量化代码重写的部分了。
依旧是分成 reduction 和 store 两类进行不同的代码生成。
reduction
首先取得 scf.yield 的操作数 red 和 ForOp 的 iter_arg 的值 iter,
并设定好之后 reduction 时要用到的 combining kind:
Value red = yield->getOperand(0);
Value iter = forOp.getRegionIterArg(0);
vector::CombiningKind kind;
接下来会进行对向量化可行性的探测,然后生成向量值并进行进一步 IR rewrite。
可行性的探测由函数 isVectorizableReduction 完成,
用来判断这个 reduction 类型的循环能不能做向量化,
不能则直接返回 false。由于这里还没涉及 codegen 的判断,
所以在第一次调用 vectorizeStmt 的时候就能分析出来并提前结束后续操作。
而如何把循环变成向量表达则由 vectorizeExpr 函数完成,其返回 boolean 值用来表达生成是否成功,
而实际的向量值则会写进通过参数传递的 mlir::Value 引用里。
Value vrhs;
if (isVectorizableReduction(red, iter, kind) &&
vectorizeExpr(rewriter, forOp, vl, red, codegen, vmask, vrhs)) {
// ...
}
isVectorizableReduction 会尝试获取定义 red 的操作,
如果有 arith.addf/addi/ subf/subi mulf/muli andi/ori/xori 之外的操作其会直接返回 false 值。
接下来则是定义 vector 的 reduction 类型 vector::CombiningKind,
存进传入的 kind 指针里。
addf/addi/subf/subi 都归类于 ADD 类型,
mulf/muli 归类于 MUL 类型,
剩下的则各自分别归类于 AND/OR/XOR 类型。
除了需要判断 DefineOp 的操作是否能被转换成 vector reduction,
还需要判断 DefineOp 的操作数是不是 ForOp 的 iter_arg。
什么是定义操作 (DefiningOp)?
如果有
%1 = arith.addi %0, 那么arith.addi就是%1的定义操作,%1就是arith.addi的操作结果。
isVectorizableReduction 的大致结构长这样:
static bool isVectorizableReduction(Value red, Value iter, vector::CombiningKind &kind) {
if (auto addf = red.getDefiningOp<arith::AddFOp>()) {
kind = vector::CombiningKind::ADD;
return addf->getOperand(0) == iter || addf->getOperand(1) == iter;
}
// ...
return false;
}
vectorizeExpr 首先会判断输入的 scf.yield 的操作数 red 的类型能不能作为 vector 的元素类型。
接下来会判断 invariant 的类型。
loop invariant
loop invariant 是一种逻辑断言,是循环的一种属性。 其为循环内的某一个状态的断言,在每一次循环之后值永远是 true。 可以用于证明循环的属性和采用循环的扩展算法。 循环不变式在进入循环和每次迭代后都为真,因此在退出循环时,循环不变式和循环终止条件都能得到保证。
如果两次函数调用都返回成功的值才会进行下一步的 IR rewrite。
在 vectorizeStmt 函数里会操作上文提到的新创建 ForOp,
通过 iter 的值创建新的 mask 和一系列 vector masked load 操作,
更新 scf.yield 的返回值。
然后把旧 ForOp 区域的 result, induction variable 和 iter_arg 全替换成新 ForOp 的。
最后把旧 ForOp 给消除掉。
rewriter.replaceAllUsesWith(forOp.getResult(0), vres);
rewriter.replaceAllUsesWith(forOp.getInductionVar(),
forOpNew.getInductionVar());
rewriter.replaceAllUsesWith(forOp.getRegionIterArg(0),
forOpNew.getRegionIterArg(0));
rewriter.eraseOp(forOp);
Parallel
首先判断 yield 之前的操作,也就是 ForOp block 的倒数第二条操作是否为 memref.store。
然后取得 store 操作中的 indice 和右操作数,用这两个值和前面的 vmask, vl 等值实现向量化。
if (auto store = dyn_cast<memref::StoreOp>(last)) {
// Analyze/vectorize store operation.
auto subs = store.getIndices();
SmallVector<Value> idxs;
Value rhs = store.getValue();
Value vrhs;
if (vectorizeSubscripts(rewriter, forOp, vl, subs, codegen, vmask, idxs) &&
vectorizeExpr(rewriter, forOp, vl, rhs, codegen, vmask, vrhs)) {
if (codegen) {
genVectorStore(rewriter, loc, store.getMemRef(), idxs, vmask, vrhs);
rewriter.eraseOp(store);
其中 subs 和 rhs 对应下面 MLIR 代码中的 %1 和 %2。
memref.store %3, %2[%1] : memref<1024xf32>