Skip to content

Commit e834053

Browse files
committed
Detect unusable perf counters
1 parent 792a10d commit e834053

5 files changed

Lines changed: 232 additions & 15 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,12 @@ instruction counts and RSS growth remain independently guarded.
162162

163163
## Measurement scope and limitations
164164

165-
- Linux instruction counting uses `perf_event_open`. Rootless or restricted
166-
runners commonly deny it; the artifact then says `process_time`, never
167-
`instructions`.
165+
- Linux instruction counting uses `perf_event_open`. Each pytest process runs
166+
one brief startup calibration and validates the counter's scheduled time on
167+
every read. Access denial, a zero/nonfunctional counter, no scheduled time,
168+
or less than 90% scheduled time causes an automatic `process_time` fallback;
169+
the artifact records the exact unavailable reason. This avoids both false
170+
zero-instruction baselines and noisy heavily multiplexed baselines.
168171
- CPU time is process CPU consumed during each pytest call. Hardware
169172
instructions count only the thread running that call. The artifact records
170173
these scopes explicitly. Summed job and session CPU remain guarded so

src/pytest_perfguard/metrics.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@
2929
_PERF_EVENT_IOC_ENABLE = 0x2400
3030
_PERF_EVENT_IOC_DISABLE = 0x2401
3131
_PERF_EVENT_IOC_RESET = 0x2403
32+
_PERF_FORMAT_TOTAL_TIME_ENABLED = 1 << 0
33+
_PERF_FORMAT_TOTAL_TIME_RUNNING = 1 << 1
3234
_PERF_FLAG_FD_CLOEXEC = 1 << 3
33-
_COUNTER_VALUE_SIZE = struct.calcsize("=Q")
35+
_COUNTER_READ_FORMAT = "=QQQ"
36+
_COUNTER_VALUE_SIZE = struct.calcsize(_COUNTER_READ_FORMAT)
37+
_MIN_COUNTER_RUNNING_PERCENT = 90
38+
_CALIBRATION_ITERATIONS = 20_000
3439

3540
_ATTR_DISABLED = 1 << 0
3641
_ATTR_EXCLUDE_KERNEL = 1 << 5
@@ -64,11 +69,50 @@ def _perf_event_syscall_number() -> int | None:
6469
}.get(platform.machine().lower())
6570

6671

72+
def _normalize_instruction_count(value: int, time_enabled: int, time_running: int) -> int:
73+
"""Validate and scale a perf counter reading."""
74+
if time_running == 0:
75+
raise OSError("perf hardware counter was not scheduled (time_running=0)")
76+
if time_running > time_enabled:
77+
raise OSError(
78+
"perf hardware counter returned inconsistent timing "
79+
f"(time_enabled={time_enabled}, time_running={time_running})"
80+
)
81+
if value == 0:
82+
raise OSError("perf hardware instruction counter returned zero despite scheduled work")
83+
if time_running * 100 < time_enabled * _MIN_COUNTER_RUNNING_PERCENT:
84+
raise OSError(
85+
"perf hardware counter was heavily multiplexed "
86+
f"(time_enabled={time_enabled}, time_running={time_running}, "
87+
f"minimum_running={_MIN_COUNTER_RUNNING_PERCENT}%)"
88+
)
89+
return value * time_enabled // time_running
90+
91+
92+
def _instruction_calibration_workload() -> int:
93+
"""Execute enough deterministic Python work to prove that a counter advances."""
94+
checksum = 0
95+
for value in range(_CALIBRATION_ITERATIONS):
96+
checksum = (checksum + value) & 0xFFFFFFFF
97+
return checksum
98+
99+
100+
def _errno_details(error_number: int) -> str:
101+
error_name = errno.errorcode.get(error_number, "UNKNOWN")
102+
try:
103+
error_message = os.strerror(error_number)
104+
except ValueError:
105+
error_message = "unknown error"
106+
return f"errno={error_number} {error_name}: {error_message}"
107+
108+
67109
class _InstructionCounter:
68110
"""A Linux perf-event counter attached to the current test thread."""
69111

70112
def __init__(self, file_descriptor: int) -> None:
71113
self._file_descriptor = file_descriptor
114+
self._time_enabled = 0
115+
self._time_running = 0
72116

