#
# 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.
#


"""tests of QuantRNN module.
"""
import pytest

import torch
from torch import nn

import numpy as np

from pytorch_quantization.nn.modules import quant_rnn
from pytorch_quantization import tensor_quant

from tests.fixtures import verbose

from . import utils

# make everything run on the GPU
torch.set_default_tensor_type('torch.cuda.FloatTensor')
# change default type to double if utils.compare flags a small error, may just be floating point rounding error
# torch.set_default_tensor_type('torch.cuda.DoubleTensor')

np.random.seed(1234)
torch.manual_seed(1234)
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(1234)

# pylint: disable=no-self-use, missing-docstring, redefined-builtin, bad-continuation

# global state for saving/loading test vectors
SAVE_VECTORS = 0
VECTOR_FILE = 'tests/quant_rnn_test_vectors.pt'
if SAVE_VECTORS:
    TEST_VECTORS = dict()
else:
    TEST_VECTORS = torch.load(VECTOR_FILE)


class TestQuantLSTMCell():
    """
    tests for quant_rnn.QuantLSTMCell
    default parameters in QuantLSTMCell:
        bias=True,
        num_bits_weight=8, quant_mode_weight='per_channel',
        num_bits_input=8, quant_mode_input='per_tensor'

    Tests of real quantization mode (nonfake) are disabled as it is not fully supported yet.
    """

    def test_basic_forward(self, verbose):
        """Do a forward pass on the cell module and see if anything catches fire."""
        batch = 7
        input_size = 11
        hidden_size = 9

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=8)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=8, axis=(1,))
        quant_rnn_object = quant_rnn.QuantLSTMCell(input_size, hidden_size, bias=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        quant_rnn_object._input_quantizer.disable()
        quant_rnn_object._weight_quantizer.disable()

        input = torch.randn(batch, input_size)
        hidden = torch.randn(batch, hidden_size)
        cell = torch.randn(batch, hidden_size)

        quant_rnn_object(input, hx=(hidden, cell))

    def test_no_quant_input_hidden(self, verbose):
        """QuantLSTM with quantization disabled vs. pytorch LSTM for input and hidden inputs."""
        batch = 17
        input_size = 13
        hidden_size = 7

        quant_rnn_object = quant_rnn.QuantLSTMCell(input_size, hidden_size, bias=False)
        quant_rnn_object._input_quantizer.disable()
        quant_rnn_object._weight_quantizer.disable()
        ref_rnn_object = nn.LSTMCell(input_size, hidden_size, bias=False)

        # copy weights from one rnn to the other
        ref_rnn_object.load_state_dict(quant_rnn_object.state_dict())

        input = torch.randn(batch, input_size)
        hidden = torch.randn(batch, hidden_size)
        cell = torch.randn(batch, hidden_size)

        quant_hout, quant_cout = quant_rnn_object(input, hx=(hidden, cell))
        ref_hout, ref_cout = ref_rnn_object(input, hx=(hidden, cell))

        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)

    def test_no_quant_input_hidden_bias(self, verbose):
        """QuantLSTMCell with quantization disabled vs. pytorch LSTMCell for input, hidden inputs and bias."""
        batch = 19
        input_size = 11
        hidden_size = 3

        quant_rnn_object = quant_rnn.QuantLSTMCell(input_size, hidden_size, bias=True)
        quant_rnn_object._input_quantizer.disable()
        quant_rnn_object._weight_quantizer.disable()
        ref_rnn_object = nn.LSTMCell(input_size, hidden_size, bias=True)

        # copy weights from one rnn to the other
        ref_rnn_object.load_state_dict(quant_rnn_object.state_dict())

        input = torch.randn(batch, input_size)
        hidden = torch.randn(batch, hidden_size)
        cell = torch.randn(batch, hidden_size)

        quant_hout, quant_cout = quant_rnn_object(input, hx=(hidden, cell))
        ref_hout, ref_cout = ref_rnn_object(input, hx=(hidden, cell))

        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)

    def test_against_unquantized(self, verbose):
        """Quantization should introduce bounded error utils.compare to pytorch implementation."""
        batch = 9
        input_size = 13
        hidden_size = 7

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=16)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=16, axis=(1,))
        quant_rnn_object = quant_rnn.QuantLSTMCell(input_size, hidden_size, bias=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        ref_rnn_object = nn.LSTMCell(input_size, hidden_size, bias=False)

        # copy weights from one rnn to the other
        ref_rnn_object.load_state_dict(quant_rnn_object.state_dict())

        input = torch.randn(batch, input_size)
        hidden = torch.randn(batch, hidden_size)
        cell = torch.randn(batch, hidden_size)

        quant_hout, quant_cout = quant_rnn_object(input, hx=(hidden, cell))
        ref_hout, ref_cout = ref_rnn_object(input, hx=(hidden, cell))

        # The difference between reference and quantized should be bounded in a range
        # Small values which become 0 after quantization lead to large relative errors. rtol and atol could be
        # much smaller without those values
        utils.compare(quant_hout, ref_hout, rtol=1e-4, atol=1e-4)
        utils.compare(quant_cout, ref_cout, rtol=1e-4, atol=1e-4)

        # check that quantization introduces some error
        utils.assert_min_mse(quant_hout, ref_hout, tol=1e-20)
        utils.assert_min_mse(quant_cout, ref_cout, tol=1e-20)

    def test_quant_input_hidden(self, verbose):
        """QuantLSTMCell vs. manual input quantization + pytorchLSTMCell."""
        batch = 15
        input_size = 121
        hidden_size = 51
        num_bits = 4

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=num_bits)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=num_bits)
        quant_rnn_object = quant_rnn.QuantLSTMCell(input_size, hidden_size, bias=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        ref_rnn_object = nn.LSTMCell(input_size, hidden_size, bias=False)

        input = torch.randn(batch, input_size)
        hidden = torch.randn(batch, hidden_size)
        cell = torch.randn(batch, hidden_size)

        quant_hout, quant_cout = quant_rnn_object(input, hx=(hidden, cell))

        quant_input, quant_hidden = utils.quantize_by_range_fused((input, hidden), num_bits)

        utils.copy_state_and_quantize_fused(ref_rnn_object, quant_rnn_object, num_bits)

        ref_hout, ref_cout = ref_rnn_object(quant_input, hx=(quant_hidden, cell))

        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)

    def test_quant_input_hidden_bias(self, verbose):
        """QuantLSTMCell vs. manual input quantization + pytorchLSTMCell
            bias should not be quantized
        """
        batch = 9
        input_size = 23
        hidden_size = 31
        num_bits = 7

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=num_bits)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=num_bits)
        quant_rnn_object = quant_rnn.QuantLSTMCell(input_size, hidden_size, bias=True,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        ref_rnn_object = nn.LSTMCell(input_size, hidden_size, bias=True)

        input = torch.randn(batch, input_size)
        hidden = torch.randn(batch, hidden_size)
        cell = torch.randn(batch, hidden_size)

        quant_hout, quant_cout = quant_rnn_object(input, hx=(hidden, cell))

        quant_input, quant_hidden = utils.quantize_by_range_fused((input, hidden), num_bits)

        utils.copy_state_and_quantize_fused(ref_rnn_object, quant_rnn_object, num_bits)

        ref_hout, ref_cout = ref_rnn_object(quant_input, hx=(quant_hidden, cell))

        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)

    def test_quant_different_prec(self, verbose):
        """QuantLSTMCell vs. manual input quantization + pytorch LSTMCell
            different input and weight precisions
        """
        batch = 27
        input_size = 11
        hidden_size = 10
        num_bits_weight = 4
        num_bits_input = 8

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=num_bits_input)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=num_bits_weight)
        quant_rnn_object = quant_rnn.QuantLSTMCell(input_size, hidden_size, bias=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        ref_rnn_object = nn.LSTMCell(input_size, hidden_size, bias=False)

        input = torch.randn(batch, input_size)
        hidden = torch.randn(batch, hidden_size)
        cell = torch.randn(batch, hidden_size)

        quant_hout, quant_cout = quant_rnn_object(input, hx=(hidden, cell))

        quant_input, quant_hidden = utils.quantize_by_range_fused((input, hidden), num_bits_input)

        utils.copy_state_and_quantize_fused(ref_rnn_object, quant_rnn_object, num_bits_weight)

        ref_hout, ref_cout = ref_rnn_object(quant_input, hx=(quant_hidden, cell))

        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)


