-
Notifications
You must be signed in to change notification settings - Fork 1
新IR常量折叠
- paddle.nn 中的函数会将参数初始化的 op 追加到 start program 中
- 执行器执行 start program
- 在 NewIRInterpreter 阶段 build scope,把 op 用到的输入输出 var 都构造出来并放到 global scope 里
- NewIRInterpreter 按序执行 op kernel。之后,初始化后的参数就在 global scope 里了
- 执行器执行 main program,可以从 global scope 里拿到参数
- ...
网络和参数都就绪之后,调用 Executor 的 run() 方法执行,这里以新执行器为例。新执行器会:
- 创建 StandaloneExecutor实例
- feed data
- 调用 StandaloneExecutor::run
StandaloneExecutor 的构造函数依次做了几件重要的事情:
- TranslateLegacyProgramToProgram,旧 IR 被翻译成新 IR
- 常规 pass,如常量折叠、算子融合、死代码消除等
- PdOpLowerToKernelPass,新 IR 绑定到 kernel
- inplace pass
- 创建 InterpreterCore,将 impl 构造为 NewIRInterpreter (直到这一步才用到 scope_,之前都没用到)
其中真正负责执行的是 NewIRInterpreter。
- GetParameterForSingleBlock a. 遍历 BlockDesc 的所有 OpDesc,再遍历每个 OpDesc 的所有 input_var_name ⅰ. 判断是否 is_parameter,判断是否 is_unseen_variable ⅱ. 如果同时 is_parameter 和 is_unseen_variable,就说明 need_get_parameter_op ⅲ. 如果 need_get_parameter_op,InsertGetParamaterOp。同时维护一个全局从 var_name 到 OpResult 的映射。调用 program_->SetParameter(var_name, nullptr)** 这里第二个参数为空,这也是常量折叠pass有问题的根本原因,因为ir翻译期间并没有引入scope,导致参数数据无法在此时被加载到program里**
- TranslateBlock。根据不同的 op type 选择不同的 OpTranslateFn进行转换
- SetParameterFromSingleBlock。如果 output 在 parameter_name_mappings_中已经存在了,说明这个 param 需要更新,说明 need_set_parameter_op
主要的处理逻辑是在 ProcessBlock() 方法中,遍历了 block 的每个 op,根据 OpYamlInfoParser 绑定 kernel 信息。
NewIRInterpreter 在构造函数中主要做了 BuildScope。scope 除了存储参数,还要存储中间的 tensor 数据。参数已经在之前被放入到 scope 里了,但 tensor 还没有,所以需要为这些 tensor 分配好内存,并用 scope 管理。
在执行 NewIRInterpreter::Run 时:
- SolvePersisableVarNames。遍历所有 vars,把 Persisable 的 parameter 记录下来,parameter 不会被 gc 回收
- BuildInstruction。遍历所有 op 然后 BuildPhiContext。从 scope 里拿到 var, 再根据 var 拿到 op 的输入和输出 tensor,放到 Context 里。这样 Kernel 在调用时就可以从 Context 里拿到 tensor 了。
- PreAnalysis
- TraceRunImpl 或 MultiThreadRunImpl,根据拓扑序和依赖关系调用 Instruction。
- 根据 fetch list 获取返回值。
一个很自然的想法是,能不能通过 codegen 的方式为每个 op 生成 fold 方法?为此,我们梳理一下 op codegen 的大概流程 在 gen.py 的 OpGenerator 里:
- 根据 op_yaml_files 和 op_compat_yaml_file 解析生成 op_compat_item
- 构造 OpInfoParser(op, op_compat_item)
- 根据上面解析出的结果,格式化 OP_INFO_TEMPLATE,用于生成算子的 GetOpInfo() 方法。这东西很重要。OpInfoTuple 把所有元信息和 op 打包在一起
- 根据模板生成头文件
- 根据模板 CC_FILE_TEMPLATE 生成源文件
理论上拿到 OpInfoTuple 就可以找到 kernel 并构建 InferMetaContext 和 KernelContext 了。只要把 KernelContext 传给 kernel 就能执行了。(需要先 BuildScope,为 kernel 的 output 在 scope 里申请出空间) 所以我们可以看出,并不需要额外的 codegen 了。想运行指定的 op,只要拿到它的 OpInfoTuple,然后传入 scope 就可以。可以提供类似如下的接口:
DenseTensor runSingleOp(Pperation* op, Scope* scope) {
1. BuildScope
2. 获取 OpInfoTuple
3. 构建 InferMetaContext
4. 构建 KernelContext
5. run infer_meta
6. run phi_kernel
7. 从 scope 里找出 result 然后返回
}
- 根据 op 创建 temp_program
- PdOpLowerToKernelPass,根据 temp_program 构造 kernel_program
- 创建 InterpreterCore 并执行
- 根据 fetch_list 取出 out_tensor
- 根据 out_tensor 构造 pir::Parameter
- 在 scope_ 中创建新 param_var,并把 out_tensor 赋值给 param_var
- 在原 program 中 SetParameter
- 插入 get_parameter_op 并替换子图
可以看到,新IR常量折叠里主要存在两个问题:
问题一:需要从原 program 中获取 Parameter,但我们在 TranslateLegacyProgramToProgram 时,只记录了 param_name,并没有创建 Parameter 对象(见上面流程梳理3.1)。目前我们仍然是把 param 以 var 的形式存储在 scope 里了。而在 pass 阶段,并没有传入 scope,也就没法获得原 program 的参数。
问题二:需要单独构造出 temp program,并构造 InterpreterCore。其实可以直接调用 op 的 kernel。
对于问题一,在 pass 阶段获取参数有两种做法:
- 把 scope 传入 TranslateLegacyProgramToProgram 阶段,这样可以从 scope 里拿到参数,创建 Parameter 对象,再通过 SetParameter 设置到 program 里。这样做的话,新 IR 体系下,就不需要把参数存储在 scope 里了。
- 把 scope 传入 PassManager,让 pass 在执行时也可以拿到参数。这样做就不需要在 program 的 Parameter 里维护 data 指针了。
个人倾向于第二种解法。从逻辑角度讲,既然我们认为 param 和 tensor 都是 variable,那么用同一种方式管理他们的数据才是更符合直觉的。都用 scope 来管理就足够了,没必要在 program 里的 Parameter 维护 data 指针。
对于问题二,由于我们在进入常规 pass 阶段时还没有完成 PdOpLowerToKernelPass 了,所以需要先将 op 绑定到 kernel 进行了绑定,再 SelectKernel 然后 BuildPhiContext,利用 kernel 和 scope 中的 param 完成计算,并将计算结果作为新的 param 存储在 scope 里。之后和原来一样插入 get_parameter_op 并替换子图就 OK 了。(其实这个逻辑已经有类似的实现了,即 PhiKernelAdaptor。我们可以构造临时 program,然后复用 PhiKernelAdaptor 算出结果)
MLIR 基础设施提供两种规范化的方法:
mlir 的 pattern rewiter 提供了常量折叠的 pass,但这块我还不是很理解怎么根据 GenericOp 执行计算(用 llvm 吗?),后面再仔细看看
template <typename ConcreteType>
class FoldConstantBase : public OpRewritePattern<GenericOp> {
LogicalResult matchAndRewrite(GenericOp genericOp,
PatternRewriter &rewriter) const override {
// 拿到 kernel ?
RegionComputationFn computeFn =
static_cast<const ConcreteType *>(this)->getRegionComputeFn(genericOp);
// 计算
// return ConstantOp
}
};
MLIR 的Op可能会覆盖fold来实现,与一般的 DAG 到 DAG Pattern匹配器相比,它暴露了一个更简单的 API,并适用于通用的匹配器不适用的情况。 例如,DAG 重写可以删除当前函数中的任意节点,这可能会使迭代器无效。 作为 API 的常量折叠则不会删除任何节点,它只是提供一个常量值(列表)并允许客户端根据需要更新其数据结构。在规范化pass之外 ,fold在Dialect Conversion基础架构中用作合法化机制,并且可以通过OpBuilder::createOrFold在任何地方使用OpBuilder直接调用。fold 的限制是不能创建新的Op,只能替换根Op(但不能删除)。 它允许原地更新Op,或返回一组预先存在的值(或属性)以替换Op。
tvm 和 paddle 的做法比较类似,也是构造了一个 dlcontext 并设置设备为 cpu,创建 llvm target 然后构造了一个 CreateInterpreter,执行了 CreateInterpreter::Eval
Expr FoldConstant(const Expr& expr, const IRModule& mod) {
using tvm::transform::PassContext;
DLContext ctx;
ctx.device_type = kDLCPU;
ctx.device_id = 0;
Target target = Target::Create("llvm");
// use a fresh build context
// in case we are already in a build context.
With<PassContext> fresh_build_ctx(PassContext::Create());
return ConstantFolder(CreateInterpreter(mod, ctx, target), mod).Mutate(expr);
}
看起来 oneflow 是通过 mlir 的 fold 的一套方法实现的常量折叠 oneflow 主要是实现了 UnaryFold 和 BinaryFold 两种基本的折叠方式。上层 op 根据自己的参数数目直接复用 UnaryFold 和 BinaryFold,并且 op 在 fold 里硬编码自己的 kernel。
OpFoldResult UnaryFold(MLIRContext* ctx, ArrayRef<Attribute> operands,
const std::function<MaybeTensor(const TensorPtr&)>& f) {
::oneflow::LazyMode::Guard guard{false};
if (!operands.front()) { return {}; } // Important!
const auto attr_dict = operands.front().cast<mlir::DictionaryAttr>();
auto attrs = NamedAttrList(attr_dict);
const auto tensor = support::DenseElementsAttrToTensor(
attr_dict.get("value"), attr_dict.get(OpTrait::IsOpConfCompatible<void>::getDeviceTagAttr()),
attr_dict.get(OpTrait::IsOpConfCompatible<void>::getDeviceNameAttr()));
const auto result = f(tensor).GetPtrOrThrow();
attrs.set("value", support::TensorToDenseElementsAttr(result, ctx));
attrs.set(OpTrait::IsOpConfCompatible<void>::getOpNameAttr(), GenNewVariableOpName(ctx));
attrs.set(OpTrait::TensorSource<void>::getDataTypeAttrName(),
attr_dict.get(OpTrait::TensorSource<void>::getDataTypeAttrName()));
return attrs.getDictionary(ctx);
}
OpFoldResult BinaryFold(MLIRContext* ctx, ArrayRef<Attribute> operands,
const std::function<MaybeTensor(const TensorPtr&, const TensorPtr&)>& f) {
::oneflow::LazyMode::Guard guard{false};
if (!(operands.front() && operands.back())) { return {}; } // Important!
auto lhs_attr_dict = operands.front().cast<mlir::DictionaryAttr>();
auto rhs_attr_dict = operands.back().cast<mlir::DictionaryAttr>();
if (!DictionaryAttrsHaveSameDataType({lhs_attr_dict, rhs_attr_dict})) {
llvm::errs()
<< "Input tensors should have same data type in binary operation of constant folding."
<< "\n";
return nullptr;
}
}
//////////////////////////////////////////
OpFoldResult TransposeOp::fold(FoldAdaptor adaptor) {
auto operands = adaptor.getOperands();
return UnaryFold(getContext(), operands, [this](const auto& tensor) {
std::vector<int32_t> perm_;
for (auto& x : getPerm().getValue()) { perm_.emplace_back(x.cast<IntegerAttr>().getSInt()); }
return functional::Transpose(tensor, perm_);
});
}
OpFoldResult ReshapeOp::fold(FoldAdaptor adaptor) {
auto operands = adaptor.getOperands();
return UnaryFold(getContext(), operands, [this](const auto& tensor) {
std::vector<int64_t> shape_vec;
for (auto& x : getShape().getValue()) {
shape_vec.emplace_back(x.cast<mlir::IntegerAttr>().getValue().getSExtValue());
}
return functional::Reshape(
tensor, ::oneflow::Shape(::oneflow::DimVector(shape_vec.begin(), shape_vec.end())));
});
}
OpFoldResult ScalarAddOp::fold(FoldAdaptor adaptor) {
auto operands = adaptor.getOperands();
return UnaryFold(getContext(), operands, [this](const auto& tensor) -> MaybeTensor {
if (getHasIntOperand()) { return functional::ScalarAdd(tensor, getIntOperand(), 1, false); }
if (getHasFloatOperand()) {
return functional::ScalarAdd(tensor, getFloatOperand().convertToDouble(), 1, false);
}
emitError("Scalar op must has a int operand or a float operand.");
return TensorPtr();
});
}
OpFoldResult SqrtOp::fold(FoldAdaptor adaptor) {
auto operands = adaptor.getOperands();
return UnaryFold(getContext(), operands, functional::Sqrt);
}
OpFoldResult BroadcastMulOp::fold(FoldAdaptor adaptor) {
auto operands = adaptor.getOperands();
return BinaryFold(getContext(), operands, functional::Mul);
}
OpFoldResult BroadcastDivOp::fold(FoldAdaptor adaptor) {
auto operands = adaptor.getOperands();
return BinaryFold(getContext(), operands, functional::Div);
}
OpFoldResult BroadcastSubOp::fold(FoldAdaptor adaptor) {
auto operands = adaptor.getOperands();
return BinaryFold(getContext(), operands, [](const auto& lhs, const auto& rhs) -> MaybeTensor {
return functional::Sub(lhs, rhs, /*alpha=*/1.0, false);
});
}
但比较奇怪的是,只有少量几个 op 实现了 fold 方法,其它 op 难道无法折叠吗?
进行折叠的控制逻辑还是在 mlir 里。调用链如下:
- mlir::applyOpPatternsAndFold
- MultiOpPatternRewriteDriver::simplify
- GreedyPatternRewriteDriver::processWorklist
- OperationFolder::tryToFold
- op 覆盖的 fold 方法