# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you 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.

import ml_dtypes
import numpy as np
import pytest

import tvm
import tvm.testing
from tvm.contrib.pickle_memoize import memoize
from tvm.testing import env


def get_random_tensor(shape, dtype):
    if dtype == "int8":
        return np.random.randint(-128, 128, shape).astype(dtype)
    elif dtype == "uint8":
        return np.random.randint(0, 256, shape).astype(dtype)
    return np.random.uniform(-1, 1, shape).astype(dtype)


def verify_group_gemm(
    func_name, M, N, K, num_groups, x_dtype, weight_dtype, out_dtype, use_scale, rtol, atol
):
    group_gemm_func = tvm.get_global_func(func_name, allow_missing=True)
    if group_gemm_func is None:
        print(f"Skipped as {func_name} is not available")
        return

    @memoize("tvm.contrib.cutlass.test_group_gemm_sm90")
    def get_ref_data():
        assert M % num_groups == 0
        M_per_group = M // num_groups
        a_np = get_random_tensor((M, K), x_dtype)
        b_np = get_random_tensor((num_groups, N, K), weight_dtype)
        indptr_np = np.arange(1, num_groups + 1).astype("int64") * M_per_group
        c_np = np.concatenate(
            [a_np[i * M_per_group : (i + 1) * M_per_group] @ b_np[i].T for i in range(num_groups)],
            axis=0,
        )
        return a_np, b_np, indptr_np, c_np

    def to_numpy_dtype(dtype):
        mapping = {"float8_e5m2": ml_dtypes.float8_e5m2, "float8_e4m3fn": ml_dtypes.float8_e4m3fn}
        return mapping.get(dtype, dtype)

    a_np, b_np, indptr_np, c_np = get_ref_data()

    def run_and_check():
        dev = tvm.cuda(0)
        a_nd = tvm.runtime.tensor(a_np.astype(to_numpy_dtype(x_dtype)), device=dev)
        b_nd = tvm.runtime.tensor(b_np.astype(to_numpy_dtype(weight_dtype)), device=dev)
        c_nd = tvm.runtime.empty(c_np.shape, dtype=out_dtype, device=dev)
        indptr_nd = tvm.runtime.tensor(indptr_np, device=dev)
        workspace = tvm.runtime.empty((4096 * 1024,), dtype="uint8", device=dev)
        if use_scale:
            scale = tvm.runtime.tensor(np.array([1.0], dtype="float32"), device=dev)
            group_gemm_func(a_nd, b_nd, indptr_nd, workspace, scale, c_nd)
        else:
            group_gemm_func(a_nd, b_nd, indptr_nd, workspace, c_nd)
        tvm.testing.assert_allclose(c_nd.numpy(), c_np, rtol=rtol, atol=atol)

    tvm.testing.run_with_gpu_lock(run_and_check)


@pytest.mark.skipif(not env.build_flag_enabled("USE_CUTLASS"), reason="need cutlass")
@pytest.mark.gpu
@pytest.mark.skipif(not env.has_cuda_compute(9), reason="need cuda compute >= 9.0")
def test_group_gemm_sm90():
    verify_group_gemm(
        "cutlass.group_gemm",
        8,
        128,
        128,
        4,
        "float16",
        "float16",
        "float16",
        False,
        rtol=1e-3,
        atol=1e-3,
    )
    verify_group_gemm(
        "cutlass.group_gemm_e5m2_e5m2_fp16",
        8,
        16,
        16,
        4,
        "float8_e5m2",
        "float8_e5m2",
        "float16",
        True,
        rtol=1e-1,
        atol=1,
    )
    verify_group_gemm(
        "cutlass.group_gemm_e4m3_e4m3_fp16",
        8,
        16,
        16,
        4,
        "float8_e4m3fn",
        "float8_e4m3fn",
        "float16",
        True,
        rtol=1e-1,
        atol=1,
    )


