Skip to content

cs_survival_kit.bench

A small, stdlib-only toolkit for benchmarking how code scales with input size.

Define a Benchmark, add one case per implementation to compare, and run it:

from cs_survival_kit.bench import Benchmark

b = Benchmark("sorting", sizes=[10**k for k in range(2, 6)])
b.case("sorted", setup=lambda n: list(range(n, 0, -1)), run=sorted)
results = b.run()
results.table()

Run benchmark files from the command line with python -m cs_survival_kit.bench.

Benchmark

Benchmark(name: str, sizes: Sequence[int], *, per_item: bool = False)

A named set of cases, each timed across a range of input sizes.

Each case pairs a setup function, which builds the inputs for a size and is not timed, with a run function, which is. Cases in the same benchmark are alternatives to compare, such as two implementations of one operation.

Parameters:

Name Type Description Default
name str

A unique name for the benchmark, such as "dynamic_array.append". Stored results are keyed by it.

required
sizes Sequence[int]

The input sizes to measure. Cases use these unless they bring their own.

required
per_item bool

Set this when a run of size n performs n operations, such as n appends. The results table then also shows time divided by n, the amortized cost of one operation. Leave it off when n is only the size of the input to a single operation, such as sorting n items.

False

Raises:

Type Description
ValueError

If sizes is empty or contains a non-positive size.

Examples:

>>> b = Benchmark("sum", sizes=[1_000, 10_000])
>>> b.case("builtin", setup=lambda n: list(range(n)), run=sum)
>>> results = b.run(smoke=True)
>>> [case.sizes for case in results.cases]
[[1000]]
Source code in lib/cs_survival_kit/bench/core.py
def __init__(
    self, name: str, sizes: Sequence[int], *, per_item: bool = False
) -> None:
    self.name = name
    self.sizes = _validate_sizes(sizes)
    self.per_item = per_item
    self._cases: list[_Case] = []

case

case(label: str, *, setup: Callable[[int], T], run: Callable[[T], object], sizes: Sequence[int] | None = None) -> None

Add a case to the benchmark.

Parameters:

Name Type Description Default
label str

A name for the case, unique within the benchmark.

required
setup Callable[[int], T]

Builds the inputs for a size n. Not timed. It is called again before every timed call, so run is free to mutate what it is given.

required
run Callable[[T], object]

The code to time. It receives whatever setup returned.

required
sizes Sequence[int] | None

Sizes for this case only, overriding the benchmark's. Use it to cap a slow case at smaller inputs.

None

Raises:

Type Description
ValueError

If label is already taken, or sizes is invalid.

Source code in lib/cs_survival_kit/bench/core.py
def case[T](
    self,
    label: str,
    *,
    setup: Callable[[int], T],
    run: Callable[[T], object],
    sizes: Sequence[int] | None = None,
) -> None:
    """Add a case to the benchmark.

    Args:
        label: A name for the case, unique within the benchmark.
        setup: Builds the inputs for a size `n`. Not timed. It is called
            again before every timed call, so `run` is free to mutate
            what it is given.
        run: The code to time. It receives whatever `setup` returned.
        sizes: Sizes for this case only, overriding the benchmark's. Use
            it to cap a slow case at smaller inputs.

    Raises:
        ValueError: If `label` is already taken, or `sizes` is invalid.
    """
    if any(case.label == label for case in self._cases):
        raise ValueError(f"duplicate case label: {label!r}")
    case_sizes = self.sizes if sizes is None else _validate_sizes(sizes)
    self._cases.append(_Case(label, setup, run, case_sizes))

run

run(repeat: int = 5, *, smoke: bool = False) -> Results

Time every case at every size.

Each measurement calls run in a loop until the timed calls add up to about 0.1 seconds, so that fast calls are not lost in timer noise. The reported time is per call, and is the minimum over repeat measurements: the minimum is the run least disturbed by the rest of the machine. Garbage collection is disabled while timing.

A case that raises NotImplementedError is reported as "not implemented" and skipped, so benchmarks can be written before the code they measure.

Parameters:

Name Type Description Default
repeat int

How many measurements to take per size.

5
smoke bool

Only check that the cases execute: time the smallest size once, with a single call. The numbers are meaningless.

False

Returns:

Type Description
Results

The measurements for every case.

Raises:

Type Description
ValueError

If repeat is less than 1.

Source code in lib/cs_survival_kit/bench/core.py
def run(self, repeat: int = 5, *, smoke: bool = False) -> Results:
    """Time every case at every size.

    Each measurement calls `run` in a loop until the timed calls add up
    to about 0.1 seconds, so that fast calls are not lost in timer noise.
    The reported time is per call, and is the minimum over `repeat`
    measurements: the minimum is the run least disturbed by the rest of
    the machine. Garbage collection is disabled while timing.

    A case that raises `NotImplementedError` is reported as
    `"not implemented"` and skipped, so benchmarks can be written before
    the code they measure.

    Args:
        repeat: How many measurements to take per size.
        smoke: Only check that the cases execute: time the smallest size
            once, with a single call. The numbers are meaningless.

    Returns:
        The measurements for every case.

    Raises:
        ValueError: If `repeat` is less than 1.
    """
    if repeat < 1:
        raise ValueError("repeat must be at least 1")
    run_at = datetime.now(UTC).isoformat(timespec="seconds")
    cases = [_run_case(case, repeat, smoke) for case in self._cases]
    return Results(
        self.name, run_at, __version__, _environment(), cases, self.per_item
    )

