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.
Files changed (104) hide show
  1. inferencekit/__init__.py +9 -0
  2. inferencekit/__main__.py +5 -0
  3. inferencekit/analysis/__init__.py +1 -0
  4. inferencekit/analysis/ncu_csv.py +284 -0
  5. inferencekit/analysis/nsys_sqlite.py +518 -0
  6. inferencekit/canonical.py +104 -0
  7. inferencekit/checkpoint.py +396 -0
  8. inferencekit/cli.py +274 -0
  9. inferencekit/cuda/bw_peak.cu +252 -0
  10. inferencekit/cuda/torch_bw.py +60 -0
  11. inferencekit/engines/__init__.py +7 -0
  12. inferencekit/engines/adapters.py +190 -0
  13. inferencekit/engines/clip/__init__.py +13 -0
  14. inferencekit/engines/clip/comfyui.py +749 -0
  15. inferencekit/engines/clip/diffusers_ltx.py +601 -0
  16. inferencekit/engines/clip/noise.py +46 -0
  17. inferencekit/engines/clip/official_ltx.py +316 -0
  18. inferencekit/engines/clip/server.py +542 -0
  19. inferencekit/engines/clip/stepdump.py +182 -0
  20. inferencekit/engines/gates.py +94 -0
  21. inferencekit/engines/sglang_worker.py +86 -0
  22. inferencekit/engines/vllm_worker.py +129 -0
  23. inferencekit/evidence.py +427 -0
  24. inferencekit/gpuinfo.py +93 -0
  25. inferencekit/hardware.py +135 -0
  26. inferencekit/mcp_server.py +178 -0
  27. inferencekit/ops/__init__.py +30 -0
  28. inferencekit/ops/bench.py +184 -0
  29. inferencekit/ops/bench_audio.py +384 -0
  30. inferencekit/ops/bench_clip.py +819 -0
  31. inferencekit/ops/bench_image.py +393 -0
  32. inferencekit/ops/bench_rounds.py +297 -0
  33. inferencekit/ops/bench_serve.py +866 -0
  34. inferencekit/ops/ceiling.py +135 -0
  35. inferencekit/ops/compare.py +378 -0
  36. inferencekit/ops/correctness.py +153 -0
  37. inferencekit/ops/doctor.py +486 -0
  38. inferencekit/ops/gates.py +1772 -0
  39. inferencekit/ops/gates_clip.py +930 -0
  40. inferencekit/ops/intake.py +1321 -0
  41. inferencekit/ops/ledger.py +749 -0
  42. inferencekit/ops/manifest.py +309 -0
  43. inferencekit/ops/ncu.py +183 -0
  44. inferencekit/ops/nsys.py +224 -0
  45. inferencekit/ops/prompts.py +316 -0
  46. inferencekit/ops/quality.py +2161 -0
  47. inferencekit/ops/quality_clip.py +690 -0
  48. inferencekit/ops/quality_clip_steps.py +341 -0
  49. inferencekit/ops/report.py +317 -0
  50. inferencekit/ops/review.py +119 -0
  51. inferencekit/ops/rocprof.py +89 -0
  52. inferencekit/ops/roofline.py +392 -0
  53. inferencekit/ops/store.py +61 -0
  54. inferencekit/proc.py +172 -0
  55. inferencekit/prompts/ATTRIBUTION.md +56 -0
  56. inferencekit/prompts/__init__.py +167 -0
  57. inferencekit/prompts/build.py +518 -0
  58. inferencekit/prompts/dev-load.jsonl.gz +0 -0
  59. inferencekit/prompts/dev-load.manifest.json +212 -0
  60. inferencekit/prompts/dev.jsonl.gz +0 -0
  61. inferencekit/prompts/dev.manifest.json +214 -0
  62. inferencekit/prompts/exclusions.json +54 -0
  63. inferencekit/quality/__init__.py +23 -0
  64. inferencekit/quality/client.py +66 -0
  65. inferencekit/quality/data/gsm8k_LICENSE.txt +21 -0
  66. inferencekit/quality/fake.py +329 -0
  67. inferencekit/quality/gsm8k.py +224 -0
  68. inferencekit/quality/logprobs.py +106 -0
  69. inferencekit/quality/metrics.py +235 -0
  70. inferencekit/quality/niah.py +63 -0
  71. inferencekit/quality/parquet.py +296 -0
  72. inferencekit/quality/sampling.py +262 -0
  73. inferencekit/quality/step_replay.py +217 -0
  74. inferencekit/quality/video_metrics.py +304 -0
  75. inferencekit/registry.py +206 -0
  76. inferencekit/review/__init__.py +6 -0
  77. inferencekit/review/diff.py +163 -0
  78. inferencekit/review/media.py +181 -0
  79. inferencekit/review/page.py +920 -0
  80. inferencekit/review/rundir.py +844 -0
  81. inferencekit/roofline_headers.py +504 -0
  82. inferencekit/schemas/evidence.v1.json +130 -0
  83. inferencekit/schemas/evidence.v2.json +499 -0
  84. inferencekit/schemas/intake.v1.json +276 -0
  85. inferencekit/schemas/run-manifest.v1.json +118 -0
  86. inferencekit/serve/__init__.py +8 -0
  87. inferencekit/serve/audio_client.py +330 -0
  88. inferencekit/serve/client.py +446 -0
  89. inferencekit/serve/clip_client.py +351 -0
  90. inferencekit/serve/clip_rounds.py +429 -0
  91. inferencekit/serve/image_client.py +373 -0
  92. inferencekit/serve/loadgen.py +136 -0
  93. inferencekit/serve/metrics.py +203 -0
  94. inferencekit/serve/profiles.py +105 -0
  95. inferencekit/serve/prompts_io.py +125 -0
  96. inferencekit/serve/rounds.py +1111 -0
  97. inferencekit/serve/servers.py +897 -0
  98. inferencekit/stats.py +249 -0
  99. inferencekit_cli-0.1.0.dist-info/METADATA +233 -0
  100. inferencekit_cli-0.1.0.dist-info/RECORD +104 -0
  101. inferencekit_cli-0.1.0.dist-info/WHEEL +4 -0
  102. inferencekit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  103. inferencekit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  104. inferencekit_cli-0.1.0.dist-info/licenses/NOTICE +9 -0
