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.
Files changed (72) hide show
  1. kernelport/__init__.py +3 -0
  2. kernelport/__main__.py +5 -0
  3. kernelport/_skills/kernelport/SKILL.md +200 -0
  4. kernelport/_skills/kernelport/backends/cpu.md +138 -0
  5. kernelport/_skills/kernelport/backends/hip.md +53 -0
  6. kernelport/_skills/kernelport/backends/metal.md +76 -0
  7. kernelport/_skills/kernelport/backends/triton.md +117 -0
  8. kernelport/_skills/kernelport/backends/webgpu.md +146 -0
  9. kernelport/_skills/kernelport/common_bugs.md +40 -0
  10. kernelport/agent.py +178 -0
  11. kernelport/analyzer/__init__.py +0 -0
  12. kernelport/analyzer/cuda_parser.py +480 -0
  13. kernelport/analyzer/models.py +80 -0
  14. kernelport/analyzer/suggest.py +120 -0
  15. kernelport/backends/__init__.py +5 -0
  16. kernelport/backends/base.py +138 -0
  17. kernelport/backends/cpu/__init__.py +1 -0
  18. kernelport/backends/cpu/capability.py +25 -0
  19. kernelport/backends/cpu/config.py +36 -0
  20. kernelport/backends/cpu/runner.py +67 -0
  21. kernelport/backends/hip/__init__.py +1 -0
  22. kernelport/backends/hip/capability.py +34 -0
  23. kernelport/backends/hip/config.py +28 -0
  24. kernelport/backends/hip/runner.py +48 -0
  25. kernelport/backends/metal/__init__.py +1 -0
  26. kernelport/backends/metal/capability.py +32 -0
  27. kernelport/backends/metal/config.py +35 -0
  28. kernelport/backends/metal/runner.py +142 -0
  29. kernelport/backends/triton/__init__.py +1 -0
  30. kernelport/backends/triton/capability.py +29 -0
  31. kernelport/backends/triton/config.py +41 -0
  32. kernelport/backends/triton/runner.py +44 -0
  33. kernelport/backends/webgpu/__init__.py +1 -0
  34. kernelport/backends/webgpu/capability.py +34 -0
  35. kernelport/backends/webgpu/config.py +38 -0
  36. kernelport/backends/webgpu/runner.py +285 -0
  37. kernelport/bench/__init__.py +0 -0
  38. kernelport/bench/timer.py +76 -0
  39. kernelport/cli.py +501 -0
  40. kernelport/doctor.py +285 -0
  41. kernelport/hardware.py +60 -0
  42. kernelport/harness/__init__.py +0 -0
  43. kernelport/harness/compare.py +152 -0
  44. kernelport/harness/device_source.py +273 -0
  45. kernelport/harness/expr.py +80 -0
  46. kernelport/harness/inputs.py +194 -0
  47. kernelport/harness/reference.py +76 -0
  48. kernelport/harness/results.py +118 -0
  49. kernelport/harness/run_cuda.py +126 -0
  50. kernelport/harness/spec.py +199 -0
  51. kernelport/harness/tolerances.py +48 -0
  52. kernelport/harness/verify.py +131 -0
  53. kernelport/hashing.py +28 -0
  54. kernelport/mcp_server.py +95 -0
  55. kernelport/report/__init__.py +1 -0
  56. kernelport/report/data.py +147 -0
  57. kernelport/report/html.py +174 -0
  58. kernelport/report/markdown.py +75 -0
  59. kernelport/setup_agents/__init__.py +1 -0
  60. kernelport/setup_agents/backup.py +59 -0
  61. kernelport/setup_agents/detect.py +78 -0
  62. kernelport/setup_agents/register.py +302 -0
  63. kernelport/setup_agents/skills_install.py +31 -0
  64. kernelport/tools.py +455 -0
  65. kernelport/tune.py +99 -0
  66. kernelport/workspace.py +400 -0
  67. kernelport/zoo.py +176 -0
  68. kernelport-0.1.0.dist-info/METADATA +228 -0
  69. kernelport-0.1.0.dist-info/RECORD +72 -0
  70. kernelport-0.1.0.dist-info/WHEEL +4 -0
  71. kernelport-0.1.0.dist-info/entry_points.txt +2 -0
  72. 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