from __future__ import annotations
import argparse
import gc
import statistics
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import TypeVar
import mpmath as mp
import numpy as np
from scipy.special import hyp2f1 as scipy_hyp2f1
import cfsem
DEFAULT_NATIVE_SIZE = 10 * (1 << 16)
DEFAULT_PARALLEL_SIZE = 20 * DEFAULT_NATIVE_SIZE
DEFAULT_SCIPY_SIZE = 10 * DEFAULT_NATIVE_SIZE
DEFAULT_MPMATH_SIZE = 1 << 10
COMPLEX_CASES = np.asarray(
[
(0.5 + 0.25j, 1.25 - 0.5j, 2.0 + 0.75j, 0.1 + 0.2j),
(-2.0 + 0.0j, 1.2 + 0.4j, 3.5 - 0.2j, 2.0 + 0.5j),
(0.7 + 0.2j, 1.3 - 0.1j, 2.4 + 0.3j, -3.0 + 0.4j),
(0.4 + 0.2j, 1.1 + 0.3j, 2.5 + 0.5j, 0.98 + 0.03j),
(0.4 + 0.2j, 1.1 + 0.3j, 2.7 - 0.2j, 4.0 + 2.0j),
(0.7 + 0.2j, 1.2 - 0.3j, 2.1 + 0.1j, 0.5 + 0.866_025_403_784_438_6j),
(0.4 + 0.2j, 0.9 - 0.1j, 3.3 + 0.100_000_001j, 0.97 + 0.02j),
],
dtype=np.complex128,
)
REAL_CASES = np.asarray(
[
(0.5, 1.1, 2.4, 0.2),
(1.2, 0.7, 2.8, -0.5),
(0.4, 1.1, 2.5, 0.95),
(0.7, 1.3, 3.1, -3.0),
(-3.0, 1.4, 2.5, 0.8),
(12.5, 9.25, 17.0, 0.4),
],
dtype=np.float64,
)
T = TypeVar("T")
@dataclass(frozen=True)
class Timing:
name: str
seconds: float
size: int
@property
def values_per_second(self) -> float:
return self.size / self.seconds
def tiled_arguments(cases: np.ndarray, size: int, dtype: np.dtype) -> tuple[np.ndarray, ...]:
indices = np.arange(size) % len(cases)
return tuple(np.ascontiguousarray(cases[indices, column], dtype=dtype) for column in range(4))
def time_call(name: str, function: Callable[[], T], size: int, repeats: int) -> tuple[Timing, T]:
samples = []
result: T | None = None
gc_was_enabled = gc.isenabled()
gc.disable()
try:
for _ in range(repeats):
start = time.perf_counter()
result = function()
samples.append(time.perf_counter() - start)
finally:
if gc_was_enabled:
gc.enable()
assert result is not None
return Timing(name, statistics.median(samples), size), result
def print_timings(title: str, timings: Sequence[Timing]) -> None:
print(f"\n{title}")
print(f"{'implementation':<30} {'values':>10} {'seconds':>10} {'values/s':>15}")
print(f"{'-' * 30} {'-' * 10} {'-' * 10} {'-' * 15}")
for timing in timings:
print(
f"{timing.name:<30} {timing.size:>10,} "
f"{timing.seconds:>10.4f} {timing.values_per_second:>15,.0f}"
)
def mpmath_arguments(arguments: tuple[np.ndarray, ...]) -> tuple[tuple[mp.mpc, ...], ...]:
return tuple(tuple(mp.mpc(value) for value in argument) for argument in arguments)
def time_cfsem_parallel(
cases: np.ndarray, size: int, validation_size: int, repeats: int
) -> tuple[Timing, np.ndarray]:
arguments = tiled_arguments(cases, size, np.dtype(np.complex128))
out = np.empty(size, dtype=np.complex128)
cfsem.hyp2f1(*(argument[: len(cases)] for argument in arguments), par=True)
timing, values = time_call(
"cfsem (parallel)",
lambda: cfsem.hyp2f1(*arguments, par=True, out=out),
size,
repeats,
)
return timing, values[:validation_size].copy()
def benchmark_complex(parallel_size: int, native_size: int, mpmath_size: int, repeats: int, dps: int) -> None:
mp.mp.dps = dps
mp_arguments = mpmath_arguments(tiled_arguments(COMPLEX_CASES, mpmath_size, np.dtype(np.complex128)))
mp.hyp2f1(*(argument[0] for argument in mp_arguments))
parallel, validation_values = time_cfsem_parallel(COMPLEX_CASES, parallel_size, mpmath_size, repeats)
serial_arguments = tiled_arguments(COMPLEX_CASES, native_size, np.dtype(np.complex128))
serial_out = np.empty(native_size, dtype=np.complex128)
serial, _ = time_call(
"cfsem (serial)",
lambda: cfsem.hyp2f1(*serial_arguments, par=False, out=serial_out),
native_size,
repeats,
)
mpmath, mpmath_values = time_call(
"mpmath (scalar loop)",
lambda: [mp.hyp2f1(a, b, c, z) for a, b, c, z in zip(*mp_arguments, strict=True)],
mpmath_size,
repeats,
)
sample = np.linspace(0, mpmath_size - 1, min(mpmath_size, 32), dtype=np.intp)
reference = np.asarray([complex(mpmath_values[index]) for index in sample])
np.testing.assert_allclose(validation_values[sample], reference, rtol=3e-10, atol=3e-11)
print_timings("Complex inputs", [parallel, serial, mpmath])
def benchmark_real(parallel_size: int, native_size: int, scipy_size: int, repeats: int) -> None:
parallel, validation_values = time_cfsem_parallel(REAL_CASES, parallel_size, scipy_size, repeats)
complex_arguments = tiled_arguments(REAL_CASES, native_size, np.dtype(np.complex128))
serial_out = np.empty(native_size, dtype=np.complex128)
serial, _ = time_call(
"cfsem (serial)",
lambda: cfsem.hyp2f1(*complex_arguments, par=False, out=serial_out),
native_size,
repeats,
)
real_arguments = tiled_arguments(REAL_CASES, scipy_size, np.dtype(np.float64))
scipy_hyp2f1(*(argument[: len(REAL_CASES)] for argument in real_arguments))
scipy, scipy_values = time_call(
"scipy.special.hyp2f1", lambda: scipy_hyp2f1(*real_arguments), scipy_size, repeats
)
np.testing.assert_allclose(validation_values.real, scipy_values, rtol=3e-10, atol=3e-11)
np.testing.assert_allclose(validation_values.imag, 0.0, atol=3e-11)
print_timings("Real inputs (z < 1)", [parallel, serial, scipy])
def positive_integer(value: str) -> int:
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return parsed
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
"--native-size",
"--size",
dest="native_size",
type=positive_integer,
default=DEFAULT_NATIVE_SIZE,
help="elements in each serial cfsem workload",
)
parser.add_argument(
"--mpmath-size",
type=positive_integer,
default=DEFAULT_MPMATH_SIZE,
help="elements in the mpmath workload",
)
parser.add_argument(
"--parallel-size",
type=positive_integer,
default=DEFAULT_PARALLEL_SIZE,
help="elements in each parallel cfsem workload",
)
parser.add_argument(
"--scipy-size",
type=positive_integer,
default=DEFAULT_SCIPY_SIZE,
help="elements in the SciPy workload",
)
parser.add_argument("--repeats", type=positive_integer, default=1, help="timed passes per implementation")
parser.add_argument("--dps", type=positive_integer, default=17, help="mpmath decimal precision")
args = parser.parse_args()
if args.mpmath_size > args.native_size:
parser.error("--mpmath-size must not exceed --native-size")
if args.parallel_size < max(args.native_size, args.scipy_size):
parser.error("--parallel-size must not be smaller than --native-size or --scipy-size")
print(
f"hyp2f1 throughput: parallel size={args.parallel_size:,}, "
f"serial size={args.native_size:,}, "
f"SciPy size={args.scipy_size:,}, "
f"mpmath size={args.mpmath_size:,}, repeats={args.repeats}, mpmath dps={args.dps}"
)
benchmark_complex(args.parallel_size, args.native_size, args.mpmath_size, args.repeats, args.dps)
benchmark_real(args.parallel_size, args.native_size, args.scipy_size, args.repeats)
if __name__ == "__main__":
main()