# 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.
"""Benchmark API overhead of different python FFI API calling overhead through DLPack API.

Specifically, we would like to understand the overall overhead python/C++ API calls.
The general goal is to understand the overall space and get a sense of what are the possible operations.

We pick function f(x, y, z) where x, y, z are length 1 tensors.
The benchmark is running in eager mode so we can see what is possible.
It is orthogonal to other optimizations. For example cudagraph can
eliminate these overheads completely. So the goal is to get a sense
of what is possible under eager mode.

Summary of some takeaways:
- numpy.add roughly takes 0.36 us per call, which gives roughly what can be done in python env.
- torch.add on gpu takes about 3.7us per call, giving us an idea of what roughly we need to get to in eager mode.
"""

from __future__ import annotations

import time
from typing import Any, Callable, NamedTuple

import numpy as np
import torch
import tvm_ffi


class TestFFITensor:
    """Test FFI Tensor that exposes __tvm_ffi_object__ protocol."""

    def __init__(self, tensor: tvm_ffi.Tensor) -> None:
        """Initialize the TestFFITensor."""
        self._tensor = tensor

    def __tvm_ffi_object__(self) -> tvm_ffi.Tensor:
        """Implement __tvm_ffi_object__ protocol."""
        return self._tensor


class TestNamedTuple(NamedTuple):
    """Test FFI NamedTuple."""

    x: torch.Tensor
    y: torch.Tensor
    z: torch.Tensor


def print_speed(name: str, speed: float) -> None:
    print(f"{name:<60} {speed} sec/call")


def print_error(name: str, error: Any) -> None:
    print(f"{name:<60} {error}")


def baseline_torch_add(repeat: int) -> None:
    """Run torch.add with one element."""

    def run_bench(device: str) -> None:
        x = torch.arange(1, device=device)
        y = torch.arange(1, device=device)
        z = torch.arange(1, device=device)

        torch.add(x, y, out=z)
        if device == "cuda":
            torch.cuda.synchronize()
        start = time.time()
        for i in range(repeat):
            torch.add(x, y, out=z)
        # note we deliberately do not use torch.cuda.synchronize()
        # because we want to see the overhead of the FFI call.
        end = time.time()
        print_speed(f"torch.add[{device}]", (end - start) / repeat)

    # rough take away: add on cuda roughly takes 3e-6 sec/call
    run_bench("cpu")
    run_bench("cuda")


def baseline_numpy_add(repeat: int) -> None:
    """Run numpy.add with one element."""
    x = np.arange(1)
    y = np.arange(1)
    z = np.arange(1)

    np.add(x, y, out=z)
    start = time.time()
    for i in range(repeat):
        np.add(x, y, out=z)
    end = time.time()
    speed = (end - start) / repeat
    print_speed("numpy.add", speed)


def baseline_cupy_add(repeat: int) -> None:
    """Run cupy.add with one element."""
    try:
        import cupy  # noqa: PLC0415
    except ImportError:
        # skip if cupy is not installed
        return
    x = cupy.arange(1)
    y = cupy.arange(1)
    z = cupy.arange(1)

    cupy.add(x, y, out=z)
    start = time.time()
    for i in range(repeat):
        cupy.add(x, y, out=z)
    end = time.time()
    speed = (end - start) / repeat
    print_speed("cupy.add", speed)


def tvm_ffi_nop(repeat: int) -> None:
    """Overhead of tvm FFI python call via calling a NOP.

    testing.nop is defined in c++ and do nothing.
    """
    nop = tvm_ffi.get_global_func("testing.nop")
    x = tvm_ffi.from_dlpack(torch.arange(1))
    y = tvm_ffi.from_dlpack(torch.arange(1))
    z = tvm_ffi.from_dlpack(torch.arange(1))
    nop(x, y, z)
    start = time.time()
    for i in range(repeat):
        nop(x, y, z)
    end = time.time()
    print_speed("tvm_ffi.nop", (end - start) / repeat)


def bench_ffi_nop_from_dlpack(name: str, x: Any, y: Any, z: Any, repeat: int) -> None:
    """Run dlpack conversion + tvm_ffi.nop.

    Measures overhead of running dlpack for each args then invoke
    """
    nop = tvm_ffi.get_global_func("testing.nop")
    tx = tvm_ffi.from_dlpack(x)
    ty = tvm_ffi.from_dlpack(y)
    tz = tvm_ffi.from_dlpack(z)
    nop(tx, ty, tz)

    start = time.time()
    for i in range(repeat):
        tx = tvm_ffi.from_dlpack(x)
        ty = tvm_ffi.from_dlpack(y)
        tz = tvm_ffi.from_dlpack(z)
        nop(tx, ty, tz)
    end = time.time()
    print_speed(name, (end - start) / repeat)


