Benchmark utils

Common utilities for benchmark scripts.

This module provides a standardized framework for running benchmarks with:

Source

Source

Click here to see the source.
  1#!/usr/bin/env python3
  2# Copyright (c) Meta Platforms, Inc. and affiliates.
  3# All rights reserved.
  4#
  5# This source code is licensed under the BSD-style license found in the
  6# LICENSE file in the root directory of this source tree.
  7
  8
  9"""Common utilities for benchmark scripts.
 10
 11This module provides a standardized framework for running benchmarks with:
 12
 13- Configurable executor types (
 14  :py:class:`~concurrent.futures.ThreadPoolExecutor`,
 15  :py:class:`~concurrent.futures.ProcessPoolExecutor`,
 16  :py:class:`~concurrent.futures.InterpreterPoolExecutor`)
 17- Warmup phase to exclude executor initialization overhead
 18- Statistical analysis with confidence intervals
 19- CSV export functionality
 20- Python version and free-threaded ABI detection
 21
 22.. seealso::
 23
 24   - :doc:`./benchmark_tarfile`
 25   - :doc:`./benchmark_wav`
 26   - :doc:`./benchmark_numpy`
 27
 28"""
 29
 30__all__ = [
 31    "BenchmarkRunner",
 32    "BenchmarkResult",
 33    "ExecutorType",
 34    "get_default_result_path",
 35    "load_results_from_csv",
 36    "save_results_to_csv",
 37]
 38
 39import csv
 40import os
 41import sys
 42import time
 43from collections.abc import Callable
 44from concurrent.futures import (
 45    as_completed,
 46    Executor,
 47    ProcessPoolExecutor,
 48    ThreadPoolExecutor,
 49)
 50from dataclasses import asdict, dataclass, field
 51from datetime import datetime, timezone
 52from enum import Enum
 53from functools import partial
 54from sys import version_info
 55from types import TracebackType
 56from typing import Any, Generic, TypeVar
 57
 58import numpy as np
 59import psutil
 60import scipy.stats
 61
 62T = TypeVar("T")
 63ConfigT = TypeVar("ConfigT")
 64
 65
 66def _is_free_threaded() -> bool:
 67    """Check if Python is running with free-threaded ABI."""
 68    try:
 69        return not sys._is_gil_enabled()  # pyre-ignore[16]
 70    except AttributeError:
 71        return False
 72
 73
 74_PYTHON_VERSION: str = f"{version_info.major}.{version_info.minor}.{version_info.micro}"
 75_FREE_THREADED: bool = _is_free_threaded()
 76
 77
 78@dataclass
 79class BenchmarkResult(Generic[ConfigT]):
 80    """BenchmarkResult()
 81
 82    Generic benchmark result containing configuration and performance metrics.
 83
 84    This class holds both the benchmark-specific configuration and the
 85    common performance statistics. It is parameterized by the config type,
 86    which allows each benchmark script to define its own configuration dataclass.
 87    """
 88
 89    config: ConfigT
 90    """Benchmark-specific configuration (e.g., data format, file size, etc.)"""
 91
 92    executor_type: str
 93    """Type of executor used (thread, process, or interpreter)"""
 94
 95    qps: float
 96    """Queries per second (mean)"""
 97
 98    ci_lower: float
 99    """Lower bound of 95% confidence interval for QPS"""
