packages feed

dtmc-0.3.0.0: benchmark/python/bench.py

#!/usr/bin/env python3
"""Pyperf benchmarks matching the public dtmc benchmark operations."""

from __future__ import annotations

import hashlib
import json
import os
from dataclasses import dataclass
from pathlib import Path
from time import perf_counter
from typing import Any, Callable

import numpy as np
import pyperf
from pydtmc import MarkovChain


@dataclass(frozen=True)
class Dataset:
    metadata: dict[str, Any]
    matrix: np.ndarray
    initial: np.ndarray

    @property
    def name(self) -> str:
        return str(self.metadata["id"])

    @property
    def family(self) -> str:
        return str(self.metadata["family"])

    @property
    def size(self) -> int:
        return int(self.metadata["size"])

    @property
    def seed(self) -> int:
        return int(self.metadata["seed"])

    @property
    def targets(self) -> list[int]:
        return [int(value) for value in self.metadata["targets"]]

    @property
    def absorbing(self) -> list[int]:
        return [int(value) for value in self.metadata["absorbing"]]

    @property
    def competing(self) -> list[int]:
        targets = set(self.targets)
        return [state for state in range(self.size) if state not in targets][
            : max(1, len(targets))
        ]


def data_root() -> Path:
    return Path(os.environ.get("DTMC_BENCH_DATA", "benchmark/data/generated")).resolve()


def load_selected_dataset() -> Dataset:
    root = data_root()
    manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
    family = os.environ.get("DTMC_BENCH_FAMILY")
    size = os.environ.get("DTMC_BENCH_SIZE")
    seed = os.environ.get("DTMC_BENCH_SEED")
    matches = [
        entry
        for entry in manifest["datasets"]
        if (family is None or entry["family"] == family)
        and (size is None or entry["size"] == int(size))
        and (seed is None or entry["seed"] == int(seed))
    ]
    if len(matches) != 1:
        raise RuntimeError(
            "select exactly one dataset with DTMC_BENCH_FAMILY, "
            "DTMC_BENCH_SIZE, and DTMC_BENCH_SEED"
        )
    entry = matches[0]
    payload_path = root / entry["file"]
    digest = hashlib.sha256(payload_path.read_bytes()).hexdigest()
    if digest != entry["sha256"]:
        raise RuntimeError(f"SHA-256 mismatch for {payload_path}")
    n = int(entry["size"])
    values = np.fromfile(payload_path, dtype="<f8")
    expected = n * n + n
    if values.size != expected:
        raise RuntimeError(f"{payload_path} contains {values.size} values, expected {expected}")
    matrix = values[: n * n].reshape((n, n)).copy()
    initial = values[n * n :].copy()
    matrix.flags.writeable = False
    initial.flags.writeable = False
    return Dataset(entry, matrix, initial)


def checksum_array(value: Any) -> float:
    array = np.asarray(value, dtype=np.float64).ravel(order="C")
    return float(np.sum(array, dtype=np.float64))


def consume_chain(chain: MarkovChain) -> float:
    return checksum_array(chain.p)


def consume_classes(classes: list[list[str]]) -> int:
    return sum(int(state) - 1 for group in classes for state in group)


def consume_classes_access(classes: list[list[str]]) -> bool:
    return classes is not None


def consume_bool(value: bool) -> int:
    return int(value)


def consume_float(value: float) -> float:
    return float(value)


def consume_sequence(value: list[int]) -> int:
    return sum(map(int, value))


def consume_optional_array(value: Any) -> float:
    if value is None:
        raise RuntimeError("PyDTMC operation unexpectedly returned None")
    return checksum_array(value)


def occupation_including_initial(chain: MarkovChain) -> np.ndarray:
    """Adapt PyDTMC's positive-time visit counts to dtmc's time-zero convention."""
    values = chain.mean_number_visits()
    if values is None:
        raise RuntimeError("PyDTMC operation unexpectedly returned None")
    adjusted = np.asarray(values, dtype=np.float64).copy()
    adjusted.flat[:: adjusted.shape[0] + 1] += 1.0
    return adjusted


