Source code for quantizeml.onnx_support.layers.base_layer

#!/usr/bin/env python
# ******************************************************************************
# Copyright 2023 Brainchip Holdings Ltd.
#
# 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.
# ******************************************************************************
__all__ = ["OnnxLayer"]

import numpy as np
import uuid
from collections import defaultdict

from onnx import ValueInfoProto
from onnx.mapping import NP_TYPE_TO_TENSOR_TYPE
from onnx.helper import make_function, make_node, make_opsetid, make_tensor_value_info
from onnx.defs import onnx_opset_version

from .register import register_new_subgraph, infer_function_parameters
from ..graph_tools import to_field, value_info_to_tensor_shape, array_to_tp

DOMAIN = "com.brainchip"
VERSION = 1


def get_brainchip_opsetid():
    """Get the opset id given a target akida version.

    Returns:
        OperatorSetIdProto: the opset id
    """
    return make_opsetid(DOMAIN, VERSION)


[docs]class OnnxLayer: """Abstract class that represents an onnx subgraph in brainchip domain. Child must define the attributes on __init__ and return the node list (subgraph) on build_subgraph(). If these requirements are met, make_node() could be used to define/register the custom node. Args: base_name (str): the operation type base name. opset_imports (list of OperatorSetIdProto, optional): the custom opset. Defaults to None. name (str, optional): the node name. Defaults to ''. kwargs (dict, optional): the custom attributes. Each attribute type will be infered by ``onnx.helper.make_attribute()``. Defaults to {}. """ def __init__(self, base_name, opset_imports=None, name='', **kwargs): self.base_name = base_name self.name = name self._input = None self._output = None self._opset_imports = opset_imports or [make_opsetid("", onnx_opset_version())] self.serialize_attr = defaultdict(bool) # Load attributes # Note: this field is called 'attribute' to align it to the same ONNX standard self.attribute = self._load_attributes(**kwargs) # Create empty variable to save the weights self._weights = {} @property def op_type(self): op_name = self.base_name if self.serialize_attr["flatten"]: op_name += "Flatten" bias = self.weights.get("bias", np.array([])) if bias.size > 0: op_name += "Biased" pool_type = self.serialize_attr["pool_type"] if pool_type == "max": op_name += "MaxPool" elif pool_type == "gap": op_name += "GlobalAvgPool" if self.serialize_attr["activation"]: op_name += "ReLU" # We assume unbounded activation when max_value = 0 max_value = self.weights.get("max_value", np.array([])) if np.any(max_value != 0): op_name += "Clipped" if self.serialize_attr["scale"]: op_name += "Scaled" return op_name @property def input(self): assert self._input is not None, f"{self.name} has not being built yet." return self._input[0] if len(self._input) == 1 else self._input @property def output(self): assert self._output is not None, f"{self.name} has not being built yet." return self._output @property def weights(self): return self._weights @property def opset_imports(self): return self._opset_imports + [get_brainchip_opsetid()] def _load_attributes(self, **kwargs): attrs = [] for key, value in kwargs.items(): # Convert each value in an AttributeProto value = to_field(key, value) attrs.append(value) return attrs @staticmethod def build_subgraph(op_type): """Define the subgraph Args: op_type (str): operation type to build Returns: list of NodeProto: the operation sequence. """ raise NotImplementedError("Child must implement this function") def _add_weight(self, name, value=[], dtype="float32"): """Add a new weight into the object. Note: Weights have to be created on child in __init__. """ self._weights[name] = np.array(value, dtype) def set_weight(self, name, value): """Set a weights that can be extracted from the float model Args: name (str): the weight to modify value (np.ndarray): the new value """ assert isinstance(value, np.ndarray), f"Expected {value} is a numpy array." if name not in self.weights: raise ValueError(f"{self.name} ({self.base_name}) does not recognize '{name}'. " f"Availables: {list(self.weights)}") if value.dtype != self.weights[name].dtype: raise ValueError(f"{self.base_name}/{name} does not match with expected type " f"({self._weights[name].dtype}). Receives {value.dtype}") self._weights[name] = value def __build__(self, *input_tensor_shapes, downscale=True): """Build weights and compute the output shape Args: *input_tensor_shapes (tuple): the input shapes and types downscale (bool, optional): whether to apply downscale operation. Defaults to True. Returns: tuple: the output shape and type """ raise NotImplementedError("Child must implement this function") def build(self, *inputs_vi, downscale=True): """Build the layer in several steps: 1. Build extra weights, needed at quantization time. 2. Check weights integrity given the input shape. 3. Compute output shape. Args: inputs_vi (list of ValueInfoProto): list of inputs value info. out_name (str, optional): the output tensor name. Defaults to None. downscale (bool, optional): whether to apply downscale operation, which will change the output type. Defaults to True. """ assert all(isinstance(x, ValueInfoProto) for x in inputs_vi) # Replace empty name if not self.name: self.name = str(uuid.uuid4()) # Convert ValueInfoProto into TensorShape input_ts = [value_info_to_tensor_shape(x) for x in inputs_vi] if len(inputs_vi) > 0: self._input = inputs_vi output_ts = self.__build__(*input_ts, downscale=downscale) self._output = make_tensor_value_info(f"{self.name}/output", elem_type=NP_TYPE_TO_TENSOR_TYPE[output_ts.dtype], shape=output_ts.shape) # Special weights: each qlayer must have an output scale and (potentially) a zero point. # But the zero point type may change depending on the layer type. # That is why we add it only if child did not do it scale_zp_shape = output_ts.shape[1] self._add_weight("scale", value=np.ones(scale_zp_shape), dtype="float64") if "zero_point" not in self.weights: self._add_weight("zero_point", value=np.zeros(scale_zp_shape), dtype="int8") def __quantize__(self, *qlayers, out_tensor_range, force_fp=False): """Build weights and compute the output shape Args: qlayers (list of OnnxLayer): the input layers. Input scales and zero points will be deduced from these. out_tensor_range (tuple of np.ndarray): the min-max ranges computed by calibration. force_fp (bool, optional): whether to force output scale as a power-of-two. Defaults to False. Returns: tuple: the output shape and type """ raise NotImplementedError("Child must implement this function") def quantize(self, *qlayers, out_tensor_range, force_fp=False, downscale=True): """Quantize the float weights given a set of input scales and zero points. Args: qlayers (list of OnnxLayer): the input layers. Input scales and zero points will be deduced from these. out_tensor_range (tuple of np.ndarray): the min-max ranges computed by calibration. force_fp (bool, optional): whether to force output scale as a power-of-two. Defaults to False. downscale (bool, optional): whether to apply downscale operation, which will change the output type. Defaults to True. Returns: NodeProto, list of TensorProto: serialized objects to build the ONNX graph. """ if self._output is None or self._input is None: # Build the layer if required input_ts = [qly.output for qly in qlayers] self.build(*input_ts, downscale=downscale) # Quantize weights qweights, output_scale = self.__quantize__(*qlayers, out_tensor_range=out_tensor_range, force_fp=force_fp) # Save output scale to be recovered for next qlayer self.set_weight("scale", output_scale) # Return ONNX node and weights inputs = [ts.name for ts in self._input] + list(qweights) onnx_node = self.make_node(inputs, [self.output.name]) onnx_weights = array_to_tp(**qweights) return onnx_node, onnx_weights def make_node(self, inputs, outputs): """Return the NodeProto, setting the attributes. Args: inputs (list of str): list of input names. outputs (list of str): list of output names. Returns: NodeProto: the corresponding node. """ # Build the subgraph (implemented in derived classes) and register subgraph # to make it available, unless previously registered already nodes = self.build_subgraph(self.op_type) inputs_fn, outputs_fn, attributes_fn = infer_function_parameters(nodes) func = make_function(domain=DOMAIN, fname=self.op_type, inputs=inputs_fn, outputs=outputs_fn, nodes=nodes, opset_imports=self._opset_imports, attributes=attributes_fn) register_new_subgraph(func) # Return the node with corresponding attributes node = make_node(self.op_type, inputs, outputs, self.name, domain=DOMAIN) consume_attrs = [attr for attr in self.attribute if attr.name in func.attribute] node.attribute.extend(consume_attrs) return node