chore: import upstream snapshot with attribution
Docker Image CI / build-ubuntu2004 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:36:55 +08:00
commit c8a779b1bb
1887 changed files with 3245738 additions and 0 deletions
@@ -0,0 +1,5 @@
from polygraphy.comparator.comparator import *
from polygraphy.comparator.compare import *
from polygraphy.comparator.data_loader import *
from polygraphy.comparator.postprocess import *
from polygraphy.comparator.struct import *
@@ -0,0 +1,423 @@
#
# SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import contextlib
import copy
import queue
from multiprocessing import Process, Queue
from polygraphy import mod, util
from polygraphy.common import TensorMetadata
from polygraphy.comparator import util as comp_util
from polygraphy.comparator.compare import CompareFunc
from polygraphy.comparator.data_loader import DataLoader, DataLoaderCache
from polygraphy.comparator.struct import AccuracyResult, IterationResult, RunResults
from polygraphy.logger import G_LOGGER, LogMode
@mod.export()
class Comparator:
"""
Compares inference outputs.
"""
@staticmethod
def run(
runners,
data_loader=None,
warm_up=None,
use_subprocess=None,
subprocess_timeout=None,
subprocess_polling_interval=None,
save_inputs_path=None,
):
"""
Runs the supplied runners sequentially.
Args:
runners (List[BaseRunner]):
A list of runners to run.
data_loader (Sequence[OrderedDict[str, numpy.ndarray]]):
A generator or iterable that yields a dictionary that maps input names to input numpy buffers.
In the simplest case, this can be a `List[Dict[str, numpy.ndarray]]` .
In case you don't know details about the inputs ahead of time, you can access the
`input_metadata` property in your data loader, which will be set to an `TensorMetadata`
instance by this function.
Note that this does not work for generators or lists.
The number of iterations run by this function is controlled by the number of items supplied
by the data loader.
Defaults to an instance of `DataLoader`.
warm_up (int):
The number of warm up runs to perform for each runner before timing.
Defaults to 0.
use_subprocess (bool):
Whether each runner should be run in a subprocess. This allows each runner to have exclusive
access to the GPU. When using a subprocess, runners and loaders will never be modified.
subprocess_timeout (int):
The timeout before a subprocess is killed automatically. This is useful for handling processes
that never terminate. A value of None disables the timeout. Defaults to None.
subprocess_polling_interval (int):
The polling interval, in seconds, for checking whether a subprocess has completed or crashed.
In rare cases, omitting this parameter when subprocesses are enabled may cause this function
to hang indefinitely if the subprocess crashes.
A value of 0 disables polling. Defaults to 30 seconds.
save_inputs_path (str):
Path at which to save inputs used during inference. This will include all inputs generated by
the provided data_loader, and will be saved as a JSON List[Dict[str, numpy.ndarray]].
Returns:
RunResults:
A mapping of runner names to the results of their inference.
The ordering of `runners` is preserved in this mapping.
"""
warm_up = util.default(warm_up, 0)
data_loader = util.default(data_loader, DataLoader())
use_subprocess = util.default(use_subprocess, False)
subprocess_polling_interval = util.default(subprocess_polling_interval, 30)
loader_cache = DataLoaderCache(data_loader, save_inputs_path=save_inputs_path)
def execute_runner(runner, loader_cache):
with runner as active_runner:
# DataLoaderCache will ensure that the feed_dict does not contain any extra entries
# based on the provided input_metadata.
loader_cache.set_input_metadata(
active_runner.get_input_metadata(use_numpy_dtypes=False)
)
if warm_up:
G_LOGGER.start(
f"{active_runner.name:35} | Running {warm_up} warm-up run(s)"
)
try:
feed_dict = loader_cache[0]
except IndexError:
G_LOGGER.warning(
f"{warm_up} warm-up run(s) were requested, but data loader did not supply any data. Skipping warm-up run(s)"
)
else:
G_LOGGER.ultra_verbose(
f"Warm-up Input Buffers:\n{util.indent_block(feed_dict)}"
)
# First do a few warm-up runs, and don't time them.
for _ in range(warm_up):
active_runner.infer(feed_dict=feed_dict)
G_LOGGER.finish(
f"{active_runner.name:35} | Finished {warm_up} warm-up run(s)"
)
# Then, actual iterations.
index = 0
iteration_results = []
iterations_num = len(loader_cache)
total_runtime = 0
for index, feed_dict in enumerate(loader_cache):
G_LOGGER.info(
f"{active_runner.name:35}\n---- Inference Input(s) ----\n{TensorMetadata().from_feed_dict(feed_dict)}",
mode=LogMode.ONCE,
)
G_LOGGER.extra_verbose(
lambda: f"{active_runner.name:35} | Feeding inputs:\n{util.indent_block(dict(feed_dict))}"
)
outputs = active_runner.infer(feed_dict=feed_dict)
runtime = active_runner.last_inference_time()
total_runtime += runtime
# Only make a deep copy if we have more than one iteration.
# For single iteration case, we can use the outputs directly since they won't be reused.
# This allows running with a large number of outputs (e.g. for accuracy debugging) without memory explosion.
iteration_results.append(
IterationResult(
outputs=copy.deepcopy(outputs) if iterations_num > 1 else outputs,
runtime=runtime,
runner_name=active_runner.name,
)
)
G_LOGGER.info(
f"{active_runner.name:35}\n---- Inference Output(s) ----\n{TensorMetadata().from_feed_dict(outputs)}",
mode=LogMode.ONCE,
)
G_LOGGER.extra_verbose(
lambda: f"{active_runner.name:35} | Inference Time: {runtime * 1000.0:.3f} ms | Received outputs:\n{util.indent_block(dict(outputs))}"
)
total_runtime_ms = total_runtime * 1000.0
G_LOGGER.finish(
f"{active_runner.name:35} | Completed {index + 1} iteration(s) in {total_runtime_ms:.4g} ms | Average inference time: {total_runtime_ms / float(index + 1):.4g} ms."
)
return iteration_results
# Wraps execute_runner to use a queue.
def execute_runner_with_queue(runner_queue, runner, loader_cache):
iteration_results = None
try:
iteration_results = execute_runner(runner, loader_cache)
except:
# Cannot necessarily send the exception back over the queue.
G_LOGGER.backrace()
util.try_send_on_queue(runner_queue, iteration_results)
# After finishing, send the updated loader_cache back.
util.try_send_on_queue(runner_queue, loader_cache)
# Do all inferences in one loop, then comparisons at a later stage.
# We run each runner in a separate process so that we can provide exclusive GPU access for each runner.
run_results = RunResults()
if not runners:
G_LOGGER.warning(
"No runners were provided to Comparator.run(). Inference will not be run, and run results will be empty."
)
for runner in runners:
G_LOGGER.start(f"{runner.name:35} | Activating and starting inference")
if use_subprocess:
runner_queue = Queue()
process = Process(
target=execute_runner_with_queue,
args=(runner_queue, runner, loader_cache),
)
process.start()
# If a subprocess hangs in a certain way, then process.join could block forever. Hence,
# we need to keep polling the process to make sure it really is alive.
iteration_results = None
while process.is_alive() and iteration_results is None:
try:
iteration_results = util.try_receive_on_queue(
runner_queue, timeout=subprocess_polling_interval / 2
)
# Receive updated loader cache, or fall back if it could not be sent.
loader_cache = util.try_receive_on_queue(
runner_queue, timeout=subprocess_polling_interval / 2
)
except queue.Empty:
G_LOGGER.extra_verbose("Polled subprocess - still running")
try:
assert iteration_results is not None
run_results.append((runner.name, iteration_results))
process.join(subprocess_timeout)
except:
G_LOGGER.critical(
f"{runner.name:35} | Terminated prematurely. Check the exception logged above. If there is no exception logged above, make sure not to use the --use-subprocess flag or set use_subprocess=False in Comparator.run()."
)
finally:
process.terminate()
if loader_cache is None:
G_LOGGER.critical(
"Could not send data loader cache to runner subprocess. Please try disabling subprocesses "
"by removing the --use-subprocess flag, or setting use_subprocess=False in Comparator.run()"
)
else:
run_results.append((runner.name, execute_runner(runner, loader_cache)))
G_LOGGER.verbose(f"Successfully ran: {[r.name for r in runners]}")
return run_results
@staticmethod
def postprocess(run_results, postprocess_func):
"""
Applies post processing to all the outputs in the provided run results.
This is a convenience function to avoid the need for manual iteration over the run_results dictionary.
Args:
run_results (RunResults): The result of Comparator.run().
postprocess_func (Callable(IterationResult) -> IterationResult):
The function to apply to each ``IterationResult``.
Returns:
RunResults: The updated run results.
"""
G_LOGGER.start(
f"Applying post-processing to outputs: {postprocess_func.__name__}"
)
for _, iteration_results in run_results:
for index, iter_res in enumerate(iteration_results):
iteration_results[index] = postprocess_func(iter_res)
G_LOGGER.finish("Finished applying post-processing")
return run_results
@staticmethod
def default_comparisons(run_results):
# Sets up default comparisons - which is to compare each runner to the subsequent one.
return [(i, i + 1) for i in range(len(run_results) - 1)]
@staticmethod
def compare_accuracy(
run_results, fail_fast=False, comparisons=None, compare_func=None
):
"""
Args:
run_results (RunResults): The result of Comparator.run()
fail_fast (bool): Whether to exit after the first failure
comparisons (List[Tuple[int, int]]):
Comparisons to perform, specified by runner indexes. For example, [(0, 1), (1, 2)]
would compare the first runner with the second, and the second with the third.
By default, this compares each result to the subsequent one.
compare_func (Callable(IterationResult, IterationResult) -> OrderedDict[str, bool]):
A function that takes in two IterationResults, and returns a dictionary that maps output
names to a boolean (or anything convertible to a boolean) indicating whether outputs matched.
The order of arguments to this function is guaranteed to be the same as the ordering of the
tuples contained in `comparisons`.
Returns:
AccuracyResult:
A summary of the results of the comparisons. The order of the keys (i.e. runner pairs) is
guaranteed to be the same as the order of `comparisons`. For more details, see the AccuracyResult
docstring (e.g. help(AccuracyResult)).
"""
def find_mismatched(match_dict):
return [name for name, matched in match_dict.items() if not bool(matched)]
compare_func = util.default(compare_func, CompareFunc.simple())
comparisons = util.default(
comparisons, Comparator.default_comparisons(run_results)
)
accuracy_result = AccuracyResult()
for runner0_index, runner1_index in comparisons:
(runner0_name, results0), (runner1_name, results1) = (
run_results[runner0_index],
run_results[runner1_index],
)
G_LOGGER.start(f"Accuracy Comparison | {runner0_name} vs. {runner1_name}")
with G_LOGGER.indent():
runner_pair = (runner0_name, runner1_name)
accuracy_result[runner_pair] = []
num_iters = min(len(results0), len(results1))
for iteration, (result0, result1) in enumerate(zip(results0, results1)):
if num_iters > 1:
G_LOGGER.info(f"Iteration: {iteration}")
with contextlib.ExitStack() as stack:
if num_iters > 1:
stack.enter_context(G_LOGGER.indent())
iteration_match_dict = compare_func(result0, result1)
accuracy_result[runner_pair].append(iteration_match_dict)
mismatched_outputs = find_mismatched(iteration_match_dict)
if fail_fast and mismatched_outputs:
return accuracy_result
G_LOGGER.extra_verbose(
f"Finished comparing {runner0_name} with {runner1_name}"
)
passed, _, total = accuracy_result.stats(runner_pair)
pass_rate = accuracy_result.percentage(runner_pair) * 100.0
msg = f"Accuracy Summary | {runner0_name} vs. {runner1_name} | Passed: {passed}/{total} iterations | Pass Rate: {pass_rate}%"
if passed == total:
G_LOGGER.finish(msg)
else:
G_LOGGER.error(msg)
return accuracy_result
@staticmethod
def validate(run_results, check_inf=None, check_nan=None, fail_fast=None):
"""
Checks output validity.
Args:
run_results (Dict[str, List[IterationResult]]): The result of Comparator.run().
check_inf (bool): Whether to fail on Infs. Defaults to False.
check_nan (bool): Whether to fail on NaNs. Defaults to True.
fail_fast (bool): Whether to fail after the first invalid value. Defaults to False.
Returns:
bool: True if all outputs were valid, False otherwise.
"""
check_inf = util.default(check_inf, False)
check_nan = util.default(check_nan, True)
fail_fast = util.default(fail_fast, False)
def is_finite(output):
non_finite = util.array.logical_not(util.array.isfinite(output))
if util.array.any(non_finite):
G_LOGGER.error(
"Inf Detected | One or more non-finite values were encountered in this output"
)
G_LOGGER.info(
"Note: Use -vv or set logging verbosity to EXTRA_VERBOSE to display non-finite values",
mode=LogMode.ONCE,
)
G_LOGGER.extra_verbose(f"Note: non-finite values at:\n{non_finite}")
G_LOGGER.extra_verbose(
f"Note: non-finite values:\n{output[non_finite]}"
)
return False
return True
def is_not_nan(output):
nans = util.array.isnan(output)
if util.array.any(nans):
G_LOGGER.error(
"NaN Detected | One or more NaNs were encountered in this output"
)
G_LOGGER.info(
"Note: Use -vv or set logging verbosity to EXTRA_VERBOSE to display locations of NaNs",
mode=LogMode.ONCE,
)
G_LOGGER.extra_verbose(f"Note: NaNs at:\n{nans}")
return False
return True
def validate_output(runner_name, output_name, output):
G_LOGGER.start(
f"{runner_name:35} | Validating output: {output_name} (check_inf={check_inf}, check_nan={check_nan})"
)
with G_LOGGER.indent():
comp_util.log_output_stats(output)
output_valid = True
if check_nan:
output_valid &= is_not_nan(output)
if check_inf:
output_valid &= is_finite(output)
if output_valid:
G_LOGGER.finish(f"PASSED | Output: {output_name} is valid")
else:
G_LOGGER.error(f"FAILED | Errors detected in output: {output_name}")
return output_valid
all_valid = True
G_LOGGER.start(f"Output Validation | Runners: {list(run_results.keys())}")
with G_LOGGER.indent():
for runner_name, results in run_results:
for result in results:
for output_name, output in result.items():
all_valid &= validate_output(runner_name, output_name, output)
if fail_fast and not all_valid:
return False
if all_valid:
G_LOGGER.finish("PASSED | Output Validation")
else:
G_LOGGER.error("FAILED | Output Validation")
return all_valid
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,569 @@
#
# SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import contextlib
from collections import OrderedDict
from polygraphy import constants, func, mod, util
from polygraphy.comparator.struct import RunResults
from polygraphy.datatype import DataType
from polygraphy.exception import DataTypeConversionException, PolygraphyException
from polygraphy.json import save_json
from polygraphy.logger import G_LOGGER, LogMode
np = mod.lazy_import("numpy")
torch = mod.lazy_import("torch")
class ArraySampler:
def __init__(self, data_loader_backend_module, seed):
"""
Args:
data_loader_backend_module (str):
The module specifying the array type to use to generate arrays.
Can be either "numpy" or "torch".
seed (int):
The seed to use when generating random inputs.
"""
self.rng = None
VALID_ARRAY_MODULES = ["numpy", "torch"]
if data_loader_backend_module not in VALID_ARRAY_MODULES:
G_LOGGER.critical(
f"Invalid `data_loader_backend_module`. Note: got: {data_loader_backend_module} but valid modules are: {VALID_ARRAY_MODULES}"
)
self.data_loader_backend_module = data_loader_backend_module
if self.data_loader_backend_module == "numpy":
self.rng = np.random.RandomState(seed)
elif self.data_loader_backend_module == "torch":
self.rng = torch.Generator()
self.rng.manual_seed(seed)
def sample_integer(self, shape, dtype, low, high):
"""
Samples an array containing integral values in the range [low, high], inclusive
"""
dtype = (
DataType.to_dtype(
DataType.from_dtype(dtype), self.data_loader_backend_module
)
if dtype is not None
else dtype
)
if self.data_loader_backend_module == "numpy":
return np.array(
self.rng.randint(low=low, high=high + 1, size=shape, dtype=dtype)
)
elif self.data_loader_backend_module == "torch":
return torch.randint(low, high + 1, shape, generator=self.rng, dtype=dtype)
def sample_float(self, shape, dtype, fmin, fmax):
"""
Samples an array containing float values in the range [fmin, fmax], inclusive
"""
# Special handling for infinite lower/upper bounds
# Without this, two infinities will collapse into a NaN, resulting in no infinities
# in the final output.
scale = fmax - fmin
shift = fmin
if util.is_inf(fmin):
scale = fmin
shift = 0
if util.is_inf(fmax):
scale = fmax
dtype = (
DataType.to_dtype(
DataType.from_dtype(dtype), self.data_loader_backend_module
)
if dtype is not None
else dtype
)
if self.data_loader_backend_module == "numpy":
return np.array(
(self.rng.random_sample(size=shape) * scale + shift).astype(dtype)
)
elif self.data_loader_backend_module == "torch":
return torch.rand(shape, generator=self.rng, dtype=dtype)
def constant_array(self, shape, dtype):
dtype = (
DataType.to_dtype(
DataType.from_dtype(dtype), self.data_loader_backend_module
)
if dtype is not None
else dtype
)
if self.data_loader_backend_module == "numpy":
return np.array(shape, dtype=dtype)
elif self.data_loader_backend_module == "torch":
return torch.tensor(shape, dtype=dtype)
@mod.export()
class DataLoader:
"""
Generates synthetic input data.
"""
def __init__(
self,
seed=None,
iterations=None,
input_metadata=None,
int_range=None,
float_range=None,
val_range=None,
data_loader_backend_module=None,
):
"""
Args:
seed (int):
The seed to use when generating random inputs.
Defaults to ``util.constants.DEFAULT_SEED``.
iterations (int):
The number of iterations for which to supply data.
Defaults to 1.
input_metadata (TensorMetadata):
A mapping of input names to their corresponding shapes and data types.
This will be used to determine what shapes to supply for inputs with dynamic shape, as
well as to set the data type of the generated inputs.
If either dtype or shape are None, then the value will be automatically determined.
For input shape tensors, i.e. inputs whose *value* describes a shape in the model, the
provided shape will be used to populate the values of the inputs, rather than to determine
their shape.
val_range (Union[Tuple[number], Dict[str, Tuple[number]]]):
A tuple containing exactly 2 numbers, indicating the minimum and maximum values (inclusive)
the data loader should generate.
If either value in the tuple is None, the default will be used for that value.
If None is provided instead of a tuple, then the default values will be used for both the
minimum and maximum.
This can be specified on a per-input basis using a dictionary. In that case,
use an empty string ("") as the key to specify default range for inputs not explicitly listed.
Defaults to (0.0, 1.0).
data_loader_backend_module (str):
A string denoting what module to use to construct the input data arrays. Currently supports
"numpy" and "torch".
Defaults to "numpy".
int_range (Tuple[int]):
[DEPRECATED - Use val_range instead]
A tuple containing exactly 2 integers, indicating the minimum and maximum integer values (inclusive)
the data loader should generate. If either value in the tuple is None, the default will be used
for that value.
If None is provided instead of a tuple, then the default values will be used for both the
minimum and maximum.
float_range (Tuple[float]):
[DEPRECATED - Use val_range instead]
A tuple containing exactly 2 floats, indicating the minimum and maximum float values (inclusive)
the data loader should generate. If either value in the tuple is None, the default will be used
for that value.
If None is provided instead of a tuple, then the default values will be used for both the
minimum and maximum.
"""
def default_tuple(tup, default):
if tup is None or (
not isinstance(tup, tuple) and not isinstance(tup, list)
):
return default
new_tup = []
for elem, default_elem in zip(tup, default):
new_tup.append(util.default(elem, default_elem))
return tuple(new_tup)
self.seed = util.default(seed, constants.DEFAULT_SEED)
self.iterations = util.default(iterations, 1)
self.user_input_metadata = util.default(input_metadata, {})
self.data_loader_backend_module = util.default(
data_loader_backend_module, "numpy"
)
self._int_range_set = int_range is not None
if self._int_range_set:
mod.warn_deprecated(
"The int_range parameter in DataLoader", "val_range", remove_in="0.50.0"
)
self._int_range = default_tuple(int_range, (1, 25))
self._float_range_set = float_range is not None
if self._float_range_set:
mod.warn_deprecated(
"The float_range parameter in DataLoader",
"val_range",
remove_in="0.50.0",
)
self._float_range = default_tuple(float_range, (-1.0, 1.0))
self.input_metadata = None
self.default_val_range = default_tuple(val_range, (0.0, 1.0))
self.val_range = util.default(val_range, self.default_val_range)
if self.user_input_metadata:
G_LOGGER.info(
f"Will generate inference input data according to provided TensorMetadata: {self.user_input_metadata}"
)
def __repr__(self):
return util.make_repr(
"DataLoader",
seed=self.seed,
iterations=self.iterations,
input_metadata=self.user_input_metadata or None,
int_range=self._int_range,
float_range=self._float_range,
val_range=self.val_range,
data_loader_backend_module=self.data_loader_backend_module,
)[0]
def _get_range(self, name, cast_type):
if cast_type == int and self._int_range_set:
return self._int_range
elif cast_type == float and self._float_range_set:
return self._float_range
tup = util.value_or_from_dict(self.val_range, name, self.default_val_range)
return tuple(cast_type(val) for val in tup)
def __getitem__(self, index):
"""
Generates random input data.
May update the DataLoader's `input_metadata` attribute.
Args:
index (int):
Since this class behaves like an iterable, it takes an index parameter.
Generated data is guaranteed to be the same for the same index.
Returns:
OrderedDict[str, Union[numpy.ndarray, torch.Tensor]]: A mapping of input names to input numpy buffers.
"""
if index >= self.iterations:
raise IndexError()
G_LOGGER.verbose(f"Generating data using numpy seed: {self.seed + index}")
array_sampler = ArraySampler(self.data_loader_backend_module, self.seed + index)
def get_static_shape(name, shape):
static_shape = shape
if util.is_shape_dynamic(shape):
if shape.min is not None:
static_shape = shape.min
elif shape.max is not None:
static_shape = shape.max
else:
static_shape = util.override_dynamic_shape(shape)
if static_shape != shape:
if not util.is_valid_shape_override(static_shape, shape):
G_LOGGER.critical(
f"Input tensor: {name} | Cannot override original shape: {shape} to {static_shape}"
)
G_LOGGER.warning(
f"Input tensor: {name} [shape={shape}] | Will generate data of shape: {static_shape}.\n"
f"If this is incorrect, please provide a custom data loader.",
mode=LogMode.ONCE,
)
return static_shape
# Whether the user provided the values for a shape tensor input,
# rather than the shape of the input.
# If the shape is 1D, and has a value equal to the rank of the provided default shape, it is
# likely to be a shape tensor, and so its value, not shape, should be overriden.
# Note that this is a hack needed for older versions of TensorRT. Ideally, we wouldn't care
# whether the input is a shape tensor or not.
def is_shape_tensor(name, dtype):
if name not in self.input_metadata or name not in self.user_input_metadata:
return False
_, shape = self.input_metadata[name]
if (
(dtype is not None and not DataType.from_dtype(dtype).is_integral)
or util.is_shape_dynamic(shape)
or len(shape) != 1
):
return False
user_shape = self.user_input_metadata[name].shape
# Shape of shape cannot be dynamic.
return not util.is_shape_dynamic(user_shape) and len(user_shape) == shape[0]
def generate_buffer(name, dtype, shape):
if is_shape_tensor(name, dtype):
buffer = array_sampler.constant_array(shape, dtype)
G_LOGGER.info(
f"Assuming {name} is a shape tensor. Setting input values to: {buffer}. "
"If these values are not correct, please set it correctly in 'input_metadata' or by providing --input-shapes",
mode=LogMode.ONCE,
)
elif dtype is not None and (
DataType.from_dtype(dtype).is_integral
or DataType.from_dtype(dtype) == DataType.BOOL
):
imin, imax = self._get_range(
name,
cast_type=int if DataType.from_dtype(dtype).is_integral else bool,
)
G_LOGGER.verbose(
f"Input tensor: {name} | Generating input data in range: [{imin}, {imax}]",
mode=LogMode.ONCE,
)
# high is 1 greater than the max int drawn.
buffer = array_sampler.sample_integer(shape, dtype, imin, imax)
else:
fmin, fmax = self._get_range(name, cast_type=float)
G_LOGGER.verbose(
f"Input tensor: {name} | Generating input data in range: [{fmin}, {fmax}]",
mode=LogMode.ONCE,
)
buffer = array_sampler.sample_float(shape, dtype, fmin, fmax)
return buffer
if self.input_metadata is None and self.user_input_metadata is not None:
self.input_metadata = self.user_input_metadata
buffers = OrderedDict()
# Expand wildcards to inputs in user_input_metadata
inp_to_user_inp, unmatched_user_inps = util.match_keys(list(self.user_input_metadata), list(self.input_metadata))
# Warn about unused metadata
if unmatched_user_inps:
for name in unmatched_user_inps:
if util.contains_wildcard(name):
msg = f"Input tensor wildcard: {name} | Metadata was provided, but none of the matched inputs exist in one or more runners."
G_LOGGER.warning(msg)
else:
msg = f"Input tensor: {name} | Metadata was provided, but the input does not exist in one or more runners."
close_match = util.find_str_in_iterable(
name, self.input_metadata.keys()
)
if close_match:
msg += f"\nMaybe you meant to set: {close_match}?"
G_LOGGER.warning(msg)
for name, (dtype, shape) in self.input_metadata.items():
try:
dtype = (
DataType.to_dtype(
DataType.from_dtype(dtype), self.data_loader_backend_module
)
if dtype is not None
else None
)
except DataTypeConversionException:
G_LOGGER.critical(
f"Could not convert data type: {dtype} to {self.data_loader_backend_module}, so the default data loader cannot generate a {self.data_loader_backend_module} array for input: {name}. "
f"Please use a custom data loader to provide inputs. "
)
if name in inp_to_user_inp:
user_dtype, user_shape = self.user_input_metadata[inp_to_user_inp[name]]
dtype = util.default(user_dtype, dtype)
dtype = (
DataType.to_dtype(
DataType.from_dtype(dtype), self.data_loader_backend_module
)
if dtype is not None
else None
)
is_valid_shape_override = (
user_shape is not None
and util.is_valid_shape_override(user_shape, shape)
)
if util.is_shape_dynamic(user_shape):
G_LOGGER.warning(
f"Input tensor: {name} [shape={shape}] | Provided input shape: {user_shape} is dynamic.\nDynamic shapes cannot be used to generate inference data. Will use default shape instead.\nTo avoid this, please provide a fixed shape to the data loader. "
)
elif not is_valid_shape_override and not is_shape_tensor(name, dtype):
G_LOGGER.warning(
f"Input tensor: {name} [shape={shape}] | Cannot use provided custom shape: {user_shape} to override tensor shape. Will use default shape instead.",
mode=LogMode.ONCE,
)
else:
shape = util.default(user_shape, shape)
static_shape = get_static_shape(name, shape)
buffers[name] = generate_buffer(name, dtype, shape=static_shape)
# Warn about unused val_range
if not isinstance(self.val_range, tuple):
util.check_sequence_contains(
self.val_range.keys(),
list(self.input_metadata.keys()) + [""],
name="val_range",
log_func=G_LOGGER.warning,
check_missing=False,
)
return buffers
# Caches data loaded by a DataLoader for use across multiple runners.
class DataLoaderCache:
def __init__(self, data_loader, save_inputs_path=None):
self.data_loader = data_loader
self.cache = [] # List[OrderedDict[str, numpy.ndarray]]
self.save_inputs_path = save_inputs_path
@func.constantmethod
def __len__(self):
return len(self.cache)
@func.constantmethod
def __getitem__(self, iteration):
"""
Load the specified iteration from the cache if present, or load it from the data loader.
Args:
iteration (int): The iteration whose data to retrieve.
"""
if iteration >= len(self.cache):
raise IndexError()
# Attempts to match existing input buffers to the requested input_metadata
def coerce_cached_input(index, name, dtype, shape):
cached_feed_dict = self.cache[iteration]
cached_name = util.find_str_in_iterable(
name, cached_feed_dict.keys(), index
)
if cached_name is None:
G_LOGGER.critical(
f"Input tensor: {name} | Does not exist in the data loader cache."
)
if cached_name != name:
G_LOGGER.warning(
f"Input tensor: {name} | Buffer name ({cached_name}) does not match expected input name ({name})."
)
buffer = cached_feed_dict[cached_name]
if dtype != util.array.dtype(buffer):
G_LOGGER.warning(
f"Input tensor: {name} | Buffer dtype ({util.array.dtype(buffer)}) does not match expected input dtype ({dtype}), attempting to cast. "
)
try:
np_type = DataType.to_dtype(dtype, "numpy")
except:
pass
else:
type_info = None
if dtype.is_integral:
type_info = np.iinfo(np_type)
elif dtype.is_floating:
type_info = np.finfo(np_type)
if type_info is not None and util.array.any(
(buffer < type_info.min) | (buffer > type_info.max)
):
G_LOGGER.warning(
f"Some values in this input are out of range of {dtype}. Unexpected behavior may ensue!"
)
buffer = util.array.cast(buffer, dtype)
if not util.is_valid_shape_override(util.array.shape(buffer), shape):
G_LOGGER.warning(
f"Input tensor: {name} | Buffer shape ({util.array.shape(buffer)}) does not match expected input shape ({shape}). "
f"Attempting to transpose/reshape. "
)
buffer = util.try_match_shape(buffer, shape)
if util.array.dtype(buffer) != dtype or not util.is_valid_shape_override(
util.array.shape(buffer), shape
):
G_LOGGER.critical(
f"Input tensor: {name} | Cannot reuse input data due to mismatch in shape or data type.\n"
f"Note: Cached input: [dtype={util.array.dtype(buffer)}, shape={util.array.shape(buffer)}], "
f"Requested input: [dtype={dtype}, shape={shape}]"
)
return buffer
feed_dict = OrderedDict()
# Reload from data loader if needed
data_loader_feed_dict = None
for index, (name, (dtype, shape)) in enumerate(self.input_metadata.items()):
try:
buffer = coerce_cached_input(
index, name, DataType.from_dtype(dtype), shape
)
except PolygraphyException:
G_LOGGER.warning(
f"Could not use buffer previously cached from data loader for input: {name}. Attempting to reload inputs from the data loader.\nNote that this will only work if the data loader supports random access.\nPlease refer to warnings above for details on why the previously generated input buffer didn't work. "
)
try:
if data_loader_feed_dict is None:
data_loader_feed_dict = self.data_loader[iteration]
buffer = data_loader_feed_dict[name]
except:
G_LOGGER.critical(
"Could not reload inputs from data loader. Are the runners running the same model? "
"If not, please rewrite the data loader to support random access."
)
feed_dict[name] = buffer
return feed_dict
def set_input_metadata(self, input_metadata):
"""
Set the input metadata for the data loader.
Args:
input_metadata (TensorMetadata):
Input Metadata, including shape and type information. The cache may attempt to transform inputs to
match the specified input_metadata when data already in the cache does not exactly match.
"""
self.input_metadata = input_metadata
with contextlib.suppress(AttributeError):
self.data_loader.input_metadata = input_metadata
if not self.cache:
G_LOGGER.verbose("Loading inputs from data loader")
self.cache = list(self.data_loader)
def _is_feed_dict(inp):
if isinstance(inp, RunResults):
return False
try:
for name, _ in inp.items():
if not isinstance(name, str):
return False
except:
return False
else:
return True
if not self.cache:
G_LOGGER.warning("Data loader did not yield any input data.")
elif not _is_feed_dict(self.cache[0]):
G_LOGGER.critical(
f"Data loader returned an object that cannot be recognized as a feed_dict (Dict[str, Union[np.ndarray, torch.Tensor, DeviceView]]):"
f"\nNote: The object was:\n{self.cache[0]}.\n"
f"\nHint: If this is a `RunReults` object (e.g. generated with `--save-outputs`), try using the "
f"`data to-input` tool to convert it to a feed_dict compatible format. "
)
# Only save inputs the first time the cache is generated
if self.save_inputs_path is not None:
save_json(self.cache, self.save_inputs_path, "inference input data")
@@ -0,0 +1,64 @@
#
# SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
from polygraphy import mod, util
np = mod.lazy_import("numpy")
@mod.export()
class PostprocessFunc:
"""
Provides functions that can apply post-processing to `IterationResult` s.
"""
@staticmethod
# This function returns a top_k function that can be used as a postprocess_func.
def top_k(k=None):
"""
Creates a function that applies a Top-K operation to a IterationResult.
Top-K will return the indices of the k largest values in the array.
Args:
k (Union[int, Tuple[int, int], Dict[str, int], Dict[str, Tuple[int, int]]]):
The number of indices to keep and optionally the axis on which to operate.
For example, a value of ``(5, 0)`` would keep the top 5 indices along axis 0.
If this exceeds the axis length, it will be clamped.
This can be specified on a per-output basis by providing a dictionary. In that case,
use an empty string ("") as the key to specify default top-k value for outputs not explicitly listed.
If no default is present, unspecified outputs will not be modified.
Defaults to 10.
Returns:
Callable(IterationResult) -> IterationResult: The top-k function.
"""
k = util.default(k, 10)
axis = -1
# Top-K implementation.
def top_k_impl(iter_result):
for name, output in iter_result.items():
k_val = util.value_or_from_dict(k, name)
if k_val:
nonlocal axis
if util.is_sequence(k_val):
k_val, axis = k_val
iter_result[name] = util.array.topk(output, k_val, axis)[1]
return iter_result
return top_k_impl
@@ -0,0 +1,409 @@
#
# SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
from collections import OrderedDict
from polygraphy import config, mod, util
from polygraphy.common.interface import TypedDict, TypedList
from polygraphy.json import Decoder, Encoder, add_json_methods, load_json, save_json
from polygraphy.logger import G_LOGGER
class LazyArray:
"""
Represents a lazily loaded NumPy array or PyTorch Tensor.
For example, large arrays may be serialized to temporary files on the disk
to save memory.
"""
def __init__(self, arr):
"""
Args:
arr (Union[np.ndarray, torch.Tensor]): The array.
"""
self.arr = None
self.tmpfile = None
if config.ARRAY_SWAP_THRESHOLD_MB >= 0 and util.array.nbytes(arr) > (
config.ARRAY_SWAP_THRESHOLD_MB << 20
):
self.tmpfile = util.NamedTemporaryFile(suffix=".json")
G_LOGGER.extra_verbose(
f"Evicting large array ({util.array.nbytes(arr) / 1024.0 ** 2:.3f} MiB) from memory and saving to {self.tmpfile.name}"
)
save_json(arr, self.tmpfile.name)
else:
self.arr = arr
def load(self):
"""
Load the array, deserializing from the disk if it was stored earlier.
Returns:
Union[np.ndarray, torch.Tensor]: The array
"""
if self.arr is not None:
return self.arr
if self.tmpfile is None:
G_LOGGER.internal_error(
f"self.arr is None but self.tmpfile is also None; this should be impossible."
)
return load_json(self.tmpfile.name)
@Encoder.register(LazyArray, alias="LazyNumpyArray")
def encode(lazy_arr):
return {
"values": lazy_arr.load(),
}
@Decoder.register(LazyArray, alias="LazyNumpyArray")
def decode(dct):
return LazyArray(dct["values"])
@mod.export()
class IterationResult(TypedDict(lambda: str, lambda: LazyArray)):
"""
An ordered dictionary containing the result of a running a single iteration of a runner.
This maps output names to arrays, and preserves the output ordering from the runner.
NOTE: The ``POLYGRAPHY_ARRAY_SWAP_THRESHOLD_MB`` environment variable can be set to enable
the arrays to be swapped to the disk.
Also includes additional fields indicating the name of the runner which produced the
outputs, and the time required to do so.
"""
@staticmethod
def _to_lazy(nparray):
if isinstance(nparray, LazyArray):
return nparray
return LazyArray(nparray)
@staticmethod
def _to_lazy_dict(nparray_dict):
if nparray_dict is None:
return None
# Converts a Dict[str, np.ndarray] to a Dict[str, LazyArray]
lazy = OrderedDict()
for name, out in nparray_dict.items():
lazy[name] = IterationResult._to_lazy(out)
return lazy
def __init__(self, outputs=None, runtime=None, runner_name=None):
"""
Args:
outputs (Dict[str, Union[np.array, torch.Tensor]]): The outputs of this iteration, mapped to their names.
runtime (float):
The time required for this iteration, in seconds.
Only used for logging purposes.
runner_name (str):
The name of the runner that produced this output.
If this is omitted, a default name is generated.
"""
if outputs and config.ARRAY_SWAP_THRESHOLD_MB < 0:
total_size_gb = sum(
util.array.nbytes(arr)
for arr in outputs.values()
if util.array.is_torch(arr) or util.array.is_numpy(arr)
) / (1024.0**3)
if total_size_gb >= 1:
G_LOGGER.warning(
f"It looks like the outputs of this network are very large ({total_size_gb:.3f} GiB).\n"
"To reduce memory usage, you may want to allow Polygraphy to swap these arrays to the disk "
"using the POLYGRAPHY_ARRAY_SWAP_THRESHOLD_MB environment variable."
)
super().__init__(IterationResult._to_lazy_dict(outputs))
self.runtime = runtime
self.runner_name = util.default(runner_name, "custom_runner")
# Convenience methods to preserve np.ndarray in the interface.
def update(self, other):
return super().update(IterationResult._to_lazy_dict(other))
def __setitem__(self, name, arr):
return super().__setitem__(name, IterationResult._to_lazy(arr))
def values(self):
for arr in super().values():
yield arr.load()
def items(self):
for name, arr in super().items():
yield name, arr.load()
def __getitem__(self, name):
return super().__getitem__(name).load()
def __eq__(self, other):
if self.runtime != other.runtime or self.runner_name != other.runner_name:
return False
for key, val in self.items():
if key not in other:
return False
if not util.array.equal(val, other[key]):
return False
return True
@Encoder.register(IterationResult)
def encode(iter_result):
return {
"outputs": iter_result.dct,
"runtime": iter_result.runtime,
"runner_name": iter_result.runner_name,
}
@Decoder.register(IterationResult)
def decode(dct):
return IterationResult(
outputs=dct["outputs"], runtime=dct["runtime"], runner_name=dct["runner_name"]
)
@mod.export()
@add_json_methods("inference results")
class RunResults(TypedList(lambda: tuple)):
"""
Maps runners to per-iteration outputs (in the form of a ``List[IterationResult]``).
For example, if ``results`` is an instance of ``RunResults()``, then
to access the outputs of the first iteration from a specified runner, do:
::
iteration = 0
runner_name = "trt-runner"
outputs = results[runner_name][iteration]
# `outputs` is a `Dict[str, np.ndarray]`
Note: Technically, this is a ``List[Tuple[str, List[IterationResult]]]``, but includes
helpers that make it behave like an OrderedDict that can contain duplicates.
"""
def items(self):
"""
Creates a generator that yields ``Tuple[str, List[IterationResult]]`` - runner names
and corresponding outputs.
"""
for name, iteration_results in self.lst:
yield name, iteration_results
def keys(self):
"""
Creates a generator that yields runner names (str).
"""
for name, _ in self.lst:
yield name
def values(self):
"""
Creates a generator that yields runner outputs (List[IterationResult]).
"""
for _, iteration_results in self.lst:
yield iteration_results
def update(self, other):
"""
Updates the results stored in this instance.
Args:
other (Union[Dict[str, List[IterationResult]], RunResults]):
A dictionary or RunResults instance from which to update this one.
"""
for name, iteration_results in other.items():
self.lst[name] = iteration_results
return self
def add(self, out_list, runtime=None, runner_name=None):
"""
A helper to create a ``List[IterationResult]`` and map it to the specified runner_name.
This method cannot be used to modify an existing entry.
Calling this method is equivalent to:
::
results[runner_name] = []
for out in out_list:
results[runner_name].append(IterationResult(out, runtime, runner_name))
Args:
out_list (List[Dict[str, np.array]]):
One or more set of outputs where each output is a dictionary
of output names mapped to NumPy arrays.
runtime (float):
The time required for this iteration, in seconds.
Only used for logging purposes.
runner_name (str):
The name of the runner that produced this output.
If this is omitted, a default name is generated.
"""
runner_name = util.default(runner_name, "custom_runner")
iter_results = [IterationResult(out, runtime, runner_name) for out in out_list]
self[runner_name] = iter_results
def __getitem__(self, key):
if isinstance(key, int):
return self.lst[key]
for name, iteration_results in self.lst:
if name == key:
return iteration_results
G_LOGGER.critical(
f"{key:35} does not exist in this RunResults instance. Note: Available runners: {list(self.keys())}"
)
def __setitem__(self, key, value):
if isinstance(key, int):
self.lst[key] = value
return
for index, name in enumerate(self.keys()):
if name == key:
self.lst[index] = (key, value)
break
else:
self.append((key, value))
def __contains__(self, val):
if isinstance(val, str) or isinstance(val, bytes):
return val in list(self.keys())
return val in self.lst
def __eq__(self, other):
for (r0, its0), (r1, its1) in zip(self.lst, other.lst):
if r0 != r1:
return False
if its0 != its1:
return False
return True
@Encoder.register(RunResults)
def encode(results):
return {"lst": results.lst}
@Decoder.register(RunResults)
def decode(dct):
return RunResults(list(map(tuple, dct["lst"])))
@mod.export()
class AccuracyResult(TypedDict(lambda: tuple, lambda: list)):
"""
An ordered dictionary including details about the result of ``Comparator.compare_accuracy``.
More specifically, it is an ``OrderedDict[Tuple[str, str], List[OrderedDict[str, bool]]]`` which maps a runner
pair (a tuple containing both runner names) to a list of dictionaries of booleans (or anything that can be
converted into a boolean, such as an ``OutputCompareResult``), indicating whether there was a match in the outputs of
the corresponding iteration. The ``List[OrderedDict[str, bool]]`` is constructed from the dictionaries returned
by ``compare_func`` in ``compare_accuracy``.
For example, to see if there's a match between ``runner0`` and
``runner1`` during the 1st iteration for an output called ``output0``:
::
runner_pair = ("runner0", "runner1")
iteration = 0
output_name = "output0"
match = bool(accuracy_result[runner_pair][iteration][output_name])
If there's a mismatch, you can inspect the outputs from
the results of ``Comparator.run()``, assumed here to be called ``run_results``:
::
runner0_output = run_results["runner0"][iteration][output_name]
runner1_output = run_results["runner1"][iteration][output_name]
"""
def __bool__(self):
"""
Whether all outputs matched for every iteration.
You can use this function to avoid manually checking each output. For example:
::
if accuracy_result:
print("All matched!")
Returns:
bool
"""
return all(
[
bool(match)
for outs in self.values()
for out in outs
for match in out.values()
]
)
def _get_runner_pair(self, runner_pair):
return util.default(runner_pair, list(self.keys())[0])
def percentage(self, runner_pair=None):
"""
Returns the percentage of iterations that matched for the given pair of runners,
expressed as a decimal between 0.0 and 1.0.
Always returns 1.0 when the number of iterations is 0, or when there are no runner comparisons.
Args:
runner_pair (Tuple[str, str]):
A pair of runner names describing which runners to check.
Defaults to the first pair in the dictionary.
"""
if not list(self.keys()):
return 1.0 # No data in this result.
matched, _, total = self.stats(runner_pair)
if not total:
return 1.0 # No iterations
return float(matched) / float(total)
def stats(self, runner_pair=None):
"""
Returns the number of iterations that matched, mismatched, and the total number of iterations.
Args:
runner_pair (Tuple[str, str]):
A pair of runner names describing which runners to check.
Defaults to the first pair in the dictionary.
Returns:
Tuple[int, int, int]: Number of iterations that matched, mismatched, and total respectively.
"""
runner_pair = self._get_runner_pair(runner_pair)
outs = self[runner_pair]
matched = sum([all([match for match in out.values()]) for out in outs])
total = len(outs)
return matched, total - matched, total
@@ -0,0 +1,405 @@
#
# SPDX-FileCopyrightText: Copyright (c) 1993-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import functools
import math
import os
from polygraphy import config, mod, util
from polygraphy.logger import G_LOGGER
from polygraphy.datatype import DataType
import math
import os
np = mod.lazy_import("numpy")
plt = mod.lazy_import("matplotlib.pyplot")
matplotlib = mod.lazy_import("matplotlib")
def cast_up(buffer):
dtype = util.array.dtype(buffer)
if dtype == DataType.FLOAT16:
buffer = util.array.cast(buffer, DataType.FLOAT32)
elif dtype in [DataType.INT8, DataType.UINT8, DataType.INT16, DataType.UINT16]:
buffer = util.array.cast(buffer, DataType.INT32)
elif dtype == DataType.UINT32:
buffer = util.array.cast(buffer, DataType.INT64)
return buffer
def use_higher_precision(func):
"""
Decorator that will cast the input numpy buffer(s) to a higher precision before computation.
"""
@functools.wraps(func)
def wrapped(*buffers):
if any(util.is_empty_shape(util.array.shape(buffer)) for buffer in buffers):
return 0
new_buffers = [cast_up(buffer) for buffer in buffers]
return func(*new_buffers)
return wrapped
@use_higher_precision
def compute_max(buffer):
return util.array.max(buffer)
# Returns index of max value
@use_higher_precision
def compute_argmax(buffer):
return util.array.unravel_index(util.array.argmax(buffer), util.array.shape(buffer))
@use_higher_precision
def compute_min(buffer):
return util.array.min(buffer)
# Returns index of min value
@use_higher_precision
def compute_argmin(buffer):
return util.array.unravel_index(util.array.argmin(buffer), util.array.shape(buffer))
def compute_mean(buffer):
return util.array.mean(buffer, dtype=DataType.FLOAT32)
@use_higher_precision
def compute_std(buffer):
return util.array.std(buffer)
@use_higher_precision
def compute_variance(buffer):
return util.array.var(buffer)
@use_higher_precision
def compute_median(buffer):
return util.array.median(buffer)
def compute_quantile(buffer, q):
return util.array.quantile(buffer, q)
def compute_average_magnitude(buffer):
return util.array.mean(util.array.abs(buffer), dtype=DataType.FLOAT32)
def str_histogram(output, hist_range=None):
if util.array.dtype(output) == DataType.BOOL:
return ""
try:
try:
hist, bin_edges = util.array.histogram(output, range=hist_range)
except (ValueError, RuntimeError) as err:
G_LOGGER.verbose(f"Could not generate histogram. Note: Error was: {err}")
return ""
max_num_elems = compute_max(hist)
if not max_num_elems: # Empty tensor
return
bin_edges = [f"{bin:.3g}" for bin in bin_edges]
max_start_bin_width = max(len(bin) for bin in bin_edges)
max_end_bin_width = max(len(bin) for bin in bin_edges[1:])
MAX_WIDTH = 40
ret = "---- Histogram ----\n"
ret += f"{'Bin Range':{max_start_bin_width + max_end_bin_width + 5}}| Num Elems | Visualization\n"
for num, bin_start, bin_end in zip(hist, bin_edges, bin_edges[1:]):
bar = "#" * int(MAX_WIDTH * float(num) / float(max_num_elems))
ret += f"({bin_start:<{max_start_bin_width}}, {bin_end:<{max_end_bin_width}}) | {num:10} | {bar}\n"
return ret
except Exception as err:
G_LOGGER.verbose(f"Could not generate histogram.\nNote: Error was: {err}")
if config.INTERNAL_CORRECTNESS_CHECKS:
raise
return ""
def str_output_stats(output, runner_name=None):
ret = ""
if runner_name:
ret += f"{runner_name} | Stats: "
try:
ret += f"mean={compute_mean(output):.5g}, std-dev={compute_std(output):.5g}, var={compute_variance(output):.5g}, median={compute_median(output):.5g}, min={compute_min(output):.5g} at {compute_argmin(output)}, max={compute_max(output):.5g} at {compute_argmax(output)}, avg-magnitude={compute_average_magnitude(output):.5g}"
# np.quantile doesn't work with boolean input, so we don't show quantile error if the output type is boolean
if output.dtype == bool:
ret += "\n"
else:
ret += f", p90={compute_quantile(output, 0.9):.5g}, p95={compute_quantile(output, 0.95):.5g}, p99={compute_quantile(output, 0.99):.5g}\n"
except Exception as err:
G_LOGGER.verbose(f"Could not generate statistics.\nNote: Error was: {err}")
ret += "<Error while computing statistics>"
if config.INTERNAL_CORRECTNESS_CHECKS:
raise
return ret
def log_output_stats(output, info_hist=False, runner_name=None, hist_range=None):
ret = str_output_stats(output, runner_name)
G_LOGGER.info(ret)
with G_LOGGER.indent():
# For small outputs, show the entire output instead of just a histogram.
SMALL_OUTPUT_THRESHOLD = 100
if util.array.size(output) <= SMALL_OUTPUT_THRESHOLD:
G_LOGGER.log(
lambda: f"---- Values ----\n{util.indent_block(output)}",
severity=G_LOGGER.INFO if info_hist else G_LOGGER.VERBOSE,
)
G_LOGGER.log(
lambda: str_histogram(output, hist_range),
severity=G_LOGGER.INFO if info_hist else G_LOGGER.VERBOSE,
)
def build_heatmaps(
arr, min_val, max_val, prefix, save_dir=None, show=None, use_lognorm=None
):
"""
Display an array as an image or set of images. The last two dimensions are interpreted as
the height and width and the leading dimensions are flattened and treated as the number
of images to display.
Args:
arr (Union[torch.Tensor, numpy.ndarray]): The input array or tensor.
min_val (float): The minimum value in the input array
max_val (float): The maximum value in the input array
prefix (str): The prefix to use when displaying titles for figures.
save_dir (Optional[str]): Path to a directory in which to save images of the heatmaps.
show (Optional[bool]): Whether to display the heatmap.
use_lognorm (bool): Whether to use LogNorm instead of Normalize when displaying values.
"""
G_LOGGER.start(f"Building heatmaps for {prefix}. This may take a while...")
with G_LOGGER.indent():
MAX_HEIGHT = 1080
MAX_WIDTH = 1920
MAX_NUM_ROWS = 14
MAX_NUM_COLS = 7
FONT_SIZE = "xx-small"
shape = util.array.shape(arr)
if len(shape) < 3:
arr = util.array.view(
arr,
dtype=util.array.dtype(arr),
shape=([1] * (3 - len(shape))) + list(shape),
)
original_shape = util.array.shape(arr)
arr = util.array.view(
arr,
dtype=util.array.dtype(arr),
shape=(-1, original_shape[-2], original_shape[-1]),
)
shape = util.array.shape(arr)
num_images = shape[0]
def coord_str_from_img_idx(img_idx):
coord = []
for dim in reversed(original_shape[:-2]):
coord.insert(0, img_idx % dim)
img_idx //= dim
return f"({','.join(map(str, coord))},0:{shape[-2]},0:{shape[-1]})"
# We treat each 2D slice of the array as a separate image.
# Multiple images may be displayed on a single figure (in a grid) and we may have multiple figures.
num_rows = min(MAX_HEIGHT // shape[-2], MAX_NUM_ROWS)
num_cols = min(MAX_WIDTH // shape[-1], MAX_NUM_COLS)
# Remove any excess images per figure
if num_images < num_rows * num_cols:
num_cols = min(num_images, num_cols)
num_rows = math.ceil(num_images / num_cols)
num_images_per_figure = num_rows * num_cols
num_figures = math.ceil(num_images / num_images_per_figure)
# Populate each image in each figure.
for fig_idx in range(num_figures):
fig, axs = plt.subplots(
num_rows, num_cols, squeeze=False, dpi=200, constrained_layout=True
)
base_img_idx = fig_idx * num_images_per_figure
try:
# When the error is all the same, we can't use LogNorm.
if use_lognorm and min_val != max_val:
norm = matplotlib.colors.LogNorm(vmin=min_val, vmax=max_val)
prefix += " (Log Scale)"
else:
norm = matplotlib.colors.Normalize(vmin=min_val, vmax=max_val)
fig_title = f"{prefix}: {coord_str_from_img_idx(base_img_idx)} to {coord_str_from_img_idx(min(base_img_idx + num_images_per_figure, num_images) - 1)}"
fig.suptitle(fig_title, fontsize=FONT_SIZE)
G_LOGGER.extra_verbose(f"Building heatmaps for {fig_title}")
images = []
for row in range(num_rows):
for col in range(num_cols):
img_idx = base_img_idx + (col + row * num_cols)
ax = axs[row, col]
ax.set_axis_off()
if img_idx < shape[0]:
img = arr[img_idx]
title = f"{coord_str_from_img_idx(img_idx)}"
else:
img = np.zeros(shape=(shape[-2:]))
title = "Out Of Bounds"
ax.set_title(title, fontsize=FONT_SIZE)
images.append(
ax.imshow(
img, cmap="plasma", filternorm=False, resample=False
)
)
for im in images:
im.set_norm(norm)
fig.colorbar(images[0], ax=axs, shrink=0.7)
if save_dir is not None:
path = os.path.join(
save_dir, f"{util.sanitize_filename(fig_title)}.svg"
)
util.makedirs(path)
G_LOGGER.info(f"Saving '{prefix}' heatmap to: '{path}'")
fig.savefig(path)
if show:
plt.show()
finally:
plt.close(fig)
def scatter_plot_error_magnitude(
absdiff,
reldiff,
reference_output,
min_reldiff,
max_reldiff,
runner0_name,
runner1_name,
out0_name,
out1_name,
save_dir=None,
show=None,
):
"""
Display a plot of absolute/relative difference against the magnitude of the output.
Args:
absdiff (Union[torch.Tensor, numpy.ndarray]): The absolute difference.
reldiff (Union[torch.Tensor, numpy.ndarray]): The relative difference.
reference_output (Union[torch.Tensor, numpy.ndarray]): The output to consider as the reference output.
min_reldiff (float): The minimum relative difference
max_reldiff (float): The maximum relative difference
runner0_name (str): The name of the first runner.
runner1_name (str): The name of the second runner.
out0_name (str): The name of the output of the first runner.
out1_name (str): The name of the output of the second runner.
save_dir (Optional[str]): Path to a directory in which to save images of the plots.
show (Optional[bool]): Whether to display the error metrics plot.
"""
G_LOGGER.start(
f"Building error metrics plot for {out0_name}. This may take a while..."
)
with G_LOGGER.indent():
title = f"Error metrics between output0 and output1\noutput0: {runner0_name:35} | {out0_name}\noutput1: {runner1_name:35} | {out1_name}"
fname = util.sanitize_filename(f"error_metrics_{out0_name}.png")
TICK_FONT_SIZE = 6
TITLE_FONT_SIZE = 7
NUM_X_TICKS = 20
NUM_Y_LINEAR_TICKS = 10
def set_ax_properties(ax):
ax.tick_params(axis="x", labelrotation=90)
ax.tick_params(axis="both", labelsize=TICK_FONT_SIZE)
ax.grid(linestyle="--")
ax.xaxis.label.set_fontsize(TITLE_FONT_SIZE)
ax.yaxis.label.set_fontsize(TITLE_FONT_SIZE)
def set_linear_ax(ax):
xticks = ax.get_xticks()
yticks = ax.get_yticks()
ax.set_xticks(np.linspace(0, xticks[-1], NUM_X_TICKS))
ax.set_yticks(np.linspace(0, yticks[-1], NUM_Y_LINEAR_TICKS))
set_ax_properties(ax)
def set_log_ax(ax, min_diff, max_diff):
ax.set_yscale("log")
xticks = ax.get_xticks()
# Add a very small epsilon to prevent division by 0:
eps = 1e-15
yrange = np.log10(np.array([min_diff + eps, max_diff + eps]))
yrange[0] = math.floor(yrange[0])
yrange[1] = math.ceil(yrange[1])
ax.set_xticks(np.linspace(0, xticks[-1], NUM_X_TICKS))
ax.set_yticks(np.power(10, np.arange(yrange[0], yrange[1], 1)))
set_ax_properties(ax)
magnitude = util.array.abs(reference_output)
fig, axs = plt.subplots(2, sharex=True, constrained_layout=True)
try:
fig.suptitle(title, fontsize=TITLE_FONT_SIZE)
axs[0].scatter(magnitude, absdiff, s=1)
axs[0].set(ylabel="Absolute error")
set_linear_ax(axs[0])
axs[1].scatter(magnitude, reldiff, s=1)
label_suffix = ""
# When the range of the data is 0, we can't use log scale.
if min_reldiff != max_reldiff:
set_log_ax(axs[1], min_reldiff, max_reldiff)
label_suffix = " (log scale)"
else:
set_linear_ax(axs[1])
axs[1].set(
xlabel="output1 magnitude", ylabel=f"Relative error{label_suffix}"
)
if save_dir is not None:
path = os.path.join(save_dir, fname)
util.makedirs(path)
G_LOGGER.info(f"Saving error metrics plot to: '{path}'")
fig.savefig(path, dpi=1200, bbox_inches="tight")
if show:
plt.show()
finally:
plt.close(fig)