100
101    ci_upper: float
102    """Upper bound of 95% confidence interval for QPS"""
103
104    date: str
105    """When benchmark was run. ISO 8601 format."""
106
107    cpu_percent: float
108    """Average CPU utilization percentage during benchmark execution."""
109
110    python_version: str = field(default=_PYTHON_VERSION)
111    """Python version used for the benchmark"""
112
113    free_threaded: bool = field(default=_FREE_THREADED)
114    """Whether Python is running with free-threaded ABI."""
115
116
117class ExecutorType(Enum):
118    """ExecutorType()
119
120    Supported executor types for concurrent execution."""
121
122    THREAD = "thread"
123    """Use :py:class:`~concurrent.futures.ThreadPoolExecutor`."""
124
125    PROCESS = "process"
126    """Use :py:class:`~concurrent.futures.ProcessPoolExecutor`."""
127
128    INTERPRETER = "interpreter"
129    """Use :py:class:`~concurrent.futures.InterpreterPoolExecutor`.
130
131    Requires Python 3.14+.
132    """
133
134
135def _create_executor(executor_type: ExecutorType, max_workers: int) -> Executor:
136    """Create an executor of the specified type.
137
138    Args:
139        executor_type: Type of executor to create
140        max_workers: Maximum number of workers
141
142    Returns:
143        Executor instance
144
145    Raises:
146        ValueError: If ``executor_type`` is not supported
147    """
148    match executor_type:
149        case ExecutorType.THREAD:
150            return ThreadPoolExecutor(max_workers=max_workers)
151        case ExecutorType.PROCESS:
152            return ProcessPoolExecutor(max_workers=max_workers)
153        case ExecutorType.INTERPRETER:
154            from concurrent.futures import InterpreterPoolExecutor  # pyre-ignore[21]
155
156            return InterpreterPoolExecutor(max_workers=max_workers)
157        case _:
158            raise ValueError(f"Unsupported executor type: {executor_type}")
159
160
161def _verify_workers(executor: Executor, expected_workers: int) -> None:
162    """Verify that the executor has created the expected number of workers.
163
164    Args:
165        executor: The executor to verify
166        expected_workers: Expected number of workers
167
168    Raises:
169        RuntimeError: If the number of workers doesn't match expected
170    """
171    match executor:
172        case ThreadPoolExecutor():
173            actual_workers = len(executor._threads)
174        case ProcessPoolExecutor():
175            actual_workers = len(executor._processes)
176        case _:
177            raise ValueError(f"Unexpected executor type {type(executor)}")
178
179    if actual_workers != expected_workers:
180        raise RuntimeError(
181            f"Expected {expected_workers} workers, but executor has {actual_workers}"
182        )
183
184
185def _warmup_executor(
186    executor: Executor, func: Callable[[], T], num_iterations: int
187) -> None:
188    """Warmup the executor by running the function multiple times.
189
190    The function output is intentionally discarded; the warmup only exists to
191    spin up the worker threads/processes before measurement begins.
192
193    Args:
194        executor: The executor to warmup
195        func: Function to run for warmup
196        num_iterations: Number of warmup iterations
197    """
198    futures = [executor.submit(func) for _ in range(num_iterations)]
199    for future in as_completed(futures):
200        future.result()
201
202
203class BenchmarkRunner:
204    """Runner for executing benchmarks with configurable executors.
205
206    This class provides a standardized way to run benchmarks with:
207
208    - Warmup phase to exclude executor initialization overhead
209    - Multiple runs for statistical confidence intervals
210    - Support for different executor types
211
212    The executor is initialized and warmed up in the constructor to exclude
213    initialization overhead from benchmark measurements.
214
215    Args:
216        executor_type: Type of executor to use
217            (``"thread"``, ``"process"``, or ``"interpreter"``)
218        num_workers: Number of concurrent workers
219        warmup_iterations: Number of warmup iterations (default: ``2 * num_workers``)
220    """
221
222    def __init__(
223        self,
224        executor_type: ExecutorType,
225        num_workers: int,
226        warmup_iterations: int | None = None,
227    ) -> None:
228        self._executor_type: ExecutorType = executor_type
229
230        warmup_iters = (
231            warmup_iterations if warmup_iterations is not None else 2 * num_workers
232        )
233
234        self._executor: Executor = _create_executor(executor_type, num_workers)
235
236        _warmup_executor(self._executor, partial(time.sleep, 1), warmup_iters)
237        _verify_workers(self._executor, num_workers)
238
239    @property
240    def executor_type(self) -> ExecutorType:
241        """Get the executor type."""
242        return self._executor_type
243
244    def __enter__(self) -> "BenchmarkRunner":
245        """Enter context manager."""
246        return self
247
248    def __exit__(
249        self,
250        exc_type: type[BaseException] | None,
251        exc_val: BaseException | None,
252        exc_tb: TracebackType | None,
253    ) -> None:
254        """Exit context manager and shutdown executor."""
255        self._executor.shutdown(wait=True)
256
257    def _run_iterations(
258        self,
259        func: Callable[[], T],
260        iterations: int,
261        num_runs: int,
262    ) -> tuple[list[float], list[float], T]:
263        """Run benchmark iterations and collect QPS and CPU utilization samples.
264
265        Args:
266            func: Function to benchmark (takes no arguments)
267            iterations: Number of iterations per run
268            num_runs: Number of benchmark runs
269
270        Returns:
271            Tuple of (list of QPS samples, list of CPU percent samples, last function output)
272        """
273        qps_samples: list[float] = []
274        cpu_samples: list[float] = []
275        last_output: T | None = None
276
277        process = psutil.Process()
278
279        for _ in range(num_runs):
280            process.cpu_percent()
281            t0 = time.perf_counter()
282            futures = [self._executor.submit(func) for _ in range(iterations)]
283            for future in as_completed(futures):
284                last_output = future.result()
285            elapsed = time.perf_counter() - t0
286            cpu_percent = process.cpu_percent()
287            qps_samples.append(iterations / elapsed)
288            cpu_samples.append(cpu_percent / iterations)
289
290        assert last_output is not None
291        return qps_samples, cpu_samples, last_output
292
293    def run(
294        self,
295        config: ConfigT,
296        func: Callable[[], T],
297        iterations: int,
298        num_runs: int = 5,
299        confidence_level: float = 0.95,
300    ) -> tuple[BenchmarkResult[ConfigT], T]:
301        """Run benchmark and return results with configuration.
302
303        Args:
304            config: Benchmark-specific configuration
305            func: Function to benchmark (takes no arguments)
306            iterations: Number of iterations per run
307            num_runs: Number of benchmark runs for confidence interval calculation
308                (default: ``5``)
309            confidence_level: Confidence level for interval calculation (default: ``0.95``)
310
311        Returns:
312            Tuple of (``BenchmarkResult``, last output from function)
313        """
314        qps_samples, cpu_samples, last_output = self._run_iterations(
315            func, iterations, num_runs
316        )
317
318        qps_mean = np.mean(qps_samples)
319        qps_std = np.std(qps_samples, ddof=1)
320        degrees_freedom = num_runs - 1
321        confidence_interval = scipy.stats.t.interval(
322            confidence_level,
323            degrees_freedom,
324            loc=qps_mean,
325            scale=qps_std / np.sqrt(num_runs),
326        )
327
328        cpu_mean = np.mean(cpu_samples)
329
330        date = datetime.now(timezone.utc).isoformat()
331
332        result = BenchmarkResult(
333            config=config,
334            executor_type=self.executor_type.value,
335            qps=float(qps_mean),
336            # pyrefly: ignore [bad-argument-type]
337            ci_lower=float(confidence_interval[0]),
338            # pyrefly: ignore [bad-argument-type]
339            ci_upper=float(confidence_interval[1]),
340            date=date,
341            cpu_percent=float(cpu_mean),
342        )
343
344        return result, last_output
345
346
347def get_default_result_path(path: str, ext: str = ".csv") -> str:
348    """Get the default result path with Python version appended."""
349    base, _ = os.path.splitext(os.path.realpath(path))
350    dirname = os.path.join(os.path.dirname(base), "data")
351    filename = os.path.basename(base)
352    version_suffix = (
353        f"_{'.'.join(_PYTHON_VERSION.split('.')[:2])}{'t' if _FREE_THREADED else ''}"
354    )
355    return os.path.join(dirname, f"{filename}{version_suffix}{ext}")
356
357
358def save_results_to_csv(
359    results: list[BenchmarkResult[Any]],
360    output_file: str,
361) -> None:
362    """Save benchmark results to a CSV file.
363
364    Flattens the nested BenchmarkResult structure (config + performance metrics)
365    into a flat CSV format. Each row contains both the benchmark configuration
366    fields and the performance metrics.
367
368    Args:
369        results: List of BenchmarkResult instances
370        output_file: Output file path for the CSV file
371    """
372    if not results:
373        raise ValueError("No results to save")
374
375    flattened_results = []
376    for result in results:
377        config_dict = asdict(result.config)
378        # convert bool to int for slight readability improvement of raw CSV file
379        config_dict = {
380            k: (int(v) if isinstance(v, bool) else v) for k, v in config_dict.items()
381        }
382        flattened = {
383            "date": result.date,
384            "python_version": result.python_version,
385            "free_threaded": int(result.free_threaded),
386            **config_dict,
387            "executor_type": result.executor_type,
388            "qps": result.qps,
389            "ci_lower": result.ci_lower,
390            "ci_upper": result.ci_upper,
391            "cpu_percent": result.cpu_percent,
392        }
393        flattened_results.append(flattened)
394
395    # Get all field names from the first result
396    fieldnames = list(flattened_results[0].keys())
397
398    output_path = os.path.realpath(output_file)
399    os.makedirs(os.path.dirname(output_file), exist_ok=True)
400    with open(output_path, "w", newline="") as csvfile:
401        # Write generated marker as first line
402        # Note: Splitting the marker so as to avoid linter consider this file as generated file
403        csvfile.write("# @")
404        csvfile.write("generated\n")
405
406        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
407        writer.writeheader()
408        for result_dict in flattened_results:
409            writer.writerow(result_dict)
410
411    print(f"Results saved to {output_file}")
412
413
414def load_results_from_csv(
415    input_file: str,
416    config_type: type[ConfigT],
417) -> list[BenchmarkResult[ConfigT]]:
418    """Load benchmark results from a CSV file.
419
420    Reconstructs BenchmarkResult objects from the flattened CSV format created
421    by :py:func:`save_results_to_csv`.
422    Each row in the CSV is parsed into a :py:class:`BenchmarkResult`
423    with the appropriate config type.
424
425    Args:
426        input_file: Input CSV file path
427        config_type: The dataclass type to use for the config field
428
429    Returns:
430        List of BenchmarkResult instances with parsed config objects
431
432    Raises:
433        FileNotFoundError: If input_file does not exist
434        ValueError: If CSV format is invalid or ``config_type`` is not a dataclass
435    """
436    if not hasattr(config_type, "__dataclass_fields__"):
437        raise ValueError(f"config_type must be a dataclass, got {config_type}")
438    fields: dict[str, Any] = config_type.__dataclass_fields__  # pyre-ignore[16]
439
440    # Normalize input path and resolve symbolic links
441    input_file = os.path.realpath(input_file)
442
443    # Get the field names from the config dataclass
444    config_fields = set(fields.keys())
445
446    # Performance metric fields that are part of BenchmarkResult
447    result_fields = {
448        "executor_type",
449        "qps",
450        "ci_lower",
451        "ci_upper",
452        "date",
453        "python_version",
454        "free_threaded",
455        "cpu_percent",
456    }
457
458    results: list[BenchmarkResult[ConfigT]] = []
459
460    TRUES = ("true", "1", "yes")
461
462    with open(input_file, newline="") as csvfile:
463        reader = csv.DictReader((v for v in csvfile if not v.strip().startswith("#")))
464
465        for row in reader:
466            # Split row into config fields and result fields
467            config_dict = {}
468            result_dict = {}
469
470            for key, value in row.items():
471                if key in config_fields:
472                    config_dict[key] = value
473                elif key in result_fields:
474                    result_dict[key] = value
475                else:
476                    # Unknown field - could be from config or result
477                    # Try to infer based on whether it matches a config field name
478                    config_dict[key] = value
479
480            # Convert string values to appropriate types for config
481            typed_config_dict = {}
482            for field_name, field_info in fields.items():
483                if field_name not in config_dict:
484                    continue
485
486                value = config_dict[field_name]
487                field_type = field_info.type
488
489                # Handle type conversions
490                if field_type is int or field_type == "int":
491                    typed_config_dict[field_name] = int(value)
492                elif field_type is float or field_type == "float":
493                    # pyrefly: ignore [unsupported-operation]
494                    typed_config_dict[field_name] = float(value)
495                elif field_type is bool or field_type == "bool":
496                    typed_config_dict[field_name] = value.lower() in TRUES
497                else:
498                    # Keep as string or use the value as-is
499                    # pyrefly: ignore [unsupported-operation]
500                    typed_config_dict[field_name] = value
501
502            result = BenchmarkResult(
503                config=config_type(**typed_config_dict),
504                executor_type=result_dict["executor_type"],
505                qps=float(result_dict["qps"]),
506                ci_lower=float(result_dict["ci_lower"]),
507                ci_upper=float(result_dict["ci_upper"]),
508                date=result_dict["date"],
509                python_version=result_dict["python_version"],
510                free_threaded=result_dict["free_threaded"].lower()
511                in ("true", "1", "yes"),
512                cpu_percent=float(result_dict.get("cpu_percent", 0.0)),
513            )
514
515            results.append(result)
516
517    return results