73117
@classmethod
74118
def open(cls) -> tuple[_InstructionCounter | None, str | None]:
@@ -80,6 +124,7 @@ def open(cls) -> tuple[_InstructionCounter | None, str | None]:
80124
attributes.type = _PERF_TYPE_HARDWARE
81125
attributes.size = ctypes.sizeof(_PerfEventAttr)
82126
attributes.config = _PERF_COUNT_HW_INSTRUCTIONS
127+
attributes.read_format = _PERF_FORMAT_TOTAL_TIME_ENABLED | _PERF_FORMAT_TOTAL_TIME_RUNNING
83128
attributes.flags = _ATTR_DISABLED | _ATTR_EXCLUDE_KERNEL | _ATTR_EXCLUDE_HYPERVISOR
84129

85130
libc = ctypes.CDLL(None, use_errno=True)
@@ -94,9 +139,10 @@ def open(cls) -> tuple[_InstructionCounter | None, str | None]:
94139
)
95140
if file_descriptor < 0:
96141
error_number = ctypes.get_errno()
142+
error_details = _errno_details(error_number)
97143
if error_number in {errno.EACCES, errno.EPERM}:
98-
return None, "perf_event_open is not permitted by this runner"
99-
return None, f"perf_event_open failed with errno {error_number}"
144+
return None, f"perf_event_open is not permitted by this runner ({error_details})"
145+
return None, f"perf_event_open failed ({error_details})"
100146
return cls(int(file_descriptor)), None
101147

102148
def start(self) -> None:
@@ -112,7 +158,14 @@ def stop(self) -> int:
112158
raw_value = os.read(self._file_descriptor, _COUNTER_VALUE_SIZE)
113159
if len(raw_value) != _COUNTER_VALUE_SIZE:
114160
raise OSError("perf event returned an incomplete counter value")
115-
return int(struct.unpack("=Q", raw_value)[0])
161+
value, time_enabled, time_running = struct.unpack(_COUNTER_READ_FORMAT, raw_value)
162+
if time_enabled < self._time_enabled or time_running < self._time_running:
163+
raise OSError("perf hardware counter cumulative timing decreased")
164+
enabled_delta = time_enabled - self._time_enabled
165+
running_delta = time_running - self._time_running
166+
self._time_enabled = time_enabled
167+
self._time_running = time_running
168+
return _normalize_instruction_count(value, enabled_delta, running_delta)
116169

117170
def close(self) -> None:
118171
os.close(self._file_descriptor)
@@ -135,6 +188,13 @@ class CpuCounter:
135188

136189
def __init__(self) -> None:
137190
self._instructions, self.unavailable_reason = _InstructionCounter.open()
191+
if self._instructions is not None:
192+
try:
193+
self._instructions.start()
194+
_instruction_calibration_workload()
195+
self._instructions.stop()
196+
except OSError as error:
197+
self._disable_instructions(f"perf hardware counter calibration failed: {error}")
138198

139199
@property
140200
def backend(self) -> str:

src/pytest_perfguard/plugin.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,7 @@ def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int | pytest
127127
worker_environment = worker_fragment.get("environment")
128128
if isinstance(worker_environment, dict):
129129
environments.append(worker_environment)
130-
environment = dict(environments[0]) if environments else self._environment()
131-
backends = {str(worker_environment.get("counter_backend")) for worker_environment in environments}
132-
if len(backends) > 1:
133-
environment = {
134-
**environment,
135-
"counter_backend": "mixed",
136-
"cpu_instructions_scope": "mixed",
137-
}
130+
environment = _merge_environments(environments or [self._environment()])
138131

