#
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
#
# 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.
#


"""Utils for testing quantization."""
import numpy as np
from scipy.spatial import distance

import torch

from pytorch_quantization import tensor_quant

def quantize_by_range(x, num_bits):
    """Quantize torch tensor by range to num_bits with symmetric zero-mean quantizer."""
    amax = x.abs().max()
    x_q = tensor_quant.fake_tensor_quant(x, amax, num_bits)
    return x_q

def quantize_by_range_fused(x_tuple, num_bits):
    """Quantize multiple torch tensors by combined range to num_bits with symmetric zero-mean quantizer."""
    # compute aggregate amax across all tensors
    amax = max([x.abs().max() for x in x_tuple])
    # quantize each tensor with the aggregate amax
    x_q_tuple = tuple(tensor_quant.fake_tensor_quant(x, amax, num_bits) for x in x_tuple)
    return x_q_tuple

def copy_state_and_quantize(dst, src, num_bits):
    """Copy src to dst, quantize all 'weight' entries to num_bits."""
    src_state_dict = src.state_dict()
    dst_state_dict = dict()
    for key in src_state_dict:
        if 'weight' in key:
            dst_state_dict[key] = quantize_by_range(src_state_dict[key], num_bits)
        else:
            dst_state_dict[key] = src_state_dict[key].clone()

    dst.load_state_dict(dst_state_dict)

def copy_state_and_quantize_fused(dst, src, num_bits):
    """Copy src to dst, quantize all 'weight' entries to num_bits using the aggregate amax."""
    src_state_dict = src.state_dict()
    dst_state_dict = dict()

    # compute aggregate amax across all weight tensors
    amax = 0
    for key in src_state_dict:
        if 'weight' in key:
            amax = max(amax, src_state_dict[key].abs().max())

    # quantize each weight tensor with the aggregate amax
    for key in src_state_dict:
        if 'weight' in key:
            dst_state_dict[key] = tensor_quant.fake_tensor_quant(src_state_dict[key], amax, num_bits)
        else:
            dst_state_dict[key] = src_state_dict[key].clone()

    dst.load_state_dict(dst_state_dict)

def compare(a, b, rtol=1e-7, atol=1e-6, ctol=1e-6):
    """Compare two tensors and raise AssertionError if their difference is outside of tolerance."""
    if torch.isinf(a).any():
        raise ValueError("a contains infs")
    if torch.isinf(b).any():
        raise ValueError("b contains infs")

    a = a.detach().cpu().numpy().flatten()
    b = b.detach().cpu().numpy().flatten()

    # compare elements of a and b relative to the max value in b
    # large fp32 values may cause quantization errors that propagate to small values
    rel_diff = np.abs(a-b)/np.linalg.norm(b)
    abs_diff = np.abs(a-b)
    cos_diff = distance.cosine(a, b)
    try:
        if rel_diff.max() > rtol:
            raise AssertionError("Tensor relative error > %.2e (%.2e)" % (rtol, rel_diff.max()))
        if abs_diff.max() > atol:
            raise AssertionError("Tensor absolute error > %.2e (%.2e)" % (atol, abs_diff.max()))
        if cos_diff > ctol:
            raise AssertionError("Tensor cosine distance > %.2e (%.2e)" % (ctol, cos_diff))
        # np.testing.assert_allclose(a, b, rtol=rtol, atol=atol)
        # np.testing.assert_array_almost_equal_nulp(a, b)
    except AssertionError as e:
        print('norm(a) =', np.linalg.norm(a))
        print('norm(b) =', np.linalg.norm(b))
        print('Largest relative difference = %.2e' % rel_diff.max())
        idx = np.argmax(rel_diff)
        print('a[%d] = %.10f' % (idx, a[idx]))
        print('b[%d] = %.10f' % (idx, b[idx]))
        print('Largest absolute difference = %.2e' % abs_diff.max())
        idx = np.argmax(abs_diff)
        print('a[%d] = %.10f' % (idx, a[idx]))
        print('b[%d] = %.10f' % (idx, b[idx]))
        print('Cosine distance = %.2e' % cos_diff)
        raise e

def assert_min_mse(a, b, tol=1e-20):
    """Assert that the mean squared error between a and b is at least tol."""
    a = a.detach().cpu().numpy()
    b = b.detach().cpu().numpy()
    mse = ((a-b)**2).mean()
    if mse < tol:
        raise AssertionError("MSE = %.2e < %.2e" % (mse, tol))

def quant_np(x, amax, num_bits=8, fake=False, narrow_range=True):
    """Quantize x using numpy."""
    intmax = 2.0**(num_bits - 1) - 1
    intmin = -intmax if narrow_range else -intmax - 1
    scale = intmax / amax
    x_q = np.round(np.clip(x * scale, intmin, intmax))

    if fake:
        x_q /= scale

    return x_q
