packages feed

dtmc-0.3.0.0: benchmark/run.py

#!/usr/bin/env python3
"""Reproducible orchestration for the dtmc versus PyDTMC benchmark."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import platform
import subprocess
import sys
from pathlib import Path
from typing import Any


BENCHMARK_ROOT = Path(__file__).resolve().parent
PROJECT_ROOT = BENCHMARK_ROOT.parent
DATA_DIR = BENCHMARK_ROOT / "data"
DATA_ROOT = DATA_DIR / "generated"
RESULTS_ROOT = BENCHMARK_ROOT / "results"
VENV = BENCHMARK_ROOT / ".venv"
CABAL_PROJECT = "benchmark/cabal.project"
SPEC = json.loads((DATA_DIR / "spec.json").read_text(encoding="utf-8"))
SIZES = tuple(map(int, SPEC["sizes"]))
SEEDS = tuple(map(int, SPEC["seeds"]))
FAMILIES = tuple(map(str, SPEC["families"]))
THREAD_ENV = {
    "OPENBLAS_NUM_THREADS": "1",
    "OMP_NUM_THREADS": "1",
    "MKL_NUM_THREADS": "1",
    "VECLIB_MAXIMUM_THREADS": "1",
    "NUMEXPR_NUM_THREADS": "1",
    "MPLCONFIGDIR": str(BENCHMARK_ROOT / ".cache" / "matplotlib"),
    "XDG_CACHE_HOME": str(BENCHMARK_ROOT / ".cache"),
    "MPLBACKEND": "Agg",
}


def run(command: list[str], *, env: dict[str, str] | None = None) -> None:
    printable = " ".join(command)
    print(f"+ {printable}", flush=True)
    subprocess.run(command, cwd=PROJECT_ROOT, env=env, check=True)


def capture(command: list[str], *, env: dict[str, str] | None = None) -> str:
    try:
        return subprocess.run(
            command,
            cwd=PROJECT_ROOT,
            env=env,
            check=True,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
        ).stdout.strip()
    except (OSError, subprocess.CalledProcessError) as problem:
        return f"unavailable: {problem}"


def python_executable() -> Path:
    name = "python.exe" if os.name == "nt" else "python"
    return VENV / ("Scripts" if os.name == "nt" else "bin") / name


def haskell_executable() -> Path:
    result = subprocess.run(
        ["cabal", "list-bin", f"--project-file={CABAL_PROJECT}", "dtmc-bench"],
        cwd=PROJECT_ROOT,
        check=True,
        text=True,
        stdout=subprocess.PIPE,
    )
    path = Path(result.stdout.strip())
    if not path.exists():
        raise RuntimeError("dtmc-bench is not built; run the bootstrap command first")
    return path


def bootstrap() -> None:
    (BENCHMARK_ROOT / ".cache" / "matplotlib").mkdir(parents=True, exist_ok=True)
    if not python_executable().exists():
        run([sys.executable, "-m", "venv", str(VENV)])
    run(
        [
            str(python_executable()),
            "-m",
            "pip",
            "install",
            "--disable-pip-version-check",
            "-r",
            str(BENCHMARK_ROOT / "python" / "requirements.lock"),
        ]
    )
    run(["cabal", "build", f"--project-file={CABAL_PROJECT}", "dtmc-bench"])


def generate(mode: str) -> None:
    sizes = SIZES if mode == "full" else (10,)
    seeds = SEEDS if mode == "full" else (SEEDS[0],)
    python = str(python_executable()) if python_executable().exists() else sys.executable
    run(
        [
            python,
            str(DATA_DIR / "generate.py"),
            "--sizes",
            ",".join(map(str, sizes)),
            "--seeds",
            ",".join(map(str, seeds)),
        ]
    )


def benchmark_environment(
    entry: dict[str, Any], data_root: Path = DATA_ROOT
) -> dict[str, str]:
    environment = os.environ.copy()
    environment.update(THREAD_ENV)
    environment.update(
        {
            "DTMC_BENCH_DATA": str(data_root),
            "DTMC_BENCH_FAMILY": str(entry["family"]),
            "DTMC_BENCH_SIZE": str(entry["size"]),
            "DTMC_BENCH_SEED": str(entry["seed"]),
        }
    )
    return environment


def manifest_entries(mode: str, data_root: Path = DATA_ROOT) -> list[dict[str, Any]]:
    manifest = json.loads((data_root / "manifest.json").read_text(encoding="utf-8"))
    entries = list(manifest["datasets"])
    if mode == "smoke":
        entries = [
            entry
            for entry in entries
            if entry["size"] == 10 and entry["seed"] == SEEDS[0]
        ]
    return entries


def verify(mode: str) -> None:
    RESULTS_ROOT.mkdir(parents=True, exist_ok=True)
    (BENCHMARK_ROOT / ".cache" / "matplotlib").mkdir(parents=True, exist_ok=True)
    haskell_output = RESULTS_ROOT / "haskell-verification.json"
    verification_output = RESULTS_ROOT / "verification.json"
    environment = os.environ.copy()
    environment.update(THREAD_ENV)
    environment["DTMC_BENCH_DATA"] = str(DATA_ROOT)
    environment["DTMC_VERIFY_MAX_SIZE"] = "100" if mode == "full" else "10"
    run(
        [
            str(haskell_executable()),
            "--verify-json",
            str(haskell_output),
        ],
        env=environment,
    )
    run(
        [
            str(python_executable()),
            str(BENCHMARK_ROOT / "python" / "verify.py"),
            str(haskell_output),
            "--output",
            str(verification_output),
        ],
        env=environment,
    )


def benchmark(mode: str) -> None:
    raw_haskell = RESULTS_ROOT / "raw" / "haskell"
    raw_python = RESULTS_ROOT / "raw" / "python"
    raw_haskell.mkdir(parents=True, exist_ok=True)
    raw_python.mkdir(parents=True, exist_ok=True)

    for entry in manifest_entries(mode):
        name = str(entry["id"])
        environment = benchmark_environment(entry)
        haskell_json = raw_haskell / f"{name}.json"
        haskell_csv = raw_haskell / f"{name}.csv"
        haskell_raw = raw_haskell / f"{name}.raw"
        python_json = raw_python / f"{name}.json"
        for output in (haskell_json, haskell_csv, haskell_raw, python_json):
            output.unlink(missing_ok=True)
        criterion_command = [
            str(haskell_executable()),
            "--json",
            str(haskell_json),
            "--csv",
            str(haskell_csv),
            "--raw",
            str(haskell_raw),
            "--time-limit",
            "0.1" if mode == "smoke" else "5",
            "--resamples",
            "100" if mode == "smoke" else "10000",
        ]
        run(criterion_command, env=environment)

        pyperf_command = [
            str(python_executable()),
            str(BENCHMARK_ROOT / "python" / "bench.py"),
            "--copy-env",
            "-o",
            str(python_json),
        ]
        if mode == "smoke":
            pyperf_command.append("--fast")
        run(pyperf_command, env=environment)


def record_environment(
    results_root: Path = RESULTS_ROOT,
    data_root: Path = DATA_ROOT,
    benchmark_scope: list[str] | None = None,
) -> None:
    results_root.mkdir(parents=True, exist_ok=True)
    environment = os.environ.copy()
    environment.update(THREAD_ENV)
    python = str(python_executable()) if python_executable().exists() else sys.executable
    haskell_binary = haskell_executable()

    def digest(path: Path) -> str:
        return hashlib.sha256(path.read_bytes()).hexdigest()

    record = {
        "captured_at_utc": capture(["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"]),
        "platform": platform.platform(),
        "machine": platform.machine(),
        "processor": platform.processor(),
        "cpu_brand": capture(["sysctl", "-n", "machdep.cpu.brand_string"]),
        "python": capture([python, "--version"]),
        "python_packages": capture([python, "-m", "pip", "freeze"]),
        "numpy_configuration": capture(
            [python, "-c", "import numpy as n; n.show_config()"], env=environment
        ),
        "ghc": capture(["ghc", "--version"]),
        "cabal": capture(["cabal", "--version"]),
        "haskell_benchmark_binary": str(haskell_binary),
        "haskell_benchmark_linkage": capture(["otool", "-L", str(haskell_binary)]),
        "haskell_options": ["-O2", "-threaded", "-rtsopts"],
        "cabal_freeze_sha256": digest(BENCHMARK_ROOT / "cabal.project.freeze"),
        "python_lock_sha256": digest(BENCHMARK_ROOT / "python" / "requirements.lock"),
        "dataset_spec_sha256": digest(DATA_DIR / "spec.json"),
        "dataset_manifest_sha256": digest(data_root / "manifest.json"),
        "git_commit": capture(["git", "rev-parse", "HEAD"]),
        "git_status": capture(["git", "status", "--short"]),
        "thread_environment": THREAD_ENV,
        "benchmark_scope": benchmark_scope or "complete suite",
    }
    (results_root / "environment.json").write_text(
        json.dumps(record, indent=2) + "\n", encoding="utf-8"
    )


def profile() -> None:
    entries = manifest_entries("full")
    candidates = [entry for entry in entries if entry["family"] == "dense"]
    entry = next((item for item in candidates if item["size"] == 100), candidates[0])
    environment = benchmark_environment(entry)
    profile_root = BENCHMARK_ROOT / "profiles"
    profile_root.mkdir(parents=True, exist_ok=True)
    for operation in (
        "stationary",
        "hitting-probability/cold-all-states",
        "race/forward-committor",
        "return/mean-recurrence",
        "visits/bounded-expectation/k-100",
        "simulation/100000",
    ):
        safe = operation.replace("/", "-")
        haskell_path = profile_root / f"haskell-{safe}.txt"
        with haskell_path.open("w", encoding="utf-8") as stream:
            subprocess.run(
                [
                    str(haskell_executable()),
                    "--time-limit",
                    "0.2",
                    "--resamples",
                    "100",
                    f"{entry['id']}/{operation}",
                    "+RTS",
                    "-s",
                    "-RTS",
                ],
                cwd=PROJECT_ROOT,
                env=environment,
                check=True,
                stdout=stream,
                stderr=subprocess.STDOUT,
            )
        python_environment = environment.copy()
        python_environment["DTMC_BENCH_OPERATION"] = operation
        run(
            [
                str(python_executable()),
                str(BENCHMARK_ROOT / "python" / "bench.py"),
                "--copy-env",
                "--debug-single-value",
                "--profile",
                str(profile_root / f"python-{safe}.prof"),
            ],
            env=python_environment,
        )


def analyse(
    results_root: Path = RESULTS_ROOT, data_root: Path = DATA_ROOT
) -> None:
    run(
        [
            str(python_executable()),
            str(BENCHMARK_ROOT / "analyse.py"),
            "--data-root",
            str(data_root),
            "--results-root",
            str(results_root),
        ]
    )
    run(
        [
            str(python_executable()),
            str(BENCHMARK_ROOT / "plot.py"),
            "--results-root",
            str(results_root),
        ]
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "command",
        choices=(
            "bootstrap",
            "generate",
            "verify",
            "benchmark",
            "profile",
            "analyse",
            "all",
        ),
    )
    parser.add_argument("--mode", choices=("smoke", "full"), default="smoke")
    args = parser.parse_args()

    if args.command in {"bootstrap", "all"}:
        bootstrap()
    if args.command in {"generate", "all"}:
        generate(args.mode)
    if args.command in {"verify", "all"}:
        verify(args.mode)
    if args.command in {"benchmark", "all"}:
        benchmark(args.mode)
    if args.command == "profile":
        profile()
    if args.command in {"analyse", "all"}:
        record_environment()
        analyse()


if __name__ == "__main__":
    main()