Viewing files at commit 3cd2661fdbe9Commit detailsView latest

scripts/oakbench/envwatch.py 126 lines · 4.3 KB · python
1
"""Environment boundary sampling for measured runs.
2
3
Sample the host before and after a measurement window, then ask
4
``environment_suspect`` whether the window looks clean.  All fields are
5
nullable: a signal the host cannot provide is None, never a guess (ADR-0002).
6
Suspect rows are flagged, never deleted.
7
"""
8
9
from __future__ import annotations
10
11
import os
12
import platform
13
import re
14
import subprocess
15
from pathlib import Path
16
from typing import Any
17
18
from oakbench.runner import _command_text, _text_file, cpu_governor
19
20
LINUX_MEMINFO = Path("/proc/meminfo")
21
LINUX_THERMAL = Path("/sys/class/thermal")
22
23
24
def _loadavg() -> list[float] | None:
25
    try:
26
        return [float(value) for value in os.getloadavg()]
27
    except OSError:
28
        return None
29
30
31
def parse_meminfo_available_bytes(text: str) -> int | None:
32
    for line in text.splitlines():
33
        if line.startswith("MemAvailable:"):
34
            parts = line.split()
35
            if len(parts) >= 2 and parts[1].isdigit():
36
                return int(parts[1]) * 1024
37
    return None
38
39
40
def parse_vm_stat_free_bytes(text: str) -> int | None:
41
    page_size_match = re.search(r"page size of (\d+) bytes", text)
42
    free_match = re.search(r"Pages free:\s+(\d+)", text)
43
    if not page_size_match or not free_match:
44
        return None
45
    return int(free_match.group(1)) * int(page_size_match.group(1))
46
47
48
def parse_pmset_cpu_speed_limit(text: str) -> int | None:
49
    match = re.search(r"CPU_Speed_Limit\s*=\s*(\d+)", text)
50
    return int(match.group(1)) if match else None
51
52
53
def _mem_available_bytes(system: str) -> int | None:
54
    if system == "Linux":
55
        text = _text_file(LINUX_MEMINFO)
56
        return parse_meminfo_available_bytes(text) if text else None
57
    if system == "Darwin":
58
        text = _command_text(["/usr/bin/vm_stat"])
59
        return parse_vm_stat_free_bytes(text) if text else None
60
    return None
61
62
63
def _cpu_speed_limit_pct(system: str) -> int | None:
64
    if system != "Darwin":
65
        return None
66
    text = _command_text(["pmset", "-g", "therm"])
67
    return parse_pmset_cpu_speed_limit(text) if text else None
68
69
70
def _thermal_zone_max_c(system: str) -> float | None:
71
    if system != "Linux":
72
        return None
73
    readings = [
74
        int(value) for path in LINUX_THERMAL.glob("thermal_zone*/temp") if (value := _text_file(path)) and value.lstrip("-").isdigit()
75
    ]
76
    return max(readings) / 1000.0 if readings else None
77
78
79
def sample_environment() -> dict[str, Any]:
80
    """One boundary sample.  Timestamp is the caller's job."""
81
    system = platform.system()
82
    return {
83
        "loadavg": _loadavg(),
84
        "mem_available_bytes": _mem_available_bytes(system),
85
        "cpu_speed_limit_pct": _cpu_speed_limit_pct(system),
86
        "thermal_zone_max_c": _thermal_zone_max_c(system),
87
        "cpu_governor": cpu_governor(),
88
        "system": system,
89
    }
90
91
92
def _load_per_core(sample: dict[str, Any], cores: int) -> float | None:
93
    loadavg = sample.get("loadavg")
94
    if not loadavg or cores <= 0:
95
        return None
96
    return float(loadavg[0]) / cores
97
98
99
def environment_suspect(
100
    before: dict[str, Any],
101
    after: dict[str, Any],
102
    *,
103
    load_per_core_threshold: float = 1.5,
104
    cpu_count: int | None = None,
105
) -> list[str]:
106
    """Human-readable suspicion reasons for a measurement window (empty = clean)."""
107
    cores = cpu_count if cpu_count is not None else (os.cpu_count() or 1)
108
    reasons: list[str] = []
109
    for label, sample in (("before", before), ("after", after)):
110
        per_core = _load_per_core(sample, cores)
111
        if per_core is not None and per_core > load_per_core_threshold:
112
            reasons.append(
113
                f"load_per_core {per_core:.2f} above threshold {load_per_core_threshold:g} at {label} boundary"
114
            )
115
    for label, sample in (("before", before), ("after", after)):
116
        limit = sample.get("cpu_speed_limit_pct")
117
        if limit is not None and limit < 100:
118
            reasons.append(f"thermal throttling at {label} boundary: CPU_Speed_Limit={limit}")
119
    governors = {
120
        str(sample.get("cpu_governor"))
121
        for sample in (before, after)
122
        if sample.get("cpu_governor") is not None and sample.get("cpu_governor") != "performance"
123
    }
124
    for governor in sorted(governors):
125
        reasons.append(f"cpu_governor is '{governor}', not 'performance' (informational)")
126
    return reasons