hotkernel 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.
- hotkernel/__init__.py +8 -0
- hotkernel/gpus.py +159 -0
- hotkernel/server.py +975 -0
- hotkernel-0.1.0.dist-info/METADATA +117 -0
- hotkernel-0.1.0.dist-info/RECORD +8 -0
- hotkernel-0.1.0.dist-info/WHEEL +4 -0
- hotkernel-0.1.0.dist-info/entry_points.txt +2 -0
- hotkernel-0.1.0.dist-info/licenses/LICENSE +202 -0
hotkernel/server.py
ADDED
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
"""hotkernel MCP server.
|
|
2
|
+
|
|
3
|
+
Tools (all return JSON-serialisable dicts; errors come back as {"error": ...} so the agent
|
|
4
|
+
can read them instead of crashing):
|
|
5
|
+
|
|
6
|
+
gpu_info what card is in this machine, driver, memory, power limit, compute capability
|
|
7
|
+
roofline ridge point for this card; optionally where a kernel sits given flops/bytes
|
|
8
|
+
bench run a script n times, collect the JSON timing line it prints, return mean/std/min
|
|
9
|
+
torch_trace run a script under torch.profiler, return the top CUDA kernels by GPU time
|
|
10
|
+
profile run a command under Nsight Systems or Nsight Compute, return a parsed report
|
|
11
|
+
top_kernels read a saved profile report and return the n heaviest kernels
|
|
12
|
+
disasm PTX/SASS for a cubin/ptx file or a Triton-cache kernel via cuobjdump/nvdisasm
|
|
13
|
+
verify baseline vs candidate, interleaved, n runs each: speedup + verdict
|
|
14
|
+
(faster / slower / inconclusive / incorrect / invalid)
|
|
15
|
+
record write a verify result to results/<target>/ and append the LEADERBOARD row
|
|
16
|
+
|
|
17
|
+
Design rules:
|
|
18
|
+
* hotkernel's own environment stays light (no torch). Anything that needs the user's
|
|
19
|
+
PyTorch/Triton runs in the user's interpreter (HOTKERNEL_PYTHON or `python` on PATH).
|
|
20
|
+
* every timing result carries n, mean, std, min and the exact command, so the agent can
|
|
21
|
+
write an honest LEADERBOARD row ("measured, not claimed").
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import csv
|
|
27
|
+
import functools
|
|
28
|
+
import glob
|
|
29
|
+
import io
|
|
30
|
+
import json
|
|
31
|
+
import math
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import statistics
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import tempfile
|
|
39
|
+
import time
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
try: # mcp >= 2.0 renamed FastMCP to MCPServer; the decorator API is the same.
|
|
43
|
+
from mcp.server.mcpserver import MCPServer as _Server
|
|
44
|
+
except ImportError: # mcp 1.x
|
|
45
|
+
from mcp.server.fastmcp import FastMCP as _Server
|
|
46
|
+
|
|
47
|
+
from . import __version__, gpus
|
|
48
|
+
|
|
49
|
+
_INSTRUCTIONS = (
|
|
50
|
+
"GPU kernel profiling tools for the card in this machine. Start with gpu_info, then "
|
|
51
|
+
"bench(baseline, n>=5) for the number to beat, then torch_trace to find the dominant "
|
|
52
|
+
"kernel, roofline to classify it, disasm('triton-cache', kernel=...) to read its SASS. "
|
|
53
|
+
"bench and torch_trace run the script with HOTKERNEL_PYTHON (your torch/Triton "
|
|
54
|
+
"interpreter). profile needs nsys/ncu on PATH; without them use torch_trace. Every "
|
|
55
|
+
"timing carries n/mean/std; report speedups only against the repo baseline."
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
mcp = _Server("hotkernel", version=__version__, instructions=_INSTRUCTIONS)
|
|
59
|
+
except TypeError: # older mcp 1.x FastMCP without a `version` argument
|
|
60
|
+
mcp = _Server("hotkernel", instructions=_INSTRUCTIONS)
|
|
61
|
+
|
|
62
|
+
_DEFAULT_TIMEOUT = 600
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------- helpers
|
|
66
|
+
|
|
67
|
+
def _python() -> str:
|
|
68
|
+
return os.environ.get("HOTKERNEL_PYTHON") or shutil.which("python") or sys.executable
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _local_path(p: str | None) -> str | None:
|
|
72
|
+
"""An agent on Windows may talk to a server that runs inside WSL (claude mcp add ... wsl.exe)
|
|
73
|
+
and pass C:\\... paths. Map them to /mnt/<drive>/... when that mount exists; everything
|
|
74
|
+
else passes through untouched."""
|
|
75
|
+
if not p or sys.platform != "linux":
|
|
76
|
+
return p
|
|
77
|
+
m = re.match(r"^([A-Za-z]):[\\/](.*)$", p)
|
|
78
|
+
if m and Path("/mnt", m.group(1).lower()).is_dir():
|
|
79
|
+
return str(Path("/mnt", m.group(1).lower(), m.group(2).replace("\\", "/")))
|
|
80
|
+
return p
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run(cmd: list[str], cwd: str | None = None, timeout: int = _DEFAULT_TIMEOUT,
|
|
84
|
+
env: dict | None = None, max_out: int = 20000) -> dict:
|
|
85
|
+
"""Run a command and return its tail of stdout/stderr (max_out chars each, so a chatty
|
|
86
|
+
script cannot blow up the MCP response; tools that need whole files write them to disk)."""
|
|
87
|
+
t0 = time.perf_counter()
|
|
88
|
+
cmd = [_local_path(c) if isinstance(c, str) else c for c in cmd]
|
|
89
|
+
cwd = _local_path(cwd)
|
|
90
|
+
try:
|
|
91
|
+
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout,
|
|
92
|
+
env={**os.environ, **(env or {})}, check=False)
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return {"error": f"executable not found: {cmd[0]}", "cmd": cmd}
|
|
95
|
+
except subprocess.TimeoutExpired:
|
|
96
|
+
return {"error": f"timed out after {timeout}s", "cmd": cmd}
|
|
97
|
+
return {
|
|
98
|
+
"cmd": cmd, "returncode": p.returncode,
|
|
99
|
+
"stdout": p.stdout[-max_out:], "stderr": p.stderr[-max_out:],
|
|
100
|
+
"wall_s": round(time.perf_counter() - t0, 3),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _last_json_line(text: str) -> dict | None:
|
|
105
|
+
for line in reversed(text.splitlines()):
|
|
106
|
+
line = line.strip()
|
|
107
|
+
if line.startswith("{") and line.endswith("}"):
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(line)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
continue
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@functools.lru_cache(maxsize=4)
|
|
116
|
+
def _triton_bin_dir(py: str) -> Path | None:
|
|
117
|
+
"""Triton wheels ship ptxas/cuobjdump/nvdisasm under site-packages/triton/backends/nvidia/bin,
|
|
118
|
+
so a machine with torch+Triton can read SASS without a CUDA toolkit."""
|
|
119
|
+
r = _run([py, "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"], timeout=60)
|
|
120
|
+
if "error" in r or r["returncode"] != 0:
|
|
121
|
+
return None
|
|
122
|
+
d = Path(r["stdout"].strip()) / "triton" / "backends" / "nvidia" / "bin"
|
|
123
|
+
return d if d.is_dir() else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_TOOL_GLOBS: dict[str, list[str]] = {
|
|
127
|
+
# Linux / WSL2: apt installs under /opt/nvidia; `dpkg-deb -x` into ~/opt/nvidia keeps the
|
|
128
|
+
# same layout one level down. Windows: standalone Nsight installers and the CUDA toolkit.
|
|
129
|
+
"nsys": ["/opt/nvidia/nsight-systems/*/bin", "/opt/nvidia/nsight-systems-cli/*/bin",
|
|
130
|
+
"~/opt/nvidia/opt/nvidia/nsight-systems/*/bin",
|
|
131
|
+
"~/opt/nvidia/opt/nvidia/nsight-systems-cli/*/bin",
|
|
132
|
+
"/usr/local/cuda/bin", "/usr/local/cuda-*/bin",
|
|
133
|
+
"C:/Program Files/NVIDIA Corporation/Nsight Systems */target-windows-x64",
|
|
134
|
+
"C:/Program Files/NVIDIA GPU Computing Toolkit/CUDA/v*/bin"],
|
|
135
|
+
"ncu": ["/opt/nvidia/nsight-compute/*", "~/opt/nvidia/opt/nvidia/nsight-compute/*",
|
|
136
|
+
"/usr/local/cuda/bin", "/usr/local/cuda-*/bin",
|
|
137
|
+
"C:/Program Files/NVIDIA Corporation/Nsight Compute *",
|
|
138
|
+
"C:/Program Files/NVIDIA GPU Computing Toolkit/CUDA/v*/bin"],
|
|
139
|
+
"cuobjdump": ["/usr/local/cuda/bin", "/usr/local/cuda-*/bin",
|
|
140
|
+
"C:/Program Files/NVIDIA GPU Computing Toolkit/CUDA/v*/bin"],
|
|
141
|
+
}
|
|
142
|
+
_TOOL_GLOBS["nvdisasm"] = _TOOL_GLOBS["cuobjdump"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _find_tool(name: str) -> str | None:
|
|
146
|
+
"""PATH first, then HOTKERNEL_TOOL_DIRS, then the Triton wheel (for cuobjdump/nvdisasm),
|
|
147
|
+
then the usual Nsight/CUDA install locations. Newest version wins when several exist."""
|
|
148
|
+
path = shutil.which(name)
|
|
149
|
+
if path:
|
|
150
|
+
return path
|
|
151
|
+
dirs: list[Path] = [Path(d) for d in os.environ.get("HOTKERNEL_TOOL_DIRS", "").split(os.pathsep) if d]
|
|
152
|
+
if name in ("cuobjdump", "nvdisasm", "ptxas"):
|
|
153
|
+
tb = _triton_bin_dir(_python())
|
|
154
|
+
if tb:
|
|
155
|
+
dirs.append(tb)
|
|
156
|
+
for pattern in _TOOL_GLOBS.get(name, []):
|
|
157
|
+
dirs += [Path(p) for p in sorted(glob.glob(os.path.expanduser(pattern)), reverse=True)]
|
|
158
|
+
for d in dirs:
|
|
159
|
+
for exe in (name, name + ".exe"):
|
|
160
|
+
p = d / exe
|
|
161
|
+
if p.is_file():
|
|
162
|
+
return str(p)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _which_or_error(name: str) -> str | dict:
|
|
167
|
+
path = _find_tool(name)
|
|
168
|
+
if path:
|
|
169
|
+
return path
|
|
170
|
+
hint = {
|
|
171
|
+
"nsys": "Nsight Systems: Linux/WSL2 `apt install nsight-systems-<ver>` from NVIDIA's CUDA apt repo "
|
|
172
|
+
"(or `dpkg-deb -x` the .deb under ~/opt/nvidia); Windows: developer.nvidia.com/nsight-systems.",
|
|
173
|
+
"ncu": "Nsight Compute: Linux/WSL2 `apt install nsight-compute-<ver>` from NVIDIA's CUDA apt repo "
|
|
174
|
+
"(or `dpkg-deb -x` the .deb under ~/opt/nvidia); Windows: developer.nvidia.com/nsight-compute.",
|
|
175
|
+
}.get(name, "It ships with the CUDA toolkit; Triton wheels also bundle cuobjdump/nvdisasm "
|
|
176
|
+
"(set HOTKERNEL_PYTHON to the interpreter that has Triton).")
|
|
177
|
+
return {"error": f"{name} not found on PATH, HOTKERNEL_TOOL_DIRS, or the usual install dirs. {hint} "
|
|
178
|
+
f"Or set HOTKERNEL_TOOL_DIRS to the directory that holds {name}.",
|
|
179
|
+
"fallback": "torch_trace works without Nsight" if name in ("nsys", "ncu") else None}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --------------------------------------------------------------------------- tools
|
|
183
|
+
|
|
184
|
+
@mcp.tool()
|
|
185
|
+
def gpu_info() -> dict:
|
|
186
|
+
"""Identify the GPU(s) in this machine via nvidia-smi: name, driver, memory, power limit,
|
|
187
|
+
compute capability, max SM clock. Also returns the matching roofline table entry if known."""
|
|
188
|
+
q = "name,driver_version,memory.total,power.limit,compute_cap,clocks.max.sm"
|
|
189
|
+
r = _run(["nvidia-smi", f"--query-gpu={q}", "--format=csv,noheader,nounits"])
|
|
190
|
+
if "error" in r or r["returncode"] != 0:
|
|
191
|
+
return {"error": "nvidia-smi failed", "detail": r}
|
|
192
|
+
gpus_out = []
|
|
193
|
+
for row in csv.reader(io.StringIO(r["stdout"].strip())):
|
|
194
|
+
if len(row) < 6:
|
|
195
|
+
continue
|
|
196
|
+
name = row[0].strip()
|
|
197
|
+
spec = gpus.lookup(name)
|
|
198
|
+
gpus_out.append({
|
|
199
|
+
"name": name, "driver": row[1].strip(), "memory_mib": float(row[2]),
|
|
200
|
+
"power_limit_w": float(row[3]) if row[3].strip() not in ("", "[N/A]") else None,
|
|
201
|
+
"compute_cap": row[4].strip(), "max_sm_clock_mhz": float(row[5]),
|
|
202
|
+
"roofline_entry": spec.as_dict() if spec else None,
|
|
203
|
+
})
|
|
204
|
+
return {"gpus": gpus_out, "platform": sys.platform,
|
|
205
|
+
"wsl": "microsoft" in Path("/proc/version").read_text().lower()
|
|
206
|
+
if Path("/proc/version").exists() else False}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@mcp.tool()
|
|
210
|
+
def roofline(gpu: str = "", flops: float | None = None, bytes: float | None = None,
|
|
211
|
+
dtype: str = "bf16", measured_mem_gbs: float | None = None) -> dict:
|
|
212
|
+
"""Roofline for a GPU (default: the first GPU in this machine). Pass the kernel's flops and
|
|
213
|
+
bytes moved (bench()/verify() return them as roofline_inputs) to get the bound, the
|
|
214
|
+
attainable TFLOPS and attainable_ms from the spec sheet. Pass measured_mem_gbs (the
|
|
215
|
+
bandwidth a streaming copy reaches on this card under the repo's timer, see
|
|
216
|
+
docs/platform-matrix.md; 1501 on the reference RTX 5090) to also get attainable_ms_measured,
|
|
217
|
+
the floor a kernel can realistically get within 10% of. dtype: bf16 | fp16 | fp8 | fp32."""
|
|
218
|
+
if not gpu:
|
|
219
|
+
info = gpu_info()
|
|
220
|
+
if "error" in info or not info["gpus"]:
|
|
221
|
+
return {"error": "could not detect GPU; pass gpu='rtx5090' etc.", "detail": info}
|
|
222
|
+
gpu = info["gpus"][0]["name"]
|
|
223
|
+
spec = gpus.lookup(gpu)
|
|
224
|
+
if spec is None:
|
|
225
|
+
return {"error": f"no roofline entry for '{gpu}'", "known": sorted(gpus.SPECS)}
|
|
226
|
+
try:
|
|
227
|
+
return gpus.roofline(spec, flops, bytes, dtype, measured_mem_gbs=measured_mem_gbs)
|
|
228
|
+
except ValueError as e:
|
|
229
|
+
return {"error": str(e)}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@mcp.tool()
|
|
233
|
+
def bench(script: str, n: int = 5, args: list[str] | None = None, cwd: str | None = None,
|
|
234
|
+
timeout: int = _DEFAULT_TIMEOUT, warmup: int = 1) -> dict:
|
|
235
|
+
"""Run a Python script n times in the user's interpreter and aggregate its timing.
|
|
236
|
+
Contract: the script prints one JSON line to stdout containing at least {"ms": <float>}
|
|
237
|
+
and optionally {"ok": <bool>} for a correctness check. Returns mean/std/min over the n
|
|
238
|
+
timed runs plus every raw sample, so a LEADERBOARD row can cite n and seed."""
|
|
239
|
+
py = _python()
|
|
240
|
+
cmd = [py, script, *(args or [])]
|
|
241
|
+
samples: list[float] = []
|
|
242
|
+
oks: list[bool] = []
|
|
243
|
+
raw: list[dict] = []
|
|
244
|
+
for i in range(warmup + n):
|
|
245
|
+
r = _run(cmd, cwd=cwd, timeout=timeout)
|
|
246
|
+
if "error" in r:
|
|
247
|
+
return r
|
|
248
|
+
if r["returncode"] != 0:
|
|
249
|
+
return {"error": f"script failed on run {i}", "detail": r}
|
|
250
|
+
j = _last_json_line(r["stdout"])
|
|
251
|
+
if not j or "ms" not in j:
|
|
252
|
+
return {"error": "script did not print a JSON line with an 'ms' field",
|
|
253
|
+
"stdout_tail": r["stdout"][-2000:]}
|
|
254
|
+
if i < warmup:
|
|
255
|
+
continue
|
|
256
|
+
samples.append(float(j["ms"]))
|
|
257
|
+
oks.append(bool(j.get("ok", True)))
|
|
258
|
+
raw.append(j)
|
|
259
|
+
return {
|
|
260
|
+
"cmd": cmd, "n": n, "warmup": warmup,
|
|
261
|
+
"mean_ms": round(statistics.fmean(samples), 4),
|
|
262
|
+
"std_ms": round(statistics.pstdev(samples), 4) if n > 1 else 0.0,
|
|
263
|
+
"min_ms": round(min(samples), 4), "max_ms": round(max(samples), 4),
|
|
264
|
+
"all_ok": all(oks), "samples": raw,
|
|
265
|
+
"roofline_inputs": {k: raw[0].get(k) for k in ("min_bytes", "flops") if k in raw[0]},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
_TORCH_PROF_RUNNER = r'''
|
|
270
|
+
import json, os, runpy, sys
|
|
271
|
+
import torch
|
|
272
|
+
from torch.profiler import profile, ProfilerActivity
|
|
273
|
+
script, top = sys.argv[1], int(sys.argv[2])
|
|
274
|
+
sys.argv = [script] + sys.argv[3:]
|
|
275
|
+
# `python script.py` puts the script's directory first on sys.path; runpy does not. Targets
|
|
276
|
+
# import their sibling baseline / _timing module, so do the same here.
|
|
277
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(script)))
|
|
278
|
+
exit_code = 0
|
|
279
|
+
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True) as prof:
|
|
280
|
+
try:
|
|
281
|
+
runpy.run_path(script, run_name="__main__")
|
|
282
|
+
except SystemExit as e: # targets end with sys.exit(0 if ok else 1); keep profiling output
|
|
283
|
+
exit_code = int(e.code or 0) if isinstance(e.code, int) or e.code is None else 1
|
|
284
|
+
torch.cuda.synchronize()
|
|
285
|
+
def _dev_us(e):
|
|
286
|
+
v = getattr(e, "device_time_total", None) # torch >= 2.4
|
|
287
|
+
if v is None:
|
|
288
|
+
v = getattr(e, "cuda_time_total", 0.0) # older torch
|
|
289
|
+
return float(v or 0.0)
|
|
290
|
+
|
|
291
|
+
# torch 2.11: key_averages() mixes real GPU kernels (device_type CUDA) with CPU-side ops
|
|
292
|
+
# (aten::*, device_type CPU) whose device time is the inclusive time of the kernels they
|
|
293
|
+
# launched. Report the two separately so percentages are over kernel time only.
|
|
294
|
+
_RUNTIME_ROWS = ("cudaLaunchKernel", "cudaDeviceSynchronize", "cudaStreamSynchronize",
|
|
295
|
+
"cudaEventRecord", "cudaEventSynchronize", "cudaMemcpy", "cudaMemset",
|
|
296
|
+
"Lazy Function Loading", "Runtime Triggered Module Loading", "cudaFuncGetAttributes",
|
|
297
|
+
"cudaModuleGetLoadingMode", "cudaGetDevice", "cudaMalloc", "cudaFree")
|
|
298
|
+
kernels, ops = [], []
|
|
299
|
+
for e in prof.key_averages():
|
|
300
|
+
us = _dev_us(e)
|
|
301
|
+
if us <= 0:
|
|
302
|
+
continue
|
|
303
|
+
row = {"name": e.key, "device_time_us": round(us, 1), "calls": int(e.count),
|
|
304
|
+
"avg_us": round(us / max(int(e.count), 1), 2)}
|
|
305
|
+
if str(getattr(e, "device_type", "")).endswith("CUDA"):
|
|
306
|
+
kernels.append(row)
|
|
307
|
+
elif not e.key.startswith(_RUNTIME_ROWS):
|
|
308
|
+
shapes = getattr(e, "input_shapes", None)
|
|
309
|
+
if shapes:
|
|
310
|
+
row["input_shapes"] = str(shapes)[:200]
|
|
311
|
+
ops.append(row)
|
|
312
|
+
kernels.sort(key=lambda r: -r["device_time_us"])
|
|
313
|
+
ops.sort(key=lambda r: -r["device_time_us"])
|
|
314
|
+
total = sum(r["device_time_us"] for r in kernels) or 1.0
|
|
315
|
+
for r in kernels:
|
|
316
|
+
r["pct"] = round(100.0 * r["device_time_us"] / total, 2)
|
|
317
|
+
print("HOTKERNEL_JSON " + json.dumps({
|
|
318
|
+
"kernels": kernels[:top], "total_kernel_time_us": round(total, 1), "n_kernels": len(kernels),
|
|
319
|
+
"ops": ops[:top],
|
|
320
|
+
"ops_note": "inclusive GPU time per aten op (nested ops overlap; use kernels for shares)",
|
|
321
|
+
"script_exit_code": exit_code, "torch": torch.__version__}))
|
|
322
|
+
'''
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@mcp.tool()
|
|
326
|
+
def torch_trace(script: str, top: int = 15, args: list[str] | None = None,
|
|
327
|
+
cwd: str | None = None, timeout: int = _DEFAULT_TIMEOUT) -> dict:
|
|
328
|
+
"""Run a Python script under torch.profiler in the user's interpreter. Returns `kernels`
|
|
329
|
+
(real GPU kernels by device time, pct over total kernel time) and `ops` (aten ops with
|
|
330
|
+
input shapes, inclusive time). Works without Nsight installed; use this first."""
|
|
331
|
+
py = _python()
|
|
332
|
+
with tempfile.NamedTemporaryFile("w", suffix="_hotkernel_torchprof.py", delete=False) as f:
|
|
333
|
+
f.write(_TORCH_PROF_RUNNER)
|
|
334
|
+
runner = f.name
|
|
335
|
+
try:
|
|
336
|
+
# Targets skip their L2-flush scaffolding under this variable so it does not show up
|
|
337
|
+
# as the top kernel; timing tools (bench, verify) never set it.
|
|
338
|
+
r = _run([py, runner, script, str(top), *(args or [])], cwd=cwd, timeout=timeout,
|
|
339
|
+
env={"HOTKERNEL_NO_FLUSH": "1"})
|
|
340
|
+
finally:
|
|
341
|
+
try:
|
|
342
|
+
os.unlink(runner)
|
|
343
|
+
except OSError:
|
|
344
|
+
pass
|
|
345
|
+
if "error" in r:
|
|
346
|
+
return r
|
|
347
|
+
if r["returncode"] != 0:
|
|
348
|
+
return {"error": "script failed under torch.profiler", "detail": r}
|
|
349
|
+
for line in reversed(r["stdout"].splitlines()):
|
|
350
|
+
if line.startswith("HOTKERNEL_JSON "):
|
|
351
|
+
out = json.loads(line[len("HOTKERNEL_JSON "):])
|
|
352
|
+
for k in out.get("kernels", []):
|
|
353
|
+
k["short_name"] = _short_kernel_name(k["name"])
|
|
354
|
+
k["name"] = k["name"][:160]
|
|
355
|
+
out["cmd"] = r["cmd"]
|
|
356
|
+
out["note"] = ("whole-script profile (setup, reference, correctness check, warmup "
|
|
357
|
+
"included); the target's L2 flush is disabled here, so use bench()/"
|
|
358
|
+
"verify() for timing")
|
|
359
|
+
return out
|
|
360
|
+
return {"error": "no profiler output found", "stdout_tail": r["stdout"][-2000:]}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@mcp.tool()
|
|
364
|
+
def profile(cmd: list[str], tool: str = "nsys", out_dir: str = "results/profiles",
|
|
365
|
+
cwd: str | None = None, timeout: int = _DEFAULT_TIMEOUT,
|
|
366
|
+
ncu_kernel_regex: str = "", ncu_launch_count: int = 20) -> dict:
|
|
367
|
+
"""Run a command under Nsight Systems (tool='nsys') or Nsight Compute (tool='ncu') and
|
|
368
|
+
return the path of the report plus a parsed kernel summary. nsys gives the per-kernel time
|
|
369
|
+
breakdown; ncu gives per-kernel metrics (achieved occupancy, DRAM throughput, SM throughput)
|
|
370
|
+
and is much slower, so use ncu_kernel_regex to target one kernel."""
|
|
371
|
+
# out_dir is relative to cwd (the directory the command runs in); report paths come back
|
|
372
|
+
# absolute so the agent can pass them straight to top_kernels() or read them.
|
|
373
|
+
cwd, out_dir = _local_path(cwd), _local_path(out_dir) or out_dir
|
|
374
|
+
if cmd and cmd[0] in ("python", "python3", "py"):
|
|
375
|
+
cmd = [_python(), *cmd[1:]] # the interpreter that has the user's torch, not PATH's
|
|
376
|
+
base = Path(cwd) if cwd else Path.cwd()
|
|
377
|
+
out = Path(out_dir) if Path(out_dir).is_absolute() else base / out_dir
|
|
378
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
379
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
380
|
+
if tool == "nsys":
|
|
381
|
+
exe = _which_or_error("nsys")
|
|
382
|
+
if isinstance(exe, dict):
|
|
383
|
+
return exe
|
|
384
|
+
rep = str(out / f"nsys-{stamp}")
|
|
385
|
+
r = _run([exe, "profile", "--trace=cuda,nvtx", "--sample=none", "--cpuctxsw=none",
|
|
386
|
+
"-o", rep, "--force-overwrite", "true", *cmd], cwd=cwd, timeout=timeout,
|
|
387
|
+
env={"HOTKERNEL_NO_FLUSH": "1"}) # see torch_trace
|
|
388
|
+
if "error" in r or r["returncode"] != 0:
|
|
389
|
+
return {"error": "nsys profile failed", "detail": r}
|
|
390
|
+
# --output <base> makes nsys write <base>_cuda_gpu_kern_sum.csv itself, so a long kernel
|
|
391
|
+
# list is never cut by the stdout tail we keep.
|
|
392
|
+
stats = _run([exe, "stats", "--report", "cuda_gpu_kern_sum", "--format", "csv",
|
|
393
|
+
"--force-export", "true", "--output", rep, rep + ".nsys-rep"],
|
|
394
|
+
cwd=cwd, timeout=timeout)
|
|
395
|
+
if "error" in stats or stats["returncode"] != 0:
|
|
396
|
+
return {"error": "nsys stats failed", "detail": stats, "report": rep + ".nsys-rep"}
|
|
397
|
+
csv_path = rep + ".kern_sum.csv"
|
|
398
|
+
written = Path(rep + "_cuda_gpu_kern_sum.csv")
|
|
399
|
+
if written.exists():
|
|
400
|
+
written.replace(csv_path)
|
|
401
|
+
else: # older nsys that ignored --output: fall back to captured stdout
|
|
402
|
+
Path(csv_path).write_text(stats["stdout"])
|
|
403
|
+
parsed = top_kernels(csv_path, 10)
|
|
404
|
+
return {"tool": "nsys", "nsys": exe, "report": rep + ".nsys-rep", "summary_csv": csv_path,
|
|
405
|
+
"profile_wall_s": r["wall_s"], "top": parsed.get("kernels"),
|
|
406
|
+
"n_kernels_total": parsed.get("n_kernels_total"),
|
|
407
|
+
"total_gpu_time_ns": parsed.get("total_gpu_time_ns"),
|
|
408
|
+
"note": "kernel times include the whole script (setup, reference, correctness "
|
|
409
|
+
"check, warmup) and the target ran with HOTKERNEL_NO_FLUSH=1, so per-call "
|
|
410
|
+
"durations are warm-L2 numbers, shorter than bench()/verify() which flush "
|
|
411
|
+
"L2 before every call and are the numbers that count"}
|
|
412
|
+
if tool == "ncu":
|
|
413
|
+
exe = _which_or_error("ncu")
|
|
414
|
+
if isinstance(exe, dict):
|
|
415
|
+
return exe
|
|
416
|
+
rep = str(out / f"ncu-{stamp}")
|
|
417
|
+
ncu_cmd = [exe, "--set", "basic", "--launch-count", str(ncu_launch_count),
|
|
418
|
+
"-o", rep, "--force-overwrite"]
|
|
419
|
+
if ncu_kernel_regex:
|
|
420
|
+
ncu_cmd += ["--kernel-name", f"regex:{ncu_kernel_regex}"]
|
|
421
|
+
r = _run([*ncu_cmd, *cmd], cwd=cwd, timeout=timeout, env={"HOTKERNEL_NO_FLUSH": "1"})
|
|
422
|
+
if "error" in r:
|
|
423
|
+
return r
|
|
424
|
+
if "ERR_NVGPUCTRPERM" in (r["stdout"] + r["stderr"]):
|
|
425
|
+
return {
|
|
426
|
+
"error": "ncu ran but the driver refused GPU performance-counter access "
|
|
427
|
+
"(ERR_NVGPUCTRPERM). This is a one-time driver setting, not a hotkernel bug.",
|
|
428
|
+
"fix": {
|
|
429
|
+
"windows_or_wsl2": "On Windows (also for WSL2 guests): NVIDIA App > System > "
|
|
430
|
+
"Advanced > Developer > Manage GPU Performance Counters > allow "
|
|
431
|
+
"all users; or NVIDIA Control Panel (run as administrator) > "
|
|
432
|
+
"Desktop > Enable Developer Settings, then Developer > Manage GPU "
|
|
433
|
+
"Performance Counters > 'Allow access to the GPU performance "
|
|
434
|
+
"counters to all users'. No reboot needed.",
|
|
435
|
+
"linux": "/etc/modprobe.d/nvidia.conf: options nvidia "
|
|
436
|
+
"NVreg_RestrictProfilingToAdminUsers=0, then reboot (or run ncu as root).",
|
|
437
|
+
"docs": "https://developer.nvidia.com/ERR_NVGPUCTRPERM",
|
|
438
|
+
},
|
|
439
|
+
"fallback": "profile(tool='nsys') and torch_trace work without this permission.",
|
|
440
|
+
"detail": r,
|
|
441
|
+
}
|
|
442
|
+
if r["returncode"] != 0 or not Path(rep + ".ncu-rep").exists():
|
|
443
|
+
return {"error": "ncu failed", "detail": r}
|
|
444
|
+
# --log-file makes ncu write the CSV itself; a details page is one row per metric per
|
|
445
|
+
# launch (hundreds of KB for a few launches) and would not survive the stdout tail.
|
|
446
|
+
csv_path = rep + ".details.csv"
|
|
447
|
+
imp = _run([exe, "--import", rep + ".ncu-rep", "--csv", "--page", "details",
|
|
448
|
+
"--log-file", csv_path], cwd=cwd, timeout=timeout)
|
|
449
|
+
if "error" in imp or imp["returncode"] != 0 or not Path(csv_path).exists():
|
|
450
|
+
return {"error": "ncu --import failed", "detail": imp, "report": rep + ".ncu-rep"}
|
|
451
|
+
parsed = top_kernels(csv_path, 10, tool="ncu")
|
|
452
|
+
return {"tool": "ncu", "report": rep + ".ncu-rep", "details_csv": csv_path,
|
|
453
|
+
"top": parsed.get("kernels"), "parse_error": parsed.get("error"),
|
|
454
|
+
"note": "the target ran with HOTKERNEL_NO_FLUSH=1 and ncu replays each kernel "
|
|
455
|
+
"with its own cache control, so duration_us here is not comparable with "
|
|
456
|
+
"bench()/verify() (which flush L2 before every call); use ncu for "
|
|
457
|
+
"throughput %, occupancy and registers, verify() for time"}
|
|
458
|
+
return {"error": f"unknown tool '{tool}', use 'nsys' or 'ncu'"}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _num(s: str | None) -> float | None:
|
|
462
|
+
if s is None:
|
|
463
|
+
return None
|
|
464
|
+
s = s.strip().replace(",", "")
|
|
465
|
+
if s in ("", "n/a", "N/A", "-"):
|
|
466
|
+
return None
|
|
467
|
+
try:
|
|
468
|
+
return float(s)
|
|
469
|
+
except ValueError:
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _short_kernel_name(name: str) -> str:
|
|
474
|
+
"""'void at::native::elementwise_kernel<(int)128, ...>(int, T3)' -> 'elementwise_kernel';
|
|
475
|
+
Triton names ('rmsnorm_fwd_kernel') come back unchanged."""
|
|
476
|
+
junk = {"void", "std", "type", "enable_if", "internal", "anonymous", "namespace"}
|
|
477
|
+
# identifiers (with namespaces) that open a template or call: the first non-junk one is
|
|
478
|
+
# the kernel; cuBLAS names start with `std::enable_if<...>::type internal::gemvx::kernel<`.
|
|
479
|
+
for cand in re.findall(r"((?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*[<(]", name):
|
|
480
|
+
parts = cand.split("::")
|
|
481
|
+
if parts[-1] in junk or parts[0] in ("std",):
|
|
482
|
+
continue
|
|
483
|
+
return "::".join(parts[-2:]) if parts[-1] == "kernel" and len(parts) > 1 else parts[-1]
|
|
484
|
+
return name.strip()[:60]
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _top_kernels_nsys(p: Path, n: int) -> dict:
|
|
488
|
+
"""nsys stats --report cuda_gpu_kern_sum --format csv. Verified against nsys 2026.1.3:
|
|
489
|
+
header 'Time (%),Total Time (ns),Instances,Avg (ns),Med (ns),Min (ns),Max (ns),StdDev (ns),Name'
|
|
490
|
+
preceded by free-text 'Generating SQLite...' / 'Processing ...' lines."""
|
|
491
|
+
lines = p.read_text().splitlines()
|
|
492
|
+
start = next((i for i, l in enumerate(lines)
|
|
493
|
+
if l.startswith(("Time (%)", "\"Time (%)\""))), None)
|
|
494
|
+
if start is None:
|
|
495
|
+
return {"error": "could not find the nsys cuda_gpu_kern_sum header row", "head": lines[:5]}
|
|
496
|
+
rows = list(csv.DictReader(io.StringIO("\n".join(lines[start:]))))
|
|
497
|
+
out = []
|
|
498
|
+
for r in rows[:n]:
|
|
499
|
+
full = r.get("Name") or ""
|
|
500
|
+
row = {
|
|
501
|
+
"name": full[:160],
|
|
502
|
+
"short_name": _short_kernel_name(full),
|
|
503
|
+
"time_pct": _num(r.get("Time (%)")) or 0.0,
|
|
504
|
+
"total_ns": _num(r.get("Total Time (ns)")) or 0.0,
|
|
505
|
+
"calls": int(_num(r.get("Instances")) or 0),
|
|
506
|
+
"avg_ns": _num(r.get("Avg (ns)")) or 0.0,
|
|
507
|
+
}
|
|
508
|
+
for key, col in (("med_ns", "Med (ns)"), ("min_ns", "Min (ns)"), ("max_ns", "Max (ns)"),
|
|
509
|
+
("std_ns", "StdDev (ns)")):
|
|
510
|
+
v = _num(r.get(col))
|
|
511
|
+
if v is not None:
|
|
512
|
+
row[key] = v
|
|
513
|
+
out.append(row)
|
|
514
|
+
total_ns = sum(_num(r.get("Total Time (ns)")) or 0.0 for r in rows)
|
|
515
|
+
return {"tool": "nsys", "source": str(p), "n_kernels_total": len(rows),
|
|
516
|
+
"total_gpu_time_ns": total_ns, "kernels": out}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# Metric names as printed by `ncu --import <rep> --csv --page details` for --set basic.
|
|
520
|
+
_NCU_METRICS = {
|
|
521
|
+
"Duration": "duration",
|
|
522
|
+
"Achieved Occupancy": "achieved_occupancy_pct",
|
|
523
|
+
"Theoretical Occupancy": "theoretical_occupancy_pct",
|
|
524
|
+
"DRAM Throughput": "dram_throughput_pct",
|
|
525
|
+
"Memory Throughput": "memory_throughput_pct",
|
|
526
|
+
"Compute (SM) Throughput": "sm_throughput_pct",
|
|
527
|
+
"L1/TEX Cache Throughput": "l1_throughput_pct",
|
|
528
|
+
"L2 Cache Throughput": "l2_throughput_pct",
|
|
529
|
+
"Registers Per Thread": "registers_per_thread",
|
|
530
|
+
"Static Shared Memory Per Block": "static_smem_per_block",
|
|
531
|
+
"Dynamic Shared Memory Per Block": "dynamic_smem_per_block",
|
|
532
|
+
"Waves Per SM": "waves_per_sm",
|
|
533
|
+
"SM Frequency": "sm_frequency",
|
|
534
|
+
"DRAM Frequency": "dram_frequency",
|
|
535
|
+
"Elapsed Cycles": "elapsed_cycles",
|
|
536
|
+
}
|
|
537
|
+
_DURATION_TO_US = {"nsecond": 1e-3, "ns": 1e-3, "usecond": 1.0, "us": 1.0,
|
|
538
|
+
"msecond": 1e3, "ms": 1e3, "second": 1e6, "s": 1e6}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _top_kernels_ncu(p: Path, n: int) -> dict:
|
|
542
|
+
"""Parse the long-format CSV that `ncu --import <rep> --csv --page details` prints: one row
|
|
543
|
+
per (kernel launch, metric) with columns ID, Kernel Name, Block Size, Grid Size, Section Name,
|
|
544
|
+
Metric Name, Metric Unit, Metric Value. Written against the documented format and a synthetic
|
|
545
|
+
fixture (tests/); not yet verified on hardware because ncu needs the performance-counter
|
|
546
|
+
permission (ERR_NVGPUCTRPERM) that profile() reports."""
|
|
547
|
+
lines = p.read_text().splitlines()
|
|
548
|
+
start = next((i for i, l in enumerate(lines) if "\"Metric Name\"" in l or "Metric Name" in l), None)
|
|
549
|
+
if start is None:
|
|
550
|
+
return {"error": "could not find the ncu details CSV header (no 'Metric Name' column)",
|
|
551
|
+
"head": lines[:5]}
|
|
552
|
+
rows = list(csv.DictReader(io.StringIO("\n".join(lines[start:]))))
|
|
553
|
+
launches: dict[str, dict] = {}
|
|
554
|
+
for r in rows:
|
|
555
|
+
lid = r.get("ID") or r.get("ID") or ""
|
|
556
|
+
full = r.get("Kernel Name") or ""
|
|
557
|
+
k = launches.setdefault(lid, {
|
|
558
|
+
"id": lid, "name": full[:160], "short_name": _short_kernel_name(full),
|
|
559
|
+
"block_size": r.get("Block Size"), "grid_size": r.get("Grid Size"),
|
|
560
|
+
"device": r.get("Device"), "cc": r.get("CC"), "metrics": {},
|
|
561
|
+
})
|
|
562
|
+
mname = (r.get("Metric Name") or "").strip()
|
|
563
|
+
unit = (r.get("Metric Unit") or "").strip()
|
|
564
|
+
val = _num(r.get("Metric Value"))
|
|
565
|
+
rule = (r.get("Rule Name") or "").strip()
|
|
566
|
+
if rule and not mname: # ncu's own advice rows: keep them, agents find them useful
|
|
567
|
+
k.setdefault("hints", []).append({
|
|
568
|
+
"rule": rule, "type": r.get("Rule Type"),
|
|
569
|
+
"estimated_speedup_pct": _num(r.get("Estimated Speedup")),
|
|
570
|
+
"text": (r.get("Rule Description") or "").strip()[:400]})
|
|
571
|
+
continue
|
|
572
|
+
if not mname or val is None:
|
|
573
|
+
continue
|
|
574
|
+
key = _NCU_METRICS.get(mname)
|
|
575
|
+
if key == "duration":
|
|
576
|
+
k["duration_us"] = round(val * _DURATION_TO_US.get(unit, 1.0), 4)
|
|
577
|
+
elif key:
|
|
578
|
+
k["metrics"][key] = val
|
|
579
|
+
if unit and key not in ("dram_throughput_pct", "memory_throughput_pct",
|
|
580
|
+
"sm_throughput_pct", "achieved_occupancy_pct",
|
|
581
|
+
"theoretical_occupancy_pct", "l1_throughput_pct",
|
|
582
|
+
"l2_throughput_pct"):
|
|
583
|
+
k["metrics"][key + "_unit"] = unit
|
|
584
|
+
out = sorted(launches.values(), key=lambda k: -(k.get("duration_us") or 0.0))[:n]
|
|
585
|
+
for k in out:
|
|
586
|
+
m = k["metrics"]
|
|
587
|
+
dram, sm = m.get("dram_throughput_pct"), m.get("sm_throughput_pct")
|
|
588
|
+
if dram is not None and sm is not None:
|
|
589
|
+
k["bound_hint"] = ("memory" if dram >= sm else "compute") if max(dram, sm) >= 60 \
|
|
590
|
+
else "latency (neither DRAM nor SM above 60% of peak)"
|
|
591
|
+
return {"tool": "ncu", "source": str(p), "n_launches": len(launches), "kernels": out,
|
|
592
|
+
"hints_note": "`hints` are ncu's own rules with its estimated speedups; the estimates "
|
|
593
|
+
"assume the named limiter is the only one and run high for "
|
|
594
|
+
"memory-bound kernels (a 'partial last wave' rule can claim 30% on a "
|
|
595
|
+
"kernel already at 85% of DRAM bandwidth). Treat them as leads, verify() "
|
|
596
|
+
"decides."}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@mcp.tool()
|
|
600
|
+
def top_kernels(summary_csv: str, n: int = 10, tool: str = "nsys") -> dict:
|
|
601
|
+
"""Read a saved profile CSV (from profile()) and return the n heaviest kernels.
|
|
602
|
+
tool='nsys': cuda_gpu_kern_sum CSV -> share of GPU time, calls, avg/med/min/max/std ns.
|
|
603
|
+
tool='ncu': details CSV -> per-launch duration, achieved occupancy, DRAM / SM throughput %,
|
|
604
|
+
registers per thread, and a memory/compute/latency hint."""
|
|
605
|
+
p = Path(_local_path(summary_csv) or summary_csv)
|
|
606
|
+
if not p.exists():
|
|
607
|
+
return {"error": f"no such file: {summary_csv}"}
|
|
608
|
+
if tool == "nsys":
|
|
609
|
+
return _top_kernels_nsys(p, n)
|
|
610
|
+
if tool == "ncu":
|
|
611
|
+
return _top_kernels_ncu(p, n)
|
|
612
|
+
return {"error": f"unknown tool '{tool}', use 'nsys' or 'ncu'"}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@mcp.tool()
|
|
616
|
+
def disasm(target: str, level: str = "sass", kernel: str = "", max_lines: int = 1500) -> dict:
|
|
617
|
+
"""Disassemble a kernel. target is a .cubin/.ptx/.so/.exe path, or 'triton-cache' to search
|
|
618
|
+
~/.triton/cache (and TRITON_CACHE_DIR) for the most recent kernel whose name contains
|
|
619
|
+
`kernel`. level: 'sass' (via cuobjdump -sass) or 'ptx' (cuobjdump -ptx / the .ptx file).
|
|
620
|
+
A real kernel is 500-3000 SASS lines; max_lines<=0 returns all of them."""
|
|
621
|
+
if target == "triton-cache":
|
|
622
|
+
roots = [Path(os.environ.get("TRITON_CACHE_DIR", Path.home() / ".triton" / "cache"))]
|
|
623
|
+
cands: list[Path] = []
|
|
624
|
+
for root in roots:
|
|
625
|
+
if root.exists():
|
|
626
|
+
ext = "*.cubin" if level == "sass" else "*.ptx"
|
|
627
|
+
cands += [p for p in root.rglob(ext) if kernel in p.stem]
|
|
628
|
+
if not cands:
|
|
629
|
+
return {"error": f"no cached Triton {level} found for kernel '{kernel}'",
|
|
630
|
+
"searched": [str(r) for r in roots]}
|
|
631
|
+
path = max(cands, key=lambda p: p.stat().st_mtime)
|
|
632
|
+
else:
|
|
633
|
+
path = Path(_local_path(target) or target)
|
|
634
|
+
if not path.exists():
|
|
635
|
+
return {"error": f"no such file: {target}"}
|
|
636
|
+
if level == "ptx" and path.suffix == ".ptx":
|
|
637
|
+
text = path.read_text()
|
|
638
|
+
else:
|
|
639
|
+
exe = _which_or_error("cuobjdump")
|
|
640
|
+
if isinstance(exe, dict):
|
|
641
|
+
return exe
|
|
642
|
+
flag = "-sass" if level == "sass" else "-ptx"
|
|
643
|
+
# SASS for a real kernel is hundreds of KB; keep all of it, max_lines trims below.
|
|
644
|
+
r = _run([exe, flag, str(path)], max_out=50_000_000)
|
|
645
|
+
if "error" in r or r["returncode"] != 0:
|
|
646
|
+
return {"error": "cuobjdump failed", "detail": r}
|
|
647
|
+
text = r["stdout"]
|
|
648
|
+
lines = text.splitlines()
|
|
649
|
+
keep = len(lines) if max_lines <= 0 else max_lines
|
|
650
|
+
return {"path": str(path), "level": level, "n_lines": len(lines),
|
|
651
|
+
"text": "\n".join(lines[:keep]),
|
|
652
|
+
"truncated": len(lines) > keep,
|
|
653
|
+
**({"note": f"showing {keep} of {len(lines)} lines; pass max_lines=0 for all"}
|
|
654
|
+
if len(lines) > keep else {})}
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# --------------------------------------------------------------------------- verify / record
|
|
658
|
+
|
|
659
|
+
def _nvidia_smi_state() -> dict:
|
|
660
|
+
"""What a LEADERBOARD row has to cite: card, driver, power limit, and the clock/temperature
|
|
661
|
+
at the moment of the measurement."""
|
|
662
|
+
q = "name,driver_version,power.limit,clocks.sm,temperature.gpu"
|
|
663
|
+
r = _run(["nvidia-smi", f"--query-gpu={q}", "--format=csv,noheader,nounits"], timeout=30)
|
|
664
|
+
if "error" in r or r["returncode"] != 0:
|
|
665
|
+
return {"gpu": None, "driver": None, "power_limit_w": None}
|
|
666
|
+
row = [c.strip() for c in next(csv.reader(io.StringIO(r["stdout"].strip())), [])] + [""] * 5
|
|
667
|
+
return {"gpu": row[0] or None, "driver": row[1] or None, "power_limit_w": _num(row[2]),
|
|
668
|
+
"sm_clock_mhz": _num(row[3]), "temperature_c": _num(row[4])}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _run_target(cmd: list[str], cwd: str | None, timeout: int) -> dict:
|
|
672
|
+
"""One run of a contract script. A JSON line with ok=false and a non-zero exit is a valid
|
|
673
|
+
'incorrect' result, not a crash; no JSON line at all is an error."""
|
|
674
|
+
r = _run(cmd, cwd=cwd, timeout=timeout)
|
|
675
|
+
if "error" in r:
|
|
676
|
+
return r
|
|
677
|
+
j = _last_json_line(r["stdout"])
|
|
678
|
+
if not j or "ms" not in j:
|
|
679
|
+
return {"error": f"script exited {r['returncode']} without a JSON line carrying 'ms'",
|
|
680
|
+
"stdout_tail": r["stdout"][-2000:], "stderr_tail": r["stderr"][-2000:]}
|
|
681
|
+
return {"ms": float(j["ms"]), "ok": bool(j.get("ok", True)), "json": j, "wall_s": r["wall_s"]}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _stats(xs: list[float]) -> dict:
|
|
685
|
+
n = len(xs)
|
|
686
|
+
return {"n": n, "mean_ms": round(statistics.fmean(xs), 5),
|
|
687
|
+
"std_ms": round(statistics.stdev(xs), 5) if n > 1 else 0.0,
|
|
688
|
+
"min_ms": round(min(xs), 5), "max_ms": round(max(xs), 5),
|
|
689
|
+
"samples_ms": [round(x, 5) for x in xs]}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _decide(b: list[float], c: list[float], b_ok: list[bool], c_ok: list[bool],
|
|
693
|
+
mismatch: list[str]) -> dict:
|
|
694
|
+
"""The verdict rules, kept free of I/O so they can be unit-tested:
|
|
695
|
+
invalid the two scripts do not measure the same thing, or the baseline fails itself
|
|
696
|
+
incorrect any candidate run printed ok=false (no speedup is reported)
|
|
697
|
+
faster/slower |mean_b - mean_c| > 2 * sqrt(std_b^2/n + std_c^2/n) (sample std)
|
|
698
|
+
inconclusive otherwise: the difference is inside the noise, run again or change more"""
|
|
699
|
+
out = {"baseline": _stats(b), "candidate": _stats(c),
|
|
700
|
+
"baseline_all_ok": all(b_ok), "candidate_all_ok": all(c_ok),
|
|
701
|
+
"contract_mismatch": mismatch}
|
|
702
|
+
if mismatch:
|
|
703
|
+
out.update(verdict="invalid", speedup=None,
|
|
704
|
+
reason="baseline and candidate disagree on " + ", ".join(mismatch)
|
|
705
|
+
+ "; a candidate must keep the baseline's shape, dtype, iters and seed")
|
|
706
|
+
return out
|
|
707
|
+
if not all(b_ok):
|
|
708
|
+
out.update(verdict="invalid", speedup=None,
|
|
709
|
+
reason="the baseline failed its own correctness check; fix the target first")
|
|
710
|
+
return out
|
|
711
|
+
if not all(c_ok):
|
|
712
|
+
out.update(verdict="incorrect", speedup=None,
|
|
713
|
+
reason=f"candidate printed ok=false in {c_ok.count(False)} of {len(c_ok)} runs; "
|
|
714
|
+
"no speedup is reported for incorrect output")
|
|
715
|
+
return out
|
|
716
|
+
n = min(len(b), len(c))
|
|
717
|
+
sb, sc = out["baseline"]["std_ms"], out["candidate"]["std_ms"]
|
|
718
|
+
delta = out["baseline"]["mean_ms"] - out["candidate"]["mean_ms"]
|
|
719
|
+
threshold = 2.0 * math.sqrt(sb ** 2 / n + sc ** 2 / n)
|
|
720
|
+
significant = abs(delta) > threshold
|
|
721
|
+
speedup = out["baseline"]["mean_ms"] / out["candidate"]["mean_ms"]
|
|
722
|
+
verdict = ("faster" if delta > 0 else "slower") if significant else "inconclusive"
|
|
723
|
+
out.update(speedup=round(speedup, 4), delta_ms=round(delta, 5),
|
|
724
|
+
threshold_ms=round(threshold, 5), significant=significant, verdict=verdict,
|
|
725
|
+
reason=(f"candidate mean {out['candidate']['mean_ms']:.4f} ms vs baseline "
|
|
726
|
+
f"{out['baseline']['mean_ms']:.4f} ms; |delta| {abs(delta):.4f} ms "
|
|
727
|
+
f"{'>' if significant else '<='} 2*SE {threshold:.4f} ms (n={n})"))
|
|
728
|
+
return out
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@mcp.tool()
|
|
732
|
+
def verify(candidate: str, baseline: str, n: int = 7, warmup: int = 2,
|
|
733
|
+
args: list[str] | None = None, cwd: str | None = None,
|
|
734
|
+
timeout: int = _DEFAULT_TIMEOUT) -> dict:
|
|
735
|
+
"""Decide whether a candidate script beats the baseline script. Both follow the bench
|
|
736
|
+
contract (print one JSON line with "ms" and "ok"). Runs them interleaved (baseline,
|
|
737
|
+
candidate, baseline, candidate, ...) warmup+n times each, back to back on the same card,
|
|
738
|
+
discards the warmup pairs, and returns mean/std/min for both plus a verdict:
|
|
739
|
+
'faster' or 'slower' when |mean_b - mean_c| > 2*sqrt(std_b^2/n + std_c^2/n),
|
|
740
|
+
'inconclusive' otherwise, 'incorrect' if the candidate printed ok=false in any run (then no
|
|
741
|
+
speedup is reported), 'invalid' if the two scripts disagree on shape/dtype/iters/seed or the
|
|
742
|
+
baseline fails its own check. Also records gpu, driver, power limit, timestamp and the exact
|
|
743
|
+
commands. Pass the result to record()."""
|
|
744
|
+
py = _python()
|
|
745
|
+
b_cmd = [py, baseline, *(args or [])]
|
|
746
|
+
c_cmd = [py, candidate, *(args or [])]
|
|
747
|
+
t0 = time.perf_counter()
|
|
748
|
+
b_ms: list[float] = []
|
|
749
|
+
c_ms: list[float] = []
|
|
750
|
+
b_ok: list[bool] = []
|
|
751
|
+
c_ok: list[bool] = []
|
|
752
|
+
b_json: dict | None = None
|
|
753
|
+
c_json: dict | None = None
|
|
754
|
+
for i in range(warmup + n):
|
|
755
|
+
for label, cmd in (("baseline", b_cmd), ("candidate", c_cmd)):
|
|
756
|
+
r = _run_target(cmd, cwd, timeout)
|
|
757
|
+
if "error" in r:
|
|
758
|
+
return {"error": f"{label} failed on run {i}: {r['error']}", "cmd": cmd,
|
|
759
|
+
"detail": {k: v for k, v in r.items() if k != "error"}}
|
|
760
|
+
if i < warmup:
|
|
761
|
+
continue
|
|
762
|
+
if label == "baseline":
|
|
763
|
+
b_ms.append(r["ms"]); b_ok.append(r["ok"]); b_json = b_json or r["json"]
|
|
764
|
+
else:
|
|
765
|
+
c_ms.append(r["ms"]); c_ok.append(r["ok"]); c_json = c_json or r["json"]
|
|
766
|
+
assert b_json is not None and c_json is not None
|
|
767
|
+
# The candidate must measure the same thing the same way: shape, dtype, iteration count,
|
|
768
|
+
# seed, and the timing protocol (a candidate timed under an older protocol is not
|
|
769
|
+
# comparable, whatever its number says).
|
|
770
|
+
mismatch = [k for k in ("shape", "dtype", "iters", "seed", "protocol", "timing")
|
|
771
|
+
if k in b_json and b_json.get(k) != c_json.get(k)]
|
|
772
|
+
out = _decide(b_ms, c_ms, b_ok, c_ok, mismatch)
|
|
773
|
+
out.update({
|
|
774
|
+
"n": n, "warmup": warmup, "order": "interleaved baseline/candidate",
|
|
775
|
+
"cmds": {"baseline": b_cmd, "candidate": c_cmd}, "cwd": cwd,
|
|
776
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
777
|
+
"wall_s": round(time.perf_counter() - t0, 1),
|
|
778
|
+
"contract": {k: b_json.get(k) for k in ("shape", "dtype", "iters", "seed", "protocol",
|
|
779
|
+
"timing", "rotation_k", "rotation_bytes",
|
|
780
|
+
"l2_bytes", "torch")
|
|
781
|
+
if k in b_json},
|
|
782
|
+
# What to pass to roofline(): the target's own traffic floor and nominal flop count.
|
|
783
|
+
"roofline_inputs": {k: b_json.get(k) for k in ("min_bytes", "flops") if k in b_json},
|
|
784
|
+
"candidate_extra": {k: v for k, v in c_json.items()
|
|
785
|
+
if k not in b_json and k not in ("ms", "ok")},
|
|
786
|
+
"note": "speedup = this call's baseline mean / candidate mean and moves with the "
|
|
787
|
+
"baseline's run-to-run noise (a few %); to rank hypotheses against each other "
|
|
788
|
+
"compare candidate mean_ms, which is what record() uses for keep",
|
|
789
|
+
**_nvidia_smi_state(),
|
|
790
|
+
})
|
|
791
|
+
return out
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
_LEADERBOARD_INTRO = """# Leaderboard
|
|
795
|
+
|
|
796
|
+
Every row below is generated by the `record()` tool from the JSON logs in `results/<target>/`;
|
|
797
|
+
each call rebuilds the whole table from those files, so it can always be reproduced from the
|
|
798
|
+
raw data and nobody edits it by hand. Each log is one `verify()` result: baseline and candidate
|
|
799
|
+
run interleaved on the stated card, n runs each after warmup, same shape, same dtype, same
|
|
800
|
+
power limit, timed by the target's baseline (the current targets flush L2 before every timed
|
|
801
|
+
call; the `timing` field in each log says so). The Speedup cell is filled only when the verdict
|
|
802
|
+
was `faster` (the gap beat 2 * sqrt(std_b^2/n + std_c^2/n) and every candidate run passed the
|
|
803
|
+
correctness check); otherwise it says `no gain`, `incorrect` or `invalid`. Speedup is relative
|
|
804
|
+
to the baseline script in `examples/`, never to a vendor library. The log link holds every
|
|
805
|
+
sample, the exact commands, driver, clock and temperature.
|
|
806
|
+
|
|
807
|
+
"""
|
|
808
|
+
_LEADERBOARD_COLUMNS = (
|
|
809
|
+
"| Target | GPU | Power | Baseline mean ms (n) | Candidate mean ms (n) | Speedup | "
|
|
810
|
+
"What changed | Date | Commit | Who |\n"
|
|
811
|
+
"|--------|-----|-------|----------------------|-----------------------|---------|"
|
|
812
|
+
"--------------|------|--------|-----|\n")
|
|
813
|
+
_SUPERSEDED_INTRO = """
|
|
814
|
+
## Superseded, not counted
|
|
815
|
+
|
|
816
|
+
Logs kept under `results/<target>/superseded-<reason>/`. They were measured under an earlier
|
|
817
|
+
timing protocol and are not comparable with the table above; they stay so nothing disappears.
|
|
818
|
+
|
|
819
|
+
"""
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _speedup_cell(v: dict) -> str:
|
|
823
|
+
verdict = v.get("verdict")
|
|
824
|
+
if verdict == "faster" and v.get("speedup"):
|
|
825
|
+
return f"{v['speedup']:.2f}x"
|
|
826
|
+
if verdict in ("slower", "inconclusive"):
|
|
827
|
+
return "no gain"
|
|
828
|
+
return str(verdict) # incorrect / invalid
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _leaderboard_row(payload: dict, log_path: Path, base: Path) -> str:
|
|
832
|
+
v = payload["verify"]
|
|
833
|
+
b, c = v["baseline"], v["candidate"]
|
|
834
|
+
power = f"{v['power_limit_w']:.0f} W" if v.get("power_limit_w") else ""
|
|
835
|
+
rel_log = log_path.relative_to(base).as_posix()
|
|
836
|
+
what = " ".join((payload.get("note") or "").replace("|", "/").split())
|
|
837
|
+
what = f"{what} ([log]({rel_log}))" if what else f"[log]({rel_log})"
|
|
838
|
+
stamp = payload.get("recorded_at") or ""
|
|
839
|
+
date = f"{stamp[:4]}-{stamp[4:6]}-{stamp[6:8]}" if len(stamp) >= 8 else ""
|
|
840
|
+
return (f"| {payload.get('target', '')} | {v.get('gpu') or ''} | {power} | "
|
|
841
|
+
f"{b['mean_ms']:.4f} ({b['n']}) | {c['mean_ms']:.4f} ({c['n']}) | {_speedup_cell(v)} | "
|
|
842
|
+
f"{what} | {date} | {payload.get('commit', '')} | {payload.get('who', '')} |\n")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _read_record(p: Path) -> dict | None:
|
|
846
|
+
"""A record() log, or None for any other JSON that happens to live under results/."""
|
|
847
|
+
try:
|
|
848
|
+
d = json.loads(p.read_text())
|
|
849
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
850
|
+
return None
|
|
851
|
+
v = d.get("verify") if isinstance(d, dict) else None
|
|
852
|
+
if isinstance(v, dict) and "target" in d and {"verdict", "baseline", "candidate"} <= set(v):
|
|
853
|
+
return d
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _rebuild_leaderboard(base: Path) -> dict:
|
|
858
|
+
"""Regenerate LEADERBOARD.md from results/<target>/*.json (current rows, sorted by target
|
|
859
|
+
then time) and results/<target>/superseded-*/*.json (listed separately, not counted)."""
|
|
860
|
+
current: list[tuple[str, str, str]] = []
|
|
861
|
+
superseded: list[tuple[str, str, str]] = []
|
|
862
|
+
results = base / "results"
|
|
863
|
+
if results.is_dir():
|
|
864
|
+
for tdir in sorted(p for p in results.iterdir() if p.is_dir()):
|
|
865
|
+
for p in sorted(tdir.glob("*.json")):
|
|
866
|
+
d = _read_record(p)
|
|
867
|
+
if d:
|
|
868
|
+
current.append((d.get("target", ""), d.get("recorded_at", ""),
|
|
869
|
+
_leaderboard_row(d, p, base)))
|
|
870
|
+
for sdir in sorted(p for p in tdir.glob("superseded*") if p.is_dir()):
|
|
871
|
+
reason = sdir.name.removeprefix("superseded").strip("-_ ") or "superseded"
|
|
872
|
+
for p in sorted(sdir.glob("*.json")):
|
|
873
|
+
d = _read_record(p)
|
|
874
|
+
if d:
|
|
875
|
+
superseded.append((d.get("target", ""), d.get("recorded_at", ""),
|
|
876
|
+
f"| {reason} " + _leaderboard_row(d, p, base)))
|
|
877
|
+
text = _LEADERBOARD_INTRO + _LEADERBOARD_COLUMNS + "".join(r for _, _, r in sorted(current))
|
|
878
|
+
if superseded:
|
|
879
|
+
text += (_SUPERSEDED_INTRO + "| Reason " + _LEADERBOARD_COLUMNS.replace("\n|--", "\n|--------|--", 1)
|
|
880
|
+
+ "".join(r for _, _, r in sorted(superseded)))
|
|
881
|
+
(base / "LEADERBOARD.md").write_text(text)
|
|
882
|
+
return {"rows": len(current), "superseded_rows": len(superseded)}
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
@mcp.tool()
|
|
886
|
+
def record(target: str, verify_result: dict | str, note: str = "", who: str = "",
|
|
887
|
+
cwd: str | None = None) -> dict:
|
|
888
|
+
"""Write a verify() result to results/<target>/<timestamp>.json, then regenerate
|
|
889
|
+
LEADERBOARD.md from every log under results/ (so the table is always reproducible from
|
|
890
|
+
the raw files). The Speedup cell is filled only when the verdict is 'faster'; otherwise it
|
|
891
|
+
says 'no gain', 'incorrect' or 'invalid'. Call it after every verify(), win or lose; never
|
|
892
|
+
edit LEADERBOARD.md by hand. `note` is the one-line "what changed"; `who` says honestly
|
|
893
|
+
who produced the candidate, e.g. 'agent:<model> running kernel-speedrun unattended' or
|
|
894
|
+
'hand-written by <who> during tool bring-up'."""
|
|
895
|
+
if isinstance(verify_result, str):
|
|
896
|
+
try:
|
|
897
|
+
verify_result = json.loads(verify_result)
|
|
898
|
+
except json.JSONDecodeError as e:
|
|
899
|
+
return {"error": f"verify_result is not JSON: {e}"}
|
|
900
|
+
v = verify_result
|
|
901
|
+
if not isinstance(v, dict) or not {"verdict", "baseline", "candidate"} <= set(v):
|
|
902
|
+
return {"error": "verify_result must be the object returned by verify() "
|
|
903
|
+
"(it needs verdict, baseline and candidate)"}
|
|
904
|
+
cwd = _local_path(cwd)
|
|
905
|
+
base = Path(cwd) if cwd else Path.cwd()
|
|
906
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", target).strip("_") or "target"
|
|
907
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
908
|
+
log_dir = base / "results" / safe
|
|
909
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
910
|
+
log_path = log_dir / f"{stamp}.json"
|
|
911
|
+
for i in range(1, 1000): # two records in the same second must not overwrite each other
|
|
912
|
+
if not log_path.exists():
|
|
913
|
+
break
|
|
914
|
+
log_path = log_dir / f"{stamp}-{i}.json"
|
|
915
|
+
git = _run(["git", "rev-parse", "--short", "HEAD"], cwd=str(base), timeout=30)
|
|
916
|
+
commit = git["stdout"].strip() if git.get("returncode") == 0 else ""
|
|
917
|
+
# The verdict is relative to the baseline, so once a good candidate exists every later
|
|
918
|
+
# variant is still 'faster'. `keep` therefore compares the candidate mean with the best
|
|
919
|
+
# earlier 'faster' row that has the same `who` (the agent's own rows in this run; the
|
|
920
|
+
# prompt puts the run id in `who`). The all-time best for the target is reported too, but
|
|
921
|
+
# its code may belong to another run, so it never drives the advice.
|
|
922
|
+
best_same_who: tuple[float, str] | None = None
|
|
923
|
+
best_all: tuple[float, str] | None = None
|
|
924
|
+
for p in sorted(log_dir.glob("*.json")):
|
|
925
|
+
d = _read_record(p)
|
|
926
|
+
if not d or d["verify"].get("verdict") != "faster":
|
|
927
|
+
continue
|
|
928
|
+
c_ms, rel = d["verify"]["candidate"]["mean_ms"], p.relative_to(base).as_posix()
|
|
929
|
+
if best_all is None or c_ms < best_all[0]:
|
|
930
|
+
best_all = (c_ms, rel)
|
|
931
|
+
if d.get("who") == who and (best_same_who is None or c_ms < best_same_who[0]):
|
|
932
|
+
best_same_who = (c_ms, rel)
|
|
933
|
+
this_ms = v["candidate"]["mean_ms"]
|
|
934
|
+
improved = v["verdict"] == "faster" and (best_same_who is None or this_ms < best_same_who[0])
|
|
935
|
+
payload = {"target": target, "note": note, "who": who, "commit": commit,
|
|
936
|
+
"recorded_at": stamp, "verify": v}
|
|
937
|
+
log_path.write_text(json.dumps(payload, indent=1) + "\n")
|
|
938
|
+
counts = _rebuild_leaderboard(base)
|
|
939
|
+
noise = 2.0 * float(v["candidate"].get("std_ms") or 0.0)
|
|
940
|
+
equivalent = (not improved and v["verdict"] == "faster" and best_same_who is not None
|
|
941
|
+
and this_ms - best_same_who[0] <= noise)
|
|
942
|
+
if improved:
|
|
943
|
+
advice = ("keep this change: lowest candidate mean among your rows so far "
|
|
944
|
+
"(compare candidate mean_ms between hypotheses, not speedups; the speedup "
|
|
945
|
+
"moves with the baseline's noise)")
|
|
946
|
+
elif equivalent:
|
|
947
|
+
advice = (f"same as your best within noise ({this_ms:.4f} vs {best_same_who[0]:.4f} ms, "
|
|
948
|
+
f"2*std {noise:.4f} ms): a re-run or an equivalent change; nothing to revert, "
|
|
949
|
+
"but it does not count as an improvement")
|
|
950
|
+
elif v["verdict"] == "faster":
|
|
951
|
+
advice = (f"revert to your own best ({best_same_who[0]:.4f} ms, {best_same_who[1]}); "
|
|
952
|
+
"this row is faster than the baseline but not better than that")
|
|
953
|
+
else:
|
|
954
|
+
advice = "revert or fix the candidate; this row is not a win"
|
|
955
|
+
return {"log": str(log_path), "leaderboard": str(base / "LEADERBOARD.md"),
|
|
956
|
+
"row": _leaderboard_row(payload, log_path, base).strip(),
|
|
957
|
+
"verdict": v["verdict"], "speedup_cell": _speedup_cell(v),
|
|
958
|
+
"candidate_mean_ms": this_ms, "keep": improved, "improved_on_best": improved,
|
|
959
|
+
"equivalent_to_best": equivalent,
|
|
960
|
+
"previous_best_candidate_ms": best_same_who[0] if best_same_who else None,
|
|
961
|
+
"previous_best_log": best_same_who[1] if best_same_who else None,
|
|
962
|
+
"previous_best_scope": "rows with the same `who` (your own earlier rows)",
|
|
963
|
+
"all_time_best_candidate_ms": best_all[0] if best_all else None,
|
|
964
|
+
"all_time_best_log": best_all[1] if best_all else None,
|
|
965
|
+
"all_time_best_note": "may come from another run or author; its code is in that "
|
|
966
|
+
"log's candidate command, not necessarily in your file",
|
|
967
|
+
"advice": advice, **counts}
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def main() -> None:
|
|
971
|
+
mcp.run()
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
if __name__ == "__main__":
|
|
975
|
+
main()
|