Viewing files at commit dc75e12cb299Commit detailsView latest

scripts/oakbench/runlock.py 137 lines · 4.2 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
45
46
def _lock_path() -> Path:
47
    configured = os.environ.get("OAK_BENCH_LOCK_PATH")
48
    if configured:
49
        return Path(configured)
50
    return Path(tempfile.gettempdir()) / "oak-bench-measurement.lock"
51
52
53
def _read_holder(handle: TextIO) -> str:
54
    try:
55
        handle.seek(0)
56
        text = handle.read().strip()
57
    except OSError:
58
        return "unknown"
59
    if not text:
60
        return "unknown"
61
    return text.splitlines()[0].strip() or "unknown"
62
63
64
def _write_holder(handle: TextIO, label: str) -> None:
65
    try:
66
        handle.seek(0)
67
        handle.truncate()
68
        handle.write(f"{label} pid={os.getpid()}\n")
69
        handle.flush()
70
    except OSError:
71
        pass
72
73
74
def _warn_disabled(reason: str) -> None:
75
    print(f"[runlock] measurement lock disabled: {reason}", file=sys.stderr, flush=True)
76
77
78
@contextlib.contextmanager
79
def measurement_lock(label: str) -> Iterator[LockInfo]:
80
    """Acquire the process-wide benchmark measurement lock."""
81
    if os.environ.get("OAK_BENCH_LOCK", "").lower() == "off":
82
        yield LockInfo(wait_ms=None, enabled=False)
83
        return
84
85
    if fcntl is None:
86
        _warn_disabled("fcntl is not available on this platform")
87
        yield LockInfo(wait_ms=None, enabled=False)
88
        return
89
90
    path = _lock_path()
91
    try:
92
        path.parent.mkdir(parents=True, exist_ok=True)
93
        fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o666)
94
        handle = os.fdopen(fd, "r+", encoding="utf-8")
95
    except OSError as exc:
96
        _warn_disabled(f"could not open {path}: {exc}")
97
        yield LockInfo(wait_ms=None, enabled=False)
98
        return
99
100
    wait_start = time.perf_counter()
101
    next_progress = wait_start + PROGRESS_INTERVAL_S
102
    acquired = False
103
    try:
104
        while True:
105
            try:
106
                fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
107
                acquired = True
108
                break
109
            except BlockingIOError:
110
                pass
111
            except OSError as exc:
112
                if getattr(exc, "errno", None) not in BLOCKED_ERRNOS:
113
                    _warn_disabled(f"could not acquire {path}: {exc}")
114
                    yield LockInfo(wait_ms=None, enabled=False)
115
                    return
116
117
            now = time.perf_counter()
118
            if now >= next_progress:
119
                waited_s = int(now - wait_start)
120
                holder = _read_holder(handle)
121
                print(
122
                    f"[runlock] waiting for measurement lock held by {holder} ({waited_s}s)",
123
                    file=sys.stderr,
124
                    flush=True,
125
                )
126
                next_progress += PROGRESS_INTERVAL_S
127
            time.sleep(min(POLL_INTERVAL_S, max(0.0, next_progress - time.perf_counter())))
128
129
        wait_ms = (time.perf_counter() - wait_start) * 1000
130
        _write_holder(handle, label)
131
        yield LockInfo(wait_ms=wait_ms, enabled=True)
132
    finally:
133
        try:
134
            if acquired:
135
                fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
136
        finally:
137
            handle.close()