[iree-import-onnx] improve handling of large models (#19217)
This pr adds a few options:
1. `--large-model` allows disabling the onnx model checker if a user
knows ahead of time that the model is too large. It will also not load
the external weights in memory unless saving the parameters.
2. `--num-initializers-threshold` allows storing initializers to the
irpa file in batches with a specified number of entries. This can reduce
the memory overhead of first gathering all of the initializers, then
saving them to the irpa at once.
3. `--externalize-inputs-threshold` allows converting inputs to
externalized weights. This is useful for the following workflow:
exporting a HF pytorch model with safetensors, saving a `.irpa` from the
safetensor weights directly, and exporting to onnx with
`export_params=False` and `do_constant_folding=False` (which converts
weights to inputs and avoids folding weights with things like
transposes). When importing to mlir, you can set
`externalize-inputs-threshold=<num_original_inputs>` and it will convert
the inputs from and beyond that threshold to `util.global` ops.
4. `--save-params`/`--no-save-params` factors saving parameters out of
`import_initializer`, and one can avoid saving parameters with
`--no-save-params`. Useful for debugging compilation failures.
## TODO:
Figure out what to do about loading the onnx model and updating opset
version. It's possible to do opset version updating without weights in a
somewhat hacky way, since models > 2GB fail on opset version updating.
Add documentation
---------
Signed-off-by: zjgarvey <zjgarvey@gmail.com>
diff --git a/compiler/bindings/python/iree/compiler/tools/import_onnx/__main__.py b/compiler/bindings/python/iree/compiler/tools/import_onnx/__main__.py
index fc108d8..81ec745 100644
--- a/compiler/bindings/python/iree/compiler/tools/import_onnx/__main__.py
+++ b/compiler/bindings/python/iree/compiler/tools/import_onnx/__main__.py
@@ -20,7 +20,6 @@
from pathlib import Path
import sys
import tempfile
-
from .importer_externalization_overrides import *
@@ -32,25 +31,45 @@
imp: Any = None
if args.externalize_params:
- imp = IREENodeImporter.define_function(
- model_info.main_graph, m, args.num_elements_threshold, args.params_scope
+ if not args.save_params:
+ param_path = None
+ elif args.save_params_to:
+ param_path = args.save_params_to
+ elif (args.output_file is not None) and (args.output_file != "-"):
+ output_dir = Path(args.output_file).parent
+ output_stem = Path(args.output_file).stem
+ param_path = output_dir / (output_stem + "_params.irpa")
+ else:
+ raise ValueError(
+ "If `--externalize-params` is set and `--output-file` is stdout, either `--save-params-to` or `--no-save-params` must be set."
+ )
+ data_dir = (
+ args.data_dir
+ if args.data_dir is not None
+ else str(Path(args.input_file).parent)
)
+ param_bit_threshold = (
+ None
+ if args.param_gb_threshold is None
+ else int(args.param_gb_threshold * 8 * (10**9))
+ )
+ param_data = ParamData(
+ param_bit_threshold=param_bit_threshold,
+ num_elements_threshold=args.num_elements_threshold,
+ params_scope=args.params_scope,
+ data_dir=data_dir,
+ param_path=str(param_path),
+ input_index_threshold=args.externalize_inputs_threshold,
+ )
+ imp = IREENodeImporter.define_function(model_info.main_graph, m, param_data)
else:
imp = onnx_importer.NodeImporter.define_function(model_info.main_graph, m)
+
imp.import_all()
if not args.no_verify:
m.verify()
- if args.externalize_params:
- default_param_path = Path(args.output_file).parent / Path(args.output_file).stem
- param_path = (
- (str(default_param_path) + "_params.irpa")
- if args.save_params_to is None
- else str(args.save_params_to)
- )
- imp.param_archive.create_archive_file(param_path)
-
# TODO: This isn't very efficient output. If these files ever
# get large, enable bytecode and direct binary emission to save
# some copies.
@@ -60,41 +79,54 @@
else:
print(m.get_asm(assume_verified=not args.no_verify))
+ if args.externalize_params and args.save_params:
+ imp.save_params()
+
def load_onnx_model(args: argparse.Namespace) -> onnx.ModelProto:
input_dir = os.path.dirname(os.path.abspath(args.input_file))
- # Load the model, with possible external data coming from the default
- # location, or the location specified on the command line.
- if args.data_dir is None:
- raw_model = onnx.load(args.input_file)
- else:
- raw_model = onnx.load(args.input_file, load_external_data=False)
- onnx.load_external_data_for_model(raw_model, str(args.data_dir))
-
- # Only change the opset version if it is greater than the current one.
- if args.opset_version and args.opset_version > raw_model.opset_import[0].version:
- raw_model = onnx.version_converter.convert_version(
- raw_model, args.opset_version
+ # TODO: setup updating opset version without loading external weights.
+ if args.opset_version and args.large_model:
+ raise NotImplementedError(
+ "Updating the opset version for large models is currently unsupported."
)
- # Do shape inference two ways. First, attempt in-memory to avoid redundant
- # loading and the need for writing a temporary file somewhere. If that
- # fails, typically because of the 2 GB protobuf size limit, try again via
- # files. See
- # https://onnx.ai/onnx/repo-docs/PythonAPIOverview.html#shape-inference-a-large-onnx-model-2gb
- # for details about the file-based technique.
+ if not args.large_model:
+ # Load the model, with possible external data coming from the default
+ # location, or the location specified on the command line.
+ if args.data_dir is None:
+ raw_model = onnx.load(args.input_file)
+ else:
+ raw_model = onnx.load(args.input_file, load_external_data=False)
+ onnx.load_external_data_for_model(raw_model, str(args.data_dir))
- # Run the checker to test whether the file is above the threshold for
- # in-memory shape inference. If not, go ahead and do the shape inference.
- try:
- onnx.checker.check_model(raw_model)
- inferred_model = onnx.shape_inference.infer_shapes(
- raw_model, data_prop=args.data_prop
- )
- return inferred_model
- except ValueError:
- pass
+ # Only change the opset version if it is greater than the current one.
+ if (
+ args.opset_version
+ and args.opset_version > raw_model.opset_import[0].version
+ ):
+ raw_model = onnx.version_converter.convert_version(
+ raw_model, args.opset_version
+ )
+
+ # Do shape inference two ways. First, attempt in-memory to avoid redundant
+ # loading and the need for writing a temporary file somewhere. If that
+ # fails, typically because of the 2 GB protobuf size limit, try again via
+ # files. See
+ # https://onnx.ai/onnx/repo-docs/PythonAPIOverview.html#shape-inference-a-large-onnx-model-2gb
+ # for details about the file-based technique.
+
+ # Run the checker to test whether the file is above the threshold for
+ # in-memory shape inference. If not, go ahead and do the shape inference.
+ try:
+ onnx.checker.check_model(raw_model)
+ inferred_model = onnx.shape_inference.infer_shapes(
+ raw_model, data_prop=args.data_prop
+ )
+ return inferred_model
+ except ValueError:
+ pass
# Model is too big for in-memory inference: do file-based shape inference
# to a temp file.
@@ -111,7 +143,9 @@
# Load the temp file and the external data.
inferred_model = onnx.load(temp_inferred_file, load_external_data=False)
data_dir = Path(input_dir if args.data_dir is None else args.data_dir)
- onnx.load_external_data_for_model(inferred_model, str(data_dir))
+ # we don't need to load the model weights in-memory when externalizing params
+ if not args.externalize_params:
+ onnx.load_external_data_for_model(inferred_model, str(data_dir))
return inferred_model
@@ -146,30 +180,63 @@
type=int,
)
parser.add_argument(
- "--num-elements-threshold",
- help="Minimum number of elements for an initializer to be externalized. Only has an effect if 'externalize-params' is true.",
- type=int,
- default=100,
- )
- parser.add_argument(
- "--externalize-params",
- help="Externalize large parameters and store them on the disk, to load at runtime.",
+ "--large-model",
+ help="Setting this to true is recommended for large models that do not require --opset-version."
+ " It will bypass loading external weights and running the onnx checker to determine the model size.",
action=argparse.BooleanOptionalAction,
default=False,
)
- parser.add_argument(
+ # args for saving a file with externalized params
+ externalization_args = parser.add_argument_group(
+ "externalization", "args used to customize the externalization of model weights"
+ )
+ externalization_args.add_argument(
+ "--externalize-params",
+ help="Import the mlir file with large weights replaced by external reference calls.",
+ action=argparse.BooleanOptionalAction,
+ default=False,
+ )
+ externalization_args.add_argument(
+ "--externalize-inputs-threshold",
+ help="Treats inputs at or after the provided index as external parameters of the model."
+ " Only has an effect if 'externalize-params' is true.",
+ type=int,
+ )
+ externalization_args.add_argument(
+ "--num-elements-threshold",
+ help="Minimum number of elements for an initializer to be externalized."
+ " Only has an effect if 'externalize-params' is true.",
+ type=int,
+ default=100,
+ )
+ externalization_args.add_argument(
+ "--params-scope",
+ help="The namespace or the scope in which the externalized parameters are placed."
+ " Default is 'model'.",
+ type=str,
+ default="model",
+ )
+ # args for creating an external weight file
+ externalization_args.add_argument(
+ "--save-params",
+ help="Whether to save the params to a file. Setting this to false will generate mlir with externalized weights"
+ " without creating an associated .irpa file.",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ )
+ externalization_args.add_argument(
+ "--param-gb-threshold",
+ help="Setting this will flush params to a temp file when total in-memory param size exceeds the Gigabyte threshold."
+ " This is less efficient (about x2 slower) and only recommended for machines with limited RAM.",
+ type=float,
+ )
+ externalization_args.add_argument(
"--save-params-to",
help="Location to save the externalized parameters. When not set, the parameters will be written to '<output_file_name>_params.irpa'"
" under the namespace 'model', which can be configured by passing the namespace string to 'params-scope'.",
default=None,
type=Path,
)
- parser.add_argument(
- "--params-scope",
- help="The namespace or the scope in which the externalized parameters are placed. Default is 'model'.",
- type=str,
- default="model",
- )
args = parser.parse_args(argv)
return args
diff --git a/compiler/bindings/python/iree/compiler/tools/import_onnx/importer_externalization_overrides.py b/compiler/bindings/python/iree/compiler/tools/import_onnx/importer_externalization_overrides.py
index d99b269..8146333 100644
--- a/compiler/bindings/python/iree/compiler/tools/import_onnx/importer_externalization_overrides.py
+++ b/compiler/bindings/python/iree/compiler/tools/import_onnx/importer_externalization_overrides.py
@@ -5,15 +5,20 @@
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
import copy
+import logging
import random
import string
-import iree.runtime as rt
+import sys
+import time
+import tempfile
+from pathlib import Path
+from typing import Optional, Tuple, Any, NamedTuple, Union
-from ...dialects import util
-from typing import Optional, Tuple, Any
+import numpy
try:
import onnx
+ from onnx import numpy_helper
except ModuleNotFoundError as e:
raise ModuleNotFoundError(
f"iree-import-onnx requires that the `onnx` Python package is installed "
@@ -27,7 +32,7 @@
"iree-import-onnx is only available if IREE was built with Torch support"
) from e
-from onnx import numpy_helper
+from ...dialects import util
from ...ir import (
Context,
@@ -44,6 +49,17 @@
IntegerType,
)
+logger = logging.getLogger(__name__)
+
+
+class ParamData(NamedTuple):
+ param_bit_threshold: Optional[int]
+ num_elements_threshold: int
+ params_scope: str
+ data_dir: str
+ param_path: str
+ input_index_threshold: Optional[int]
+
class IREENodeImporter(onnx_importer.NodeImporter):
def __init__(
@@ -55,8 +71,7 @@
context_cache: "onnx_importer.ContextCache",
module_op: Operation,
module_cache: "onnx_importer.ModuleCache",
- num_elements_threshold: int,
- params_scope: str,
+ param_data: ParamData,
):
super().__init__(
graph_info,
@@ -69,9 +84,8 @@
self.last_global_op = None
self.symbol_table = SymbolTable(module_op)
self.symbol_table.insert(parent_op)
- self.num_elements_threshold = num_elements_threshold
- self.param_archive = rt.ParameterIndex()
- self.params_scope = params_scope
+ self.param_data = param_data
+ self.globals = []
def sanitize_name(self, name: str) -> str:
# There are often some initializers in the models that have no name
@@ -92,9 +106,17 @@
new_name = str(random.randrange(1, 1000)) + "__" + ch
return new_name
+ def get_type_info_from_type(self, tp: onnx.TypeProto):
+ tt = tp.tensor_type
+ if tt.elem_type:
+ dims = tuple(
+ (d.dim_value if not d.dim_param else None) for d in tt.shape.dim
+ )
+ return dims, tt.elem_type
+
def create_tensor_global(
self,
- t: onnx.TensorProto,
+ t: Union[onnx.TensorProto, onnx.ValueInfoProto],
) -> Tuple[str, IrType]:
# Always create globals at the top. Then after created, if there was
# a prior one, move the new one to after it to maintain declaration
@@ -106,8 +128,18 @@
# After lowering to linalg-on-tensors, the data type needs to be signless.
# So, we construct the globals to have signless types, and use
# torch_c.from_builtin_tensor to convert to the correct frontend type.
+ if isinstance(t, onnx.TensorProto):
+ dims = tuple(t.dims)
+ data_type = t.data_type
+ elif isinstance(t, onnx.ValueInfoProto):
+ dims, data_type = self.get_type_info_from_type(t.type)
+ else:
+ raise TypeError(
+ f"Expected an onnx.TensorProto or an onnx.ValueInfoProto, recieved {type(t)} from {name}"
+ )
+
vtensor_type = RankedTensorType.get(
- tuple(t.dims), ELEM_TYPE_TO_SIGNLESS_IR_TYPE[t.data_type]()
+ dims, ELEM_TYPE_TO_SIGNLESS_IR_TYPE[data_type]()
)
ir_attrs = {
"sym_name": StringAttr.get(name),
@@ -115,7 +147,7 @@
"type": TypeAttr.get(vtensor_type),
}
- external_scope_attr = StringAttr.get(self.params_scope)
+ external_scope_attr = StringAttr.get(self.param_data.params_scope)
external_name_attr = StringAttr.get(name)
ir_attrs["initial_value"] = Attribute.parse(
f"#stream.parameter.named<{external_scope_attr}::{external_name_attr}> : {vtensor_type}"
@@ -138,8 +170,7 @@
cls,
graph_info: onnx_importer.GraphInfo,
module_op: Operation,
- num_elements_threshold: int,
- params_scope: str,
+ param_data: ParamData,
context_cache: Optional["onnx_importer.ContextCache"] = None,
module_cache: Optional["onnx_importer.ModuleCache"] = None,
private: bool = False,
@@ -155,6 +186,16 @@
# Recover per-module caches of various attributes.
# Allows modification in the same module_op without
# loss of current state.
+ all_input_map = copy.deepcopy(graph_info.input_map)
+ num_inputs = len(all_input_map.items())
+ if param_data.input_index_threshold is not None:
+ if param_data.input_index_threshold not in range(0, num_inputs):
+ raise ValueError(
+ f"input_index_threshold must be in the range [0,num_inputs={num_inputs})"
+ )
+ for _index in range(param_data.input_index_threshold, num_inputs):
+ _discarded = graph_info.input_map.popitem()
+
mc = (
module_cache
if module_cache is not None
@@ -187,32 +228,49 @@
context_cache=cc,
module_op=module_op,
module_cache=mc,
- num_elements_threshold=num_elements_threshold,
- params_scope=params_scope,
+ param_data=param_data,
)
for node_name, input_value in zip(graph_info.input_map.keys(), block.arguments):
imp._nv_map[node_name] = input_value
imp._populate_graph_attrs(func_op)
+ imp._gi.input_map = all_input_map
return imp
+ def import_all(self, func=True):
+ num_inputs = len(self._gi.input_map.items())
+ if self.param_data.input_index_threshold is not None:
+ for i in range(self.param_data.input_index_threshold, num_inputs):
+ self.import_initializer(list(self._gi.input_map.values())[i])
+ super().import_all(func)
+
def import_initializer(
- self, initializer: onnx.TensorProto, extern_name: Optional[str] = None
+ self,
+ initializer: Union[onnx.TensorProto, onnx.ValueInfoProto],
+ extern_name: Optional[str] = None,
) -> Value:
# If an explicitly specified name is given, use that; otherwise, pick
# up the name from the tensor proto itself
initializer_name = extern_name if extern_name else initializer.name
- dims = list(initializer.dims)
- num_elements = 1
- for d in dims:
- num_elements = num_elements * d
- if num_elements < self.num_elements_threshold:
- imported_tensor = super().import_initializer(initializer)
- self._nv_map[initializer_name] = imported_tensor
- return imported_tensor
+ if isinstance(initializer, onnx.TensorProto):
+ dims = tuple(initializer.dims)
+ num_elements = 1
+ for d in dims:
+ num_elements = num_elements * d
+ if num_elements < self.param_data.num_elements_threshold:
+ imported_tensor = super().import_initializer(initializer)
+ self._nv_map[initializer_name] = imported_tensor
+ return imported_tensor
+ data_type = initializer.data_type
+ elif isinstance(initializer, onnx.ValueInfoProto):
+ dims, data_type = self.get_type_info_from_type(initializer.type)
+ else:
+ raise TypeError(
+ f"Expected an onnx.TensorProto or an onnx.ValueInfoProto, recieved {type(initializer)} from {initializer_name}"
+ )
actual_symbol_name, tensor_type = self.create_tensor_global(initializer)
vtensor_type = self._cc.get_vtensor_type(
- tuple(initializer.dims), self._cc.tensor_element_type(initializer.data_type)
+ dims, self._cc.tensor_element_type(data_type)
)
with InsertionPoint(self._b), Location.name(initializer_name):
@@ -224,10 +282,101 @@
).result
self._nv_map[initializer_name] = converted_value
- tensor_as_array = numpy_helper.to_array(initializer)
- self.param_archive.add_buffer(actual_symbol_name, tensor_as_array)
+ if isinstance(initializer, onnx.TensorProto):
+ self.globals.append((initializer_name, actual_symbol_name))
return converted_value
+ def save_params(self):
+ """
+ Only gets called if the arg `--save-params` is set to `True`.
+ Saving params requires iree-runtime, so putting the import here will avoid requiring it uniformly for the importer.
+ """
+ try:
+ import iree.runtime as rt
+ except ModuleNotFoundError as e:
+ raise ModuleNotFoundError(
+ "iree-import-onnx requires iree runtime api for externalizing parameters. "
+ "For example: `pip install iree-base-runtime`"
+ ) from e
+
+ param_archive = rt.ParameterIndex()
+ # if we don't need to save params in batches, gather all tensors in param_archive
+ if self.param_data.param_bit_threshold is None:
+ for name, actual_symbol_name in self.globals:
+ initializer = self._gi.initializer_map[name]
+ tensor_as_array = numpy_helper.to_array(
+ initializer, base_dir=self.param_data.data_dir
+ )
+ param_archive.add_buffer(actual_symbol_name, tensor_as_array)
+ param_archive.create_archive_file(self.param_data.param_path)
+ return
+
+ # else we need to save in batches:
+ # 1. setup a temporary directory to save smaller param files
+ # 2. keep a target_index for storing references to saved tensors
+ # 3. create an archive file for target_index at param_path to gather all temp data
+ target_index = rt.ParameterIndex()
+ in_memory_param_bits = 0
+ iter = 0
+ t00 = time.time()
+ with tempfile.TemporaryDirectory(
+ dir=Path(self.param_data.param_path).parent
+ ) as temp_dir_name:
+ get_curr_path = lambda: str(Path(temp_dir_name) / f"params_{iter}.irpa")
+ for name, actual_symbol_name in self.globals:
+ initializer = self._gi.initializer_map[name]
+ tensor_as_array = numpy_helper.to_array(
+ initializer, base_dir=self.param_data.data_dir
+ )
+ param_archive.add_buffer(actual_symbol_name, tensor_as_array)
+ # get the new param size
+ elem_dtype = tensor_as_array.dtype
+ elem_kind = elem_dtype.kind
+ if elem_kind not in ["i", "f"]:
+ raise TypeError(f"Unhandled numpy dtype: {elem_dtype}")
+ elem_info = (
+ numpy.iinfo(elem_dtype)
+ if elem_kind == "i"
+ else numpy.finfo(elem_dtype)
+ )
+ elem_bits = elem_info.bits
+ param_bits = tensor_as_array.size * elem_bits
+ # update the running total memory use
+ in_memory_param_bits += param_bits
+ if param_bits >= self.param_data.param_bit_threshold:
+ logger.warning(
+ f"Single parameter {name} is {param_bits} bits, "
+ + f"which exceeds threshold of {self.param_data.param_bit_threshold} bits."
+ )
+ # flush the param archive to a temp file if the threshold is exceeded
+ if in_memory_param_bits >= self.param_data.param_bit_threshold:
+ t0 = time.time()
+ param_archive.create_archive_file(
+ get_curr_path(),
+ target_index=target_index,
+ )
+ logger.info(
+ f"iter {iter} with {in_memory_param_bits} bits took {time.time() - t0}s to flush"
+ )
+ iter += 1
+ del param_archive
+ param_archive = rt.ParameterIndex()
+ in_memory_param_bits = 0
+
+ # write any remaining params to a temp file
+ t0 = time.time()
+ param_archive.create_archive_file(
+ get_curr_path(), target_index=target_index
+ )
+ logger.info(
+ f"iter {iter} with {in_memory_param_bits} bits took {time.time() - t0}s to flush"
+ )
+ # combine all temporary param files into the final result
+ t0 = time.time()
+ target_index.create_archive_file(self.param_data.param_path)
+ logger.info(f"combining {iter + 1} irpa files took {time.time() - t0}s")
+ logger.info(f"total time to save params: {time.time()-t00}")
+
ELEM_TYPE_TO_SIGNLESS_IR_TYPE = copy.deepcopy(onnx_importer.ELEM_TYPE_TO_IR_TYPE_CB)
diff --git a/compiler/bindings/python/test/tools/import_onnx_test.py b/compiler/bindings/python/test/tools/import_onnx_test.py
index 6089b65..0692e4c 100644
--- a/compiler/bindings/python/test/tools/import_onnx_test.py
+++ b/compiler/bindings/python/test/tools/import_onnx_test.py
@@ -8,6 +8,7 @@
import sys
import tempfile
import unittest
+from pathlib import Path
def run_tool(*argv: str):
@@ -125,6 +126,70 @@
# there should be no inlined constants.
self.assertNotIn("onnx.Constant", contents)
+ def testExternalizeInitializersThreshold(self):
+ run_tool(
+ LARGE_WEIGHTS_ONNX_FILE_PATH,
+ "--externalize-params",
+ "--param-gb-threshold",
+ "0.0",
+ "-o",
+ self.outputPath,
+ )
+ with open(self.outputPath, "rt") as f:
+ contents = f.read()
+ self.assertIn("util.global", contents)
+ self.assertIn("util.global.load", contents)
+ self.assertIn("onnx.Constant", contents)
+
+ def testExternalizeInputsThreshold(self):
+ run_tool(
+ LARGE_WEIGHTS_ONNX_FILE_PATH,
+ "--externalize-params",
+ "--externalize-inputs-threshold",
+ "0",
+ "-o",
+ self.outputPath,
+ )
+ with open(self.outputPath, "rt") as f:
+ contents = f.read()
+ self.assertIn("util.global", contents)
+ self.assertIn("util.global.load", contents)
+ self.assertNotIn("%arg0", contents)
+ self.assertIn("onnx.Constant", contents)
+
+ def testNoSaveParams(self):
+ param_path = str(Path(self.outputPath).parent / "test.irpa")
+ run_tool(
+ LARGE_WEIGHTS_ONNX_FILE_PATH,
+ "--externalize-params",
+ "--no-save-params",
+ "--save-params-to",
+ param_path,
+ "-o",
+ self.outputPath,
+ )
+ if os.path.exists(param_path):
+ self.fail("expected param_path to not exist with --no-save-params")
+ with open(self.outputPath, "rt") as f:
+ contents = f.read()
+ self.assertIn("util.global", contents)
+ self.assertIn("util.global.load", contents)
+ self.assertIn("onnx.Constant", contents)
+
+ def testLargeModelFlag(self):
+ run_tool(
+ LARGE_WEIGHTS_ONNX_FILE_PATH,
+ "--externalize-params",
+ "--large-model",
+ "-o",
+ self.outputPath,
+ )
+ with open(self.outputPath, "rt") as f:
+ contents = f.read()
+ self.assertIn("util.global", contents)
+ self.assertIn("util.global.load", contents)
+ self.assertIn("onnx.Constant", contents)
+
if __name__ == "__main__":
try: