#
# SPDX-FileCopyrightText: Copyright (c) 1993-2023 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#


"""Tests of calibrators"""
import pytest
import numpy as np

import torch

from pytorch_quantization import utils as quant_utils
from pytorch_quantization import calib
from pytorch_quantization import nn as quant_nn
import tests.utils as test_utils
from tests.fixtures import verbose
from tests.fixtures.models import QuantLeNet

np.random.seed(12345)
torch.manual_seed(12345)

# pylint:disable=missing-docstring, no-self-use

class TestMaxCalibrator():

    def test_simple_run(self):
        max_calibrator = calib.MaxCalibrator(8, None, False)

        x_1 = torch.rand(129).cuda()
        x_2 = torch.rand(127).cuda()
        max_calibrator.collect(x_1)
        max_calibrator.collect(x_2)

        test_utils.compare(max_calibrator.compute_amax(), torch.max(x_1.max(), x_2.max()), atol=0, rtol=0, ctol=0)

        # Nothing to test other than creation
        max_calibrator = calib.MaxCalibrator(8, None, True)

    def test_fine_grain(self):
        axis = 0
        reducs_axis = (1, 2, 3)
        max_calibrator = calib.MaxCalibrator(8, axis, False)

        x_1 = torch.rand(31, 63, 7, 7).cuda()
        x_2 = torch.rand(31, 63, 7, 7).cuda()
        max_calibrator.collect(x_1)
        max_calibrator.collect(x_2)

        assert max_calibrator.compute_amax().shape[0] == 31

        test_utils.compare(max_calibrator.compute_amax(),
                           quant_utils.reduce_amax(torch.max(x_1, x_2), axis=reducs_axis),
                           atol=0, rtol=0, ctol=0)

        max_calibrator.reset()
        assert max_calibrator.compute_amax() is None

    def test_reverse_axis(self):
        axis = -4
        reducs_axis = (1, 2, 3)
        max_calibrator = calib.MaxCalibrator(8, axis, False)

        x_1 = torch.rand(31, 63, 7, 7).cuda()
        x_2 = torch.rand(31, 63, 7, 7).cuda()
        max_calibrator.collect(x_1)
        max_calibrator.collect(x_2)

        assert max_calibrator.compute_amax().shape[0] == 31

        test_utils.compare(max_calibrator.compute_amax(),
                           quant_utils.reduce_amax(torch.max(x_1, x_2), axis=reducs_axis),
                           atol=0, rtol=0, ctol=0)

        max_calibrator.reset()
        assert max_calibrator.compute_amax() is None

    def test_raises(self):
        axis = 0
        max_calibrator = calib.MaxCalibrator(8, axis, False)

        x_2 = torch.rand(32, 63, 7, 7).cuda()
        x_3 = torch.rand(33, 63, 7, 7).cuda()
        max_calibrator.collect(x_2)
        with pytest.raises(RuntimeError, match="shape changed"):
            max_calibrator.collect(x_3)

    def test_track_amax(self):
        max_calibrator = calib.MaxCalibrator(8, None, False, track_amax=True)

        x_1 = torch.rand(129).cuda()
        x_2 = torch.rand(127).cuda()
        max_calibrator.collect(x_1)
        max_calibrator.collect(x_2)

        test_utils.compare(max_calibrator.compute_amax(), torch.max(x_1.max(), x_2.max()), atol=0, rtol=0, ctol=0)
        np.testing.assert_array_equal(max_calibrator.amaxs[0], x_1.max().cpu().numpy())
        np.testing.assert_array_equal(max_calibrator.amaxs[1], x_2.max().cpu().numpy())

    def test_repr(self):
        max_calibrator = calib.MaxCalibrator(8, None, False, track_amax=True)
        repr(max_calibrator)

class TestHistogramCalibrator():

    def test_grow(self, verbose):
        x_1 = torch.tensor([0, 255, 255, 255, 255, 255]).cuda()
        x_2 = torch.tensor([0, 255, 255, 255, 255, 256]).cuda()

        hist_calibrator = calib.HistogramCalibrator(8, None, False, grow_method='stretch')
        hist_calibrator.collect(x_1)
        hist_calibrator.collect(x_2)

        amax = hist_calibrator.compute_amax(method='entropy')

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be closer to 256 because the last bin gets stretched to (~255, 257)
        assert (amax - 255.).abs() < (amax - 256.).abs()

        hist_calibrator = calib.HistogramCalibrator(8, None, False, grow_method='append')
        hist_calibrator.collect(x_1)
        hist_calibrator.collect(x_2)

        amax = hist_calibrator.compute_amax(method='mse')

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be closer to 255
        assert (amax - 255.).abs() < 0.5

    def test_skip_zeros(self, verbose):
        x_1 = torch.tensor([0, 0, 0, 0, 0, 1, 2, 3, 4, 5])
        x_2 = torch.tensor([0, 0, 0, 0, 0, 6, 7, 8, 9, 10])

        calibrator = calib.HistogramCalibrator(8, None, False, skip_zeros=True)
        calibrator.collect(x_1)
        calibrator.collect(x_2)

        amax = calibrator.compute_amax("percentile", percentile=50)

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be close to 5
        assert (amax - 5.).abs() < 10/2048

    def test_torch_hist(self):
        x_1 = torch.rand(1023, device="cuda")
        x_1[0] = 0
        x_2 = torch.rand(1023, device="cuda") + 1  # Make sure histogram bins need to be grown
        x_2[1] = 0

        calibrator_np = calib.HistogramCalibrator(8, None, False, num_bins=19, torch_hist=False)
        calibrator_torch = calib.HistogramCalibrator(8, None, False, num_bins=19, torch_hist=True)

        calibrator_np.collect(x_1)
        calibrator_torch.collect(x_1)
        assert calibrator_torch._calib_hist.numel() == calibrator_torch._calib_bin_edges.numel() - 1
        np.testing.assert_array_equal(calibrator_np._calib_hist, calibrator_torch._calib_hist.cpu().numpy())
        np.testing.assert_array_almost_equal(
            calibrator_np._calib_bin_edges, calibrator_torch._calib_bin_edges.cpu().numpy())

        # Test multiple collections with some of them needs to expand range
        for _ in range(3):
            calibrator_np.collect(x_2)
            calibrator_torch.collect(x_2)
            calibrator_np.collect(x_1)
            calibrator_torch.collect(x_1)

            # Test compute_amax function doesn't convert _calib_hist and _calib_bin_edges unnecessarily
            calibrator_np.compute_amax("percentile", percentile=99.99)
            calibrator_torch.compute_amax("percentile", percentile=99.99)

            np.testing.assert_array_equal(calibrator_np._calib_hist, calibrator_torch._calib_hist.cpu().numpy())
            np.testing.assert_array_almost_equal(
                calibrator_np._calib_bin_edges, calibrator_torch._calib_bin_edges.cpu().numpy())
            assert calibrator_torch._calib_hist.numel() == calibrator_torch._calib_bin_edges.numel() - 1


class TestEntropyCalibrator():

    def test_one_tensor(self, verbose):
        hist_calibrator = calib.HistogramCalibrator(8, None, False, grow_method='stretch')

        x_2 = torch.rand(11, 7, 3, 3).cuda() # uniform in (0,1)
        x_2[1, 1, 1, 1] = 10. # create outlier
        hist_calibrator.collect(x_2)

        # Don't have a better test metric. One outlier 10 should be discared by KL-divergence
        amax = hist_calibrator.compute_amax("entropy")

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        assert amax < 1.1

    def test_unsigned(self, verbose):
        hist_calibrator = calib.HistogramCalibrator(8, None, True, grow_method='stretch')

        x_2 = torch.rand(11, 7, 3, 3).cuda() # uniform in (0,1)
        x_2[1, 1, 1, 1] = 10. # create outlier
        hist_calibrator.collect(x_2)

        amax = hist_calibrator.compute_amax("entropy")

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        assert amax < 1.1

    @pytest.mark.parametrize("torch_hist", [False, True])
    def test_two_tensor(self, torch_hist, verbose):
        hist_calibrator = calib.HistogramCalibrator(8, None, False, torch_hist=torch_hist)

        x_2 = torch.rand(11, 7, 3, 3).cuda() # uniform in (0,1)
        x_2[1, 1, 1, 1] = 10. # create outlier


        x_2 = torch.rand(11, 7, 3, 3).cuda() # uniform in (0,1)
        x_2[1, 1, 1, 1] = 10. # create outlier
        hist_calibrator.collect(x_2)
        x_3 = torch.rand(11, 7, 3, 3).cuda()
        hist_calibrator.collect(x_3)

        # Don't have a better test metric. One outlier 10 should be discared by KL-divergence
        amax = hist_calibrator.compute_amax("entropy")

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        assert amax < 1.1

    def test_repr(self):
        hist_calibrator = calib.HistogramCalibrator(8, None, True)
        repr(hist_calibrator)

class TestMSECalibrator():

    def test_one_tensor(self, verbose):
        calibrator = calib.HistogramCalibrator(8, None, False)

        x_1 = torch.ones(11, 7, 3, 3).cuda() * 255.
        x_1[1, 1, 1, 1] = 256. # create an outlier
        calibrator.collect(x_1)

        amax = calibrator.compute_amax("mse")

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be closer to 255
        assert (amax - 255.).abs() < (amax - 256.).abs()

    def test_unsigned_one_tensor(self, verbose):
        calibrator = calib.HistogramCalibrator(8, None, True)

        x_1 = torch.ones(11, 7, 3, 3).cuda() * 512.
        x_1[1, 1, 1, 1] = 513. # create an outlier
        calibrator.collect(x_1)

        amax = calibrator.compute_amax("mse")

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be closer to 512
        assert (amax - 512.).abs() < (amax - 513.).abs()

    @pytest.mark.parametrize("torch_hist", [False, True])
    def test_two_tensor(self, torch_hist, verbose):
        calibrator = calib.HistogramCalibrator(8, None, False, torch_hist=torch_hist)

        x_1 = torch.ones(11, 7, 3, 3).cuda() * 255.
        x_1[1, 1, 1, 1] = 256. # create an outlier
        calibrator.collect(x_1)
        x_2 = torch.ones(11, 7, 3, 3).cuda() * 255.
        calibrator.collect(x_2)

        amax = calibrator.compute_amax("mse")

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be closer to 255
        assert (amax - 255.).abs() < (amax - 256.).abs()

    def test_repr(self):
        calibrator = calib.HistogramCalibrator(8, None, False)
        repr(calibrator)

class TestPercentileCalibrator():

    def test_one_tensor(self, verbose):
        calibrator = calib.HistogramCalibrator(8, None, False)

        x_1 = torch.arange(100)
        calibrator.collect(x_1)

        amax = calibrator.compute_amax("percentile", percentile=90)

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be approximately 89
        assert (amax - 89.).abs() < 100/1024

    def test_unsigned_one_tensor(self, verbose):
        calibrator = calib.HistogramCalibrator( 8, None, True)

        x_1 = torch.arange(100)
        calibrator.collect(x_1)

        amax = calibrator.compute_amax("percentile", percentile=80)

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be approximately 79
        assert (amax - 79.).abs() < 100/2048

    @pytest.mark.parametrize("torch_hist", [False, True])
    def test_two_tensor(self, torch_hist, verbose):
        calibrator = calib.HistogramCalibrator(8, None, False, torch_hist=torch_hist)

        x_1 = torch.arange(100)
        calibrator.collect(x_1)
        x_2 = torch.arange(0, 50, 0.5)
        calibrator.collect(x_2)
        amax = calibrator.compute_amax("percentile", percentile=99)

        if verbose:
            print('amax={:.4f}'.format(amax.item()), end=' ')

        # amax should be approximately 97
        assert (amax - 97.).abs() < 100/1024

    def test_repr(self):
        calibrator = calib.HistogramCalibrator(8, None, False)
        repr(calibrator)

    def test_range(self):
        calibrator = calib.HistogramCalibrator(8, None, False)
        x_1 = torch.arange(100)
        calibrator.collect(x_1)
        with pytest.raises(ValueError, match="range"):
            calibrator.compute_amax("percentile", percentile=-10)
        with pytest.raises(ValueError, match="range"):
            calibrator.compute_amax("percentile", percentile=200)

class TestCalibrateWeights():

    def test_max(self):
        torch.manual_seed(12345)
        ref_lenet = QuantLeNet()
        torch.manual_seed(12345)
        test_lenet = QuantLeNet()

        for module in ref_lenet.modules():
            if isinstance(module, (quant_nn.QuantConv2d, quant_nn.QuantLinear)):
                module.weight_quantizer.enable_calib()
                module.weight_quantizer.disable_quant()
                module.weight_quantizer(module.weight)
                module.weight_quantizer.load_calib_amax()

        calib.calibrate_weights(test_lenet, method="max")

        for ref_module, test_module in zip(ref_lenet.modules(), test_lenet.modules()):
            if isinstance(ref_module, (quant_nn.QuantConv2d, quant_nn.QuantLinear)):
                test_utils.compare(
                    ref_module.weight_quantizer.amax, test_module.weight_quantizer.amax, rtol=0, atol=0, ctol=0)
                assert ref_module.weight_quantizer.amax.shape == test_module.weight_quantizer.amax.shape

    def test_shape_with_axis(self):
        """Check calibrate_weight function returns same shape as TensorQuantizer"""
        torch.manual_seed(12345)
        ref_lenet = QuantLeNet()
        torch.manual_seed(12345)
        test_lenet = QuantLeNet()

        for module in ref_lenet.modules():
            if isinstance(module, (quant_nn.QuantConv2d, quant_nn.QuantLinear)):
                module.weight_quantizer.enable_calib()
                module.weight_quantizer.disable_quant()
                module.weight_quantizer(module.weight)
                module.weight_quantizer.load_calib_amax()

        calib.calibrate_weights(test_lenet, method="percentile")

        for ref_module, test_module in zip(ref_lenet.modules(), test_lenet.modules()):
            if isinstance(ref_module, (quant_nn.QuantConv2d, quant_nn.QuantLinear)):
                assert ref_module.weight_quantizer.amax.shape == test_module.weight_quantizer.amax.shape

    def test_percentile(self):
        torch.manual_seed(12345)
        test_lenet = QuantLeNet()
        test_percentile = 99.99

        ref_calibrator = calib.HistogramCalibrator(8, None, False)

        calib.calibrate_weights(test_lenet, method="percentile", perchannel=False, percentile=test_percentile)
        ref_calibrator.collect(test_lenet.conv1.weight)
        ref_amax = ref_calibrator.compute_amax("percentile", percentile=test_percentile)
        test_utils.compare(ref_amax, test_lenet.conv1.weight_quantizer.amax, rtol=0, atol=0, ctol=0)

    def test_percentile_with_axis(self):
        torch.manual_seed(12345)
        test_lenet = QuantLeNet()
        test_percentile = 99.99

        ref_calibrator = calib.HistogramCalibrator(8, None, False)

        calib.calibrate_weights(test_lenet, method="percentile", perchannel=True, percentile=test_percentile)
        ref_calibrator.collect(test_lenet.conv2.weight[1])
        ref_amax = ref_calibrator.compute_amax("percentile", percentile=test_percentile)
        test_utils.compare(ref_amax, test_lenet.conv2.weight_quantizer.amax[1], rtol=0, atol=0, ctol=0)

    def test_mse(self):
        torch.manual_seed(12345)
        test_lenet = QuantLeNet()

        ref_calibrator = calib.HistogramCalibrator(8, None, False)

        calib.calibrate_weights(test_lenet, method="mse", perchannel=False)
        ref_calibrator.collect(test_lenet.conv1.weight)
        ref_amax = ref_calibrator.compute_amax("mse")
        test_utils.compare(ref_amax, test_lenet.conv1.weight_quantizer.amax, rtol=0, atol=0, ctol=0)

    def test_mse_with_axis(self):
        torch.manual_seed(12345)
        test_lenet = QuantLeNet()

        ref_calibrator = calib.HistogramCalibrator(8, None, False)

        calib.calibrate_weights(test_lenet, method="mse", perchannel=True)
        ref_calibrator.collect(test_lenet.conv2.weight[1])
        ref_amax = ref_calibrator.compute_amax("mse")
        test_utils.compare(ref_amax, test_lenet.conv2.weight_quantizer.amax[1], rtol=0, atol=0, ctol=0)
