edgegate 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
edgegate/matrix.py ADDED
@@ -0,0 +1,367 @@
1
+ """Run a matrix of variants x repeats and tabulate crash rate and resources per cell.
2
+
3
+ Matrix file (YAML):
4
+ version: 1
5
+ base: edgegate.yaml # the config every cell starts from (path relative to this file)
6
+ repeats: 6
7
+ duration_s: 150 # optional: overrides benchmark.duration_s for every cell
8
+ stale_processes: [myengine] # optional: kill processes whose /proc comm starts with these before each run
9
+ cells:
10
+ - { name: baseline }
11
+ - { name: b2, args: { set: { "-b": "2" } } } # replace the value after a flag (or append flag+value)
12
+ - { name: noS, args: { remove: ["-S"] } } # drop a bare flag (use set to change a valued one)
13
+ - { name: trt, args: { add: ["-S"] }, duration_s: 240 }
14
+ - { name: fp32, env: { PRECISION: fp32 } }
15
+ - { name: pm25, power_mode: 3 } # nvpmodel id; must switch without a reboot
16
+ - { name: prev, path: ../builds/engine-v4.0.4 } # a different executable or model for this cell
17
+ Cells are interleaved (rep 1 of every cell, then rep 2, ...) so slow drift cannot favour one cell, and a run
18
+ whose results file already exists is skipped, so an interrupted matrix resumes where it stopped.
19
+ """
20
+ import copy
21
+ import datetime as dt
22
+ import json
23
+ import os
24
+ import pathlib
25
+ import re
26
+ import signal
27
+ import subprocess
28
+ import time
29
+
30
+ import yaml
31
+
32
+ from .compare import evaluate, status
33
+ from .config import ConfigError, load as load_config
34
+ from .measure import run as measure_run
35
+
36
+
37
+ class MatrixError(Exception):
38
+ pass
39
+
40
+
41
+ def load_matrix(path):
42
+ path = pathlib.Path(path)
43
+ try:
44
+ m = yaml.safe_load(path.read_text()) or {}
45
+ except FileNotFoundError:
46
+ raise ConfigError(f"matrix file not found: {path}") from None
47
+ except yaml.YAMLError as e:
48
+ raise ConfigError(f"matrix file is not valid YAML: {e}") from None
49
+ if m.get("version") != 1:
50
+ raise ConfigError("matrix: 'version: 1' is required")
51
+ if not m.get("base"):
52
+ raise ConfigError("matrix: base (a config file) is required")
53
+ cells = m.get("cells")
54
+ if not isinstance(cells, list) or not cells:
55
+ raise ConfigError("matrix: cells must be a non-empty list")
56
+ names = set()
57
+ for i, c in enumerate(cells):
58
+ if not isinstance(c, dict) or not re.fullmatch(r"[A-Za-z0-9_.-]+", str(c.get("name", ""))):
59
+ raise ConfigError(f"matrix: cells[{i}].name is required (letters, digits, _ . -)")
60
+ if c["name"] in names:
61
+ raise ConfigError(f"matrix: duplicate cell name {c['name']}")
62
+ names.add(c["name"])
63
+ a = c.get("args") or {}
64
+ if not isinstance(a, dict) or not isinstance(a.get("set", {}), dict) or not isinstance(a.get("add", []), list) \
65
+ or not isinstance(a.get("remove", []), list):
66
+ raise ConfigError(f"matrix: cells[{i}].args must have 'set' (mapping), 'add' (list) and/or 'remove' (list)")
67
+ if "power_mode" in c and not isinstance(c["power_mode"], int):
68
+ raise ConfigError(f"matrix: cells[{i}].power_mode must be an nvpmodel id (integer)")
69
+ if "path" in c:
70
+ if not isinstance(c["path"], str) or not c["path"]:
71
+ raise ConfigError(f"matrix: cells[{i}].path must be a path string")
72
+ c["path"] = os.path.normpath(os.path.join(pathlib.Path(path).resolve().parent, c["path"]))
73
+ if not pathlib.Path(c["path"]).exists():
74
+ raise ConfigError(f"matrix: cells[{i}].path does not exist: {c['path']}")
75
+ m["repeats"] = int(m.get("repeats", 1))
76
+ if m["repeats"] < 1:
77
+ raise ConfigError("matrix: repeats must be >= 1")
78
+ m["_root"] = path.resolve().parent
79
+ m["base"] = str((m["_root"] / m["base"]).resolve())
80
+ m.setdefault("stale_processes", [])
81
+ m.setdefault("max_crash_rate", 0.0) # a cell fails its JUnit case when crashed/n exceeds this
82
+ m.setdefault("require_gate_pass", True) # ...or when any run failed its own gate (e.g. ran clean but under spec)
83
+ return m
84
+
85
+
86
+ def apply_cell(base_cfg, cell, matrix):
87
+ """A fresh config for one cell: base config with the cell's argument, env, duration and note edits applied."""
88
+ cfg = copy.deepcopy(base_cfg)
89
+ model = cfg["model"]
90
+ a = cell.get("args") or {}
91
+ if a and model["backend"] != "command":
92
+ raise ConfigError(f"matrix: cell {cell['name']} edits args but the base backend is {model['backend']}")
93
+ args = list(model.get("args") or [])
94
+ for flag in (a.get("remove") or []): # drop a bare flag such as -S; change valued flags with set
95
+ args = [x for x in args if x != flag]
96
+ for flag, val in (a.get("set") or {}).items():
97
+ val = str(val)
98
+ if flag in args and args.index(flag) + 1 < len(args):
99
+ args[args.index(flag) + 1] = val
100
+ else:
101
+ args += [flag, val]
102
+ args += [str(x) for x in (a.get("add") or [])]
103
+ model["args"] = args
104
+ if cell.get("env"):
105
+ model["env"] = {**(model.get("env") or {}), **{k: str(v) for k, v in cell["env"].items()}}
106
+ dur = cell.get("duration_s", matrix.get("duration_s"))
107
+ if dur is not None:
108
+ cfg["benchmark"]["duration_s"] = dur
109
+ if cell.get("path"):
110
+ model["path"] = cell["path"] # a different build of the engine, or a different model file
111
+ return cfg
112
+
113
+
114
+ # ---- nvpmodel helpers (Jetson); all tolerate a host without nvpmodel
115
+
116
+ def power_modes(conf_text):
117
+ """{id: name} from /etc/nvpmodel.conf text."""
118
+ return {int(m.group(1)): m.group(2) for m in re.finditer(r"<\s*POWER_MODEL\s+ID=(\d+)\s+NAME=([A-Za-z0-9_]+)", conf_text)}
119
+
120
+
121
+ def current_power_mode():
122
+ try:
123
+ out = subprocess.run(["sudo", "-n", "nvpmodel", "-q"], capture_output=True, text=True, timeout=15).stdout
124
+ except (OSError, subprocess.SubprocessError):
125
+ return None
126
+ for line in out.splitlines():
127
+ if "NV Power Mode" in line:
128
+ return line.split(":", 1)[-1].strip()
129
+ return None
130
+
131
+
132
+ def set_power_mode(mode_id, modes, wait_s=20):
133
+ """Switch nvpmodel and wait until it reports the expected name. Refuses modes that need a reboot: the
134
+ matrix process would not survive one. Returns the mode name."""
135
+ name = modes.get(mode_id)
136
+ if name is None:
137
+ raise MatrixError(f"nvpmodel id {mode_id} is not in /etc/nvpmodel.conf (known: {modes})")
138
+ if current_power_mode() == name:
139
+ return name
140
+ p = subprocess.run(["sudo", "-n", "nvpmodel", "-m", str(mode_id)], input="no\n", capture_output=True, text=True, timeout=60)
141
+ if "Reboot required" in (p.stdout + p.stderr):
142
+ raise MatrixError(f"nvpmodel mode {mode_id} ({name}) requires a reboot on this board; run it as a separate matrix after rebooting into it")
143
+ t0 = time.monotonic()
144
+ while time.monotonic() - t0 < wait_s:
145
+ if current_power_mode() == name:
146
+ time.sleep(3)
147
+ return name
148
+ time.sleep(1)
149
+ raise MatrixError(f"nvpmodel did not report {name} within {wait_s} s (now: {current_power_mode()})")
150
+
151
+
152
+ def kill_stale(prefixes):
153
+ """SIGKILL every process whose /proc/<pid>/comm starts with one of the prefixes. Returns how many."""
154
+ n = 0
155
+ if not prefixes:
156
+ return 0
157
+ for pid in os.listdir("/proc"):
158
+ if not pid.isdigit() or int(pid) == os.getpid():
159
+ continue
160
+ try:
161
+ comm = pathlib.Path(f"/proc/{pid}/comm").read_text().strip()
162
+ except OSError:
163
+ continue
164
+ if any(comm.startswith(pfx) for pfx in prefixes):
165
+ try:
166
+ os.kill(int(pid), signal.SIGKILL); n += 1
167
+ except (ProcessLookupError, PermissionError):
168
+ pass
169
+ return n
170
+
171
+
172
+ # ---- the run
173
+
174
+ def _now():
175
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
176
+
177
+
178
+ def run_matrix(matrix, out_dir, log=print, dry_run=False):
179
+ base_cfg = load_config(matrix["base"])
180
+ out_dir = pathlib.Path(out_dir).resolve()
181
+ out_dir.mkdir(parents=True, exist_ok=True)
182
+ logf = open(out_dir / "LOG", "a")
183
+ def say(msg):
184
+ logf.write(f"{_now()} {msg}\n"); logf.flush(); log(msg)
185
+ needs_pm = any("power_mode" in c for c in matrix["cells"])
186
+ modes, start_mode = {}, None
187
+ if needs_pm:
188
+ try:
189
+ modes = power_modes(pathlib.Path("/etc/nvpmodel.conf").read_text())
190
+ except OSError:
191
+ raise MatrixError("a cell sets power_mode but /etc/nvpmodel.conf is not readable (not a Jetson?)") from None
192
+ start_mode = current_power_mode()
193
+ start_id = next((i for i, n in modes.items() if n == start_mode), None)
194
+ say(f"matrix start: {len(matrix['cells'])} cells x {matrix['repeats']} repeats -> {out_dir}")
195
+ done = skipped = 0
196
+ try:
197
+ for rep in range(1, matrix["repeats"] + 1):
198
+ for cell in matrix["cells"]:
199
+ cdir = out_dir / cell["name"]; cdir.mkdir(exist_ok=True)
200
+ rpath = cdir / f"r{rep}.json"
201
+ if rpath.exists():
202
+ skipped += 1; continue
203
+ cfg = apply_cell(base_cfg, cell, matrix)
204
+ if dry_run:
205
+ say(f"[dry] {cell['name']} r{rep}: {cfg['model']['path']} {' '.join(cfg['model'].get('args') or [])} duration={cfg['benchmark'].get('duration_s')}")
206
+ continue
207
+ try:
208
+ if "power_mode" in cell:
209
+ set_power_mode(cell["power_mode"], modes)
210
+ elif needs_pm and start_id is not None:
211
+ set_power_mode(start_id, modes)
212
+ except MatrixError as e:
213
+ say(f"== {cell['name']} r{rep}: SKIP {e}")
214
+ rpath.write_text(json.dumps({"skipped": str(e), "cell": cell["name"], "rep": rep, "created_at": _now()}, indent=1))
215
+ continue
216
+ k = kill_stale(matrix["stale_processes"])
217
+ if k:
218
+ time.sleep(3)
219
+ say(f"== {cell['name']} r{rep} mode={current_power_mode() or '-'} killed_stale={k} {_now()}")
220
+ results = measure_run(cfg, notes=f"matrix cell {cell['name']} rep {rep}" + (f": {cell['note']}" if cell.get("note") else ""),
221
+ out_dir=cdir / f"r{rep}.d")
222
+ checks = evaluate(results, cfg["thresholds"])
223
+ results["gate"] = {"status": status(checks), "failed": [c.name for c in checks if c.status == "fail"],
224
+ "checked": [c.name for c in checks if c.status != "skip"], "baseline": None}
225
+ results["matrix"] = {"cell": cell["name"], "rep": rep}
226
+ rpath.write_text(json.dumps(results, indent=1) + "\n")
227
+ done += 1
228
+ say(" " + summarize_run(results))
229
+ finally:
230
+ if needs_pm and start_id is not None and not dry_run:
231
+ try:
232
+ set_power_mode(start_id, modes)
233
+ except MatrixError as e:
234
+ say(f"could not restore power mode {start_mode}: {e}")
235
+ kill_stale(matrix["stale_processes"])
236
+ say(f"matrix done: {done} new runs, {skipped} already present")
237
+ logf.close()
238
+ return tabulate(matrix, out_dir)
239
+
240
+
241
+ def summarize_run(r):
242
+ if r.get("command"):
243
+ c = r["command"]
244
+ line = f"exit {c['exit_code']}{' (full window)' if c.get('timed_out') else ''} after {c['wall_s']} s"
245
+ for name, m in (r.get("metrics") or {}).items():
246
+ if m.get("count") is not None:
247
+ line += f" · {name} {m['count']}" + (f" ({m['per_s']}/s)" if m.get("per_s") is not None else "")
248
+ else:
249
+ lat = r["latency_ms"]
250
+ line = f"latency p50 {lat.get('p50')} ms, p99 {lat.get('p99')} ms"
251
+ if r.get("power"):
252
+ line += f" · power {r['power']['total_w']} W"
253
+ line += f" · gate {r.get('gate', {}).get('status', '-')}"
254
+ return line
255
+
256
+
257
+ def _crashed(r):
258
+ """A run is a crash when the exit-code check failed; without that rule, when a command run exited early
259
+ with a non-zero code; for latency runs, when the gate failed. None for skipped cells."""
260
+ if "skipped" in r:
261
+ return None
262
+ gate = r.get("gate") or {}
263
+ if r.get("command"):
264
+ if gate.get("failed") is not None and any(c == "exit_code" for c in gate["failed"]):
265
+ return True
266
+ if "exit_code" in gate.get("checked", []):
267
+ return False
268
+ return not r["command"].get("timed_out") and r["command"].get("exit_code") != 0
269
+ return gate.get("status") == "fail"
270
+
271
+
272
+ def tabulate(matrix, out_dir):
273
+ """Per-cell summary from whatever result files exist. Returns the summary dict and writes summary.json."""
274
+ out_dir = pathlib.Path(out_dir)
275
+ cells = []
276
+ for cell in matrix["cells"]:
277
+ rows = []
278
+ for f in sorted((out_dir / cell["name"]).glob("r*.json"), key=lambda p: int(p.stem[1:])):
279
+ r = json.loads(f.read_text())
280
+ if "skipped" in r:
281
+ rows.append({"rep": r["rep"], "skipped": r["skipped"]}); continue
282
+ cr = _crashed(r)
283
+ row = {"rep": r.get("matrix", {}).get("rep", int(f.stem[1:])), "crashed": cr,
284
+ "gate": r.get("gate", {}).get("status"), "failed": r.get("gate", {}).get("failed", []),
285
+ "power_w": (r.get("power") or {}).get("total_w"), "board_ram_peak_mb": r["memory"].get("board_ram_peak_mb"),
286
+ "peak_rss_mb": r["memory"].get("peak_rss_mb")}
287
+ if r.get("command"):
288
+ row.update({"exit_code": r["command"]["exit_code"], "wall_s": r["command"]["wall_s"],
289
+ "forensics": r["command"].get("forensics"),
290
+ "metrics": {k: v.get("count") for k, v in (r.get("metrics") or {}).items()},
291
+ "rates": {k: v.get("per_s") for k, v in (r.get("metrics") or {}).items() if v.get("per_s") is not None}})
292
+ else:
293
+ row.update({"latency_p99_ms": r["latency_ms"].get("p99"), "throughput_ips": r.get("throughput_ips")})
294
+ rows.append(row)
295
+ measured = [x for x in rows if "skipped" not in x]
296
+ crashed = [x for x in measured if x["crashed"]]
297
+ clean = [x for x in measured if not x["crashed"]]
298
+ gate_failed = [x for x in measured if x.get("gate") == "fail"]
299
+ # "alive but wrong": ran the full window yet failed a non-exit check (zero output, over power, ...)
300
+ under_spec = [x for x in clean if x.get("gate") == "fail"]
301
+ def mean(key, src):
302
+ vals = [x[key] for x in src if x.get(key) is not None]
303
+ return round(sum(vals) / len(vals), 2) if vals else None
304
+ rates = {}
305
+ for k in {k for x in clean for k in (x.get("rates") or {})}:
306
+ vals = [x["rates"][k] for x in clean if x.get("rates", {}).get(k) is not None]
307
+ rates[k] = round(sum(vals) / len(vals), 3) if vals else None
308
+ rate = len(crashed) / len(measured) if measured else None
309
+ failed_checks = sorted({c for x in under_spec for c in x.get("failed", []) if c != "exit_code"})
310
+ if not measured:
311
+ verdict = "skip"
312
+ elif rate > matrix.get("max_crash_rate", 0.0) or (matrix.get("require_gate_pass", True) and gate_failed):
313
+ verdict = "fail"
314
+ else:
315
+ verdict = "pass"
316
+ cells.append({"name": cell["name"], "note": cell.get("note"), "n": len(measured), "skipped": len(rows) - len(measured),
317
+ "crashed": len(crashed), "crash_rate": round(rate, 3) if rate is not None else None,
318
+ "gate_failed": len(gate_failed), "under_spec": len(under_spec), "under_spec_checks": failed_checks,
319
+ "time_to_crash_s": [x["wall_s"] for x in crashed if "wall_s" in x],
320
+ "clean_power_w": mean("power_w", clean), "clean_rates": rates,
321
+ "clean_latency_p99_ms": mean("latency_p99_ms", clean),
322
+ "board_ram_peak_mb": max((x["board_ram_peak_mb"] or 0 for x in measured), default=None),
323
+ "forensic_patterns": sorted({(x.get("forensics") or {}).get("ram_pattern") for x in crashed if x.get("forensics")} - {None}),
324
+ "status": verdict,
325
+ "runs": rows})
326
+ summary = {"edgegate_matrix": 1, "created_at": _now(), "base": matrix["base"], "repeats": matrix["repeats"],
327
+ "max_crash_rate": matrix.get("max_crash_rate", 0.0), "require_gate_pass": matrix.get("require_gate_pass", True), "cells": cells}
328
+ (out_dir / "summary.json").write_text(json.dumps(summary, indent=1) + "\n")
329
+ return summary
330
+
331
+
332
+ def markdown(summary):
333
+ L = [f"# edgegate matrix: {pathlib.Path(summary['base']).name}, {summary['repeats']} repeats per cell", "",
334
+ "| cell | crashed | alive but under spec | time to crash | clean rate | clean power W | board RAM peak MB | RAM before crash | verdict |",
335
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- |"]
336
+ for c in summary["cells"]:
337
+ tc = c["time_to_crash_s"]
338
+ tcs = "—" if not tc else (f"{min(tc):.0f} s" if round(min(tc)) == round(max(tc)) else f"{min(tc):.0f}–{max(tc):.0f} s")
339
+ rate = ", ".join(f"{k} {v}/s" for k, v in (c["clean_rates"] or {}).items()) or (f"p99 {c['clean_latency_p99_ms']} ms" if c.get("clean_latency_p99_ms") else "—")
340
+ us = f"{c['under_spec']} / {c['n']}" + (f" ({', '.join(c['under_spec_checks'])})" if c['under_spec_checks'] else "")
341
+ L.append(f"| {c['name']}{' (' + c['note'] + ')' if c.get('note') else ''} | {c['crashed']} / {c['n']} | {us} | {tcs} | {rate} | {c['clean_power_w'] or '—'} | {c['board_ram_peak_mb'] or '—'} | {', '.join(c['forensic_patterns']) or '—'} | {c['status'].upper()} |")
342
+ L += ["", f"A cell FAILS when its crash rate exceeds {summary['max_crash_rate']:.0%}"
343
+ + (" or when any run failed its own gate ('alive but under spec': it ran the full window but missed a check such as a metric minimum)." if summary.get("require_gate_pass", True) else ".")
344
+ + " 'RAM before crash' is the pattern in the last 30 s of each crashed run: plateau, step (one excursion) or cycling (repeated large swings, typically an allocation that keeps failing)."]
345
+ return "\n".join(L) + "\n"
346
+
347
+
348
+ def junit(summary):
349
+ import xml.etree.ElementTree as ET
350
+ cells = summary["cells"]
351
+ ts = ET.Element("testsuite", name="edgegate-matrix", tests=str(len(cells)),
352
+ failures=str(sum(c["status"] == "fail" for c in cells)), skipped=str(sum(c["status"] == "skip" for c in cells)),
353
+ errors="0", timestamp=summary["created_at"].rstrip("Z"))
354
+ for c in cells:
355
+ tc = ET.SubElement(ts, "testcase", classname="edgegate.matrix", name=c["name"])
356
+ msg = f"{c['crashed']} of {c['n']} runs crashed (limit {summary['max_crash_rate']:.0%})"
357
+ if c.get("under_spec"):
358
+ msg += f"; {c['under_spec']} ran the full window but failed {', '.join(c['under_spec_checks']) or 'a check'}"
359
+ if c["status"] == "fail":
360
+ ET.SubElement(tc, "failure", message=msg, type="crash_rate").text = msg
361
+ elif c["status"] == "skip":
362
+ ET.SubElement(tc, "skipped", message="no measured runs")
363
+ props = ET.SubElement(tc, "properties")
364
+ for k in ("crashed", "n", "crash_rate", "under_spec", "clean_power_w", "board_ram_peak_mb"):
365
+ if c.get(k) is not None:
366
+ ET.SubElement(props, "property", name=k, value=str(c[k]))
367
+ return ET.tostring(ts, encoding="unicode", xml_declaration=True)
edgegate/measure.py ADDED
@@ -0,0 +1,267 @@
1
+ """Run the benchmark and the accuracy pass; produce the results document."""
2
+ import datetime as dt
3
+ import os
4
+ import pathlib
5
+ import platform
6
+ import re
7
+ import signal
8
+ import subprocess
9
+ import threading
10
+ import time
11
+
12
+ import numpy as np
13
+
14
+ from . import __version__, dataset
15
+ from .backends import BackendError, make_backend, model_sha256
16
+ from .sampler import make_sampler, peak_rss_mb, write_timeline
17
+
18
+
19
+ def _now():
20
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
21
+
22
+
23
+ def latency_stats(samples):
24
+ a = np.asarray(samples, dtype=np.float64)
25
+ return {"p50": round(float(np.percentile(a, 50)), 3), "p90": round(float(np.percentile(a, 90)), 3),
26
+ "p99": round(float(np.percentile(a, 99)), 3), "mean": round(float(a.mean()), 3),
27
+ "min": round(float(a.min()), 3), "max": round(float(a.max()), 3), "n": int(a.size)}
28
+
29
+
30
+ def _device_info(cfg):
31
+ info = {"name": cfg["device"].get("name") or platform.node(), "hostname": platform.node(),
32
+ "machine": platform.machine(), "python": platform.python_version()}
33
+ for p, key in (("/etc/nv_tegra_release", "l4t"), ("/proc/device-tree/model", "model")):
34
+ try:
35
+ info[key] = pathlib.Path(p).read_text(errors="replace").strip().split("\n")[0].rstrip("\x00")
36
+ except OSError:
37
+ pass
38
+ try:
39
+ out = subprocess.run(["nvpmodel", "-q"], capture_output=True, text=True, timeout=10).stdout
40
+ for line in out.splitlines():
41
+ if "NV Power Mode" in line:
42
+ info["power_mode"] = line.split(":", 1)[-1].strip()
43
+ except (OSError, subprocess.SubprocessError):
44
+ pass
45
+ return info
46
+
47
+
48
+ def accuracy_top1(backend, ds):
49
+ correct = total = 0
50
+ for x, label in dataset.load(ds):
51
+ out = backend.infer(x)
52
+ pred = int(np.argmax(np.asarray(out).reshape(out.shape[0], -1), axis=1)[0])
53
+ correct += int(pred == label)
54
+ total += 1
55
+ if total == 0:
56
+ raise BackendError("dataset yielded no samples")
57
+ return {"metric": "top1", "value": round(correct / total, 4), "correct": correct, "n": total}
58
+
59
+
60
+ class ProcessTreeRss:
61
+ """Polls /proc for every process in the session started for the command and records the peak summed RSS."""
62
+
63
+ def __init__(self, sid, interval_s=0.5):
64
+ self.sid, self.interval_s, self.peak_mb = sid, interval_s, 0.0
65
+ self.series = [] # (t_s, rss_mb) for the timeline
66
+ self._t0 = time.monotonic()
67
+ self._stop = threading.Event()
68
+ self._thread = threading.Thread(target=self._loop, daemon=True)
69
+
70
+ def _sample(self):
71
+ total_kb = 0
72
+ for pid in os.listdir("/proc"):
73
+ if not pid.isdigit():
74
+ continue
75
+ try:
76
+ stat = pathlib.Path(f"/proc/{pid}/stat").read_text()
77
+ if int(stat.rsplit(")", 1)[1].split()[3]) != self.sid: # field 6 = session id
78
+ continue
79
+ for line in pathlib.Path(f"/proc/{pid}/status").read_text().splitlines():
80
+ if line.startswith("VmRSS:"):
81
+ total_kb += int(line.split()[1]); break
82
+ except (OSError, ValueError, IndexError):
83
+ continue
84
+ return total_kb / 1024.0
85
+
86
+ def _loop(self):
87
+ while not self._stop.is_set():
88
+ mb = self._sample()
89
+ self.series.append((round(time.monotonic() - self._t0, 3), round(mb, 1)))
90
+ self.peak_mb = max(self.peak_mb, mb)
91
+ self._stop.wait(self.interval_s)
92
+
93
+ def __enter__(self):
94
+ self._thread.start(); return self
95
+
96
+ def __exit__(self, *exc):
97
+ self._stop.set(); self._thread.join(timeout=3)
98
+ self.peak_mb = max(self.peak_mb, self._sample())
99
+
100
+
101
+ def _count_metrics(metrics, out_dir, wall_s):
102
+ found = {}
103
+ for m in metrics:
104
+ path = pathlib.Path(m["file"].replace("{out}", str(out_dir)))
105
+ try:
106
+ text = path.read_text(errors="replace")
107
+ except OSError:
108
+ found[m["name"]] = {"count": None, "per_s": None, "file": str(path), "note": "file not found"}
109
+ continue
110
+ n = len(re.findall(m["pattern"], text, re.MULTILINE)) # ^ and $ anchor per line, as in grep
111
+ found[m["name"]] = {"count": n, "per_s": round(n / wall_s, 3) if wall_s else None, "file": str(path)}
112
+ return found
113
+
114
+
115
+ def forensics(samples, rss_series, end_t_s, window_s=30.0):
116
+ """What the board looked like in the last `window_s` seconds before the process died (or before the window
117
+ closed). `samples` are tegrastats samples with t_s; `rss_series` is [(t_s, rss_mb)] for the process tree.
118
+ death_t_s = last moment the process tree still held real memory (above 5% of its peak); None if it never fell."""
119
+ death = None
120
+ if rss_series:
121
+ peak = max(v for _, v in rss_series)
122
+ # Alive means the tree still holds real memory. A warm-up spike that settles to a lower plateau is not
123
+ # a death (an engine that peaks at 12 GB loading models and then runs at 4.6 GB is healthy), so the
124
+ # floor is small and absolute: below 5% of peak or 64 MB, whichever is larger.
125
+ floor = max(peak * 0.05, 64.0)
126
+ alive = [t for t, v in rss_series if v > floor]
127
+ if alive and alive[-1] < rss_series[-1][0] - 1.0: # it fell more than a second before sampling stopped
128
+ death = alive[-1]
129
+ t_end = death if death is not None else end_t_s
130
+ win = [x for x in samples if x.get("t_s") is not None and t_end - window_s <= x["t_s"] <= t_end]
131
+ out = {"window_s": window_s, "death_t_s": round(death, 1) if death is not None else None, "samples": len(win)}
132
+ rams = [x["ram_mb"] for x in win if x.get("ram_mb") is not None]
133
+ if rams:
134
+ out["ram_mb"] = {"min": min(rams), "max": max(rams), "mean": round(sum(rams) / len(rams))}
135
+ # swings: how many times RAM moved by more than a quarter of its range inside the window.
136
+ # 0 or 1 = a plateau or a single step; many = allocation cycling.
137
+ span = max(rams) - min(rams)
138
+ swings, last = 0, rams[0]
139
+ for v in rams[1:]:
140
+ if span and abs(v - last) > span * 0.25:
141
+ swings += 1; last = v
142
+ out["ram_swings"] = swings
143
+ # plateau: steady (a healthy engine after warm-up). step: one excursion, up or up-and-back (a load
144
+ # spike, or a crash right after one). cycling: repeated large swings (an allocation that keeps failing).
145
+ out["ram_pattern"] = "plateau" if span < 512 or swings <= 1 else ("step" if swings == 2 else "cycling")
146
+ rails = sorted({n for x in win for n in x["rails"]})
147
+ if rails:
148
+ out["rails_w"] = {r: round(sum(x["rails"].get(r, 0) for x in win) / len(win) / 1000.0, 2) for r in rails}
149
+ rss_win = [v for t, v in rss_series if t_end - window_s <= t <= t_end]
150
+ if rss_win:
151
+ out["process_rss_mb"] = {"min": round(min(rss_win)), "max": round(max(rss_win))}
152
+ return out
153
+
154
+
155
+ def run_command(cfg, backend, out_dir, notes=None):
156
+ """The command backend: run the process for benchmark.duration_s (or until it exits), sampling meanwhile."""
157
+ bench = cfg["benchmark"]
158
+ duration = bench.get("duration_s") or 0
159
+ out_dir = pathlib.Path(out_dir).resolve() # absolute: the command runs with its own cwd (the config dir)
160
+ out_dir.mkdir(parents=True, exist_ok=True)
161
+ sub = lambda x: x.replace("{out}", str(out_dir))
162
+ cmd = [backend.path, *[sub(a) for a in backend.args]]
163
+ env = {**os.environ, **{k: sub(str(v)) for k, v in backend.env.items()}}
164
+ stdout_path = out_dir / "stdout.log"
165
+ timed_out = False
166
+ started_at = _now()
167
+ t0 = time.monotonic()
168
+ with open(stdout_path, "wb") as log, make_sampler(cfg) as sampler:
169
+ proc = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT, cwd=sub(backend.cwd) if backend.cwd else None,
170
+ env=env, start_new_session=True)
171
+ with ProcessTreeRss(proc.pid) as rss:
172
+ try:
173
+ proc.wait(timeout=duration if duration > 0 else None)
174
+ except subprocess.TimeoutExpired:
175
+ timed_out = True
176
+ try:
177
+ os.killpg(proc.pid, signal.SIGTERM)
178
+ proc.wait(timeout=10)
179
+ except (ProcessLookupError, subprocess.TimeoutExpired):
180
+ try:
181
+ os.killpg(proc.pid, signal.SIGKILL)
182
+ except ProcessLookupError:
183
+ pass
184
+ proc.wait(timeout=10)
185
+ wall_s = round(time.monotonic() - t0, 2)
186
+ exit_code = 124 if timed_out else proc.returncode # 124 mirrors coreutils timeout: ran the full window
187
+ power = sampler.summary()
188
+ memory = {"peak_rss_mb": round(rss.peak_mb, 1)}
189
+ if power and power.get("ram_peak_mb") is not None:
190
+ memory["board_ram_peak_mb"] = power.pop("ram_peak_mb")
191
+ elif power:
192
+ power.pop("ram_peak_mb", None)
193
+ metrics = _count_metrics(cfg.get("metrics", []), out_dir, wall_s)
194
+ timeline = None
195
+ if write_timeline(out_dir / "timeline.csv", sampler.timeline(), {"process_rss_mb": rss.series}):
196
+ timeline = str(out_dir / "timeline.csv")
197
+ last = forensics(sampler.timeline(), rss.series, wall_s)
198
+ none = {"p50": None, "p90": None, "p99": None, "mean": None, "min": None, "max": None, "n": 0,
199
+ "source": "n/a: command backend has no per-inference latency; see metrics[*].per_s"}
200
+ return {
201
+ "edgegate_version": __version__,
202
+ "created_at": _now(),
203
+ "device": _device_info(cfg),
204
+ "model": {"path": os.path.relpath(cfg["model"]["path"], cfg["_root"]), "sha256": model_sha256(cfg["model"]["path"]),
205
+ "input_shape": None, **backend.describe()},
206
+ "benchmark": {"warmup": 0, "iterations": 0, "duration_s": duration},
207
+ "command": {"exit_code": exit_code, "timed_out": timed_out, "wall_s": wall_s, "stdout": str(stdout_path),
208
+ "argv": cmd, "timeline": timeline, "started_at": started_at, "forensics": last},
209
+ "metrics": metrics,
210
+ "latency_ms": none,
211
+ "throughput_ips": None,
212
+ "memory": memory,
213
+ "power": power,
214
+ "accuracy": None,
215
+ "notes": [n for n in (notes,) if n],
216
+ }
217
+
218
+
219
+ def run(cfg, notes=None, out_dir=None):
220
+ backend = make_backend(cfg)
221
+ if backend.name == "command":
222
+ return run_command(cfg, backend, out_dir or (pathlib.Path(cfg["_root"]) / "edgegate-out"), notes)
223
+ bench = cfg["benchmark"]
224
+ with make_sampler(cfg) as sampler:
225
+ raw = backend.bench(bench["warmup"], bench["iterations"], bench.get("duration_s", 0))
226
+ power = sampler.summary()
227
+ if power and out_dir:
228
+ pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True)
229
+ if write_timeline(pathlib.Path(out_dir) / "timeline.csv", sampler.timeline()):
230
+ power["timeline"] = str(pathlib.Path(out_dir) / "timeline.csv")
231
+
232
+ if isinstance(raw, dict): # trtexec summary
233
+ latency = {"p50": raw["median"], "p90": None, "p99": raw.get("p99"), "mean": raw["mean"],
234
+ "min": raw["min"], "max": raw["max"], "n": bench["iterations"], "source": "trtexec summary"}
235
+ throughput = raw.get("throughput_qps")
236
+ else:
237
+ latency = latency_stats(raw)
238
+ throughput = round(1000.0 / latency["mean"], 2) if latency["mean"] else None
239
+
240
+ acc = None
241
+ acc_note = None
242
+ if cfg.get("dataset"):
243
+ if backend.can_infer:
244
+ acc = accuracy_top1(backend, cfg["dataset"])
245
+ else:
246
+ acc_note = f"accuracy skipped: backend '{backend.name}' cannot run arbitrary inputs; install onnxruntime on the device to measure it"
247
+
248
+ memory = {"peak_rss_mb": peak_rss_mb()}
249
+ if power and power.get("ram_peak_mb") is not None:
250
+ memory["board_ram_peak_mb"] = power.pop("ram_peak_mb")
251
+ elif power:
252
+ power.pop("ram_peak_mb", None)
253
+
254
+ return {
255
+ "edgegate_version": __version__,
256
+ "created_at": _now(),
257
+ "device": _device_info(cfg),
258
+ "model": {"path": os.path.relpath(cfg["model"]["path"], cfg["_root"]), "sha256": model_sha256(cfg["model"]["path"]),
259
+ "input_shape": cfg["model"]["input"]["shape"], **backend.describe()},
260
+ "benchmark": {"warmup": bench["warmup"], "iterations": bench["iterations"], "duration_s": bench.get("duration_s", 0)},
261
+ "latency_ms": latency,
262
+ "throughput_ips": throughput,
263
+ "memory": memory,
264
+ "power": power,
265
+ "accuracy": acc,
266
+ "notes": [n for n in (notes, acc_note) if n],
267
+ }