API Reference

Functions

get_default_result_path(path: str, ext: str = '.csv') str[source]

Get the default result path with Python version appended.

load_results_from_csv(input_file: str, config_type: type[ConfigT]) list[BenchmarkResult[ConfigT]][source]

Load benchmark results from a CSV file.

Reconstructs BenchmarkResult objects from the flattened CSV format created by save_results_to_csv(). Each row in the CSV is parsed into a BenchmarkResult with the appropriate config type.

Parameters:
  • input_file – Input CSV file path

  • config_type – The dataclass type to use for the config field

Returns:

List of BenchmarkResult instances with parsed config objects

Raises:
save_results_to_csv(results: list[BenchmarkResult[Any]], output_file: str) None[source]

Save benchmark results to a CSV file.

Flattens the nested BenchmarkResult structure (config + performance metrics) into a flat CSV format. Each row contains both the benchmark configuration fields and the performance metrics.

Parameters:
  • results – List of BenchmarkResult instances

  • output_file – Output file path for the CSV file

Classes

class BenchmarkRunner(executor_type: ExecutorType, num_workers: int, warmup_iterations: int | None = None)[source]

Runner for executing benchmarks with configurable executors.

This class provides a standardized way to run benchmarks with:

  • Warmup phase to exclude executor initialization overhead

  • Multiple runs for statistical confidence intervals

  • Support for different executor types