@pytest.mark.skipif(not env.build_flag_enabled("USE_CUTLASS"), reason="need cutlass")
@pytest.mark.gpu
@pytest.mark.skipif(not env.has_cuda_compute(10), reason="need cuda compute >= 10.0")
def test_group_gemm_sm100():
    verify_group_gemm(
        "cutlass.group_gemm",
        8,
        128,
        128,
        4,
        "bfloat16",
        "bfloat16",
        "bfloat16",
        False,
        rtol=1e-2,
        atol=1e-3,
    )


def rowwise_quant_fp8_e4m3(shape: tuple[int, int], block_size: tuple[int, int], dtype: str):
    x_full_np = (np.random.rand(*shape) * 2 - 1).astype(dtype)
    x_scale_shape = (
        *shape[:-1],
        (shape[-1] + block_size[1] - 1) // block_size[1],
    )
    # For each (block_size[1]) block, compute the max abs value of `w_full_np`
    x_max_abs_np = np.zeros(x_scale_shape, dtype="float32")
    for i in range(x_scale_shape[-1]):
        x_max_abs_np[..., i] = np.max(
            np.abs(x_full_np[..., i * block_size[1] : min((i + 1) * block_size[1], shape[-1])]),
            axis=-1,
        )[0]
    # Scale is the `x_max_abs_np` divided by the max value of quant_dtype in ml_dtypes
    fp8_max = float(ml_dtypes.finfo("float8_e4m3fn").max)
    x_scale_np = x_max_abs_np / fp8_max
    # `x_np` is the `x_full_np` divided by the `x_scale_np` (with block awareness),
    # clamped to (-fp8_max, fp8_max), and cast to `quant_dtype`
    x_np = np.zeros_like(x_full_np, dtype="float8_e4m3fn")
    for i in range(x_scale_shape[-1]):
        x_np[..., i * block_size[1] : min((i + 1) * block_size[1], shape[-1])] = np.clip(
            x_full_np[..., i * block_size[1] : min((i + 1) * block_size[1], shape[-1])]
            / x_scale_np[..., i : i + 1],
            -fp8_max,
            fp8_max,
        )

    x_scale_np = np.random.rand(*x_scale_np.shape).astype("float32") / fp8_max
    for i in range(x_scale_shape[-1]):
        x_full_np[..., i * block_size[1] : min((i + 1) * block_size[1], shape[-1])] = (
            x_np[..., i * block_size[1] : min((i + 1) * block_size[1], shape[-1])].astype(
                x_scale_np.dtype
            )
            * x_scale_np[..., i : i + 1]
        )
    return x_np, x_scale_np


def blockwise_quant_fp8_e4m3(shape: tuple[int, int], block_size: tuple[int, int], dtype: str):
    w_full_np = (np.random.rand(*shape) * 2 - 1).astype(dtype)
    w_scale_shape = (
        *shape[:-2],
        (shape[-2] + block_size[0] - 1) // block_size[0],
        (shape[-1] + block_size[1] - 1) // block_size[1],
    )
    # For each (block_size[0], block_size[1]) block, compute the max abs value of `w_full_np`
    w_max_abs_np = np.zeros(w_scale_shape, dtype="float32")
    for i in range(w_scale_shape[-2]):
        for j in range(w_scale_shape[-1]):
            block_shape = (
                *shape[:-2],
                min(block_size[0], shape[-2] - i * block_size[0]),
                min(block_size[1], shape[-1] - j * block_size[1]),
            )
            w_max_abs_np[..., i, j] = np.max(
                np.abs(
                    w_full_np[
                        ...,
                        i * block_size[0] : min((i + 1) * block_size[0], shape[-2]),
                        j * block_size[1] : min((j + 1) * block_size[1], shape[-1]),
                    ]
                ).reshape(*shape[:-2], block_shape[-2] * block_shape[-1]),
                axis=-1,
            )
    # Scale is the `w_max_abs_np` divided by the max value of quant_dtype in ml_dtypes
    fp8_max = float(ml_dtypes.finfo("float8_e4m3fn").max)
    w_scale_np = w_max_abs_np / fp8_max
    # `w_np` is the `w_full_np` divided by the `w_scale_np` (with block awareness),
    # clamped to (-fp8_max, fp8_max), and cast to `quant_dtype`
    w_np = np.zeros_like(w_full_np, dtype="float8_e4m3fn")
    if len(w_scale_shape) == 2:
        for i in range(w_scale_shape[-2]):
            for j in range(w_scale_shape[-1]):
                w_np[
                    i * block_size[0] : min((i + 1) * block_size[0], shape[-2]),
                    j * block_size[1] : min((j + 1) * block_size[1], shape[-1]),
                ] = np.clip(
                    w_full_np[
                        i * block_size[0] : min((i + 1) * block_size[0], shape[-2]),
                        j * block_size[1] : min((j + 1) * block_size[1], shape[-1]),
                    ]
                    / w_scale_np[..., i, j],
                    -fp8_max,
                    fp8_max,
                )
    else:
        for e in range(w_scale_shape[0]):
            for i in range(w_scale_shape[-2]):
                for j in range(w_scale_shape[-1]):
                    w_np[
                        e,
                        i * block_size[0] : min((i + 1) * block_size[0], shape[-2]),
                        j * block_size[1] : min((j + 1) * block_size[1], shape[-1]),
                    ] = np.clip(
                        w_full_np[
                            e,
                            i * block_size[0] : min((i + 1) * block_size[0], shape[-2]),
                            j * block_size[1] : min((j + 1) * block_size[1], shape[-1]),
                        ]
                        / w_scale_np[e, i, j],
                        -fp8_max,
                        fp8_max,
                    )

    w_scale_np = np.random.rand(*w_scale_np.shape).astype("float32") / fp8_max
    return w_np, w_scale_np