def tvm_ffi_nop_from_torch_dlpack(repeat: int) -> None:
    """Run dlpack conversion + tvm_ffi.nop.

    Measures overhead of running dlpack for each args then invoke
    """
    x = torch.arange(1)
    y = torch.arange(1)
    z = torch.arange(1)
    bench_ffi_nop_from_dlpack("tvm_ffi.nop+from_dlpack(torch)", x, y, z, repeat)


def tvm_ffi_nop_from_numpy_dlpack(repeat: int) -> None:
    """Run dlpack conversion + tvm_ffi.nop.

    Measures overhead of running dlpack for each args then invoke
    """
    x = np.arange(1)
    y = np.arange(1)
    z = np.arange(1)
    bench_ffi_nop_from_dlpack("tvm_ffi.nop+from_dlpack(numpy)", x, y, z, repeat)


def tvm_ffi_self_dlpack_nop(repeat: int) -> None:
    """Run dlpack conversion + tvm_ffi.nop.

    Measures overhead of running dlpack for each args then invoke
    """
    x = tvm_ffi.from_dlpack(torch.arange(1))
    y = tvm_ffi.from_dlpack(torch.arange(1))
    z = tvm_ffi.from_dlpack(torch.arange(1))
    bench_ffi_nop_from_dlpack("tvm_ffi.nop+from_dlpack(tvm)", x, y, z, repeat)


def tvm_ffi_nop_from_torch_utils_to_dlpack(repeat: int) -> None:
    """Measures overhead of running dlpack for each args then invoke
    but uses the legacy torch.utils.dlpack.to_dlpack API.

    This helps to measure possible implementation overhead of torch.
    """
    nop = tvm_ffi.get_global_func("testing.nop")
    x = torch.arange(1)
    y = torch.arange(1)
    z = torch.arange(1)

    tx = tvm_ffi.from_dlpack(torch.utils.dlpack.to_dlpack(x))
    ty = tvm_ffi.from_dlpack(torch.utils.dlpack.to_dlpack(y))
    tz = tvm_ffi.from_dlpack(torch.utils.dlpack.to_dlpack(z))
    nop(tx, ty, tz)

    start = time.time()
    for i in range(repeat):
        tx = tvm_ffi.from_dlpack(torch.utils.dlpack.to_dlpack(x))
        ty = tvm_ffi.from_dlpack(torch.utils.dlpack.to_dlpack(y))
        tz = tvm_ffi.from_dlpack(torch.utils.dlpack.to_dlpack(z))
        nop(tx, ty, tz)
    end = time.time()
    speed = (end - start) / repeat
    print_speed("tvm_ffi.nop+from_dlpack(torch.utils)", speed)


def bench_tvm_ffi_nop_autodlpack(name: str, x: Any, y: Any, z: Any, repeat: int) -> None:
    """Measures overhead of running dlpack via auto convert by directly
    take torch.Tensor as inputs.
    """
    nop = tvm_ffi.get_global_func("testing.nop")
    nop(x, y, z)
    start = time.time()
    for i in range(repeat):
        nop(x, y, z)
    end = time.time()
    speed = (end - start) / repeat
    print_speed(name, speed)


def bench_tvm_ffi_nop_autodlpack_tuple(name: str, args: TestNamedTuple, repeat: int) -> None:
    """Measures overhead of running dlpack via auto convert by directly
    take torch.Tensor as inputs.
    """
    nop = tvm_ffi.get_global_func("testing.nop")
    nop(args)
    start = time.time()
    for i in range(repeat):
        nop(args)
    end = time.time()
    speed = (end - start) / repeat
    print_speed(name, speed)


def tvm_ffi_nop_autodlpack_from_torch(
    repeat: int, device: str = "cpu", stream: bool = False
) -> None:
    """Measures overhead of running dlpack via auto convert by directly
    take torch.Tensor as inputs.
    """
    # use larger to ensure alignment req is met
    x = torch.arange(1, device=device)
    y = torch.arange(1, device=device)
    z = torch.arange(1, device=device)
    if stream:
        with torch.cuda.stream(torch.cuda.Stream()):
            bench_tvm_ffi_nop_autodlpack(
                f"tvm_ffi.nop.autodlpack(torch[{device}][stream])", x, y, z, repeat
            )
    else:
        bench_tvm_ffi_nop_autodlpack(f"tvm_ffi.nop.autodlpack(torch[{device}])", x, y, z, repeat)