def bounded_return_probability(chain: MarkovChain, steps: int) -> float:
    probabilities = chain.first_passage_probabilities(steps, 0, [0])
    return float(np.sum(probabilities, dtype=np.float64))


def finite_time_probability(
    chain: MarkovChain,
    steps: int,
    initial: np.ndarray,
    destination: int,
) -> float:
    distribution = chain.redistribute(
        steps,
        initial_status=initial,
        output_last=True,
    )
    return float(distribution[destination])


def bounded_hitting_masses(
    chain: MarkovChain,
    steps: int,
    initial_state: int,
    targets: list[int],
) -> np.ndarray:
    return np.asarray(
        chain.first_passage_probabilities(steps, initial_state, targets),
        dtype=np.float64,
    )


def target_absorbing_matrix(
    matrix: np.ndarray,
    targets: list[int],
) -> np.ndarray:
    """Preserve first hits while making PyDTMC's target events disjoint."""
    adjusted = matrix.copy()
    adjusted[targets, :] = 0.0
    adjusted[targets, targets] = 1.0
    adjusted.flags.writeable = False
    return adjusted


def exact_hitting_probability(
    chain: MarkovChain,
    steps: int,
    initial_state: int,
    targets: list[int],
) -> float:
    masses = bounded_hitting_masses(chain, steps, initial_state, targets)
    return float(masses[-1])


def bounded_hitting_probability(
    chain: MarkovChain,
    steps: int,
    initial_state: int,
    targets: list[int],
) -> float:
    masses = bounded_hitting_masses(chain, steps, initial_state, targets)
    return float(np.sum(masses, dtype=np.float64))


def upper_hitting_probability(
    chain: MarkovChain,
    steps: int,
    initial_state: int,
    targets: list[int],
) -> float:
    return 1.0 - bounded_hitting_probability(
        chain,
        steps,
        initial_state,
        targets,
    )


def bounded_visit_expectation(
    chain: MarkovChain, steps: int, rewards: np.ndarray
) -> float:
    return float(chain.expected_rewards(steps - 1, rewards)[0])


def fresh_chain(dataset: Dataset) -> MarkovChain:
    return MarkovChain(dataset.matrix)


def operation_selected(name: str) -> bool:
    selected_many = os.environ.get("DTMC_BENCH_OPERATIONS")
    if selected_many is not None:
        operations = [item for item in selected_many.split(",") if item]
        return any(name.endswith(f"/{operation}") for operation in operations)

    selected = os.environ.get("DTMC_BENCH_OPERATION")
    return selected is None or selected in name


def register_fresh(
    runner: pyperf.Runner,
    name: str,
    setup: Callable[[], Any],
    operation: Callable[[Any], Any],
    consume: Callable[[Any], Any],
) -> None:
    """Exclude fixture setup while still using a fresh cache for every sample."""
    if not operation_selected(name):
        return

    def timed(loops: int) -> float:
        elapsed = 0.0
        for _ in range(loops):
            fixture = setup()
            started = perf_counter()
            result = operation(fixture)
            consume(result)
            elapsed += perf_counter() - started
        return elapsed

    runner.bench_time_func(name, timed)


def register_warm(
    runner: pyperf.Runner,
    name: str,
    fixture: Any,
    operation: Callable[[Any], Any],
    consume: Callable[[Any], Any],
) -> None:
    if not operation_selected(name):
        return
    inner_loops = 10_000

    def timed(loops: int) -> float:
        started = perf_counter()
        for _ in range(loops * inner_loops):
            consume(operation(fixture))
        return perf_counter() - started

    runner.bench_time_func(name, timed, inner_loops=inner_loops)


