Source code for simplebench.reporters.csv.reporter.reporter
"""Reporter for benchmark results using CSV files.
This module provides the :class:`~.CSVReporter` class, which is responsible for
outputting benchmark results to CSV files.
"""
from __future__ import annotations
import csv
from io import StringIO
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias
from simplebench.defaults import DEFAULT_INTERVAL_SCALE
from simplebench.enums import Section
from simplebench.exceptions import SimpleBenchTypeError
from simplebench.reporters.reporter import Reporter
from simplebench.reporters.reporter.options import ReporterOptions
from simplebench.results import Results
from simplebench.si_units import si_scale_for_smallest
from simplebench.type_proxies import is_case
from simplebench.utils import sigfigs
from simplebench.validators import validate_type
from .config import CSVConfig
from .exceptions import _CSVReporterErrorTag
from .options import CSVField, CSVOptions
Options: TypeAlias = CSVOptions
if TYPE_CHECKING:
from simplebench.case import Case
[docs]
class CSVReporter(Reporter):
"""Class for outputting benchmark results to CSV files.
It supports reporting statistics for various sections,
either separately or together, to the filesystem, via a callback function,
or to the console in CSV format.
The CSV files are tagged with metadata comments including the case title,
description, and units for clarity.
Defined command-line flags:
--csv: {file, console, callback} (default=file) Outputs results to CSV.
.. code-block:: bash
program.py --csv # Outputs results to CSV files in the filesystem (default).
program.py --csv filesystem # Outputs results to CSV files in the filesystem.
program.py --csv console # Outputs results to the console in CSV format.
program.py --csv callback # Outputs results via a callback function in CSV format.
program.py --csv filesystem console # Outputs results to both CSV files and the console.
:ivar name: The unique identifying name of the reporter.
:vartype name: str
:ivar description: A brief description of the reporter.
:vartype description: str
:ivar choices: Iterable of :class:`~.ChoicesConf` instances defining
the reporter instance, CLI flags, :class:`~.ChoiceConf` name, supported
:class:`~simplebench.enums.Section` objects, supported output
:class:`~simplebench.enums.Target` objects, and supported output
:class:`~simplebench.enums.Format` for the reporter.
:vartype choices: Iterable[:class:`~.ChoicesConf`]
:ivar targets: The supported output targets for the reporter.
:vartype targets: set[:class:`~simplebench.enums.Target`]
:ivar formats: The supported output formats for the reporter.
:vartype formats: set[:class:`~simplebench.enums.Format`]
"""
_OPTIONS_TYPE: ClassVar[type[CSVOptions]] = CSVOptions # pylint: disable=line-too-long # type: ignore[reportInvalidVariableOverride] # noqa: E501
""":meta private:"""
_OPTIONS_KWARGS: ClassVar[dict[str, Any]] = {}
""":meta private:"""
def __init__(self, config: CSVConfig | None = None) -> None:
"""Initialize the :class:`~.CSVReporter`.
.. note::
The exception documentation below refers to validation of subclass configuration
class variables :attr:`~._OPTIONS_TYPE` and :attr:`~._OPTIONS_KWARGS`. These must be
correctly defined in any subclass of :class:`~.CSVReporter` to ensure proper
functionality.
:param config: An optional configuration object to override default reporter settings.
If not provided, default settings will be used.
:type config: CSVConfig | None
:raises SimpleBenchTypeError: If the subclass configuration types are invalid.
:raises SimpleBenchValueError: If the subclass configuration values are invalid.
"""
if config is None:
config = CSVConfig()
super().__init__(config)
[docs]
def render(self, *, case: Case, section: Section, options: ReporterOptions) -> str:
"""Renders the benchmark results as tagged CSV data and returns it as a string.
:param case: The :class:`~simplebench.case.Case` instance representing the
benchmarked code.
:param section: The section to output (eg. :attr:`~simplebench.enums.Section.OPS` or
:attr:`~simplebench.enums.Section.TIMING`).
:param options: The options for the CSV report.
:return: The benchmark results formatted as tagged CSV data.
:raises SimpleBenchValueError: If the specified section is unsupported.
"""
if not is_case(case): # Handle deferred import type checking
raise SimpleBenchTypeError(
f"Invalid case argument: expected Case instance, got {type(case).__name__}",
tag=_CSVReporterErrorTag.RENDER_INVALID_CASE)
section = validate_type(section, Section, 'section',
_CSVReporterErrorTag.RENDER_INVALID_SECTION)
options = validate_type(options, Options, 'options',
_CSVReporterErrorTag.RENDER_INVALID_OPTIONS)
included_fields = options.fields
base_unit: str = self.get_base_unit_for_section(section=section)
results: list[Results] = case.results
# Determine a common SI scale for the output values to improve readability
all_numbers: list[float] = self.get_all_stats_values(results=results, section=section)
common_unit, common_scale = si_scale_for_smallest(numbers=all_numbers, base_unit=base_unit)
with StringIO() as csvfile:
csvfile.seek(0)
writer = csv.writer(csvfile)
writer.writerow([f'# title: {case.title}'])
writer.writerow([f'# description: {case.description}'])
writer.writerow([f'# unit: {common_unit}'])
header: list[str] = []
if not options.variation_cols_last:
for value in case.variation_cols.values():
header.append(value)
for field in included_fields:
match field:
case CSVField.N:
header.append('N')
case CSVField.ITERATIONS:
header.append('Iterations')
case CSVField.ROUNDS:
header.append('Rounds')
case CSVField.ELAPSED_SECONDS:
header.append('Elapsed Seconds')
case CSVField.MEAN:
header.append(f'mean ({common_unit})')
case CSVField.MEDIAN:
header.append(f'median ({common_unit})')
case CSVField.MIN:
header.append(f'min ({common_unit})')
case CSVField.MAX:
header.append(f'max ({common_unit})')
case CSVField.P5:
header.append(f'5th ({common_unit})')
case CSVField.P95:
header.append(f'95th ({common_unit})')
case CSVField.STD_DEV:
header.append(f'std dev ({common_unit})')
case CSVField.RSD_PERCENT:
header.append('rsd (%)')
if options.variation_cols_last:
for value in case.variation_cols.values():
header.append(value)
writer.writerow(header)
for result in results:
stats_target = result.results_section(section)
row: list[str | float | int] = []
if not options.variation_cols_last:
for value in result.variation_marks.values():
row.append(value)
for field in included_fields:
match field:
case CSVField.N:
row.append(result.n)
case CSVField.ITERATIONS:
row.append(len(result.iterations))
case CSVField.ROUNDS:
row.append(result.rounds)
case CSVField.ELAPSED_SECONDS:
row.append(sigfigs(result.total_elapsed * DEFAULT_INTERVAL_SCALE, 10))
case CSVField.MEAN:
row.append(sigfigs(stats_target.mean * common_scale))
case CSVField.MEDIAN:
row.append(sigfigs(stats_target.median * common_scale))
case CSVField.MIN:
row.append(sigfigs(stats_target.minimum * common_scale))
case CSVField.MAX:
row.append(sigfigs(stats_target.maximum * common_scale))
case CSVField.P5:
row.append(sigfigs(stats_target.percentiles[5] * common_scale))
case CSVField.P95:
row.append(sigfigs(stats_target.percentiles[95] * common_scale))
case CSVField.STD_DEV:
row.append(sigfigs(stats_target.standard_deviation * common_scale))
case CSVField.RSD_PERCENT:
row.append(sigfigs(stats_target.relative_standard_deviation))
if options.variation_cols_last:
for value in result.variation_marks.values():
row.append(value)
writer.writerow(row)
csvfile.seek(0)
return csvfile.read()