def blockwise_matmul(
    x_fp8_np: np.ndarray,
    x_scale_np: np.ndarray,
    w_np: np.ndarray,
    w_scale_np: np.ndarray,
    block_size: tuple[int, int],
    dtype: str,
):
    o_np = np.zeros((x_fp8_np.shape[0], w_np.shape[0]), dtype=dtype)
    for j in range(w_scale_np.shape[0]):
        for k in range(w_scale_np.shape[1]):
            o_np[:, j * block_size[0] : min((j + 1) * block_size[0], w_np.shape[0])] += (
                np.matmul(
                    x_fp8_np[
                        :, k * block_size[1] : min((k + 1) * block_size[1], x_fp8_np.shape[1])
                    ].astype(dtype),
                    w_np[
                        j * block_size[0] : min((j + 1) * block_size[0], w_np.shape[0]),
                        k * block_size[1] : min((k + 1) * block_size[1], w_np.shape[1]),
                    ].T.astype(dtype),
                )
                * x_scale_np[:, k : k + 1]
                * w_scale_np[j, k]
            )
    return o_np


def blockwise_bmm(
    x_fp8_np: np.ndarray,
    x_scale_np: np.ndarray,
    w_np: np.ndarray,
    w_scale_np: np.ndarray,
    block_size: tuple[int, int],
    dtype: str,
):
    o_np = np.zeros((x_fp8_np.shape[0], x_fp8_np.shape[1], w_np.shape[1]), dtype=dtype)
    for j in range(w_scale_np.shape[1]):
        for k in range(w_scale_np.shape[2]):
            o_np[..., j * block_size[0] : min((j + 1) * block_size[0], w_np.shape[1])] += (
                np.matmul(
                    x_fp8_np[
                        ..., k * block_size[1] : min((k + 1) * block_size[1], x_fp8_np.shape[2])
                    ].astype(dtype),
                    w_np[
                        ...,
                        j * block_size[0] : min((j + 1) * block_size[0], w_np.shape[1]),
                        k * block_size[1] : min((k + 1) * block_size[1], w_np.shape[2]),
                    ]
                    .transpose(0, 2, 1)
                    .astype(dtype),
                )
                * x_scale_np[..., k : k + 1]
                * w_scale_np[..., j : j + 1, k : k + 1]
            )
    return o_np


