Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
calibrate.py1268 linesDownload Raw Back to quantization
1#!/usr/bin/env python
2# -------------------------------------------------------------------------
3# Copyright (c) Microsoft, Intel Corporation. All rights reserved.
4# Licensed under the MIT License. See License.txt in the project root for
5# license information.
6# --------------------------------------------------------------------------
7import abc
8import copy
9import itertools
10import os
11import uuid
12from collections.abc import Sequence
13from enum import Enum
14from pathlib import Path
15
16import numpy as np
17import onnx
18from onnx import ModelProto, TensorProto, helper, numpy_helper
19
20import onnxruntime
21
22from .quant_utils import apply_plot, load_model_with_shape_infer, smooth_distribution
23
24
25def rel_entr(pk: np.ndarray, qk: np.ndarray) -> np.ndarray:
26    """
27    See https://docs.scipy.org/doc/scipy/reference/generated/scipy.special.rel_entr.html#scipy.special.rel_entr.
28    Python implementation.
29    """
30    res = np.empty(pk.shape, dtype=pk.dtype)
31    res[:] = pk[:] * np.log(pk[:] / qk[:])
32    c2 = (pk == 0) & (qk >= 0)
33    res[c2] = 0
34    c1 = (pk > 0) & (qk > 0)
35    res[~c1] = np.inf
36    return res
37
38
39def entropy(
40    pk: np.ndarray,
41    qk: np.ndarray,
42    base: float | None = None,
43    axis: int = 0,
44) -> np.ndarray:
45    """
46    Simplifeied version of entropy.
47    Source: https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.entropy.html.
48    This avoids taking a dependency on scipy just for this function.
49    """
50    assert base is None or base > 0, "base={base} must be a positive number or `None`."
51    assert qk is not None, "qk is None"
52
53    pk = np.asarray(pk).astype(np.float32)
54    pk = 1.0 * pk / np.sum(pk, axis=axis, keepdims=True)
55
56    qk = np.asarray(qk).astype(np.float32)
57    pk, qk = np.broadcast_arrays(pk, qk)
58    qk = 1.0 * qk / np.sum(qk, axis=axis, keepdims=True)
59    vec = rel_entr(pk, qk)
60
61    s = np.sum(vec, axis=axis)
62    if base is not None:
63        s /= np.log(base)
64    return s.astype(pk.dtype)
65
66
67class TensorData:
68    _allowed = frozenset(["avg", "std", "lowest", "highest", "hist", "hist_edges", "bins"])
69    _floats = frozenset(["avg", "std", "lowest", "highest", "hist_edges"])
70
71    def __init__(self, **kwargs):
72        self._attrs = list(kwargs.keys())
73        for k, v in kwargs.items():
74            if k not in TensorData._allowed:
75                raise ValueError(f"Unexpected value {k!r} not in {TensorData._allowed}.")
76            if k in TensorData._floats:
77                if not hasattr(v, "dtype"):
78                    raise ValueError(f"Unexpected type {type(v)} for k={k!r}")
79                if v.dtype not in (np.float16, np.float32):
80                    raise ValueError(f"Unexpected dtype {v.dtype} for k={k!r}")
81            setattr(self, k, v)
82
83    @property
84    def range_value(self):
85        if not hasattr(self, "lowest") or not hasattr(self, "highest"):
86            raise AttributeError(f"Attributes 'lowest' and/or 'highest' missing in {dir(self)}.")
87        return (self.lowest, self.highest)
88
89    @property
90    def avg_std(self):
91        if not hasattr(self, "avg") or not hasattr(self, "std"):
92            raise AttributeError(f"Attributes 'avg' and/or 'std' missing in {dir(self)}.")
93        return (self.avg, self.std)
94
95    def to_dict(self):
96        # This is needed to serialize the data into JSON.
97        data = {k: getattr(self, k) for k in self._attrs}
98        data["CLS"] = self.__class__.__name__
99        return data
100
101
102class TensorsData:
103    def __init__(self, calibration_method, data: dict[str, TensorData | tuple]):
104        self.calibration_method = calibration_method
105        self.data = {}
106        for k, v in data.items():
107            if not isinstance(k, str):
108                raise TypeError(f"Keys must be strings not {type(k)}.")
109            if isinstance(v, tuple):
110                if calibration_method == CalibrationMethod.MinMax and len(v) == 2:
111                    self.data[k] = TensorData(lowest=v[0], highest=v[1])
112                    continue
113                if len(v) == 4:
114                    self.data[k] = TensorData(lowest=v[0], highest=v[1], hist=v[2], bins=v[3])
115                    continue
116                raise TypeError(f"Unexpected tuple for {k:r}, it has {len(v)} elements: {v}.")
117            if not isinstance(v, TensorData):
118                raise TypeError(f"Values must be TensorData not {type(v)}.")
119            self.data[k] = v
120
121    def __iter__(self):
122        yield from self.data
123
124    def __contains__(self, key):
125        return key in self.data
126
127    def __getitem__(self, key):
128        return self.data[key]
129
130    def __setitem__(self, key, value):
131        if key not in self.data:
132            raise RuntimeError(f"Only an existing tensor can be modified, {key!r} is not.")
133        self.data[key] = value
134
135    def keys(self):
136        return self.data.keys()
137
138    def values(self):
139        return self.data.values()
140
141    def items(self):
142        return self.data.items()
143
144    def to_dict(self):
145        # This is needed to serialize the data into JSON.
146        data = {
147            "CLS": self.__class__.__name__,
148            "data": self.data,
149            "calibration_method": self.calibration_method,
150        }
151        return data
152
153
154class CalibrationMethod(Enum):
155    MinMax = 0
156    Entropy = 1
157    Percentile = 2
158    Distribution = 3
159
160
161class CalibrationDataReader(metaclass=abc.ABCMeta):
162    @classmethod
163    def __subclasshook__(cls, subclass):
164        return (hasattr(subclass, "get_next") and callable(subclass.get_next)) or NotImplemented
165
166    @abc.abstractmethod
167    def get_next(self) -> dict:
168        """generate the input data dict for ONNXinferenceSession run"""
169        raise NotImplementedError
170
171    def __iter__(self):
172        return self
173
174    def __next__(self):
175        result = self.get_next()
176        if result is None:
177            raise StopIteration
178        return result
179
180    def __len__(self):
181        raise NotImplementedError
182
183    def set_range(self, start_index: int, end_index: int):
184        raise NotImplementedError
185
186
187class CalibraterBase:
188    def __init__(
189        self,
190        model_path: str | Path,
191        op_types_to_calibrate: Sequence[str] | None = None,
192        augmented_model_path="augmented_model.onnx",
193        symmetric=False,
194        use_external_data_format=False,
195        per_channel=False,
196    ):
197        """
198        :param model_path: ONNX model to calibrate. It should be a model file path
199        :param op_types_to_calibrate: operator types to calibrate. By default, calibrate all the float32/float16 tensors.
200        :param augmented_model_path: save augmented model to this path.
201        :param symmetric: make range of tensor symmetric (central point is 0).
202        :param use_external_data_format: use external data format to store model which size is >= 2Gb.
203        :param per_channel: whether to compute ranges per each channel.
204        """
205        if isinstance(model_path, str):
206            self.model = load_model_with_shape_infer(Path(model_path))
207        elif isinstance(model_path, Path):
208            self.model = load_model_with_shape_infer(model_path)
209        else:
210            raise ValueError("model_path should be model path.")
211
212        self.op_types_to_calibrate = op_types_to_calibrate
213        self.augmented_model_path = augmented_model_path
214        self.symmetric = symmetric
215        self.use_external_data_format = use_external_data_format
216        self.per_channel = per_channel
217
218        self.augment_model = None
219        self.infer_session = None
220        self.execution_providers = ["CPUExecutionProvider"]
221
222    def set_execution_providers(self, execution_providers=["CPUExecutionProvider"]):  # noqa: B006
223        """
224        reset the execution providers to execute the collect_data. It triggers to re-creating inference session.
225        """
226        self.execution_providers = execution_providers
227        self.create_inference_session()
228
229    def create_inference_session(self):
230        """
231        create an OnnxRuntime InferenceSession.
232        """
233        sess_options = onnxruntime.SessionOptions()
234        sess_options.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_DISABLE_ALL
235        self.infer_session = onnxruntime.InferenceSession(
236            self.augmented_model_path,
237            sess_options=sess_options,
238            providers=self.execution_providers,
239        )
240
241    def select_tensors_to_calibrate(self, model: ModelProto):
242        """
243        select input/output tensors of candidate nodes to calibrate.
244        returns:
245            tensors (set): set of tensor name.
246            value_infos (dict): tensor name to value info.
247        """
248        value_infos = {vi.name: vi for vi in model.graph.value_info}
249        value_infos.update({ot.name: ot for ot in model.graph.output})
250        value_infos.update({it.name: it for it in model.graph.input})
251        initializer = {init.name for init in model.graph.initializer}
252
253        tensors_to_calibrate = set()
254        tensor_type_to_calibrate = {TensorProto.FLOAT, TensorProto.FLOAT16}
255
256        for node in model.graph.node:
257            if not self.op_types_to_calibrate or node.op_type in self.op_types_to_calibrate:
258                for tensor_name in itertools.chain(node.input, node.output):
259                    if tensor_name in value_infos:
260                        vi = value_infos[tensor_name]
261                        if (
262                            vi.type.HasField("tensor_type")
263                            and (vi.type.tensor_type.elem_type in tensor_type_to_calibrate)
264                            and (tensor_name not in initializer)
265                        ):
266                            tensors_to_calibrate.add(tensor_name)
267
268        return tensors_to_calibrate, value_infos
269
270    def get_augment_model(self):
271        """
272        return: augmented onnx model. Call after calling augment_graph
273        """
274        return self.model
275
276    def augment_graph(self):
277        """
278        abstract method: augment the input model to prepare for collecting data. It will:
279            1. augment the model to be able to collect desired statistics data
280            2. save augmented model to augmented_model_paths
281        """
282        raise NotImplementedError
283
284    def collect_data(self, data_reader: CalibrationDataReader):
285        """
286        abstract method: collect the tensors that will be used for range computation. It can be called multiple times.
287        """
288        raise NotImplementedError
289
290    def compute_data(self) -> TensorsData:
291        """
292        abstract method: compute data based on the calibration method stored in TensorsData
293        """
294        raise NotImplementedError
295
296
297class MinMaxCalibrater(CalibraterBase):
298    def __init__(
299        self,
300        model_path: str | Path,
301        op_types_to_calibrate: Sequence[str] | None = None,
302        augmented_model_path="augmented_model.onnx",
303        symmetric=False,
304        use_external_data_format=False,
305        moving_average=False,
306        averaging_constant=0.01,
307        max_intermediate_outputs=None,
308        per_channel=False,
309    ):
310        """
311        :param model_path: ONNX model to calibrate. It is a model path
312        :param op_types_to_calibrate: operator types to calibrate. By default, calibrate all the float32/float16 tensors.
313        :param augmented_model_path: save augmented model to this path.
314        :param symmetric: make range of tensor symmetric (central point is 0).
315        :param use_external_data_format: use external data format to store model which size is >= 2Gb
316        :param moving_average: compute the moving average of the minimum and maximum values instead of the global minimum and maximum.
317        :param averaging_constant: constant smoothing factor to use when computing the moving average.
318        :param max_intermediate_outputs: maximum number of intermediate outputs before an intermediate range is computed.
319        :param per_channel: whether to compute ranges per each channel.
320        """
321        super().__init__(
322            model_path,
323            op_types_to_calibrate=op_types_to_calibrate,
324            augmented_model_path=augmented_model_path,
325            symmetric=symmetric,
326            use_external_data_format=use_external_data_format,
327            per_channel=per_channel,
328        )
329        self.intermediate_outputs = []
330        self.calibrate_tensors_range = None
331        self.num_model_outputs = len(self.model.graph.output)
332        self.model_original_outputs = {output.name for output in self.model.graph.output}
333        self.moving_average = moving_average
334        if moving_average and (averaging_constant < 0 or averaging_constant > 1):
335            raise ValueError("Invalid averaging constant, which should not be < 0 or > 1.")
336        self.averaging_constant = averaging_constant
337        self.max_intermediate_outputs = max_intermediate_outputs
338
339    def augment_graph(self):
340        """
341        Adds ReduceMin and ReduceMax nodes to all quantization_candidates op type nodes in
342        model and ensures their outputs are stored as part of the graph output
343        :return: augmented ONNX model
344        """
345        tensors, _ = self.select_tensors_to_calibrate(self.model)
346        reshape_shape_name = str(uuid.uuid4())
347        reshape_shape = numpy_helper.from_array(np.array([-1], dtype=np.int64), reshape_shape_name)
348        self.model.graph.initializer.append(reshape_shape)
349
350        def get_op_version(op_type, model):
351            for opset_import in model.opset_import:
352                if onnx.defs.has(op_type, opset_import.domain):
353                    return opset_import.version
354            raise RuntimeError(f"Model does not contain a version for '{op_type}'.")
355
356        def insert_nodes(tensor_name, new_nodes):
357            index = next(
358                (i for i, x in enumerate(self.model.graph.node) if tensor_name in x.input), len(self.model.graph.node)
359            )
360            for node in new_nodes:
361                self.model.graph.node.insert(index, node)
362                index += 1
363
364        def add_reduce_min_max(tensor_name, reduce_op_name):
365            # When doing ReduceMax/ReduceMin, ORT can't reduce on dim with value of 0 if 'keepdims' is false.
366            # To make the code simple, we always let keepdims to be 1.
367            keepdims = 1
368
369            # Adding ReduceMin/ReduceMax nodes: ReduceMin/ReduceMax -> Reshape-> (output)
370            reduce_output = tensor_name + "_" + reduce_op_name
371            intermediate_output = reduce_output + "_Reshape"
372            reduce_node = onnx.helper.make_node(
373                reduce_op_name, [tensor_name], [intermediate_output], keepdims=keepdims, name=reduce_output
374            )
375
376            reshape_node = onnx.helper.make_node(
377                "Reshape",
378                inputs=[intermediate_output, reshape_shape_name],
379                outputs=[reduce_output],
380                name=intermediate_output,
381            )
382
383            value_infos = {vi.name: vi for vi in self.model.graph.value_info}
384            value_infos.update({o.name: o for o in self.model.graph.output})
385            value_infos.update({i.name: i for i in self.model.graph.input})
386            if tensor_name in value_infos:
387                onnx_type = value_infos[tensor_name].type.tensor_type.elem_type
388            else:
389                raise ValueError(
390                    f"Unable to guess tensor type for tensor {tensor_name!r}, "
391                    "running shape inference before quantization may resolve this issue."
392                )
393
394            # Include axes in reduce_op when per_channel, always keeping axis=1
395            if self.per_channel:
396                tensor_rank = len(value_infos[tensor_name].type.tensor_type.shape.dim)
397                reduced_axes = [0, *range(2, tensor_rank)]
398                # Depending on opset version, axes in ReduceMin/ReduceMax are in attribute or inputs
399                if get_op_version(reduce_op_name, self.model) < 18:
400                    reduce_node.attribute.append(helper.make_attribute("axes", reduced_axes))
401                else:
402                    reduce_axes_name = str(uuid.uuid4())
403                    reduce_axes = numpy_helper.from_array(np.array(reduced_axes, dtype=np.int64), reduce_axes_name)
404                    reduce_node.input.append(reduce_axes_name)
405                    self.model.graph.initializer.append(reduce_axes)
406
407            insert_nodes(tensor_name, [reduce_node, reshape_node])
408            self.model.graph.output.append(helper.make_tensor_value_info(reduce_output, onnx_type, [None]))
409
410        for tensor in tensors:
411            add_reduce_min_max(tensor, "ReduceMin")
412            add_reduce_min_max(tensor, "ReduceMax")
413
414        onnx.save(
415            self.model,
416            self.augmented_model_path,
417            save_as_external_data=self.use_external_data_format,
418        )
419
420    def clear_collected_data(self):
421        self.intermediate_outputs = []
422
423    def collect_data(self, data_reader: CalibrationDataReader):
424        while True:
425            inputs = data_reader.get_next()
426            if not inputs:
427                break
428            self.intermediate_outputs.append(
429                [
430                    value if sess_o.name not in self.model_original_outputs else None
431                    for sess_o, value in zip(
432                        self.infer_session.get_outputs(), self.infer_session.run(None, inputs), strict=False
433                    )
434                ]
435            )
436            if (
437                self.max_intermediate_outputs is not None
438                and len(self.intermediate_outputs) == self.max_intermediate_outputs
439            ):
440                self.clear_collected_data()
441
442        if len(self.intermediate_outputs) == 0 and self.calibrate_tensors_range is None:
443            raise ValueError("No data is collected.")
444
445        t = self.compute_data()
446        if not isinstance(t, TensorsData):
447            raise TypeError(f"compute_data must return a TensorsData not {type(t)}.")
448        self.clear_collected_data()
449
450    def merge_range(self, old_range, new_range):
451        if not old_range:
452            return new_range
453
454        for key, value in old_range.items():
455            # Handling for structured data types with TensorData
456            if isinstance(value, TensorData):
457                old_min = value.range_value[0]
458                old_max = value.range_value[1]
459            else:
460                old_min, old_max = value
461
462            if isinstance(new_range[key], TensorData):
463                new_min = new_range[key].range_value[0]
464                new_max = new_range[key].range_value[1]
465            else:
466                new_min, new_max = new_range[key]
467
468            if self.moving_average:
469                min_value = old_min + self.averaging_constant * (new_min - old_min)
470                max_value = old_max + self.averaging_constant * (new_max - old_max)
471            else:
472                min_value = min(old_min, new_min)
473                max_value = max(old_max, new_max)
474
475            # If structured as TensorData, wrap the result accordingly
476            if isinstance(value, TensorData) or isinstance(new_range[key], TensorData):
477                new_range[key] = TensorData(lowest=min_value, highest=max_value)
478            else:
479                new_range[key] = (min_value, max_value)
480
481        return new_range
482
483    def compute_data(self) -> TensorsData:
484        """
485        Compute the min-max range of tensor
486        :return: dictionary mapping: {added node names: (ReduceMin, ReduceMax) pairs }
487        """
488
489        if len(self.intermediate_outputs) == 0:
490            return self.calibrate_tensors_range
491
492        output_names = [self.infer_session.get_outputs()[i].name for i in range(len(self.intermediate_outputs[0]))]
493        output_dicts_list = [
494            dict(zip(output_names, intermediate_output, strict=False))
495            for intermediate_output in self.intermediate_outputs
496        ]
497
498        merged_output_dict = {}
499        for d in output_dicts_list:
500            for k, v in d.items():
501                merged_output_dict.setdefault(k, []).append(v)
502        added_output_names = output_names[self.num_model_outputs :]
503        calibrate_tensor_names = [
504            added_output_names[i].rpartition("_")[0] for i in range(0, len(added_output_names), 2)
505        ]  # output names
506
507        merged_added_output_dict = {
508            i: merged_output_dict[i] for i in merged_output_dict if i not in self.model_original_outputs
509        }
510
511        pairs = []
512        for i in range(0, len(added_output_names), 2):
513            if self.moving_average:
514                min_value_array = np.nanmean(merged_added_output_dict[added_output_names[i]], axis=0)
515                max_value_array = np.nanmean(merged_added_output_dict[added_output_names[i + 1]], axis=0)
516            else:
517                min_value_array = np.nanmin(merged_added_output_dict[added_output_names[i]], axis=0)
518                max_value_array = np.nanmax(merged_added_output_dict[added_output_names[i + 1]], axis=0)
519
520            if self.symmetric:
521                max_absolute_value = np.nanmax([np.abs(min_value_array), np.abs(max_value_array)], axis=0)
522                pairs.append((-max_absolute_value, max_absolute_value))
523            else:
524                pairs.append((min_value_array, max_value_array))
525
526        new_calibrate_tensors_range = TensorsData(
527            CalibrationMethod.MinMax, dict(zip(calibrate_tensor_names, pairs, strict=False))
528        )
529        if self.calibrate_tensors_range:
530            self.calibrate_tensors_range = self.merge_range(self.calibrate_tensors_range, new_calibrate_tensors_range)
531        else:
532            self.calibrate_tensors_range = new_calibrate_tensors_range
533
534        return self.calibrate_tensors_range
535
536
537class HistogramCalibrater(CalibraterBase):
538    def __init__(
539        self,
540        model_path: str | Path,
541        op_types_to_calibrate: Sequence[str] | None = None,
542        augmented_model_path="augmented_model.onnx",
543        use_external_data_format=False,
544        method="percentile",
545        symmetric=False,
546        num_bins=128,
547        num_quantized_bins=2048,
548        percentile=99.999,
549        scenario="same",
550    ):
551        """
552        :param model_path: ONNX model to calibrate. It is a model path.
553        :param op_types_to_calibrate: operator types to calibrate. By default, calibrate all the float32/float16 tensors.
554        :param augmented_model_path: save augmented model to this path.
555        :param use_external_data_format: use external data format to store model which size is >= 2Gb
556        :param method: A string. One of ['entropy', 'percentile'].
557        :param symmetric: make range of tensor symmetric (central point is 0).
558        :param num_bins: number of bins to create a new histogram for collecting tensor values.
559        :param num_quantized_bins: number of quantized bins. Default 128.
560        :param percentile: A float number between [0, 100]. Default 99.99.
561        :param scenario: see :class:`DistributionCalibrater`
562        """
563        super().__init__(
564            model_path,
565            op_types_to_calibrate=op_types_to_calibrate,
566            augmented_model_path=augmented_model_path,
567            symmetric=symmetric,
568            use_external_data_format=use_external_data_format,
569        )
570        self.intermediate_outputs = []
571        self.calibrate_tensors_range = None
572        self.num_model_outputs = len(self.model.graph.output)
573        self.model_original_outputs = {output.name for output in self.model.graph.output}
574        self.collector = None
575        self.method = method
576        self.num_bins = num_bins
577        self.num_quantized_bins = num_quantized_bins
578        self.percentile = percentile
579        self.tensors_to_calibrate = None
580        self.scenario = scenario
581
582    def augment_graph(self):
583        """
584        make all quantization_candidates op type nodes as part of the graph output.
585        :return: augmented ONNX model
586        """
587        self.tensors_to_calibrate, value_infos = self.select_tensors_to_calibrate(self.model)
588        for tensor in self.tensors_to_calibrate:
589            if tensor not in self.model_original_outputs:
590                self.model.graph.output.append(value_infos[tensor])
591
592        onnx.save(
593            self.model,
594            self.augmented_model_path,
595            save_as_external_data=self.use_external_data_format,
596        )
597
598    def clear_collected_data(self):
599        self.intermediate_outputs = []
600
601    def collect_data(self, data_reader: CalibrationDataReader):
602        """
603        Entropy Calibrator collects operators' tensors as well as generates tensor histogram for each operator.
604        """
605        input_names_set = {node_arg.name for node_arg in self.infer_session.get_inputs()}
606        output_names = [node_arg.name for node_arg in self.infer_session.get_outputs()]
607
608        while True:
609            inputs = data_reader.get_next()
610            if not inputs:
611                break
612            outputs = self.infer_session.run(None, inputs)
613
614            # Copy np.ndarray only for graph outputs that are also graph inputs to workaround bug:
615            # https://github.com/microsoft/onnxruntime/issues/21922
616            fixed_outputs = []
617            for output_index, output in enumerate(outputs):
618                if output_names[output_index] in input_names_set:
619                    fixed_outputs.append(copy.copy(output))
620                else:
621                    fixed_outputs.append(output)
622
623            self.intermediate_outputs.append(fixed_outputs)
624
625        if len(self.intermediate_outputs) == 0:
626            raise ValueError("No data is collected.")
627
628        output_dicts_list = [
629            dict(zip(output_names, intermediate_output, strict=False))
630            for intermediate_output in self.intermediate_outputs
631        ]
632
633        merged_dict = {}
634        for d in output_dicts_list:
635            for k, v in d.items():
636                merged_dict.setdefault(k, []).append(v)
637
638        clean_merged_dict = {i: merged_dict[i] for i in merged_dict if i in self.tensors_to_calibrate}
639
640        if not self.collector:
641            self.collector = HistogramCollector(
642                method=self.method,
643                symmetric=self.symmetric,
644                num_bins=self.num_bins,
645                num_quantized_bins=self.num_quantized_bins,
646                percentile=self.percentile,
647                scenario=self.scenario,
648            )
649        self.collector.collect(clean_merged_dict)
650
651        self.clear_collected_data()
652
653    def compute_data(self) -> TensorsData:
654        """
655        Compute the min-max range of tensor
656        :return: dictionary mapping: {tensor name: (min value, max value)}
657        """
658        if not self.collector:
659            raise ValueError("No collector created and can't generate calibration data.")
660
661        if isinstance(self, EntropyCalibrater):
662            cal = CalibrationMethod.Entropy
663        elif isinstance(self, PercentileCalibrater):
664            cal = CalibrationMethod.Percentile
665        elif isinstance(self, DistributionCalibrater):
666            cal = CalibrationMethod.Distribution
667        else:
668            raise TypeError(f"Unknown calibrater {type(self)}. This method must be overwritten.")
669        return TensorsData(cal, self.collector.compute_collection_result())
670
671
672class EntropyCalibrater(HistogramCalibrater):
673    def __init__(
674        self,
675        model_path: str | Path,
676        op_types_to_calibrate: Sequence[str] | None = None,
677        augmented_model_path="augmented_model.onnx",
678        use_external_data_format=False,
679        method="entropy",
680        symmetric=False,
681        num_bins=128,
682        num_quantized_bins=128,
683    ):
684        """
685        :param model_path: ONNX model to calibrate. It is a model path
686        :param op_types_to_calibrate: operator types to calibrate. By default, calibrate all the float32/float16 tensors.
687        :param augmented_model_path: save augmented model to this path.
688        :param use_external_data_format: use external data format to store model which size is >= 2Gb
689        :param method: A string. One of ['entropy', 'percentile', 'distribution'].
690        :param symmetric: make range of tensor symmetric (central point is 0).
691        :param num_bins: number of bins to create a new histogram for collecting tensor values.
692        :param num_quantized_bins: number of quantized bins. Default 128.
693        """
694        super().__init__(
695            model_path,
696            op_types_to_calibrate,
697            augmented_model_path,
698            use_external_data_format,
699            method=method,
700            symmetric=symmetric,
701            num_bins=num_bins,
702            num_quantized_bins=num_quantized_bins,
703        )
704
705
706class PercentileCalibrater(HistogramCalibrater):
707    def __init__(
708        self,
709        model_path: str | Path,
710        op_types_to_calibrate: Sequence[str] | None = None,
711        augmented_model_path="augmented_model.onnx",
712        use_external_data_format=False,
713        method="percentile",
714        symmetric=False,
715        num_bins=2048,
716        percentile=99.999,
717    ):
718        """
719        :param model_path: ONNX model to calibrate. It is a model path
720        :param op_types_to_calibrate: operator types to calibrate. By default, calibrate all the float32/float16 tensors.
721        :param augmented_model_path: save augmented model to this path.
722        :param use_external_data_format: use external data format to store model which size is >= 2Gb
723        :param method: A string. One of ['entropy', 'percentile', 'distribution'].
724        :param symmetric: make range of tensor symmetric (central point is 0).
725        :param num_quantized_bins: number of quantized bins. Default 128.
726        :param percentile: A float number between [0, 100]. Default 99.99.
727        """
728        super().__init__(
729            model_path,
730            op_types_to_calibrate,
731            augmented_model_path,
732            use_external_data_format,
733            method=method,
734            symmetric=symmetric,
735            num_bins=num_bins,
736            percentile=percentile,
737        )
738
739
740class DistributionCalibrater(HistogramCalibrater):
741    def __init__(
742        self,
743        model_path: str | Path,
744        op_types_to_calibrate: Sequence[str] | None = None,
745        augmented_model_path="augmented_model.onnx",
746        use_external_data_format=False,
747        method="distribution",
748        num_bins=128,
749        scenario="same",
750    ):
751        """
752        :param model_path: ONNX model to calibrate. It is a model path
753        :param op_types_to_calibrate: operator types to calibrate. By default, calibrate all the float32/float16 tensors.
754        :param augmented_model_path: save augmented model to this path.
755        :param use_external_data_format: use external data format to store model which size is >= 2Gb
756        :param method: A string. One of ['entropy', 'percentile', 'distribution'].
757        :param symmetric: make range of tensor symmetric (central point is 0).
758        :param num_bins: number of bins to create a new histogram for collecting tensor values.
759        :param scenario: for float 8 only, if `scenario="same"`,
760            the algorithm weights and float 8 follow the same distribution,
761            if `scenario="p3"`, it assumes the weights follow
762            a gaussian law and float 8 ~ X^3 where X is a gaussian law
763        """
764        super().__init__(
765            model_path,
766            op_types_to_calibrate,
767            augmented_model_path,
768            use_external_data_format,
769            method=method,
770            num_bins=num_bins,
771            scenario=scenario,
772        )
773
774
775class CalibrationDataCollector(metaclass=abc.ABCMeta):
776    """
777    Base class for collecting data for calibration-based quantization.
778    """
779
780    @abc.abstractmethod
781    def collect(self, name_to_arr):
782        """
783        Generate informative data based on given data.
784            name_to_arr : dict
785                tensor name to NDArray data
786        """
787        raise NotImplementedError
788
789    @abc.abstractmethod
790    def compute_collection_result(self):
791        """
792        Get the optimal result among collection data.
793        """
794        raise NotImplementedError
795
796
797class HistogramCollector(CalibrationDataCollector):
798    """
799    Collecting histogram for each tensor. Percentile and Entropy method are supported.
800
801    ref: https://github.com//apache/incubator-mxnet/blob/master/python/mxnet/contrib/quantization.py
802    ref: https://docs.nvidia.com/deeplearning/tensorrt/pytorch-quantization-toolkit/docs/_modules/
803                 pytorch_quantization/calib/histogram.html
804    """
805
806    def __init__(self, method, symmetric, num_bins, num_quantized_bins, percentile, scenario):
807        self.histogram_dict = {}
808        self.method = method
809        self.symmetric = symmetric
810        self.num_bins = num_bins
811        self.num_quantized_bins = num_quantized_bins
812        self.percentile = percentile
813        self.scenario = scenario
814
815    def get_histogram_dict(self):
816        return self.histogram_dict
817
818    def collect(self, name_to_arr):
819        print("Collecting tensor data and making histogram ...")
820
821        # TODO: Currently we have different collect() for entropy and percentile method respectively.
822        #       Need unified collect in the future.
823        if self.method in {"distribution", "entropy"}:
824            return self.collect_value(name_to_arr)
825        elif self.method == "percentile":
826            if self.symmetric:
827                return self.collect_absolute_value(name_to_arr)
828            else:
829                return self.collect_value(name_to_arr)
830        else:
831            raise ValueError("Only 'entropy', 'percentile' or 'distribution' methods are supported")
832
833    def collect_absolute_value(self, name_to_arr):
834        """
835        Collect histogram on absolute value
836        """
837        for tensor, data_arr in name_to_arr.items():
838            if isinstance(data_arr, list):
839                for arr in data_arr:
840                    assert isinstance(arr, np.ndarray), f"Unexpected type {type(arr)} for tensor={tensor!r}"
841                dtypes = {a.dtype for a in data_arr}
842                assert len(dtypes) == 1, (
843                    f"The calibration expects only one element type but got {dtypes} for tensor={tensor!r}"
844                )
845                data_arr_np = np.asarray(data_arr)
846            elif not isinstance(data_arr, np.ndarray):
847                raise ValueError(f"Unexpected type {type(data_arr)} for tensor={tensor!r}")
848            else:
849                data_arr_np = data_arr
850            data_arr_np = data_arr_np.flatten()
851            if data_arr_np.size > 0:
852                min_value = np.nanmin(data_arr_np)
853                max_value = np.nanmax(data_arr_np)
854            else:
855                min_value = np.array(0, dtype=data_arr_np.dtype)
856                max_value = np.array(0, dtype=data_arr_np.dtype)
857
858            data_arr_np = np.absolute(data_arr_np)  # only consider absolute value
859
860            if tensor not in self.histogram_dict:
861                # first time it uses num_bins to compute histogram.
862                hist, hist_edges = np.histogram(data_arr_np, bins=self.num_bins)
863                hist_edges = hist_edges.astype(data_arr_np.dtype)
864                assert data_arr_np.dtype != np.float64, (
865                    "only float32 or float16 is supported, every constant must be explicitly typed"
866                )
867                self.histogram_dict[tensor] = (hist, hist_edges, min_value, max_value)
868            else:
869                old_histogram = self.histogram_dict[tensor]
870                old_min = old_histogram[2]
871                old_max = old_histogram[3]
872                assert hasattr(old_min, "dtype"), f"old_min should be a numpy array but is {type(old_min)}"
873                assert hasattr(old_max, "dtype"), f"old_min should be a numpy array but is {type(old_max)}"
874                old_hist = old_histogram[0]
875                old_hist_edges = old_histogram[1]
876                temp_amax = np.nanmax(data_arr_np)
877                if temp_amax > old_hist_edges[-1]:
878                    # increase the number of bins
879                    width = old_hist_edges[1] - old_hist_edges[0]
880                    # NOTE: np.arange may create an extra bin after the one containing temp_amax
881                    new_bin_edges = np.arange(old_hist_edges[-1] + width, temp_amax + width, width)
882                    old_hist_edges = np.hstack((old_hist_edges, new_bin_edges))
883                hist, hist_edges = np.histogram(data_arr_np, bins=old_hist_edges)
884                hist_edges = hist_edges.astype(data_arr_np.dtype)
885                hist[: len(old_hist)] += old_hist
886                assert data_arr_np.dtype != np.float64, (
887                    "only float32 or float16 is supported, every constant must be explicitly typed"
888                )
889                self.histogram_dict[tensor] = (hist, hist_edges, min(old_min, min_value), max(old_max, max_value))
890
891    def collect_value(self, name_to_arr):
892        """
893        Collect histogram on real value
894        """
895        for tensor, data_arr in name_to_arr.items():
896            data_arr = np.asarray(data_arr)  # noqa: PLW2901
897            data_arr = data_arr.flatten()  # noqa: PLW2901
898
899            if data_arr.size > 0:
900                min_value = np.nanmin(data_arr)
901                max_value = np.nanmax(data_arr)
902            else:
903                min_value = np.array(0, dtype=data_arr.dtype)
904                max_value = np.array(0, dtype=data_arr.dtype)
905
906            threshold = np.array(max(abs(min_value), abs(max_value)), dtype=data_arr.dtype)
907
908            if tensor in self.histogram_dict:
909                old_histogram = self.histogram_dict[tensor]
910                self.histogram_dict[tensor] = self.merge_histogram(
911                    old_histogram, data_arr, min_value, max_value, threshold
912                )
913            else:
914                hist, hist_edges = np.histogram(data_arr, self.num_bins, range=(-threshold, threshold))
915                self.histogram_dict[tensor] = (
916                    hist,
917                    hist_edges,
918                    min_value,
919                    max_value,
920                    threshold,
921                )
922
923    def merge_histogram(self, old_histogram, data_arr, new_min, new_max, new_threshold):
924        (old_hist, old_hist_edges, old_min, old_max, old_threshold) = old_histogram
925
926        if new_threshold <= old_threshold:
927            new_hist, _ = np.histogram(data_arr, len(old_hist), range=(-old_threshold, old_threshold))
928            return (
929                new_hist + old_hist,
930                old_hist_edges,
931                min(old_min, new_min),
932                max(old_max, new_max),
933                old_threshold,
934            )
935        else:
936            if old_threshold == 0:
937                hist, hist_edges = np.histogram(data_arr, len(old_hist), range=(-new_threshold, new_threshold))
938                hist += old_hist
939            else:
940                old_num_bins = len(old_hist)
941                old_stride = 2 * old_threshold / old_num_bins
942                half_increased_bins = int((new_threshold - old_threshold) // old_stride + 1)
943                new_num_bins = old_num_bins + 2 * half_increased_bins
944                new_threshold = half_increased_bins * old_stride + old_threshold
945                hist, hist_edges = np.histogram(data_arr, new_num_bins, range=(-new_threshold, new_threshold))
946                hist[half_increased_bins : new_num_bins - half_increased_bins] += old_hist
947            return (
948                hist,
949                hist_edges,
950                min(old_min, new_min),
951                max(old_max, new_max),
952                new_threshold,
953            )
954
955    def compute_collection_result(self):
956        if not self.histogram_dict or len(self.histogram_dict) == 0:
957            raise ValueError("Histogram has not been collected. Please run collect() first.")
958        print(f"Finding optimal threshold for each tensor using {self.method!r} algorithm ...")
959
960        if self.method == "entropy":
961            return self.compute_entropy()
962        elif self.method == "percentile":
963            return self.compute_percentile()
964        elif self.method == "distribution":
965            return self.compute_distribution()
966        else:
967            raise ValueError("Only 'entropy', 'percentile' or 'distribution' methods are supported")
968
969    def compute_percentile(self):
970        if self.percentile < 0 or self.percentile > 100:
971            raise ValueError("Invalid percentile. Must be in range 0 <= percentile <= 100.")
972
973        histogram_dict = self.histogram_dict
974        percentile = self.percentile
975
976        thresholds_dict = {}  # per tensor thresholds
977
978        print(f"Number of tensors : {len(histogram_dict)}")
979        print(f"Number of histogram bins : {self.num_bins}")
980        print(f"Percentile : ({100.0 - percentile},{percentile})")
981
982        for tensor, histogram in histogram_dict.items():
983            hist = histogram[0]
984            hist_edges = histogram[1]
985            total = hist.sum()
986            cdf = np.cumsum(hist / total)
987            if self.symmetric:
988                idx_right = np.searchsorted(cdf, percentile / 100.0)
989
990                thresholds_dict[tensor] = (
991                    -np.array(hist_edges[idx_right], dtype=hist_edges.dtype),
992                    np.array(hist_edges[idx_right], dtype=hist_edges.dtype),
993                )
994            else:
995                percent_to_cut_one_side = (100.0 - percentile) / 200.0
996                idx_right = np.searchsorted(cdf, 1.0 - percent_to_cut_one_side)
997                idx_left = np.searchsorted(cdf, percent_to_cut_one_side)
998                thresholds_dict[tensor] = (
999                    np.array(hist_edges[idx_left], dtype=hist_edges.dtype),
1000                    np.array(hist_edges[idx_right], dtype=hist_edges.dtype),
1001                )
1002            min_value = histogram[2]
1003            max_value = histogram[3]
1004            if thresholds_dict[tensor][0] < min_value:
1005                thresholds_dict[tensor] = (min_value, thresholds_dict[tensor][1])
1006            if thresholds_dict[tensor][1] > max_value:
1007                thresholds_dict[tensor] = (thresholds_dict[tensor][0], max_value)
1008            thresholds_dict[tensor] = (*thresholds_dict[tensor], *hist[:2])
1009            # Plot histogram for debug only
1010            if os.environ.get("QUANTIZATION_DEBUG", "0") in (1, "1"):
1011                apply_plot(hist, hist_edges)
1012
1013        return thresholds_dict
1014
1015    def compute_entropy(self):
1016        histogram_dict = self.histogram_dict
1017        num_quantized_bins = self.num_quantized_bins
1018
1019        thresholds_dict = {}  # per tensor thresholds
1020
1021        print(f"Number of tensors : {len(histogram_dict)}")
1022        print(f"Number of histogram bins : {self.num_bins} (The number may increase depends on the data it collects)")
1023        print(f"Number of quantized bins : {self.num_quantized_bins}")
1024
1025        for tensor, histogram in histogram_dict.items():
1026            optimal_threshold = self.get_entropy_threshold(histogram, num_quantized_bins)
1027            thresholds_dict[tensor] = optimal_threshold
1028            thresholds_dict[tensor] = (*optimal_threshold, *histogram[:2])
1029
1030            # Plot histogram for debug only
1031            if os.environ.get("QUANTIZATION_DEBUG", "0") in (1, "1"):
1032                apply_plot(histogram[0], histogram[1])
1033
1034        return thresholds_dict
1035
1036    @staticmethod
1037    def _avg_std(hist, hist_edges, power=1):
1038        if power <= 0:
1039            raise ValueError(f"power={power} <= 0 is invalid.")
1040        values = (hist_edges[:-1] + hist_edges[1:]) * 0.5
1041        if power == 1:
1042            avg = (hist * values).sum() / hist.sum()
1043            std = ((hist * values**2).sum() / hist.sum() - avg**2) ** 0.5
1044            return np.array(avg, dtype=hist_edges.dtype), np.array(std, dtype=hist_edges.dtype)
1045        if int(power) == power and int(power) % 2 == 1:
1046            avg = (hist * values**power).sum() / hist.sum()
1047            std = ((hist * (values**power - avg) ** 2).sum() / hist.sum()) ** 0.5
1048            return np.array(avg, dtype=hist_edges.dtype), np.array(std, dtype=hist_edges.dtype)
1049
1050        fact = np.abs(values) / values
1051        fact[np.isnan(fact)] = 1
1052        fact[np.isinf(fact)] = 1
1053        values = np.abs(values) ** power * fact
1054        avg = (hist * values).sum() / hist.sum()
1055        std = ((hist * values**2).sum() / hist.sum() - avg**2) ** 0.5
1056        return np.array(avg, dtype=hist_edges.dtype), np.array(std, dtype=hist_edges.dtype)
1057
1058    def compute_distribution(self):
1059        if self.num_bins < 512:
1060            raise ValueError("Invalid num_bins. Must be in range 512 <= num_bins.")
1061
1062        histogram_dict = self.histogram_dict
1063        thresholds_dict = {}  # per tensor thresholds
1064
1065        print(f"Number of tensors : {len(histogram_dict)}")
1066        print(f"Number of histogram bins : {self.num_bins}")
1067        print(f"Scenario : {self.scenario!r})")
1068
1069        for tensor, histogram in histogram_dict.items():
1070            hist = histogram[0]
1071            hist_edges = histogram[1]
1072
1073            assert hist_edges.dtype != np.float64
1074            if self.scenario == "same":
1075                avg_coef, std_coef = self._avg_std(hist, hist_edges, power=1)
1076            elif self.scenario == "p3":
1077                avg_coef, std_coef = self._avg_std(hist, hist_edges, power=1.0 / 3.0)
1078            else:
1079                raise ValueError("Invalid scenario. Must be in {'same', 'p3'}.")
1080            assert avg_coef.dtype != np.float64
1081            assert std_coef.dtype != np.float64
1082            assert hist_edges.dtype != np.float64
1083            thresholds_dict[tensor] = TensorData(
1084                avg=avg_coef,
1085                std=std_coef,
1086                hist=hist,
1087                hist_edges=hist_edges,
1088                lowest=hist_edges.min(),
1089                highest=hist_edges.max(),
1090            )
1091
1092            # Plot histogram for debug only
1093            if os.environ.get("QUANTIZATION_DEBUG", "0") in (1, "1"):
1094                apply_plot(hist, hist_edges)
1095
1096        return thresholds_dict
1097
1098    def get_entropy_threshold(self, histogram, num_quantized_bins):
1099        """Given a dataset, find the optimal threshold for quantizing it.
1100        The reference distribution is `q`, and the candidate distribution is `p`.
1101        `q` is a truncated version of the original distribution.
1102        Ref: http://on-demand.gputechconf.com/gtc/2017/presentation/s7310-8-bit-inference-with-tensorrt.pdf
1103        """
1104        hist = histogram[0]
1105        hist_edges = histogram[1]
1106        num_bins = hist.size
1107        zero_bin_index = num_bins // 2
1108        num_half_quantized_bin = num_quantized_bins // 2
1109
1110        dtype = histogram[1].dtype
1111        kl_divergence = np.zeros(zero_bin_index - num_half_quantized_bin + 1)
1112        thresholds = [(np.array(0, dtype=dtype), np.array(0, dtype=dtype)) for i in range(kl_divergence.size)]
1113
1114        # <------------ num bins ---------------->
1115        #        <--- quantized bins ---->
1116        # |======|===========|===========|=======|
1117        #              zero bin index
1118        #        ^                       ^
1119        #        |                       |
1120        #   start index               end index          (start of iteration)
1121        #     ^                             ^
1122        #     |                             |
1123        #  start index                  end index               ...
1124        # ^                                      ^
1125        # |                                      |
1126        # start index                    end index       (end of iteration)
1127
1128        for i in range(num_half_quantized_bin, zero_bin_index + 1, 1):
1129            start_index = zero_bin_index - i
1130            end_index = min(zero_bin_index + i + 1, num_bins)
1131
1132            thresholds[i - num_half_quantized_bin] = (hist_edges[start_index], hist_edges[end_index])
1133
1134            sliced_distribution = copy.deepcopy(hist[start_index:end_index])
1135
1136            # reference distribution p
1137            p = sliced_distribution.copy()  # a copy of np array
1138            left_outliers_count = sum(hist[:start_index])
1139            right_outliers_count = sum(hist[end_index:])
1140            p[0] += left_outliers_count
1141            p[-1] += right_outliers_count
1142
1143            # nonzeros[i] incidates whether p[i] is non-zero
1144            nonzeros = (p != 0).astype(np.int64)
1145
1146            # quantize p.size bins into quantized bins (default 128 bins)
1147            quantized_bins = np.zeros(num_quantized_bins, dtype=np.int64)
1148            num_merged_bins = sliced_distribution.size // num_quantized_bins
1149
1150            # merge bins into quantized bins
1151            for index in range(num_quantized_bins):
1152                start = index * num_merged_bins
1153                end = start + num_merged_bins
1154                quantized_bins[index] = sum(sliced_distribution[start:end])
1155            quantized_bins[-1] += sum(sliced_distribution[num_quantized_bins * num_merged_bins :])
1156
1157            # in order to compare p and q, we need to make length of q equals to length of p
1158            # expand quantized bins into p.size bins
1159            q = np.zeros(p.size, dtype=np.int64)
1160            for index in range(num_quantized_bins):
1161                start = index * num_merged_bins
1162                end = start + num_merged_bins
1163
1164                norm = sum(nonzeros[start:end])
1165                if norm != 0:
1166                    q[start:end] = quantized_bins[index] / norm
1167
1168            p = smooth_distribution(p)
1169            q = smooth_distribution(q)
1170            if p is None or q is None:
1171                div = np.array(np.inf, dtype=dtype)
1172            else:
1173                div = np.array(entropy(p, q), dtype=dtype)
1174            kl_divergence[i - num_half_quantized_bin] = div
1175
1176        min_kl_divergence_idx = np.argmin(kl_divergence)
1177        optimal_threshold = thresholds[min_kl_divergence_idx]
1178        min_value = histogram[2]
1179        max_value = histogram[3]
1180        if optimal_threshold[0] < min_value:
1181            optimal_threshold = (min_value, optimal_threshold[1])
1182        if optimal_threshold[1] > max_value:
1183            optimal_threshold = (optimal_threshold[0], max_value)
1184        assert hasattr(optimal_threshold[0], "dtype")
1185        assert hasattr(optimal_threshold[1], "dtype")
1186        return optimal_threshold
1187
1188
1189def create_calibrator(
1190    model: str | Path,
1191    op_types_to_calibrate: Sequence[str] | None = None,
1192    augmented_model_path="augmented_model.onnx",
1193    calibrate_method=CalibrationMethod.MinMax,
1194    use_external_data_format=False,
1195    providers=None,
1196    extra_options={},  # noqa: B006
1197):
1198    calibrator = None
1199    if calibrate_method == CalibrationMethod.MinMax:
1200        # default settings for min-max algorithm

Showing the first 1,200 of 1268 lines. Download the file for the rest.

codekingpro/portable-devtools · Team Ai