| 1 | """Cross-process serialization for benchmark measurement phases.
|
| 2 |
|
| 3 | Co-running benchmarks poison each other's timings: a lane measuring latency
|
| 4 | while another process is compiling Rust or running its own timed workload is
|
| 5 | recording machine contention, not VCS behavior. The harness therefore
|
| 6 | serializes measured work across processes while still allowing editing, builds,
|
| 7 | fixture generation, and result writing to run in parallel outside this lock.
|
| 8 |
|
| 9 | Set ``OAK_BENCH_LOCK=off`` to disable locking. That is only safe on a machine
|
| 10 | running one benchmark at a time. The lock file defaults to
|
| 11 | ``tempfile.gettempdir() / "oak-bench-measurement.lock"`` and can be overridden
|
| 12 | with ``OAK_BENCH_LOCK_PATH``.
|
| 13 | """
|
| 14 |
|
| 15 | from __future__ import annotations
|
| 16 |
|
| 17 | import contextlib
|
| 18 | import errno
|
| 19 | import os
|
| 20 | import sys
|
| 21 | import tempfile
|
| 22 | import time
|
| 23 | from dataclasses import dataclass
|
| 24 | from pathlib import Path
|
| 25 | from typing import Iterator, TextIO
|
| 26 |
|
| 27 | try:
|
| 28 | import fcntl
|
| 29 | except ImportError: # pragma: no cover - exercised on platforms without fcntl
|
| 30 | fcntl = None # type: ignore[assignment]
|
| 31 |
|
| 32 |
|
| 33 | PROGRESS_INTERVAL_S = 30.0
|
| 34 | POLL_INTERVAL_S = 0.1
|
| 35 | BLOCKED_ERRNOS = {errno.EACCES, errno.EAGAIN}
|
| 36 | if hasattr(errno, "EWOULDBLOCK"):
|
| 37 | BLOCKED_ERRNOS.add(errno.EWOULDBLOCK)
|
| 38 |
|
| 39 |
|
| 40 | @dataclass(frozen=True)
|
| 41 | class LockInfo:
|
| 42 | wait_ms: float | None
|
| 43 | enabled: bool
|
| 44 | path: str | None = None
|
| 45 |
|
| 46 |
|
| 47 | def measurement_lock_path() -> Path:
|
| 48 | """Canonical identity of the lock every measurement lane will use."""
|
| 49 | configured = os.environ.get("OAK_BENCH_LOCK_PATH")
|
| 50 | if configured:
|
| 51 | return Path(configured).expanduser().resolve()
|
| 52 | return (Path(tempfile.gettempdir()) / "oak-bench-measurement.lock").resolve()
|
| 53 |
|
| 54 |
|
| 55 | def _lock_path() -> Path:
|
| 56 | return measurement_lock_path()
|
| 57 |
|
| 58 |
|
| 59 | def _read_holder(handle: TextIO) -> str:
|
| 60 | try:
|
| 61 | handle.seek(0)
|
| 62 | text = handle.read().strip()
|
| 63 | except OSError:
|
| 64 | return "unknown"
|
| 65 | if not text:
|
| 66 | return "unknown"
|
| 67 | return text.splitlines()[0].strip() or "unknown"
|
| 68 |
|
| 69 |
|
| 70 | def _write_holder(handle: TextIO, label: str) -> None:
|
| 71 | try:
|
| 72 | handle.seek(0)
|
| 73 | handle.truncate()
|
| 74 | handle.write(f"{label} pid={os.getpid()}\n")
|
| 75 | handle.flush()
|
| 76 | except OSError:
|
| 77 | pass
|
| 78 |
|
| 79 |
|
| 80 | def _warn_disabled(reason: str) -> None:
|
| 81 | print(f"[runlock] measurement lock disabled: {reason}", file=sys.stderr, flush=True)
|
| 82 |
|
| 83 |
|
| 84 | @contextlib.contextmanager
|
| 85 | def measurement_lock(label: str) -> Iterator[LockInfo]:
|
| 86 | """Acquire the process-wide benchmark measurement lock."""
|
| 87 | path = _lock_path()
|
| 88 | if os.environ.get("OAK_BENCH_LOCK", "").lower() == "off":
|
| 89 | yield LockInfo(wait_ms=None, enabled=False, path=str(path))
|
| 90 | return
|
| 91 |
|
| 92 | if fcntl is None:
|
| 93 | _warn_disabled("fcntl is not available on this platform")
|
| 94 | yield LockInfo(wait_ms=None, enabled=False, path=str(path))
|
| 95 | return
|
| 96 |
|
| 97 | try:
|
| 98 | path.parent.mkdir(parents=True, exist_ok=True)
|
| 99 | fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o666)
|
| 100 | handle = os.fdopen(fd, "r+", encoding="utf-8")
|
| 101 | except OSError as exc:
|
| 102 | _warn_disabled(f"could not open {path}: {exc}")
|
| 103 | yield LockInfo(wait_ms=None, enabled=False, path=str(path))
|
| 104 | return
|
| 105 |
|
| 106 | wait_start = time.perf_counter()
|
| 107 | next_progress = wait_start + PROGRESS_INTERVAL_S
|
| 108 | acquired = False
|
| 109 | try:
|
| 110 | while True:
|
| 111 | try:
|
| 112 | fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 113 | acquired = True
|
| 114 | break
|
| 115 | except BlockingIOError:
|
| 116 | pass
|
| 117 | except OSError as exc:
|
| 118 | if getattr(exc, "errno", None) not in BLOCKED_ERRNOS:
|
| 119 | _warn_disabled(f"could not acquire {path}: {exc}")
|
| 120 | yield LockInfo(wait_ms=None, enabled=False, path=str(path))
|
| 121 | return
|
| 122 |
|
| 123 | now = time.perf_counter()
|
| 124 | if now >= next_progress:
|
| 125 | waited_s = int(now - wait_start)
|
| 126 | holder = _read_holder(handle)
|
| 127 | print(
|
| 128 | f"[runlock] waiting for measurement lock held by {holder} ({waited_s}s)",
|
| 129 | file=sys.stderr,
|
| 130 | flush=True,
|
| 131 | )
|
| 132 | next_progress += PROGRESS_INTERVAL_S
|
| 133 | time.sleep(min(POLL_INTERVAL_S, max(0.0, next_progress - time.perf_counter())))
|
| 134 |
|
| 135 | wait_ms = (time.perf_counter() - wait_start) * 1000
|
| 136 | _write_holder(handle, label)
|
| 137 | yield LockInfo(wait_ms=wait_ms, enabled=True, path=str(path))
|
| 138 | finally:
|
| 139 | try:
|
| 140 | if acquired:
|
| 141 | fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
| 142 | finally:
|
| 143 | handle.close()
|