CultLib/packages/cultcache-py/src/cultcache_py/compare_csharp.py
2026-06-14 01:30:50 +01:00

108 lines
4.2 KiB
Python

from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
from .benchmark import run_benchmark
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="cultcache-py-compare-csharp")
parser.add_argument("--records", type=int, default=5000)
parser.add_argument("--repo-root", type=Path, default=None)
parser.add_argument("--json", action="store_true", dest="emit_json")
args = parser.parse_args(argv)
if args.records <= 0:
raise ValueError("--records must be greater than zero")
result = compare_with_csharp(records=args.records, repo_root=args.repo_root)
if args.emit_json:
print(json.dumps(result, indent=2, sort_keys=True))
else:
print(f"records: {result['records']}")
print(f"csharpStatus: {result['csharpStatus']}")
for runtime in ("python", "csharp"):
benchmark = result.get(runtime)
if not benchmark:
continue
print(runtime)
for metric in benchmark["metrics"]:
print(f"- {metric['name']}: {metric['opsPerSecond']:.0f} ops/s ({metric['elapsedMs']:.2f} ms)")
return 0 if result["csharpStatus"] == "ok" else 1
def compare_with_csharp(*, records: int, repo_root: Path | None = None) -> dict[str, Any]:
resolved_root = _resolve_repo_root(repo_root)
python_result = run_benchmark(records)
csharp_result, status, error = _run_csharp_benchmark(resolved_root, records)
comparison = _compare_common_metrics(python_result, csharp_result) if csharp_result else []
result: dict[str, Any] = {
"records": records,
"repoRoot": str(resolved_root),
"python": python_result,
"csharpStatus": status,
"comparison": comparison,
}
if csharp_result is not None:
result["csharp"] = csharp_result
if error:
result["csharpError"] = error
return result
def _resolve_repo_root(repo_root: Path | None) -> Path:
if repo_root is not None:
return repo_root.resolve()
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "CultLib.sln").is_file():
return parent
raise FileNotFoundError("Could not locate CultLib.sln; pass --repo-root.")
def _run_csharp_benchmark(repo_root: Path, records: int) -> tuple[dict[str, Any] | None, str, str | None]:
project = repo_root / "packages" / "cultcache-py" / "tools" / "GameCult.Caching.Benchmark" / "GameCult.Caching.Benchmark.csproj"
if not project.is_file():
return None, "missing", f"C# benchmark project not found: {project}"
completed = subprocess.run(
["dotnet", "run", "--project", str(project), "-c", "Release", "--", "--records", str(records), "--json"],
cwd=repo_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if completed.returncode != 0:
return None, "failed", completed.stderr.strip() or completed.stdout.strip()
try:
payload = next(line for line in reversed(completed.stdout.splitlines()) if line.strip())
return json.loads(payload), "ok", None
except json.JSONDecodeError as exc:
return None, "invalid-json", f"{exc}: {completed.stdout}"
except StopIteration:
return None, "invalid-json", "C# benchmark produced no stdout."
def _compare_common_metrics(python_result: dict[str, Any], csharp_result: dict[str, Any]) -> list[dict[str, Any]]:
python_metrics = {metric["name"]: metric for metric in python_result["metrics"]}
csharp_metrics = {metric["name"]: metric for metric in csharp_result["metrics"]}
comparison = []
for name in sorted(set(python_metrics).intersection(csharp_metrics)):
python_ops = float(python_metrics[name]["opsPerSecond"])
csharp_ops = float(csharp_metrics[name]["opsPerSecond"])
comparison.append({
"name": name,
"pythonOpsPerSecond": python_ops,
"csharpOpsPerSecond": csharp_ops,
"pythonToCsharpRatio": python_ops / csharp_ops if csharp_ops > 0 else None,
})
return comparison
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))