CaseResult dataclass

CaseResult(label: str, status: str, sizes: list[int], seconds: list[float], slope: float | None)

The measurements for one case of a benchmark.

Attributes:

Name Type Description
label str

The case's label.

status str

"ok", or "not implemented" if the case raised NotImplementedError.

sizes list[int]

The input sizes that were measured.

seconds list[float]

Per-call time for each entry of sizes.

slope float | None

Log-log slope of seconds against sizes, or None.

Fit dataclass

Fit(slope: float | None, growth: str)

The empirical growth of one case.

Attributes:

Name Type Description
slope float | None

Least-squares slope of log(seconds) against log(n), or None when there are too few sizes to fit.

growth str

A rough human-readable reading of the slope.

Results dataclass

Results(name: str, run_at: str, package_version: str, environment: dict[str, str], cases: list[CaseResult], per_item: bool = False)

The outcome of one Benchmark.run.

Attributes:

Name Type Description
name str

The benchmark's name.

run_at str

When the run started, as an ISO 8601 UTC timestamp.

package_version str

The cs-survival-kit version that was measured.

environment dict[str, str]

The interpreter and machine the run happened on.

cases list[CaseResult]

One result per case, in the order the cases were added.

per_item bool

Whether a run of size n performs n operations, so that time divided by n is a meaningful per-operation cost.

fit

fit() -> dict[str, Fit]

Estimate each case's growth from its measurements.

The slope of time against size on a log-log scale approximates the exponent k in O(n^k): about 0 is constant, about 1 is linear, about 2 is quadratic. O(n log n) has no exponent of its own and reads as slightly above 1.

This is an empirical sanity check, not a proof. Constant factors, caches and small sizes all bend the line.

Returns:

Type Description
dict[str, Fit]

A mapping from case label to its fit.

Source code in lib/cs_survival_kit/bench/core.py
def fit(self) -> dict[str, Fit]:
    """Estimate each case's growth from its measurements.

    The slope of time against size on a log-log scale approximates the
    exponent `k` in `O(n^k)`: about 0 is constant, about 1 is linear,
    about 2 is quadratic. `O(n log n)` has no exponent of its own and
    reads as slightly above 1.

    This is an empirical sanity check, not a proof. Constant factors,
    caches and small sizes all bend the line.

    Returns:
        A mapping from case label to its fit.
    """
    fits: dict[str, Fit] = {}
    for case in self.cases:
        if case.status != OK:
            growth = case.status
        elif case.slope is None:
            growth = "-"
        else:
            growth = describe_slope(case.slope)
        fits[case.label] = Fit(case.slope, growth)
    return fits

format

format() -> str

Render the results as a plain-text table.

Returns:

Type Description
str

One row per size and one column per case, followed by each

str

case's slope and growth. For a per_item benchmark, a second

str

table follows with every time divided by its size: the amortized

str

cost of one operation. A flat column there means constant cost

str

per operation; a growing one means each operation gets more

str

expensive as the input grows.

Source code in lib/cs_survival_kit/bench/core.py
def format(self) -> str:
    """Render the results as a plain-text table.

    Returns:
        One row per size and one column per case, followed by each
        case's slope and growth. For a `per_item` benchmark, a second
        table follows with every time divided by its size: the amortized
        cost of one operation. A flat column there means constant cost
        per operation; a growing one means each operation gets more
        expensive as the input grows.
    """
    fits = self.fit()
    sizes = sorted({n for case in self.cases for n in case.sizes})
    columns: list[list[str]] = [
        ["n", *(f"{n:,}" for n in sizes), "slope", "growth"]
    ]
    per_item_columns: list[list[str]] = [["n", *(f"{n:,}" for n in sizes)]]
    for case in self.cases:
        times = dict(zip(case.sizes, case.seconds, strict=True))
        fit = fits[case.label]
        columns.append(
            [
                case.label,
                *(_format_seconds(times[n]) if n in times else "-" for n in sizes),
                "-" if fit.slope is None else f"{fit.slope:.2f}",
                fit.growth,
            ]
        )
        per_item_columns.append(
            [
                case.label,
                *(
                    _format_seconds(times[n] / n) if n in times else "-"
                    for n in sizes
                ),
            ]
        )
    lines = [self.name, *_format_columns(columns)]
    if self.per_item:
        lines += ["", "per item (time / n)", *_format_columns(per_item_columns)]
    return "\n".join(lines)

table

table() -> None

Print the results as a plain-text table.

Source code in lib/cs_survival_kit/bench/core.py
def table(self) -> None:
    """Print the results as a plain-text table."""
    print(self.format())

to_dict

to_dict() -> dict[str, Any]

Convert the results to a JSON-serializable dictionary.

Returns:

Type Description
dict[str, Any]

The benchmark entry stored in benchmarks.json.

Source code in lib/cs_survival_kit/bench/core.py
def to_dict(self) -> dict[str, Any]:
    """Convert the results to a JSON-serializable dictionary.

    Returns:
        The benchmark entry stored in `benchmarks.json`.
    """
    return asdict(self)