# -*- coding: utf-8 -*-

# PyVkFFT
#   (c) 2021- : ESRF-European Synchrotron Radiation Facility
#       authors:
#         Vincent Favre-Nicolin, favre@esrf.fr
#

"""
Benchmark functions. These are implemented using separate process,
one for each test - this involves a fair amount of overhead, but avoids
any resource conflict, or issue with GPU contexts, deletion of cufft
plans (https://github.com/lebedov/scikit-cuda/issues/308), etc..
"""

import warnings
import os
import timeit
from multiprocessing import Process, Queue
import platform
from itertools import permutations
from time import localtime, strftime
import numpy as np
import matplotlib.pyplot as plt

from pyvkfft.version import vkfft_version, vkfft_git_version
from pyvkfft.base import primes

try:
    from cpuinfo import get_cpu_info
except ImportError:
    get_cpu_info = None


def get_cpu_name():
    if get_cpu_info is None:
        return "CPU"
    d = get_cpu_info()
    return f"{d['brand_raw']}"


# test for GPU packages in parallel process (slower but cleaner)

def _test_pyvkfft_cuda(q):
    try:
        import pycuda.autoinit
        import pycuda.driver as cuda
        import pycuda.gpuarray as cua
        from pycuda import curandom
        import pyvkfft.cuda
        from pyvkfft.cuda import VkFFTApp as cuVkFFTApp, cuda_compile_version, \
            cuda_driver_version, cuda_runtime_version
        q.put((True, cuda_compile_version(), cuda_driver_version(), cuda_runtime_version()))
    except:
        q.put((False, None, None, None))


def test_pyvkfft_cuda():
    """
    Test if pyvkfft_cuda is available. The test is made in a separate process.
    Also return the
    """
    q = Queue()
    p = Process(target=_test_pyvkfft_cuda, args=(q,))
    p.start()
    has_pyvkfft_cuda, cu_version_compile, cu_version_driver, cu_version_runtime = q.get()
    p.join()
    return has_pyvkfft_cuda, cu_version_compile, cu_version_driver, cu_version_runtime


def _test_pyvkfft_opencl(q):
    try:
        import pyopencl as cl
        import pyopencl.array as cla
        from pyopencl import clrandom
        import pyvkfft.opencl
        from pyvkfft.opencl import VkFFTApp as clVkFFTApp
        q.put(True)
    except:
        q.put(False)


def test_pyvkfft_opencl():
    """
    Test if pyvkfft_opencl is available. The test is made in a separate process.
    """
    q = Queue()
    p = Process(target=_test_pyvkfft_opencl, args=(q,))
    p.start()
    has_pyvkfft_opencl = q.get()
    p.join()
    return has_pyvkfft_opencl


