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.
- tokenwatt_check-0.1.0/.github/workflows/ci.yml +16 -0
- tokenwatt_check-0.1.0/.github/workflows/publish.yml +17 -0
- tokenwatt_check-0.1.0/.gitignore +7 -0
- tokenwatt_check-0.1.0/LICENSE +21 -0
- tokenwatt_check-0.1.0/PKG-INFO +107 -0
- tokenwatt_check-0.1.0/README.md +84 -0
- tokenwatt_check-0.1.0/pyproject.toml +36 -0
- tokenwatt_check-0.1.0/src/tokenwatt_check/__init__.py +3 -0
- tokenwatt_check-0.1.0/src/tokenwatt_check/__main__.py +3 -0
- tokenwatt_check-0.1.0/src/tokenwatt_check/cli.py +115 -0
- tokenwatt_check-0.1.0/src/tokenwatt_check/demo.py +34 -0
- tokenwatt_check-0.1.0/src/tokenwatt_check/gpu.py +144 -0
- tokenwatt_check-0.1.0/src/tokenwatt_check/host.py +78 -0
- tokenwatt_check-0.1.0/src/tokenwatt_check/load.py +73 -0
- tokenwatt_check-0.1.0/src/tokenwatt_check/verdict.py +157 -0
- tokenwatt_check-0.1.0/tests/test_verdict.py +46 -0
|
@@ -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,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,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
|