"""
check_concurrency_ppcr.py
ppcr: partial pre calculated randoms

Compare runtimes for a monte carlo simulation of pi by using a plain
python function, multiple threads, processes and subinterpreters (when
supported).

The random numbers are pre-calculated in the threads or processes, so
there is no expensive ipc overhead by transfering pre-calculated numbers
from the main thread.

The calculation of the random numbers in python 3.13t starts to take a
long time if the list exceeds about 2e6 tuples of random data.
"""

import argparse
import sys
import time

from random import random
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import ProcessPoolExecutor


def _has_isolated_interpreters():
    try:
        return sys.implementation.supports_isolated_interpreters
    except AttributeError:
        return False


HAS_ISOLATED_INTERPRETERS = _has_isolated_interpreters()
if HAS_ISOLATED_INTERPRETERS:
    from concurrent.futures import InterpreterPoolExecutor
    

def calculate_pi(runs):
    random_numbers = [(random(), random()) for _ in range(runs)]
    inside = 0
    for x, y in random_numbers:
        if x*x + y*y < 1:
            inside += 1
    pi = 4 * inside / len(random_numbers)
    return pi
        

class ConcurrencyRunner:

    def __init__(self, runs, tasks):
        self.runs = runs
        self.tasks = tasks
            
    def calculate_pi_by_executor(self, executor):
        with executor(max_workers=self.tasks) as e:
            runs = self.runs // self.tasks
            args = [runs] * self.tasks
            results = e.map(calculate_pi, args)  # less expensive ipc
            result = sum(results) / self.tasks
        return result
    
    def calculate_pi_by_threads(self, *_):
        return self.calculate_pi_by_executor(ThreadPoolExecutor)

    def calculate_pi_by_processes(self, *_):
        return self.calculate_pi_by_executor(ProcessPoolExecutor)

    def calculate_pi_by_subinterpreters(self, *_):
        return self.calculate_pi_by_executor(InterpreterPoolExecutor)

    def get_runtime(self, function):
        start = time.perf_counter()
        result = function(self.runs)
        stop = time.perf_counter()
        delta = stop - start
        return delta, result
        
    def run(self):

        def print_header():
            fn = "function name"
            rt = "runtime"
            fc = "factor"
            rs = "result"
            header = f"{fn:32}{rt:>10}{fc:>10}{rs:>10}"
            print(header)
            print("-" * len(header))
            
        functions = [
            calculate_pi,
            self.calculate_pi_by_threads,
            self.calculate_pi_by_processes
        ]
        if HAS_ISOLATED_INTERPRETERS:
            functions.append(self.calculate_pi_by_subinterpreters)
            
        print_header()
        reference_runtime = None
        for function in functions:
            runtime, result = self.get_runtime(function)
            if reference_runtime is None:
                reference_runtime = runtime
            factor = runtime / reference_runtime
            print(f"{function.__name__:32}{runtime:10.5f}{factor:10.2f}{result:10.4f}")
        print("\n")  # nicer output to stdout
    
    
def main(args):
    runs = int(args.runs)
    tasks = args.tasks
    print()
    print(f"Running version     : {sys.version}")
    print(f"Running script      : {sys.argv[0]}")
    print(f"Number of iterations: {runs:<6.3g}")
    print(f"Concurrency level   : {tasks}\n")
    runner = ConcurrencyRunner(runs=runs, tasks=tasks)
    runner.run()
    
    
def parse():
    parser = argparse.ArgumentParser()
    parser.add_argument("-r", dest="runs", default=1e6, type=float, help="number of runs")
    parser.add_argument("-t", dest="tasks", default=1, type=int, help="number of concurrent tasks")
    args = parser.parse_args()
    return args
    
    
if __name__ == "__main__":
    main(parse())
