onnxruntime/onnxruntime/core/graph/function_utils.cc
Dmitri Smirnov f19c673595
If Branch Constant Folding (#18105)
### Description

When and if `If` condition proves to be a constant value, inline the
corresponding subgraph yielding to more constant folding and
optimization.

### Motivation and Context
Newly converted models feature lots of nested `If` nodes that can be
inlined and collapsed.

In particular, for the sample models we are gaining on TorchScript
exported models.
For `HF Mobile Bert Dynamo` runtime went down from 0.069 -> 0.046. In
total, AOT inlining + `If` constant folding
yields improvement of about 50% 0.102 -> 0.046. Brining us very close to
TorchScript exported models.

`HF Bart Dynamo` further improves 0.668 -> 0.45. AOT + `If` constant
folding improves 0.98 -> 0.45

Earlier the size of 
HF Mobile Bert **161Mb+**, now **98Mb**
HF Bart Dynamo pre-optimized model was about **1.2Gb**. It is now
**710MB**


![image](https://github.com/microsoft/onnxruntime/assets/11303988/1491a247-d371-4e66-85a3-2aeb702e8ca0)
2023-11-13 17:33:30 -08:00

516 lines
22 KiB
C++

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
#include <unordered_map>
#include "core/graph/function_utils.h"
#include "core/common/inlined_containers.h"
#include "core/framework/tensorprotoutils.h"
#include "onnx/shape_inference/implementation.h"
#include "core/graph/function_impl.h"
#include "core/graph/model_load_utils.h"
namespace onnxruntime {
namespace function_utils {
using string = std::string;
using namespace ONNX_NAMESPACE;
// Utilify function to get the imported version of domain from opset imports
// Returns -1 if requested domain is not found in the opset_imports
template <template <typename, typename> class M>
static int GetVersionForDomain(const std::string& domain, const M<std::string, int>& opset_imports) {
auto it = opset_imports.find(domain);
if (it == opset_imports.end()) {
return -1;
}
return it->second;
}
std::unique_ptr<ONNX_NAMESPACE::OpSchema> CreateSchema(
const Graph& graph,
const IndexedSubGraph& nodes_to_fuse, bool allow_aggregated_tensor_type) {
const auto* meta_def = nodes_to_fuse.GetMetaDef();
using ONNX_NAMESPACE::OpSchema;
auto op_schema = std::make_unique<OpSchema>(meta_def->name, __FILE__, __LINE__);
op_schema->SetDomain(meta_def->domain);
op_schema->SetDoc(meta_def->doc_string);
op_schema->SinceVersion(meta_def->since_version);
if (meta_def->type_and_shape_inference_function) {
op_schema->TypeAndShapeInferenceFunction(meta_def->type_and_shape_inference_function);
}
if (allow_aggregated_tensor_type) {
// The generated schema will use the same type constraint for all inputs and outputs,
// and that type constraint will match all tensor types.
// Due to this, a user of this style of schema must manually check whether any applicable type constraints
// for each input or output are satisfied prior to creating a node that uses this schema
//
op_schema->TypeConstraint("TAggregatedTypes", ONNX_NAMESPACE::OpSchema::all_tensor_types_ir4(),
"all_tensor_types_ir4");
}
int i = 0;
for (const auto& input : meta_def->inputs) {
const auto* input_arg = graph.GetNodeArg(input);
// inputs must have a type. can be inferred for outputs.
ORT_ENFORCE(input_arg->Type() != nullptr);
op_schema->Input(i, input, "",
allow_aggregated_tensor_type ? "TAggregatedTypes" : *input_arg->Type(),
OpSchema::FormalParameterOption::Single, /*is_homogeneous=*/!allow_aggregated_tensor_type);
i++;
}
i = 0;
for (const auto& output : meta_def->outputs) {
const auto* output_arg = graph.GetNodeArg(output);
op_schema->Output(i, output, "",
allow_aggregated_tensor_type ? "TAggregatedTypes" : *output_arg->Type(),
OpSchema::FormalParameterOption::Single, /*is_homogeneous=*/!allow_aggregated_tensor_type);
i++;
}
op_schema->Finalize();
return op_schema;
}
// Auto inferred and generate an opschema for stand-alone functions
// TODO: revisit to see if we can eliminate typeconstraint step
static void IOTypeConstraintHelper(const ONNX_NAMESPACE::FunctionProto& onnx_func_proto,
std::unique_ptr<ONNX_NAMESPACE::OpSchema>& op_schema,
const InlinedHashMap<std::string, int>& input_name_idx_map,
const InlinedHashMap<std::string, int>& output_name_idx_map) {
std::vector<std::pair<std::string, std::string>> input_types_list(onnx_func_proto.input_size());
std::vector<std::pair<std::string, std::string>> output_types_list(onnx_func_proto.output_size());
size_t num_of_inputs = 0;
size_t num_of_outputs = 0;
for (const auto& node : onnx_func_proto.node()) {
num_of_inputs += node.input_size();
num_of_outputs += node.output_size();
}
InlinedHashMap<std::string, std::vector<std::string>> type_constraint_map;
type_constraint_map.reserve(num_of_inputs + num_of_outputs);
InlinedHashMap<std::string_view, ONNX_NAMESPACE::AttributeProto_AttributeType> attribute_type_map;
attribute_type_map.reserve(onnx_func_proto.node_size());
// Create an all permissive list of data types. This will be used in case of model local functions
// when we cannot infer the type constraints from function proto body
InlinedHashSet<std::string_view> all_types;
all_types.reserve(ONNX_NAMESPACE::OpSchema::all_tensor_types_ir4().size() +
ONNX_NAMESPACE::OpSchema::all_tensor_sequence_types().size());
all_types.insert(ONNX_NAMESPACE::OpSchema::all_tensor_types_ir4().cbegin(),
ONNX_NAMESPACE::OpSchema::all_tensor_types_ir4().cend());
all_types.insert(ONNX_NAMESPACE::OpSchema::all_tensor_sequence_types().cbegin(),
ONNX_NAMESPACE::OpSchema::all_tensor_sequence_types().cend());
auto schema_registry = ONNX_NAMESPACE::OpSchemaRegistry::Instance();
InlinedHashMap<std::string, int> opset_imports;
for (const auto& relied_opset : onnx_func_proto.opset_import()) {
opset_imports[relied_opset.domain()] = static_cast<int>(relied_opset.version());
}
std::function<void(const ONNX_NAMESPACE::NodeProto&)> process_node = [&](const ONNX_NAMESPACE::NodeProto& node) {
auto it = opset_imports.find(node.domain());
ORT_ENFORCE(it != opset_imports.end(),
"No opset registered for domain " + node.domain() + " in function opset imports.");
int domain_version = it->second;
ORT_ENFORCE(domain_version != -1,
"No opset registered for domain " + node.domain() + " in function opset imports.");
const auto* node_op_schema = schema_registry->GetSchema(node.op_type(), domain_version, node.domain());
int variadic_arg_idx = -1;
for (int i = 0; i < node.input_size(); ++i) {
if (node_op_schema && variadic_arg_idx == -1) {
// The check is applied only if we have not seen a variadic parameter so far:
ORT_ENFORCE(static_cast<size_t>(i) < node_op_schema->inputs().size(),
"Too many inputs for op " + node.op_type());
}
const auto& in_name = node.input().Get(i);
auto iter = input_name_idx_map.find(in_name);
if (iter != input_name_idx_map.end()) {
int idx = iter->second;
// if we have hit a variadic arg it is the last input in the schema, so we need to use that index not i.
auto schema_idx = variadic_arg_idx != -1 ? variadic_arg_idx : i;
std::string type_str;
if (node_op_schema) {
type_str = node_op_schema->inputs().at(schema_idx).GetTypeStr() + "in" + std::to_string(idx);
} else {
type_str = "Tin" + std::to_string(idx);
}
input_types_list[idx] = std::make_pair(in_name, type_str);
if (!type_constraint_map.count(type_str)) {
// If schema is available for the node then get the allowed types from the schema
// else add all types to allowed types list. It is OK to add all types. Any issues will be
// caught later if we try to inline the nodes and there is no kernl available for
// the requested types.
auto& dest_types = type_constraint_map[type_str];
if (node_op_schema) {
const auto& types = node_op_schema->inputs().at(schema_idx).GetTypes();
dest_types.reserve(dest_types.size() + types.size());
for (const auto* s : types) {
dest_types.emplace_back(*s);
}
} else {
dest_types.reserve(dest_types.size() + all_types.size());
for (const auto& s : all_types) {
dest_types.emplace_back(s);
}
}
}
}
// if this is a variadic input there are no more inputs in the schema
if (node_op_schema && variadic_arg_idx == -1 &&
node_op_schema->inputs().at(i).GetOption() == OpSchema::FormalParameterOption::Variadic) {
variadic_arg_idx = i;
}
}
variadic_arg_idx = -1;
for (int i = 0; i < node.output_size(); ++i) {
auto& out_name = node.output().Get(i);
auto iter = output_name_idx_map.find(out_name);
if (iter != output_name_idx_map.end()) {
int idx = iter->second;
// if we have hit a variadic arg it is the last output in the schema, so we need to use that index.
auto schema_idx = variadic_arg_idx != -1 ? variadic_arg_idx : i;
std::string type_str;
if (node_op_schema) {
type_str = node_op_schema->outputs().at(schema_idx).GetTypeStr() + "out" + std::to_string(idx);
} else {
type_str = "Tout" + std::to_string(idx);
}
output_types_list[idx] = std::make_pair(out_name, type_str);
if (!type_constraint_map.count(type_str)) {
// If schema is available for the node then get the allowed types from the schema
// else add all types to allowed types list. It is OK to add all types. Any issues will be
// caught later if we try to inline the nodes and there is no kernel available for
// the requested types.
auto& dest_types = type_constraint_map[type_str];
if (node_op_schema) {
const auto& types = node_op_schema->outputs().at(schema_idx).GetTypes();
dest_types.reserve(dest_types.size() + types.size());
for (auto* data_type : types) {
dest_types.emplace_back(*data_type);
}
} else {
dest_types.reserve(dest_types.size() + all_types.size());
for (const auto& data_type : all_types) {
dest_types.emplace_back(data_type);
}
}
}
}
// if this is a variadic output there are no more outputs in the schema
if (node_op_schema && variadic_arg_idx == -1 &&
node_op_schema->outputs().at(i).GetOption() == OpSchema::FormalParameterOption::Variadic) {
variadic_arg_idx = i;
}
}
// If an subgraph node attribute has a specified
// type attribute, we add its referenced attribute
// into the op's schema
for (auto& attr : node.attribute()) {
if (!attr.ref_attr_name().empty() && utils::HasType(attr))
attribute_type_map[attr.ref_attr_name()] = attr.type();
if (attr.ref_attr_name().empty() && attr.has_g()) {
for (const auto& sgnode : attr.g().node())
process_node(sgnode);
}
}
};
for (const auto& node : onnx_func_proto.node())
process_node(node);
int i = 0;
for (auto& input : input_types_list) {
if (!input.first.empty()) {
op_schema->Input(i, input.first, "", input.second);
} else {
// Handle unused input: its type can be anything.
std::string type_str = "Tin" + std::to_string(i);
op_schema->Input(i, onnx_func_proto.input(i), "", type_str);
auto& dest_types = type_constraint_map[type_str];
dest_types.reserve(dest_types.size() + all_types.size());
for (const auto& data_type : all_types) {
dest_types.emplace_back(data_type);
}
}
++i;
}
i = 0;
for (auto& output : output_types_list) {
op_schema->Output(i, output.first, "", output.second);
++i;
}
for (auto& tc : type_constraint_map) {
op_schema->TypeConstraint(tc.first, tc.second, "");
}
const auto attr_end = attribute_type_map.end();
for (auto& attribute_name : onnx_func_proto.attribute()) {
auto hit = attribute_type_map.find(attribute_name);
if (hit != attr_end)
op_schema->Attr(attribute_name, "", hit->second, false);
}
}
std::unique_ptr<ONNX_NAMESPACE::OpSchema> CreateSchema(const std::string& function_domain,
const std::string& function_name,
const std::unordered_map<std::string, const ONNX_NAMESPACE::FunctionProto*>& model_local_functions,
const std::unordered_map<std::string, int>& domain_version_map,
const SchemaRegistryManager& schema_registry,
const logging::Logger& logger,
bool allow_released_opsets_only) {
std::string func_identifier = function_utils::GetFunctionIdentifier(function_domain, function_name);
auto iter = model_local_functions.find(func_identifier);
if (iter == model_local_functions.end()) {
ORT_THROW("The given function name: ", function_name, ", domain: ", function_domain, " is not found in model local functions");
}
auto* onnx_func_proto = iter->second;
ORT_ENFORCE(onnx_func_proto);
// generate the schema for this function template
// For schema defined functions get the version from the node in parent graph.
// For the functions which do not have schema defined (model local functions)
// get the since version from the version in opset imports using the domain.
auto it = domain_version_map.find(function_domain);
auto since_version = it == domain_version_map.end() ? -1 : it->second;
auto op_schema = std::make_unique<ONNX_NAMESPACE::OpSchema>();
op_schema->SetName(function_name);
op_schema->SetDomain(function_domain);
op_schema->SetDoc(onnx_func_proto->doc_string());
op_schema->SinceVersion(static_cast<ONNX_NAMESPACE::OperatorSetVersion>(since_version));
InlinedHashMap<std::string, int> input_name_idx_map;
input_name_idx_map.reserve(onnx_func_proto->input_size());
for (int i = 0, input_size = onnx_func_proto->input_size(); i < input_size; ++i) {
input_name_idx_map[onnx_func_proto->input().Get(i)] = i;
}
InlinedHashMap<std::string, int> output_name_idx_map;
output_name_idx_map.reserve(onnx_func_proto->output_size());
for (int i = 0, output_size = onnx_func_proto->output_size(); i < output_size; ++i) {
output_name_idx_map[onnx_func_proto->output().Get(i)] = i;
}
// Infer a op_schema for stand-alone functions.
IOTypeConstraintHelper(*onnx_func_proto, op_schema, input_name_idx_map, output_name_idx_map);
auto allow_official_onnx_release_only_final =
allow_released_opsets_only && model_load_utils::IsAllowReleasedONNXOpsetsOnlySet();
const auto onnx_released_versions =
schema_registry.GetLastReleasedOpsetVersions(false);
std::unordered_map<std::string, int> func_domain_to_version;
func_domain_to_version.reserve(onnx_func_proto->opset_import().size());
for (auto& opSet : onnx_func_proto->opset_import()) {
const auto& domain = opSet.domain();
const auto version = gsl::narrow_cast<int>(opSet.version());
model_load_utils::ValidateOpsetForDomain(onnx_released_versions, logger,
allow_official_onnx_release_only_final, domain, version);
// We need to overwrite the domain here with ("") or else the loop below will try to find ("")
// in the map and if not found (when domain == kOnnxDomainAlias), adds an entry for ("", 11).
// This effectively ignores the opset version specified by the model for the onnx domain.
if (domain == kOnnxDomainAlias) {
func_domain_to_version[kOnnxDomain] = version;
} else {
func_domain_to_version[domain] = version;
}
}
// Instantiate once and reuse for all shape inference calls.
constexpr bool check_type_true = true;
constexpr int error_mode_throw = 1;
constexpr bool enable_data_propagation_false = false;
static const ONNX_NAMESPACE::ShapeInferenceOptions inference_options{check_type_true, error_mode_throw, enable_data_propagation_false};
// model_local_functions is a member of Model instance and will be alive at the time this is invoked.
op_schema->TypeAndShapeInferenceFunction(
[onnx_func_proto, func_domain_to_version = std::move(func_domain_to_version), &model_local_functions](ONNX_NAMESPACE::InferenceContext& ctx) {
auto* schema_registry = ONNX_NAMESPACE::OpSchemaRegistry::Instance();
// https://github.com/microsoft/onnxruntime/issues/17061
// We are passing a nullptr for the symbol table, because symbol table must be global
// for all the shape inferencing to work correctly. Otherwise, unrelated shapes get
// the same symbolic shapes and are marked for memory re-use. This is a Temp fix.
constexpr ONNX_NAMESPACE::shape_inference::SymbolTableImpl* symbolTable = nullptr;
ONNX_NAMESPACE::shape_inference::InferShapeForFunctionNode(*onnx_func_proto, func_domain_to_version,
schema_registry, ctx, inference_options, model_local_functions,
symbolTable, nullptr);
});
op_schema->Finalize();
return op_schema;
}
class Inliner {
private:
std::string prefix_;
const onnxruntime::NodeAttributes& attr_map_;
std::vector<InlinedHashMap<std::string, std::string>> rename_scopes_;
Inliner(const std::string& prefix, const onnxruntime::NodeAttributes& attr_map) : prefix_(prefix),
attr_map_(attr_map) {
// Create an empty mapping for the top-level scope.
rename_scopes_.emplace_back();
}
// Replace given name with a unique version of the name, and cache the
// renaming-binding in current scope.
void make_unique(std::string& name) {
auto new_name{prefix_};
new_name.append("_").append(name);
auto& current_scope = rename_scopes_.back();
current_scope[name] = new_name;
name = std::move(new_name);
}
void rename(std::string& name, bool is_new_def) {
if (name.empty()) return;
for (auto i = rename_scopes_.size(); i > 0; --i) {
const auto& map = rename_scopes_[i - 1];
auto iter = map.find(name);
if (iter != map.end()) {
name = iter->second;
return;
}
}
if (is_new_def) {
make_unique(name);
}
// Otherwise, it is a reference to an outer-scope variable that should not be renamed.
}
template <bool isOutput>
void bind(google::protobuf::RepeatedPtrField<string>& formals, const google::protobuf::RepeatedPtrField<string>& actuals) {
// Every formal parameter name FP should be replace by the corresponding actual parameter name AP.
// However, if AP is empty, it is a missing optional parameter. This does not make any difference
// for inputs. However, for outputs we use a unique dummy name to handle the case that it
// is used in an output-context where it is not optional.
ORT_ENFORCE(actuals.size() <= formals.size(),
"Number of actual parameters cannot exceed number of formal parameters");
auto& current_scope = rename_scopes_.back();
int i = 0;
for (; i < actuals.size(); ++i) {
std::string& formal = *formals.Mutable(i);
std::string rename_as = actuals.Get(i);
if constexpr (isOutput) {
if (rename_as.empty())
rename_as.assign(prefix_).append("_").append(formal);
}
current_scope[formal] = rename_as;
if (!rename_as.empty())
formal = std::move(rename_as);
}
for (; i < formals.size(); ++i) {
std::string& formal = *formals.Mutable(i);
std::string rename_as;
if constexpr (isOutput) {
rename_as.assign(prefix_).append("_").append(formal);
}
current_scope[formal] = rename_as;
if (!rename_as.empty())
formal = std::move(rename_as);
}
}
// Process a node:
void transform(NodeProto& n) {
if (!n.name().empty())
n.set_name(prefix_ + "_" + n.name());
for (auto& x : *n.mutable_input()) {
rename(x, false);
}
for (auto& y : *n.mutable_output()) {
rename(y, true);
}
auto& attributes = *n.mutable_attribute();
const auto attr_map_end = attr_map_.cend();
for (auto attr_iter = attributes.begin(); attr_iter != attributes.end();) {
auto& attr = *attr_iter;
if (!attr.ref_attr_name().empty()) {
// Attribute-references must be replaced by the corresponding attribute-value in the call-node
// if the call-node contains the attribute. Otherwise, this attribute must be removed.
auto entry = attr_map_.find(attr.ref_attr_name());
if (entry != attr_map_end) {
// Copy value of attribute, but retain original name:
std::string name = attr.name();
attr = entry->second;
attr.set_name(name);
} else {
attr_iter = attributes.erase(attr_iter);
continue;
}
}
// Subgraphs must be recursively processed.
if (attr.has_g()) {
transform(*attr.mutable_g());
}
for (auto& graph : *attr.mutable_graphs())
transform(graph);
++attr_iter;
}
}
// Process a sub-graph, contained as an attribute in a control-flow op node.
void transform(GraphProto& graph) {
rename_scopes_.emplace_back();
for (auto& x : *graph.mutable_input())
make_unique(*x.mutable_name());
for (auto& init : *graph.mutable_initializer())
make_unique(*init.mutable_name());
for (auto& y : *graph.mutable_output())
make_unique(*y.mutable_name());
for (auto& n : *graph.mutable_node())
transform(n);
rename_scopes_.pop_back();
}
public:
// The main specialization method: specialize a FunctionProto for a particular call-site.
static void specialize(const NodeProto& callnode, FunctionProto& callee, const onnxruntime::NodeAttributes& attr_map,
const std::string& unique_prefix) {
Inliner inliner(unique_prefix, attr_map);
inliner.bind<false>(*callee.mutable_input(), callnode.input());
inliner.bind<true>(*callee.mutable_output(), callnode.output());
for (auto& n : *callee.mutable_node())
inliner.transform(n);
}
};
void Specialize(ONNX_NAMESPACE::FunctionProto& called_function, const ONNX_NAMESPACE::NodeProto& calling_node,
const onnxruntime::NodeAttributes& attr_map, const std::string& unique_prefix) {
Inliner::specialize(calling_node, called_function, attr_map, unique_prefix);
}
void Specialize(ONNX_NAMESPACE::FunctionProto& called_function, const Node& calling_node, const std::string& unique_prefix) {
ONNX_NAMESPACE::NodeProto calling_node_proto;
calling_node.ToProto(calling_node_proto);
onnxruntime::NodeAttributes attr_map = calling_node.GetAttributes();
for (auto& attribute_proto : called_function.attribute_proto()) {
ORT_IGNORE_RETURN_VALUE(attr_map.emplace(attribute_proto.name(), attribute_proto));
}
Specialize(called_function, calling_node_proto, attr_map, unique_prefix);
}
} // namespace function_utils
} // namespace onnxruntime