def tvm_ffi_nop_autodlpack_from_numpy(repeat: int) -> None:
    """Measures overhead of running dlpack via auto convert by directly
    take numpy.ndarray as inputs.
    """
    # use larger to ensure alignment req is met
    x = np.arange(256)
    y = np.arange(256)
    z = np.arange(256)
    bench_tvm_ffi_nop_autodlpack("tvm_ffi.nop.autodlpack(numpy)", x, y, z, repeat)


def tvm_ffi_nop_autodlpack_from_dltensor_test_wrapper(repeat: int, device: str) -> None:
    """Measures overhead of running dlpack via auto convert by directly
    take test wrapper as inputs. This effectively measure DLPack exchange in tvm ffi.
    """
    x = tvm_ffi.from_dlpack(torch.arange(1, device=device))
    y = tvm_ffi.from_dlpack(torch.arange(1, device=device))
    z = tvm_ffi.from_dlpack(torch.arange(1, device=device))
    x = tvm_ffi.core.DLTensorTestWrapper(x)
    y = tvm_ffi.core.DLTensorTestWrapper(y)
    z = tvm_ffi.core.DLTensorTestWrapper(z)
    bench_tvm_ffi_nop_autodlpack(
        f"tvm_ffi.nop.autodlpack(DLTensorTestWrapper[{device}])", x, y, z, repeat
    )


def tvm_ffi_nop_autodlpack_from_test_tensor_namedtuple(repeat: int, device: str) -> None:
    """Measures overhead of running dlpack via auto convert by directly
    take test wrapper as inputs. This effectively measure DLPack exchange in tvm ffi.
    """
    x = torch.arange(1, device=device)
    y = torch.arange(1, device=device)
    z = torch.arange(1, device=device)
    args = TestNamedTuple(x=x, y=y, z=z)
    bench_tvm_ffi_nop_autodlpack_tuple(
        f"tvm_ffi.nop.autodlpack(NamedTuple[{device}])", args, repeat
    )


def bench_to_dlpack(x: Any, name: str, repeat: int) -> None:
    x.__dlpack__()
    start = time.time()
    for i in range(repeat):
        x.__dlpack__()
    end = time.time()
    speed = (end - start) / repeat
    print_speed(name, speed)


def bench_to_dlpack_versioned(
    x: Any, name: str, repeat: int, max_version: tuple[int, int] = (1, 1)
) -> None:
    """Measures overhead of running dlpack with latest 1.1."""
    try:
        x.__dlpack__(max_version=max_version)
        start = time.time()
        for i in range(repeat):
            x.__dlpack__(max_version=max_version)
        end = time.time()
        speed = (end - start) / repeat
        print_speed(name, speed)
    except Exception as e:
        print_error(name, e)


def bench_torch_utils_to_dlpack(repeat: int) -> None:
    """Measures overhead of running torch.utils.dlpack.to_dlpack."""
    x = torch.arange(1)
    torch.utils.dlpack.to_dlpack(x)
    start = time.time()
    for i in range(repeat):
        torch.utils.dlpack.to_dlpack(x)
    end = time.time()
    speed = (end - start) / repeat
    print_speed("torch.utils.dlpack.to_dlpack", speed)


def torch_get_cuda_stream_native(device_id: int) -> int:
    return torch.cuda.current_stream(device_id).cuda_stream


def load_torch_get_current_cuda_stream() -> Callable[[int], int]:
    """Create a faster get_current_cuda_stream for torch through cpp extension."""
    from torch.utils import cpp_extension  # noqa: PLC0415

    if torch.version.cuda is not None:
        source = """
        #include <c10/cuda/CUDAStream.h>

        int64_t get_current_cuda_stream(int device_id) {
            at::cuda::CUDAStream stream = at::cuda::getCurrentCUDAStream(device_id);
            // fast invariant, default stream is always 0
            if (stream.id() == 0) return 0;
            // convert to cudaStream_t
            return reinterpret_cast<int64_t>(static_cast<cudaStream_t>(stream));
        }
        """
    elif torch.version.hip is not None:
        source = """
        #include <c10/hip/HIPStream.h>

        int64_t get_current_cuda_stream(int device_id) {
            at::hip::HIPStream stream = at::hip::getCurrentHIPStream(device_id);
            // fast invariant, default stream is always 0
            if (stream.id() == 0) return 0;
            // convert to hipStream_t
            return reinterpret_cast<int64_t>(static_cast<hipStream_t>(stream));
        }
        """
    result = cpp_extension.load_inline(
        name="get_current_cuda_stream",
        cpp_sources=[source],
        cuda_sources=[],
        extra_cflags=["-O3"],
        extra_include_paths=cpp_extension.include_paths("cuda"),
        functions=["get_current_cuda_stream"],
    )
    return result.get_current_cuda_stream