class TestQuantLSTM():
    """
    tests for quant_rnn.QuantLSTM
    default parameters in QuantLSTM:
        bias=True,
        quant_weight=True, bits_weight=8, fake_quantTrue, quant_mode_weight='channel',
        quant_input=True, bits_acts=8, quant_mode_input='tensor'

    Tests of real quantization mode (nonfake) are disabled as it is not fully supported yet.
    """

    def test_basic_forward(self, verbose):
        """Do a forward pass on the layer module and see if anything catches fire."""
        batch = 5
        input_size = 13
        hidden_size = 31
        seq_len = 1

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=8)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=8, axis=(1,))
        quant_rnn_object = quant_rnn.QuantLSTM(input_size, hidden_size,
                num_layers=1, bias=False, batch_first=False, dropout=0, bidirectional=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        input = torch.randn(seq_len, batch, input_size)
        hidden = torch.randn(seq_len, batch, hidden_size)
        cell = torch.randn(seq_len, batch, hidden_size)
        quant_rnn_object(input, hx=(hidden, cell))

    def test_no_quant(self, verbose):
        """QuantLSTM with quantization disabled vs. pytorch LSTM."""
        batch = 11
        input_size = 14
        hidden_size = 22
        seq_len = 1
        quant_rnn_object = quant_rnn.QuantLSTM(input_size, hidden_size,
                num_layers=1, bias=False, batch_first=False, dropout=0, bidirectional=False)
        quant_rnn_object._input_quantizers[0].disable()
        quant_rnn_object._weight_quantizers[0].disable()
        ref_rnn_object = nn.LSTM(input_size, hidden_size,
                num_layers=1, bias=False, batch_first=False, dropout=0, bidirectional=False)

        # copy weights from one rnn to the other
        ref_rnn_object.load_state_dict(quant_rnn_object.state_dict())

        input = torch.randn(seq_len, batch, input_size)
        hidden = torch.randn(seq_len, batch, hidden_size)
        cell = torch.randn(seq_len, batch, hidden_size)

        quant_out, (quant_hout, quant_cout) = quant_rnn_object(input)
        ref_out, (ref_hout, ref_cout) = ref_rnn_object(input)

        utils.compare(quant_out, ref_out)
        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)

    def test_no_quant_input_hidden(self, verbose):
        """QuantLSTM with quantization disabled vs. pytorch LSTM for input and hidden inputs."""
        batch = 13
        input_size = 19
        hidden_size = 20
        seq_len = 1

        quant_rnn_object = quant_rnn.QuantLSTM(input_size, hidden_size,
                num_layers=1, bias=False, batch_first=False, dropout=0, bidirectional=False)
        quant_rnn_object._input_quantizers[0].disable()
        quant_rnn_object._weight_quantizers[0].disable()
        ref_rnn_object = nn.LSTM(input_size, hidden_size,
                num_layers=1, bias=False, batch_first=False, dropout=0, bidirectional=False)

        # copy weights from one rnn to the other
        ref_rnn_object.load_state_dict(quant_rnn_object.state_dict())

        input = torch.randn(seq_len, batch, input_size)
        hidden = torch.randn(seq_len, batch, hidden_size)
        cell = torch.randn(seq_len, batch, hidden_size)

        quant_out, (quant_hout, quant_cout) = quant_rnn_object(input, hx=(hidden, cell))
        ref_out, (ref_hout, ref_cout) = ref_rnn_object(input, hx=(hidden, cell))

        utils.compare(quant_out, ref_out)
        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)

    def test_no_quant_all_modes(self, verbose):
        """QuantLSTM with quantization disabled vs. pytorch LSTM for all modes."""

        def testcase(input_size, hidden_size, seq_len, batch, num_layers, bias, batch_first, dropout, bidirectional):

            quant_rnn_object = quant_rnn.QuantLSTM(input_size, hidden_size,
                    num_layers=num_layers, bias=bias, batch_first=batch_first, dropout=dropout,
                    bidirectional=bidirectional)

            num_quantizers = num_layers * 2 if bidirectional else num_layers
            for i in range(num_quantizers):
                quant_rnn_object._input_quantizers[i].disable()
                quant_rnn_object._weight_quantizers[i].disable()

            ref_rnn_object = nn.LSTM(input_size, hidden_size,
                    num_layers=num_layers, bias=bias, batch_first=batch_first, dropout=dropout,
                    bidirectional=bidirectional)

            # copy state from one rnn to the other
            ref_rnn_object.load_state_dict(quant_rnn_object.state_dict())

            input = torch.randn(seq_len, batch, input_size)
            num_directions = 2 if bidirectional else 1
            hidden = torch.randn(num_layers*num_directions, batch, hidden_size)
            cell = torch.randn(num_layers*num_directions, batch, hidden_size)

            quant_out, (quant_hout, quant_cout) = quant_rnn_object(input, hx=(hidden, cell))
            ref_out, (ref_hout, ref_cout) = ref_rnn_object(input, hx=(hidden, cell))

            utils.compare(quant_out, ref_out)
            utils.compare(quant_hout, ref_hout)
            utils.compare(quant_cout, ref_cout)

        # test various permuatations of the following parameters:
        #   size, num_layers, bias, batch_first, dropout, bidirectional
        testcase(32, 27, 1, 1, 1, False, False, 0, False)
        testcase(19, 63, 1, 1, 2, False, False, 0, False)
        testcase(11, 41, 1, 1, 1, True, False, 0, False)
        testcase(33, 31, 1, 1, 1, False, True, 0, False)
        # testcase(32, 32, 1, 1, 2, False, False, 0.5, False) #TODO(pjudd) this fails look into dropout seeding
        testcase(73, 13, 1, 1, 1, False, False, 0, True)

    def test_against_unquantized(self, verbose):
        """Quantization should introduce bounded error utils.compare to pytorch implementation."""
        batch = 21
        input_size = 33
        hidden_size = 25
        seq_len = 1

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=16)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=16, axis=(1,))
        quant_rnn_object = quant_rnn.QuantLSTM(input_size, hidden_size,
                num_layers=1, bias=False, batch_first=False, dropout=0, bidirectional=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        ref_rnn_object = nn.LSTM(input_size, hidden_size,
                num_layers=1, bias=False, batch_first=False, dropout=0, bidirectional=False)

        # copy weights from one rnn to the other
        ref_rnn_object.load_state_dict(quant_rnn_object.state_dict())

        input = torch.randn(seq_len, batch, input_size)
        hidden = torch.randn(seq_len, batch, hidden_size)
        cell = torch.randn(seq_len, batch, hidden_size)

        quant_out, (quant_hout, quant_cout) = quant_rnn_object(input, hx=(hidden, cell))
        ref_out, (ref_hout, ref_cout) = ref_rnn_object(input, hx=(hidden, cell))

        # The difference between reference and quantized should be bounded in a range
        # Small values which become 0 after quantization lead to large relative errors. rtol and atol could be
        # much smaller without those values
        utils.compare(quant_out, ref_out, rtol=1e-4, atol=1e-4)
        utils.compare(quant_hout, ref_hout, rtol=1e-4, atol=1e-4)
        utils.compare(quant_cout, ref_cout, rtol=1e-4, atol=1e-4)

        # check that quantization introduces some error
        utils.assert_min_mse(quant_out, ref_out, tol=1e-20)
        utils.assert_min_mse(quant_hout, ref_hout, tol=1e-20)
        utils.assert_min_mse(quant_cout, ref_cout, tol=1e-20)

    def test_quant_input_hidden(self, verbose):
        """QuantLSTM vs. manual input quantization + pytorchLSTM."""
        batch = 13
        input_size = 17
        hidden_size = 7
        seq_len = 1
        num_bits = 6

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=num_bits)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=num_bits)
        quant_rnn_object = quant_rnn.QuantLSTM(input_size, hidden_size, num_layers=1, bias=False,
                batch_first=False, dropout=0, bidirectional=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        ref_rnn_object = nn.LSTM(input_size, hidden_size, num_layers=1, bias=False,
                batch_first=False, dropout=0, bidirectional=False)

        input = torch.randn(seq_len, batch, input_size)
        hidden = torch.randn(seq_len, batch, hidden_size)
        cell = torch.randn(seq_len, batch, hidden_size)

        quant_input, quant_hidden = utils.quantize_by_range_fused((input, hidden), num_bits)

        utils.copy_state_and_quantize_fused(ref_rnn_object, quant_rnn_object, num_bits)

        quant_out, (quant_hout, quant_cout) = quant_rnn_object(input, hx=(hidden, cell))
        ref_out, (ref_hout, ref_cout) = ref_rnn_object(quant_input, hx=(quant_hidden, cell))

        utils.compare(quant_out, ref_out)
        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)

    def test_quant_input_hidden_bias(self, verbose):
        """QuantLSTM vs. manual input quantization + pytorchLSTM."""
        batch = 17
        input_size = 13
        hidden_size = 7
        seq_len = 1
        num_bits = 5

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=num_bits)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=num_bits)
        quant_rnn_object = quant_rnn.QuantLSTM(input_size, hidden_size, num_layers=1, bias=True,
                batch_first=False, dropout=0, bidirectional=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        ref_rnn_object = nn.LSTM(input_size, hidden_size, num_layers=1, bias=True,
                batch_first=False, dropout=0, bidirectional=False)

        input = torch.randn(seq_len, batch, input_size)
        hidden = torch.randn(seq_len, batch, hidden_size)
        cell = torch.randn(seq_len, batch, hidden_size)

        quant_input, quant_hidden = utils.quantize_by_range_fused((input, hidden), num_bits)

        utils.copy_state_and_quantize_fused(ref_rnn_object, quant_rnn_object, num_bits)

        quant_out, (quant_hout, quant_cout) = quant_rnn_object(input, hx=(hidden, cell))
        ref_out, (ref_hout, ref_cout) = ref_rnn_object(quant_input, hx=(quant_hidden, cell))

        utils.compare(quant_out, ref_out)
        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)

    def test_quant_different_prec(self, verbose):
        """QuantLSTM vs. manual input quantization + pytorchLSTM."""
        batch = 22
        input_size = 23
        hidden_size = 24
        seq_len = 1
        num_bits_weight = 4
        num_bits_input = 8

        quant_desc_input = tensor_quant.QuantDescriptor(num_bits=num_bits_input)
        quant_desc_weight = tensor_quant.QuantDescriptor(num_bits=num_bits_weight)
        quant_rnn_object = quant_rnn.QuantLSTM(input_size, hidden_size, num_layers=1, bias=False,
                batch_first=False, dropout=0, bidirectional=False,
                quant_desc_input=quant_desc_input, quant_desc_weight=quant_desc_weight)
        ref_rnn_object = nn.LSTM(input_size, hidden_size, num_layers=1, bias=False,
                batch_first=False, dropout=0, bidirectional=False)

        input = torch.randn(seq_len, batch, input_size)
        hidden = torch.randn(seq_len, batch, hidden_size)
        cell = torch.randn(seq_len, batch, hidden_size)

        quant_input, quant_hidden = utils.quantize_by_range_fused((input, hidden), num_bits_input)

        utils.copy_state_and_quantize_fused(ref_rnn_object, quant_rnn_object, num_bits_weight)

        quant_out, (quant_hout, quant_cout) = quant_rnn_object(input, hx=(hidden, cell))
        ref_out, (ref_hout, ref_cout) = ref_rnn_object(quant_input, hx=(quant_hidden, cell))

        utils.compare(quant_out, ref_out)
        utils.compare(quant_hout, ref_hout)
        utils.compare(quant_cout, ref_cout)


class TestEpilogue():
    """Run after all tests to save globals."""

    def test_save_vectors(self, verbose):
        """Save test vectors to file."""
        if SAVE_VECTORS:
            torch.save(TEST_VECTORS, VECTOR_FILE)
            raise Exception('Saved test vectors to {}, for testing set SAVE_VECTORS = 0'.format(VECTOR_FILE))