@pytest.mark.skipif(not env.build_flag_enabled("USE_CUTLASS"), reason="need cutlass")
@pytest.mark.gpu
@pytest.mark.skipif(not env.has_cuda_compute(9), reason="need cuda compute >= 9.0")
def test_fp8_e4m3_groupwise_scaled_gemm():
    M = 16
    N = 4608
    K = 896
    block_size = (128, 128)
    assert N % 128 == 0 and K % 128 == 0  # Only support N/K are multiple of 128

    func_name = "cutlass.groupwise_scaled_gemm_e4m3fn_e4m3fn"
    gemm_func = tvm.get_global_func(func_name, allow_missing=True)
    if gemm_func is None:
        print(f"Skipped as {func_name} is not available")
        return

    dtype = "bfloat16"
    x_np, x_scale_np = rowwise_quant_fp8_e4m3((M, K), block_size, dtype)
    w_np, w_scale_np = blockwise_quant_fp8_e4m3((N, K), block_size, dtype)
    o_np = blockwise_matmul(x_np, x_scale_np, w_np, w_scale_np, block_size, dtype)

    def run_and_check():
        device = tvm.cuda(0)
        x_tvm = tvm.runtime.tensor(x_np, device=device)
        x_scale_tvm = tvm.runtime.tensor(x_scale_np.T, device=device)
        w_tvm = tvm.runtime.tensor(w_np, device=device)
        w_scale_tvm = tvm.runtime.tensor(w_scale_np, device=device)
        workspace = tvm.runtime.empty((4096 * 1024,), dtype="uint8", device=device)
        o_tvm = tvm.runtime.empty((M, N), dtype=dtype, device=device)
        gemm_func(
            x_tvm,
            w_tvm,
            x_scale_tvm,
            w_scale_tvm,
            workspace,
            block_size[0],
            block_size[1],
            o_tvm,
        )
        tvm.testing.assert_allclose(o_tvm.numpy(), o_np, rtol=1e-4, atol=0.5)

    tvm.testing.run_with_gpu_lock(run_and_check)


@pytest.mark.skipif(not env.build_flag_enabled("USE_CUTLASS"), reason="need cutlass")
@pytest.mark.gpu
@pytest.mark.skipif(not env.has_cuda_compute(9), reason="need cuda compute >= 9.0")
def test_fp8_e4m3_groupwise_scaled_bmm():
    B = 16
    M = 40
    N = 512
    K = 128
    block_size = (128, 128)
    assert N % 128 == 0 and K % 128 == 0  # Only support N/K are multiple of 128

    func_name = "cutlass.groupwise_scaled_bmm_e4m3fn_e4m3fn"
    gemm_func = tvm.get_global_func(func_name, allow_missing=True)
    if gemm_func is None:
        print(f"Skipped as {func_name} is not available")
        return

    dtype = "bfloat16"
    x_np, x_scale_np = rowwise_quant_fp8_e4m3((B, M, K), block_size, dtype)
    w_np, w_scale_np = blockwise_quant_fp8_e4m3((B, N, K), block_size, dtype)
    o_np = blockwise_bmm(x_np, x_scale_np, w_np, w_scale_np, block_size, dtype)

    def run_and_check():
        device = tvm.cuda(0)
        x_tvm = tvm.runtime.tensor(x_np, device=device)
        x_scale_tvm = tvm.runtime.tensor(x_scale_np.transpose(0, 2, 1), device=device)
        w_tvm = tvm.runtime.tensor(w_np, device=device)
        w_scale_tvm = tvm.runtime.tensor(w_scale_np, device=device)
        workspace = tvm.runtime.empty((4096 * 1024,), dtype="uint8", device=device)
        o_tvm = tvm.runtime.empty((B, M, N), dtype=dtype, device=device)
        gemm_func(
            x_tvm,
            w_tvm,
            x_scale_tvm,
            w_scale_tvm,
            workspace,
            block_size[0],
            block_size[1],
            o_tvm,
        )
        tvm.testing.assert_allclose(o_tvm.numpy(), o_np, rtol=1e-4, atol=0.5)

    tvm.testing.run_with_gpu_lock(run_and_check)


if __name__ == "__main__":
    tvm.testing.main()
