kernelport 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.
- kernelport/__init__.py +3 -0
- kernelport/__main__.py +5 -0
- kernelport/_skills/kernelport/SKILL.md +200 -0
- kernelport/_skills/kernelport/backends/cpu.md +138 -0
- kernelport/_skills/kernelport/backends/hip.md +53 -0
- kernelport/_skills/kernelport/backends/metal.md +76 -0
- kernelport/_skills/kernelport/backends/triton.md +117 -0
- kernelport/_skills/kernelport/backends/webgpu.md +146 -0
- kernelport/_skills/kernelport/common_bugs.md +40 -0
- kernelport/agent.py +178 -0
- kernelport/analyzer/__init__.py +0 -0
- kernelport/analyzer/cuda_parser.py +480 -0
- kernelport/analyzer/models.py +80 -0
- kernelport/analyzer/suggest.py +120 -0
- kernelport/backends/__init__.py +5 -0
- kernelport/backends/base.py +138 -0
- kernelport/backends/cpu/__init__.py +1 -0
- kernelport/backends/cpu/capability.py +25 -0
- kernelport/backends/cpu/config.py +36 -0
- kernelport/backends/cpu/runner.py +67 -0
- kernelport/backends/hip/__init__.py +1 -0
- kernelport/backends/hip/capability.py +34 -0
- kernelport/backends/hip/config.py +28 -0
- kernelport/backends/hip/runner.py +48 -0
- kernelport/backends/metal/__init__.py +1 -0
- kernelport/backends/metal/capability.py +32 -0
- kernelport/backends/metal/config.py +35 -0
- kernelport/backends/metal/runner.py +142 -0
- kernelport/backends/triton/__init__.py +1 -0
- kernelport/backends/triton/capability.py +29 -0
- kernelport/backends/triton/config.py +41 -0
- kernelport/backends/triton/runner.py +44 -0
- kernelport/backends/webgpu/__init__.py +1 -0
- kernelport/backends/webgpu/capability.py +34 -0
- kernelport/backends/webgpu/config.py +38 -0
- kernelport/backends/webgpu/runner.py +285 -0
- kernelport/bench/__init__.py +0 -0
- kernelport/bench/timer.py +76 -0
- kernelport/cli.py +501 -0
- kernelport/doctor.py +285 -0
- kernelport/hardware.py +60 -0
- kernelport/harness/__init__.py +0 -0
- kernelport/harness/compare.py +152 -0
- kernelport/harness/device_source.py +273 -0
- kernelport/harness/expr.py +80 -0
- kernelport/harness/inputs.py +194 -0
- kernelport/harness/reference.py +76 -0
- kernelport/harness/results.py +118 -0
- kernelport/harness/run_cuda.py +126 -0
- kernelport/harness/spec.py +199 -0
- kernelport/harness/tolerances.py +48 -0
- kernelport/harness/verify.py +131 -0
- kernelport/hashing.py +28 -0
- kernelport/mcp_server.py +95 -0
- kernelport/report/__init__.py +1 -0
- kernelport/report/data.py +147 -0
- kernelport/report/html.py +174 -0
- kernelport/report/markdown.py +75 -0
- kernelport/setup_agents/__init__.py +1 -0
- kernelport/setup_agents/backup.py +59 -0
- kernelport/setup_agents/detect.py +78 -0
- kernelport/setup_agents/register.py +302 -0
- kernelport/setup_agents/skills_install.py +31 -0
- kernelport/tools.py +455 -0
- kernelport/tune.py +99 -0
- kernelport/workspace.py +400 -0
- kernelport/zoo.py +176 -0
- kernelport-0.1.0.dist-info/METADATA +228 -0
- kernelport-0.1.0.dist-info/RECORD +72 -0
- kernelport-0.1.0.dist-info/WHEEL +4 -0
- kernelport-0.1.0.dist-info/entry_points.txt +2 -0
- kernelport-0.1.0.dist-info/licenses/LICENSE +21 -0
kernelport/doctor.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Environment report: OS, Python, torch, GPU, installed extras, and a smoke
|
|
2
|
+
kernel for every backend that is available on this machine.
|
|
3
|
+
|
|
4
|
+
Every check is isolated: a missing package or a failing backend is reported
|
|
5
|
+
with a reason, never raised.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import platform
|
|
12
|
+
import sys
|
|
13
|
+
import traceback
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
# Package that marks each optional extra as installed.
|
|
18
|
+
EXTRA_MARKERS: dict[str, list[str]] = {
|
|
19
|
+
"ref": ["torch"],
|
|
20
|
+
"cuda": ["torch", "cupy"],
|
|
21
|
+
"triton": ["torch", "triton"],
|
|
22
|
+
"metal": ["mlx"],
|
|
23
|
+
"webgpu": ["wgpu"],
|
|
24
|
+
"cpu": ["numba"],
|
|
25
|
+
"agent": ["anthropic"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class BackendCheck:
|
|
31
|
+
"""Result of checking one backend on this machine."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
available: bool
|
|
35
|
+
reason: str
|
|
36
|
+
smoke: str = "skipped" # "pass", "fail", or "skipped"
|
|
37
|
+
detail: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class DoctorReport:
|
|
42
|
+
"""Full environment report."""
|
|
43
|
+
|
|
44
|
+
os: str
|
|
45
|
+
python: str
|
|
46
|
+
torch: str
|
|
47
|
+
cuda_available: bool
|
|
48
|
+
gpu_name: str
|
|
49
|
+
compute_capability: str
|
|
50
|
+
extras: dict[str, bool] = field(default_factory=dict)
|
|
51
|
+
backends: list[BackendCheck] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
"""Return a JSON-serialisable dict."""
|
|
55
|
+
return asdict(self)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def ok(self) -> bool:
|
|
59
|
+
"""True when no available backend failed its smoke test."""
|
|
60
|
+
return all(b.smoke != "fail" for b in self.backends)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _has(module: str) -> bool:
|
|
64
|
+
return importlib.util.find_spec(module) is not None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _torch_info() -> tuple[str, bool, str, str]:
|
|
68
|
+
if not _has("torch"):
|
|
69
|
+
return "not installed", False, "", ""
|
|
70
|
+
try:
|
|
71
|
+
import torch
|
|
72
|
+
|
|
73
|
+
if torch.cuda.is_available():
|
|
74
|
+
cc = torch.cuda.get_device_capability(0)
|
|
75
|
+
return torch.__version__, True, torch.cuda.get_device_name(0), f"sm_{cc[0]}{cc[1]}"
|
|
76
|
+
return torch.__version__, False, "", ""
|
|
77
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
78
|
+
return f"error: {exc}", False, "", ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------- smoke tests
|
|
82
|
+
# Each returns None on success or raises. Inputs are tiny and fixed.
|
|
83
|
+
|
|
84
|
+
def _smoke_cuda() -> None:
|
|
85
|
+
import cupy as cp
|
|
86
|
+
import numpy as np
|
|
87
|
+
|
|
88
|
+
src = r"""extern "C" __global__ void add(const float* a, const float* b, float* c, int n) {
|
|
89
|
+
int i = blockIdx.x * blockDim.x + threadIdx.x;
|
|
90
|
+
if (i < n) c[i] = a[i] + b[i];
|
|
91
|
+
}"""
|
|
92
|
+
kern = cp.RawKernel(src, "add")
|
|
93
|
+
n = 1000
|
|
94
|
+
a = cp.arange(n, dtype=cp.float32)
|
|
95
|
+
b = cp.full(n, 2.0, dtype=cp.float32)
|
|
96
|
+
c = cp.empty_like(a)
|
|
97
|
+
kern(((n + 255) // 256,), (256,), (a, b, c, np.int32(n)))
|
|
98
|
+
if not bool(cp.allclose(c, a + 2.0)):
|
|
99
|
+
raise AssertionError("CuPy RawKernel produced wrong result")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _smoke_triton() -> None:
|
|
103
|
+
import torch
|
|
104
|
+
import triton
|
|
105
|
+
import triton.language as tl
|
|
106
|
+
|
|
107
|
+
@triton.jit
|
|
108
|
+
def add_kernel(x_ptr, y_ptr, out_ptr, n, BLOCK: tl.constexpr):
|
|
109
|
+
offs = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
|
|
110
|
+
mask = offs < n
|
|
111
|
+
tl.store(out_ptr + offs, tl.load(x_ptr + offs, mask=mask) + tl.load(y_ptr + offs, mask=mask), mask=mask)
|
|
112
|
+
|
|
113
|
+
n = 1000
|
|
114
|
+
x = torch.arange(n, device="cuda", dtype=torch.float32)
|
|
115
|
+
y = torch.full((n,), 2.0, device="cuda")
|
|
116
|
+
out = torch.empty_like(x)
|
|
117
|
+
add_kernel[(triton.cdiv(n, 256),)](x, y, out, n, BLOCK=256)
|
|
118
|
+
if not torch.equal(out, x + 2.0):
|
|
119
|
+
raise AssertionError("Triton kernel produced wrong result")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _smoke_cpu() -> None:
|
|
123
|
+
import numpy as np
|
|
124
|
+
from numba import njit, prange
|
|
125
|
+
|
|
126
|
+
@njit(parallel=True, cache=False)
|
|
127
|
+
def add(a, b, c):
|
|
128
|
+
for i in prange(a.shape[0]):
|
|
129
|
+
c[i] = a[i] + b[i]
|
|
130
|
+
|
|
131
|
+
a = np.arange(1000, dtype=np.float32)
|
|
132
|
+
c = np.empty_like(a)
|
|
133
|
+
add(a, np.full_like(a, 2.0), c)
|
|
134
|
+
if not np.array_equal(c, a + 2.0):
|
|
135
|
+
raise AssertionError("Numba kernel produced wrong result")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _smoke_metal() -> None:
|
|
139
|
+
import mlx.core as mx
|
|
140
|
+
|
|
141
|
+
kern = mx.fast.metal_kernel(
|
|
142
|
+
name="add",
|
|
143
|
+
input_names=["a"],
|
|
144
|
+
output_names=["out"],
|
|
145
|
+
source="uint i = thread_position_in_grid.x; out[i] = a[i] + 2.0f;",
|
|
146
|
+
)
|
|
147
|
+
a = mx.arange(1000, dtype=mx.float32)
|
|
148
|
+
(out,) = kern(
|
|
149
|
+
inputs=[a], grid=(1000, 1, 1), threadgroup=(256, 1, 1),
|
|
150
|
+
output_shapes=[a.shape], output_dtypes=[a.dtype],
|
|
151
|
+
)
|
|
152
|
+
if not bool(mx.array_equal(out, a + 2.0)):
|
|
153
|
+
raise AssertionError("MLX metal kernel produced wrong result")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _smoke_webgpu() -> None:
|
|
157
|
+
import numpy as np
|
|
158
|
+
import wgpu
|
|
159
|
+
|
|
160
|
+
from kernelport.backends.webgpu.runner import device
|
|
161
|
+
|
|
162
|
+
dev = device()
|
|
163
|
+
a = np.arange(1000, dtype=np.float32)
|
|
164
|
+
buf = dev.create_buffer_with_data(data=a.tobytes(), usage=wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_SRC)
|
|
165
|
+
module = dev.create_shader_module(code="""
|
|
166
|
+
@group(0) @binding(0) var<storage, read_write> a: array<f32>;
|
|
167
|
+
@compute @workgroup_size(256)
|
|
168
|
+
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
169
|
+
if (gid.x < 1000u) { a[gid.x] = a[gid.x] + 2.0; }
|
|
170
|
+
}""")
|
|
171
|
+
pipe = dev.create_compute_pipeline(layout="auto", compute={"module": module, "entry_point": "main"})
|
|
172
|
+
group = dev.create_bind_group(layout=pipe.get_bind_group_layout(0),
|
|
173
|
+
entries=[{"binding": 0, "resource": {"buffer": buf}}])
|
|
174
|
+
enc = dev.create_command_encoder()
|
|
175
|
+
cp = enc.begin_compute_pass()
|
|
176
|
+
cp.set_pipeline(pipe)
|
|
177
|
+
cp.set_bind_group(0, group)
|
|
178
|
+
cp.dispatch_workgroups(4)
|
|
179
|
+
cp.end()
|
|
180
|
+
dev.queue.submit([enc.finish()])
|
|
181
|
+
out = np.frombuffer(dev.queue.read_buffer(buf), dtype=np.float32)
|
|
182
|
+
if not np.array_equal(out, a + 2.0):
|
|
183
|
+
raise AssertionError("WGSL kernel produced wrong result")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --------------------------------------------------------------- capabilities
|
|
187
|
+
|
|
188
|
+
def _cuda_gpu() -> tuple[bool, str]:
|
|
189
|
+
if not _has("torch"):
|
|
190
|
+
return False, "torch not installed (pip install kernelport[cuda])"
|
|
191
|
+
import torch
|
|
192
|
+
|
|
193
|
+
if not torch.cuda.is_available():
|
|
194
|
+
return False, "torch.cuda.is_available() is False (CPU-only torch or no NVIDIA GPU)"
|
|
195
|
+
if getattr(torch.version, "hip", None):
|
|
196
|
+
return False, "ROCm GPU, not NVIDIA CUDA"
|
|
197
|
+
return True, "ok"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _check_cuda() -> tuple[bool, str]:
|
|
201
|
+
ok, why = _cuda_gpu()
|
|
202
|
+
if not ok:
|
|
203
|
+
return ok, why
|
|
204
|
+
if not _has("cupy"):
|
|
205
|
+
return False, "cupy not installed (pip install kernelport[cuda])"
|
|
206
|
+
return True, "CuPy + NVRTC on NVIDIA GPU"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _registry_check(name: str) -> Callable[[], tuple[bool, str]]:
|
|
210
|
+
def check() -> tuple[bool, str]:
|
|
211
|
+
from kernelport.backends.base import get_backend
|
|
212
|
+
|
|
213
|
+
return get_backend(name).is_available()
|
|
214
|
+
return check
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# The original CUDA runner is not a port backend; every port backend comes from the registry.
|
|
218
|
+
SMOKES: dict[str, Callable[[], None]] = {"triton": _smoke_triton, "cpu": _smoke_cpu, "metal": _smoke_metal,
|
|
219
|
+
"webgpu": _smoke_webgpu}
|
|
220
|
+
BACKENDS: list[tuple[str, Callable[[], tuple[bool, str]], Callable[[], None] | None]] = [
|
|
221
|
+
("cuda (original)", _check_cuda, _smoke_cuda),
|
|
222
|
+
*[(n, _registry_check(n), SMOKES.get(n)) for n in ("triton", "cpu", "metal", "webgpu", "hip")],
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _run_backend(name: str, check: Callable, smoke: Callable | None) -> BackendCheck:
|
|
227
|
+
try:
|
|
228
|
+
ok, reason = check()
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
return BackendCheck(name, False, f"capability check crashed: {exc}")
|
|
231
|
+
result = BackendCheck(name, ok, reason)
|
|
232
|
+
if ok and smoke is not None:
|
|
233
|
+
try:
|
|
234
|
+
smoke()
|
|
235
|
+
result.smoke = "pass"
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
result.smoke = "fail"
|
|
238
|
+
result.detail = f"{type(exc).__name__}: {exc}\n" + traceback.format_exc(limit=3)
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def run_doctor() -> DoctorReport:
|
|
243
|
+
"""Collect environment info and smoke-test every available backend."""
|
|
244
|
+
torch_ver, cuda_ok, gpu, cc = _torch_info()
|
|
245
|
+
report = DoctorReport(
|
|
246
|
+
os=f"{platform.system()} {platform.release()} ({platform.machine()})",
|
|
247
|
+
python=sys.version.split()[0],
|
|
248
|
+
torch=torch_ver,
|
|
249
|
+
cuda_available=cuda_ok,
|
|
250
|
+
gpu_name=gpu,
|
|
251
|
+
compute_capability=cc,
|
|
252
|
+
extras={k: all(_has(m) for m in mods) for k, mods in EXTRA_MARKERS.items()},
|
|
253
|
+
)
|
|
254
|
+
report.backends = [_run_backend(*b) for b in BACKENDS]
|
|
255
|
+
return report
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def print_report(report: DoctorReport) -> None:
|
|
259
|
+
"""Pretty-print a doctor report with rich."""
|
|
260
|
+
from rich.console import Console
|
|
261
|
+
from rich.markup import escape
|
|
262
|
+
from rich.table import Table
|
|
263
|
+
|
|
264
|
+
con = Console()
|
|
265
|
+
con.print("[bold]kernelport doctor[/bold]")
|
|
266
|
+
con.print(f"OS: {report.os}")
|
|
267
|
+
con.print(f"Python: {report.python}")
|
|
268
|
+
con.print(f"torch: {report.torch}")
|
|
269
|
+
con.print(f"CUDA: {report.cuda_available}")
|
|
270
|
+
if report.gpu_name:
|
|
271
|
+
con.print(f"GPU: {report.gpu_name} ({report.compute_capability})")
|
|
272
|
+
con.print("Extras: " + ", ".join(f"{k}={'yes' if v else 'no'}" for k, v in report.extras.items()))
|
|
273
|
+
|
|
274
|
+
table = Table(title="Backends")
|
|
275
|
+
for col in ("backend", "available", "smoke", "reason"):
|
|
276
|
+
table.add_column(col)
|
|
277
|
+
colors = {"pass": "green", "fail": "red", "skipped": "dim"}
|
|
278
|
+
for b in report.backends:
|
|
279
|
+
table.add_row(b.name, "yes" if b.available else "no",
|
|
280
|
+
f"[{colors[b.smoke]}]{b.smoke}[/]", escape(b.reason))
|
|
281
|
+
con.print(table)
|
|
282
|
+
for b in report.backends:
|
|
283
|
+
if b.smoke == "fail":
|
|
284
|
+
con.print(f"[red]{b.name} smoke failed:[/red]\n{b.detail}")
|
|
285
|
+
con.print("[green]doctor: OK[/green]" if report.ok else "[red]doctor: FAILED[/red]")
|
kernelport/hardware.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Human-readable hardware names, recorded with every result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _cpu_model() -> str:
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
try:
|
|
15
|
+
import winreg
|
|
16
|
+
|
|
17
|
+
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
|
18
|
+
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as key:
|
|
19
|
+
return str(winreg.QueryValueEx(key, "ProcessorNameString")[0]).strip()
|
|
20
|
+
except OSError:
|
|
21
|
+
pass
|
|
22
|
+
elif sys.platform == "darwin":
|
|
23
|
+
try:
|
|
24
|
+
return subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True,
|
|
25
|
+
text=True, timeout=5).stdout.strip()
|
|
26
|
+
except (OSError, subprocess.SubprocessError):
|
|
27
|
+
pass
|
|
28
|
+
else:
|
|
29
|
+
try:
|
|
30
|
+
with open("/proc/cpuinfo", encoding="utf-8") as f:
|
|
31
|
+
for line in f:
|
|
32
|
+
if line.startswith("model name"):
|
|
33
|
+
return line.split(":", 1)[1].strip()
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
return platform.processor() or platform.machine()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache(maxsize=1)
|
|
40
|
+
def cpu_name() -> str:
|
|
41
|
+
"""CPU model with logical thread count, e.g. 'AMD Ryzen 7 7840HS (16 threads)'."""
|
|
42
|
+
model = " ".join(_cpu_model().split()) or "unknown CPU"
|
|
43
|
+
threads = os.cpu_count()
|
|
44
|
+
return f"{model} ({threads} threads)" if threads else model
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def gpu_name() -> str:
|
|
48
|
+
"""Name of GPU 0 as seen by torch, with arch, or '' if none."""
|
|
49
|
+
try:
|
|
50
|
+
import torch
|
|
51
|
+
except ImportError:
|
|
52
|
+
return ""
|
|
53
|
+
if not torch.cuda.is_available():
|
|
54
|
+
return ""
|
|
55
|
+
name = torch.cuda.get_device_name(0)
|
|
56
|
+
if getattr(torch.version, "hip", None):
|
|
57
|
+
arch = getattr(torch.cuda.get_device_properties(0), "gcnArchName", "")
|
|
58
|
+
return f"{name} ({arch.split(':')[0]})" if arch else name
|
|
59
|
+
major, minor = torch.cuda.get_device_capability(0)
|
|
60
|
+
return f"{name} (sm_{major}{minor})"
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Compare a backend's output against the frozen reference.
|
|
2
|
+
|
|
3
|
+
Checks, in order: shape, dtype, NaN placement, Inf placement (with sign),
|
|
4
|
+
then element-wise tolerance. The failure report is compact so it fits in an
|
|
5
|
+
agent's context: one line of numbers plus small expected/actual slices.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
|
|
15
|
+
from kernelport.harness.tolerances import Tolerance
|
|
16
|
+
|
|
17
|
+
SLICE_RADIUS = 4 # elements shown either side of the worst index
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TensorCompare:
|
|
22
|
+
"""Result of comparing one output tensor."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
passed: bool
|
|
26
|
+
shape: list[int]
|
|
27
|
+
dtype: str
|
|
28
|
+
reason: str = "" # empty on pass
|
|
29
|
+
max_abs_err: float = 0.0
|
|
30
|
+
max_rel_err: float = 0.0
|
|
31
|
+
worst_index: list[int] | None = None
|
|
32
|
+
num_mismatched: int = 0
|
|
33
|
+
num_elements: int = 0
|
|
34
|
+
expected_slice: list[Any] = field(default_factory=list)
|
|
35
|
+
actual_slice: list[Any] = field(default_factory=list)
|
|
36
|
+
slice_start: int = 0 # flat index of the first slice element
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
"""JSON-serialisable dict."""
|
|
40
|
+
return asdict(self)
|
|
41
|
+
|
|
42
|
+
def summary(self) -> str:
|
|
43
|
+
"""One-line human summary."""
|
|
44
|
+
if self.passed:
|
|
45
|
+
return f"{self.name}: ok (max abs {self.max_abs_err:.3g}, max rel {self.max_rel_err:.3g})"
|
|
46
|
+
return f"{self.name}: {self.reason}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dtype_name(t: torch.dtype) -> str:
|
|
50
|
+
return str(t).replace("torch.", "")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fmt(v: float) -> Any:
|
|
54
|
+
if v != v or v in (float("inf"), float("-inf")):
|
|
55
|
+
return str(v)
|
|
56
|
+
return float(f"{v:.6g}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _slices(expected: torch.Tensor, actual: torch.Tensor, flat_idx: int) -> tuple[list, list, int]:
|
|
60
|
+
e, a = expected.reshape(-1), actual.reshape(-1)
|
|
61
|
+
lo = max(0, flat_idx - SLICE_RADIUS)
|
|
62
|
+
hi = min(e.numel(), flat_idx + SLICE_RADIUS + 1)
|
|
63
|
+
return [_fmt(x) for x in e[lo:hi].tolist()], [_fmt(x) for x in a[lo:hi].tolist()], lo
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _unravel(flat: int, shape: tuple[int, ...]) -> list[int]:
|
|
67
|
+
idx = []
|
|
68
|
+
for s in reversed(shape):
|
|
69
|
+
idx.append(flat % s if s else 0)
|
|
70
|
+
flat //= s if s else 1
|
|
71
|
+
return list(reversed(idx))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compare_tensor(name: str, actual: torch.Tensor, expected: torch.Tensor,
|
|
75
|
+
out_dtype: torch.dtype, tol: Tolerance) -> TensorCompare:
|
|
76
|
+
"""Compare ``actual`` (backend output) to ``expected`` (reference, any precision)."""
|
|
77
|
+
res = TensorCompare(name, False, list(expected.shape), _dtype_name(out_dtype),
|
|
78
|
+
num_elements=expected.numel())
|
|
79
|
+
actual = actual.detach().cpu()
|
|
80
|
+
if tuple(actual.shape) != tuple(expected.shape):
|
|
81
|
+
res.reason = f"shape mismatch: expected {tuple(expected.shape)}, got {tuple(actual.shape)}"
|
|
82
|
+
return res
|
|
83
|
+
if actual.dtype != out_dtype:
|
|
84
|
+
res.reason = f"dtype mismatch: expected {_dtype_name(out_dtype)}, got {_dtype_name(actual.dtype)}"
|
|
85
|
+
return res
|
|
86
|
+
if expected.numel() == 0:
|
|
87
|
+
res.passed = True
|
|
88
|
+
return res
|
|
89
|
+
|
|
90
|
+
if not out_dtype.is_floating_point:
|
|
91
|
+
exp_i = expected.to(out_dtype).to(torch.int64)
|
|
92
|
+
act_i = actual.to(torch.int64)
|
|
93
|
+
bad = exp_i != act_i
|
|
94
|
+
diff = (exp_i - act_i).abs()
|
|
95
|
+
res.max_abs_err = float(diff.max())
|
|
96
|
+
res.num_mismatched = int(bad.sum())
|
|
97
|
+
if res.num_mismatched:
|
|
98
|
+
flat = int(torch.argmax(bad.reshape(-1).to(torch.int8)))
|
|
99
|
+
res.worst_index = _unravel(flat, tuple(expected.shape))
|
|
100
|
+
res.expected_slice, res.actual_slice, res.slice_start = _slices(exp_i, act_i, flat)
|
|
101
|
+
res.reason = (f"{res.num_mismatched}/{res.num_elements} integer values differ; "
|
|
102
|
+
f"first at {res.worst_index}")
|
|
103
|
+
return res
|
|
104
|
+
res.passed = True
|
|
105
|
+
return res
|
|
106
|
+
|
|
107
|
+
# Round the exact reference to the output dtype, then compare in float64.
|
|
108
|
+
exp = expected.to(out_dtype).double()
|
|
109
|
+
act = actual.double()
|
|
110
|
+
|
|
111
|
+
exp_nan, act_nan = torch.isnan(exp), torch.isnan(act)
|
|
112
|
+
if not torch.equal(exp_nan, act_nan):
|
|
113
|
+
bad = exp_nan != act_nan
|
|
114
|
+
flat = int(torch.argmax(bad.reshape(-1).to(torch.int8)))
|
|
115
|
+
res.num_mismatched = int(bad.sum())
|
|
116
|
+
res.worst_index = _unravel(flat, tuple(exp.shape))
|
|
117
|
+
res.expected_slice, res.actual_slice, res.slice_start = _slices(exp, act, flat)
|
|
118
|
+
kind = "unexpected NaN" if bool(act_nan.reshape(-1)[flat]) else "missing NaN"
|
|
119
|
+
res.reason = (f"{kind}: {res.num_mismatched}/{res.num_elements} NaN positions differ; "
|
|
120
|
+
f"first at {res.worst_index} (NaN may mean the element was never written)")
|
|
121
|
+
return res
|
|
122
|
+
|
|
123
|
+
exp_inf, act_inf = torch.isinf(exp), torch.isinf(act)
|
|
124
|
+
inf_bad = (exp_inf | act_inf) & ~((exp_inf & act_inf) & (torch.sign(exp) == torch.sign(act)))
|
|
125
|
+
if bool(inf_bad.any()):
|
|
126
|
+
flat = int(torch.argmax(inf_bad.reshape(-1).to(torch.int8)))
|
|
127
|
+
res.num_mismatched = int(inf_bad.sum())
|
|
128
|
+
res.worst_index = _unravel(flat, tuple(exp.shape))
|
|
129
|
+
res.expected_slice, res.actual_slice, res.slice_start = _slices(exp, act, flat)
|
|
130
|
+
res.reason = f"Inf mismatch: {res.num_mismatched}/{res.num_elements} positions; first at {res.worst_index}"
|
|
131
|
+
return res
|
|
132
|
+
|
|
133
|
+
finite = torch.isfinite(exp)
|
|
134
|
+
e, a = torch.where(finite, exp, 0.0), torch.where(finite, act, 0.0)
|
|
135
|
+
abs_err = (a - e).abs()
|
|
136
|
+
rel_err = abs_err / e.abs().clamp_min(torch.finfo(torch.float64).tiny)
|
|
137
|
+
allowed = tol.atol + tol.rtol * e.abs()
|
|
138
|
+
excess = abs_err - allowed
|
|
139
|
+
res.max_abs_err = float(abs_err.max())
|
|
140
|
+
res.max_rel_err = float(torch.where(abs_err > 0, rel_err, 0.0).max())
|
|
141
|
+
bad = excess > 0
|
|
142
|
+
res.num_mismatched = int(bad.sum())
|
|
143
|
+
if res.num_mismatched:
|
|
144
|
+
flat = int(torch.argmax(excess.reshape(-1)))
|
|
145
|
+
res.worst_index = _unravel(flat, tuple(exp.shape))
|
|
146
|
+
res.expected_slice, res.actual_slice, res.slice_start = _slices(exp, act, flat)
|
|
147
|
+
res.reason = (f"{res.num_mismatched}/{res.num_elements} values outside tolerance "
|
|
148
|
+
f"(rtol={tol.rtol:g}, atol={tol.atol:g}); max abs err {res.max_abs_err:.4g}, "
|
|
149
|
+
f"max rel err {res.max_rel_err:.4g}, worst at {res.worst_index}")
|
|
150
|
+
return res
|
|
151
|
+
res.passed = True
|
|
152
|
+
return res
|