Benchmark utils¶
Common utilities for benchmark scripts.
This module provides a standardized framework for running benchmarks with:
Configurable executor types (
ThreadPoolExecutor,ProcessPoolExecutor,InterpreterPoolExecutor)Warmup phase to exclude executor initialization overhead
Statistical analysis with confidence intervals
CSV export functionality
Python version and free-threaded ABI detection
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 aBenchmarkResultwith 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:
FileNotFoundError – If input_file does not exist
ValueError – If CSV format is invalid or
config_typeis not a dataclass
- 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.
- config: ConfigT¶
Benchmark-specific configuration (e.g., data format, file size, etc.)
- class ExecutorType[source]¶
Supported executor types for concurrent execution.
- INTERPRETER = 'interpreter'¶
-
Requires Python 3.14+.
- PROCESS = 'process'¶
Use
ProcessPoolExecutor.
- THREAD = 'thread'¶
Use
ThreadPoolExecutor.