The executor is initialized and warmed up in the constructor to exclude initialization overhead from benchmark measurements.

Parameters:
  • executor_type – Type of executor to use ("thread", "process", or "interpreter")

  • num_workers – Number of concurrent workers

  • warmup_iterations – Number of warmup iterations (default: 2 * num_workers)

property executor_type: ExecutorType[source]

Get the executor type.

run(config: ConfigT, func: Callable[[], T], iterations: int, num_runs: int = 5, confidence_level: float = 0.95) tuple[BenchmarkResult[ConfigT], T][source]

Run benchmark and return results with configuration.

Parameters:
  • config – Benchmark-specific configuration

  • func – Function to benchmark (takes no arguments)

  • iterations – Number of iterations per run

  • num_runs – Number of benchmark runs for confidence interval calculation (default: 5)

  • confidence_level – Confidence level for interval calculation (default: 0.95)

Returns:

Tuple of (BenchmarkResult, last output from function)

class BenchmarkResult[source]

Generic benchmark result containing configuration and performance metrics.

This class holds both the benchmark-specific configuration and the common performance statistics. It is parameterized by the config type, which allows each benchmark script to define its own configuration dataclass.

ci_lower: float

Lower bound of 95% confidence interval for QPS

ci_upper: float

Upper bound of 95% confidence interval for QPS

config: ConfigT

Benchmark-specific configuration (e.g., data format, file size, etc.)

cpu_percent: float

Average CPU utilization percentage during benchmark execution.

date: str

When benchmark was run. ISO 8601 format.

executor_type: str

Type of executor used (thread, process, or interpreter)

free_threaded: bool = False

Whether Python is running with free-threaded ABI.

python_version: str = '3.12.14'

Python version used for the benchmark

qps: float

Queries per second (mean)

class ExecutorType[source]

Supported executor types for concurrent execution.

INTERPRETER = 'interpreter'

Use InterpreterPoolExecutor.

Requires Python 3.14+.

PROCESS = 'process'

Use ProcessPoolExecutor.

THREAD = 'thread'

Use ThreadPoolExecutor.