# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries.
# SPDX-License-Identifier: BSD-3-Clause
# pylint: disable=too-many-lines, redefined-builtin
"""Utilities that are used for different AIMET PyTorch features"""
import itertools
from typing import (
List,
Tuple,
Union,
Dict,
Callable,
Any,
Iterable,
Optional,
TextIO,
Mapping,
)
import contextlib
from contextlib import contextmanager, ExitStack
import os
import pickle
import logging
import functools
from packaging import version
import torch.nn
import torch
from torch.utils.data import DataLoader, Dataset
from torch.utils._pytree import tree_map
from torch.nn.modules.module import (
_global_backward_hooks,
_global_forward_pre_hooks,
_global_forward_hooks,
)
try:
from torch.nn.modules.module import _global_backward_pre_hooks
except ImportError:
_global_backward_pre_hooks = None
from torchvision import datasets, transforms
from aimet_torch.common.utils import (
deprecated,
AimetLogger,
Handle,
profile as _profile,
)
from aimet_torch.common.quantsim import _get_minimum_scale
from aimet_torch.quantization._utils import interleave, concretize_block_size
logger = AimetLogger.get_area_logger(AimetLogger.LogAreas.Utils)
dtypes_to_ignore_for_quantization = (int, bool, str, tuple, type(None))
torch_dtypes_to_ignore_for_quantization = [
torch.int,
torch.int8,
torch.int16,
torch.int32,
torch.int64,
torch.bool,
torch.uint8,
]
allowed_output_types = (torch.Tensor, float, *dtypes_to_ignore_for_quantization)
DROPOUT_TYPES = (torch.nn.Dropout, torch.nn.Dropout2d, torch.nn.Dropout3d)
# list of modules which need to be treated as a leaf module
modules_to_treat_as_leaf = []
# list of modules not to treat as leaf
modules_not_to_treat_as_leaf = [torch.nn.ModuleList, torch.nn.ModuleDict]
class StopForwardException(Exception):
"""
Dummy exception to early-terminate forward-pass
"""
class ModuleData:
"""
Collect input and output data to and from module
"""
def __init__(
self,
model: torch.nn.Module,
module: torch.nn.Module,
forward_fn: Callable[[torch.nn.Module, Any], Any] = None,
):
"""
:param model: Pytorch model
:param module: Module reference
:param forward_fn: Adapter function that performs forward pass given a model and inputs
yielded from the data loader.
"""
self._model = model
self._module = module
self._forward_fn = forward_fn or self.default_forward_fn
def collect_inp_out_data(
self, args, kwargs: Mapping[str, Any], collect_input: bool, collect_output: bool
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Collect input and output data depending on the collect_input and collect_output flag
:param model_input: Input to model, Can be a single tensor or a list/tuple of tensors
:param collect_input: Boolean to collect input or not
:param collect_output: Boolean to collect output or not
:return: Module's input and output data
"""
def adjust_input_dtype(module, inp):
if hasattr(module, "weight") and module.weight is not None:
dtype = module.weight.dtype
# Cast input to dtype only if it is a floating point tensor (float, half, bfloat16, etc.).
# If input is a non-float tensor (e.g. long, bool), leave the input uncasted.
return tree_map(
lambda x: (
x.to(dtype)
if isinstance(x, torch.Tensor) and x.is_floating_point()
else x
),
inp,
)
return inp
handles = [
mod.register_forward_pre_hook(adjust_input_dtype)
for mod in self._model.modules()
]
def _hook_to_collect_inp_out_data(_, inp, out):
"""
hook to collect input and output data
"""
if collect_input:
inp_data_list.append(inp[0])
if collect_output:
out_data_list.append(out)
raise StopForwardException
inp_data_list = []
out_data_list = []
handles.append(
self._module.register_forward_hook(_hook_to_collect_inp_out_data)
)
# get the model's device placement information
device = get_device(self._model)
# place the input to appropriate device
args = change_tensor_device_placement(args, device)
kwargs = change_tensor_device_placement(kwargs, device)
# Custom injected exception is raised when the activations data from desired module is collected.
try:
with in_eval_mode(self._model), torch.no_grad():
_ = self._forward_fn(self._model, *args, **kwargs)
except StopForwardException:
pass
finally:
# remove hook handle
for handle in handles:
handle.remove()
inp_data, out_data = None, None
if inp_data_list and isinstance(inp_data_list[0], torch.Tensor):
inp_data = inp_data_list[0].detach()
if out_data_list and isinstance(out_data_list[0], torch.Tensor):
out_data = out_data_list[0].detach()
return inp_data, out_data
@staticmethod
def default_forward_fn(
model: torch.nn.Module,
inputs: Union[torch.tensor, List[torch.Tensor], Tuple[torch.Tensor]],
):
"""
Default forward function that performs forward pass given a model and inputs yielded from
the data loader. Data loader which yields torch.Tensor object that can be directly
passed into the model, or a data loader which yields a tuple of length two where its
first element can be directly passed into the model.
:param model: PyTorch model.
:param inputs: Inputs passed to model.
"""
# When provided dataloader is labeled (model_inputs, labels), then ignore the second element (labels).
if isinstance(inputs, (list, tuple)):
inputs, _ = inputs
if isinstance(inputs, torch.Tensor):
inputs = [inputs]
model(*inputs)
class CachedDataset(Dataset):
"""
Cache number of batches from the data loader at given path location and
provide interface to fetch single batch of model inputs.
"""
# pylint: disable=super-init-not-called
def __init__(self, data_loader: DataLoader, num_batches: Optional[int], path: str):
"""
:param data_loader: Data loader
:param num_batches: Number of batches to fetch from data loader
:param path: Path to save model inputs
"""
if data_loader:
if num_batches is not None and len(data_loader) < num_batches:
raise ValueError(
f"Can not fetch {num_batches} batches from "
f"a data loader of length {len(data_loader)}."
)
self._num_batches = None
self._path = path
if num_batches is None:
self._cache_model_inputs(data_loader)
else:
self._cache_model_inputs(itertools.islice(data_loader, num_batches))
assert self._num_batches is not None
else:
assert len(os.listdir(path)) == num_batches
self._num_batches = num_batches
self._path = path
logger.info(
"Found %d batches of data at path location: %s",
self._num_batches,
self._path,
)
def __len__(self):
return self._num_batches
def __getitem__(self, index: int):
path = os.path.join(self._path, "model_inputs_" + str(index))
with open(path, "rb") as file:
batch = pickle.load(file)
return batch
def __iter__(self):
for i in range(self.__len__()):
yield self.__getitem__(i)
def _cache_model_inputs(self, data_loader):
"""
Function to cache number of batches individually in separate file at provided path location
"""
if not os.path.exists(self._path):
os.makedirs(self._path)
for i, batch in enumerate(data_loader):
path = os.path.join(self._path, f"model_inputs_{i}")
args = (batch,)
kwargs = {}
with open(path, "wb") as file:
pickle.dump((args, kwargs), file)
self._num_batches = i + 1
logger.info(
"Caching %d batches from data loader at path location: %s",
self._num_batches,
self._path,
)
def run_hook_for_layers(
model: torch.nn.Module,
input_shapes: Union[Tuple, List[Tuple]],
hook,
module_type_for_attaching_hook=None,
leaf_node_only=True,
):
"""
Register the given hook function for all layers in the model
:param model: Model
:param input_shapes: Shape of inputs to pass to the model
:param hook: Hook function to register
:param module_type_for_attaching_hook: Tuple of torch.nn module types for which hook has to be attached
:param leaf_node_only: Set to False if all modules are required
:return: None
"""
# ------------------------
# Register hook function
# ------------------------
hooks = []
# All leaf modules
modules = [
module
for module in model.modules()
if not leaf_node_only or is_leaf_module(module)
]
if module_type_for_attaching_hook:
# if needed, filter by module types specified by caller
modules = [
module
for module in modules
if isinstance(module, module_type_for_attaching_hook)
]
for module in modules:
hooks.append(module.register_forward_hook(hook))
# ------------------------------------------------
# Run forward pass to execute the hook functions
# ------------------------------------------------
device = get_device(model)
dummy_tensors = create_rand_tensors_given_shapes(input_shapes, device)
with in_eval_mode(model), torch.no_grad():
_ = model(*dummy_tensors)
# --------------------------
# Remove all hooks we added
# --------------------------
for h in hooks:
h.remove()
def run_hook_for_layers_with_given_input(
model: torch.nn.Module,
input_tensor: Union[torch.Tensor, Tuple],
hook,
module_type_for_attaching_hook=None,
leaf_node_only=True,
fwd_func=None,
):
"""
Register the given hook function for all layers in the model
:param model: Model
:param input_tensor: Input tensor to the model. If more than one model inputs, use a tuple
:param hook: Hook function to register
:param module_type_for_attaching_hook: Tuple of torch.nn module types for which hook has to be attached
:param leaf_node_only: Set to False if all modules are required
:param fwd_func: forward function for model inference
:return: None
"""
# pylint: disable=too-many-branches
# ------------------------
# Register hook function
# ------------------------
hooks = []
# All leaf modules
modules = []
# Based on the modules in modules_to_treat_as_leaf, we do not want to further continue searching for next level
# of modules present in modules_to_treat_as_leaf. To achieve this, save them in modules_to_skip
modules_to_skip = set()
for module in model.modules():
if module not in modules_to_skip:
# pylint: disable=protected-access
if isinstance(module, tuple(modules_to_treat_as_leaf)):
modules.append(module)
# check for modules inside the 'module' and add them to modules_to_skip
for sub_module in module._modules.values():
modules_to_skip.add(sub_module)
else:
if leaf_node_only:
if is_leaf_module(module):
modules.append(module)
else:
modules.append(module)
if module_type_for_attaching_hook:
# if needed, filter by module types specified by caller
modules = [
module
for module in modules
if isinstance(module, module_type_for_attaching_hook)
]
try:
for module in modules:
hooks.append(module.register_forward_hook(hook))
# ------------------------------------------------
# Run forward pass to execute the hook functions
# ------------------------------------------------
with in_eval_mode(model), torch.no_grad():
if fwd_func:
_ = fwd_func(model, input_tensor)
else:
if isinstance(input_tensor, (list, tuple)):
_ = model(*input_tensor)
elif isinstance(input_tensor, dict):
try:
_ = model(**input_tensor)
except TypeError:
# Some models require inputs as dict.
# https://github.com/pytorch/vision/blob/ef2920cc80bac61282b3b19a775b3c33de4e7551/torchvision/ops/feature_pyramid_network.py#L172
_ = model(input_tensor)
else:
_ = model(input_tensor)
finally:
# --------------------------
# Remove all hooks we added
# --------------------------
for h in hooks:
h.remove()
def create_fake_data_loader(dataset_size: int, batch_size: int, image_size=(1, 28, 28)):
"""
Helper function to create fake data loader which is default image size (1, 28, 28)
:param dataset_size : total images in data set
:param batch_size : batch size
:param image_size : size of input
:return:
"""
transform = transforms.Compose([transforms.ToTensor()])
data_loader = torch.utils.data.DataLoader(
datasets.FakeData(
size=dataset_size,
image_size=image_size,
num_classes=10,
transform=transform,
target_transform=None,
),
batch_size=batch_size,
shuffle=False,
)
return data_loader
def get_module_to_name_dict(
model: torch.nn.Module, prefix: str = ""
) -> Dict[torch.nn.Module, str]:
"""
Get a dictionary mapping model modules to names
:param model: Model to get mapping for
:param prefix: Prefix string to prepend to names
:return: Dictionary mapping model modules to names
"""
module_to_name_dict = {}
for name, module in model.named_modules(prefix=prefix):
module_to_name_dict[module] = name
return module_to_name_dict
def get_layer_name(model, layer):
"""
Helper function to get layer name given model and layer reference
:param model: model (nn.Module)
:param layer: layer reference
:return:
"""
for name, module in model.named_modules():
if module is layer:
return name
raise KeyError(f"Couldn't find layer {layer} from model {model}")
def is_model_on_gpu(model):
"""
Function to check whether given model is created on GPU or CPU
Assumption : model is on single device
:return:
True if the model is on GPU, False if on CPU
"""
return next(model.parameters()).is_cuda
def get_device(model):
"""
Function to find which device is model on
Assumption : model is on single device
:param model:
:return: Device on which model is present
"""
return next(model.parameters()).device
def is_leaf_module(module):
"""Utility function to determine if the given module is a leaf module - that is, does not have children modules
:return:
True if the module is a leaf, False otherwise
"""
# pylint: disable=import-outside-toplevel
from aimet_torch._base.nn.modules._spconv import CustomSparseConv3DLayer
try:
_ = next(module.children())
except StopIteration:
has_child = False
else:
has_child = True
return (
not has_child
or type(module) in modules_to_treat_as_leaf
or (
CustomSparseConv3DLayer is not None
and isinstance(module, CustomSparseConv3DLayer)
)
) and not isinstance(module, tuple(modules_not_to_treat_as_leaf))
def has_hooks(module: torch.nn.Module):
"""Returns True if the module uses hooks."""
# pylint: disable=protected-access
return (
module._backward_hooks
or module._backward_pre_hooks
or module._forward_hooks
or module._forward_pre_hooks
or _global_backward_pre_hooks
or _global_backward_hooks
or _global_forward_hooks
or _global_forward_pre_hooks
)
def get_ordered_list_of_modules(
model: torch.nn.Module,
dummy_input: Union[torch.Tensor, List[torch.Tensor], Tuple],
fwd_func=None,
ignore_duplicates=False,
) -> List:
"""
Finds ordered modules in given model.
:param model: PyTorch model.
:param dummy_input: Dummy input to the model. Used to parse model graph.
:param fwd_func: forward function for model inference
:param ignore_duplicates: If True, don't add a module to ordered_list again if it was already seen before.
:return: List of module name, module in order.
"""
seen_modules = set()
def _hook_to_collect_name_of_module(module, _, __):
"""
hook to find name of module
"""
module_name = module_to_name_dict[module]
if module_name in seen_modules and ignore_duplicates:
return
list_modules.append([module_name, module])
seen_modules.add(module_name)
module_to_name_dict = {}
for name, module in model.named_modules():
module_to_name_dict[module] = name
list_modules = []
run_hook_for_layers_with_given_input(
model, dummy_input, hook=_hook_to_collect_name_of_module, fwd_func=fwd_func
)
return list_modules
def replace_modules(
model: torch.nn.Module,
condition: Callable[[torch.nn.Module], bool],
factory: Callable[[torch.nn.Module], torch.nn.Module],
):
"""
Replace all modules that satisfy the given condition
"""
def fn(parent):
for name, child in parent.named_children():
if condition(child):
setattr(parent, name, factory(child))
model.apply(fn)
def create_rand_tensors_given_shapes(input_shape, device: torch.device):
"""
Given shapes of some tensors, create one or more random tensors and return them as a list of tensors
:param input_shape: Shapes of tensors to create (possibly nested) tuple of integers
:param device: Device to create tensors on
:return: Created list of tensors
"""
try:
input_shapes = [torch.Size(input_shape)]
except TypeError:
input_shapes = input_shape
rand_tensors = []
for shape in input_shapes:
try:
t = torch.rand(torch.Size(shape), device=device)
except TypeError:
t = create_rand_tensors_given_shapes(shape, device)
rand_tensors.append(t)
return rand_tensors
def get_ordered_lists_of_conv_fc(
model: torch.nn.Module, dummy_input: Union[torch.Tensor, Tuple, List]
) -> List:
"""
Finds order of nodes in graph
:param model: model
:param dummy_input: A dummy input to the model. Can be a Tensor or a Tuple of Tensors
:return: List of names in graph in order
"""
module_list = get_ordered_list_of_modules(model, dummy_input)
module_list = [
[name, module]
for name, module in module_list
if isinstance(
module,
(
torch.nn.Conv1d,
torch.nn.Conv2d,
torch.nn.Linear,
torch.nn.ConvTranspose2d,
torch.nn.Conv3d,
),
)
]
return module_list
def change_tensor_device_placement(input_data, device: torch.device):
"""
Change the tensor_data's device placement
:param input_data: torch.tensor , list of torch.tensors, or tuple of torch.tensors
:param device: device
:return: tensor_data with modified device placement
"""
return tree_map(
lambda x: x.to(device) if isinstance(x, torch.Tensor) else x, input_data
)
def nested_map(data, fn: Callable[[torch.Tensor], torch.Tensor]):
"""
Apply a function to a nested tuple, list, or dict of tensors.
:param data: Tensor, or a nested tuple, list, or dict of tensors.
:param fn: Function to apply to the tensors
:return: Nested structure of tensors with function applied
"""
if isinstance(data, torch.Tensor):
return fn(data)
if isinstance(data, (tuple, list)):
cls = tuple if isinstance(data, tuple) else list
return cls(nested_map(x, fn) for x in data)
if isinstance(data, dict):
return {key: nested_map(value, fn) for key, value in data.items()}
logger.debug(
"unexpected input type=%s, expecting torch.Tensor, tuple, list, or dict. skipping..",
type(data),
)
return data
def find_num_inout_tensors_per_module(model: torch.nn.Module, input_tensor) -> Dict:
"""
Returns a map of module -> number of output tensors, for all the children modules of the
provided module
:param model: Torch module to find children modules for
:param input_tensor: Input tensor to use to run forward pass for the model. If model needs more than one input
tensor, pass a tuple
:return: map of module -> number of output tensors
"""
num_inout_map = {}
def record_num_outputs(module, inputs, outputs):
num_inputs = len(inputs) if isinstance(inputs, (List, Tuple)) else 1
num_outputs = len(outputs) if isinstance(outputs, (List, Tuple)) else 1
num_inout_map[module] = (num_inputs, num_outputs)
run_hook_for_layers_with_given_input(model, input_tensor, record_num_outputs)
return num_inout_map
def get_reused_modules(
model: torch.nn.Module, model_input: Union[torch.Tensor, Tuple]
) -> List[Tuple[str, torch.nn.Module]]:
"""
Identify modules which are used more than once in the model
:param model: Model to check for modules used more than once
:param model_input: Input to the model
:return: List of tuples of name and module for modules in the model which are used more than once
"""
module_set = set()
reused_modules_set = set()
def forward_hook(curr_module, _, _1):
"""
Custom forward hook function to add modules to module_set and reused_module_set.
:param curr_module: Current module being traversed during forward pass.
:param _1: Unused param
"""
if curr_module in module_set:
reused_modules_set.add(curr_module)
else:
module_set.add(curr_module)
run_hook_for_layers_with_given_input(model, model_input, forward_hook)
reused_modules_list = []
for name, module in model.named_modules():
if is_leaf_module(module) and module in reused_modules_set:
reused_modules_list.append((name, module))
return reused_modules_list
@contextlib.contextmanager
def in_eval_mode(module: Union[torch.nn.Module, Iterable[torch.nn.Module]]):
"""
Utility to temporarily put model in eval mode using context manager.
:param module: PyTorch module or a list of modules
:return: None
"""
with _in_mode(module, train=False):
yield
@contextlib.contextmanager
def in_train_mode(module: Union[torch.nn.Module, Iterable[torch.nn.Module]]):
"""
Utility to temporarily put model in train mode using context manager.
:param module: PyTorch module or a list of modules
:return: None
"""
with _in_mode(module, train=True):
yield
@contextlib.contextmanager
def _in_mode(modules: Union[torch.nn.Module, Iterable[torch.nn.Module]], train: bool):
if isinstance(modules, torch.nn.Module):
modules = (modules,)
modules = set(itertools.chain(*(m.modules() for m in modules)))
original_modes = {module: module.training for module in modules}
try:
for module in modules:
module.training = train
yield
finally:
for module, original_mode in original_modes.items():
module.training = original_mode
def is_torch_nn_module(module: torch.nn.Module) -> bool:
"""
Utility function to determine if the given module is from torch.nn class or not.
For modules like torch.nn.Conv2d, the utility will return True.
:param module: PyTorch module.
:return: True if the module from torch.nn class, False otherwise
"""
return (
isinstance(module, torch.nn.Module)
and type(module) in torch.nn.__dict__.values()
)
def is_torch_nn_leaf_module(module: torch.nn.Module) -> bool:
"""
Utility function to determine if the given module is leaf and from torch.nn class or not.
:param module: PyTorch module.
:return: True if the module is leaf and from torch.nn class, False otherwise
"""
torch_nn_leaf_module = False
if is_leaf_module(module) and is_torch_nn_module(module):
torch_nn_leaf_module = True
return torch_nn_leaf_module
def get_torch_tensortype_shape(
torch_graph_output: torch._C.TensorType,
) -> Union[None, List[int]]:
"""
Given an output tensor from a torch graph, return its shape, or return None if the output tensor is not a
tensortype.
"""
# pylint: disable=protected-access
shape = None
if isinstance(torch_graph_output.type(), torch._C.TensorType):
shape = torch_graph_output.type().sizes()
return shape
def get_all_quantizers(model: torch.nn.Module):
"""
Get all the quantizers in the model
:param model: Root module
:returns: List of parameter, input, and output quantizers
"""
param_quantizers = []
input_quantizers = []
output_quantizers = []
for module in model.modules():
_param_qtzrs = getattr(module, "param_quantizers", {}).values()
_input_qtzrs = getattr(module, "input_quantizers", [])
_output_qtzrs = getattr(module, "output_quantizers", [])
if _param_qtzrs:
param_quantizers.extend(_param_qtzrs)
if _input_qtzrs:
input_quantizers.extend(
_input_qtzrs.values()
if isinstance(_input_qtzrs, dict)
else _input_qtzrs
)
if _output_qtzrs:
output_quantizers.extend(
_output_qtzrs.values()
if isinstance(_output_qtzrs, dict)
else _output_qtzrs
)
return param_quantizers, input_quantizers, output_quantizers
def disable_all_quantizers(model: torch.nn.Module):
"""
Temporarily disable all quantizers in the model within with-as block, or permanently disable
without employing context manager.
:param model: Root module
:returns: Handle that enable all quantizers in the model upon handle.remove().
"""
# pylint: disable=import-outside-toplevel, cyclic-import
from aimet_torch.nn.base import BaseQuantizationMixin
if any(isinstance(m, BaseQuantizationMixin) for m in model.modules()):
return remove_all_quantizers(model)
param_quantizers, input_quantizers, output_quantizers = get_all_quantizers(model)
all_quantizers = param_quantizers + input_quantizers + output_quantizers
active_quantizers = set(
quantizer for quantizer in all_quantizers if quantizer.enabled
)
def cleanup():
for quantizer in active_quantizers:
quantizer.enabled = True
try:
for quantizer in active_quantizers:
quantizer.enabled = False
return Handle(cleanup)
except:
cleanup()
raise
def save_to_cache(tensor, dir_path, idx):
"""
Save tensor data into provided path with index
:param tensor: Tensor
:param dir_path: Provided path to save data
:param idx: Index of the file
"""
if not os.path.exists(dir_path):
os.makedirs(dir_path)
path = os.path.join(dir_path, f"model_inputs_{idx}")
with open(path, "wb") as cache:
pickle.dump(tensor, cache)
def cache_intermediate_datasets(
cached_dataset,
cache_on_cpu,
model,
module_name,
forward_fn,
path=None,
incl_kwargs: bool = False,
):
"""
Cache the input tensor of the target module and save to CPU or disk for latter usage
:param cached_dataset: Cached dataset
:param cache_on_cpu: True if caching data on CPU, False if caching to disk
:param model: Model that contains the target module
:param module_name: Name of the target module
:param forward_fn: Forward function that performs forward pass given a model and inputs
:param path: Location to save cached data if caching to dick
:param incl_kwargs: if True, capture kwargs, normalize and attach to inputs.
:return: Cached data on CPU
"""
# pylint: disable=cell-var-from-loop, too-many-locals, missing-class-docstring, missing-function-docstring
cached_data = []
*parent_name, child_name = module_name.split(".")
parent = model.get_submodule(".".join(parent_name))
orig_child = getattr(parent, child_name)
class CachingHelper(torch.nn.Module):
def forward(self, *args, **kwargs):
if not incl_kwargs:
kwargs = {}
if cache_on_cpu:
cached_data.append(
change_tensor_device_placement((args, kwargs), torch.device("cpu"))
)
else:
save_to_cache((args, kwargs), path, idx)
raise StopForwardException
caching_helper = CachingHelper()
try:
setattr(parent, child_name, caching_helper)
iterator = iter(cached_dataset)
for idx in range(len(cached_dataset)):
args, kwargs = next(iterator)
try:
with in_eval_mode(model), torch.no_grad():
_ = forward_fn(model, *args, **kwargs)
except StopForwardException:
pass
return cached_data
finally:
setattr(parent, child_name, orig_child)
def profile(
label: str,
file: Union[str, os.PathLike, TextIO] = None,
new_file: bool = False,
logger: Optional[logging.Logger] = None, # pylint: disable=redefined-outer-name
):
"""
Profile a block of code and save profiling information into a file.
:param label: String label associated with the block of code to profile (shows up in the profiling print)
:param file: File path and name or a file-like object to send output text to (Default: stdout)
:param new_file: True if a new file is to be created to hold profiling info, False if an existing file should be
appended to. This flag is only valid when ``file`` is a path, not a file-like object.
:param logger: If logger is provided, profiling string will also be printed with INFO logging level
"""
if not torch.cuda.is_available():
return profile_async(label, file, new_file, logger)
ctx = _profile(label, file, new_file, logger, cleanup=torch.cuda.synchronize)
return _ContextManager(
action=ctx.__enter__, cleanup=lambda: ctx.__exit__(None, None, None)
) # pylint: disable=no-member
def profile_async(
label: str,
file: Union[str, os.PathLike, TextIO] = None,
new_file: bool = False,
logger: Optional[logging.Logger] = None, # pylint: disable=redefined-outer-name
):
"""
Profile a block of code and save profiling information into a file.
:param label: String label associated with the block of code to profile (shows up in the profiling print)
:param file: File path and name or a file-like object to send output text to (Default: stdout)
:param new_file: True if a new file is to be created to hold profiling info, False if an existing file should be
appended to. This flag is only valid when ``file`` is a path, not a file-like object.
:param logger: If logger is provided, profiling string will also be printed with INFO logging level
"""
ctx = _profile(label, file, new_file, logger, cleanup=None)
return _ContextManager(
action=ctx.__enter__, cleanup=lambda: ctx.__exit__(None, None, None)
) # pylint: disable=no-member
def is_vector_encoding(encoding: Optional[List[Dict]]) -> bool:
"""
Check if encoding is from vector quantization
:param encoding: List of encoding dictionaries
:return: True if all required vector quantization properties are included in encoding
"""
if encoding is None:
return False
required_properties = (
"rows_per_block",
"cols_per_block",
"vector_dim",
"vector_stride",
"index_bw",
)
return all((property_ in encoding[0] for property_ in required_properties))
def get_all_named_parameters(model: torch.nn.Module):
"""
Yields all (name, parameter) pairs in model including redundant parameters.
:param model: torch.nn.Module from which to retrieve parameters
"""
for name, module in model.named_modules(remove_duplicate=False):
for param_name, parameter in module.named_parameters(recurse=False):
if name:
yield name + "." + param_name, parameter
else:
# Don't prepend . if module name is "" (Parameter owned by base model)
yield param_name, parameter
@contextlib.contextmanager
def place_model(model: torch.nn.Module, device: torch.device):
"""
Temporarily place model on given device
"""
original_device = get_device(model)
try:
model.to(device=device)
yield
finally:
model.to(device=original_device)
def get_param_channel_axis(module: torch.nn.Module, param_name: str):
"""
Given a module and its param name, this method returns the channel axis of the given parameter.
:param module: torch.nn.Module
:param param_name: str representing the name of the parameter
"""
channel_axis = 0
if isinstance(
module,
(torch.nn.ConvTranspose1d, torch.nn.ConvTranspose2d, torch.nn.ConvTranspose3d),
):
channel_axis = 1 if param_name == "weight" else 0
return channel_axis
def _is_expandable(src_shape: Tuple[int, ...], target_shape: Tuple[int, ...]) -> bool:
"""
Returns true if source shape can be expanded as target shape
"""
if len(src_shape) > len(target_shape):
return False
for src_dim, dst_dim in zip(src_shape[::-1], target_shape[::-1]):
if src_dim not in (1, dst_dim):
return False
return True
def _is_reducible(src_shape: Tuple[int, ...], target_shape: Tuple[int, ...]) -> bool:
"""
Returns true if source shape can be reduced as target shape
"""
return _is_expandable(target_shape, src_shape) # pylint: disable=arguments-out-of-order
def reduce(
input: torch.Tensor,
shape: tuple[int, ...],
reduce_op: Callable,
block_size: tuple[int, ...] | None = None,
):
"""
Reduce input into given shape.
:param input: Input to reduce
:param shape: Shape of the reduced output
:param reduce_op: Reduce operation
:param block_size: Block size for block-wise reduction
"""
output_shape = shape
if block_size is not None:
block_size = concretize_block_size(input.shape, shape, block_size)
input = input.reshape(-1, *interleave(shape, block_size))
shape = interleave(shape, 1)
if not _is_reducible(input.shape, shape):
raise RuntimeError(
f"Input of shape {list(input.shape)} can't be reduced to shape {list(shape)}"
)
padded_shape = (*itertools.repeat(1, len(input.shape) - len(shape)), *shape)
reduce_dims = tuple(axis for axis, dim in enumerate(padded_shape) if dim == 1)
other_dims = tuple(axis for axis, dim in enumerate(padded_shape) if dim > 1)
permute_dims = reduce_dims + other_dims
return reduce_op(
input.permute(permute_dims).reshape(-1, *output_shape), dim=0, keepdim=False
)
class _ContextManager:
def __init__(self, action: Callable[[], Any], cleanup: Callable[[], Any]):
self._action = action
self._cleanup = cleanup
def __enter__(self):
self._action()
return self
def __exit__(self, *_):
self._cleanup()
def __call__(self, fn: Callable):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
with self:
return fn(*args, **kwargs)
return wrapper
class _NullAttribute:
pass
def patch_attr(obj, attr_name, new_attr) -> _ContextManager:
"""
Temporarily overwrite object attribute
"""
if isinstance(obj, torch.nn.Module):
if attr_name in obj._parameters or attr_name in obj._buffers: # pylint: disable=protected-access
return _patch_param_or_buffer(obj, attr_name, new_attr)
if hasattr(obj, attr_name):
old_attr = getattr(obj, attr_name)
else:
old_attr = _NullAttribute()
action = lambda: setattr(obj, attr_name, new_attr)
def cleanup():
try:
delattr(obj, attr_name)
except AttributeError:
pass
if not hasattr(obj, attr_name) and not isinstance(old_attr, _NullAttribute):
setattr(obj, attr_name, old_attr)
return _ContextManager(action, cleanup)
def _patch_param_or_buffer(
module: torch.nn.Module,
param_or_buffer_name: str,
new_param_or_buffer: torch.Tensor,
):
"""
Temporarily substitute the reference to the a parameter with the quantized parameter.
Under the scope of this function, ``getattr(module, param_or_buffer_name)`` will return
``new_param_or_buffer`` instead of the original parameter.
:param module: Module that owns the parameter
:param param_or_buffer_name: Name of the parameter
:param new_param_or_buffer: New parameter to replace the original parameter
"""
# pylint: disable=protected-access
orig_param_or_buffer = getattr(module, param_or_buffer_name)
if orig_param_or_buffer is not None:
assert new_param_or_buffer.shape == orig_param_or_buffer.shape
if param_or_buffer_name in module._parameters:
container = module._parameters
elif param_or_buffer_name in module._buffers:
container = module._buffers
elif param_or_buffer_name in module.__dict__:
# Some non-standard modules (e.g. replicas of torch.nn.DataParallel) store their parameters
container = module.__dict__
else:
raise RuntimeError(
f"'{param_or_buffer_name}' is not a valid name of parameter of buffer of {type(module)}."
)
action = lambda: container.update({param_or_buffer_name: new_param_or_buffer})
cleanup = lambda: container.update({param_or_buffer_name: orig_param_or_buffer})
return _ContextManager(action, cleanup)
class _StraightThroughEstimator(torch.autograd.Function): # pylint: disable=abstract-method
@staticmethod
def forward(ctx, op, *args, **kwargs): # pylint:disable=arguments-differ, unused-argument
return op(*args, **kwargs)
@staticmethod
def backward(ctx, *grad):
return (None, *grad)
def ste_round(*args, **kwargs):
"""
Applies straight-through rounding
"""
return _StraightThroughEstimator.apply(torch.round, *args, **kwargs)
class StatisticsNotFoundError(RuntimeError):
"""
Error raised when compute_encodings() is invoked without statistics
"""
_ENABLE_RECOMPUTE = False
def _set_enable_recompute(mode: bool):
original_mode = _ENABLE_RECOMPUTE
def action():
global _ENABLE_RECOMPUTE # pylint: disable=global-statement
_ENABLE_RECOMPUTE = mode
def cleanup():
global _ENABLE_RECOMPUTE # pylint: disable=global-statement
_ENABLE_RECOMPUTE = original_mode
return _ContextManager(action, cleanup)
def is_recompute_enabled():
"""
Returns True if recomputation for memory saving is enabled; False otherwise.
"""
return _ENABLE_RECOMPUTE
@deprecated(
"Use PyTorch native API (torch.utils.checkpoint) instead.",
deletion_planned="2.38.0",
)
def enable_recompute():
"""
Enable recomputation for memory saving.
"""
return _set_enable_recompute(True)
def no_recompute():
"""
Disable recomputation for memory saving.
"""
return _set_enable_recompute(False)
def allow_recompute(fn):
"""
Allow recomputation of activation of the given function during training
if recompute is enabled.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if is_recompute_enabled():
# Enable activation recompute (a.k.a. activataion checkpointing)
# to reduce memory footprint of training
return torch.utils.checkpoint.checkpoint(
fn, *args, use_reentrant=False, **kwargs
)
return fn(*args, **kwargs)
return wrapper
def flatten_nn_module_list(module):
"""
Flatten nested list of nn.Modules into a flat list
"""
def flat_iter(mod):
if isinstance(mod, (list, tuple, torch.nn.ModuleList)):
for x in mod:
yield from flat_iter(x)
else:
yield mod
return list(flat_iter(module))
def _map_qmodule(modules, func):
# pylint: disable=import-outside-toplevel
# pylint: disable=protected-access, cyclic-import
from aimet_torch.nn import BaseQuantizationMixin
contexts = []
ctx = _ContextManager(
action=lambda: None,
cleanup=lambda: [context._cleanup() for context in contexts],
)
if isinstance(modules, torch.nn.Module):
modules = [modules]
try:
for module_elem in modules:
for module in module_elem.modules():
if isinstance(module, BaseQuantizationMixin):
context = func(module)
contexts.append(context)
except Exception:
ctx._cleanup()
raise
return ctx
[docs]
def remove_output_quantizers(modules):
"""
Temporarily remove all output quantizers
Example:
>>> print(sim.model)
Sequential(
(0): QuantizedConv2d(
3, 3, kernel_size=(3, 3), stride=(1, 1)
(param_quantizers): ModuleDict(
(weight): QuantizeDequantize(shape=(3, 1, 1, 1), qmin=-128, qmax=127, symmetric=True)
(bias): None
)
(input_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
(output_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
)
)
>>> with remove_output_quantizers(sim.model):
... print(sim.model)
...
Sequential(
(0): QuantizedConv2d(
3, 3, kernel_size=(3, 3), stride=(1, 1)
(param_quantizers): ModuleDict(
(weight): QuantizeDequantize(shape=(3, 1, 1, 1), qmin=-128, qmax=127, symmetric=True)
(bias): None
)
(input_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
(output_quantizers): ModuleList(
(0): None
)
)
)
"""
# pylint: disable=protected-access
return _map_qmodule(modules, lambda qmodule: qmodule._remove_output_quantizers())
[docs]
def remove_param_quantizers(modules):
"""
Temporarily remove all parameter quantizers
Example:
>>> print(sim.model)
Sequential(
(0): QuantizedConv2d(
3, 3, kernel_size=(3, 3), stride=(1, 1)
(param_quantizers): ModuleDict(
(weight): QuantizeDequantize(shape=(3, 1, 1, 1), qmin=-128, qmax=127, symmetric=True)
(bias): None
)
(input_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
(output_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
)
)
>>> with remove_param_quantizers(sim.model):
... print(sim.model)
...
Sequential(
(0): QuantizedConv2d(
3, 3, kernel_size=(3, 3), stride=(1, 1)
(param_quantizers): ModuleDict(
(weight): None
(bias): None
)
(input_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
(output_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
)
)
"""
# pylint: disable=protected-access
return _map_qmodule(modules, lambda qmodule: qmodule._remove_param_quantizers())
[docs]
def remove_activation_quantizers(modules):
"""
Temporarily remove all input and output quantizers
Example:
>>> print(sim.model)
Sequential(
(0): QuantizedConv2d(
3, 3, kernel_size=(3, 3), stride=(1, 1)
(param_quantizers): ModuleDict(
(weight): QuantizeDequantize(shape=(3, 1, 1, 1), qmin=-128, qmax=127, symmetric=True)
(bias): None
)
(input_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
(output_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
)
)
>>> with remove_activation_quantizers(sim.model):
... print(sim.model)
...
Sequential(
(0): QuantizedConv2d(
3, 3, kernel_size=(3, 3), stride=(1, 1)
(param_quantizers): ModuleDict(
(weight): QuantizeDequantize(shape=(3, 1, 1, 1), qmin=-128, qmax=127, symmetric=True)
(bias): None
)
(input_quantizers): ModuleList(
(0): None
)
(output_quantizers): ModuleList(
(0): None
)
)
)
"""
if not isinstance(modules, torch.nn.Module):
# Shallow copy in case modules is an iterator
modules = list(modules)
context_1 = remove_input_quantizers(modules)
context_2 = remove_output_quantizers(modules)
# pylint: disable=protected-access
return _ContextManager(
action=lambda: None,
cleanup=lambda: (context_1._cleanup(), context_2._cleanup()),
)
[docs]
def remove_all_quantizers(modules):
"""
Temporarily remove all quantizers
Example:
>>> print(sim.model)
Sequential(
(0): QuantizedConv2d(
3, 3, kernel_size=(3, 3), stride=(1, 1)
(param_quantizers): ModuleDict(
(weight): QuantizeDequantize(shape=(3, 1, 1, 1), qmin=-128, qmax=127, symmetric=True)
(bias): None
)
(input_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
(output_quantizers): ModuleList(
(0): QuantizeDequantize(shape=(), qmin=0, qmax=255, symmetric=False)
)
)
)
>>> with remove_all_quantizers(sim.model):
... print(sim.model)
...
Sequential(
(0): QuantizedConv2d(
3, 3, kernel_size=(3, 3), stride=(1, 1)
(param_quantizers): ModuleDict(
(weight): None
(bias): None
)
(input_quantizers): ModuleList(
(0): None
)
(output_quantizers): ModuleList(
(0): None
)
)
)
"""
if not isinstance(modules, torch.nn.Module):
# Shallow copy in case modules is an iterator
modules = list(modules)
context_1 = remove_activation_quantizers(modules)
context_2 = remove_param_quantizers(modules)
# pylint: disable=protected-access
return _ContextManager(
action=lambda: None,
cleanup=lambda: (context_1._cleanup(), context_2._cleanup()),
)
def has_no_quantizers(module, ignore_params: bool = False) -> bool:
"""
Helper function to check if a module has any quantizers enabled
"""
return (
all(inp_qtzr is None for inp_qtzr in module.input_quantizers)
and all(out_qtzr is None for out_qtzr in module.output_quantizers)
and (
ignore_params
or all(
param_qtzr is None for param_qtzr in module.param_quantizers.values()
)
)
)
def rgetattr(obj, attr):
"""Drop in replacement for __getattr__ that can handle dotted attribute strings"""
return functools.reduce(getattr, [obj] + attr.split("."))
def rsetattr(obj, attr, val):
"""Drop in replacement for __setattr__ that can handle dotted attribute strings"""
pre, _, post = attr.rpartition(".")
pre_obj = rgetattr(obj, pre) if pre else obj
return setattr(pre_obj, post, val)
def apply_fn_recursively_to_all_elems(fn, container):
"""Apply fn to all elements in recursively composed container"""
if container is None:
return None
if isinstance(container, (list, tuple)):
return [apply_fn_recursively_to_all_elems(fn, elem) for elem in container]
if isinstance(container, dict):
return {
key: apply_fn_recursively_to_all_elems(fn, elem)
for key, elem in container.items()
}
return fn(container)
def flatten_list(container):
"""Helper function to flatten nested list/tuple into 1D"""
if not container:
return container
if not isinstance(container, (list, tuple)):
return [container]
if isinstance(container[0], (list, tuple)):
return flatten_list(container[0]) + flatten_list(container[1:])
if len(container) == 1:
return container
return container[:1] + flatten_list(container[1:])
def default_forward_fn(model, inputs):
"""
Default forward function.
:param model: pytorch model
:param inputs: model inputs
"""
if isinstance(inputs, torch.Tensor):
inputs = [inputs]
return model(*inputs)
_torch_compiler_is_compiling: Callable[[], bool]
_torch_compiler_is_dynamo_compiling: Callable[[], bool]
_torch_compiler_is_exporting: Callable[[], bool]
if version.parse(torch.__version__) >= version.parse("2.7"):
_torch_compiler_is_compiling = torch.compiler.is_compiling
_torch_compiler_is_dynamo_compiling = torch.compiler.is_dynamo_compiling
_torch_compiler_is_exporting = torch.compiler.is_exporting
else:
# torch < 2.7.0 doesn't have torch.compiler.is_compiling/exporting API
def _torch_compiler_is_compiling() -> bool:
return False
def _torch_compiler_is_dynamo_compiling() -> bool:
return False
def _torch_compiler_is_exporting() -> bool:
return False
_qtensor_enabled = True
@contextmanager
def _enable_qtensor_casting(enable: bool):
global _qtensor_enabled # pylint: disable=global-statement
original_value = _qtensor_enabled
try:
_qtensor_enabled = enable
yield
finally:
_qtensor_enabled = original_value
def _is_qtensor_casting_enabled():
return _qtensor_enabled
@contextmanager
def _inference_mode(model: torch.nn.Module, prequantize_parameters: bool):
# pylint: disable=protected-access
from .quantsim import QuantizationSimModel
from .quantization.tensor import DequantizedTensor
from .quantization.base import QuantizerBase
with ExitStack() as stack:
for q in model.modules():
if isinstance(q, QuantizerBase):
dtype = next(
p.dtype for p in itertools.chain(q.parameters(), q.buffers())
)
stack.enter_context(q._precompute_encodings(dtype=dtype))
stack.enter_context(_enable_qtensor_casting(False))
if prequantize_parameters:
stack.enter_context(
QuantizationSimModel._apply_qdq_to_model_parameters(model)
)
stack.enter_context(remove_param_quantizers(model))
def cast_param_to_plain_tensor(module):
for name, param in module.named_parameters(recurse=False):
if isinstance(param, DequantizedTensor):
stack.enter_context(
patch_attr(module, name, param.as_subclass(torch.Tensor))
)
model.apply(cast_param_to_plain_tensor)
yield
class _DecompositionError(RuntimeError):
pass
# Half-ULP relative rounding error of each floating-point storage format
# (mantissa bits: bf16=7, fp16=10). fp32-native data has no meaningful storage
# rounding; a tiny floor keeps the on-grid tolerance well defined.
_ULP_REL_BF16 = 2.0**-8
_ULP_REL_FP16 = 2.0**-11
_ULP_REL_FP32 = 2.0**-20
# Per-element on-grid test: dev <= _ULP_SAFETY * ulp_rel * |x / scale|, where
# |x / scale| is the true stored magnitude (|code| on the dense grid, |code +
# 0.5| on the shifted grid). A genuine pre-quantized value lands on the grid to
# within storage noise, whose bound scales with that magnitude, so no absolute
# near-zero floor is needed. The dense grid's code-0 element is exactly 0 and is
# excluded from the test anyway; the shifted grid's code-0 element is 0.5 * scale, so
# scaling the tolerance by |code + 0.5| (not |code|) is what keeps it on-grid.
# _ULP_SAFETY absorbs round-to-nearest plus any upstream arithmetic before the
# value was stored in its narrow float format.
_ULP_SAFETY = 3.0
# Candidate-divisor gating: reject divisors clearly worse than the per-block best
# off-grid fraction, then among survivors take the smallest (coarsest grid) whose
# mean on-grid deviation is within _FIT_MARGIN of the best.
_VALID_MARGIN = 0.005
_FIT_MARGIN = 0.5
# Absolute cap on the winning divisor's off-grid fraction. The relative margin
# above only compares candidates against each other; for a genuinely unquantized
# (continuous) block every candidate scores a large, similar off-grid fraction,
# so the "best of a bad lot" can still pass the relative test. A true quantized
# block's winning divisor lands at ~0 off-grid (bounded by storage noise only),
# so this ceiling is what separates the two.
_ABS_OFFGRID_CEIL = 0.05
# Tensor-level accept gates: fraction of blocks that must cleanly fit some grid,
# and the reconstruction relRMS ceiling (scaled off the measured noise floor).
_MIN_MATCHED_FRAC = 0.90
_RECON_RELRMS_MULT = 5.0
_RECON_RELRMS_FLOOR = 1e-3
# Number of least-squares scale-refinement iterations (see _refine_scale_ls).
_DECOMPOSE_LS_ITERS = 4
# Early-exit gate. A block that lies on a uniform grid has at most
# (qmax - qmin + 1) distinct magnitudes (its codes are integers in that range and
# share one scale), regardless of which divisor recovers the step. A continuous
# (non-quantized) block instead has close to one distinct magnitude per element.
# If most blocks show far more distinct magnitudes than the grid could produce,
# the tensor cannot be QDQ, so the divisor search is skipped. The slack factor
# leaves margin for storage rounding splitting a level into a few nearby values.
_EARLY_EXIT_LEVEL_SLACK = 4
_EARLY_EXIT_MIN_OVER_FRAC = 0.90
@torch.no_grad()
def _storage_ulp_rel(x: torch.Tensor) -> float:
"""Infer the half-ULP relative rounding error of the format ``x`` was
originally stored in.
If ``x`` is itself a narrow float (bf16/fp16), that format's rounding error
is returned directly. If ``x`` is fp32, the format it was upcast from is
read off the bit pattern: a bf16/fp16 value upcast to fp32 zero-pads the
mantissa, so the newly added low mantissa bits are exactly zero iff the value
truly came from that narrower format (bf16 zero-pads the low 16 bits, fp16
the low 13). A narrow-float origin is only credited when *every* nonzero
element carries the fingerprint (a true upcast makes it exact for all of
them); any weaker or mixed pattern falls back to the fp32 bound, i.e. the
tightest tolerance, so an ambiguous tensor is never granted the looser
narrow-float tolerance it cannot justify. bf16 is checked first because its
zero pattern is a superset of fp16's.
"""
if x.dtype == torch.bfloat16:
return _ULP_REL_BF16
if x.dtype == torch.float16:
return _ULP_REL_FP16
if x.dtype != torch.float32:
# Any other dtype (fp64, etc.): use the widest bound, which only loosens
# the on-grid tolerance and never wrongly tightens it.
return _ULP_REL_BF16
flat = x.reshape(-1)
nz = flat != 0
if not bool(nz.any()):
return _ULP_REL_FP32
bits = flat[nz].view(torch.int32)
if bool(((bits & 0xFFFF) == 0).all()):
return _ULP_REL_BF16
if bool(((bits & 0x1FFF) == 0).all()):
return _ULP_REL_FP16
return _ULP_REL_FP32
@torch.no_grad()
def _candidate_divisors(k_ests, active, max_k, odd_only, device):
"""Small set of candidate integer divisors k such that ``scale = anchor / k``:
a +/-2 window around each unique rounded per-block estimate, plus ``{1, max_k}``
backstops. ``k_ests`` is a list of per-block estimate tensors (each [B]); the
windows around all of them are unioned, so complementary estimates (e.g. a
min-nonzero anchor and a magnitude-gap anchor) can each contribute the true
divisor for the blocks they suit. ``odd_only`` restricts to odd divisors for
the shifted (zero_point_shift == 0.5) grid, whose codes are the odd
integers."""
centre_list = []
for k_est in k_ests:
if active.any():
centre_list.append(torch.unique(k_est[active].round().long()))
if centre_list:
centres = torch.unique(torch.cat(centre_list))
else:
centres = torch.tensor([1], dtype=torch.long, device=device)
offs = torch.arange(-2, 3, dtype=torch.long, device=device)
around = (centres.unsqueeze(1) + offs.unsqueeze(0)).reshape(-1)
cand = torch.cat(
[around, torch.tensor([1, max_k], dtype=torch.long, device=device)]
)
cand = torch.unique(cand)
cand = cand[(cand >= 1) & (cand <= max_k)]
if odd_only:
cand = cand[cand % 2 == 1]
if cand.numel() == 0:
cand = torch.tensor([1], dtype=torch.long, device=device)
return cand
@torch.no_grad()
def _select_divisor(
x, absmax, min_nonzero, zero_point_shift, max_level, ulp_rel, eps=1e-12
):
"""Pick the coarsest divisor ``k`` (``scale = anchor / k``) that explains each
block's grid, without assuming the smallest nonzero magnitude equals one step.
Anchoring the scale at ``absmax`` and sweeping candidate divisors recovers the
step even for blocks whose codes never reach +/-1 (e.g. a depthwise conv with
only a handful of taps per channel), which a "min nonzero == 1 * scale"
assumption cannot handle. Selection is a two-stage relative-margin gate:
reject divisors clearly worse than the best off-grid fraction, then among
survivors take the smallest (coarsest grid) whose mean on-grid deviation is
within ``_FIT_MARGIN`` of the best. The per-element on-grid tolerance is set
from the tensor's measured storage precision (``ulp_rel``).
Returns ``(k_chosen [B] long, accepted [B] bool)``.
"""
B, _ = x.shape
device = x.device
nonzero = x.abs() > eps
nnz = nonzero.sum(dim=1).clamp(min=1).to(x.dtype)
active = torch.isfinite(min_nonzero) & (absmax > 0)
# The shifted grid (zero_point_shift == 0.5) places codes at the half
# integers, i.e. its stored values are odd multiples of half the step, so its
# anchor is 2 * absmax and its divisors are the odd integers up to
# 2 * max_level + 1. The dense grid (zero_point_shift == 0) uses absmax and
# every integer divisor up to max_level. Only these two shifts are supported.
shifted_grid = zero_point_shift == 0.5
anchor = (2.0 if shifted_grid else 1.0) * absmax
max_k = (2 * max_level + 1) if shifted_grid else max_level
# Two complementary per-block estimates of the divisor k = anchor / step:
# - min-nonzero anchor: assumes the smallest nonzero magnitude is one step.
# Correct when the block's codes reach +/-1, wrong when they do not
# (e.g. a near-constant channel [99, 100, 101], whose min-nonzero is 99).
# - magnitude-gap anchor: step = smallest positive gap between distinct
# sorted magnitudes. Correct precisely when codes are consecutive (so the
# gap is one step) even if they never reach +/-1, which recovers the true
# fine grid of near-constant channels the min-nonzero anchor collapses.
# Both windows are searched; the on-grid fit below picks whichever k fits.
k_est_min = absmax / min_nonzero.clamp(min=eps)
xs, _ = x.abs().sort(dim=1)
gaps = xs[:, 1:] - xs[:, :-1]
if gaps.shape[1] == 0:
# Single-element blocks have no gap; fall back to the anchor (one step).
min_gap = anchor.clamp(min=eps)
else:
gaps = torch.where(gaps > eps, gaps, gaps.new_full((), float("inf")))
min_gap = gaps.min(dim=1).values
# All magnitudes equal (no positive gap): fall back to a single-step block.
min_gap = torch.where(torch.isfinite(min_gap), min_gap, anchor.clamp(min=eps))
k_est_gap = anchor / min_gap.clamp(min=eps)
cand = _candidate_divisors(
[k_est_min, k_est_gap], active, max_k, odd_only=shifted_grid, device=device
)
num_cand = cand.numel()
rel_noise = _ULP_SAFETY * ulp_rel
offgrid = x.new_full((B, num_cand), 1.0)
meandev = x.new_full((B, num_cand), 1.0)
for j, k_int in enumerate(cand.tolist()):
s_k = (anchor / k_int).clamp(min=eps)
r = x / s_k.unsqueeze(1)
# Storage noise scales with the true stored magnitude |x/scale|, which is
# |code| on the dense grid and |code + 0.5| on the shifted grid. Capture
# it BEFORE shifting r to integer-code space, otherwise the shifted grid's
# zero-code element (whose value is 0.5 * scale, not 0, so it is never
# masked out by `nonzero`) would get a zero tolerance and be judged
# off-grid on nothing but bf16/fp16 rounding noise.
thr = r.abs().mul_(rel_noise)
r = r - zero_point_shift
# dev = |r - round(r)|, the distance from the nearest integer code.
dev = r.round().sub_(r).abs_()
bad = dev > thr
bad &= nonzero
offgrid[:, j] = bad.sum(dim=1).float() / nnz
meandev[:, j] = dev.mul_(nonzero).sum(dim=1) / nnz
min_off = offgrid.min(dim=1, keepdim=True).values
valid = offgrid <= min_off + _VALID_MARGIN
masked = torch.where(valid, meandev, meandev.new_full((), float("inf")))
best_fit = masked.min(dim=1, keepdim=True).values
fit_ok = valid & (meandev <= best_fit * (1.0 + _FIT_MARGIN))
has_valid = valid.any(dim=1)
smallest_good = fit_ok.float().argmax(dim=1) # first True == smallest k
best_resid = offgrid.argmin(dim=1) # fallback for non-quantized blocks
col = torch.where(has_valid, smallest_good, best_resid)
k_chosen = cand[col]
chosen_offgrid = torch.gather(offgrid, 1, col.unsqueeze(1)).squeeze(1)
accepted = has_valid & active & (chosen_offgrid <= _ABS_OFFGRID_CEIL)
return k_chosen, accepted
@torch.no_grad()
def _refine_scale(
x, k_chosen, zero_point_shift, qmin, qmax, eps=1e-12, iters=_DECOMPOSE_LS_ITERS
):
"""Recover the per-block scale in two stages: a robust median seed followed by
least-squares (Lloyd) refinement.
Seed: ``median(|x_i| / |code_i|)`` over on-grid elements, using the anchor
scale (``absmax / k``) only to derive the integer codes. This is immune to
any single element's storage noise, unlike reading the scale off one point.
Refine: with the codes held fixed, the scale that minimizes reconstruction
error ``||code * scale - x||`` in closed form is ``<x, code> / <code, code>``
(with ``code + zero_point_shift`` for a shifted grid), not the median. A few
Lloyd iterations
drive the scale to that optimum, which is the exact quantity governing how
idempotently the recovered grid reconstructs the weight. A per-block
keep-best guard makes the refinement monotone: a block adopts a refined scale
only if it strictly lowers that block's reconstruction error, so the result is
never worse than the seed.
Returns ``(scale [B], codes [B, N])``.
"""
absmax = x.abs().amax(dim=1)
anchor = (2.0 if zero_point_shift == 0.5 else 1.0) * absmax
s_anchor = anchor / k_chosen.to(x.dtype)
def _codes(s):
r = x / s.unsqueeze(1)
r = r - zero_point_shift
return r.round().clamp(qmin, qmax)
def _sse(s):
code = _codes(s)
denom = code + zero_point_shift
return ((denom * s.unsqueeze(1) - x) ** 2).sum(dim=1)
# Stage 1: robust median seed.
code = _codes(s_anchor)
denom = code + zero_point_shift
nonzero = x.abs() > eps
valid = nonzero & (denom.abs() > eps)
safe_denom = torch.where(denom.abs() > eps, denom, torch.ones_like(denom))
ratio = (x / safe_denom).abs()
ratio = torch.where(valid, ratio, ratio.new_full((), float("nan")))
s_med = torch.nanmedian(ratio, dim=1).values
s_med = torch.where(torch.isfinite(s_med) & (s_med > 0), s_med, s_anchor)
# Stage 2: Lloyd refinement, monotone via per-block keep-best.
best_s = s_med
best_sse = _sse(best_s)
s = s_med
for _ in range(iters):
code = _codes(s)
denom = code + zero_point_shift
num = (x * denom).sum(dim=1)
den = (denom * denom).sum(dim=1).clamp(min=eps)
s_new = num / den
s_new = torch.where(s_new > eps, s_new, s)
sse_new = _sse(s_new)
improve = sse_new < best_sse
best_s = torch.where(improve, s_new, best_s)
best_sse = torch.where(improve, sse_new, best_sse)
s = s_new
return best_s, _codes(best_s)
@torch.no_grad()
def _decompose_prequantized_tensor(
input_qdq: torch.Tensor,
qmin: int,
qmax: int,
scale_shape: tuple[int, ...],
block_size: tuple[int, ...] | None = None,
zero_point_shift: float = 0.0,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Decompose a pre-quantized tensor (input_qdq) into an integer tensor (input_q)
and float scale (scale), recovering the grid the tensor already lies on.
Rather than assuming the smallest nonzero magnitude equals one step, the true
step of each block is found by anchoring at its largest magnitude and sweeping
candidate integer divisors (see _select_divisor). This recovers weights whose
codes never reach +/-1 -- e.g. depthwise convolutions with only a few taps per
channel, or channels sliced out of a larger fused weight -- which a
min-nonzero anchor cannot. The dense grid (``zero_point_shift == 0``) and the
shifted grid whose codes are the odd integers (``zero_point_shift == 0.5``) are
both supported.
A block is accepted when its elements land on the recovered grid within a
tolerance derived from the tensor's own storage precision (bf16/fp16/fp32);
the tensor as a whole is accepted only if enough blocks fit cleanly and the
overall reconstruction error stays at the storage-noise floor, otherwise
``_DecompositionError`` is raised so the caller falls back to min/max
calibration. The recovered scale is refined to the per-block least-squares
optimum (see _refine_scale).
"""
if len(scale_shape) > len(input_qdq.shape):
raise ValueError
if zero_point_shift not in (0.0, 0.5):
raise ValueError(
f"zero_point_shift must be 0.0 (dense grid) or 0.5 (shifted grid), "
f"got {zero_point_shift}"
)
max_level = max(abs(qmin), abs(qmax))
min_scale = _get_minimum_scale(qmax - qmin)
concrete_block_size = concretize_block_size(
input_qdq.shape, scale_shape, block_size or tuple(-1 for _ in scale_shape)
)
concrete_block_size = (
*input_qdq.shape[: len(input_qdq.shape) - len(concrete_block_size)],
*concrete_block_size,
)
orig_shape = input_qdq.shape
reshaped = input_qdq.reshape(
*interleave(
[dim // b for dim, b in zip(input_qdq.shape, concrete_block_size)],
concrete_block_size,
)
)
ndim = reshaped.ndim
block_count_dims = tuple(range(0, ndim, 2))
block_size_dims = tuple(range(1, ndim, 2))
permuted = reshaped.permute(*block_count_dims, *block_size_dims)
permuted_shape = permuted.shape
num_blocks = 1
for d in block_count_dims:
num_blocks *= reshaped.shape[d]
# (num_blocks, block_numel); compute in fp32 for a stable divisor search.
x = permuted.reshape(num_blocks, -1).float()
ulp_rel = _storage_ulp_rel(x)
# Early exit: if most blocks carry far more distinct magnitudes than a grid of
# (qmax - qmin + 1) levels could ever produce, the tensor is not pre-quantized.
# This bound holds for any divisor, so it is a safe reject that skips the
# expensive divisor search and falls back to min/max calibration. Only worth
# the sort for blocks large enough for the search to dominate runtime.
num_levels = qmax - qmin + 1
if x.shape[1] > _EARLY_EXIT_LEVEL_SLACK * num_levels:
xs, _ = x.abs().sort(dim=1)
distinct = (xs[:, 1:] != xs[:, :-1]).sum(dim=1) + 1
over_cap = distinct > _EARLY_EXIT_LEVEL_SLACK * num_levels
if over_cap.float().mean().item() >= _EARLY_EXIT_MIN_OVER_FRAC:
raise _DecompositionError(
f"not QDQ-like (early exit): {over_cap.float().mean().item():.3f} of "
f"blocks exceed {_EARLY_EXIT_LEVEL_SLACK * num_levels} distinct magnitudes"
)
x_abs = x.abs()
absmax = x_abs.amax(dim=1)
min_nonzero = torch.where(x_abs > 0, x_abs, x.new_full((), float("inf"))).amin(
dim=1
)
active = torch.isfinite(min_nonzero) & (absmax > 0)
k_chosen, accepted = _select_divisor(
x, absmax, min_nonzero, zero_point_shift, max_level, ulp_rel
)
scale, _ = _refine_scale(x, k_chosen, zero_point_shift, qmin, qmax)
# Inactive (all-zero) blocks have no grid to recover; give them the minimum
# representable scale. Active blocks keep their recovered scale, clamped to
# the same floor since AIMET never produces a scale below min_scale in
# practice -- a smaller scale would trigger a known HTP underflow bug.
scale = torch.where(
active, scale.clamp_min(min_scale), x.new_full(scale.shape, min_scale)
)
# Tensor-level verification, gates scaled to the measured noise floor.
s_col = scale.unsqueeze(1)
codes = (x / s_col - zero_point_shift).round().clamp(qmin, qmax)
recon = (codes + zero_point_shift) * s_col
code_absmax = int(codes.abs().max().item()) if codes.numel() else 0
# All-zero blocks reconstruct exactly (0 == 0) and count as matched.
matched = (accepted & active) | ~active
matched_frac = matched.float().mean().item() if num_blocks > 0 else 0.0
recon_relrms = (recon - x).norm() / x.norm().clamp(min=1e-12)
recon_gate = max(_RECON_RELRMS_MULT * ulp_rel, _RECON_RELRMS_FLOOR)
if not (
code_absmax > 0
and matched_frac >= _MIN_MATCHED_FRAC
and recon_relrms.item() <= recon_gate
):
raise _DecompositionError(
f"not QDQ-like: matched={matched_frac:.4f}, relRMS={recon_relrms.item():.2e} "
f"(gate={recon_gate:.2e}, ulp_rel={ulp_rel:.2e}), code_absmax={code_absmax}"
)
# Map codes back to the original layout, and scale to scale_shape.
inv_perm = tuple(
int(i)
for i in torch.argsort(
torch.tensor(block_count_dims + block_size_dims)
).tolist()
)
input_q = codes.reshape(permuted_shape).permute(*inv_perm).reshape(orig_shape)
scale = scale.reshape(scale_shape)
return input_q, scale