inferencekit-cli 0.1.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.
- inferencekit/__init__.py +9 -0
- inferencekit/__main__.py +5 -0
- inferencekit/analysis/__init__.py +1 -0
- inferencekit/analysis/ncu_csv.py +284 -0
- inferencekit/analysis/nsys_sqlite.py +518 -0
- inferencekit/canonical.py +104 -0
- inferencekit/checkpoint.py +396 -0
- inferencekit/cli.py +274 -0
- inferencekit/cuda/bw_peak.cu +252 -0
- inferencekit/cuda/torch_bw.py +60 -0
- inferencekit/engines/__init__.py +7 -0
- inferencekit/engines/adapters.py +190 -0
- inferencekit/engines/clip/__init__.py +13 -0
- inferencekit/engines/clip/comfyui.py +749 -0
- inferencekit/engines/clip/diffusers_ltx.py +601 -0
- inferencekit/engines/clip/noise.py +46 -0
- inferencekit/engines/clip/official_ltx.py +316 -0
- inferencekit/engines/clip/server.py +542 -0
- inferencekit/engines/clip/stepdump.py +182 -0
- inferencekit/engines/gates.py +94 -0
- inferencekit/engines/sglang_worker.py +86 -0
- inferencekit/engines/vllm_worker.py +129 -0
- inferencekit/evidence.py +427 -0
- inferencekit/gpuinfo.py +93 -0
- inferencekit/hardware.py +135 -0
- inferencekit/mcp_server.py +178 -0
- inferencekit/ops/__init__.py +30 -0
- inferencekit/ops/bench.py +184 -0
- inferencekit/ops/bench_audio.py +384 -0
- inferencekit/ops/bench_clip.py +819 -0
- inferencekit/ops/bench_image.py +393 -0
- inferencekit/ops/bench_rounds.py +297 -0
- inferencekit/ops/bench_serve.py +866 -0
- inferencekit/ops/ceiling.py +135 -0
- inferencekit/ops/compare.py +378 -0
- inferencekit/ops/correctness.py +153 -0
- inferencekit/ops/doctor.py +486 -0
- inferencekit/ops/gates.py +1772 -0
- inferencekit/ops/gates_clip.py +930 -0
- inferencekit/ops/intake.py +1321 -0
- inferencekit/ops/ledger.py +749 -0
- inferencekit/ops/manifest.py +309 -0
- inferencekit/ops/ncu.py +183 -0
- inferencekit/ops/nsys.py +224 -0
- inferencekit/ops/prompts.py +316 -0
- inferencekit/ops/quality.py +2161 -0
- inferencekit/ops/quality_clip.py +690 -0
- inferencekit/ops/quality_clip_steps.py +341 -0
- inferencekit/ops/report.py +317 -0
- inferencekit/ops/review.py +119 -0
- inferencekit/ops/rocprof.py +89 -0
- inferencekit/ops/roofline.py +392 -0
- inferencekit/ops/store.py +61 -0
- inferencekit/proc.py +172 -0
- inferencekit/prompts/ATTRIBUTION.md +56 -0
- inferencekit/prompts/__init__.py +167 -0
- inferencekit/prompts/build.py +518 -0
- inferencekit/prompts/dev-load.jsonl.gz +0 -0
- inferencekit/prompts/dev-load.manifest.json +212 -0
- inferencekit/prompts/dev.jsonl.gz +0 -0
- inferencekit/prompts/dev.manifest.json +214 -0
- inferencekit/prompts/exclusions.json +54 -0
- inferencekit/quality/__init__.py +23 -0
- inferencekit/quality/client.py +66 -0
- inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
- inferencekit/quality/fake.py +329 -0
- inferencekit/quality/gsm8k.py +224 -0
- inferencekit/quality/logprobs.py +106 -0
- inferencekit/quality/metrics.py +235 -0
- inferencekit/quality/niah.py +63 -0
- inferencekit/quality/parquet.py +296 -0
- inferencekit/quality/sampling.py +262 -0
- inferencekit/quality/step_replay.py +217 -0
- inferencekit/quality/video_metrics.py +304 -0
- inferencekit/registry.py +206 -0
- inferencekit/review/__init__.py +6 -0
- inferencekit/review/diff.py +163 -0
- inferencekit/review/media.py +181 -0
- inferencekit/review/page.py +920 -0
- inferencekit/review/rundir.py +844 -0
- inferencekit/roofline_headers.py +504 -0
- inferencekit/schemas/evidence.v1.json +130 -0
- inferencekit/schemas/evidence.v2.json +499 -0
- inferencekit/schemas/intake.v1.json +276 -0
- inferencekit/schemas/run-manifest.v1.json +118 -0
- inferencekit/serve/__init__.py +8 -0
- inferencekit/serve/audio_client.py +330 -0
- inferencekit/serve/client.py +446 -0
- inferencekit/serve/clip_client.py +351 -0
- inferencekit/serve/clip_rounds.py +429 -0
- inferencekit/serve/image_client.py +373 -0
- inferencekit/serve/loadgen.py +136 -0
- inferencekit/serve/metrics.py +203 -0
- inferencekit/serve/profiles.py +105 -0
- inferencekit/serve/prompts_io.py +125 -0
- inferencekit/serve/rounds.py +1111 -0
- inferencekit/serve/servers.py +897 -0
- inferencekit/stats.py +249 -0
- inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
- inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
- inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
- inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
inferencekit/evidence.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Evidence records: build, validate, store, load.
|
|
2
|
+
|
|
3
|
+
A record is the unit of truth in inferencekit. Its id is ``"ev_" + sha256(canonical JSON
|
|
4
|
+
of the record without evidence_id)``, so:
|
|
5
|
+
* the same id always means the same content (a report citing ev_ab12... cites
|
|
6
|
+
exactly those numbers), and
|
|
7
|
+
* tampering is detectable: ``verify`` recomputes the id.
|
|
8
|
+
|
|
9
|
+
Schemas: new records are written as ``inferencekit.evidence.v2`` (``schemas/evidence.v2.json``);
|
|
10
|
+
v1 records (``inferkit.evidence.v1``) still load and validate against their own schema.
|
|
11
|
+
v2 keeps the v1 envelope and adds serving workloads and the kinds of bench serve, gates and quality,
|
|
12
|
+
and clip workloads and the kinds of bench clip, gates clip and quality clip.
|
|
13
|
+
|
|
14
|
+
Storage is ``<runs>/<evidence_id>/evidence.json`` plus any artifact files (traces,
|
|
15
|
+
logs). ``<runs>`` is ``./runs`` unless ``--runs-dir`` or ``INFERENCEKIT_RUNS_DIR`` says
|
|
16
|
+
otherwise (``INFERKIT_RUNS_DIR``, the 0.1 name, is still read as a deprecated fallback).
|
|
17
|
+
A record made with a ``run_dir`` argument is also copied to ``<run_dir>/evidence/<evidence_id>.json``,
|
|
18
|
+
so one copy of a run directory (e.g. an rsync from a GPU host) carries every record of that run.
|
|
19
|
+
|
|
20
|
+
Secret safety: records never contain environment variable values, and commands are
|
|
21
|
+
stored only as digests. As a second line of defence, ``redact`` runs before hashing and
|
|
22
|
+
replaces (1) any text that contains the value of a secret-looking environment variable
|
|
23
|
+
(``*TOKEN*``, ``*KEY*``...) and (2) the value of any secret-looking *key* in a dict or in
|
|
24
|
+
JSON-like text, such as ``{"hf_token": "..."}`` typed into ``--engine-args``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import json
|
|
31
|
+
import math
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import uuid
|
|
36
|
+
from importlib import resources
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Iterable
|
|
39
|
+
|
|
40
|
+
from inferencekit import __version__
|
|
41
|
+
from inferencekit.canonical import canonical_json, file_sha256, sha256_hex
|
|
42
|
+
from inferencekit.registry import Call, OpError, utc_now
|
|
43
|
+
|
|
44
|
+
# New records are written as v2; v1 records (written by early pre-release versions) still read and validate.
|
|
45
|
+
SCHEMA_V1 = "inferkit.evidence.v1"
|
|
46
|
+
SCHEMA_V2 = "inferencekit.evidence.v2"
|
|
47
|
+
SCHEMA_ID = SCHEMA_V2
|
|
48
|
+
SCHEMA_FILES = {SCHEMA_V1: "evidence.v1.json", SCHEMA_V2: "evidence.v2.json"}
|
|
49
|
+
KINDS_V1 = ("environment", "roofline", "ceiling", "benchmark", "nsys_capture", "nsys_timeline",
|
|
50
|
+
"ncu_capture", "ncu_kernels", "correctness", "comparison")
|
|
51
|
+
KINDS = KINDS_V1 + ("serve_benchmark", "capacity", "gates", "quality_trajectory", "quality_task", "manifest")
|
|
52
|
+
# Video clips: bench clip, gates clip, quality clip, quality clip-steps.
|
|
53
|
+
KINDS += ("clip_benchmark", "clip_gates", "clip_quality", "clip_steps")
|
|
54
|
+
# Client-timed images and speech. Structural validity never establishes semantic quality.
|
|
55
|
+
KINDS += ("image_benchmark", "audio_benchmark", "rocprof_capture", "rocprof_summary")
|
|
56
|
+
# identity.workload fields: the v1 four are always present (null when unknown); the v2 serving fields
|
|
57
|
+
# are written only when the operation knows them.
|
|
58
|
+
WORKLOAD_V1 = ("prompt_len", "gen_len", "batch", "seed")
|
|
59
|
+
WORKLOAD_V2 = ("mode", "bucket", "concurrency", "rate_rps", "duration_s", "prompt_set", "slo", "endpoint_kind",
|
|
60
|
+
"prefix_cache", "request_set", "image_task_set", "concurrency_source")
|
|
61
|
+
# Clip workloads: the requested shape, the seed policy and the clip set; written only when known.
|
|
62
|
+
WORKLOAD_V2 += ("task", "width", "height", "num_frames", "fps", "steps", "guidance_scale", "seed_policy", "seeds",
|
|
63
|
+
"clip_set", "audio")
|
|
64
|
+
CLAIM_CLASSES = ("observed", "derived", "inferred")
|
|
65
|
+
|
|
66
|
+
_default_root: Path | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_default_root(path: str | os.PathLike | None) -> None:
|
|
70
|
+
global _default_root
|
|
71
|
+
_default_root = Path(path) if path else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def default_root() -> Path:
|
|
75
|
+
if _default_root is not None:
|
|
76
|
+
return _default_root
|
|
77
|
+
return Path(os.environ.get("INFERENCEKIT_RUNS_DIR") or os.environ.get("INFERKIT_RUNS_DIR") or "runs")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --------------------------------------------------------------------------- building
|
|
81
|
+
|
|
82
|
+
def metric(name: str, value: float | int | None, unit: str, stat: str, n: int | None = None,
|
|
83
|
+
ci95: Iterable[float | None] | None = None, subject: str | None = None) -> dict:
|
|
84
|
+
"""One number with its unit and the statistic it is (median, max, ceiling...)."""
|
|
85
|
+
m = {"name": name, "value": _finite(value), "unit": unit, "stat": stat, "n": n,
|
|
86
|
+
"ci95": [_finite(x) for x in ci95] if ci95 is not None else None}
|
|
87
|
+
if subject is not None:
|
|
88
|
+
m["subject"] = subject
|
|
89
|
+
return m
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def identity(gpu: dict | None = None, engine: dict | None = None, model: dict | None = None,
|
|
93
|
+
workload: dict | None = None) -> dict:
|
|
94
|
+
"""The four things a number depends on. Unknown fields are null, never guessed."""
|
|
95
|
+
g = {"name": None, "count": None, "driver": None, "cuda": None, "clocks": None}
|
|
96
|
+
e = {"name": None, "version": None, "commit": None, "flags": {}}
|
|
97
|
+
m = {"id": None, "revision": None, "dtype": None, "quant": None}
|
|
98
|
+
w = {k: None for k in WORKLOAD_V1}
|
|
99
|
+
for base, extra in ((g, gpu), (e, engine), (m, model), (w, workload)):
|
|
100
|
+
for k, v in (extra or {}).items():
|
|
101
|
+
if k in base:
|
|
102
|
+
base[k] = v
|
|
103
|
+
for k in WORKLOAD_V2: # serving workloads (v2): mode, concurrency grid, prompt set, SLO...
|
|
104
|
+
if (workload or {}).get(k) is not None:
|
|
105
|
+
w[k] = workload[k]
|
|
106
|
+
return {"gpu": g, "engine": e, "model": m, "workload": w}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build(call: Call, *, kind: str, claim_class: str, summary: str, identity_: dict | None = None,
|
|
110
|
+
metrics: list[dict] | None = None, artifacts: list[dict] | None = None,
|
|
111
|
+
missing: list[str] | None = None, limitations: list[str] | None = None,
|
|
112
|
+
data: dict | None = None) -> dict:
|
|
113
|
+
"""Assemble a record (without its id). ``missing`` lists what could not be measured."""
|
|
114
|
+
if kind not in KINDS:
|
|
115
|
+
raise ValueError(f"unknown evidence kind {kind}")
|
|
116
|
+
if claim_class not in CLAIM_CLASSES:
|
|
117
|
+
raise ValueError(f"unknown claim class {claim_class}")
|
|
118
|
+
missing = sorted(set(missing or []))
|
|
119
|
+
body_data = dict(data or {})
|
|
120
|
+
body_data.setdefault("request", call.request())
|
|
121
|
+
return {
|
|
122
|
+
"schema": SCHEMA_ID,
|
|
123
|
+
"kind": kind,
|
|
124
|
+
"claim_class": claim_class,
|
|
125
|
+
"summary": summary[:600],
|
|
126
|
+
"identity": identity_ or identity(),
|
|
127
|
+
"provenance": {"tool": "inferencekit", "tool_version": __version__, "argv_digest": call.argv_digest(),
|
|
128
|
+
"started": call.started, "ended": utc_now()},
|
|
129
|
+
"metrics": metrics or [],
|
|
130
|
+
"artifacts": artifacts or [],
|
|
131
|
+
"coverage": {"complete": not missing, "missing": missing},
|
|
132
|
+
"limitations": _dedupe(limitations or []),
|
|
133
|
+
"data": body_data,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def artifact(path: str | os.PathLike, run_dir: str | os.PathLike | None, role: str) -> dict:
|
|
138
|
+
"""Describe a file by content hash. Paths inside the run dir are stored relative to it."""
|
|
139
|
+
p = Path(path)
|
|
140
|
+
sha, size = file_sha256(p)
|
|
141
|
+
if run_dir is not None:
|
|
142
|
+
try:
|
|
143
|
+
rel = p.resolve().relative_to(Path(run_dir).resolve())
|
|
144
|
+
return {"path": rel.as_posix(), "sha256": sha, "bytes": size, "role": role}
|
|
145
|
+
except ValueError:
|
|
146
|
+
pass
|
|
147
|
+
# An input that lives elsewhere (e.g. a trace the user passed in): keep the path
|
|
148
|
+
# relative to the working directory when possible, so home directories do not leak.
|
|
149
|
+
try:
|
|
150
|
+
shown = p.resolve().relative_to(Path.cwd().resolve()).as_posix()
|
|
151
|
+
except ValueError:
|
|
152
|
+
shown = str(p)
|
|
153
|
+
return {"path": shown, "sha256": sha, "bytes": size, "role": role}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _dedupe(items: list[str]) -> list[str]:
|
|
157
|
+
seen, out = set(), []
|
|
158
|
+
for s in items:
|
|
159
|
+
if s not in seen:
|
|
160
|
+
seen.add(s)
|
|
161
|
+
out.append(s)
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _finite(x):
|
|
166
|
+
"""NaN/inf cannot be stored in canonical JSON; they mean 'undefined', i.e. null."""
|
|
167
|
+
if isinstance(x, float) and (math.isnan(x) or math.isinf(x)):
|
|
168
|
+
return None
|
|
169
|
+
return x
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _clean(value: Any) -> Any:
|
|
173
|
+
if isinstance(value, dict):
|
|
174
|
+
return {str(k): _clean(v) for k, v in value.items()}
|
|
175
|
+
if isinstance(value, (list, tuple)):
|
|
176
|
+
return [_clean(v) for v in value]
|
|
177
|
+
if isinstance(value, float):
|
|
178
|
+
return _finite(value)
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --------------------------------------------------------------------------- secrets
|
|
183
|
+
|
|
184
|
+
# Environment variable NAMES that usually hold a secret (HF_TOKEN, OPENAI_API_KEY, AWS_SECRET_ACCESS_KEY...).
|
|
185
|
+
_SECRET_NAME = re.compile(r"TOKEN|SECRET|PASSW|(^|_)KEY($|_)|API_?KEY|ACCESS_?KEY|PRIVATE|CREDENTIAL|AUTH|COOKIE",
|
|
186
|
+
re.IGNORECASE)
|
|
187
|
+
# Dict / JSON KEYS that hold a secret. Stricter than _SECRET_NAME on purpose: the key must *end* in the
|
|
188
|
+
# secret word, so "hf_token" and "api_key" match but "max_num_batched_tokens" and "tokenizer" do not.
|
|
189
|
+
_SECRET_KEY = re.compile(r"(^|[_-])(token|api_?key|access_?key|secret(_?key)?|password|passwd|credentials?|auth)$",
|
|
190
|
+
re.IGNORECASE)
|
|
191
|
+
# "key": "value", 'key': 'value' (JSON or a Python dict repr) or key="value". Group 2 is the key; the
|
|
192
|
+
# lookbehind and the length cap keep matching linear on long runs of word characters (hex dumps, logs).
|
|
193
|
+
_KEY_VALUE = re.compile(r"""(["']?)(?<![A-Za-z0-9_.-])([A-Za-z0-9_.-]{1,64})\1(\s*[:=]\s*)(["'])(.*?)(?<!\\)\4""")
|
|
194
|
+
REDACTED = "<redacted>"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def looks_secret_name(name: str) -> bool:
|
|
198
|
+
"""True for environment variable names such as HF_TOKEN or MY_API_KEY."""
|
|
199
|
+
return bool(_SECRET_NAME.search(name))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def looks_secret_key(key: str) -> bool:
|
|
203
|
+
"""True for dict / JSON keys such as hf_token, api_key or password."""
|
|
204
|
+
return bool(_SECRET_KEY.search(str(key)))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _secret_values() -> list[tuple[str, str]]:
|
|
208
|
+
"""(name, value) of environment variables whose *names* look secret. Values stay in memory only."""
|
|
209
|
+
out = []
|
|
210
|
+
for name, value in os.environ.items():
|
|
211
|
+
if looks_secret_name(name) and value and len(value) >= 8: # short values would redact common words
|
|
212
|
+
out.append((name, value))
|
|
213
|
+
return sorted(out, key=lambda nv: -len(nv[1]))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _redact_key_value(m: re.Match) -> str:
|
|
217
|
+
if not looks_secret_key(m.group(2)):
|
|
218
|
+
return m.group(0)
|
|
219
|
+
q = m.group(4)
|
|
220
|
+
return f"{m.group(1)}{m.group(2)}{m.group(1)}{m.group(3)}{q}{REDACTED}{q}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def redact_text(text: str) -> str:
|
|
224
|
+
for name, value in _secret_values():
|
|
225
|
+
if value in text:
|
|
226
|
+
text = text.replace(value, f"<redacted:{name}>")
|
|
227
|
+
# A secret typed straight into a JSON argument or echoed in a config dump: '"hf_token": "hf_..."'.
|
|
228
|
+
text = _KEY_VALUE.sub(_redact_key_value, text)
|
|
229
|
+
# Paths under the home directory name the user; write them as ~/... instead.
|
|
230
|
+
home = os.path.expanduser("~").rstrip("/")
|
|
231
|
+
if len(home) > 1 and home + "/" in text:
|
|
232
|
+
text = text.replace(home + "/", "~/")
|
|
233
|
+
return text
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def redact(value: Any) -> Any:
|
|
237
|
+
if isinstance(value, dict):
|
|
238
|
+
return {k: REDACTED if looks_secret_key(k) and isinstance(v, str) else redact(v) for k, v in value.items()}
|
|
239
|
+
if isinstance(value, list):
|
|
240
|
+
return [redact(v) for v in value]
|
|
241
|
+
if isinstance(value, str):
|
|
242
|
+
return redact_text(value)
|
|
243
|
+
return value
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --------------------------------------------------------------------------- ids and schema
|
|
247
|
+
|
|
248
|
+
def compute_id(record: dict) -> str:
|
|
249
|
+
body = {k: v for k, v in record.items() if k != "evidence_id"}
|
|
250
|
+
return "ev_" + sha256_hex(canonical_json(body))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def finalize(record: dict) -> dict:
|
|
254
|
+
"""Clean numbers, redact secrets, then stamp the content hash as the id."""
|
|
255
|
+
body = redact(_clean({k: v for k, v in record.items() if k != "evidence_id"}))
|
|
256
|
+
eid = compute_id(body)
|
|
257
|
+
ordered = {"schema": body["schema"], "evidence_id": eid}
|
|
258
|
+
ordered.update({k: v for k, v in body.items() if k != "schema"})
|
|
259
|
+
return ordered
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def schema_path(schema_id: str = SCHEMA_ID) -> Path:
|
|
263
|
+
"""The schema file for ``schema_id``: the copy shipped in the wheel, or the repo copy in a checkout."""
|
|
264
|
+
name = SCHEMA_FILES.get(schema_id)
|
|
265
|
+
if name is None:
|
|
266
|
+
raise ValueError(f"unknown evidence schema {schema_id!r}; known: {sorted(SCHEMA_FILES)}")
|
|
267
|
+
try:
|
|
268
|
+
packaged = resources.files("inferencekit").joinpath(f"schemas/{name}")
|
|
269
|
+
if packaged.is_file():
|
|
270
|
+
return Path(str(packaged))
|
|
271
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
272
|
+
pass
|
|
273
|
+
return Path(__file__).resolve().parents[2] / "schemas" / name
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_schema_cache: dict[str, dict] = {}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def load_schema(schema_id: str = SCHEMA_ID) -> dict:
|
|
280
|
+
if schema_id not in _schema_cache:
|
|
281
|
+
_schema_cache[schema_id] = json.loads(schema_path(schema_id).read_text())
|
|
282
|
+
return _schema_cache[schema_id]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def validate(record: dict) -> list[str]:
|
|
286
|
+
"""Schema errors as readable strings (empty list = valid). The record's own ``schema`` picks v1 or v2."""
|
|
287
|
+
try:
|
|
288
|
+
import jsonschema
|
|
289
|
+
except ImportError: # pragma: no cover - jsonschema is a declared dependency
|
|
290
|
+
return ["jsonschema not installed: record not validated"]
|
|
291
|
+
schema_id = record.get("schema") if isinstance(record, dict) else None
|
|
292
|
+
if schema_id not in SCHEMA_FILES:
|
|
293
|
+
return [f"schema: {schema_id!r} is not a known evidence schema ({', '.join(sorted(SCHEMA_FILES))})"]
|
|
294
|
+
validator = jsonschema.Draft202012Validator(load_schema(schema_id))
|
|
295
|
+
errors = []
|
|
296
|
+
for err in sorted(validator.iter_errors(record), key=lambda e: list(e.path)):
|
|
297
|
+
where = "/".join(str(p) for p in err.path) or "(root)"
|
|
298
|
+
errors.append(f"{where}: {err.message}")
|
|
299
|
+
return errors
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def verify(record: dict) -> dict:
|
|
303
|
+
"""Integrity check: does the id match the content, and does the record fit the schema?"""
|
|
304
|
+
errors = validate(record)
|
|
305
|
+
return {"id_ok": compute_id(record) == record.get("evidence_id"), "schema_ok": not errors,
|
|
306
|
+
"schema_errors": errors[:20]}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def integrity_problem(record: dict) -> str | None:
|
|
310
|
+
"""None when a record's id matches its content; otherwise a one-line reason.
|
|
311
|
+
|
|
312
|
+
Operations that build a new record from stored ones (compare, correctness, roofline
|
|
313
|
+
with a measured ceiling...) call this on each input. Without it, a hand-edited input
|
|
314
|
+
would be laundered into a fresh record whose own id verifies.
|
|
315
|
+
"""
|
|
316
|
+
if compute_id(record) != record.get("evidence_id"):
|
|
317
|
+
return f"{record.get('evidence_id')} does not match its content (edited after capture?)"
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --------------------------------------------------------------------------- the store
|
|
322
|
+
|
|
323
|
+
def _copy_to_run_dir(rec: dict, text: str) -> None:
|
|
324
|
+
"""Also write the record to ``<run_dir>/evidence/<id>.json`` when it was made with a ``run_dir``
|
|
325
|
+
argument (bench serve, bench rounds, gates, quality...), so the run directory alone carries its
|
|
326
|
+
evidence: review build and ledger trailers work on a synced copy of ``runs/<run-id>/`` with no store.
|
|
327
|
+
Best effort: the store copy is the one that counts."""
|
|
328
|
+
args = ((rec.get("data") or {}).get("request") or {}).get("args")
|
|
329
|
+
rd = args.get("run_dir") if isinstance(args, dict) else None
|
|
330
|
+
if not isinstance(rd, str) or not rd:
|
|
331
|
+
return
|
|
332
|
+
try:
|
|
333
|
+
run = Path(rd).expanduser()
|
|
334
|
+
if not run.is_dir():
|
|
335
|
+
return
|
|
336
|
+
d = run / "evidence"
|
|
337
|
+
d.mkdir(exist_ok=True)
|
|
338
|
+
(d / f"{rec['evidence_id']}.json").write_text(text)
|
|
339
|
+
except OSError:
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class Store:
|
|
344
|
+
"""Files under ``<root>/<evidence_id>/``. Staging dirs hold artifacts until the id is known."""
|
|
345
|
+
|
|
346
|
+
def __init__(self, root: str | os.PathLike | None = None):
|
|
347
|
+
self.root = Path(root) if root else default_root()
|
|
348
|
+
|
|
349
|
+
def staging(self) -> Path:
|
|
350
|
+
"""A scratch dir for an operation's artifacts; ``save`` moves it to the run dir."""
|
|
351
|
+
d = self.root / ".staging" / uuid.uuid4().hex
|
|
352
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
353
|
+
return d
|
|
354
|
+
|
|
355
|
+
@contextlib.contextmanager
|
|
356
|
+
def staging_dir(self):
|
|
357
|
+
"""``with store.staging_dir() as staging:`` -- a staging dir that is deleted if the block fails.
|
|
358
|
+
|
|
359
|
+
On success ``save`` has already moved its files into the run dir. On any error
|
|
360
|
+
(including Ctrl-C) the directory and everything in it is removed, so raw program
|
|
361
|
+
output that was never redacted does not stay behind in ``runs/.staging``.
|
|
362
|
+
"""
|
|
363
|
+
d = self.staging()
|
|
364
|
+
try:
|
|
365
|
+
yield d
|
|
366
|
+
except BaseException:
|
|
367
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
def run_dir(self, evidence_id: str) -> Path:
|
|
371
|
+
return self.root / evidence_id
|
|
372
|
+
|
|
373
|
+
def save(self, record: dict, staging: Path | None = None) -> dict:
|
|
374
|
+
rec = finalize(record)
|
|
375
|
+
errors = validate(rec)
|
|
376
|
+
if errors:
|
|
377
|
+
raise OpError("evidence record failed schema validation: " + "; ".join(errors[:5]))
|
|
378
|
+
target = self.run_dir(rec["evidence_id"])
|
|
379
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
if staging is not None and staging.exists():
|
|
381
|
+
for item in staging.iterdir():
|
|
382
|
+
dest = target / item.name
|
|
383
|
+
if dest.exists():
|
|
384
|
+
if dest.is_dir():
|
|
385
|
+
shutil.rmtree(dest)
|
|
386
|
+
else:
|
|
387
|
+
dest.unlink()
|
|
388
|
+
shutil.move(str(item), str(dest))
|
|
389
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
390
|
+
text = json.dumps(rec, indent=2, ensure_ascii=False) + "\n"
|
|
391
|
+
(target / "evidence.json").write_text(text)
|
|
392
|
+
_copy_to_run_dir(rec, text)
|
|
393
|
+
return rec
|
|
394
|
+
|
|
395
|
+
def paths(self) -> list[Path]:
|
|
396
|
+
if not self.root.exists():
|
|
397
|
+
return []
|
|
398
|
+
return sorted(self.root.glob("ev_*/evidence.json"))
|
|
399
|
+
|
|
400
|
+
def load(self, ref: str) -> dict:
|
|
401
|
+
"""Load by full id, unique id prefix (like git), or a path to an evidence.json."""
|
|
402
|
+
p = Path(ref)
|
|
403
|
+
if p.suffix == ".json" and p.is_file():
|
|
404
|
+
return json.loads(p.read_text())
|
|
405
|
+
if p.is_dir() and (p / "evidence.json").is_file():
|
|
406
|
+
return json.loads((p / "evidence.json").read_text())
|
|
407
|
+
ref = ref if ref.startswith("ev_") else "ev_" + ref
|
|
408
|
+
exact = self.run_dir(ref) / "evidence.json"
|
|
409
|
+
if exact.is_file():
|
|
410
|
+
return json.loads(exact.read_text())
|
|
411
|
+
if len(ref) < 3 + 6:
|
|
412
|
+
raise OpError(f"evidence prefix '{ref}' is too short (use at least 6 hex characters)")
|
|
413
|
+
matches = [q for q in self.paths() if q.parent.name.startswith(ref)]
|
|
414
|
+
if not matches:
|
|
415
|
+
raise OpError(f"no evidence matching '{ref}' under {self.root}")
|
|
416
|
+
if len(matches) > 1:
|
|
417
|
+
raise OpError(f"evidence prefix '{ref}' is ambiguous ({len(matches)} matches)")
|
|
418
|
+
return json.loads(matches[0].read_text())
|
|
419
|
+
|
|
420
|
+
def records(self) -> list[dict]:
|
|
421
|
+
out = []
|
|
422
|
+
for q in self.paths():
|
|
423
|
+
try:
|
|
424
|
+
out.append(json.loads(q.read_text()))
|
|
425
|
+
except (OSError, ValueError):
|
|
426
|
+
continue
|
|
427
|
+
return out
|
inferencekit/gpuinfo.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""GPU facts from nvidia-smi, shared by doctor and by every GPU-touching operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from inferencekit.proc import run, which
|
|
8
|
+
|
|
9
|
+
# Asked for one by one if the combined query fails (older drivers lack some fields).
|
|
10
|
+
QUERY_FIELDS = [
|
|
11
|
+
"index", "name", "uuid", "driver_version", "memory.total", "compute_cap", "pstate", "persistence_mode",
|
|
12
|
+
"clocks.sm", "clocks.mem", "clocks.max.sm", "clocks.max.mem", "clocks.applications.graphics",
|
|
13
|
+
"clocks.default_applications.graphics", "clocks.applications.memory", "clocks.default_applications.memory",
|
|
14
|
+
"power.limit", "temperature.gpu",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _query(fields: list[str]) -> list[dict] | None:
|
|
19
|
+
smi = which("nvidia-smi")
|
|
20
|
+
if not smi:
|
|
21
|
+
return None
|
|
22
|
+
r = run([smi, f"--query-gpu={','.join(fields)}", "--format=csv,noheader,nounits"], timeout=30)
|
|
23
|
+
if not r.ok:
|
|
24
|
+
return None
|
|
25
|
+
rows = []
|
|
26
|
+
for line in r.stdout.strip().splitlines():
|
|
27
|
+
parts = [p.strip() for p in line.split(",")]
|
|
28
|
+
if len(parts) == len(fields):
|
|
29
|
+
rows.append({f: (None if v in ("[N/A]", "N/A", "[Not Supported]", "") else v) for f, v in zip(fields, parts)})
|
|
30
|
+
return rows
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def query_gpus() -> list[dict]:
|
|
34
|
+
"""One dict per visible GPU (empty list without nvidia-smi or a driver)."""
|
|
35
|
+
rows = _query(QUERY_FIELDS)
|
|
36
|
+
if rows is not None:
|
|
37
|
+
return rows
|
|
38
|
+
# Field-by-field fallback: keep whatever this driver supports.
|
|
39
|
+
base = _query(["index", "name", "driver_version"])
|
|
40
|
+
if not base:
|
|
41
|
+
return []
|
|
42
|
+
for f in QUERY_FIELDS:
|
|
43
|
+
if f in ("index", "name", "driver_version"):
|
|
44
|
+
continue
|
|
45
|
+
extra = _query(["index", f])
|
|
46
|
+
if extra:
|
|
47
|
+
by_index = {e["index"]: e[f] for e in extra}
|
|
48
|
+
for row in base:
|
|
49
|
+
row[f] = by_index.get(row["index"])
|
|
50
|
+
return base
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def driver_cuda_version() -> str | None:
|
|
54
|
+
"""Highest CUDA version the driver supports, from the nvidia-smi banner."""
|
|
55
|
+
smi = which("nvidia-smi")
|
|
56
|
+
if not smi:
|
|
57
|
+
return None
|
|
58
|
+
r = run([smi], timeout=30)
|
|
59
|
+
m = re.search(r"CUDA Version:\s*([\d.]+)", r.stdout)
|
|
60
|
+
return m.group(1) if m else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def clocks_state(gpu: dict) -> str | None:
|
|
64
|
+
"""'locked' when application clocks differ from their defaults, else 'default'.
|
|
65
|
+
|
|
66
|
+
Clock locks matter because a benchmark at boost clocks and one at locked base
|
|
67
|
+
clocks are different experiments. Locks set with ``nvidia-smi -lgc`` are not
|
|
68
|
+
exposed by every driver; doctor records that caveat.
|
|
69
|
+
"""
|
|
70
|
+
pairs = [("clocks.applications.graphics", "clocks.default_applications.graphics"),
|
|
71
|
+
("clocks.applications.memory", "clocks.default_applications.memory")]
|
|
72
|
+
known = False
|
|
73
|
+
for cur, default in pairs:
|
|
74
|
+
if gpu.get(cur) and gpu.get(default):
|
|
75
|
+
known = True
|
|
76
|
+
if gpu[cur] != gpu[default]:
|
|
77
|
+
return "locked"
|
|
78
|
+
return "default" if known else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def gpu_identity(gpus: list[dict] | None = None) -> dict:
|
|
82
|
+
"""The ``identity.gpu`` block of an evidence record (first visible GPU, plus count)."""
|
|
83
|
+
gpus = query_gpus() if gpus is None else gpus
|
|
84
|
+
if not gpus:
|
|
85
|
+
from inferencekit.hardware import discover_non_nvidia
|
|
86
|
+
devices = discover_non_nvidia()["devices"]
|
|
87
|
+
if not devices:
|
|
88
|
+
return {}
|
|
89
|
+
return {"name": devices[0]["name"], "count": len(devices), "driver": None,
|
|
90
|
+
"cuda": None, "clocks": None}
|
|
91
|
+
g = gpus[0]
|
|
92
|
+
return {"name": g.get("name"), "count": len(gpus), "driver": g.get("driver_version"),
|
|
93
|
+
"cuda": driver_cuda_version(), "clocks": clocks_state(g)}
|
inferencekit/hardware.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Read-only accelerator discovery; no framework import or test kernel is launched.
|
|
2
|
+
|
|
3
|
+
NVIDIA discovery remains in gpuinfo. AMD reads Linux DRM sysfs, Apple reads
|
|
4
|
+
selected system_profiler/sysctl fields. Missing fields stay unknown. No host,
|
|
5
|
+
serial, UUID, or raw vendor output is retained.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from inferencekit.proc import run, which
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read(path: Path) -> str | None:
|
|
20
|
+
try:
|
|
21
|
+
return path.read_text().strip() or None
|
|
22
|
+
except OSError:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sysctl(key: str) -> str | None:
|
|
27
|
+
result = run(["sysctl", "-n", key], timeout=15)
|
|
28
|
+
return result.stdout.strip() if result.ok else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def amd_devices(root: Path = Path("/sys/class/drm")) -> list[dict]:
|
|
32
|
+
devices = []
|
|
33
|
+
seen = set()
|
|
34
|
+
for card in sorted(root.glob("card*")):
|
|
35
|
+
if not re.fullmatch(r"card\d+", card.name):
|
|
36
|
+
continue
|
|
37
|
+
device = card / "device"
|
|
38
|
+
if _read(device / "vendor") != "0x1002":
|
|
39
|
+
continue
|
|
40
|
+
cls = _read(device / "class")
|
|
41
|
+
if cls and not cls.startswith("0x03"):
|
|
42
|
+
continue
|
|
43
|
+
resolved = device.resolve()
|
|
44
|
+
if resolved in seen:
|
|
45
|
+
continue
|
|
46
|
+
seen.add(resolved)
|
|
47
|
+
name = _read(device / "product_name")
|
|
48
|
+
name_source = "sysfs product_name" if name else None
|
|
49
|
+
pci_id = _read(device / "device")
|
|
50
|
+
if not name and which("lspci"):
|
|
51
|
+
result = run(["lspci", "-s", resolved.name, "-mm"], timeout=15)
|
|
52
|
+
try:
|
|
53
|
+
fields = shlex.split(result.stdout.strip()) if result.ok else []
|
|
54
|
+
except ValueError:
|
|
55
|
+
fields = []
|
|
56
|
+
if len(fields) >= 4:
|
|
57
|
+
name, name_source = fields[3], "lspci PCI database"
|
|
58
|
+
memory = _read(device / "mem_info_vram_total")
|
|
59
|
+
devices.append({"name": name or f"AMD GPU ({pci_id or 'unknown PCI device'})",
|
|
60
|
+
"name_source": name_source or "PCI id only; product unverified",
|
|
61
|
+
"backend": "rocm", "memory_bytes": int(memory) if memory and memory.isdigit() else None,
|
|
62
|
+
"memory_kind": "vram", "pci_device_id": pci_id,
|
|
63
|
+
"source": "Linux DRM sysfs"})
|
|
64
|
+
return devices
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rocm_devices() -> list[dict]:
|
|
68
|
+
"""Fallback for WSL /dev/dxg and hosts without DRM sysfs; CPU agents do not count.
|
|
69
|
+
|
|
70
|
+
rocminfo enumerates agents but launches no kernels. Its heap sizes are not
|
|
71
|
+
labelled dedicated VRAM here: under WSL they can describe shared memory.
|
|
72
|
+
"""
|
|
73
|
+
executable = which("rocminfo")
|
|
74
|
+
if not executable:
|
|
75
|
+
return []
|
|
76
|
+
result = run([executable], timeout=30)
|
|
77
|
+
if not result.ok:
|
|
78
|
+
return []
|
|
79
|
+
devices = []
|
|
80
|
+
for block in re.split(r"(?m)^\s*Agent\s+\d+\s*$", result.stdout):
|
|
81
|
+
name = re.search(r"(?m)^\s*Name:\s*(gfx[0-9a-f]+)\s*$", block)
|
|
82
|
+
kind = re.search(r"(?m)^\s*Device Type:\s*(\w+)", block)
|
|
83
|
+
if not name or not kind or kind.group(1) != "GPU":
|
|
84
|
+
continue
|
|
85
|
+
marketing = re.search(r"(?m)^\s*Marketing Name:\s*(.+)$", block)
|
|
86
|
+
product = marketing.group(1).strip() if marketing else None
|
|
87
|
+
devices.append({"name": product or f"AMD GPU ({name.group(1)})", "architecture": name.group(1),
|
|
88
|
+
"name_source": "rocminfo Marketing Name" if product else "rocminfo gfx target only",
|
|
89
|
+
"backend": "rocm", "memory_bytes": None, "memory_kind": "vram",
|
|
90
|
+
"source": "rocminfo GPU agent; dedicated VRAM not verified"})
|
|
91
|
+
return devices
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def apple_devices() -> list[dict]:
|
|
95
|
+
if platform.system() != "Darwin" or platform.machine() not in ("arm64", "aarch64"):
|
|
96
|
+
return []
|
|
97
|
+
brand, memory = _sysctl("machdep.cpu.brand_string"), _sysctl("hw.memsize")
|
|
98
|
+
if not brand or not brand.startswith("Apple"):
|
|
99
|
+
return []
|
|
100
|
+
result = run(["system_profiler", "SPDisplaysDataType", "-json"], timeout=30)
|
|
101
|
+
try:
|
|
102
|
+
rows = json.loads(result.stdout).get("SPDisplaysDataType", []) if result.ok else []
|
|
103
|
+
except (ValueError, AttributeError):
|
|
104
|
+
rows = []
|
|
105
|
+
# On Apple Silicon this is one GPU sharing system memory, not dedicated VRAM.
|
|
106
|
+
gpu = next((row for row in rows if str(row.get("sppci_vendor", "")).lower().startswith("sppci_vendor_apple")), {})
|
|
107
|
+
return [{"name": gpu.get("sppci_model") or brand, "name_source": "system_profiler/sysctl",
|
|
108
|
+
"backend": "metal", "memory_bytes": int(memory) if memory and memory.isdigit() else None,
|
|
109
|
+
"memory_kind": "unified", "source": "macOS system_profiler/sysctl"}]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def discover_non_nvidia() -> dict:
|
|
113
|
+
devices = apple_devices() or amd_devices() or rocm_devices()
|
|
114
|
+
accelerator_interface = any(Path(p).exists() for p in ("/dev/dxg", "/dev/kfd", "/dev/dri"))
|
|
115
|
+
backend = devices[0]["backend"] if devices else "unknown" if accelerator_interface else "cpu"
|
|
116
|
+
return {"platform": backend, "devices": devices,
|
|
117
|
+
"cpu": {"architecture": platform.machine(), "logical_cores": os.cpu_count()},
|
|
118
|
+
"limitations": (["Device identification does not prove runtime or profiler support; verify the exact "
|
|
119
|
+
"GPU, OS and installed runtime against current official docs and a smoke request."]
|
|
120
|
+
if devices else ["Accelerator identity is unknown; a device interface exists but vendor "
|
|
121
|
+
"discovery failed." if accelerator_interface else
|
|
122
|
+
"No supported accelerator detected; a missing driver or unsupported "
|
|
123
|
+
"device can also produce this result."])}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def profiler_route(backend: str) -> dict:
|
|
127
|
+
return {
|
|
128
|
+
"nvidia": {"timeline": "nsys", "kernel": "ncu", "documentation": "https://docs.nvidia.com/nsight-systems/"},
|
|
129
|
+
"rocm": {"timeline": "rocprofv3", "kernel": "rocprofv3 counters (verify GPU support)",
|
|
130
|
+
"documentation": "https://rocm.docs.amd.com/projects/rocprofiler-sdk/en/latest/how-to/using-rocprofv3.html"},
|
|
131
|
+
"metal": {"timeline": "Instruments Metal System Trace", "kernel": "Xcode Metal debugger / MLX capture",
|
|
132
|
+
"documentation": "https://developer.apple.com/documentation/xcode/optimizing-gpu-performance"},
|
|
133
|
+
"cpu": {"timeline": "perf / Instruments Time Profiler", "kernel": "platform CPU profiler",
|
|
134
|
+
"documentation": "https://www.kernel.org/doc/html/latest/admin-guide/perf-security.html"},
|
|
135
|
+
}.get(backend, {"timeline": None, "kernel": None, "documentation": None})
|