check_concurrency_pcr.py

"""
check_concurrency_pcr.py
pcr: 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).

Random numbers are calculated before the monte carlo simulation starts
and provided as arguments to calculate_pi, resulting in an overhead for
inter-process and subinterpreter communication.

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 itertools
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(random_numbers):
    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
        self.random_numbers = [(random(), random()) for _ in range(self.runs)]
                
    def calculate_pi_by_executor(self, executor):
        with executor(max_workers=self.tasks) as e:
            items_per_task = len(self.random_numbers) // self.tasks
            args = list(itertools.batched(self.random_numbers, items_per_task))
            args = args[:self.tasks]
            results = e.map(calculate_pi, args)  # potential 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.random_numbers)
        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")
    print("create random numbers ... ", end="", flush=True)
    start = time.perf_counter()
    runner = ConcurrencyRunner(runs=runs, tasks=tasks)
    stop = time.perf_counter()
    print(f"duration: {stop-start:6.2g} sec\n")
    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())