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/__init__.py
ADDED
edgegate/backends.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Inference backends. Each exposes bench() -> latency samples and, if it can run arbitrary inputs, infer()."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import pathlib
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def model_sha256(path):
|
|
17
|
+
h = hashlib.sha256()
|
|
18
|
+
with open(path, "rb") as f:
|
|
19
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
20
|
+
h.update(chunk)
|
|
21
|
+
return "sha256:" + h.hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _random_input(shape, dtype):
|
|
25
|
+
return np.random.default_rng(0).standard_normal(shape).astype(dtype)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------------------ ONNX Runtime (host, or Jetson with the NVIDIA wheel)
|
|
29
|
+
|
|
30
|
+
class OnnxRuntimeBackend:
|
|
31
|
+
name = "onnxruntime"
|
|
32
|
+
can_infer = True
|
|
33
|
+
|
|
34
|
+
def __init__(self, model_path, input_spec, precision="fp32"):
|
|
35
|
+
try:
|
|
36
|
+
import onnxruntime as ort
|
|
37
|
+
except ImportError:
|
|
38
|
+
raise BackendError("onnxruntime is not installed (pip install edgegate[host])") from None
|
|
39
|
+
self.ort = ort
|
|
40
|
+
providers = ort.get_available_providers()
|
|
41
|
+
preferred = [p for p in ("TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider") if p in providers]
|
|
42
|
+
self.session = ort.InferenceSession(str(model_path), providers=preferred)
|
|
43
|
+
self.provider = self.session.get_providers()[0]
|
|
44
|
+
meta = self.session.get_inputs()[0]
|
|
45
|
+
self.input_name = input_spec["name"] or meta.name
|
|
46
|
+
self.shape = tuple(input_spec["shape"])
|
|
47
|
+
self.dtype = np.dtype(input_spec["dtype"])
|
|
48
|
+
self.output_name = self.session.get_outputs()[0].name
|
|
49
|
+
self.precision = "fp32" # ORT runs the graph as exported
|
|
50
|
+
|
|
51
|
+
def describe(self):
|
|
52
|
+
return {"backend": self.name, "provider": self.provider, "version": self.ort.__version__, "precision": self.precision}
|
|
53
|
+
|
|
54
|
+
def infer(self, x):
|
|
55
|
+
return self.session.run([self.output_name], {self.input_name: x})[0]
|
|
56
|
+
|
|
57
|
+
def bench(self, warmup, iterations, duration_s=0):
|
|
58
|
+
x = _random_input(self.shape, self.dtype)
|
|
59
|
+
for _ in range(warmup):
|
|
60
|
+
self.infer(x)
|
|
61
|
+
samples = []
|
|
62
|
+
for _ in range(iterations):
|
|
63
|
+
t0 = time.perf_counter_ns()
|
|
64
|
+
self.infer(x)
|
|
65
|
+
samples.append((time.perf_counter_ns() - t0) / 1e6)
|
|
66
|
+
return samples
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------ TensorRT via trtexec (Jetson)
|
|
70
|
+
|
|
71
|
+
TRTEXEC_LATENCY_RE = re.compile(
|
|
72
|
+
r"Latency:\s*min\s*=\s*([\d.]+)\s*ms,\s*max\s*=\s*([\d.]+)\s*ms,\s*mean\s*=\s*([\d.]+)\s*ms,"
|
|
73
|
+
r"\s*median\s*=\s*([\d.]+)\s*ms,\s*percentile\((\d+)%\)\s*=\s*([\d.]+)\s*ms")
|
|
74
|
+
TRTEXEC_THROUGHPUT_RE = re.compile(r"Throughput:\s*([\d.]+)\s*qps")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_trtexec_output(text):
|
|
78
|
+
"""Pull the latency summary out of trtexec's stdout. Returns dict or raises BackendError."""
|
|
79
|
+
m = TRTEXEC_LATENCY_RE.search(text)
|
|
80
|
+
if not m:
|
|
81
|
+
raise BackendError("could not find a 'Latency:' summary line in trtexec output")
|
|
82
|
+
pct = int(m.group(5))
|
|
83
|
+
out = {"min": float(m.group(1)), "max": float(m.group(2)), "mean": float(m.group(3)),
|
|
84
|
+
"median": float(m.group(4)), f"p{pct}": float(m.group(6))}
|
|
85
|
+
t = TRTEXEC_THROUGHPUT_RE.search(text)
|
|
86
|
+
if t:
|
|
87
|
+
out["throughput_qps"] = float(t.group(1))
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TrtexecBackend:
|
|
92
|
+
"""Builds (and caches) a TensorRT engine with trtexec, then benchmarks it. Accuracy needs onnxruntime."""
|
|
93
|
+
name = "trtexec"
|
|
94
|
+
can_infer = False
|
|
95
|
+
|
|
96
|
+
def __init__(self, model_path, input_spec, precision="fp16", cache_dir=None):
|
|
97
|
+
self.trtexec = shutil.which("trtexec") or ("/usr/src/tensorrt/bin/trtexec" if pathlib.Path("/usr/src/tensorrt/bin/trtexec").exists() else None)
|
|
98
|
+
if not self.trtexec:
|
|
99
|
+
raise BackendError("trtexec not found on PATH or at /usr/src/tensorrt/bin/trtexec")
|
|
100
|
+
self.model_path = pathlib.Path(model_path)
|
|
101
|
+
self.shape = tuple(input_spec["shape"])
|
|
102
|
+
self.input_name = input_spec["name"]
|
|
103
|
+
self.precision = precision
|
|
104
|
+
self.cache_dir = pathlib.Path(cache_dir or self.model_path.parent / ".edgegate-engines")
|
|
105
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
tag = model_sha256(self.model_path)[7:19]
|
|
107
|
+
self.engine = self.cache_dir / f"{self.model_path.stem}-{tag}-{precision}-{'x'.join(map(str, self.shape))}.engine"
|
|
108
|
+
self.version = self._version()
|
|
109
|
+
|
|
110
|
+
def _version(self):
|
|
111
|
+
try:
|
|
112
|
+
out = subprocess.run([self.trtexec, "--help"], capture_output=True, text=True, timeout=30).stdout
|
|
113
|
+
except (OSError, subprocess.SubprocessError):
|
|
114
|
+
return None
|
|
115
|
+
m = re.search(r"TensorRT\s+v?([\d.]+)", out)
|
|
116
|
+
return m.group(1) if m else None
|
|
117
|
+
|
|
118
|
+
def describe(self):
|
|
119
|
+
return {"backend": self.name, "trtexec": self.trtexec, "version": self.version,
|
|
120
|
+
"precision": self.precision, "engine": str(self.engine)}
|
|
121
|
+
|
|
122
|
+
def _run(self, args, timeout):
|
|
123
|
+
try:
|
|
124
|
+
p = subprocess.run([self.trtexec, *args], capture_output=True, text=True, timeout=timeout)
|
|
125
|
+
except subprocess.TimeoutExpired:
|
|
126
|
+
raise BackendError(f"trtexec timed out after {timeout}s") from None
|
|
127
|
+
if p.returncode != 0:
|
|
128
|
+
tail = "\n".join((p.stdout + p.stderr).splitlines()[-15:])
|
|
129
|
+
raise BackendError(f"trtexec failed (exit {p.returncode}):\n{tail}")
|
|
130
|
+
return p.stdout
|
|
131
|
+
|
|
132
|
+
def build(self):
|
|
133
|
+
if self.engine.exists():
|
|
134
|
+
return False
|
|
135
|
+
args = [f"--onnx={self.model_path}", f"--saveEngine={self.engine}"]
|
|
136
|
+
if self.precision == "fp16":
|
|
137
|
+
args.append("--fp16")
|
|
138
|
+
elif self.precision == "int8":
|
|
139
|
+
args.append("--int8")
|
|
140
|
+
if self.input_name:
|
|
141
|
+
dims = "x".join(map(str, self.shape))
|
|
142
|
+
args += [f"--shapes={self.input_name}:{dims}"]
|
|
143
|
+
self._run(args, timeout=1800)
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
def bench(self, warmup, iterations, duration_s=0):
|
|
147
|
+
"""trtexec runs for at least `iterations` AND at least `duration_s` seconds (its own default is 3 s).
|
|
148
|
+
Power sampling needs a window long enough for DVFS to settle, so the Jetson example sets 10 s."""
|
|
149
|
+
self.build()
|
|
150
|
+
out = self._run([f"--loadEngine={self.engine}", f"--warmUp={max(warmup * 10, 200)}",
|
|
151
|
+
f"--iterations={iterations}", f"--duration={duration_s}", "--percentile=99"],
|
|
152
|
+
timeout=max(600, duration_s * 3 + 120))
|
|
153
|
+
self.last = parse_trtexec_output(out)
|
|
154
|
+
# trtexec reports a summary, not per-iteration samples; synthesize the fields the gate needs.
|
|
155
|
+
return self.last
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class CommandBackend:
|
|
159
|
+
"""Any process as the device under test: an inference engine replaying a recording, a service under a
|
|
160
|
+
fixed load, a whole pipeline. edgegate runs it for benchmark.duration_s, samples power and RAM meanwhile,
|
|
161
|
+
tracks the peak RSS of its process tree, then counts the metrics you name in its output files.
|
|
162
|
+
There is no per-inference latency; throughput comes from the counted events over the wall time."""
|
|
163
|
+
name = "command"
|
|
164
|
+
can_infer = False
|
|
165
|
+
|
|
166
|
+
def __init__(self, path, args, env, cwd=None):
|
|
167
|
+
self.path, self.args, self.env, self.cwd = path, list(args or []), dict(env or {}), cwd
|
|
168
|
+
self.last = None
|
|
169
|
+
|
|
170
|
+
def describe(self):
|
|
171
|
+
return {"backend": self.name, "precision": None, "version": None, "args": self.args}
|
|
172
|
+
|
|
173
|
+
def bench(self, warmup, iterations, duration_s=0):
|
|
174
|
+
raise BackendError("the command backend is driven by measure.run_command, not bench()")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def make_backend(cfg):
|
|
178
|
+
m = cfg["model"]
|
|
179
|
+
if m["backend"] == "onnxruntime":
|
|
180
|
+
return OnnxRuntimeBackend(m["path"], m["input"], m.get("precision", "fp32"))
|
|
181
|
+
if m["backend"] == "command":
|
|
182
|
+
return CommandBackend(m["path"], m.get("args"), m.get("env"), m.get("cwd"))
|
|
183
|
+
return TrtexecBackend(m["path"], m["input"], m.get("precision", "fp16"))
|
edgegate/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__, config
|
|
8
|
+
from .backends import BackendError
|
|
9
|
+
from .compare import evaluate, status
|
|
10
|
+
from .dataset import DatasetError
|
|
11
|
+
from .measure import run as measure_run
|
|
12
|
+
from .report import junit, markdown
|
|
13
|
+
from . import matrix as matrix_mod
|
|
14
|
+
|
|
15
|
+
EXIT_OK, EXIT_ERROR, EXIT_FAIL = 0, 1, 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _err(msg):
|
|
19
|
+
print(msg, file=sys.stderr)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write(path, text):
|
|
23
|
+
p = pathlib.Path(path)
|
|
24
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
p.write_text(text)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_json(path, what):
|
|
29
|
+
try:
|
|
30
|
+
return json.loads(pathlib.Path(path).read_text())
|
|
31
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
32
|
+
raise config.ConfigError(f"{what} {path}: {e}") from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _emit(a, results, checks, baseline):
|
|
36
|
+
for c in checks:
|
|
37
|
+
print(f"{c.status.upper():5} {c.name}: {c.message}")
|
|
38
|
+
print(f"{status(checks).upper()}: {sum(c.status == 'fail' for c in checks)} failing check(s), "
|
|
39
|
+
f"{sum(c.status == 'skip' for c in checks)} skipped")
|
|
40
|
+
if a.junit:
|
|
41
|
+
_write(a.junit, junit(results, checks)); print(f"wrote {a.junit}")
|
|
42
|
+
if a.markdown:
|
|
43
|
+
_write(a.markdown, markdown(results, checks, baseline)); print(f"wrote {a.markdown}")
|
|
44
|
+
return EXIT_FAIL if status(checks) == "fail" else EXIT_OK
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_run(a):
|
|
48
|
+
cfg = config.load(a.config)
|
|
49
|
+
out_dir = pathlib.Path(a.out).with_suffix(".d")
|
|
50
|
+
results = measure_run(cfg, notes=a.note, out_dir=out_dir)
|
|
51
|
+
_write(a.out, json.dumps(results, indent=1) + "\n")
|
|
52
|
+
print(f"wrote {a.out}")
|
|
53
|
+
lat = results["latency_ms"]
|
|
54
|
+
if results.get("command"):
|
|
55
|
+
c = results["command"]
|
|
56
|
+
line = f"exit {c['exit_code']}{' (full window)' if c['timed_out'] else ''} after {c['wall_s']} s"
|
|
57
|
+
for name, m in results.get("metrics", {}).items():
|
|
58
|
+
line += f" · {name} {m['count']}" + (f" ({m['per_s']}/s)" if m.get("per_s") is not None else "")
|
|
59
|
+
else:
|
|
60
|
+
line = f"latency p50 {lat['p50']} ms, p99 {lat['p99']} ms, mean {lat['mean']} ms"
|
|
61
|
+
if results.get("power"):
|
|
62
|
+
line += f" · power {results['power']['total_w']} W"
|
|
63
|
+
if results.get("accuracy"):
|
|
64
|
+
line += f" · {results['accuracy']['metric']} {results['accuracy']['value']}"
|
|
65
|
+
print(line)
|
|
66
|
+
for n in results.get("notes", []):
|
|
67
|
+
print(f"note: {n}")
|
|
68
|
+
if a.check:
|
|
69
|
+
baseline = _load_json(a.baseline, "baseline") if a.baseline else None
|
|
70
|
+
checks = evaluate(results, cfg["thresholds"], baseline)
|
|
71
|
+
# record the verdict in the results file so a later run can tell whether this one is fit to be a baseline
|
|
72
|
+
results["gate"] = {"status": status(checks), "failed": [c.name for c in checks if c.status == "fail"],
|
|
73
|
+
"checked": [c.name for c in checks if c.status != "skip"], "baseline": a.baseline}
|
|
74
|
+
_write(a.out, json.dumps(results, indent=1) + "\n")
|
|
75
|
+
return _emit(a, results, checks, baseline)
|
|
76
|
+
return EXIT_OK
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_check(a):
|
|
80
|
+
cfg = config.load(a.config)
|
|
81
|
+
results = _load_json(a.results, "results")
|
|
82
|
+
baseline = _load_json(a.baseline, "baseline") if a.baseline else None
|
|
83
|
+
return _emit(a, results, evaluate(results, cfg["thresholds"], baseline), baseline)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_matrix(a):
|
|
87
|
+
m = matrix_mod.load_matrix(a.config)
|
|
88
|
+
summary = matrix_mod.run_matrix(m, a.out, dry_run=a.dry_run)
|
|
89
|
+
if a.dry_run:
|
|
90
|
+
return EXIT_OK
|
|
91
|
+
md = matrix_mod.markdown(summary)
|
|
92
|
+
_write(pathlib.Path(a.out) / "summary.md", md)
|
|
93
|
+
print(md)
|
|
94
|
+
if a.junit:
|
|
95
|
+
_write(a.junit, matrix_mod.junit(summary)); print(f"wrote {a.junit}")
|
|
96
|
+
if a.markdown:
|
|
97
|
+
_write(a.markdown, md); print(f"wrote {a.markdown}")
|
|
98
|
+
print(f"wrote {pathlib.Path(a.out) / 'summary.json'}")
|
|
99
|
+
return EXIT_FAIL if any(c["status"] == "fail" for c in summary["cells"]) else EXIT_OK
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv=None):
|
|
103
|
+
ap = argparse.ArgumentParser(prog="edgegate", description="CI gate for model-plus-firmware builds on edge devices.")
|
|
104
|
+
ap.add_argument("--version", action="version", version=f"edgegate {__version__}")
|
|
105
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
106
|
+
|
|
107
|
+
p = sub.add_parser("run", help="measure latency, memory, power and accuracy on this device")
|
|
108
|
+
p.add_argument("-c", "--config", default="edgegate.yaml")
|
|
109
|
+
p.add_argument("-o", "--out", default="results/latest.json")
|
|
110
|
+
p.add_argument("--note", help="free-text note stored with the results (e.g. firmware build id)")
|
|
111
|
+
p.add_argument("--check", action="store_true", help="also evaluate thresholds (and --baseline if given); exit 2 on failure")
|
|
112
|
+
p.add_argument("--baseline", help="results JSON to compare against")
|
|
113
|
+
p.add_argument("--junit", help="write JUnit XML for the checks")
|
|
114
|
+
p.add_argument("--markdown", help="write a Markdown summary")
|
|
115
|
+
p.set_defaults(func=cmd_run)
|
|
116
|
+
|
|
117
|
+
p = sub.add_parser("check", help="evaluate an existing results file against thresholds and a baseline")
|
|
118
|
+
p.add_argument("-c", "--config", default="edgegate.yaml")
|
|
119
|
+
p.add_argument("--results", required=True)
|
|
120
|
+
p.add_argument("--baseline")
|
|
121
|
+
p.add_argument("--junit")
|
|
122
|
+
p.add_argument("--markdown")
|
|
123
|
+
p.set_defaults(func=cmd_check)
|
|
124
|
+
|
|
125
|
+
p = sub.add_parser("matrix", help="run variants x repeats (interleaved, resumable) and tabulate crash rate per cell")
|
|
126
|
+
p.add_argument("-c", "--config", default="matrix.yaml", help="matrix file (see edgegate.matrix docstring)")
|
|
127
|
+
p.add_argument("-o", "--out", default="results/matrix", help="results directory; existing r<N>.json files are skipped")
|
|
128
|
+
p.add_argument("--junit", help="one JUnit testcase per cell")
|
|
129
|
+
p.add_argument("--markdown", help="copy of the summary table")
|
|
130
|
+
p.add_argument("--dry-run", action="store_true", help="print the command line of every pending run and exit")
|
|
131
|
+
p.set_defaults(func=cmd_matrix)
|
|
132
|
+
|
|
133
|
+
a = ap.parse_args(argv)
|
|
134
|
+
try:
|
|
135
|
+
return a.func(a)
|
|
136
|
+
except (config.ConfigError, BackendError, DatasetError, RuntimeError, matrix_mod.MatrixError) as e:
|
|
137
|
+
_err(f"error: {e}")
|
|
138
|
+
return EXIT_ERROR
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
sys.exit(main())
|
edgegate/compare.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Turn a results document, optional baseline, and thresholds into pass/fail checks."""
|
|
2
|
+
from dataclasses import dataclass, asdict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Check:
|
|
7
|
+
name: str
|
|
8
|
+
status: str # pass | fail | skip
|
|
9
|
+
value: object = None
|
|
10
|
+
limit: object = None
|
|
11
|
+
baseline: object = None
|
|
12
|
+
message: str = ""
|
|
13
|
+
|
|
14
|
+
def as_dict(self):
|
|
15
|
+
return asdict(self)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# A percentage regression on a tiny absolute value is noise (a 15 µs model "regresses" 150% from
|
|
19
|
+
# scheduler jitter). Regressions must also exceed an absolute floor to count. Overridable per metric.
|
|
20
|
+
FLOORS = {"latency_p99_regression_pct": ("latency_regression_floor_ms", 0.5),
|
|
21
|
+
"memory_regression_pct": ("memory_regression_floor_mb", 16.0),
|
|
22
|
+
"power_regression_pct": ("power_regression_floor_w", 0.25)}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _pct_change(cur, base):
|
|
26
|
+
if base in (None, 0) or cur is None:
|
|
27
|
+
return None
|
|
28
|
+
return (cur - base) / base * 100.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def baseline_problem(baseline, thresholds):
|
|
32
|
+
"""Why this baseline must not anchor regression checks, or None. A run that failed its own gate, or a
|
|
33
|
+
command run that exited outside ok_exit_codes, is not a reference: comparing against it produces verdicts
|
|
34
|
+
like 'power regressed 66%' when the baseline simply died early."""
|
|
35
|
+
if not baseline or not (thresholds or {}).get("baseline_must_pass", True):
|
|
36
|
+
return None
|
|
37
|
+
gate = baseline.get("gate")
|
|
38
|
+
if gate and gate.get("status") == "fail":
|
|
39
|
+
return "baseline failed its own gate: " + ", ".join(gate.get("failed", [])[:4])
|
|
40
|
+
cmd = baseline.get("command")
|
|
41
|
+
ok = (thresholds or {}).get("ok_exit_codes")
|
|
42
|
+
if cmd is not None and ok is not None and cmd.get("exit_code") not in ok:
|
|
43
|
+
return f"baseline process exited {cmd.get('exit_code')}, not in accepted {ok}"
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def evaluate(results, thresholds, baseline=None):
|
|
48
|
+
checks = []
|
|
49
|
+
t = thresholds or {}
|
|
50
|
+
bad_baseline = baseline_problem(baseline, t)
|
|
51
|
+
if bad_baseline:
|
|
52
|
+
checks.append(Check("baseline_valid", "fail", None, None, baseline.get("created_at"), message=bad_baseline + "; regression checks skipped"))
|
|
53
|
+
baseline = None
|
|
54
|
+
lat = results["latency_ms"].get("p99")
|
|
55
|
+
mem = results["memory"].get("peak_rss_mb")
|
|
56
|
+
board_mem = results["memory"].get("board_ram_peak_mb")
|
|
57
|
+
pw = results["power"]["total_w"] if results.get("power") else None
|
|
58
|
+
acc = results["accuracy"]["value"] if results.get("accuracy") else None
|
|
59
|
+
b = baseline or {}
|
|
60
|
+
b_lat = b.get("latency_ms", {}).get("p99") if b else None
|
|
61
|
+
b_mem = b.get("memory", {}).get("peak_rss_mb") if b else None
|
|
62
|
+
b_pw = b["power"]["total_w"] if b and b.get("power") else None
|
|
63
|
+
b_acc = b["accuracy"]["value"] if b and b.get("accuracy") else None
|
|
64
|
+
|
|
65
|
+
def absolute(name, value, limit, higher_is_bad=True, unit=""):
|
|
66
|
+
if limit is None:
|
|
67
|
+
return
|
|
68
|
+
if value is None:
|
|
69
|
+
checks.append(Check(name, "skip", None, limit, message="not measured")); return
|
|
70
|
+
ok = value <= limit if higher_is_bad else value >= limit
|
|
71
|
+
word = "within" if ok else ("exceeds" if higher_is_bad else "below")
|
|
72
|
+
checks.append(Check(name, "pass" if ok else "fail", value, limit,
|
|
73
|
+
message=f"{value}{unit} {word} limit {limit}{unit}"))
|
|
74
|
+
|
|
75
|
+
def regression(name, value, base, pct_limit, unit=""):
|
|
76
|
+
if pct_limit is None:
|
|
77
|
+
return
|
|
78
|
+
if value is None or base is None:
|
|
79
|
+
checks.append(Check(name, "skip", value, pct_limit, base,
|
|
80
|
+
message="no baseline" if base is None else "not measured")); return
|
|
81
|
+
change = _pct_change(value, base)
|
|
82
|
+
floor_key, floor_default = FLOORS[name]
|
|
83
|
+
floor = t.get(floor_key, floor_default)
|
|
84
|
+
over_pct = change > pct_limit
|
|
85
|
+
over_floor = (value - base) > floor
|
|
86
|
+
ok = not (over_pct and over_floor)
|
|
87
|
+
note = f" (under the {floor}{unit} floor)" if over_pct and not over_floor else ""
|
|
88
|
+
checks.append(Check(name, "pass" if ok else "fail", value, pct_limit, base,
|
|
89
|
+
message=f"{value}{unit} vs baseline {base}{unit} ({change:+.1f}%, limit +{pct_limit}%){note}"))
|
|
90
|
+
|
|
91
|
+
absolute("latency_p99_ms_max", lat, t.get("latency_p99_ms_max"), unit=" ms")
|
|
92
|
+
absolute("memory_peak_mb_max", board_mem if board_mem is not None else mem, t.get("memory_peak_mb_max"), unit=" MB")
|
|
93
|
+
absolute("power_w_max", pw, t.get("power_w_max"), unit=" W")
|
|
94
|
+
absolute("accuracy_min", acc, t.get("accuracy_min"), higher_is_bad=False)
|
|
95
|
+
|
|
96
|
+
regression("latency_p99_regression_pct", lat, b_lat, t.get("latency_p99_regression_pct"), unit=" ms")
|
|
97
|
+
regression("memory_regression_pct", mem, b_mem, t.get("memory_regression_pct"), unit=" MB")
|
|
98
|
+
regression("power_regression_pct", pw, b_pw, t.get("power_regression_pct"), unit=" W")
|
|
99
|
+
|
|
100
|
+
drop = t.get("accuracy_drop_max")
|
|
101
|
+
if drop is not None:
|
|
102
|
+
if acc is None or b_acc is None:
|
|
103
|
+
checks.append(Check("accuracy_drop_max", "skip", acc, drop, b_acc,
|
|
104
|
+
message="no baseline" if b_acc is None else "not measured"))
|
|
105
|
+
else:
|
|
106
|
+
d = b_acc - acc
|
|
107
|
+
checks.append(Check("accuracy_drop_max", "pass" if d <= drop else "fail", acc, drop, b_acc,
|
|
108
|
+
message=f"{acc} vs baseline {b_acc} (drop {d:+.4f}, limit {drop})"))
|
|
109
|
+
|
|
110
|
+
cmd = results.get("command")
|
|
111
|
+
ok_codes = t.get("ok_exit_codes")
|
|
112
|
+
if cmd is not None and ok_codes is not None:
|
|
113
|
+
rc = cmd["exit_code"]
|
|
114
|
+
checks.append(Check("exit_code", "pass" if rc in ok_codes else "fail", rc, ok_codes,
|
|
115
|
+
message=f"exit {rc}{' after the full window' if cmd.get('timed_out') else ''}, accepted {ok_codes}"))
|
|
116
|
+
metrics = results.get("metrics") or {}
|
|
117
|
+
b_metrics = (b.get("metrics") or {}) if b else {}
|
|
118
|
+
for name, limit in (t.get("metric_min") or {}).items():
|
|
119
|
+
absolute(f"metric_min:{name}", (metrics.get(name) or {}).get("count"), limit, higher_is_bad=False)
|
|
120
|
+
for name, pct_limit in (t.get("metric_regression_pct") or {}).items():
|
|
121
|
+
cur = (metrics.get(name) or {}).get("count")
|
|
122
|
+
base = (b_metrics.get(name) or {}).get("count")
|
|
123
|
+
if cur is None or base is None:
|
|
124
|
+
checks.append(Check(f"metric_regression_pct:{name}", "skip", cur, pct_limit, base,
|
|
125
|
+
message="no baseline" if base is None else "not measured")); continue
|
|
126
|
+
drop_pct = (base - cur) / base * 100.0 if base else 0.0 # positive when the count fell
|
|
127
|
+
floor = (t.get("metric_regression_floor") or {}).get(name, 0)
|
|
128
|
+
ok = not (drop_pct > pct_limit and (base - cur) > floor)
|
|
129
|
+
checks.append(Check(f"metric_regression_pct:{name}", "pass" if ok else "fail", cur, pct_limit, base,
|
|
130
|
+
message=f"{cur} vs baseline {base} ({-drop_pct:+.1f}%, limit -{pct_limit}%)"))
|
|
131
|
+
|
|
132
|
+
if baseline and baseline.get("model", {}).get("sha256") == results["model"]["sha256"] \
|
|
133
|
+
and baseline.get("device", {}).get("name") != results["device"]["name"]:
|
|
134
|
+
checks.append(Check("baseline_device", "skip", results["device"]["name"], None, baseline["device"].get("name"),
|
|
135
|
+
message="baseline was recorded on a different device; regression checks compare across devices"))
|
|
136
|
+
return checks
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def status(checks):
|
|
140
|
+
return "fail" if any(c.status == "fail" for c in checks) else "pass"
|
edgegate/config.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Project configuration: a small YAML file next to the model."""
|
|
2
|
+
import pathlib
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
"benchmark": {"warmup": 20, "iterations": 200, "batch": 1, "duration_s": 0},
|
|
9
|
+
"power": {"sampler": "auto", "interval_ms": 200},
|
|
10
|
+
"thresholds": {},
|
|
11
|
+
"device": {"name": None},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(path):
|
|
20
|
+
path = pathlib.Path(path)
|
|
21
|
+
try:
|
|
22
|
+
cfg = yaml.safe_load(path.read_text()) or {}
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
raise ConfigError(f"config not found: {path}") from None
|
|
25
|
+
except yaml.YAMLError as e:
|
|
26
|
+
raise ConfigError(f"config is not valid YAML: {path}: {e}") from None
|
|
27
|
+
|
|
28
|
+
if cfg.get("version") != 1:
|
|
29
|
+
raise ConfigError("config: 'version: 1' is required")
|
|
30
|
+
model = cfg.get("model")
|
|
31
|
+
if not isinstance(model, dict) or "path" not in model:
|
|
32
|
+
raise ConfigError("config: model.path is required")
|
|
33
|
+
if model.get("backend") not in ("onnxruntime", "trtexec", "command"):
|
|
34
|
+
raise ConfigError("config: model.backend must be 'onnxruntime', 'trtexec' or 'command'")
|
|
35
|
+
if model["backend"] == "command":
|
|
36
|
+
if not isinstance(model.get("args", []), list) or not all(isinstance(x, str) for x in model.get("args", [])):
|
|
37
|
+
raise ConfigError("config: model.args must be a list of strings")
|
|
38
|
+
if not isinstance(model.get("env", {}), dict):
|
|
39
|
+
raise ConfigError("config: model.env must be a mapping")
|
|
40
|
+
model["input"] = {"name": None, "shape": None, "dtype": None}
|
|
41
|
+
model.setdefault("precision", None)
|
|
42
|
+
if cfg.get("dataset") is not None:
|
|
43
|
+
raise ConfigError("config: dataset is not supported with the command backend (count metrics instead)")
|
|
44
|
+
else:
|
|
45
|
+
inp = model.get("input") or {}
|
|
46
|
+
if "shape" not in inp or not isinstance(inp["shape"], list) or not all(isinstance(d, int) and d > 0 for d in inp["shape"]):
|
|
47
|
+
raise ConfigError("config: model.input.shape must be a list of positive integers (batch first)")
|
|
48
|
+
model["input"] = {"name": inp.get("name"), "shape": inp["shape"], "dtype": inp.get("dtype", "float32")}
|
|
49
|
+
model.setdefault("precision", "fp16")
|
|
50
|
+
|
|
51
|
+
metrics = cfg.get("metrics") or []
|
|
52
|
+
if not isinstance(metrics, list):
|
|
53
|
+
raise ConfigError("config: metrics must be a list")
|
|
54
|
+
for i, m in enumerate(metrics):
|
|
55
|
+
if not isinstance(m, dict) or not all(isinstance(m.get(k), str) and m.get(k) for k in ("name", "file", "pattern")):
|
|
56
|
+
raise ConfigError(f"config: metrics[{i}] needs string fields name, file and pattern")
|
|
57
|
+
try:
|
|
58
|
+
re.compile(m["pattern"])
|
|
59
|
+
except re.error as e:
|
|
60
|
+
raise ConfigError(f"config: metrics[{i}].pattern is not a valid regex: {e}") from None
|
|
61
|
+
cfg["metrics"] = metrics
|
|
62
|
+
|
|
63
|
+
ds = cfg.get("dataset")
|
|
64
|
+
if ds is not None:
|
|
65
|
+
if ds.get("type") not in ("npz", "image_folder"):
|
|
66
|
+
raise ConfigError("config: dataset.type must be 'npz' or 'image_folder'")
|
|
67
|
+
if "path" not in ds:
|
|
68
|
+
raise ConfigError("config: dataset.path is required")
|
|
69
|
+
ds.setdefault("limit", None)
|
|
70
|
+
ds.setdefault("preprocess", {})
|
|
71
|
+
|
|
72
|
+
for section, defaults in DEFAULTS.items():
|
|
73
|
+
merged = dict(defaults)
|
|
74
|
+
merged.update(cfg.get(section) or {})
|
|
75
|
+
cfg[section] = merged
|
|
76
|
+
|
|
77
|
+
t = cfg["thresholds"]
|
|
78
|
+
for key in ("latency_p99_ms_max", "memory_peak_mb_max", "power_w_max", "accuracy_min",
|
|
79
|
+
"latency_p99_regression_pct", "memory_regression_pct", "power_regression_pct", "accuracy_drop_max",
|
|
80
|
+
"latency_regression_floor_ms", "memory_regression_floor_mb", "power_regression_floor_w"):
|
|
81
|
+
if key in t and not isinstance(t[key], (int, float)):
|
|
82
|
+
raise ConfigError(f"config: thresholds.{key} must be a number")
|
|
83
|
+
if "ok_exit_codes" in t and (not isinstance(t["ok_exit_codes"], list) or not all(isinstance(x, int) for x in t["ok_exit_codes"])):
|
|
84
|
+
raise ConfigError("config: thresholds.ok_exit_codes must be a list of integers")
|
|
85
|
+
for key in ("metric_min", "metric_regression_pct", "metric_regression_floor"):
|
|
86
|
+
if key in t and (not isinstance(t[key], dict) or not all(isinstance(v, (int, float)) for v in t[key].values())):
|
|
87
|
+
raise ConfigError(f"config: thresholds.{key} must map metric names to numbers")
|
|
88
|
+
|
|
89
|
+
cfg["_root"] = path.resolve().parent
|
|
90
|
+
cfg["model"]["path"] = str((cfg["_root"] / model["path"]).resolve())
|
|
91
|
+
if model["backend"] == "command": # relative args and files resolve against the config's directory
|
|
92
|
+
model["cwd"] = str((cfg["_root"] / model["cwd"]).resolve()) if model.get("cwd") else str(cfg["_root"])
|
|
93
|
+
if ds is not None:
|
|
94
|
+
ds["path"] = str((cfg["_root"] / ds["path"]).resolve())
|
|
95
|
+
return cfg
|
edgegate/dataset.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Labeled samples for accuracy measurement. Two formats: an .npz with x and y, or an image folder with labels.csv."""
|
|
2
|
+
import csv
|
|
3
|
+
import pathlib
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatasetError(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load(ds):
|
|
13
|
+
"""Yield (input_array, label) pairs. input_array has the model's input shape with batch dimension 1."""
|
|
14
|
+
kind, path, limit = ds["type"], pathlib.Path(ds["path"]), ds.get("limit")
|
|
15
|
+
if kind == "npz":
|
|
16
|
+
yield from _npz(path, limit)
|
|
17
|
+
else:
|
|
18
|
+
yield from _image_folder(path, limit, ds.get("preprocess") or {})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _npz(path, limit):
|
|
22
|
+
try:
|
|
23
|
+
data = np.load(path)
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
raise DatasetError(f"dataset not found: {path}") from None
|
|
26
|
+
if "x" not in data or "y" not in data:
|
|
27
|
+
raise DatasetError(f"{path}: expected arrays 'x' and 'y'")
|
|
28
|
+
x, y = data["x"], data["y"]
|
|
29
|
+
if len(x) != len(y):
|
|
30
|
+
raise DatasetError(f"{path}: x has {len(x)} samples but y has {len(y)}")
|
|
31
|
+
n = len(x) if limit is None else min(limit, len(x))
|
|
32
|
+
for i in range(n):
|
|
33
|
+
yield x[i][None, ...].astype(np.float32), int(y[i])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _image_folder(path, limit, pre):
|
|
37
|
+
try:
|
|
38
|
+
from PIL import Image
|
|
39
|
+
except ImportError:
|
|
40
|
+
raise DatasetError("image_folder datasets need Pillow (pip install edgegate[host])") from None
|
|
41
|
+
labels = path / "labels.csv"
|
|
42
|
+
if not labels.is_file():
|
|
43
|
+
raise DatasetError(f"{labels}: missing (one 'path,label' row per image)")
|
|
44
|
+
size = tuple(pre.get("size", [224, 224]))
|
|
45
|
+
mean = np.array(pre.get("mean", [0.485, 0.456, 0.406]), dtype=np.float32)
|
|
46
|
+
std = np.array(pre.get("std", [0.229, 0.224, 0.225]), dtype=np.float32)
|
|
47
|
+
scale = float(pre.get("scale", 1.0 / 255.0))
|
|
48
|
+
layout = pre.get("layout", "NCHW")
|
|
49
|
+
with labels.open(newline="") as f:
|
|
50
|
+
rows = [r for r in csv.DictReader(f)]
|
|
51
|
+
if rows and not {"path", "label"} <= set(rows[0]):
|
|
52
|
+
raise DatasetError(f"{labels}: header must be 'path,label'")
|
|
53
|
+
if limit is not None:
|
|
54
|
+
rows = rows[:limit]
|
|
55
|
+
for r in rows:
|
|
56
|
+
img = Image.open(path / r["path"]).convert("RGB").resize(size)
|
|
57
|
+
arr = (np.asarray(img, dtype=np.float32) * scale - mean) / std # HWC
|
|
58
|
+
if layout == "NCHW":
|
|
59
|
+
arr = arr.transpose(2, 0, 1)
|
|
60
|
+
yield arr[None, ...].astype(np.float32), int(r["label"])
|