@@ -0,0 +1,153 @@
1
+ """correctness: does an optimisation change what the model generates?
2
+
3
+ With greedy decoding (temperature 0) the same model and prompt should give the
4
+ same tokens. Faster kernels change floating-point summation order, so a near-tie
5
+ between the top two logits can flip, and from that position on the two sequences
6
+ follow different contexts. So the useful numbers are:
7
+ * exact match rate: sequences identical end to end;
8
+ * first-divergence index per sequence (later = smaller numerical difference);
9
+ * prefix agreement: mean of (first divergence / length);
10
+ * position-wise agreement (counts matches even after divergence; shown, not judged).
11
+ Optional: per-token logprobs within the agreeing prefix, checked against a tolerance.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import statistics
18
+ from pathlib import Path
19
+
20
+ from inferencekit import evidence as ev
21
+ from inferencekit.registry import Arg, OpError, operation
22
+
23
+ HIST_BINS = [(0, 0), (1, 7), (8, 31), (32, 127), (128, 511), (512, None)]
24
+
25
+
26
+ def load_sequences(store: ev.Store, ref: str) -> tuple[list[dict], dict | None]:
27
+ """[{tokens, logprobs?}] from a JSONL file or an evidence record with data.tokens."""
28
+ p = Path(ref)
29
+ if p.is_file() and p.suffix in (".jsonl", ".json", ".txt"):
30
+ seqs = []
31
+ for n, line in enumerate(p.read_text().splitlines(), 1):
32
+ line = line.strip()
33
+ if not line:
34
+ continue
35
+ try:
36
+ obj = json.loads(line)
37
+ except ValueError:
38
+ raise OpError(f"{p}:{n}: not JSON") from None
39
+ if isinstance(obj, list):
40
+ seqs.append({"tokens": obj})
41
+ elif isinstance(obj, dict) and isinstance(obj.get("tokens"), list):
42
+ seqs.append({"tokens": obj["tokens"], "logprobs": obj.get("logprobs")})
43
+ else:
44
+ raise OpError(f"{p}:{n}: expected a list of token ids or {{\"tokens\": [...]}}")
45
+ return seqs, None
46
+ rec = store.load(ref)
47
+ tokens = rec.get("data", {}).get("tokens")
48
+ if not tokens:
49
+ raise OpError(f"{ref}: record has no data.tokens (run bench decode with record_tokens)")
50
+ if tokens and isinstance(tokens[0], list):
51
+ return [{"tokens": t} for t in tokens], rec
52
+ return [{"tokens": tokens}], rec
53
+
54
+
55
+ def first_divergence(a: list, b: list) -> int | None:
56
+ """Index of the first differing token; None if identical (including length)."""
57
+ for i, (x, y) in enumerate(zip(a, b)):
58
+ if x != y:
59
+ return i
60
+ return None if len(a) == len(b) else min(len(a), len(b))
61
+
62
+
63
+ def _bin_label(lo, hi):
64
+ return f"{lo}" if lo == hi else (f"{lo}+" if hi is None else f"{lo}-{hi}")
65
+
66
+
67
+ @operation("check_correctness", ("correctness",), "Greedy token agreement between a reference and a candidate: "
68
+ "exact matches, first-divergence positions, optional logprob tolerance.",
69
+ [Arg("reference", "string", "Evidence id (with data.tokens) or a JSONL file of token-id lists.",
70
+ required=True),
71
+ Arg("candidate", "string", "Evidence id or JSONL file, same order as the reference.", required=True),
72
+ Arg("min_prefix_agreement", "number", "PASS needs mean prefix agreement >= this (1.0 = identical).",
73
+ default=1.0),
74
+ Arg("logprob_tol", "number", "Max |logprob difference| allowed inside the agreeing prefix "
75
+ "(only when both inputs carry logprobs).")])
76
+ def check_correctness(call, reference, candidate, min_prefix_agreement, logprob_tol):
77
+ store = ev.Store()
78
+ ref, ref_rec = load_sequences(store, reference)
79
+ cand, cand_rec = load_sequences(store, candidate)
80
+ limitations, missing = [], []
81
+ if len(ref) != len(cand):
82
+ limitations.append(f"different sequence counts ({len(ref)} vs {len(cand)}): compared the first "
83
+ f"{min(len(ref), len(cand))}")
84
+ n = min(len(ref), len(cand))
85
+ divergences, prefix_fracs, posmatch, postotal = [], [], 0, 0
86
+ max_lp_diff = None
87
+ for r, c in zip(ref[:n], cand[:n]):
88
+ a, b = r["tokens"], c["tokens"]
89
+ d = first_divergence(a, b)
90
+ divergences.append(d)
91
+ length = max(len(a), len(b)) or 1
92
+ prefix_fracs.append((length if d is None else d) / length)
93
+ posmatch += sum(1 for x, y in zip(a, b) if x == y)
94
+ postotal += length
95
+ if r.get("logprobs") and c.get("logprobs"):
96
+ upto = len(a) if d is None else d
97
+ diffs = [abs(x - y) for x, y in zip(r["logprobs"][:upto], c["logprobs"][:upto])
98
+ if x is not None and y is not None]
99
+ if diffs:
100
+ max_lp_diff = max(max_lp_diff or 0.0, max(diffs))
101
+ if n == 0:
102
+ missing.append("sequences")
103
+ exact = sum(1 for d in divergences if d is None)
104
+ prefix = statistics.mean(prefix_fracs) if prefix_fracs else None
105
+ diverged = [d for d in divergences if d is not None]
106
+ hist = {}
107
+ for lo, hi in HIST_BINS:
108
+ hist[_bin_label(lo, hi)] = sum(1 for d in diverged if d >= lo and (hi is None or d <= hi))
109
+ hist["identical"] = exact
110
+
111
+ if n == 0:
112
+ verdict, why = "UNPROVEN", "no sequences to compare"
113
+ elif prefix is not None and prefix >= min_prefix_agreement - 1e-12:
114
+ verdict, why = "PASS", f"mean prefix agreement {prefix:.4f} >= {min_prefix_agreement}"
115
+ else:
116
+ verdict, why = "FAIL", f"mean prefix agreement {prefix:.4f} < {min_prefix_agreement}"
117
+ if logprob_tol is not None:
118
+ if max_lp_diff is None:
119
+ limitations.append("logprob tolerance requested but inputs carry no logprobs: not checked")
120
+ missing.append("logprobs")
121
+ if verdict == "PASS":
122
+ verdict, why = "UNPROVEN", why + "; logprob tolerance not checked"
123
+ elif max_lp_diff > logprob_tol:
124
+ verdict, why = "FAIL", f"max logprob difference {max_lp_diff:.3g} > {logprob_tol}"
125
+ if n == 1:
126
+ limitations.append("a single sequence: one near-tie decides the result; use several prompts")
127
+ for rec in (ref_rec, cand_rec):
128
+ problem = ev.integrity_problem(rec) if rec else None
129
+ if problem: # an edited input must not become a fresh, verifying PASS
130
+ limitations.append(problem)
131
+ missing.append("input integrity")
132
+ if verdict == "PASS":
133
+ verdict, why = "UNPROVEN", why + "; an input failed its integrity check"
134
+
135
+ metrics = [ev.metric("exact_match_pct", 100 * exact / n if n else None, "%", "rate", n=n),
136
+ ev.metric("prefix_agreement_pct", 100 * prefix if prefix is not None else None, "%", "mean", n=n),
137
+ ev.metric("positionwise_agreement_pct", 100 * posmatch / postotal if postotal else None, "%", "rate",
138
+ n=postotal)]
139
+ if diverged:
140
+ metrics.append(ev.metric("first_divergence_index", statistics.median(diverged), "tokens", "median",
141
+ n=len(diverged)))
142
+ if max_lp_diff is not None:
143
+ metrics.append(ev.metric("max_logprob_abs_diff", max_lp_diff, "nats", "max", n=n))
144
+ summary = f"{verdict}: {exact}/{n} sequences identical, prefix agreement " + (
145
+ f"{100 * prefix:.1f}%" if prefix is not None else "n/a")
146
+ ident = (cand_rec or ref_rec or {}).get("identity") or ev.identity()
147
+ record = ev.build(call, kind="correctness", claim_class="derived", summary=summary, identity_=ident,
148
+ metrics=metrics, missing=missing, limitations=limitations,
149
+ data={"verdict": verdict, "reason": why, "sequences": n,
150
+ "first_divergence": divergences[:1000], "divergence_histogram": hist,
151
+ "reference": ref_rec["evidence_id"] if ref_rec else str(reference),
152
+ "candidate": cand_rec["evidence_id"] if cand_rec else str(candidate)})
153
+ return store.save(record)
@@ -0,0 +1,486 @@
1
+ """doctor: what is this machine, and can it profile?
2
+
3
+ Run it first. Most "my numbers make no sense" problems are environmental: another
4
+ process on the GPU, clocks locked low, a profiler that cannot read hardware
5
+ counters, or an engine version different from the one you think you have.
6
+
7
+ Counter access is read from the driver setting and the process's capabilities, never
8
+ assumed from the user being root: root inside a container often lacks CAP_SYS_ADMIN, and
9
+ then ncu fails with ERR_NVGPUCTRPERM like any other user. ``--probe-profilers`` settles it
10
+ by profiling one tiny kernel with ncu and nsys (when they are installed).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from importlib import metadata
23
+ from pathlib import Path
24
+
25
+ from inferencekit import evidence as ev
26
+ from inferencekit.gpuinfo import clocks_state, driver_cuda_version, query_gpus
27
+ from inferencekit.hardware import discover_non_nvidia, profiler_route
28
+ from inferencekit.proc import run, tool_version, which
29
+ from inferencekit.registry import Arg, operation
30
+
31
+ ENGINE_PACKAGES = ["vllm", "sglang", "torch", "transformers", "triton", "flashinfer-python", "flash-attn",
32
+ "xformers", "tensorrt-llm", "llama-cpp-python", "diffusers", "mlx", "mlx-lm", "mlx-audio"]
33
+
34
+ # Runs inside another interpreter (an engine's venv) to read its package versions.
35
+ _VERSION_PROBE = """
36
+ import json, sys
37
+ from importlib import metadata
38
+ out = {}
39
+ for name in json.loads(sys.argv[1]):
40
+ try:
41
+ out[name] = metadata.version(name)
42
+ except metadata.PackageNotFoundError:
43
+ pass
44
+ print(json.dumps(out))
45
+ """
46
+
47
+
48
+ def engine_versions(python: str | None) -> dict:
49
+ if not python or os.path.realpath(python) == os.path.realpath(sys.executable):
50
+ found = {}
51
+ for name in ENGINE_PACKAGES:
52
+ try:
53
+ found[name] = metadata.version(name)
54
+ except metadata.PackageNotFoundError:
55
+ pass
56
+ return found
57
+ try:
58
+ p = subprocess.run([python, "-c", _VERSION_PROBE, json.dumps(ENGINE_PACKAGES)], capture_output=True,
59
+ text=True, timeout=120)
60
+ return json.loads(p.stdout.strip().splitlines()[-1]) if p.returncode == 0 else {}
61
+ except (OSError, ValueError, IndexError, subprocess.TimeoutExpired):
62
+ return {}
63
+
64
+
65
+ def llamacpp_version() -> dict | None:
66
+ """``llama-cli --version`` prints e.g. 'version: 4589 (1a2b3c4)' (build number and commit)."""
67
+ for name in ("llama-cli", "llama-bench", "llama-server"):
68
+ path = which(name)
69
+ if not path:
70
+ continue
71
+ r = run([path, "--version"], timeout=30)
72
+ m = re.search(r"version:\s*(\d+)\s*\(([0-9a-f]+)\)", r.stdout + r.stderr)
73
+ if m:
74
+ return {"binary": name, "build": m.group(1), "commit": m.group(2)}
75
+ return None
76
+
77
+
78
+ PARAMS_FILE = Path("/proc/driver/nvidia/params")
79
+ STATUS_FILE = Path("/proc/self/status")
80
+ CAP_SYS_ADMIN = 21 # bit of the CapEff mask in /proc/<pid>/status
81
+ PROBE_HINT = "run 'inferencekit doctor --probe-profilers' or one small 'ncu capture' to find out"
82
+
83
+
84
+ def profiling_admin_only(path: Path = PARAMS_FILE) -> int | None:
85
+ """The driver's RmProfilingAdminOnly (1: counters for admins only, 0: for everyone), None if unreadable."""
86
+ try:
87
+ m = re.search(r"RmProfilingAdminOnly:\s*(\d+)", path.read_text())
88
+ except OSError:
89
+ return None
90
+ return int(m.group(1)) if m else None
91
+
92
+
93
+ def has_cap_sys_admin(path: Path = STATUS_FILE) -> bool | None:
94
+ """Whether this process's effective capabilities include CAP_SYS_ADMIN, None if unreadable."""
95
+ try:
96
+ m = re.search(r"^CapEff:\s*([0-9a-fA-F]+)\s*$", path.read_text(), re.M)
97
+ except OSError:
98
+ return None
99
+ return bool(int(m.group(1), 16) >> CAP_SYS_ADMIN & 1) if m else None
100
+
101
+
102
+ def counter_permission(params_path: Path = PARAMS_FILE, status_path: Path = STATUS_FILE,
103
+ euid: int | None = None) -> dict:
104
+ """Can this process read GPU performance counters (needed by ncu)? Status allowed | admin_only | unknown.
105
+
106
+ The NVIDIA driver parameter RmProfilingAdminOnly=1 (the Linux default) restricts counters to admin
107
+ users, which the driver checks as CAP_SYS_ADMIN, not as uid 0; ncu then fails with ERR_NVGPUCTRPERM.
108
+ So the answer follows the capability: only RmProfilingAdminOnly=0 gives ``allowed``; under
109
+ RmProfilingAdminOnly=1 a process without CAP_SYS_ADMIN (a container's root usually) is
110
+ ``admin_only``, and one with it (root on a VM or bare-metal host, or a non-root process granted the
111
+ capability) is ``unknown`` until a capture is tried (``--probe-profilers``): ncu usually works there,
112
+ but a container runtime or hypervisor can still refuse. Without the parameter the answer is
113
+ ``unknown`` too. Nothing is profiled here.
114
+ """
115
+ if euid is None:
116
+ euid = os.geteuid() if hasattr(os, "geteuid") else None
117
+ admin_only = profiling_admin_only(params_path)
118
+ cap = has_cap_sys_admin(status_path)
119
+ facts = {"rm_profiling_admin_only": admin_only, "euid": euid, "cap_sys_admin": cap}
120
+ who = "root" if euid == 0 else "non-root"
121
+ if admin_only == 0:
122
+ return {"status": "allowed", "reason": "RmProfilingAdminOnly=0: the driver lets every user read counters",
123
+ **facts}
124
+ if admin_only is not None:
125
+ if cap:
126
+ return {"status": "unknown", "reason": f"RmProfilingAdminOnly=1 and this {who} process has CAP_SYS_ADMIN, "
127
+ "which the driver asks for: ncu is expected to work, but a "
128
+ f"container or VM can still refuse (probe needed: {PROBE_HINT})",
129
+ **facts}
130
+ if cap is False and euid == 0:
131
+ reason = ("RmProfilingAdminOnly=1 and this root process lacks CAP_SYS_ADMIN (usual in a container): "
132
+ "ncu fails with ERR_NVGPUCTRPERM")
133
+ elif cap is False:
134
+ reason = ("RmProfilingAdminOnly=1 and this process lacks CAP_SYS_ADMIN: ncu needs CAP_SYS_ADMIN (root "
135
+ "on a VM or bare-metal host, or a container started with it), or the driver option "
136
+ "NVreg_RestrictProfilingToAdminUsers=0")
137
+ elif euid == 0:
138
+ return {"status": "unknown", "reason": "RmProfilingAdminOnly=1 and this process's capabilities are "
139
+ f"unreadable, so CAP_SYS_ADMIN is unknown (probe needed: "
140
+ f"{PROBE_HINT})", **facts}
141
+ else:
142
+ reason = ("RmProfilingAdminOnly=1, and this non-root process's capabilities are unreadable: ncu "
143
+ "needs CAP_SYS_ADMIN, or the driver option NVreg_RestrictProfilingToAdminUsers=0")
144
+ return {"status": "admin_only", "reason": reason, **facts}
145
+ if euid == 0 and cap is False:
146
+ return {"status": "admin_only", "reason": "root without CAP_SYS_ADMIN and no readable RmProfilingAdminOnly "
147
+ "(drivers default it to 1): ncu is expected to fail with "
148
+ "ERR_NVGPUCTRPERM", **facts}
149
+ ncu = which("ncu")
150
+ if ncu:
151
+ r = run([ncu, "--query-metrics"], timeout=120)
152
+ if "ERR_NVGPUCTRPERM" in r.stdout + r.stderr:
153
+ return {"status": "admin_only", "reason": "ncu --query-metrics reported ERR_NVGPUCTRPERM", **facts}
154
+ if r.ok:
155
+ return {"status": "unknown", "reason": "no readable driver parameter; ncu can enumerate metrics but "
156
+ f"counter access is untested (probe needed: {PROBE_HINT})", **facts}
157
+ return {"status": "unknown", "reason": f"no readable /proc/driver/nvidia/params and no ncu (probe needed: "
158
+ f"{PROBE_HINT})", **facts}
159
+
160
+
161
+ # --------------------------------------------------------------------------- --probe-profilers
162
+
163
+ # Programs that launch one small CUDA kernel. First choice: the standard library only. ctypes loads the
164
+ # driver (libcuda.so.1, present on every NVIDIA host), the driver compiles a PTX kernel for whatever GPU this is
165
+ # (PTX is the portable assembly that CUDA compiles to), and the kernel doubles 1M floats. Fallbacks: torch in
166
+ # the engine's interpreter, then a .cu built with nvcc.
167
+ _DRIVER_PROGRAM = r'''import ctypes, sys
168
+ PTX = b"""
169
+ .version 6.0
170
+ .target sm_50
171
+ .address_size 64
172
+ .visible .entry twice(.param .u64 x, .param .u32 n)
173
+ {
174
+ .reg .pred p;
175
+ .reg .b32 r<5>;
176
+ .reg .f32 f<3>;
177
+ .reg .b64 rd<5>;
178
+ ld.param.u64 rd1, [x];
179
+ ld.param.u32 r1, [n];
180
+ mov.u32 r2, %ctaid.x;
181
+ mov.u32 r3, %ntid.x;
182
+ mov.u32 r4, %tid.x;
183
+ mad.lo.s32 r2, r2, r3, r4;
184
+ setp.ge.u32 p, r2, r1;
185
+ @p bra DONE;
186
+ cvta.to.global.u64 rd2, rd1;
187
+ mul.wide.u32 rd3, r2, 4;
188
+ add.s64 rd4, rd2, rd3;
189
+ ld.global.f32 f1, [rd4];
190
+ add.f32 f2, f1, f1;
191
+ st.global.f32 [rd4], f2;
192
+ DONE:
193
+ ret;
194
+ }
195
+ """
196
+ cuda = ctypes.CDLL("libcuda.so.1")
197
+ def check(rc, what):
198
+ if rc:
199
+ name = ctypes.c_char_p()
200
+ cuda.cuGetErrorName(rc, ctypes.byref(name))
201
+ sys.exit(what + ": " + (name.value or b"error").decode())
202
+ n = 1 << 20
203
+ dev, ctx, mod, fn, ptr = ctypes.c_int(), ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_uint64()
204
+ check(cuda.cuInit(0), "cuInit")
205
+ check(cuda.cuDeviceGet(ctypes.byref(dev), 0), "cuDeviceGet")
206
+ check(cuda.cuDevicePrimaryCtxRetain(ctypes.byref(ctx), dev), "cuDevicePrimaryCtxRetain")
207
+ check(cuda.cuCtxSetCurrent(ctx), "cuCtxSetCurrent")
208
+ check(cuda.cuModuleLoadData(ctypes.byref(mod), PTX), "cuModuleLoadData")
209
+ check(cuda.cuModuleGetFunction(ctypes.byref(fn), mod, b"twice"), "cuModuleGetFunction")
210
+ check(cuda.cuMemAlloc_v2(ctypes.byref(ptr), ctypes.c_size_t(4 * n)), "cuMemAlloc")
211
+ check(cuda.cuMemsetD32_v2(ptr, ctypes.c_uint(0x3F800000), ctypes.c_size_t(n)), "cuMemsetD32")
212
+ count = ctypes.c_uint32(n)
213
+ args = (ctypes.c_void_p * 2)(ctypes.cast(ctypes.byref(ptr), ctypes.c_void_p),
214
+ ctypes.cast(ctypes.byref(count), ctypes.c_void_p))
215
+ u = ctypes.c_uint
216
+ check(cuda.cuLaunchKernel(fn, u((n + 255) // 256), u(1), u(1), u(256), u(1), u(1), u(0), None, args, None),
217
+ "cuLaunchKernel")
218
+ check(cuda.cuCtxSynchronize(), "cuCtxSynchronize")
219
+ out = ctypes.c_float()
220
+ check(cuda.cuMemcpyDtoH_v2(ctypes.byref(out), ptr, ctypes.c_size_t(4)), "cuMemcpyDtoH")
221
+ check(cuda.cuMemFree_v2(ptr), "cuMemFree")
222
+ if out.value != 2.0:
223
+ sys.exit("the kernel wrote %r, expected 2.0" % out.value)
224
+ print("ok")
225
+ '''
226
+ _TORCH_PROGRAM = ("import torch\n"
227
+ "x = torch.ones(1 << 20, device='cuda')\n"
228
+ "y = (x * 2.0).sum()\n"
229
+ "torch.cuda.synchronize()\n"
230
+ "print(float(y))\n")
231
+ _CU_PROGRAM = r"""#include <cstdio>
232
+ __global__ void twice(float *x, int n) {
233
+ int i = blockIdx.x * blockDim.x + threadIdx.x;
234
+ if (i < n) x[i] = 2.0f * x[i];
235
+ }
236
+ int main() {
237
+ const int n = 1 << 20;
238
+ float *x = nullptr;
239
+ if (cudaMalloc(&x, n * sizeof(float)) != cudaSuccess) { printf("cudaMalloc failed\n"); return 2; }
240
+ twice<<<(n + 255) / 256, 256>>>(x, n);
241
+ cudaError_t e = cudaDeviceSynchronize();
242
+ cudaFree(x);
243
+ if (e != cudaSuccess) { printf("%s\n", cudaGetErrorString(e)); return 3; }
244
+ printf("ok\n");
245
+ return 0;
246
+ }
247
+ """
248
+ NCU_METRIC = "sm__cycles_elapsed.avg"
249
+ PROBE_TIMEOUT_S = 300
250
+
251
+
252
+ def _tail(r, n: int = 300) -> str:
253
+ lines = [ln.strip() for ln in (r.stdout + "\n" + r.stderr).splitlines() if ln.strip()]
254
+ errs = [ln for ln in lines if "ERR_" in ln or "==ERROR==" in ln or "error" in ln.lower()]
255
+ return ev.redact_text(" | ".join((errs or lines)[-3:]))[:n] or f"exit {r.returncode}"
256
+
257
+
258
+ def _probe_program(python: str | None, work: Path) -> tuple[list[str] | None, str]:
259
+ """(argv of a program that runs one small CUDA kernel and exits 0, what it is); argv None when no
260
+ candidate ran without a profiler (then the profilers are not tried: a failure would say nothing)."""
261
+ tried = []
262
+ driver = work / "probe_kernel.py"
263
+ driver.write_text(_DRIVER_PROGRAM)
264
+ argv = [sys.executable, "-I", str(driver)]
265
+ r = run(argv, timeout=PROBE_TIMEOUT_S)
266
+ if r.ok:
267
+ return argv, "one PTX kernel launched through the CUDA driver API (Python standard library only)"
268
+ tried.append(f"driver API program: {_tail(r, 160)}")
269
+ py = python or sys.executable
270
+ r = run([py, "-c", _TORCH_PROGRAM], timeout=PROBE_TIMEOUT_S)
271
+ if r.ok:
272
+ return [py, "-c", _TORCH_PROGRAM], f"torch in {Path(py).name}: one elementwise kernel and a reduction"
273
+ tried.append(f"torch program: {_tail(r, 160)}")
274
+ why = "; ".join(tried)
275
+ nvcc = which("nvcc")
276
+ if not nvcc:
277
+ return None, f"no CUDA program could run ({why}; no nvcc to build one)"
278
+ src, exe = work / "probe.cu", work / "probe"
279
+ src.write_text(_CU_PROGRAM)
280
+ b = run([nvcc, "-o", str(exe), str(src)], timeout=PROBE_TIMEOUT_S)
281
+ if not b.ok:
282
+ return None, f"no CUDA program could run ({why}; nvcc build failed: {_tail(b, 160)})"
283
+ r = run([str(exe)], timeout=PROBE_TIMEOUT_S)
284
+ if not r.ok:
285
+ return None, f"no CUDA program could run ({why}; the nvcc-built kernel failed: {_tail(r, 160)})"
286
+ return [str(exe)], "a one-kernel CUDA program built with nvcc"
287
+
288
+
289
+ def _probe_ncu(ncu: str, program: list[str]) -> dict:
290
+ t0 = time.monotonic()
291
+ r = run([ncu, "--launch-count", "1", "--metrics", NCU_METRIC, *program], timeout=PROBE_TIMEOUT_S)
292
+ out = r.stdout + r.stderr
293
+ res = {"ran": True, "returncode": r.returncode, "seconds": round(time.monotonic() - t0, 2)}
294
+ if "ERR_NVGPUCTRPERM" in out:
295
+ return {**res, "capture_ok": False, "error": "ERR_NVGPUCTRPERM: no permission to read GPU counters"}
296
+ value = re.search(re.escape(NCU_METRIC) + r"\s+\S+\s+([\d.,]+)", out)
297
+ if r.ok and value:
298
+ return {**res, "capture_ok": True, "error": None, "metric": NCU_METRIC,
299
+ "value": float(value.group(1).replace(",", ""))}
300
+ if r.ok and NCU_METRIC in out and "No kernels were profiled" not in out:
301
+ return {**res, "capture_ok": True, "error": None, "metric": NCU_METRIC, "value": None}
302
+ return {**res, "capture_ok": False, "error": _tail(r)}
303
+
304
+
305
+ def _probe_nsys(nsys: str, program: list[str], work: Path) -> dict:
306
+ t0 = time.monotonic()
307
+ out = work / "probe"
308
+ r = run([nsys, "profile", "--trace=cuda", "--sample=none", "--cpuctxsw=none", "--force-overwrite=true",
309
+ "-o", str(out), *program], timeout=PROBE_TIMEOUT_S)
310
+ res = {"ran": True, "returncode": r.returncode, "seconds": round(time.monotonic() - t0, 2)}
311
+ report = out.with_suffix(".nsys-rep")
312
+ if r.ok and report.is_file():
313
+ return {**res, "capture_ok": True, "error": None, "report_bytes": report.stat().st_size}
314
+ if r.ok:
315
+ return {**res, "capture_ok": False, "error": "nsys exited 0 but wrote no .nsys-rep report"}
316
+ return {**res, "capture_ok": False, "error": _tail(r)}
317
+
318
+
319
+ def profiler_probe(python: str | None) -> dict:
320
+ """Profile one tiny kernel with ncu (one counter) and nsys (a CUDA trace), when each is installed.
321
+
322
+ ``capture_ok`` per tool: True when the capture worked, False with ``error`` when it failed, None when
323
+ it was not tried (tool missing, or no CUDA program could run). Never raises: a probe failure is a finding.
324
+ """
325
+ tools = {"ncu": which("ncu"), "nsys": which("nsys")}
326
+ out: dict = {"program": None, "ncu": None, "nsys": None}
327
+ if not any(tools.values()):
328
+ for name in tools:
329
+ out[name] = {"ran": False, "capture_ok": None, "error": f"{name} not found"}
330
+ return out
331
+ try:
332
+ work = ev.Store().staging() # under the evidence store, removed below: nothing of the probe is kept
333
+ except OSError as e:
334
+ for name in tools:
335
+ out[name] = {"ran": False, "capture_ok": None, "error": f"no scratch directory for the probe: {e}"[:300]}
336
+ return out
337
+ try:
338
+ program, how = _probe_program(python, work)
339
+ out["program" if program else "program_error"] = how
340
+ for name, path in tools.items():
341
+ if not path:
342
+ out[name] = {"ran": False, "capture_ok": None, "error": f"{name} not found"}
343
+ elif program is None:
344
+ out[name] = {"ran": False, "capture_ok": None, "error": how}
345
+ else:
346
+ try:
347
+ out[name] = _probe_ncu(path, program) if name == "ncu" else _probe_nsys(path, program, work)
348
+ except Exception as e: # noqa: BLE001 -- the probe must never fail doctor
349
+ out[name] = {"ran": True, "capture_ok": False, "error": f"{type(e).__name__}: {e}"[:300]}
350
+ except Exception as e: # noqa: BLE001
351
+ out["error"] = f"{type(e).__name__}: {e}"[:300]
352
+ finally:
353
+ shutil.rmtree(work, ignore_errors=True)
354
+ return out
355
+
356
+
357
+ def gpu_processes() -> list[dict]:
358
+ """Other compute processes on the GPU: they steal bandwidth and skew timings."""
359
+ smi = which("nvidia-smi")
360
+ if not smi:
361
+ return []
362
+ r = run([smi, "--query-compute-apps=pid,process_name,used_memory", "--format=csv,noheader,nounits"], timeout=30)
363
+ out = []
364
+ for line in r.stdout.strip().splitlines():
365
+ parts = [p.strip() for p in line.split(",")]
366
+ if len(parts) == 3:
367
+ out.append({"pid": parts[0], "name": Path(parts[1]).name, "used_mib": parts[2]})
368
+ return out
369
+
370
+
371
+ @operation("doctor", ("doctor",), "Hardware identity and profiling route (NVIDIA, AMD ROCm, Apple Metal); "
372
+ "selected AMD/Apple OS facts; NVIDIA driver, CUDA, clocks, nsys/ncu and profiling-counter permission "
373
+ "(driver setting and CAP_SYS_ADMIN, never assumed from root), engine versions; optionally a tiny ncu and "
374
+ "nsys test capture.",
375
+ [Arg("python", "string", "Interpreter of the engine's environment, to read its package versions "
376
+ "(default: this interpreter). --probe-profilers falls back to its torch when the standard-library "
377
+ "test program cannot run."),
378
+ Arg("probe_profilers", "boolean", "Also profile one tiny CUDA kernel (a standard-library driver-API "
379
+ "program; falls back to torch in --python, then nvcc) with ncu (--launch-count 1, one counter) and "
380
+ "with nsys, when installed, and record whether each capture worked. Each step stops after "
381
+ f"{PROBE_TIMEOUT_S // 60} minutes; a failed capture never fails doctor.", default=False)])
382
+ def doctor(call, python, probe_profilers=False):
383
+ gpus = query_gpus()
384
+ hardware = None if gpus else discover_non_nvidia()
385
+ if hardware and (hardware["devices"] or hardware["platform"] == "unknown"):
386
+ return _vendor_doctor(call, python, probe_profilers, hardware)
387
+ limitations, missing = [], []
388
+ if not gpus:
389
+ missing.append("gpu")
390
+ limitations.append("nvidia-smi not found or no GPU visible: no GPU facts recorded")
391
+ tools = {name: tool_version(name) for name in ("nvcc", "nsys", "ncu")}
392
+ for name, v in tools.items():
393
+ if v is None:
394
+ missing.append(name)
395
+ perm = counter_permission()
396
+ probe = profiler_probe(python) if probe_profilers else None
397
+ if probe is not None:
398
+ ncu = probe.get("ncu") or {}
399
+ if ncu.get("capture_ok"):
400
+ perm = {**perm, "status": "allowed", "reason": f"ncu read {NCU_METRIC} on a test kernel "
401
+ "(--probe-profilers)", "static_reason": perm["reason"]}
402
+ elif ncu.get("capture_ok") is False and "ERR_NVGPUCTRPERM" in (ncu.get("error") or ""):
403
+ perm = {**perm, "status": "admin_only", "reason": "ncu test capture failed with ERR_NVGPUCTRPERM "
404
+ "(--probe-profilers)", "static_reason": perm["reason"]}
405
+ for name in ("ncu", "nsys"):
406
+ r = probe.get(name) or {}
407
+ if r.get("capture_ok") is False:
408
+ limitations.append(f"{name} test capture failed: {r.get('error')}")
409
+ elif r.get("capture_ok") is None and tools.get(name):
410
+ limitations.append(f"{name} test capture not tried: {r.get('error')}")
411
+ if perm["status"] == "admin_only":
412
+ limitations.append(f"profiling counters are admin-only: 'ncu capture' fails without admin rights "
413
+ f"(CAP_SYS_ADMIN); {perm['reason']}")
414
+ elif perm["status"] == "unknown":
415
+ limitations.append(f"profiling counter access unknown: {perm['reason']}")
416
+ engines = engine_versions(python)
417
+ llama = llamacpp_version()
418
+ procs = gpu_processes()
419
+ if procs:
420
+ limitations.append(f"{len(procs)} compute process(es) already on the GPU: timings will be contended")
421
+ clocks = clocks_state(gpus[0]) if gpus else None
422
+ if clocks == "default":
423
+ limitations.append("clock lock state read from application clocks; locks set with 'nvidia-smi -lgc' are "
424
+ "not visible on every driver")
425
+ gpu_rows = [{k: g.get(k) for k in ("index", "name", "memory.total", "compute_cap", "pstate", "persistence_mode",
426
+ "clocks.sm", "clocks.mem", "clocks.max.sm", "clocks.max.mem", "power.limit",
427
+ "temperature.gpu")} for g in gpus]
428
+ ident = ev.identity(gpu={"name": gpus[0].get("name") if gpus else None, "count": len(gpus) or None,
429
+ "driver": gpus[0].get("driver_version") if gpus else None,
430
+ "cuda": driver_cuda_version(), "clocks": clocks})
431
+ name = gpus[0]["name"] if gpus else "no GPU"
432
+ counters = perm["status"] + (" (probe needed)" if perm["status"] == "unknown" else "")
433
+ summary = (f"{len(gpus)}x {name}; nsys {tools['nsys'] or 'missing'}, ncu {tools['ncu'] or 'missing'}; "
434
+ f"counters {counters}; engines: {', '.join(f'{k} {v}' for k, v in engines.items()) or 'none'}")
435
+ if probe is not None:
436
+ words = {True: "ok", False: "FAILED", None: "not tried"}
437
+ summary += "; test capture: " + ", ".join(f"{t} {words[(probe.get(t) or {}).get('capture_ok')]}"
438
+ for t in ("ncu", "nsys"))
439
+ data = {"hardware": hardware or {"platform": "nvidia", "devices": gpu_rows},
440
+ "profiling_route": profiler_route("nvidia" if gpus else "cpu"), "gpus": gpu_rows, "tools": tools, "nvcc_toolkit": tools["nvcc"],
441
+ "profiling_counters": perm, "engines": engines, "llamacpp": llama,
442
+ "gpu_processes": procs,
443
+ # names only: which relevant variables are set, never their values
444
+ "env_set": sorted(k for k in ("CUDA_VISIBLE_DEVICES", "CUDA_HOME", "HF_HOME",
445
+ "VLLM_ATTENTION_BACKEND", "HF_TOKEN") if k in os.environ)}
446
+ if probe is not None:
447
+ data["profiler_probe"] = probe
448
+ record = ev.build(call, kind="environment", claim_class="observed", summary=summary, identity_=ident,
449
+ metrics=[ev.metric("gpu_count", len(gpus), "count", "observed")], missing=missing,
450
+ limitations=limitations, data=data)
451
+ return ev.Store().save(record)
452
+
453
+
454
+ def _vendor_doctor(call, python, probe_profilers, hardware):
455
+ """Report observed vendor facts without running CUDA-only permission/probe code."""
456
+ backend, devices = hardware["platform"], hardware["devices"]
457
+ names = ("rocprofv3", "amd-smi", "hipcc") if backend == "rocm" else ("xcrun",) if backend == "metal" else ()
458
+ tools = {name: tool_version(name) for name in names}
459
+ limitations = list(hardware["limitations"])
460
+ limitations += ["NVIDIA-only ceiling bw and nsys/ncu capture are NOT RUN on this platform.",
461
+ "Other accelerator processes and clock state are not automatically checked here; inspect "
462
+ "vendor tools and record an uncontended device before timing."]
463
+ probe = None
464
+ if probe_profilers:
465
+ probe = {"program": None, "ncu": {"ran": False, "capture_ok": None, "error": "NVIDIA-only probe"},
466
+ "nsys": {"ran": False, "capture_ok": None, "error": "NVIDIA-only probe"}}
467
+ limitations.append("--probe-profilers is NVIDIA-only; vendor capture support is NOT RUN.")
468
+ data = {"gpus": devices, "hardware": hardware, "tools": tools, "nvcc_toolkit": None,
469
+ "profiling_route": profiler_route(backend),
470
+ "profiling_counters": {"status": "not_applicable", "reason": "NVIDIA permission checks do not "
471
+ "apply; vendor counter support/permission is untested"},
472
+ "engines": engine_versions(python), "llamacpp": llamacpp_version(), "gpu_processes": [],
473
+ "env_set": sorted(k for k in ("HIP_VISIBLE_DEVICES", "ROCR_VISIBLE_DEVICES", "CUDA_VISIBLE_DEVICES",
474
+ "ROCM_PATH", "MTL_CAPTURE_ENABLED", "HF_HOME", "HF_TOKEN")
475
+ if k in os.environ)}
476
+ if probe is not None:
477
+ data["profiler_probe"] = probe
478
+ record = ev.build(call, kind="environment", claim_class="observed",
479
+ summary=(f"{len(devices)}x {devices[0]['name']}" if devices else "unknown accelerator count/identity") + "; "
480
+ + f"platform {backend}; vendor profiling support untested",
481
+ identity_=ev.identity(gpu={"name": devices[0]["name"] if devices else None, "count": len(devices) or None,
482
+ "driver": None, "cuda": None, "clocks": None}),
483
+ metrics=[ev.metric("gpu_count", len(devices), "count", "observed")] if devices else [],
484
+ missing=["profiling counter permission", "gpu processes", "gpu clocks"],
485
+ limitations=limitations, data=data)
486
+ return ev.Store().save(record)