Viewing files at commit 4f4412a18356Commit detailsView latest

scripts/oakbench/runlock.py 143 lines · 4.5 KB · python
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()