139132
payload = {
140133
"schema_version": SCHEMA_VERSION,
@@ -199,6 +192,7 @@ def _environment(self) -> dict[str, object]:
199192
return {
200193
"architecture": platform.machine() or "unknown",
201194
"counter_backend": self._cpu_counter.backend,
195+
"cpu_instructions_unavailable_reason": self._cpu_counter.unavailable_reason,
202196
"cpu_instructions_scope": instruction_scope,
203197
"cpu_model": cpu_model(),
204198
"cpu_time_scope": "pytest_call_process",
@@ -209,6 +203,25 @@ def _environment(self) -> dict[str, object]:
209203
}
210204

211205

206+
def _merge_environments(environments: list[dict[str, object]]) -> dict[str, object]:
207+
environment = dict(environments[0])
208+
backends = {str(worker_environment.get("counter_backend")) for worker_environment in environments}
209+
scopes = {str(worker_environment.get("cpu_instructions_scope")) for worker_environment in environments}
210+
reasons = sorted(
211+
{
212+
reason
213+
for worker_environment in environments
214+
if isinstance((reason := worker_environment.get("cpu_instructions_unavailable_reason")), str) and reason
215+
}
216+
)
217+
environment["cpu_instructions_unavailable_reason"] = " | ".join(reasons) if reasons else None
218+
if len(backends) > 1:
219+
environment["counter_backend"] = "mixed"
220+
if len(scopes) > 1:
221+
environment["cpu_instructions_scope"] = "mixed"
222+
return environment
223+
224+
212225
def _aggregate_job_metrics(
213226
fragments: list[dict[str, Any]],
214227
tests: list[dict[str, Any]],

tests/test_compare.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ def test_incompatible_runner_uses_a_separate_bucket() -> None:
207207
assert environment_fingerprint(current["environment"]) not in baseline["jobs"]["test"]["buckets"]
208208

209209

210+
def test_instruction_unavailable_reason_does_not_split_environment_bucket() -> None:
211+
current = _current()
212+
fingerprint = environment_fingerprint(current["environment"])
213+
current["environment"]["cpu_instructions_unavailable_reason"] = "errno=1 EPERM"
214+
215+
assert environment_fingerprint(current["environment"]) == fingerprint
216+
217+
210218
def test_record_mode_keeps_rolling_history_but_not_a_moving_anchor() -> None:
211219
baseline = None
212220
for index in range(25):

tests/test_plugin.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

33
import ctypes
4+
import errno
45
import json
56
import os
7+
import struct
68
import subprocess
79
import sys
810
from pathlib import Path
@@ -34,6 +36,105 @@ def test_process_metrics_are_available() -> None:
3436
assert memory_backend() in {"peak_rss_fallback", "proc_rss", "windows_working_set"}
3537

3638

39+
@pytest.mark.parametrize(
40+
("value", "time_enabled", "time_running", "expected"),
41+
[
42+
(123, 1_000, 1_000, 123),
43+
(900, 1_000, 900, 1_000),
44+
],
45+
)
46+
def test_instruction_count_normalizes_scheduled_time(
47+
value: int,
48+
time_enabled: int,
49+
time_running: int,
50+
expected: int,
51+
) -> None:
52+
assert metrics_module._normalize_instruction_count(value, time_enabled, time_running) == expected # noqa: SLF001
53+
54+
55+
@pytest.mark.parametrize(
56+
("value", "time_enabled", "time_running", "message"),
57+
[
58+
(100, 1_000, 899, "heavily multiplexed"),
59+
(100, 1_000, 0, "not scheduled"),
60+
(0, 1_000, 1_000, "returned zero"),
61+
(100, 999, 1_000, "inconsistent timing"),
62+
],
63+
)
64+
def test_instruction_count_rejects_unreliable_readings(
65+
value: int,
66+
time_enabled: int,
67+
time_running: int,
68+
message: str,
69+
) -> None:
70+
with pytest.raises(OSError, match=message):
71+
metrics_module._normalize_instruction_count(value, time_enabled, time_running) # noqa: SLF001
72+
73+
74+
def test_instruction_counter_uses_per_measurement_scheduling_time(monkeypatch: pytest.MonkeyPatch) -> None:
75+
monkeypatch.setattr(metrics_module, "fcntl", SimpleNamespace(ioctl=mock.Mock()))
76+
monkeypatch.setattr(
77+
os,
78+
"read",
79+
mock.Mock(
80+
side_effect=[
81+
struct.pack("=QQQ", 100, 1_000, 1_000),
82+
struct.pack("=QQQ", 90, 2_000, 1_900),
83+
]
84+
),
85+
)
86+
counter = metrics_module._InstructionCounter(42) # noqa: SLF001
87+
88+
assert counter.stop() == 100
89+
assert counter.stop() == 100
90+
91+
92+
def test_instruction_calibration_failure_falls_back_to_process_time() -> None:
93+
instruction_counter = mock.Mock(spec=metrics_module._InstructionCounter) # noqa: SLF001
94+
instruction_counter.stop.side_effect = OSError("perf hardware instruction counter returned zero")
95+
96+
with mock.patch.object(
97+
metrics_module._InstructionCounter, # noqa: SLF001
98+
"open",
99+
return_value=(instruction_counter, None),
100+
):
101+
counter = CpuCounter()
102+
103+
assert counter.backend == "process_time"
104+
assert counter.unavailable_reason == (
105+
"perf hardware counter calibration failed: perf hardware instruction counter returned zero"
106+
)
107+
instruction_counter.start.assert_called_once_with()
108+
instruction_counter.stop.assert_called_once_with()
109+
instruction_counter.close.assert_called_once_with()
110+
111+
112+
@pytest.mark.parametrize(
113+
("error_number", "error_name"),
114+
[
115+
(errno.EPERM, "EPERM"),
116+
(errno.EACCES, "EACCES"),
117+
],
118+
)
119+
def test_perf_event_open_reason_preserves_errno(
120+
monkeypatch: pytest.MonkeyPatch,
121+
error_number: int,
122+
error_name: str,
123+
) -> None:
124+
syscall = mock.Mock(return_value=-1)
125+
libc = SimpleNamespace(syscall=syscall)
126+
monkeypatch.setattr(metrics_module, "_perf_event_syscall_number", lambda: 298)
127+
monkeypatch.setattr(metrics_module, "fcntl", SimpleNamespace())
128+
monkeypatch.setattr(ctypes, "CDLL", lambda *_args, **_kwargs: libc)
129+
monkeypatch.setattr(ctypes, "get_errno", lambda: error_number)
130+
131+
counter, reason = metrics_module._InstructionCounter.open() # noqa: SLF001
132+
133+
assert counter is None
134+
assert reason is not None
135+
assert f"errno={error_number} {error_name}" in reason
136+
137+
37138
def test_runner_key_prefers_override_then_gitlab_identity(monkeypatch: pytest.MonkeyPatch) -> None:
38139
monkeypatch.setenv("CI_RUNNER_ID", "runner-123")
39140
monkeypatch.setenv("CI_RUNNER_DESCRIPTION", "shared-medium-host")
@@ -102,6 +203,12 @@ def test_plugin_writes_one_current_artifact(tmp_path: Path) -> None:
102203
assert record["outcome"] == "passed"
103204
assert record["metrics"]["cpu_time_ns"] > 0
104205
assert payload["environment"]["counter_backend"] in {"perf_event_open", "process_time"}
206+
unavailable_reason = payload["environment"]["cpu_instructions_unavailable_reason"]
207+
if payload["environment"]["counter_backend"] == "process_time":
208+
assert isinstance(unavailable_reason, str)
209+
assert unavailable_reason
210+
else:
211+
assert unavailable_reason is None
105212
assert payload["environment"]["cpu_time_scope"] == "pytest_call_process"
106213
assert payload["environment"]["memory_scope"] == "process_rss_boundaries"
107214
assert payload["environment"]["runner_key"] == "runner-123"
@@ -133,6 +240,32 @@ def test_xdist_workers_are_merged_into_one_artifact(tmp_path: Path) -> None:
133240
assert payload["job_metrics"]["cpu_time_ns"] == sum(test["metrics"]["cpu_time_ns"] for test in payload["tests"])
134241

135242

243+
def test_xdist_environment_merges_counter_backends_and_reasons() -> None:
244+
environment = plugin_module._merge_environments( # noqa: SLF001
245+
[
246+
{
247+
"counter_backend": "perf_event_open",
248+
"cpu_instructions_scope": "current_thread",
249+
"cpu_instructions_unavailable_reason": None,
250+
},
251+
{
252+
"counter_backend": "process_time",
253+
"cpu_instructions_scope": "unavailable",
254+
"cpu_instructions_unavailable_reason": "errno=1 EPERM",
255+
},
256+
{
257+
"counter_backend": "process_time",
258+
"cpu_instructions_scope": "unavailable",
259+
"cpu_instructions_unavailable_reason": "counter returned zero",
260+
},
261+
]
262+
)
263+
264+
assert environment["counter_backend"] == "mixed"
265+
assert environment["cpu_instructions_scope"] == "mixed"
266+
assert environment["cpu_instructions_unavailable_reason"] == "counter returned zero | errno=1 EPERM"
267+
268+
136269
def test_source_fingerprint_is_scoped_to_the_test_function(tmp_path: Path) -> None:
137270
first_artifact = tmp_path / "first.json"
138271
second_artifact = tmp_path / "second.json"

0 commit comments

Comments
 (0)