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/__init__.py +3 -0
- edgegate/backends.py +183 -0
- edgegate/cli.py +142 -0
- edgegate/compare.py +140 -0
- edgegate/config.py +95 -0
- edgegate/dataset.py +60 -0
- edgegate/matrix.py +367 -0
- edgegate/measure.py +267 -0
- edgegate/report.py +82 -0
- edgegate/sampler.py +140 -0
- edgegate-0.2.0.dist-info/METADATA +132 -0
- edgegate-0.2.0.dist-info/RECORD +16 -0
- edgegate-0.2.0.dist-info/WHEEL +5 -0
- edgegate-0.2.0.dist-info/entry_points.txt +2 -0
- edgegate-0.2.0.dist-info/licenses/LICENSE +202 -0
- edgegate-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
}
|