def register_benchmarks(runner: pyperf.Runner, dataset: Dataset) -> None:
    prefix = dataset.name
    matrix = dataset.matrix
    initial = dataset.initial
    targets = dataset.targets
    competing = dataset.competing
    target = targets[0]
    hitting_matrix = target_absorbing_matrix(matrix, targets)
    point_initial = np.zeros(dataset.size, dtype=np.float64)
    point_initial[0] = 1.0
    point_initial.flags.writeable = False

    register_fresh(
        runner,
        f"{prefix}/construction/public-consumed",
        lambda: matrix,
        MarkovChain,
        consume_chain,
    )

    for steps in (1, 10, 100):
        label = "one-step" if steps == 1 else f"k-{steps}"
        register_fresh(
            runner,
            f"{prefix}/evolution/{label}",
            lambda: fresh_chain(dataset),
            lambda chain, count=steps: chain.redistribute(
                count, initial_status=initial, output_last=True
            ),
            checksum_array,
        )

    if dataset.size <= 100:
        register_fresh(
            runner,
            f"{prefix}/finite-time/step",
            lambda: fresh_chain(dataset),
            lambda chain: chain.p[0, target],
            consume_float,
        )
        for steps in (10, 100):
            register_fresh(
                runner,
                f"{prefix}/finite-time/n-step/k-{steps}",
                lambda: fresh_chain(dataset),
                lambda chain, count=steps: finite_time_probability(
                    chain,
                    count,
                    point_initial,
                    target,
                ),
                consume_float,
            )
            register_fresh(
                runner,
                f"{prefix}/finite-time/observation/k-{steps}",
                lambda: fresh_chain(dataset),
                lambda chain, count=steps: finite_time_probability(
                    chain,
                    count,
                    initial,
                    target,
                ),
                consume_float,
            )

    if dataset.size <= 500:
        for exponent in (2, 10, 100):
            register_fresh(
                runner,
                f"{prefix}/power/{exponent}",
                lambda: fresh_chain(dataset),
                lambda chain, value=exponent: chain.to_nth_order(value),
                consume_chain,
            )

    if dataset.family != "dense" or dataset.size <= 500:
        register_fresh(
            runner,
            f"{prefix}/structure/classes-lifecycle",
            lambda: matrix,
            lambda value: MarkovChain(value).communicating_classes,
            consume_classes,
        )
        register_fresh(
            runner,
            f"{prefix}/structure/classes-cold",
            lambda: fresh_chain(dataset),
            lambda chain: chain.communicating_classes,
            consume_classes,
        )

        warm_classes = fresh_chain(dataset)
        consume_classes(warm_classes.communicating_classes)
        register_warm(
            runner,
            f"{prefix}/structure/classes-warm/access",
            warm_classes,
            lambda chain: chain.communicating_classes,
            consume_classes_access,
        )
        register_warm(
            runner,
            f"{prefix}/structure/classes-warm/consumed",
            warm_classes,
            lambda chain: chain.communicating_classes,
            consume_classes,
        )
        register_fresh(
            runner,
            f"{prefix}/structure/irreducible-cold",
            lambda: fresh_chain(dataset),
            lambda chain: chain.is_irreducible,
            consume_bool,
        )

        if dataset.family == "periodic":
            register_fresh(
                runner,
                f"{prefix}/structure/period-cold",
                lambda: fresh_chain(dataset),
                lambda chain: chain.period,
                consume_float,
            )
            warm_period = fresh_chain(dataset)
            consume_float(warm_period.period)
            register_warm(
                runner,
                f"{prefix}/structure/period-warm",
                warm_period,
                lambda chain: chain.period,
                consume_float,
            )
            register_fresh(
                runner,
                f"{prefix}/structure/cyclic-classes-cold",
                lambda: fresh_chain(dataset),
                lambda chain: chain.cyclic_classes,
                consume_classes,
            )
            warm_cyclic_classes = fresh_chain(dataset)
            consume_classes(warm_cyclic_classes.cyclic_classes)
            register_warm(
                runner,
                f"{prefix}/structure/cyclic-classes-warm",
                warm_cyclic_classes,
                lambda chain: chain.cyclic_classes,
                consume_classes,
            )

        warm_irreducible = fresh_chain(dataset)
        consume_bool(warm_irreducible.is_irreducible)
        register_warm(
            runner,
            f"{prefix}/structure/irreducible-warm",
            warm_irreducible,
            lambda chain: chain.is_irreducible,
            consume_bool,
        )

    if dataset.size <= 500:
        register_fresh(
            runner,
            f"{prefix}/stationary",
            lambda: fresh_chain(dataset),
            lambda chain: chain.pi,
            checksum_array,
        )
        register_fresh(
            runner,
            f"{prefix}/hitting-probability/cold-all-states",
            lambda: fresh_chain(dataset),
            lambda chain: chain.hitting_probabilities(targets),
            checksum_array,
        )
        register_fresh(
            runner,
            f"{prefix}/hitting-time/cold-all-states",
            lambda: fresh_chain(dataset),
            lambda chain: chain.hitting_times(targets),
            checksum_array,
        )

        if dataset.family in {"dense", "low-outdegree"}:
            register_fresh(
                runner,
                f"{prefix}/race/forward-committor",
                lambda: fresh_chain(dataset),
                lambda chain: chain.committor_probabilities(
                    "forward", competing, targets
                ),
                consume_optional_array,
            )

    if dataset.family in {"dense", "low-outdegree"} and dataset.size <= 500:
        register_fresh(
            runner,
            f"{prefix}/return/mean-recurrence",
            lambda: fresh_chain(dataset),
            lambda chain: chain.mean_recurrence_times(),
            consume_optional_array,
        )

    if dataset.size <= 100:
        rewards = np.zeros(dataset.size, dtype=np.float64)
        rewards[targets[0]] = 1.0
        rewards.flags.writeable = False
        for steps in (10, 100):
            for label, operation in (
                ("exact", exact_hitting_probability),
                ("at-most", bounded_hitting_probability),
                ("greater-than", upper_hitting_probability),
            ):
                register_fresh(
                    runner,
                    f"{prefix}/hitting/bounded/{label}/k-{steps}",
                    lambda: MarkovChain(hitting_matrix),
                    lambda chain, count=steps, query=operation: query(
                        chain,
                        count,
                        0,
                        targets,
                    ),
                    consume_float,
                )
            register_fresh(
                runner,
                f"{prefix}/return/bounded/k-{steps}",
                lambda: fresh_chain(dataset),
                lambda chain, count=steps: bounded_return_probability(chain, count),
                consume_float,
            )
            register_fresh(
                runner,
                f"{prefix}/visits/bounded-expectation/k-{steps}",
                lambda: fresh_chain(dataset),
                lambda chain, count=steps: bounded_visit_expectation(
                    chain, count, rewards
                ),
                consume_float,
            )

    if dataset.family == "absorbing" and dataset.size <= 500:
        register_fresh(
            runner,
            f"{prefix}/fundamental-matrix",
            lambda: fresh_chain(dataset),
            lambda chain: chain.fundamental_matrix,
            consume_optional_array,
        )
        register_fresh(
            runner,
            f"{prefix}/absorption/probabilities",
            lambda: fresh_chain(dataset),
            lambda chain: chain.absorption_probabilities(),
            consume_optional_array,
        )
        register_fresh(
            runner,
            f"{prefix}/absorption-time",
            lambda: fresh_chain(dataset),
            lambda chain: chain.mean_absorption_times(),
            consume_optional_array,
        )

    if dataset.family in {"absorbing", "reducible"} and dataset.size <= 250:
        register_fresh(
            runner,
            f"{prefix}/occupation-matrix",
            lambda: fresh_chain(dataset),
            occupation_including_initial,
            consume_optional_array,
        )

    if dataset.family in {"dense", "low-outdegree"}:
        for transitions in (10_000, 100_000):
            register_fresh(
                runner,
                f"{prefix}/simulation/{transitions}",
                lambda: fresh_chain(dataset),
                lambda chain, count=transitions: chain.simulate(
                    count,
                    initial_state=0,
                    output_indices=True,
                    seed=dataset.seed,
                ),
                consume_sequence,
            )


def main() -> None:
    dataset = load_selected_dataset()
    # One cold operation per value prevents pyperf calibration from recreating
    # an expensive untimed fixture thousands of times. Three processes with ten
    # values give 30 independent full-operation samples in the normal suite.
    runner = pyperf.Runner(processes=3, values=10, warmups=3, loops=1)
    runner.metadata["dataset"] = dataset.name
    runner.metadata["dataset_sha256"] = dataset.metadata["sha256"]
    runner.metadata["pydtmc_version"] = "9.0.0"
    register_benchmarks(runner, dataset)


if __name__ == "__main__":
    main()