def _test_skcuda(q):
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        try:
            import pycuda.autoinit
            import pycuda.driver as cu_drv
            import pycuda.gpuarray as cua
            from pycuda import curandom
            import skcuda.fft as cu_fft
            from skcuda.cufft import _cufft_version as v
            q.put((True, "%d.%d.%d" % (v // 1000, (v // 100) % 100, v % 100)))
        except:
            q.put((False, None))


def test_skcuda():
    q = Queue()
    p = Process(target=_test_skcuda, args=(q,))
    p.start()
    has_skcuda, cufft_version = q.get()
    p.join()
    return has_skcuda, cufft_version


def _test_cupy(q):
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        try:
            import cupy as cp
            with cp.cuda.Device(0).use():
                vd = cp.cuda.runtime.driverGetVersion()
                vr = cp.cuda.runtime.runtimeGetVersion()
                q.put((True, "%d.%d.%d" % (vd // 1000, (vd // 100) % 100, vd % 100),
                       "%d.%d.%d" % (vr // 1000, (vr // 100) % 100, vr % 100)))
        except:
            q.put((False, None))


def test_cupy():
    q = Queue()
    p = Process(target=_test_cupy, args=(q,))
    p.start()
    has_cupy, cuda_driver_version, cuda_runtime_version = q.get()
    p.join()
    return has_cupy, cuda_driver_version, cuda_runtime_version


def _test_gpyfft(q):
    """
    Test if scikit-cuda is available. The test is made in a separate process.
    """
    try:
        import pyopencl as cl
        import pyopencl.array as cla
        from pyopencl import clrandom
        import gpyfft
        q.put(True)
    except:
        q.put(False)


def test_gpyfft():
    """
    Test if gpyfft is available. The test is made in a separate process.
    """
    q = Queue()
    p = Process(target=_test_gpyfft, args=(q,))
    p.start()
    has_gpyfft = q.get()
    p.join()
    return has_gpyfft


def _bench_pyvkfft_opencl(q, sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None,
                          opencl_platform=None, args=None, inplace=True, r2c=False, dct=False, dst=False):
    import pyopencl as cl
    import pyopencl.array as cla
    from pyopencl import clrandom
    import pyvkfft.opencl
    from pyvkfft.opencl import VkFFTApp as clVkFFTApp

    if r2c or dct or dst:
        dtype = np.float64 if precision == 'double' else np.float32
    else:
        dtype = np.complex128 if precision == 'double' else np.complex64

    gpu_name_real = gpu_name
    platform_name_real = opencl_platform
    if 'PYOPENCL_CTX' in os.environ:
        cl_ctx = cl.create_some_context()
    else:
        cl_ctx = None
        has_pocl = False
        for p in cl.get_platforms():
            if opencl_platform is not None:
                if opencl_platform.lower() not in p.name.lower():
                    continue
            elif "portable" in p.name.lower():
                # Try to skip PoCL unless it was requested
                has_pocl = True
                continue
            for d in p.get_devices():
                if gpu_name is not None:
                    if gpu_name.lower() not in d.name.lower():
                        continue
                if d.type & cl.device_type.GPU == 0:
                    continue
                gpu_name_real = d.name
                platform_name_real = p.name
                # print("Selected OpenCL device: ", d.name)
                cl_ctx = cl.Context(devices=(d,))
                break
            if cl_ctx is not None:
                break
        if cl_ctx is None and opencl_platform is None and has_pocl:
            # Try again without excluding PoCL
            for p in cl.get_platforms():
                for d in p.get_devices():
                    if gpu_name is not None:
                        if gpu_name.lower() not in d.name.lower():
                            continue
                    if d.type & cl.device_type.GPU == 0:
                        continue
                    gpu_name_real = d.name
                    platform_name_real = p.name
                    # print("Selected OpenCL device: ", d.name)
                    cl_ctx = cl.Context(devices=(d,))
                    break
                if cl_ctx is not None:
                    break
    cq = cl.CommandQueue(cl_ctx, properties=cl.command_queue_properties.PROFILING_ENABLE)
    dt = 0

    r2c_odd = False
    if r2c and inplace:
        # Pad with 1 or 2 elements for inplace r2c
        sh = list(sh)
        if sh[-1] % 2:
            r2c_odd = True
            sh[-1] += 1
        else:
            sh[-1] += 2
        sh = tuple(sh)

    d = clrandom.rand(cq, shape=sh, dtype=np.float32).astype(dtype)
    if inplace:
        d1 = d
    else:
        if r2c:
            sh1 = [n for n in d.shape]
            sh1[-1] = sh1[-1] // 2 + 1
            d1 = cla.empty(cq, shape=tuple(sh1), dtype=np.complex128 if precision == 'double' else np.complex64)
        else:
            d1 = cla.empty_like(d)
    try:
        kwargs = {}
        if args is not None:
            for k, v in args.items():
                if k in ["disableReorderFourStep", "coalescedMemory", "numSharedBanks",
                         "aimThreads", "performBandwidthBoost", "registerBoost",
                         "registerBoostNonPow2", "registerBoost4Step", "warpSize", "useLUT",
                         "groupedBatch", "forceCallbackVersionRealTransforms", "tune_config"]:
                    kwargs[k] = v
        app = clVkFFTApp(d.shape, dtype=dtype, queue=cq, ndim=ndim, inplace=inplace,
                         r2c=r2c, dct=dct, dst=dst, r2c_odd=r2c_odd, **kwargs)
        algo_str = app.get_algo_str()
        nup_str = ''.join(str(nup) for nup in app.get_nb_upload())
        vkfft_str = f"algo={algo_str} buf={app.get_tmp_buffer_str()} up={nup_str}"
        if 'tune_config' in kwargs:
            for k, v in kwargs['tune_config'].items():
                if k == "backend":
                    continue
                vkfft_str += f" {k}={getattr(app, k)}"
        for i in range(nb_repeat):
            cq.finish()
            t0 = timeit.default_timer()
            # Apparently OpenCL events don't always work. Need kernel events ?
            # start = cl.enqueue_marker(cq)
            for ii in range(nb_loop):
                d1 = app.fft(d, d1)
                d = app.ifft(d1, d)
            # end = cl.enqueue_marker(cq)
            # end.wait()
            # dt1 = 1e-9 * (start.profile.END - end.profile.END)
            cq.finish()
            dt1 = (timeit.default_timer() - t0) / nb_loop
            if dt == 0:
                dt = dt1
            elif dt1 < dt:
                dt = dt1
        # print("%4d %4dx%4d 2D FFT+iFFT dt=%6.2f ms %7.2f Gbytes/s [pyvkfft.opencl]  [nb=%4d]" %
        #      (nz, n, n, dt / nb * 1000, gbps, nb))
        gbps = d.nbytes * ndim * 2 * 2 / dt / 1024 ** 3
    except Exception:
        import traceback
        print(traceback.format_exc())
        gbps = 0
    results = {'dt': dt, 'gbps': gbps, 'gpu_name_real': gpu_name_real,
               'platform_name_real': platform_name_real,
               'vkfft_str': vkfft_str, 'algo_str': algo_str, 'nup_str': nup_str}
    if q is None:
        return results
    else:
        q.put(results)


def bench_pyvkfft_opencl(sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None,
                         opencl_platform=None, args=None, serial=False,
                         inplace=True, r2c=False, dct=False, dst=False):
    if serial:
        return _bench_pyvkfft_opencl(None, sh, precision, ndim, nb_repeat, nb_loop, gpu_name,
                                     opencl_platform, args, inplace=inplace,
                                     r2c=r2c, dct=dct, dst=dst)
    q = Queue()
    p = Process(target=_bench_pyvkfft_opencl, args=(q, sh, precision, ndim, nb_repeat, nb_loop, gpu_name,
                                                    opencl_platform, args, inplace, r2c, dct, dst))
    p.start()
    try:
        results = q.get(timeout=20)
    except:
        results = {'dt': 0, 'gbps': 0, 'gpu_name_real': None, 'platform_name_real': None,
                   'vkfft_str': None}
    p.join()
    return results


def _bench_pyvkfft_cuda(q, sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None, args=None,
                        inplace=True, r2c=False, dct=False, dst=False):
    import pycuda.autoprimaryctx  # See https://github.com/lebedov/scikit-cuda/issues/330#issuecomment-1125471345
    import pycuda.driver as cu_drv
    import pycuda.gpuarray as cua
    from pycuda import curandom
    import pyvkfft.cuda
    from pyvkfft.cuda import VkFFTApp as cuVkFFTApp

    if r2c or dct or dst:
        dtype = np.float64 if precision == 'double' else np.float32
    else:
        dtype = np.complex128 if precision == 'double' else np.complex64

    if gpu_name is None:
        gpu_name_real = pycuda.autoprimaryctx.device.name()
        cu_ctx = pycuda.autoprimaryctx.context
    else:
        for i in range(cu_drv.Device.count()):
            d = cu_drv.Device(i)
            if gpu_name.lower() in d.name().lower():
                gpu_name_real = d.name()
                cu_ctx = d.retain_primary_context()
                break
    cu_ctx.push()
    dt = 0

    r2c_odd = False
    if r2c and inplace:
        # Pad with 1 or 2 elements for inplace r2c
        sh = list(sh)
        if sh[-1] % 2:
            r2c_odd = True
            sh[-1] += 1
        else:
            sh[-1] += 2
        sh = tuple(sh)

    d = curandom.rand(shape=sh, dtype=np.float32).astype(dtype)

    if inplace:
        d1 = d
    else:
        if r2c:
            sh1 = [n for n in d.shape]
            sh1[-1] = sh1[-1] // 2 + 1
            d1 = cua.empty(shape=tuple(sh1), dtype=np.complex128 if precision == 'double' else np.complex64)
        else:
            d1 = cua.empty_like(d)

    try:
        kwargs = {}
        if args is not None:
            for k, v in args.items():
                if k in ["disableReorderFourStep", "coalescedMemory", "numSharedBanks",
                         "aimThreads", "performBandwidthBoost", "registerBoost",
                         "registerBoostNonPow2", "registerBoost4Step", "warpSize", "useLUT",
                         "groupedBatch", "tune_config"]:
                    kwargs[k] = v
        app = cuVkFFTApp(d.shape, dtype=dtype, ndim=ndim, inplace=inplace,
                         r2c=r2c, dct=dct, dst=dst, r2c_odd=r2c_odd, **kwargs)
        algo_str = app.get_algo_str()
        nup_str = ''.join(str(nup) for nup in app.get_nb_upload())
        vkfft_str = f"algo={algo_str} buf={app.get_tmp_buffer_str()} up={nup_str}"
        if 'tune_config' in kwargs:
            for k, v in kwargs['tune_config'].items():
                if k == "backend":
                    continue
                vkfft_str += f" {k}={getattr(app, k)}"
        start = cu_drv.Event()
        stop = cu_drv.Event()
        for i in range(nb_repeat):
            cu_ctx.synchronize()
            start.record()
            for ii in range(nb_loop):
                d1 = app.fft(d, d1)
                d = app.ifft(d1, d)
            stop.record()
            cu_ctx.synchronize()
            dt1 = stop.time_since(start) / 1000 / nb_loop
            if dt == 0:
                dt = dt1
            elif dt1 < dt:
                dt = dt1
        # print("%4d %4dx%4d 2D FFT+iFFT dt=%6.2f ms %7.2f Gbytes/s [pyvkfft.cuda]    [nb=%4d]" %
        #      (nz, n, n, dt / nb * 1000, gbps, nb))
        del app
        gbps = d.nbytes * ndim * 2 * 2 / dt / 1024 ** 3
    except:
        gbps = 0
    cu_ctx.pop()
    results = {'dt': dt, 'gbps': gbps, 'gpu_name_real': gpu_name_real, 'vkfft_str': vkfft_str,
               'algo_str': algo_str, 'nup_str': nup_str}
    if q is None:
        return results
    else:
        q.put(results)


def bench_pyvkfft_cuda(sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None, args=None, serial=False,
                       inplace=True, r2c=False, dct=False, dst=False):
    if serial:
        return _bench_pyvkfft_cuda(None, sh, precision, ndim, nb_repeat, nb_loop, gpu_name, args, inplace=inplace,
                                   r2c=r2c, dct=dct, dst=dst)
    q = Queue()
    p = Process(target=_bench_pyvkfft_cuda, args=(q, sh, precision, ndim, nb_repeat, nb_loop, gpu_name,
                                                  args, inplace, r2c, dct, dst))
    p.start()
    try:
        results = q.get(timeout=20)
    except:
        results = {'dt': 0, 'gbps': 0, 'gpu_name_real': None, 'vkfft_str': None}
    p.join()
    return results


def _bench_cupy(q, sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None):
    import cupy as cp
    import cupyx
    dtype = np.complex128 if precision == 'double' else np.complex64
    if gpu_name is None:
        dev = cp.cuda.Device(0).use()
    else:
        for i in range(cp.cuda.runtime.getDeviceCount()):
            if gpu_name.lower() in cp.cuda.runtime.getDeviceProperties(i)['name'].decode().lower():
                dev = cp.cuda.Device(i).use()
                break
    gpu_name_real = cp.cuda.runtime.getDeviceProperties(dev.id)['name'].decode()
    d = cp.random.uniform(0, 1, sh, dtype=np.float32).astype(dtype)
    dt = 0
    start = cp.cuda.Event()
    stop = cp.cuda.Event()
    ax = list(range(len(sh)))[-ndim:]
    # Explicitly creating the plan does not speed up
    # plan = cupyx.scipy.fft.get_fft_plan(d, axes=ax, value_type='C2C')
    for i in range(nb_repeat):
        dev.synchronize()
        start.record()
        for ii in range(nb_loop):
            cupyx.scipy.fft.fftn(d, axes=ax, overwrite_x=True)
            cupyx.scipy.fft.ifftn(d, axes=ax, overwrite_x=True)
        # a = cp.fft.fftn(d, axes=ax)
        # a = cp.fft.ifftn(d, axes=ax)
        stop.record()
        dev.synchronize()
        dt1 = cp.cuda.get_elapsed_time(start, stop) / 1000 / nb_loop
        if dt == 0:
            dt = dt1
        elif dt1 < dt:
            dt = dt1
    gbps = d.nbytes * ndim * 2 * 2 / dt / 1024 ** 3
    results = {'dt': dt, 'gbps': gbps, 'gpu_name_real': gpu_name_real}
    if q is None:
        return results
    else:
        q.put(results)


def bench_cupy(sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None, serial=False):
    if serial:
        return _bench_cupy(None, sh, precision, ndim, nb_repeat, nb_loop, gpu_name)
    q = Queue()
    p = Process(target=_bench_cupy, args=(q, sh, precision, ndim, nb_repeat, nb_loop, gpu_name))
    p.start()
    try:
        results = q.get(timeout=10)
    except:
        results = {'dt': 0, 'gbps': 0, 'gpu_name_real': None}
    p.join()
    return results


def _bench_skcuda(q, sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None):
    import pycuda.autoprimaryctx  # See https://github.com/lebedov/scikit-cuda/issues/330#issuecomment-1125471345
    import pycuda.driver as cu_drv
    from pycuda import curandom
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        import skcuda.fft as cu_fft
    dtype = np.complex128 if precision == 'double' else np.complex64
    if gpu_name is None:
        gpu_name_real = pycuda.autoprimaryctx.device.name()
        cu_ctx = pycuda.autoprimaryctx.context
    else:
        for i in range(cu_drv.Device.count()):
            d = cu_drv.Device(i)
            if gpu_name.lower() in d.name().lower():
                gpu_name_real = d.name()
                cu_ctx = d.retain_primary_context()
                break
    cu_ctx.push()
    d = curandom.rand(shape=sh, dtype=np.float32).astype(dtype)
    if ndim == 1:
        plan = cu_fft.Plan(sh[-1], dtype, dtype, batch=sh[-2])
    elif ndim == 2:
        plan = cu_fft.Plan(sh[-2:], dtype, dtype, batch=sh[-3])
    else:
        plan = cu_fft.Plan(sh[-3:], dtype, dtype, batch=sh[-4])
    dt = 0
    start = cu_drv.Event()
    stop = cu_drv.Event()
    for i in range(nb_repeat):
        cu_ctx.synchronize()
        start.record()
        for ii in range(nb_loop):
            cu_fft.fft(d, d, plan)
            cu_fft.ifft(d, d, plan)
        stop.record()
        cu_ctx.synchronize()
        dt1 = stop.time_since(start) / 1000 / nb_loop
        if dt == 0:
            dt = dt1
        elif dt1 < dt:
            dt = dt1
    gbps = d.nbytes * ndim * 2 * 2 / dt / 1024 ** 3
    cu_ctx.pop()
    results = {'dt': dt, 'gbps': gbps, 'gpu_name_real': gpu_name_real}
    if q is None:
        return results
    else:
        q.put(results)


def bench_skcuda(sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None, serial=False):
    if serial:
        return _bench_skcuda(None, sh, precision, ndim, nb_repeat, nb_loop, gpu_name)
    q = Queue()
    p = Process(target=_bench_skcuda, args=(q, sh, precision, ndim, nb_repeat, nb_loop, gpu_name))
    p.start()
    try:
        results = q.get(timeout=10)
    except:
        results = {'dt': 0, 'gbps': 0, 'gpu_name_real': None}
    p.join()
    return results


def _bench_gpyfft(q, sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None, opencl_platform=None):
    results = {'dt': 0, 'gbps': 0, 'gpu_name_real': None, 'platform_name_real': None}
    if max(primes(sh[-1])) > 13:
        q.put(results)
    else:
        import pyopencl as cl
        from pyopencl import clrandom
        import gpyfft
        dtype = np.complex128 if precision == 'double' else np.complex64
        gpu_name_real = gpu_name
        platform_name_real = opencl_platform
        if 'PYOPENCL_CTX' in os.environ:
            cl_ctx = cl.create_some_context()
        else:
            cl_ctx = None
            for p in cl.get_platforms():
                if opencl_platform is not None:
                    if opencl_platform.lower() not in p.name.lower():
                        continue
                for d in p.get_devices():
                    if gpu_name is not None:
                        if gpu_name.lower() not in d.name.lower():
                            continue
                    if d.type & cl.device_type.GPU == 0:
                        continue
                    gpu_name_real = d.name
                    platform_name_real = p.name
                    # print("Selected OpenCL device: ", d.name)
                    cl_ctx = cl.Context(devices=(d,))
                    break
                if cl_ctx is not None:
                    break
        cq = cl.CommandQueue(cl_ctx, properties=cl.command_queue_properties.PROFILING_ENABLE)
        dt = 0
        d = clrandom.rand(cq, shape=sh, dtype=np.float32).astype(dtype)
        for axes in permutations([-1, -2, -3][:ndim]):
            # Shuffle axes order to find fastest transform
            gpyfft_plan = gpyfft.FFT(cl_ctx, cq, d, None, axes=axes)
            for i in range(nb_repeat):
                cq.finish()
                t0 = timeit.default_timer()
                for ii in range(nb_loop):
                    gpyfft_plan.enqueue(forward=True)
                    gpyfft_plan.enqueue(forward=False)
                cq.finish()
                dt1 = (timeit.default_timer() - t0) / nb_loop
                if dt == 0:
                    dt = dt1
                elif dt1 < dt:
                    dt = dt1
            del gpyfft_plan
        gbps = d.nbytes * ndim * 2 * 2 / dt / 1024 ** 3
        results = {'dt': dt, 'gbps': gbps, 'gpu_name_real': gpu_name_real,
                   'platform_name_real': platform_name_real}
        if q is None:
            return results
        else:
            q.put(results)


def bench_gpyfft(sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, gpu_name=None, opencl_platform=None,
                 serial=False):
    if serial:
        return _bench_gpyfft(None, sh, precision, ndim, nb_repeat, nb_loop, gpu_name, opencl_platform)
    q = Queue()
    p = Process(target=_bench_gpyfft, args=(q, sh, precision, ndim, nb_repeat, nb_loop, gpu_name, opencl_platform))
    p.start()
    try:
        results = q.get(timeout=10)
    except:
        results = {'dt': 0, 'gbps': 0, 'gpu_name_real': None, 'platform_name_real': None}
    p.join()
    return results


def _bench_scipy(q, sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, nthreads=None):
    from scipy.fft import fftn, ifftn
    dtype = np.complex128 if precision == 'double' else np.complex64
    gpu_name_real = get_cpu_name()
    # Adjust the batch size for CPU calculations (10-50x slower)
    sh = list(sh)
    sh[0] = max(1, sh[0] // 10)
    d = np.random.uniform(0, 1, sh).astype(dtype)
    dt = 0
    ax = list(range(len(sh)))[-ndim:]
    for i in range(nb_repeat):
        t0 = timeit.default_timer()
        for ii in range(nb_loop):
            a = fftn(d, axes=ax, overwrite_x=True, workers=nthreads)
            a = ifftn(d, axes=ax, overwrite_x=True, workers=nthreads)
        dt1 = (timeit.default_timer() - t0) / nb_loop
        if dt == 0:
            dt = dt1
        elif dt1 < dt:
            dt = dt1
    gbps = d.nbytes * ndim * 2 * 2 / dt / 1024 ** 3
    results = {'dt': dt, 'gbps': gbps, 'gpu_name_real': gpu_name_real}
    if q is None:
        return results
    else:
        q.put(results)


def bench_scipy(sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, nthreads=None, serial=False):
    if serial:
        return _bench_scipy(None, sh, precision, ndim, nb_repeat, nb_loop, nthreads)
    q = Queue()
    p = Process(target=_bench_scipy, args=(q, sh, precision, ndim, nb_repeat, nb_loop, nthreads))
    p.start()
    try:
        results = q.get(timeout=10)
    except:
        results = {'dt': 0, 'gbps': 0, 'gpu_name_real': 'CPU'}
    p.join()
    return results


def _bench_pyfftw(q, sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, nthreads=None):
    from pyfftw.builders import fftn, ifftn

    dtype = np.complex128 if precision == 'double' else np.complex64
    gpu_name_real = get_cpu_name()
    # Adjust the batch size for CPU calculations (10-50x slower)
    sh = list(sh)
    sh[0] = max(1, sh[0] // 10)
    d = np.random.uniform(0, 1, sh).astype(dtype)
    dt = 0
    ax = list(range(len(sh)))[-ndim:]
    t1 = fftn(d, axes=ax, overwrite_input=True, planner_effort='FFTW_MEASURE', threads=nthreads)
    t2 = ifftn(d, axes=ax, overwrite_input=True, planner_effort='FFTW_MEASURE', threads=nthreads)
    for i in range(nb_repeat):
        t0 = timeit.default_timer()
        for ii in range(nb_loop):
            a = t1(d)
            a = t2(d)
        dt1 = (timeit.default_timer() - t0) / nb_loop
        if dt == 0:
            dt = dt1
        elif dt1 < dt:
            dt = dt1
    gbps = d.nbytes * ndim * 2 * 2 / dt / 1024 ** 3
    results = {'dt': dt, 'gbps': gbps, 'gpu_name_real': gpu_name_real}
    if q is None:
        return results
    else:
        q.put(results)


def bench_pyfftw(sh, precision='single', ndim=1, nb_repeat=3, nb_loop=1, nthreads=None, serial=False):
    if serial:
        return _bench_pyfftw(None, sh, precision, ndim, nb_repeat, nb_loop, nthreads)
    q = Queue()
    p = Process(target=_bench_pyfftw, args=(q, sh, precision, ndim, nb_repeat, nb_loop, nthreads))
    p.start()
    try:
        results = q.get(timeout=10)
    except:
        results = {'dt': 0, 'gbps': 0, 'gpu_name_real': 'CPU'}
    p.join()
    return results


def plot_benchmark(results, ndim, gpu_name_real, radix_max, legend_loc="lower left",
                   fig=None, figsize=(16, 8)):
    if fig is None:
        plt.figure(figsize=figsize)
    else:
        plt.clf()
    if radix_max > 13:
        # Use a different symbol for Bluestein
        x = results['n']
        maxprime = np.array(results['maxprime'])
        idx7a = np.where(maxprime <= 7)[0]
        idx7b = np.where(maxprime > 7)[0]
        idx13a = np.where(maxprime <= 13)[0]
        idx13b = np.where(maxprime > 13)[0]
        if "gpyfft[clFFT]" in results:
            y = results["gpyfft[clFFT]"]
            plt.plot(np.take(x, idx13a), np.take(y, idx13a), color='#00FF0D', marker='o', markersize=3, linestyle='',
                     label="gpyfft[clFFT]")
        if "skcuda[cuFFT]" in results:
            y = results["skcuda[cuFFT]"]
            plt.plot(np.take(x, idx7a), np.take(y, idx7a), color='#0073FF', marker='o', markersize=3,
                     linestyle='', label="skcuda[cuFFT] (radix-7)")
            plt.plot(np.take(x, idx7b), np.take(y, idx7b), color='#0073FF', marker='+', markersize=3,
                     linestyle='', label="skcuda[cuFFT] (Bluestein/?)")
        if "vkFFT.opencl" in results:
            y = results["vkFFT.opencl"]
            plt.plot(np.take(x, idx13a), np.take(y, idx13a), color='#FF00F2', marker='o', markersize=3,
                     linestyle='', label="vkFFT.opencl (radix-13)")
            plt.plot(np.take(x, idx13b), np.take(y, idx13b), color='#FF00F2', marker='+', markersize=3,
                     linestyle='', label="vkFFT.opencl (Bluestein)")
        if "vkFFT.cuda" in results:
            y = results["vkFFT.cuda"]
            plt.plot(np.take(x, idx13a), np.take(y, idx13a), color='#FF8C00', marker='o', markersize=3,
                     linestyle='', label="vkFFT.cuda (radix-13)")
            plt.plot(np.take(x, idx13b), np.take(y, idx13b), color='#FF8C00', marker='+', markersize=3,
                     linestyle='', label="vkFFT.cuda (Bluestein)")
    else:
        x = results['n']
        if "gpyfft[clFFT]" in results:
            y = results["gpyfft[clFFT]"]
            plt.plot(x, y, color='#00FF0D', marker='o', markersize=3, linestyle='', label="gpyfft[clFFT]")
        if "skcuda[cuFFT]" in results:
            y = results["skcuda[cuFFT]"]
            plt.plot(x, y, color='#0073FF', marker='o', markersize=3, linestyle='', label="skcuda[cuFFT]")
        if "vkFFT.opencl" in results:
            y = results["vkFFT.opencl"]
            plt.plot(x, y, color='#FF00F2', marker='o', markersize=3, linestyle='', label="vkFFT.opencl")
        if "vkFFT.cuda" in results:
            y = results["vkFFT.cuda"]
            plt.plot(x, y, color='#FF8C00', marker='o', markersize=3, linestyle='', label="vkFFT.cuda")

    plt.legend(loc=legend_loc, fontsize=10)
    plt.xlabel("FFT size", fontsize=12)
    plt.ylabel("idealised throughput [Gbytes/s]", fontsize=12)
    if "skcuda[cuFFT]" in results:
        has_skcuda, cufft_version = test_skcuda()
        cufft_version = ", cuFFT " + cufft_version
    else:
        cufft_version = ""
    if "vkFFT.cuda" in results:
        has_pyvkfft_cuda, cu_version_compile, cu_version_driver, cu_version_runtime = test_pyvkfft_cuda()
        cu_version = ", CUDA driver %s runtime %s" % (cu_version_driver, cu_version_runtime)
    else:
        cu_version = ""
    vkfft_git_v = '' if 'unknown' in vkfft_git_version() else f'[{vkfft_git_version()}]'
    plt.suptitle("%dD FFT speed [%s, VkFFT %s%s%s%s]" % (ndim, gpu_name_real, vkfft_version(),
                                                         vkfft_git_v, cu_version, cufft_version),
                 fontsize=12)
    plt.title("Batched FFTs, 'Ideal' throughput assumes one r+w operation per FFT axis [%s, %s]" %
              (platform.platform(), platform.node()), fontsize=10)
    plt.grid(which='both', alpha=0.3)
    plt.xlim(0)
    plt.ylim(0)
    plt.tight_layout()

    # Force refresh
    plt.draw()
    plt.gcf().canvas.draw()
    plt.pause(.001)


def init_results(has_pyvkfft_opencl, has_pyvkfft_cuda, has_skcuda, has_gpyfft):
    results = {"n": [], "maxprime": []}
    if "vkFFT.opencl" not in results and has_pyvkfft_opencl:
        results["vkFFT.opencl"] = []
        results["vkFFT.opencl-dt"] = []
    if "gpyfft[clFFT]" not in results and has_gpyfft:
        results["gpyfft[clFFT]"] = []
        results["gpyfft[clFFT]-dt"] = []
    if "vkFFT.cuda" not in results and has_pyvkfft_cuda:
        results["vkFFT.cuda"] = []
        results["vkFFT.cuda-dt"] = []
    if "skcuda[cuFFT]" not in results and has_skcuda:
        results["skcuda[cuFFT]"] = []
        results["skcuda[cuFFT]-dt"] = []
    return results


def run(nmin, nmax, radix_max, ndim, precision="single", nb_repeat=3, nb_loop=1, gpu_name=None,
        batch=True, opencl_platform=None, figsize=(16, 8),
        has_pyvkfft_opencl=None, has_pyvkfft_cuda=None, has_gpyfft=None, has_skcuda=None,
        r2c=False, dct=False, dst=False, inplace=True):
    """
    Run the benchmark, measuring the idealised memory throughput (assuming a single
    read+write operation per axis) for an inplace C2C transform using different
    fft backends available.
    Note that each test is made in a separate individual process, so this can
    take a long time.

    :param nmin: smallest size N of the array, e.g. with a shape (batch, N, N)
        for a 2D transform.
    :param nmax: largest size N for the array.
    :param radix_max: maximum radix for the tested sizes. Use a large value (1e7)
        to test all sizes regardless of the prime decomposition.
    :param precision: either 'single' or 'double'
    :param nb_repeat: number of times each fft+ifft cycle is performed, the best
        timing is kept
    :param gpu_name: name or substring (case-insensitive) of the GPU to use.
        If None, the first found will be used.
    :param batch: if True (the default), all transforms are batched so that
        the array size is large enough to yield a measurable transform time. Each
        array takes a shape e.g. (batch, N, N) for a 2D transform.
    :param opencl_platform: name or substring (case-insensitive) of the OpenCL
        platform to use. If None, the first found will be used.
    :param figsize: figure size for plotting. Set to None to disable plotting.
    :param has_pyvkfft_opencl: if True, will test pvkfft.opencl. If None,
        will be automatically detected
    :param has_pyvkfft_cuda: if True, will test pvkfft.cuda. If None,
        will be automatically detected
    :param has_gpyfft: if True, will test gpyfft (clFFT). If None,
        will be automatically detected
    :param has_skcuda: if True, will test scikit.cuda (cuFFT). If None,
        will be automatically detected
    :param r2c: if True, test an r2c transform
    :param dct: test DCT of type 1,2,3 or 4
    :param dst: test DST of type 1,2,3 or 4
    :param inplace: if True, test inplace transforms
    """
    if has_pyvkfft_opencl is None:
        has_pyvkfft_opencl = test_pyvkfft_opencl()
    if has_pyvkfft_cuda is None:
        has_pyvkfft_cuda, cu_version_compile, cu_version_driver, cu_version_runtime = test_pyvkfft_cuda()
    if has_skcuda is None:
        has_skcuda, cufft_version = test_skcuda()
    if has_gpyfft is None:
        has_gpyfft = test_gpyfft()

    results = init_results(has_pyvkfft_opencl, has_pyvkfft_cuda, has_skcuda, has_gpyfft)
    if ndim == 1:
        header_results = "    1 x batch x     N [%dD]" % (ndim)
    elif ndim == 2:
        header_results = "batch x     N x     N [%dD]" % (ndim)
    else:
        header_results = "batch x    N x    N x    N [%dD]" % (ndim)
    for b in results.keys():
        if b not in ["n", "maxprime"] and "-dt" not in b:
            header_results += "%17s  " % b

    s = f"DCT{dct}" if dct else f"DST{dst}" if dst else "R2C" if r2c else "C2C"
    if inplace:
        s = "inplace " + s

    print(f"Gbytes/s and time given for a couple (FFT, iFFT) of {s}, "
          f"dtype={np.dtype(np.complex64).name}")
    print()
    print(header_results)

    if figsize is not None:
        fig = plt.figure(figsize=figsize)

    dtype = np.complex128 if precision == 'double' else np.complex64
    gpu_name_real_ok = None
    gpu_name_real = None

    for n in range(nmin, nmax + 1):
        maxprime = max(primes(n))
        if maxprime > radix_max:
            continue
        results["n"].append(n)
        results["maxprime"].append(maxprime)
        if batch:
            # Estimate batch size to last 0.05s with at least 100 GB/s
            nb = int(round(0.05 * 100 / (n ** ndim * np.dtype(dtype).itemsize * ndim * 2 * 2 / 1024 ** 3)))
            nb = max(nb, 1)
            nb = min(nb, 99999)
        else:
            nb = 1

        if ndim == 1:
            sh = 1, nb, n
        elif ndim == 2:
            sh = nb, n, n
        else:
            sh = nb, n, n, n

        vkfft_str = None
        # OpenCL backends
        if has_pyvkfft_opencl:
            res = bench_pyvkfft_opencl(sh, precision, ndim, nb_repeat, nb_loop, gpu_name, opencl_platform,
                                       r2c=r2c, dct=dct, dst=dst, inplace=inplace)
            dt = res['dt']
            gbps = res['gbps']
            gpu_name_real = res['gpu_name_real']
            platform_name_real = res['platform_name_real']
            vkfft_str = res['vkfft_str']
            results["vkFFT.opencl"].append(gbps)
            results["vkFFT.opencl-dt"].append(dt)

        if gpu_name_real_ok is None and gpu_name_real is not None:
            gpu_name_real_ok = gpu_name_real

        if has_gpyfft:
            res = bench_gpyfft(sh, precision, ndim, nb_repeat, nb_loop, gpu_name, opencl_platform)
            dt = res['dt']
            gbps = res['gbps']
            gpu_name_real = res['gpu_name_real']
            platform_name_real = res['platform_name_real']
            results["gpyfft[clFFT]"].append(gbps)
            results["gpyfft[clFFT]-dt"].append(dt)

        # CUDA backends
        if has_pyvkfft_cuda:
            res = bench_pyvkfft_cuda(sh, precision, ndim, nb_repeat, nb_loop, gpu_name)
            dt = res['dt']
            gbps = res['gbps']
            gpu_name_real = res['gpu_name_real']
            vkfft_str = res['vkfft_str']
            results["vkFFT.cuda"].append(gbps)
            results["vkFFT.cuda-dt"].append(dt)

        if gpu_name_real_ok is None and gpu_name_real is not None:
            gpu_name_real_ok = gpu_name_real

        if has_skcuda:
            res = bench_skcuda(sh, precision, ndim, nb_repeat, nb_loop, gpu_name)
            dt = res['dt']
            gbps = res['gbps']
            gpu_name_real = res['gpu_name_real']
            results["skcuda[cuFFT]"].append(gbps)
            results["skcuda[cuFFT]-dt"].append(dt)

        # text output
        if ndim == 3:
            r = " %4d x %4d x %4d x %4d      " % sh
        else:
            r = "%5d x %5d x %5d      " % sh
        for b in results.keys():
            if b not in ["n", "maxprime"] and "-dt" not in b:
                dt = results[b + '-dt'][-1] / nb
                if dt < 1e-3:
                    r += "%7.2f [%6.2f µs]" % (results[b][-1], dt * 1e6)
                elif dt > 1:
                    r += "%7.2f [%6.2f  s]" % (results[b][-1], dt)
                else:
                    r += "%7.2f [%6.2f ms]" % (results[b][-1], dt * 1000)
        if vkfft_str is not None:
            r += f" [VkFFT: {vkfft_str}]"
        print(r)

        if len(results['n']) % 10 == 9 and figsize is not None:
            plot_benchmark(results, ndim, gpu_name_real_ok, radix_max, legend_loc="upper right",
                           fig=fig)

    if figsize is not None:
        plot_benchmark(results, ndim, gpu_name_real_ok, radix_max, "upper right", fig=fig)
        r = "-radix%d" % radix_max if radix_max < 100 else ""
        figname = 'benchmark-%dDFFT%s-%s-%s-%s-%s.png' % (ndim, gpu_name_real_ok.replace(' ', '_'), r,
                                                          platform.platform(), platform.node(),
                                                          strftime("%Y-%m-%d-%Hh%M", localtime()))
        plt.savefig(figname)
        print("Saved benchmark figure to: \n    %s" % figname)
    print()
    return results


if __name__ == '__main__':
    # res = run(nmin=32, nmax=256, radix_max=7, ndim=2, gpu_name=None)
    # res = run(nmin=32, nmax=256, radix_max=7, ndim=2, gpu_name=None, batch=False)
    # res = run(nmin=128, nmax=256, radix_max=3, ndim=2, gpu_name=None, figsize=None)
    # res = run(nmin=128, nmax=256, radix_max=3, ndim=2, gpu_name=None, figsize=None, r2c=True)
    # res = run(nmin=128, nmax=256, radix_max=3, ndim=2, gpu_name=None, figsize=None, r2c=True, inplace=False)
    res = run(nmin=128, nmax=256, radix_max=3, ndim=2, gpu_name=None, figsize=None, has_gpyfft=False,
              has_skcuda=False)
    res = run(nmin=128, nmax=256, radix_max=3, ndim=2, gpu_name=None, figsize=None, has_gpyfft=False,
              has_skcuda=False, r2c=True)
    res = run(nmin=128, nmax=256, radix_max=3, ndim=2, gpu_name=None, figsize=None, has_gpyfft=False,
              has_skcuda=False, r2c=True, inplace=False)
