tokenwatt-check 0.1.0__tar.gz

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.
@@ -0,0 +1,16 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ["3.9", "3.12"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: ${{ matrix.python }}
14
+ - run: pip install -e ".[dev]"
15
+ - run: pytest -q
16
+ - run: tokenwatt-check --demo throttled-h100 --json /tmp/r.json || test $? -eq 2
@@ -0,0 +1,17 @@
1
+ name: publish
2
+ on:
3
+ release:
4
+ types: [published]
5
+ jobs:
6
+ pypi:
7
+ runs-on: ubuntu-latest
8
+ environment: pypi
9
+ permissions:
10
+ id-token: write # PyPI trusted publishing; no API token stored
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pip install build && python -m build
17
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
6
+ tokenwatt-check-*.json
7
+ tokenwatt-check-*.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Teampulse Solutions LLC (TokenWatt)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.5
2
+ Name: tokenwatt-check
3
+ Version: 0.1.0
4
+ Summary: Check whether the GPU you rented performs like it should: power cap, clocks under load, compute, memory bandwidth, PCIe, disk and network.
5
+ Project-URL: Homepage, https://www.tokenwatt.io
6
+ Project-URL: Methodology, https://www.tokenwatt.io/methodology.html
7
+ Author-email: TokenWatt <hello@tokenwatt.io>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: benchmark,cloud,gpu,h100,inference,llm,nvidia,runpod,vast.ai
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Benchmark
16
+ Classifier: Topic :: System :: Hardware
17
+ Requires-Python: >=3.9
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Provides-Extra: torch
21
+ Requires-Dist: torch>=2.1; extra == 'torch'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # tokenwatt-check
25
+
26
+ **Is the GPU you rented performing like it should?**
27
+
28
+ GPU listings tell you the model — "H100 SXM, verified, 99.5% reliability" — not the card you get. When we
29
+ [benchmarked four rented H100s](https://www.tokenwatt.io/blog/runpod-vs-vast-vs-lambda-h100.html), two had their
30
+ power limit lowered by the host and one ran at **787 MHz instead of ~1,900** under load: every token took 2.9× longer.
31
+ Nothing on the listing showed it.
32
+
33
+ `tokenwatt-check` finds that in about two minutes:
34
+
35
+ ```
36
+ pip install tokenwatt-check torch # torch is usually already there on GPU images
37
+ tokenwatt-check
38
+ ```
39
+
40
+ ```
41
+ OVERALL: ❌ FAIL
42
+
43
+ ⚠️ power limit GPU power limit is 500 W of a possible 700 W (71%).
44
+ ❌ clocks under load SM clock during the matmul averaged 560 MHz — 41% of a healthy H100 (1360 MHz).
45
+ → Far below normal. Possible causes: aggressive power cap, poor cooling or
46
+ power delivery, a degraded card. Ask for another machine.
47
+ ❌ BF16 matmul 280 TFLOPS — 41% of the healthy H100 reference (680 TFLOPS).
48
+ ❌ memory copy bandwidth 2.2 TB/s — 73% of the healthy H100 reference (3.0 TB/s).
49
+ ✅ PCIe link Gen5 x16.
50
+ ✅ disk Sequential write 1,616 MB/s.
51
+ ✅ download 2,100 Mbit/s from Hugging Face, one stream (~6.3 min per 100 GB of weights).
52
+ ```
53
+
54
+ (Illustrative output modeled on a real under-performing node — see `src/tokenwatt_check/demo.py`. Try `tokenwatt-check --demo throttled-h100`, `capped-h100` or `healthy-h100` without a GPU.)
55
+
56
+ ## What it checks
57
+
58
+ | Check | How | Why it matters |
59
+ |---|---|---|
60
+ | Power limit | `nvidia-smi` power.limit vs power.max_limit | Hosts lower it to save power; throughput drops under load |
61
+ | Clocks under load | SM clock sampled every second during a 30 s BF16 matmul, compared with a healthy card of the same model in the same test | The clearest sign of a capped, hot or degraded card |
62
+ | Throttle reasons | nvidia-smi clock event reasons during load | Hardware or thermal slowdown means the card is protecting itself |
63
+ | Compute | Sustained BF16 GEMM (8192²) in TFLOPS | Prompt processing and large batches scale with it |
64
+ | Memory bandwidth | Device-to-device copy in TB/s | LLM token generation follows it closely |
65
+ | PCIe link | Generation and width under load vs the card's maximum | Slow links slow model loading |
66
+ | Memory errors | Uncorrected ECC errors, pending retired pages | Unreliable memory |
67
+ | Disk | 2 GB sequential write and read | How long weights take to load from disk |
68
+ | Download | 20 s from the Hugging Face CDN | You pay for the minutes spent downloading weights |
69
+
70
+ Results: `PASS`, `WARN` or `FAIL` per check with a reason and what to do, plus a JSON file with every reading
71
+ (`--json path`) and an optional Markdown report (`--md path`). Exit code 2 on `FAIL`, for scripts.
72
+
73
+ ## Options
74
+
75
+ ```
76
+ tokenwatt-check --gpu 1 # another GPU
77
+ tokenwatt-check --seconds 60 # longer load test
78
+ tokenwatt-check --no-load # inventory, disk and network only (no PyTorch needed)
79
+ tokenwatt-check --no-net --no-disk
80
+ ```
81
+
82
+ ## Reference values
83
+
84
+ Compute, bandwidth and clock-under-load are compared with a healthy card of the same model (`src/tokenwatt_check/verdict.py`):
85
+
86
+ | GPU | BF16 matmul | Clock during matmul | Copy bandwidth | Source |
87
+ |---|---|---|---|---|
88
+ | H100 SXM | 680 TFLOPS | 1,360 MHz | 3.0 TB/s | measured, RunPod Secure Cloud, 700 W |
89
+ | H200 | 660 TFLOPS | 1,460 MHz | 4.3 TB/s | measured, RunPod Secure Cloud, 700 W |
90
+ | others | | | | provisional (vendor specs, public measurements) |
91
+
92
+ A dense matmul pulls a healthy card to its power limit, so its clock sits well below the maximum — a healthy H100 holds
93
+ about 69% of max clock in this test. That's why the clock check compares against a healthy card rather than the maximum.
94
+ If a healthy card scores low on your machine, please open an issue with your JSON so we can improve the references.
95
+
96
+ ## Privacy
97
+
98
+ Everything runs locally. Nothing is sent anywhere except the 20-second download test from huggingface.co.
99
+
100
+ ## Supported
101
+
102
+ NVIDIA GPUs with `nvidia-smi` (H100, H200, B200, A100, L40S, RTX 4090/5090 and others). AMD Instinct support is planned.
103
+
104
+ ## About
105
+
106
+ Built by [TokenWatt](https://www.tokenwatt.io), which publishes vendor-neutral measurements of LLM inference —
107
+ SLO goodput, tokens per joule and cost per million tokens — on NVIDIA and AMD. MIT licensed.
@@ -0,0 +1,84 @@
1
+ # tokenwatt-check
2
+
3
+ **Is the GPU you rented performing like it should?**
4
+
5
+ GPU listings tell you the model — "H100 SXM, verified, 99.5% reliability" — not the card you get. When we
6
+ [benchmarked four rented H100s](https://www.tokenwatt.io/blog/runpod-vs-vast-vs-lambda-h100.html), two had their
7
+ power limit lowered by the host and one ran at **787 MHz instead of ~1,900** under load: every token took 2.9× longer.
8
+ Nothing on the listing showed it.
9
+
10
+ `tokenwatt-check` finds that in about two minutes:
11
+
12
+ ```
13
+ pip install tokenwatt-check torch # torch is usually already there on GPU images
14
+ tokenwatt-check
15
+ ```
16
+
17
+ ```
18
+ OVERALL: ❌ FAIL
19
+
20
+ ⚠️ power limit GPU power limit is 500 W of a possible 700 W (71%).
21
+ ❌ clocks under load SM clock during the matmul averaged 560 MHz — 41% of a healthy H100 (1360 MHz).
22
+ → Far below normal. Possible causes: aggressive power cap, poor cooling or
23
+ power delivery, a degraded card. Ask for another machine.
24
+ ❌ BF16 matmul 280 TFLOPS — 41% of the healthy H100 reference (680 TFLOPS).
25
+ ❌ memory copy bandwidth 2.2 TB/s — 73% of the healthy H100 reference (3.0 TB/s).
26
+ ✅ PCIe link Gen5 x16.
27
+ ✅ disk Sequential write 1,616 MB/s.
28
+ ✅ download 2,100 Mbit/s from Hugging Face, one stream (~6.3 min per 100 GB of weights).
29
+ ```
30
+
31
+ (Illustrative output modeled on a real under-performing node — see `src/tokenwatt_check/demo.py`. Try `tokenwatt-check --demo throttled-h100`, `capped-h100` or `healthy-h100` without a GPU.)
32
+
33
+ ## What it checks
34
+
35
+ | Check | How | Why it matters |
36
+ |---|---|---|
37
+ | Power limit | `nvidia-smi` power.limit vs power.max_limit | Hosts lower it to save power; throughput drops under load |
38
+ | Clocks under load | SM clock sampled every second during a 30 s BF16 matmul, compared with a healthy card of the same model in the same test | The clearest sign of a capped, hot or degraded card |
39
+ | Throttle reasons | nvidia-smi clock event reasons during load | Hardware or thermal slowdown means the card is protecting itself |
40
+ | Compute | Sustained BF16 GEMM (8192²) in TFLOPS | Prompt processing and large batches scale with it |
41
+ | Memory bandwidth | Device-to-device copy in TB/s | LLM token generation follows it closely |
42
+ | PCIe link | Generation and width under load vs the card's maximum | Slow links slow model loading |
43
+ | Memory errors | Uncorrected ECC errors, pending retired pages | Unreliable memory |
44
+ | Disk | 2 GB sequential write and read | How long weights take to load from disk |
45
+ | Download | 20 s from the Hugging Face CDN | You pay for the minutes spent downloading weights |
46
+
47
+ Results: `PASS`, `WARN` or `FAIL` per check with a reason and what to do, plus a JSON file with every reading
48
+ (`--json path`) and an optional Markdown report (`--md path`). Exit code 2 on `FAIL`, for scripts.
49
+
50
+ ## Options
51
+
52
+ ```
53
+ tokenwatt-check --gpu 1 # another GPU
54
+ tokenwatt-check --seconds 60 # longer load test
55
+ tokenwatt-check --no-load # inventory, disk and network only (no PyTorch needed)
56
+ tokenwatt-check --no-net --no-disk
57
+ ```
58
+
59
+ ## Reference values
60
+
61
+ Compute, bandwidth and clock-under-load are compared with a healthy card of the same model (`src/tokenwatt_check/verdict.py`):
62
+
63
+ | GPU | BF16 matmul | Clock during matmul | Copy bandwidth | Source |
64
+ |---|---|---|---|---|
65
+ | H100 SXM | 680 TFLOPS | 1,360 MHz | 3.0 TB/s | measured, RunPod Secure Cloud, 700 W |
66
+ | H200 | 660 TFLOPS | 1,460 MHz | 4.3 TB/s | measured, RunPod Secure Cloud, 700 W |
67
+ | others | | | | provisional (vendor specs, public measurements) |
68
+
69
+ A dense matmul pulls a healthy card to its power limit, so its clock sits well below the maximum — a healthy H100 holds
70
+ about 69% of max clock in this test. That's why the clock check compares against a healthy card rather than the maximum.
71
+ If a healthy card scores low on your machine, please open an issue with your JSON so we can improve the references.
72
+
73
+ ## Privacy
74
+
75
+ Everything runs locally. Nothing is sent anywhere except the 20-second download test from huggingface.co.
76
+
77
+ ## Supported
78
+
79
+ NVIDIA GPUs with `nvidia-smi` (H100, H200, B200, A100, L40S, RTX 4090/5090 and others). AMD Instinct support is planned.
80
+
81
+ ## About
82
+
83
+ Built by [TokenWatt](https://www.tokenwatt.io), which publishes vendor-neutral measurements of LLM inference —
84
+ SLO goodput, tokens per joule and cost per million tokens — on NVIDIA and AMD. MIT licensed.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tokenwatt-check"
7
+ version = "0.1.0"
8
+ description = "Check whether the GPU you rented performs like it should: power cap, clocks under load, compute, memory bandwidth, PCIe, disk and network."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "TokenWatt", email = "hello@tokenwatt.io" }]
13
+ keywords = ["gpu", "benchmark", "h100", "cloud", "llm", "inference", "nvidia", "vast.ai", "runpod"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: Science/Research",
19
+ "Topic :: System :: Benchmark",
20
+ "Topic :: System :: Hardware",
21
+ ]
22
+ dependencies = [] # stdlib only; the load test uses PyTorch if it is installed
23
+
24
+ [project.optional-dependencies]
25
+ torch = ["torch>=2.1"]
26
+ dev = ["pytest>=7"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://www.tokenwatt.io"
30
+ Methodology = "https://www.tokenwatt.io/methodology.html"
31
+
32
+ [project.scripts]
33
+ tokenwatt-check = "tokenwatt_check.cli:main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/tokenwatt_check"]
@@ -0,0 +1,3 @@
1
+ """Check whether the GPU you rented performs like it should."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+ import sys
3
+ sys.exit(main())
@@ -0,0 +1,115 @@
1
+ """tokenwatt-check: is the GPU you rented performing like it should?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import datetime as dt
7
+ import json
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from . import __version__, gpu, host, load
13
+ from .demo import DEMOS
14
+ from .verdict import evaluate, findings_json
15
+
16
+ ICON = {"PASS": "✅", "WARN": "⚠️ ", "FAIL": "❌", "INFO": "ℹ️ "}
17
+
18
+
19
+ def say(msg: str = "") -> None:
20
+ print(msg, flush=True)
21
+
22
+
23
+ def run_checks(a) -> dict:
24
+ if not gpu.available():
25
+ sys.exit("nvidia-smi not found. tokenwatt-check currently supports NVIDIA GPUs (AMD support is planned).")
26
+ inv_all = gpu.inventory()
27
+ if a.gpu >= len(inv_all):
28
+ sys.exit(f"GPU {a.gpu} not found ({len(inv_all)} visible)")
29
+ inv = inv_all[a.gpu]
30
+ say(f"GPU {a.gpu}: {inv.get('name')} · driver {inv.get('driver_version')} · {inv.get('memory.total')} MiB")
31
+ loadres: dict = {}
32
+ ok, why = load.torch_available()
33
+ if a.no_load:
34
+ loadres["skipped"] = "--no-load"
35
+ elif not ok:
36
+ loadres["skipped"] = why
37
+ say(f"load test skipped: {why}")
38
+ else:
39
+ sampler = gpu.Sampler(gpu=a.gpu).start()
40
+ try:
41
+ say(f"load test: BF16 matmul for {a.seconds:.0f} s ({why}) …")
42
+ mm = load.matmul_tflops(seconds=a.seconds, device=a.gpu)
43
+ say(f"load test: memory copy for {a.seconds / 2:.0f} s …")
44
+ cp = load.copy_bandwidth(seconds=a.seconds / 2, device=a.gpu)
45
+ finally:
46
+ samples = sampler.stop()
47
+ # skip the first few seconds of each phase while clocks settle
48
+ mm["telemetry"] = gpu.summarize(samples, mm["t_start"] + 3, mm["t_end"])
49
+ cp["telemetry"] = gpu.summarize(samples, cp["t_start"] + 2, cp["t_end"])
50
+ loadres = {"matmul": mm, "copy": cp}
51
+ disk = {"skipped": "--no-disk"} if a.no_disk else (say("disk: 2 GB write/read …") or host.disk(a.disk_path))
52
+ net = {"skipped": "--no-net"} if a.no_net else (say("download: 20 s from huggingface.co …") or host.download())
53
+ return {"inventory": inv, "gpus_visible": len(inv_all), "load": loadres, "disk": disk, "download": net}
54
+
55
+
56
+ def render(result: dict) -> str:
57
+ inv, overall, findings, m = result["inventory"], result["overall"], result["findings"], result["metrics"]
58
+ lines = ["", f"tokenwatt-check {result['version']} · {result['time']}",
59
+ f"{inv.get('name')} · driver {inv.get('driver_version')} · {result['host'].get('hostname', '')}", "",
60
+ f" OVERALL: {ICON[overall]} {overall}", ""]
61
+ for x in findings:
62
+ lines.append(f" {ICON[x['level']]} {x['check']:<22} {x['message']}")
63
+ if x["advice"]:
64
+ lines.append(f" {'':<22} → {x['advice']}")
65
+ if m:
66
+ lines += ["", " " + " · ".join(f"{k}={v}" for k, v in m.items())]
67
+ if result.get("demo"):
68
+ lines += ["", f" (demo: {result['demo']})"]
69
+ lines += ["", " Method and reference values: https://www.tokenwatt.io/methodology.html", ""]
70
+ return "\n".join(lines)
71
+
72
+
73
+ def markdown(result: dict) -> str:
74
+ inv = result["inventory"]
75
+ rows = "\n".join(f"| {ICON[x['level']].strip()} {x['level']} | {x['check']} | {x['message']} | {x['advice']} |" for x in result["findings"])
76
+ return (f"# tokenwatt-check report\n\n**{inv.get('name')}** · driver {inv.get('driver_version')} · {result['time']}\n\n"
77
+ f"**Overall: {result['overall']}**\n\n| Result | Check | Finding | What to do |\n|---|---|---|---|\n{rows}\n\n"
78
+ f"Generated by [tokenwatt-check](https://www.tokenwatt.io) {result['version']}.\n")
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ ap = argparse.ArgumentParser(prog="tokenwatt-check", description=__doc__)
83
+ ap.add_argument("--version", action="version", version=f"tokenwatt-check {__version__}")
84
+ ap.add_argument("--gpu", type=int, default=0, help="GPU index (default 0)")
85
+ ap.add_argument("--seconds", type=float, default=30, help="matmul load duration; copy test runs half as long")
86
+ ap.add_argument("--no-load", action="store_true", help="skip the GPU load test")
87
+ ap.add_argument("--no-disk", action="store_true")
88
+ ap.add_argument("--no-net", action="store_true")
89
+ ap.add_argument("--disk-path", default=".", help="where to write the temporary disk-test file")
90
+ ap.add_argument("--json", help="write the full result to this file")
91
+ ap.add_argument("--md", help="write a Markdown report to this file")
92
+ ap.add_argument("--demo", choices=sorted(DEMOS), help="show an example report without a GPU")
93
+ a = ap.parse_args(argv)
94
+
95
+ t0 = time.time()
96
+ raw = DEMOS[a.demo] if a.demo else run_checks(a)
97
+ overall, findings, metrics = evaluate(raw["inventory"], raw.get("load", {}), raw.get("disk", {}), raw.get("download", {}))
98
+ result = {"tool": "tokenwatt-check", "version": __version__,
99
+ "time": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(),
100
+ "host": raw.get("host") or host.info(), **raw, "overall": overall,
101
+ "findings": findings_json(findings), "metrics": metrics, "elapsed_s": round(time.time() - t0, 1)}
102
+ if a.demo:
103
+ result["demo"] = raw.get("note", a.demo)
104
+ say(render(result))
105
+ stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
106
+ jpath = Path(a.json or f"tokenwatt-check-{stamp}.json")
107
+ jpath.write_text(json.dumps(result, indent=2, default=str))
108
+ if a.md:
109
+ Path(a.md).write_text(markdown(result))
110
+ say(f" full result: {jpath}")
111
+ return {"PASS": 0, "WARN": 0, "FAIL": 2}[overall]
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main())
@@ -0,0 +1,34 @@
1
+ """Example inputs for --demo, modeled on three H100 SXM nodes TokenWatt rented in October 2026.
2
+
3
+ The healthy example is a real tokenwatt-check run (RunPod Secure Cloud H100, 2026-10-09). The capped
4
+ and throttled examples use the power limits, inference-load clocks and host speeds we read on two
5
+ Vast.ai hosts; their matmul clocks and throughput are estimated from those readings, because those
6
+ runs predate this tool's load test.
7
+ """
8
+
9
+
10
+ def _h100(name, power_limit, clk, tflops, tb_s, disk_w, mbit, reasons, note):
11
+ return {
12
+ "note": note,
13
+ "host": {"hostname": name},
14
+ "inventory": {"name": "NVIDIA H100 80GB HBM3", "driver_version": "580.126.09", "memory.total": 81559,
15
+ "pcie.link.gen.max": 5, "pcie.link.width.max": 16, "pcie.link.gen.current": 5,
16
+ "pcie.link.width.current": 16, "power.limit": power_limit, "power.max_limit": 700,
17
+ "clocks.max.sm": 1980, "ecc.errors.uncorrected.volatile.total": 0, "retired_pages.pending": "No"},
18
+ "load": {"matmul": {"tflops": tflops, "telemetry": {"sm_mhz_mean": clk, "temp_c_max": 58,
19
+ "pcie_gen_under_load": 5, "pcie_width_under_load": 16,
20
+ "throttle_reasons": reasons}},
21
+ "copy": {"tb_s": tb_s}},
22
+ "disk": {"write_mb_s": disk_w},
23
+ "download": {"mbit_s": mbit, "minutes_per_100gb": round(100e3 * 8 / mbit / 60, 1)},
24
+ }
25
+
26
+
27
+ DEMOS = {
28
+ "healthy-h100": _h100("demo-healthy", 700, 1359, 682.3, 3.05, 1726, 9400, {"sw_power_cap": 1.0},
29
+ "healthy H100 SXM (modeled on a RunPod Secure Cloud node)"),
30
+ "capped-h100": _h100("demo-capped", 525, 1150, 575, 2.9, 4400, 6000, {"sw_power_cap": 1.0},
31
+ "power-capped H100 SXM (modeled on a Vast.ai datacenter host capped at 525 W)"),
32
+ "throttled-h100": _h100("demo-throttled", 500, 560, 280, 2.2, 1616, 2100, {"sw_power_cap": 1.0},
33
+ "under-performing H100 SXM (modeled on a Vast.ai community host: 787 MHz at a 500 W cap)"),
34
+ }
@@ -0,0 +1,144 @@
1
+ """GPU inventory and live sampling through nvidia-smi (no Python bindings needed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass, field
10
+
11
+ STATIC_FIELDS = [
12
+ "index", "name", "uuid", "driver_version", "memory.total", "pcie.link.gen.max", "pcie.link.width.max",
13
+ "pcie.link.gen.current", "pcie.link.width.current", "power.limit", "power.max_limit", "power.default_limit",
14
+ "clocks.max.sm", "clocks.max.memory", "ecc.mode.current", "ecc.errors.uncorrected.volatile.total",
15
+ "retired_pages.pending", "temperature.gpu", "persistence_mode", "compute_mode",
16
+ ]
17
+ SAMPLE_FIELDS = ["index", "clocks.sm", "clocks.mem", "power.draw", "temperature.gpu", "utilization.gpu",
18
+ "pcie.link.gen.current", "pcie.link.width.current", "clocks_event_reasons.active"]
19
+
20
+ # nvidia-smi clocks_event_reasons bitmask
21
+ REASONS = {
22
+ 0x1: "gpu_idle", 0x2: "applications_clocks_setting", 0x4: "sw_power_cap", 0x8: "hw_slowdown",
23
+ 0x10: "sync_boost", 0x20: "sw_thermal_slowdown", 0x40: "hw_thermal_slowdown", 0x80: "hw_power_brake_slowdown",
24
+ 0x100: "display_clock_setting",
25
+ }
26
+
27
+
28
+ def available() -> bool:
29
+ return shutil.which("nvidia-smi") is not None
30
+
31
+
32
+ def _num(v: str):
33
+ v = v.strip()
34
+ if v in ("[N/A]", "N/A", "[Not Supported]", "Not Supported", ""):
35
+ return None
36
+ for unit in (" MiB", " W", " MHz", " %"):
37
+ if v.endswith(unit):
38
+ v = v[: -len(unit)]
39
+ try:
40
+ return int(v) if v.lstrip("-").isdigit() else float(v)
41
+ except ValueError:
42
+ return v
43
+
44
+
45
+ def query(fields: list[str]) -> list[dict]:
46
+ """Run nvidia-smi --query-gpu for these fields; unsupported fields come back as None."""
47
+ out = subprocess.run(["nvidia-smi", f"--query-gpu={','.join(fields)}", "--format=csv,noheader"],
48
+ capture_output=True, text=True, timeout=20)
49
+ if out.returncode != 0:
50
+ # some drivers reject unknown fields as a whole; retry field by field
51
+ rows: list[dict] = []
52
+ for f in fields:
53
+ r = subprocess.run(["nvidia-smi", f"--query-gpu={f}", "--format=csv,noheader"],
54
+ capture_output=True, text=True, timeout=20)
55
+ vals = r.stdout.strip().splitlines() if r.returncode == 0 else []
56
+ for i, v in enumerate(vals):
57
+ while len(rows) <= i:
58
+ rows.append({})
59
+ rows[i][f] = _num(v)
60
+ return rows
61
+ rows = []
62
+ for line in out.stdout.strip().splitlines():
63
+ parts = [p.strip() for p in line.split(",")]
64
+ rows.append({f: _num(p) for f, p in zip(fields, parts)})
65
+ return rows
66
+
67
+
68
+ def inventory() -> list[dict]:
69
+ return query(STATIC_FIELDS)
70
+
71
+
72
+ def decode_reasons(mask) -> list[str]:
73
+ if isinstance(mask, str):
74
+ try:
75
+ mask = int(mask, 16)
76
+ except ValueError:
77
+ return []
78
+ if not isinstance(mask, int):
79
+ return []
80
+ return [name for bit, name in REASONS.items() if mask & bit]
81
+
82
+
83
+ @dataclass
84
+ class Sampler:
85
+ """Samples clocks, power, temperature and throttle reasons once a second in a thread."""
86
+ gpu: int = 0
87
+ period: float = 1.0
88
+ samples: list[dict] = field(default_factory=list)
89
+ _stop: threading.Event = field(default_factory=threading.Event)
90
+ _thread: threading.Thread | None = None
91
+
92
+ def _loop(self) -> None:
93
+ while not self._stop.is_set():
94
+ t = time.time()
95
+ try:
96
+ rows = query(SAMPLE_FIELDS)
97
+ if self.gpu < len(rows):
98
+ s = rows[self.gpu]
99
+ s["t"] = t
100
+ s["reasons"] = decode_reasons(s.get("clocks_event_reasons.active"))
101
+ self.samples.append(s)
102
+ except Exception: # noqa: BLE001 — keep sampling
103
+ pass
104
+ self._stop.wait(max(0.0, self.period - (time.time() - t)))
105
+
106
+ def start(self) -> "Sampler":
107
+ self._thread = threading.Thread(target=self._loop, daemon=True)
108
+ self._thread.start()
109
+ return self
110
+
111
+ def stop(self) -> list[dict]:
112
+ self._stop.set()
113
+ if self._thread:
114
+ self._thread.join(timeout=10)
115
+ return self.samples
116
+
117
+
118
+ def summarize(samples: list[dict], t0: float, t1: float) -> dict:
119
+ s = [x for x in samples if t0 <= x["t"] <= t1]
120
+ if not s:
121
+ return {}
122
+
123
+ def vals(k):
124
+ return [x[k] for x in s if isinstance(x.get(k), (int, float))]
125
+ out = {"samples": len(s)}
126
+ for k, name in (("clocks.sm", "sm_mhz"), ("clocks.mem", "mem_mhz"), ("power.draw", "power_w"),
127
+ ("temperature.gpu", "temp_c"), ("utilization.gpu", "util_pct")):
128
+ v = vals(k)
129
+ if v:
130
+ out[f"{name}_mean"] = round(sum(v) / len(v), 1)
131
+ out[f"{name}_min"] = min(v)
132
+ out[f"{name}_max"] = max(v)
133
+ reasons: dict[str, int] = {}
134
+ for x in s:
135
+ for r in x.get("reasons", []):
136
+ reasons[r] = reasons.get(r, 0) + 1
137
+ out["throttle_reasons"] = {k: round(v / len(s), 2) for k, v in reasons.items()} # share of samples
138
+ gens = vals("pcie.link.gen.current")
139
+ widths = vals("pcie.link.width.current")
140
+ if gens:
141
+ out["pcie_gen_under_load"] = max(gens)
142
+ if widths:
143
+ out["pcie_width_under_load"] = max(widths)
144
+ return out
@@ -0,0 +1,78 @@
1
+ """Host checks that decide how long a model takes to load: disk throughput and download speed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import time
9
+ import urllib.request
10
+
11
+ # a large public file on the Hugging Face CDN (what model weights come from)
12
+ DOWNLOAD_URL = "https://huggingface.co/Qwen/Qwen3-30B-A3B-FP8/resolve/main/model-00001-of-00007.safetensors"
13
+
14
+
15
+ def info() -> dict:
16
+ out = {"hostname": platform.node(), "os": platform.platform(), "python": platform.python_version(),
17
+ "cpus": os.cpu_count()}
18
+ try:
19
+ with open("/proc/meminfo") as f:
20
+ kb = int(next(line for line in f if line.startswith("MemTotal")).split()[1])
21
+ out["ram_gb"] = round(kb / 2 ** 20)
22
+ except (OSError, StopIteration, ValueError):
23
+ pass
24
+ try:
25
+ with open("/proc/cpuinfo") as f:
26
+ out["cpu"] = next(line.split(":", 1)[1].strip() for line in f if line.startswith("model name"))
27
+ except (OSError, StopIteration):
28
+ pass
29
+ return out
30
+
31
+
32
+ def disk(path: str = ".", gib: float = 2.0) -> dict:
33
+ """Sequential write (with fsync) and read of a temporary file in `path`."""
34
+ usage = shutil.disk_usage(path)
35
+ out = {"path": os.path.abspath(path), "free_gb": round(usage.free / 1e9)}
36
+ if usage.free < gib * 2 ** 30 * 1.5:
37
+ out["skipped"] = "not enough free space"
38
+ return out
39
+ fn = os.path.join(path, f".tokenwatt-check-{os.getpid()}.tmp")
40
+ block = os.urandom(16 * 2 ** 20)
41
+ n = int(gib * 2 ** 30 // len(block))
42
+ try:
43
+ t0 = time.perf_counter()
44
+ with open(fn, "wb", buffering=0) as f:
45
+ for _ in range(n):
46
+ f.write(block)
47
+ os.fsync(f.fileno())
48
+ out["write_mb_s"] = round(n * len(block) / (time.perf_counter() - t0) / 1e6)
49
+ t0 = time.perf_counter()
50
+ with open(fn, "rb", buffering=0) as f:
51
+ while f.read(16 * 2 ** 20):
52
+ pass
53
+ out["read_mb_s"] = round(n * len(block) / (time.perf_counter() - t0) / 1e6)
54
+ out["read_note"] = "may be served from page cache"
55
+ finally:
56
+ try:
57
+ os.remove(fn)
58
+ except OSError:
59
+ pass
60
+ return out
61
+
62
+
63
+ def download(seconds: float = 20.0, url: str = DOWNLOAD_URL) -> dict:
64
+ req = urllib.request.Request(url, headers={"User-Agent": "tokenwatt-check/0.1"})
65
+ got, t0 = 0, time.perf_counter()
66
+ try:
67
+ with urllib.request.urlopen(req, timeout=20) as r:
68
+ while time.perf_counter() - t0 < seconds:
69
+ chunk = r.read(4 * 2 ** 20)
70
+ if not chunk:
71
+ break
72
+ got += len(chunk)
73
+ except Exception as e: # noqa: BLE001
74
+ return {"error": f"{type(e).__name__}: {e}"[:160]}
75
+ el = time.perf_counter() - t0
76
+ mbit = got * 8 / el / 1e6 if el else 0
77
+ return {"mbit_s": round(mbit), "mb": round(got / 1e6), "seconds": round(el, 1), "source": "huggingface.co",
78
+ "minutes_per_100gb": round(100e3 * 8 / mbit / 60, 1) if mbit else None}
@@ -0,0 +1,73 @@
1
+ """Short GPU load tests with PyTorch: dense BF16 matmul (compute) and device-to-device copy (memory bandwidth)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+
8
+ def torch_available() -> tuple[bool, str]:
9
+ try:
10
+ import torch # noqa: F401
11
+ except Exception as e: # noqa: BLE001
12
+ return False, f"PyTorch not importable ({type(e).__name__}); install it with `pip install torch`"
13
+ import torch
14
+ if not torch.cuda.is_available():
15
+ return False, "PyTorch can't see a CUDA device"
16
+ return True, f"torch {torch.__version__}, CUDA {torch.version.cuda}"
17
+
18
+
19
+ def matmul_tflops(seconds: float = 30.0, n: int = 8192, device: int = 0) -> dict:
20
+ """Sustained dense BF16 GEMM throughput. Runs long enough for clocks and power to settle."""
21
+ import torch
22
+ torch.cuda.set_device(device)
23
+ a = torch.randn(n, n, device="cuda", dtype=torch.bfloat16)
24
+ b = torch.randn(n, n, device="cuda", dtype=torch.bfloat16)
25
+ for _ in range(5): # warm up kernels and clocks
26
+ a @ b
27
+ torch.cuda.synchronize()
28
+ flop = 2 * n ** 3
29
+ iters, t0 = 0, time.perf_counter()
30
+ t_start = time.time()
31
+ while True:
32
+ for _ in range(10):
33
+ a @ b
34
+ torch.cuda.synchronize()
35
+ iters += 10
36
+ el = time.perf_counter() - t0
37
+ if el >= seconds:
38
+ break
39
+ t_end = time.time()
40
+ del a, b
41
+ torch.cuda.empty_cache()
42
+ return {"tflops": round(flop * iters / el / 1e12, 1), "n": n, "dtype": "bf16", "seconds": round(el, 1),
43
+ "t_start": t_start, "t_end": t_end}
44
+
45
+
46
+ def copy_bandwidth(seconds: float = 15.0, gib: float = 4.0, device: int = 0) -> dict:
47
+ """Device-to-device copy bandwidth (read + write counted), the bound for LLM decode."""
48
+ import torch
49
+ torch.cuda.set_device(device)
50
+ free, _total = torch.cuda.mem_get_info()
51
+ gib = min(gib, free / 2 ** 30 / 2.5) # two buffers plus headroom
52
+ n = int(gib * 2 ** 30) // 2
53
+ src = torch.empty(n, device="cuda", dtype=torch.float16)
54
+ dst = torch.empty_like(src)
55
+ for _ in range(3):
56
+ dst.copy_(src)
57
+ torch.cuda.synchronize()
58
+ iters, t0 = 0, time.perf_counter()
59
+ t_start = time.time()
60
+ while True:
61
+ for _ in range(5):
62
+ dst.copy_(src)
63
+ torch.cuda.synchronize()
64
+ iters += 5
65
+ el = time.perf_counter() - t0
66
+ if el >= seconds:
67
+ break
68
+ t_end = time.time()
69
+ moved = 2 * n * 2 * iters # bytes read + written
70
+ del src, dst
71
+ torch.cuda.empty_cache()
72
+ return {"tb_s": round(moved / el / 1e12, 2), "buffer_gib": round(gib, 2), "seconds": round(el, 1),
73
+ "t_start": t_start, "t_end": t_end}
@@ -0,0 +1,157 @@
1
+ """Turn measurements into findings: PASS / WARN / FAIL with a reason and what to do about it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+
7
+ # "Healthy" reference for this tool's own load tests: sustained dense BF16 GEMM (n=8192), the SM
8
+ # clock during that GEMM, and device-to-device copy bandwidth counted as read+write. Entries marked
9
+ # "measured" come from cards TokenWatt ran this tool on; the rest are provisional (vendor specs and
10
+ # public measurements) until we measure one. Matched against nvidia-smi's name, most specific first.
11
+ #
12
+ # A dense GEMM pulls a healthy card to its power limit, so its clock sits well below clocks.max.sm
13
+ # (an H100 SXM at 700 W holds ~1,360 MHz of 1,980). The clock check compares against `gemm_mhz`.
14
+ REFERENCE = [
15
+ ("H100 NVL", {"tflops": 550, "tb_s": 3.3}),
16
+ ("H100 PCIe", {"tflops": 450, "tb_s": 1.7}),
17
+ ("H100", {"tflops": 680, "tb_s": 3.0, "gemm_mhz": 1360, "measured": "RunPod Secure Cloud, 700 W, 2026-10-09"}), # SXM
18
+ ("H200", {"tflops": 660, "tb_s": 4.3, "gemm_mhz": 1460, "measured": "RunPod Secure Cloud, 700 W, 2026-10-09"}),
19
+ ("B200", {"tflops": 1400, "tb_s": 6.5}),
20
+ ("A100-SXM4-80GB", {"tflops": 270, "tb_s": 1.7}),
21
+ ("A100 80GB PCIe", {"tflops": 250, "tb_s": 1.6}),
22
+ ("A100", {"tflops": 250, "tb_s": 1.3}), # 40 GB
23
+ ("L40S", {"tflops": 250, "tb_s": 0.75}),
24
+ ("RTX 6000 Ada", {"tflops": 250, "tb_s": 0.8}),
25
+ ("RTX 5090", {"tflops": 200, "tb_s": 1.5}),
26
+ ("RTX 4090", {"tflops": 150, "tb_s": 0.85}),
27
+ ("A10", {"tflops": 100, "tb_s": 0.5}),
28
+ ]
29
+
30
+
31
+ def reference_for(name: str | None) -> tuple[str, dict] | None:
32
+ if not name:
33
+ return None
34
+ for key, ref in REFERENCE:
35
+ if key.lower() in name.lower():
36
+ return key, ref
37
+ return None
38
+
39
+
40
+ @dataclass
41
+ class Finding:
42
+ level: str # PASS | WARN | FAIL | INFO
43
+ check: str
44
+ message: str
45
+ advice: str = ""
46
+
47
+
48
+ def evaluate(inv: dict, load: dict, host_disk: dict, host_net: dict) -> tuple[str, list[Finding], dict]:
49
+ f: list[Finding] = []
50
+ metrics: dict = {}
51
+
52
+ # power cap
53
+ lim, mx = inv.get("power.limit"), inv.get("power.max_limit")
54
+ if isinstance(lim, (int, float)) and isinstance(mx, (int, float)) and mx:
55
+ ratio = lim / mx
56
+ metrics["power_limit_pct"] = round(ratio * 100)
57
+ if ratio < 0.95:
58
+ f.append(Finding("WARN", "power limit",
59
+ f"GPU power limit is {lim:.0f} W of a possible {mx:.0f} W ({ratio:.0%}).",
60
+ "The host lowered it. Expect lower throughput under heavy load; compare price per token, not per hour."))
61
+ else:
62
+ f.append(Finding("PASS", "power limit", f"Full power limit: {lim:.0f} W."))
63
+
64
+ gemm = load.get("matmul") or {}
65
+ tel = gemm.get("telemetry") or {}
66
+ # clocks under load: against a healthy card's clock in the same GEMM when we have one,
67
+ # otherwise against clocks.max.sm with looser thresholds
68
+ ref = reference_for(inv.get("name"))
69
+ mxclk, clk = inv.get("clocks.max.sm"), tel.get("sm_mhz_mean")
70
+ base = (ref[1].get("gemm_mhz") if ref else None) or None
71
+ if isinstance(clk, (int, float)) and (base or (isinstance(mxclk, (int, float)) and mxclk)):
72
+ r = clk / (base or mxclk)
73
+ if not base:
74
+ r = min(1.0, r / 0.7) # a healthy card at its power limit runs ~70% of max under GEMM
75
+ metrics["clock_under_load_pct"] = round(r * 100)
76
+ msg = (f"SM clock during the matmul averaged {clk:.0f} MHz — {r:.0%} of a healthy {ref[0]} ({base} MHz)"
77
+ if base else f"SM clock during the matmul averaged {clk:.0f} MHz of {mxclk:.0f} MHz max")
78
+ if r < 0.6:
79
+ f.append(Finding("FAIL", "clocks under load", msg + ".",
80
+ "Far below normal. Possible causes: aggressive power cap, poor cooling or power delivery, a degraded card. Ask for another machine."))
81
+ elif r < 0.8:
82
+ f.append(Finding("WARN", "clocks under load", msg + ".", "Lower than a healthy card under this load; usually a power or thermal limit."))
83
+ else:
84
+ f.append(Finding("PASS", "clocks under load", msg + "."))
85
+
86
+ reasons = tel.get("throttle_reasons") or {}
87
+ hard = {k: v for k, v in reasons.items() if k in ("hw_slowdown", "hw_thermal_slowdown", "sw_thermal_slowdown", "hw_power_brake_slowdown") and v >= 0.1}
88
+ if hard:
89
+ f.append(Finding("FAIL", "throttling", "Hardware or thermal slowdown active during load: " +
90
+ ", ".join(f"{k} ({v:.0%} of samples)" for k, v in hard.items()) + ".",
91
+ "This is not a setting the host chose — the card is protecting itself. Avoid this machine."))
92
+ elif reasons.get("sw_power_cap", 0) >= 0.5:
93
+ f.append(Finding("INFO", "throttling", f"Running at its power limit for {reasons['sw_power_cap']:.0%} of the load test (normal under full load)."))
94
+
95
+ # compute and bandwidth vs reference
96
+ for key, val, unit, label in (("tflops", gemm.get("tflops"), "TFLOPS", "BF16 matmul"),
97
+ ("tb_s", (load.get("copy") or {}).get("tb_s"), "TB/s", "memory copy bandwidth")):
98
+ if not isinstance(val, (int, float)):
99
+ continue
100
+ metrics[key] = val
101
+ if not ref:
102
+ f.append(Finding("INFO", label, f"{val} {unit} (no reference yet for this GPU)."))
103
+ continue
104
+ pct = val / ref[1][key]
105
+ metrics[f"{key}_vs_reference_pct"] = round(pct * 100)
106
+ msg = f"{val} {unit} — {pct:.0%} of the healthy {ref[0]} reference ({ref[1][key]} {unit})"
107
+ level = "FAIL" if pct < 0.75 else "WARN" if pct < 0.9 else "PASS"
108
+ why = ("Prompt processing (prefill) and large batches scale with compute." if key == "tflops"
109
+ else "LLM token generation (decode) speed follows memory bandwidth closely.")
110
+ f.append(Finding(level, label, msg + ".", "" if level == "PASS" else f"Below what this GPU model normally delivers. {why}"))
111
+ if ref is None and inv.get("name"):
112
+ metrics["reference"] = None
113
+
114
+ # PCIe link
115
+ gmax, wmax = inv.get("pcie.link.gen.max"), inv.get("pcie.link.width.max")
116
+ gcur, wcur = tel.get("pcie_gen_under_load", inv.get("pcie.link.gen.current")), tel.get("pcie_width_under_load", inv.get("pcie.link.width.current"))
117
+ if all(isinstance(x, (int, float)) for x in (gmax, wmax, gcur, wcur)):
118
+ if gcur < gmax or wcur < wmax:
119
+ f.append(Finding("WARN", "PCIe link", f"PCIe link is Gen{gcur} x{wcur}, below the card's Gen{gmax} x{wmax}.",
120
+ "Slows model loading and host↔GPU transfers; mostly harmless once the model is loaded."))
121
+ else:
122
+ f.append(Finding("PASS", "PCIe link", f"Gen{gcur} x{wcur}."))
123
+
124
+ # memory errors
125
+ ecc, pend = inv.get("ecc.errors.uncorrected.volatile.total"), inv.get("retired_pages.pending")
126
+ if isinstance(ecc, (int, float)) and ecc > 0:
127
+ f.append(Finding("FAIL", "memory errors", f"{ecc:.0f} uncorrected ECC errors since the driver loaded.", "Unreliable memory. Do not run production work here."))
128
+ if isinstance(pend, str) and pend.lower() == "yes":
129
+ f.append(Finding("WARN", "memory errors", "Retired memory pages are pending a reset.", "The card needs a reset to retire bad memory."))
130
+
131
+ tmax = tel.get("temp_c_max")
132
+ if isinstance(tmax, (int, float)) and tmax >= 87:
133
+ f.append(Finding("WARN", "temperature", f"Reached {tmax:.0f} °C under load.", "Hot; sustained work may throttle."))
134
+
135
+ # host
136
+ w = host_disk.get("write_mb_s")
137
+ if isinstance(w, (int, float)):
138
+ metrics["disk_write_mb_s"] = w
139
+ f.append(Finding("WARN" if w < 1000 else "PASS", "disk", f"Sequential write {w:,} MB/s.",
140
+ "Slow disk: large models will take minutes to load." if w < 1000 else ""))
141
+ mb = host_net.get("mbit_s")
142
+ if isinstance(mb, (int, float)):
143
+ metrics["download_mbit_s"] = mb
144
+ mins = host_net.get("minutes_per_100gb")
145
+ # single-stream download; the same host measured 0.9 and 9.5 Gbit/s on different days, so only flag clearly slow links
146
+ f.append(Finding("WARN" if mb < 500 else "PASS", "download", f"{mb:,} Mbit/s from Hugging Face, one stream" +
147
+ (f" (~{mins} min per 100 GB of weights)." if mins else "."),
148
+ "You pay for the minutes spent downloading weights." if mb < 500 else ""))
149
+
150
+ order = {"FAIL": 3, "WARN": 2, "PASS": 1, "INFO": 0}
151
+ worst = max((order[x.level] for x in f), default=0)
152
+ overall = {3: "FAIL", 2: "WARN"}.get(worst, "PASS")
153
+ return overall, f, metrics
154
+
155
+
156
+ def findings_json(findings: list[Finding]) -> list[dict]:
157
+ return [asdict(x) for x in findings]
@@ -0,0 +1,46 @@
1
+ from tokenwatt_check.demo import DEMOS
2
+ from tokenwatt_check.gpu import _num, decode_reasons, summarize
3
+ from tokenwatt_check.verdict import evaluate, reference_for
4
+
5
+
6
+ def run(name):
7
+ d = DEMOS[name]
8
+ return evaluate(d["inventory"], d["load"], d["disk"], d["download"])
9
+
10
+
11
+ def test_demo_verdicts():
12
+ assert run("healthy-h100")[0] == "PASS"
13
+ assert run("capped-h100")[0] == "WARN"
14
+ overall, findings, metrics = run("throttled-h100")
15
+ assert overall == "FAIL"
16
+ assert metrics["clock_under_load_pct"] < 60
17
+ assert any(f.check == "clocks under load" and f.level == "FAIL" for f in findings)
18
+
19
+
20
+ def test_reference_matching_prefers_specific_names():
21
+ assert reference_for("NVIDIA H100 PCIe")[0] == "H100 PCIe"
22
+ assert reference_for("NVIDIA H100 80GB HBM3")[0] == "H100"
23
+ assert reference_for("NVIDIA A100-SXM4-80GB")[0] == "A100-SXM4-80GB"
24
+ assert reference_for("Tesla T4") is None
25
+
26
+
27
+ def test_nvidia_smi_parsing():
28
+ assert _num("700.00 W") == 700.0
29
+ assert _num("81559 MiB") == 81559
30
+ assert _num("[N/A]") is None
31
+ assert decode_reasons("0x0000000000000004") == ["sw_power_cap"]
32
+ assert decode_reasons("0x0000000000000048") == ["hw_slowdown", "hw_thermal_slowdown"]
33
+
34
+
35
+ def test_hardware_slowdown_fails():
36
+ d = DEMOS["healthy-h100"]
37
+ load = {"matmul": {**d["load"]["matmul"], "telemetry": {**d["load"]["matmul"]["telemetry"],
38
+ "throttle_reasons": {"hw_slowdown": 0.4}}}, "copy": d["load"]["copy"]}
39
+ overall, findings, _ = evaluate(d["inventory"], load, d["disk"], d["download"])
40
+ assert overall == "FAIL" and any(f.check == "throttling" for f in findings)
41
+
42
+
43
+ def test_summarize_windows_and_reasons():
44
+ s = [{"t": i, "clocks.sm": 1900 + i, "power.draw": 600.0, "reasons": ["sw_power_cap"] if i % 2 else []} for i in range(10)]
45
+ out = summarize(s, 2, 7)
46
+ assert out["samples"] == 6 and out["throttle_reasons"]["sw_power_cap"] == 0.5