def bench_torch_get_current_stream(repeat: int, name: str, func: Callable[[int], int]) -> None:
    """Measures overhead of running torch.cuda.current_stream."""
    x = torch.arange(1, device="cuda")  # noqa: F841
    func(0)
    start = time.time()
    for i in range(repeat):
        func(0)
    end = time.time()
    speed = (end - start) / repeat
    print_speed(f"torch.cuda.current_stream[{name}]", speed)


def populate_object_table(num_classes: int) -> None:
    nop = tvm_ffi.get_global_func("testing.nop")
    dummy_instances = [type(f"DummyClass{i}", (object,), {})() for i in range(num_classes)]
    for instance in dummy_instances:
        nop(instance)


def main() -> None:  # noqa: PLR0915
    repeat = 10000
    # measures impact of object dispatch table size
    # takeaway so far is that there is no impact on the performance
    num_classes = 0
    populate_object_table(num_classes)
    print("-----------------------------")
    print("Benchmark f(x, y, z) overhead")
    print("-----------------------------")
    baseline_numpy_add(repeat)
    baseline_torch_add(repeat)
    baseline_cupy_add(repeat)
    tvm_ffi_nop_from_torch_dlpack(repeat)
    tvm_ffi_nop_from_numpy_dlpack(repeat)
    tvm_ffi_self_dlpack_nop(repeat)
    tvm_ffi_nop_from_torch_utils_to_dlpack(repeat)
    tvm_ffi_nop_autodlpack_from_torch(repeat, "cpu")
    tvm_ffi_nop_autodlpack_from_torch(repeat, "cuda")
    tvm_ffi_nop_autodlpack_from_torch(repeat, "cuda", stream=True)

    tvm_ffi_nop_autodlpack_from_numpy(repeat)
    tvm_ffi_nop_autodlpack_from_dltensor_test_wrapper(repeat, "cpu")
    tvm_ffi_nop_autodlpack_from_dltensor_test_wrapper(repeat, "cuda")
    tvm_ffi_nop_autodlpack_from_test_tensor_namedtuple(repeat, "cpu")
    tvm_ffi_nop_autodlpack_from_test_tensor_namedtuple(repeat, "cuda")
    tvm_ffi_nop(repeat)
    print("-------------------------------")
    print("Benchmark x.__dlpack__ overhead")
    print("-------------------------------")
    bench_torch_utils_to_dlpack(repeat)
    bench_to_dlpack(torch.arange(1), "torch.__dlpack__", repeat)
    bench_to_dlpack(np.arange(1), "numpy.__dlpack__", repeat)
    bench_to_dlpack(tvm_ffi.from_dlpack(torch.arange(1)), "tvm.__dlpack__", repeat)
    print("---------------------------------------------------")
    print("Benchmark x.__dlpack__(max_version=(1,1)) overhead")
    print("---------------------------------------------------")
    bench_to_dlpack_versioned(torch.arange(1), "torch.__dlpack__(max_version=(1,1))", repeat)
    bench_to_dlpack_versioned(np.arange(1), "numpy.__dlpack__(max_version=(1,1))", repeat)
    bench_to_dlpack_versioned(
        tvm_ffi.from_dlpack(torch.arange(1)),
        "tvm.__dlpack__(max_version=(1,1))",
        repeat,
    )
    print("---------------------------------------------------")
    print("Benchmark torch.get_cuda_stream[default stream]")
    print("---------------------------------------------------")
    bench_torch_get_current_stream(repeat, "cpp-extension", load_torch_get_current_cuda_stream())
    bench_torch_get_current_stream(repeat, "python", torch_get_cuda_stream_native)
    print("---------------------------------------------------")
    print("Benchmark torch.get_cuda_stream[non-default stream]")
    print("---------------------------------------------------")
    with torch.cuda.stream(torch.cuda.Stream()):
        bench_torch_get_current_stream(
            repeat, "cpp-extension", load_torch_get_current_cuda_stream()
        )
        bench_torch_get_current_stream(repeat, "python", torch_get_cuda_stream_native)
    print("---------------------------------------------------")
    print("Debug information")
    print("---------------------------------------------------")
    tvm_ffi.core._print_debug_info()
    release_gil = tvm_ffi.get_global_func("testing.nop").release_gil
    print(f"TVM_FFI_RELEASE_GIL_BY_DEFAULT={int(release_gil)}")
    print("---------------------------------------------------")


if __name__ == "__main__":
    main()
