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 ADDED
@@ -0,0 +1,3 @@
1
+ """CI gate for model-plus-firmware builds on edge devices."""
2
+
3
+ __version__ = "0.2.0"
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"])