tuieval 0.2.0.dev56__tar.gz → 0.2.0.dev65__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.
Files changed (77) hide show
  1. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/CHANGELOG.md +3 -1
  2. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/PKG-INFO +5 -5
  3. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/README.md +4 -4
  4. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/models.md +21 -7
  5. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/writing-packs.md +2 -2
  6. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/_version.py +2 -2
  7. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/cli.py +1 -1
  8. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/compare.py +22 -6
  9. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/doctor.py +17 -0
  10. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/engine.py +39 -10
  11. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/machines.py +163 -5
  12. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/pta.py +71 -36
  13. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/run_evals.py +4 -3
  14. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/tui.py +31 -114
  15. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/tune.py +9 -0
  16. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/verdict.py +2 -1
  17. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/tests/test_tuieval.py +195 -16
  18. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/.gitignore +0 -0
  19. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/LICENSE +0 -0
  20. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/RELEASING.md +0 -0
  21. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/README.md +0 -0
  22. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/favicon.ico +0 -0
  23. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/readme-header.png +0 -0
  24. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/social-preview.png +0 -0
  25. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-1024.png +0 -0
  26. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-128.png +0 -0
  27. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-16.png +0 -0
  28. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-16.svg +0 -0
  29. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-256.png +0 -0
  30. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-32.png +0 -0
  31. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-48.png +0 -0
  32. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-512.png +0 -0
  33. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-64.png +0 -0
  34. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  35. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/brand/tuieval-icon.svg +0 -0
  36. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/tui-answer-reasoning.png +0 -0
  37. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/tui-run-reasoning.png +0 -0
  38. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/docs/images/tui-setup.png +0 -0
  39. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/pyproject.toml +0 -0
  40. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/__init__.py +0 -0
  41. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/__main__.py +0 -0
  42. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/builtin.toml +0 -0
  43. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/client.py +0 -0
  44. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/export.py +0 -0
  45. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/graders/__init__.py +0 -0
  46. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/graders/answer.py +0 -0
  47. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/graders/code.py +0 -0
  48. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/graders/rag.py +0 -0
  49. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/graders/reply.py +0 -0
  50. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/graders/tool_call.py +0 -0
  51. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/onboard.py +0 -0
  52. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/packs.py +0 -0
  53. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/profiles.py +0 -0
  54. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/remove.py +0 -0
  55. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/watch_proxy.py +0 -0
  75. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/workspace.py +0 -0
  76. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/src/tuieval/yamlout.py +0 -0
  77. {tuieval-0.2.0.dev56 → tuieval-0.2.0.dev65}/tests/mock_server.py +0 -0
@@ -2,10 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Nvidia and AMD GPUs on Linux: VRAM from `nvidia-smi`, `amd-smi` or `rocm-smi` sizes models (`gpu_memory_gb` overrides it), runs record peak VRAM, and `tuieval doctor` shows the GPU.
6
+ - Results has four tabs: PTA index, Production readiness, Per question and Failures. The PTA table adds tok/s, TTFT and memory; Per question can show only where models disagree; the settings warning is one line. Scorecard, pairwise, history and test quality stay in `tuieval compare`, `history` and `items`.
5
7
  - The run's ETA adds up each pack's answers left × that model's time per answer there (its history, then its own times once it has 5 answers), instead of extrapolating from the first few answers.
6
8
  - Faster start on Macs: the GPU core count comes from `ioreg` instead of `system_profiler`, which took seconds on some Macs.
7
9
  - A model a server won't serve (HTTP 401-404, e.g. OpenRouter's "No endpoints found") is never judged on it: those answers don't count, and the model stops at once instead of failing every question.
8
- - PTA index: parsimony (tokens used) and speed (time), each 100 for the best and 15 points less per doubling, and accuracy per model, as bars: the first Results tab (where `r` lands), `tuieval pta` and the report. Server errors aren't answers, and models with few answers are left out unless picked.
10
+ - PTA index: parsimony (tokens), time and accuracy per model as bars, with tokens and time shown as multiples of the best (2.4×): the first Results tab (where `r` lands), `tuieval pta` and the report. Server errors aren't answers, and models with few answers are left out unless picked.
9
11
  - Run several models at a time: `parallel_models` per machine, **Models at a time** in the TUI, or `tuieval run --parallel N` (default 1). Models wait for memory to fit, and answers note what ran alongside them.
10
12
  - More evals during a run: `n` opens Setup, and Start adds them to the run going (same tier) or queues them after it.
11
13
  - A new workspace's `models.toml` is 23 lines: the llama, local and OpenRouter servers and the defaults are built in (docs/models.md lists them in full; a server defined in `models.toml` replaces the built-in one with that name). Existing workspaces keep their settings; built-in servers they don't define become available, and adding a model still prefers the workspace's own servers.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev56
3
+ Version: 0.2.0.dev65
4
4
  Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
5
5
  Project-URL: Homepage, https://github.com/ashe-wb/tuieval
6
6
  Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
@@ -51,7 +51,7 @@ Whatever runs your models, the first steps are short:
51
51
  | You have | Do this |
52
52
  |---|---|
53
53
  | **LM Studio, Ollama or vLLM** | Start its server and load a model, then `tuieval init my-evals`: it finds the model. Later: `tuieval add` lists new ones. |
54
- | **GGUF files** | Install llama.cpp (`brew install llama.cpp`), then `tuieval add ~/models/Some-Model-Q4_K_M.gguf`. tuieval starts `llama-server` for it, sized to your machine. |
54
+ | **GGUF files** | Install llama.cpp (`brew install llama.cpp`, or a CUDA/ROCm build on Linux), then `tuieval add ~/models/Some-Model-Q4_K_M.gguf`. tuieval starts `llama-server` for it, sized to your Mac's memory or your Nvidia/AMD card's VRAM. |
55
55
  | **An OpenRouter key** | `export OPENROUTER_API_KEY=…`, then `tuieval run --tier smoke --only openrouter:<model id>`. |
56
56
 
57
57
  Stuck? `tuieval doctor` checks servers, models, packs and keys, and says what to fix.
@@ -76,7 +76,7 @@ Afterwards, every answer keeps its reasoning. Enter on a result opens it with th
76
76
  - **Verdicts you can act on.** A pack passes only with zero critical failures over enough trials, and an accuracy whose 95% lower bound clears your bar. *INCONCLUSIVE* says what evidence is missing.
77
77
  - **Two tiers:** *Screen* a sample of every pack to drop weak models fast, then *Certify* finalists on every test with repeats. Certification reuses the screening answers.
78
78
  - **Honest numbers:** every request is a fresh single-turn conversation, with prompt caching off and repeat rounds in different orders. Each answer records what was sent, and the run warns about reused prompts or identical repeats.
79
- - **Per-machine speed.** A fit check picks the largest context that fits your Mac's GPU memory, `tuieval tune` finds the fastest speed-only server flags (with a guard that rejects flags that change answers), and readiness includes a *Fast enough?* table per machine, measured or projected.
79
+ - **Per-machine speed.** A fit check picks the largest context that fits your Mac's GPU memory or your Nvidia/AMD card's VRAM, `tuieval tune` finds the fastest speed-only server flags (with a guard that rejects flags that change answers), and readiness includes a *Fast enough?* table per machine, measured or projected.
80
80
  - **Tests that check themselves.** Every test carries a reference answer and known-wrong answers, and `tuieval selftest` checks the grader accepts the first and rejects the second, and that each gate is reachable at all.
81
81
  - **History.** Verdict changes are appended to `results/verdicts.jsonl`, and replaced results are moved to `history/`, never overwritten.
82
82
 
@@ -144,7 +144,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
144
144
  1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
145
145
  2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
146
146
  3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
147
- 4. **Press `r` for results:** production readiness, verdict history, scorecard, speed & tokens (★ marks models nothing beats on both accuracy and time), per question, is the difference real?, tests that separate models, by difficulty, failures, and test quality.
147
+ 4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
148
148
 
149
149
  Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
150
150
 
@@ -176,7 +176,7 @@ tuieval help
176
176
 
177
177
  - **Start with Production readiness.** A single critical failure already means FAIL, whatever the accuracy: a model that breaks a hard rule once in 300 answers will do it in production. The Failures tab and `tuieval report` list exactly which answers failed.
178
178
  - **INCONCLUSIVE is not "nearly passed".** It says what's missing (usually a Certify run, or more trials).
179
- - **Trust "Is the difference real?" over raw percentages.** Differences of one or two tests are usually noise.
179
+ - **Trust `tuieval compare --pairwise` over raw percentages.** Differences of one or two tests are usually noise.
180
180
  - **Read `trunc` before accuracy.** A model that runs out of tokens isn't wrong; it's thinking too long for the budget.
181
181
  - **Watch TTFT for interactive use.** A model that is 5% more accurate but takes 3 s longer to start answering may be the worse choice.
182
182
 
@@ -25,7 +25,7 @@ Whatever runs your models, the first steps are short:
25
25
  | You have | Do this |
26
26
  |---|---|
27
27
  | **LM Studio, Ollama or vLLM** | Start its server and load a model, then `tuieval init my-evals`: it finds the model. Later: `tuieval add` lists new ones. |
28
- | **GGUF files** | Install llama.cpp (`brew install llama.cpp`), then `tuieval add ~/models/Some-Model-Q4_K_M.gguf`. tuieval starts `llama-server` for it, sized to your machine. |
28
+ | **GGUF files** | Install llama.cpp (`brew install llama.cpp`, or a CUDA/ROCm build on Linux), then `tuieval add ~/models/Some-Model-Q4_K_M.gguf`. tuieval starts `llama-server` for it, sized to your Mac's memory or your Nvidia/AMD card's VRAM. |
29
29
  | **An OpenRouter key** | `export OPENROUTER_API_KEY=…`, then `tuieval run --tier smoke --only openrouter:<model id>`. |
30
30
 
31
31
  Stuck? `tuieval doctor` checks servers, models, packs and keys, and says what to fix.
@@ -50,7 +50,7 @@ Afterwards, every answer keeps its reasoning. Enter on a result opens it with th
50
50
  - **Verdicts you can act on.** A pack passes only with zero critical failures over enough trials, and an accuracy whose 95% lower bound clears your bar. *INCONCLUSIVE* says what evidence is missing.
51
51
  - **Two tiers:** *Screen* a sample of every pack to drop weak models fast, then *Certify* finalists on every test with repeats. Certification reuses the screening answers.
52
52
  - **Honest numbers:** every request is a fresh single-turn conversation, with prompt caching off and repeat rounds in different orders. Each answer records what was sent, and the run warns about reused prompts or identical repeats.
53
- - **Per-machine speed.** A fit check picks the largest context that fits your Mac's GPU memory, `tuieval tune` finds the fastest speed-only server flags (with a guard that rejects flags that change answers), and readiness includes a *Fast enough?* table per machine, measured or projected.
53
+ - **Per-machine speed.** A fit check picks the largest context that fits your Mac's GPU memory or your Nvidia/AMD card's VRAM, `tuieval tune` finds the fastest speed-only server flags (with a guard that rejects flags that change answers), and readiness includes a *Fast enough?* table per machine, measured or projected.
54
54
  - **Tests that check themselves.** Every test carries a reference answer and known-wrong answers, and `tuieval selftest` checks the grader accepts the first and rejects the second, and that each gate is reachable at all.
55
55
  - **History.** Verdict changes are appended to `results/verdicts.jsonl`, and replaced results are moved to `history/`, never overwritten.
56
56
 
@@ -118,7 +118,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
118
118
  1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
119
119
  2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
120
120
  3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
121
- 4. **Press `r` for results:** production readiness, verdict history, scorecard, speed & tokens (★ marks models nothing beats on both accuracy and time), per question, is the difference real?, tests that separate models, by difficulty, failures, and test quality.
121
+ 4. **Press `r` for results:** the PTA index (parsimony (tokens), time and accuracy as bars), production readiness, per question, and failures. `tuieval compare`, `tuieval history` and `tuieval items` have the rest: whether a difference is real, verdict history and test quality.
122
122
 
123
123
  Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
124
124
 
@@ -150,7 +150,7 @@ tuieval help
150
150
 
151
151
  - **Start with Production readiness.** A single critical failure already means FAIL, whatever the accuracy: a model that breaks a hard rule once in 300 answers will do it in production. The Failures tab and `tuieval report` list exactly which answers failed.
152
152
  - **INCONCLUSIVE is not "nearly passed".** It says what's missing (usually a Certify run, or more trials).
153
- - **Trust "Is the difference real?" over raw percentages.** Differences of one or two tests are usually noise.
153
+ - **Trust `tuieval compare --pairwise` over raw percentages.** Differences of one or two tests are usually noise.
154
154
  - **Read `trunc` before accuracy.** A model that runs out of tokens isn't wrong; it's thinking too long for the budget.
155
155
  - **Watch TTFT for interactive use.** A model that is 5% more accurate but takes 3 s longer to start answering may be the worse choice.
156
156
 
@@ -69,9 +69,21 @@ The best server flags differ per model and per machine, so tuieval splits them b
69
69
 
70
70
  ## Machines
71
71
 
72
- - **Machine id** is detected automatically (e.g. `m3max-64gb`, `m2-16gb`; set `EVALS_MACHINE` to rename it). Every result records the machine, the server version and the exact speed flags used. `tuieval machines` lists this machine and every other machine that has run the evals (they record themselves in `tuning/`).
73
- - **Fit check (Apple Silicon, llama):** before starting a GGUF, tuieval reads its header (layers, KV heads, hybrid attention layers) and picks the largest context that fits this machine's GPU memory, capped at `max_ctx`. A model that can't fit at 8k context is skipped with *doesn't fit on <machine>* instead of swapping. Packs that need more context than fits are skipped. It never quantizes the KV cache on its own, since that changes answers. Servers that size their own memory (their `cmd` doesn't take `{ctx}`) are left alone: their context is the model's `max_context`; `fit_check` on the server changes that.
74
- - **Per-machine settings** go under `[machines.<id>]`: `memory_headroom_gb` (GPU memory kept free for macOS, default 4), `gpu_residency_gb` (see the stall guard) and `parallel_models` (see below).
72
+ - **Machine id** is detected automatically (e.g. `m3max-64gb`, `m2-16gb`, or `rtx4090-128gb` on a Linux machine with a discrete GPU; set `EVALS_MACHINE` to rename it). Every result records the machine, the server version and the exact speed flags used. `tuieval machines` lists this machine and every other machine that has run the evals (they record themselves in `tuning/`).
73
+ - **Fit check (llama):** before starting a GGUF, tuieval reads its header (layers, KV heads, hybrid attention layers) and picks the largest context that fits this machine's GPU memory (a Mac's unified memory, or a discrete GPU's VRAM), capped at `max_ctx`. A model that can't fit at 8k context is skipped with *doesn't fit on <machine>* instead of swapping. Packs that need more context than fits are skipped. It never quantizes the KV cache on its own, since that changes answers. Servers that size their own memory (their `cmd` doesn't take `{ctx}`) are left alone: their context is the model's `max_context`; `fit_check` on the server changes that.
74
+ - **Per-machine settings** go under `[machines.<id>]`: `memory_headroom_gb` (GPU memory kept free: default 4 on a Mac, 1.5 in a discrete GPU's VRAM), `gpu_memory_gb` (the GPU memory to size models against, when detection gets it wrong or you want to hold some back), `gpu_residency_gb` (see the stall guard) and `parallel_models` (see below).
75
+
76
+ ### Discrete GPUs (Linux: Nvidia, AMD)
77
+
78
+ With `nvidia-smi` (Nvidia) or `amd-smi` / `rocm-smi` (AMD) installed, tuieval reads each card's name and VRAM. Several cards add up, since llama.cpp splits a model across them. `tuieval doctor` shows what it found.
79
+
80
+ - Models are sized to fit **entirely in VRAM** (servers keep `-ngl 99`); splitting a model between GPU and CPU isn't supported. A model that doesn't fit is skipped with what it needs and what's free.
81
+ - **Memory** in results is the server's peak VRAM: its own processes' on Nvidia, the whole machine's on AMD (its tools don't report it per process reliably, so it's only exact when nothing else uses the GPU).
82
+ - `tuieval tune` rejects flags that leave VRAM nearly full. The macOS memory-pressure checks and the stall guard don't apply.
83
+ - A machine that recorded itself before its GPU was detected keeps that id, so its results and tuning still match; `tuieval doctor` names the new id if you want to switch (`EVALS_MACHINE`).
84
+ - Without the vendor tools, models are sized against system RAM, as on any other machine; set `gpu_memory_gb` instead.
85
+
86
+ Windows isn't supported (WSL2 works like Linux).
75
87
 
76
88
  ## Several models at a time
77
89
 
@@ -86,11 +98,13 @@ By default a run serves one model at a time. On a machine with room for more, se
86
98
 
87
99
  ## PTA index
88
100
 
89
- The **PTA index** puts parsimony (tokens), speed (time) and accuracy side by side, 0-100 each and higher is better, as a bar per score for each model, with the numbers and a table underneath. It's the first tab in Results, where `r` opens, and it's also `tuieval pta` and a section in `tuieval report`. There's no combined number; verdicts and critical failures stay in Production readiness.
101
+ The **PTA index** puts parsimony (tokens), time and accuracy side by side, as a bar for each per model (longer is better), with a table underneath. It's the first tab in Results, where `r` opens, and it's also `tuieval pta` and a section in `tuieval report`. There's no combined number; verdicts and critical failures stay in Production readiness.
102
+
103
+ - **P parsimony (tokens)**: the tokens used, reasoning and answer, on every question compared (each question at its median over repeats). Beside the bar: how many times the leanest model's, e.g. `2.4×` (`1.0×` is the leanest, ★ in the table's **tokens/answer**). Unlike time it doesn't depend on your machine: it's how much a model says to get to an answer, which is what you pay for on an API and what fills its context.
104
+ - **T time**: the total time to answer those questions, as how many times the fastest model's, e.g. `28×` (★ in **time/answer**).
105
+ - **A accuracy**: the share of answers to those questions that passed.
90
106
 
91
- - **P parsimony** (a score out of 100, not a percentage): from the tokens used, reasoning and answer, on every question compared (each question at its median over repeats). The fewest tokens score 100 (★ in the **tokens** column), and every doubling costs 15 points: `100 − 15 × log2(this model's tokens ÷ fewest)`, never below 0. Unlike speed it doesn't depend on your machine: it's how much a model says to get to an answer, which is what you pay for on an API and what fills its context.
92
- - **T speed** (a score out of 100, not a percentage): the same from the total time to answer those questions. The fastest model scores 100 (★ on its total time), and every doubling of the total time costs 15 points: `100 − 15 × log2(this model's total ÷ fastest total)`, never below 0 (about 100× slower). Twice as slow is 85, 10× slower is 50. Adding a faster model lowers the others. The table's **total time** column has the times themselves (lower is better).
93
- - **A accuracy** (a percentage): the share of answers to those questions that passed.
107
+ The P and T bars use a log scale: full for the best, 15 points (of 100) shorter for every doubling, empty at about 100 times the best. So a model at 28× still shows a bar, and adding a better model shortens the others'.
94
108
 
95
109
  Models are compared on the questions all of them answered, so one that skipped some never looks faster. Server and connection errors aren't answers: a model with only those is listed as having none. A model with fewer than half as many answers as the most-answered one is left out (and listed), or it would shrink the shared questions for everyone. Pick the models and pack to compare in the TUI, or use `--only` and `--packs`: models you pick are always compared.
96
110
 
@@ -127,7 +127,7 @@ def ticket_route(answer, test, meta):
127
127
  - `critical=True` makes every test of this grader a critical trial (any of its failures *can* be critical). Otherwise only tests with `critical: true` or `expected: NOT_AVAILABLE` are.
128
128
  - `template` is the skeleton `tuieval capture` writes for a new test of this grader.
129
129
 
130
- A grader file can also add **Scorecard columns** (Results → Scorecard, and `tuieval compare`), for numbers your use case cares about that pass/fail doesn't show:
130
+ A grader file can also add **Scorecard columns** (`tuieval compare`), for numbers your use case cares about that pass/fail doesn't show:
131
131
 
132
132
  ```python
133
133
  import json
@@ -175,7 +175,7 @@ Graders mark failures that should disqualify a model whatever its accuracy:
175
175
 
176
176
  ## Difficulty labels
177
177
 
178
- Every test has a `difficulty` of easy, medium or hard. It's for you, not the model: requests are built only from `input`, `image`, the system prompt and tools, so the label never reaches the model. The TUI shows it in the pack list (e.g. `12E 20M 8H`), next to the current question, in Recent results and Failures, and in Results → By difficulty.
178
+ Every test has a `difficulty` of easy, medium or hard. It's for you, not the model: requests are built only from `input`, `image`, the system prompt and tools, so the label never reaches the model. The TUI shows it in the pack list (e.g. `12E 20M 8H`), next to the current question, in Recent results and Failures, and as a filter in Results → Per question.
179
179
 
180
180
  A useful convention: **easy** = one rule or a direct read; **medium** = arithmetic, one trap, or two conditions; **hard** = rules in conflict, multi-step reasoning, or misleading data. Once several models have run, `tuieval items` flags labels the results contradict ("easier than labelled": a hard test every model passes; "harder than labelled": an easy test most models fail). `tuieval selftest` warns about tests without a label.
181
181
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0.dev56'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev56')
21
+ __version__ = version = '0.2.0.dev65'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev65')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -14,7 +14,7 @@ Start here
14
14
 
15
15
  Day to day
16
16
  tuieval compare [...] scorecards (--speed, --pairwise, --failures)
17
- tuieval pta the PTA index: parsimony, speed and accuracy per model, as bars
17
+ tuieval pta the PTA index: parsimony (tokens), time and accuracy per model, as bars
18
18
  tuieval report the verdicts with their evidence, as a markdown file
19
19
  tuieval history every verdict change, newest first
20
20
  tuieval items tests that don't separate models or look broken
@@ -35,7 +35,8 @@ def _load_native(data):
35
35
  m, pack, run = data["model"], data["pack"], data["run"]
36
36
  info = {"label": m["label"], "served": m.get("served_name"), "tags": m.get("tags") or [],
37
37
  "settings": data.get("settings", {}), "load_s": run.get("load_s"),
38
- "peak_rss_mb": run.get("peak_rss_mb"), "pack": pack["name"], "pack_fp": pack["fingerprint"],
38
+ "peak_rss_mb": run.get("peak_rss_mb"), "vram_peak_gb": run.get("vram_peak_gb"),
39
+ "pack": pack["name"], "pack_fp": pack["fingerprint"],
39
40
  # hosted (OpenRouter) models: the provider endpoint and its declared quantization
40
41
  "endpoint": run.get("endpoint"), "quantization": run.get("quantization"),
41
42
  "loaded_alongside": run.get("loaded_alongside") or []}
@@ -121,6 +122,12 @@ def p90(values):
121
122
  return statistics.quantiles(values, n=10)[-1] if len(values) > 1 else (values[0] if values else None)
122
123
 
123
124
 
125
+ def pct(fraction):
126
+ """A share as a percentage: "100%" and "0%" only when exact, else one decimal ("99.6%"), so a model
127
+ with a wrong answer never shows as perfect."""
128
+ return "100%" if fraction >= 1 else "0%" if fraction <= 0 else f"{100 * fraction:.1f}%"
129
+
130
+
124
131
  def median(values):
125
132
  values = [v for v in values if v is not None]
126
133
  return statistics.median(values) if values else None
@@ -154,10 +161,19 @@ def settings_notes(infos):
154
161
  per_model.setdefault(i["label"], i["settings"])
155
162
  keys = sorted({k for s in per_model.values() for k in s})
156
163
  differing = [k for k in keys if len({json.dumps(s.get(k)) for s in per_model.values()}) > 1]
157
- if differing:
158
- detail = "; ".join(f"{k}: " + ", ".join(f"{m}={per_model[m].get(k)}" for m in sorted(per_model))
159
- for k in differing)
160
- notes.append(f"Settings differ between models ({detail}). Compare those models knowing that.")
164
+ parts = []
165
+ for k in differing: # only the models that differ from the most common value, named when few
166
+ groups = collections.defaultdict(list)
167
+ for m in sorted(per_model):
168
+ groups[json.dumps(per_model[m].get(k))].append(m)
169
+ common = max(groups, key=lambda v: len(groups[v]))
170
+ for value, ms in sorted(groups.items()):
171
+ if value != common:
172
+ who = ", ".join(ms) if len(ms) <= 3 else f"{len(ms)} models"
173
+ shown = "unset" if value == "null" else json.loads(value)
174
+ parts.append(f"{k} {shown} on {who}")
175
+ if parts:
176
+ notes.append("Settings differ: " + "; ".join(parts) + ".")
161
177
  fps = collections.defaultdict(set)
162
178
  for i in infos:
163
179
  if i.get("pack"):
@@ -412,7 +428,7 @@ def by_difficulty(rows):
412
428
  cells = []
413
429
  for lvl in LEVELS:
414
430
  x = [r["ok"] for r in sub if r.get("difficulty") == lvl]
415
- cells.append(f"{sum(x)}/{len(x)} ({100 * sum(x) / len(x):.0f}%)" if x else "-")
431
+ cells.append(f"{sum(x)}/{len(x)} ({pct(sum(x) / len(x))})" if x else "-")
416
432
  table.append([m, s] + cells)
417
433
  return header, table
418
434
 
@@ -10,6 +10,7 @@ import platform
10
10
  import sys
11
11
 
12
12
  from . import engine
13
+ from . import machines
13
14
  from . import workspace
14
15
 
15
16
  OK, WARN, BAD = "✓", "!", "✗"
@@ -44,6 +45,22 @@ def check(models_path=None):
44
45
  return r.bad
45
46
  r.line(OK, f"workspace {e.root}")
46
47
 
48
+ r.head("Machine")
49
+ mc = e.machine()
50
+ r.line(OK, f"{mc.id}: {mc.summary}")
51
+ if mc.discrete:
52
+ r.line(OK, f"models are sized to {mc.gpu_limit_gb:.0f} GB of VRAM, keeping {e.headroom_gb(mc):g} GB free "
53
+ "(memory_headroom_gb, gpu_memory_gb under [machines.<id>] change it)")
54
+ new = machines.gpu_id(machines.discrete_gpus(), mc.ram_gb)
55
+ if mc.id != new and not os.environ.get("EVALS_MACHINE"):
56
+ r.line(WARN, f"keeping the id {mc.id} this machine had before its GPU was detected, so earlier results "
57
+ "and tuning still match", f"to switch: EVALS_MACHINE={new} (results under the old id "
58
+ "then count as another machine)")
59
+ elif platform.system() == "Linux" and any(os.path.exists(p) for p in ("/dev/nvidia0", "/dev/kfd")):
60
+ r.line(WARN, "this machine seems to have an Nvidia or AMD GPU, but nvidia-smi / amd-smi / rocm-smi "
61
+ "isn't installed, so models are sized against system RAM",
62
+ "install the vendor's tools, or set gpu_memory_gb under [machines.<id>] in models.toml")
63
+
47
64
  r.head("Packs")
48
65
  for err in e.pack_errors:
49
66
  r.line(BAD, err, "fix the pack, then: tuieval selftest")
@@ -1191,7 +1191,19 @@ class Engine:
1191
1191
 
1192
1192
  # ---- serving on this machine
1193
1193
  def machine(self):
1194
- return machines.detect()
1194
+ """This machine, with its models.toml [machines.<id>] gpu_memory_gb applied. A Linux machine
1195
+ that recorded itself before its GPU was detected keeps that id, so its results and tuning
1196
+ still match (EVALS_MACHINE renames it)."""
1197
+ mc = machines.detect()
1198
+ if mc.discrete and not os.environ.get("EVALS_MACHINE"):
1199
+ old = machines.machine_id(mc.chip, mc.ram_gb)
1200
+ if os.path.isdir(os.path.join(self.tuning_dir, old)) and \
1201
+ not os.path.isdir(os.path.join(self.tuning_dir, mc.id)):
1202
+ mc = dataclasses.replace(mc, id=old)
1203
+ override = self.cfg.get("machines", {}).get(mc.id, {}).get("gpu_memory_gb")
1204
+ if override:
1205
+ mc = dataclasses.replace(mc, gpu_limit_gb=float(override))
1206
+ return mc
1195
1207
 
1196
1208
  def machines(self):
1197
1209
  """This machine first, then every other machine recorded in tuning/ (synced)."""
@@ -1203,9 +1215,14 @@ class Engine:
1203
1215
  return [here] + [mc for mc in profiles.known_machines(self.tuning_dir) if mc.id != here.id]
1204
1216
 
1205
1217
  def machine_settings(self, machine_id=None):
1206
- """models.toml [machines.<id>] (optional): memory_headroom_gb, name."""
1218
+ """models.toml [machines.<id>] (optional): memory_headroom_gb, gpu_memory_gb, name."""
1207
1219
  return self.cfg.get("machines", {}).get(machine_id or self.machine().id, {})
1208
1220
 
1221
+ def headroom_gb(self, mc):
1222
+ """GPU memory kept free: memory_headroom_gb, else 4 GB on a Mac (macOS and other apps share
1223
+ it) and 1.5 GB in a discrete GPU's VRAM."""
1224
+ return float(self.machine_settings(mc.id).get("memory_headroom_gb", 1.5 if mc.discrete else 4.0))
1225
+
1209
1226
  def parallel_models(self):
1210
1227
  """How many models a run serves at a time on this machine: models.toml [machines.<id>]
1211
1228
  parallel_models (default 1)."""
@@ -1224,7 +1241,7 @@ class Engine:
1224
1241
  def memory_available_gb(self):
1225
1242
  """GPU memory models may take here (what the fit check sizes contexts against)."""
1226
1243
  mc = self.machine()
1227
- return mc.gpu_limit_gb - self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
1244
+ return mc.gpu_limit_gb - self.headroom_gb(mc)
1228
1245
 
1229
1246
  def runs_alone(self, m):
1230
1247
  """A server whose before_start step may stop other servers never runs next to another model."""
@@ -1269,8 +1286,7 @@ class Engine:
1269
1286
  if fit_check(server):
1270
1287
  mmproj = expand(m.get("mmproj", ""))
1271
1288
  extra = os.path.getsize(mmproj) if mmproj and os.path.isfile(mmproj) else 0
1272
- headroom = self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
1273
- f = machines.fit(path, mc, kv_type=kv or "f16", headroom_gb=headroom, extra_bytes=extra,
1289
+ f = machines.fit(path, mc, kv_type=kv or "f16", headroom_gb=self.headroom_gb(mc), extra_bytes=extra,
1274
1290
  want_ctx=want)
1275
1291
  ctx, fits, note, need = f.max_ctx, f.fits, f.note, f.need_gb
1276
1292
  except (OSError, ValueError) as e:
@@ -1484,11 +1500,22 @@ class Engine:
1484
1500
  time.sleep(0.2)
1485
1501
  threading.Thread(target=run, daemon=True).start()
1486
1502
 
1487
- def _watch_memory(self, proc, label, stop, peak):
1503
+ VRAM_EVERY_S = 6 # how often to read a discrete GPU's VRAM while a server runs
1504
+
1505
+ def _watch_memory(self, proc, label, stop, peak, info=None):
1488
1506
  """Peak resident memory of the server's process group (MB). On Apple Silicon this includes
1489
- the model weights mapped for the GPU."""
1507
+ the model weights mapped for the GPU. With discrete GPUs, also the peak VRAM it holds
1508
+ (info["vram_peak_gb"]): its own processes' on Nvidia, the machine's on AMD."""
1509
+ discrete = info is not None and self.machine().discrete
1510
+ last_vram = [0.0]
1511
+
1490
1512
  def run():
1491
1513
  while not stop.is_set():
1514
+ if discrete and time.time() - last_vram[0] >= self.VRAM_EVERY_S:
1515
+ last_vram[0] = time.time()
1516
+ used = machines.vram_used_gb(machines.tree_pids(proc.pid))
1517
+ if used:
1518
+ info["vram_peak_gb"] = round(max(info.get("vram_peak_gb") or 0, used), 2)
1492
1519
  try:
1493
1520
  out = subprocess.run(["ps", "-axo", "pgid=,rss="], capture_output=True, text=True).stdout
1494
1521
  rss = sum(int(r) for g, r in (l.split() for l in out.splitlines() if l.strip())
@@ -1789,7 +1816,7 @@ class Engine:
1789
1816
  f"(see logs/server/{log_name or m['label']}.log)")
1790
1817
  t0 = time.time()
1791
1818
  srv = self._start(cmd, log, cwd=cwd)
1792
- self._watch_memory(srv, m["label"], stop, peak)
1819
+ self._watch_memory(srv, m["label"], stop, peak, info)
1793
1820
  else:
1794
1821
  key_env = server.get("api_key_env")
1795
1822
  if key_env and not os.environ.get(key_env):
@@ -1881,6 +1908,7 @@ class Engine:
1881
1908
  def run_info():
1882
1909
  return {"load_s": info["load_s"], "peak_rss_mb": info["peak_mb"](),
1883
1910
  "server_facts": info["facts"] or None, "gpu_peak_gb": info.get("gpu_peak_gb"),
1911
+ "vram_peak_gb": info.get("vram_peak_gb"),
1884
1912
  "kernel_share_max": info.get("kernel_share_max"),
1885
1913
  **({"loaded_alongside": loaded_alongside} if loaded_alongside else {}), **serving_info}
1886
1914
  failed_in_row = 0
@@ -1965,7 +1993,8 @@ class Engine:
1965
1993
  return tests
1966
1994
 
1967
1995
  # ---- sittings: a pack run over several sessions (stopped and resumed) keeps its total time
1968
- SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "kernel_share_max", "machine", "loaded_alongside")
1996
+ SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "vram_peak_gb", "kernel_share_max", "machine",
1997
+ "loaded_alongside")
1969
1998
 
1970
1999
  def _sitting(self, job, info):
1971
2000
  now = time.time()
@@ -2037,7 +2066,7 @@ class Engine:
2037
2066
  out = {"started": min(s["started"] for s in sittings),
2038
2067
  "wall_s": round(sum(s.get("wall_s") or 0 for s in sittings), 1),
2039
2068
  "sittings": len(sittings), "sittings_log": sittings}
2040
- for k in ("peak_rss_mb", "gpu_peak_gb", "kernel_share_max"):
2069
+ for k in ("peak_rss_mb", "gpu_peak_gb", "vram_peak_gb", "kernel_share_max"):
2041
2070
  if peak(k) is not None:
2042
2071
  out[k] = peak(k)
2043
2072
  along = sorted({l for s in sittings for l in s.get("loaded_alongside") or []})
@@ -1,6 +1,7 @@
1
1
  """Which machine this is, what a model needs, and whether it fits.
2
2
 
3
- detect() -> Machine (chip, cores, RAM, GPU memory limit, id like "m3max-64gb")
3
+ detect() -> Machine (chip, cores, RAM, GPU memory limit, id like "m3max-64gb";
4
+ with Nvidia/AMD cards on Linux, their VRAM and an id like "rtx4090-128gb")
4
5
  read_gguf(path) -> GGUF header facts (architecture, layers, KV heads, context length, …)
5
6
  fit(model_path, …) -> Fit (largest context that fits this machine, estimated memory)
6
7
 
@@ -9,6 +10,7 @@ the KV cache on its own (that would change answers); a quantized-KV variant is a
9
10
  """
10
11
  import dataclasses
11
12
  import functools
13
+ import json
12
14
  import os
13
15
  import platform
14
16
  import re
@@ -28,15 +30,19 @@ class Machine:
28
30
  id: str
29
31
  chip: str
30
32
  ram_gb: float
31
- gpu_limit_gb: float # what Metal may wire for the GPU
33
+ gpu_limit_gb: float # what Metal may wire for the GPU; with discrete GPUs, their VRAM (all cards)
32
34
  p_cores: int
33
35
  e_cores: int
34
36
  gpu_cores: int | None
35
37
  os: str
38
+ gpu_name: str | None = None # discrete GPUs, e.g. "2× NVIDIA GeForce RTX 4090"
39
+ discrete: bool = False # the GPU has its own memory (VRAM), separate from RAM
36
40
 
37
41
  @property
38
42
  def summary(self):
39
43
  cores = f"{self.p_cores}P+{self.e_cores}E" if self.e_cores else f"{self.p_cores} cores"
44
+ if self.discrete:
45
+ return f"{self.chip} · {self.ram_gb:.0f} GB RAM · {self.gpu_name} · {self.gpu_limit_gb:.0f} GB VRAM · {cores}"
40
46
  gpu = f", {self.gpu_cores}-core GPU" if self.gpu_cores else ""
41
47
  return f"{self.chip} · {self.ram_gb:.0f} GB (GPU up to {self.gpu_limit_gb:.0f} GB) · {cores}{gpu}"
42
48
 
@@ -53,6 +59,129 @@ def machine_id(chip, ram_gb):
53
59
  return re.sub(r"[^a-z0-9]+", "", name.lower()) + f"-{round(ram_gb)}gb"
54
60
 
55
61
 
62
+ def gpu_id(gpus, ram_gb):
63
+ """A machine id from its discrete GPUs: "rtx4090-128gb", "2xrtx4090-128gb", "rx7900xtx+rtx3090-64gb"."""
64
+ names = []
65
+ for name in sorted({n for n, _ in gpus}):
66
+ short = re.sub(r"\b(nvidia|geforce|amd|radeon|ati|graphics|tm)\b", "", name.lower())
67
+ short = re.sub(r"[^a-z0-9]+", "", short) or "gpu"
68
+ count = sum(n == name for n, _ in gpus)
69
+ names.append(f"{count}x{short}" if count > 1 else short)
70
+ return "+".join(names) + f"-{round(ram_gb)}gb"
71
+
72
+
73
+ # ---------------------------------------------------------------- discrete GPUs (Linux: Nvidia, AMD)
74
+ # Read from the vendors' own tools. Anything unexpected counts as "not detected", never an error.
75
+ def _run(cmd, timeout=10):
76
+ try:
77
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
78
+ except (OSError, subprocess.TimeoutExpired):
79
+ return None
80
+ return p.stdout if p.returncode == 0 else None
81
+
82
+
83
+ def _nvidia_gpus():
84
+ out = _run(["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
85
+ gpus = []
86
+ for line in (out or "").splitlines():
87
+ name, _, mib = line.rpartition(",")
88
+ try:
89
+ gpus.append((name.strip(), float(mib) / 1024))
90
+ except ValueError:
91
+ pass
92
+ return [g for g in gpus if g[0] and g[1] > 0]
93
+
94
+
95
+ def _walk(obj):
96
+ """Every dict inside a JSON value."""
97
+ if isinstance(obj, dict):
98
+ yield obj
99
+ for v in obj.values():
100
+ yield from _walk(v)
101
+ elif isinstance(obj, list):
102
+ for v in obj:
103
+ yield from _walk(v)
104
+
105
+
106
+ def _bytes(value, unit=None):
107
+ """VRAM sizes as amd-smi/rocm-smi print them: 25753026560, "24560 MB", {"value": 24560, "unit": "MB"}."""
108
+ if isinstance(value, dict):
109
+ return _bytes(value.get("value"), value.get("unit"))
110
+ m = re.match(r"\s*([\d.]+)\s*([KMGT]i?B|B)?\s*$", str(value), re.I)
111
+ if not m:
112
+ return None
113
+ scale = {"B": 1, "KB": 2**10, "MB": 2**20, "GB": 2**30, "TB": 2**40}
114
+ u = (m.group(2) or unit or "B").upper().replace("I", "")
115
+ return float(m.group(1)) * scale.get(u, 1)
116
+
117
+
118
+ def _amd_gpus():
119
+ out = _run(["amd-smi", "static", "--asic", "--vram", "--json"])
120
+ gpus = []
121
+ try:
122
+ for d in _walk(json.loads(out)) if out else []:
123
+ if "vram" in d and isinstance(d["vram"], dict):
124
+ size = _bytes(d["vram"].get("size"))
125
+ name = (d.get("asic") or {}).get("market_name") or "AMD GPU"
126
+ if size:
127
+ gpus.append((str(name), size / GB))
128
+ except ValueError:
129
+ gpus = []
130
+ if gpus:
131
+ return gpus
132
+ out = _run(["rocm-smi", "--showmeminfo", "vram", "--showproductname", "--json"])
133
+ try:
134
+ cards = json.loads(out) if out else {}
135
+ except ValueError:
136
+ return []
137
+ for key, d in sorted(cards.items()) if isinstance(cards, dict) else []:
138
+ if not key.startswith("card") or not isinstance(d, dict):
139
+ continue
140
+ size = _bytes(d.get("VRAM Total Memory (B)"))
141
+ name = d.get("Card Series") or d.get("Card series") or d.get("Card SKU") or d.get("Card model") or "AMD GPU"
142
+ if size:
143
+ gpus.append((str(name), size / GB))
144
+ return gpus
145
+
146
+
147
+ def discrete_gpus():
148
+ """[(name, VRAM GB)] for the Nvidia or AMD cards this machine has, or [] (none, or tools missing)."""
149
+ return _nvidia_gpus() or _amd_gpus()
150
+
151
+
152
+ def vram_used_gb(pids=None):
153
+ """VRAM in use now (GB): by these processes on Nvidia (all of it when pids is None), or on the
154
+ whole machine on AMD, whose tools don't report it reliably per process. None if unknown."""
155
+ if pids is not None:
156
+ out = _run(["nvidia-smi", "--query-compute-apps=pid,used_memory", "--format=csv,noheader,nounits"])
157
+ if out is not None:
158
+ used = 0.0
159
+ for line in out.splitlines():
160
+ pid, _, mib = line.partition(",")
161
+ if pid.strip() in {str(p) for p in pids}:
162
+ try:
163
+ used += float(mib) / 1024
164
+ except ValueError:
165
+ pass
166
+ return used
167
+ out = _run(["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"])
168
+ if out is not None:
169
+ try:
170
+ return sum(float(x) for x in out.split()) / 1024
171
+ except ValueError:
172
+ return None
173
+ out = _run(["rocm-smi", "--showmeminfo", "vram", "--json"])
174
+ try:
175
+ cards = json.loads(out) if out else None
176
+ except ValueError:
177
+ return None
178
+ if not isinstance(cards, dict):
179
+ return None
180
+ used = [_bytes(d.get("VRAM Total Used Memory (B)")) for k, d in cards.items()
181
+ if k.startswith("card") and isinstance(d, dict)]
182
+ return sum(u for u in used if u) / GB if any(used) else None
183
+
184
+
56
185
  @functools.lru_cache(maxsize=1)
57
186
  def detect():
58
187
  """This machine. Override the id with EVALS_MACHINE if two machines would collide."""
@@ -73,11 +202,19 @@ def detect():
73
202
  except (OSError, subprocess.TimeoutExpired):
74
203
  pass
75
204
  os_name = "macOS " + platform.mac_ver()[0]
76
- else: # Linux and others: CPU-side facts only
205
+ else: # Linux and others: the CPU side, plus Nvidia/AMD cards if their tools are installed
77
206
  chip = platform.processor() or platform.machine()
78
207
  ram = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / GB
79
208
  p, e, gpu_cores, limit = os.cpu_count() or 1, 0, None, ram * 0.9
80
209
  os_name = platform.system()
210
+ gpus = discrete_gpus()
211
+ if gpus:
212
+ names = sorted({n for n, _ in gpus})
213
+ gpu_name = " + ".join(f"{sum(n == x for n, _ in gpus)}× {x}" if sum(n == x for n, _ in gpus) > 1 else x
214
+ for x in names)
215
+ mid = os.environ.get("EVALS_MACHINE") or gpu_id(gpus, ram)
216
+ return Machine(mid, chip, round(ram, 1), round(sum(g for _, g in gpus), 1), p, e, None, os_name,
217
+ gpu_name=gpu_name, discrete=True)
81
218
  mid = os.environ.get("EVALS_MACHINE") or machine_id(chip, ram)
82
219
  return Machine(mid, chip, round(ram, 1), round(limit, 1), p, e, gpu_cores, os_name)
83
220
 
@@ -195,14 +332,15 @@ def fit(model_path, machine=None, kv_type="f16", headroom_gb=4.0, extra_bytes=0,
195
332
  model_max = info["context_length"] or 131072
196
333
  max_ctx = int(min(room / per_tok, model_max, want_ctx or model_max)) if room > 0 else 0
197
334
  max_ctx = max_ctx // 1024 * 1024
335
+ of = " of VRAM" if machine.discrete else ""
198
336
  if max_ctx < MIN_CTX:
199
337
  need = fixed + MIN_CTX * per_tok / GB
200
338
  return Fit(False, max_ctx, round(need, 1), round(available, 1),
201
339
  f"doesn't fit on {machine.id}: needs ~{need:.0f} GB at {MIN_CTX // 1024}k context, "
202
- f"{available:.0f} GB available")
340
+ f"{available:.0f} GB{of} available")
203
341
  need = fixed + max_ctx * per_tok / GB
204
342
  return Fit(True, max_ctx, round(need, 1), round(available, 1),
205
- f"fits on {machine.id} up to {max_ctx // 1024}k context (~{need:.0f} of {available:.0f} GB)")
343
+ f"fits on {machine.id} up to {max_ctx // 1024}k context (~{need:.0f} of {available:.0f} GB{of})")
206
344
 
207
345
 
208
346
  # ---------------------------------------------------------------- memory pressure
@@ -287,6 +425,26 @@ def tree_cpu_seconds(pid):
287
425
  return (sum(rows[p][1] for p in tree), sum(rows[p][2] for p in tree))
288
426
 
289
427
 
428
+ def tree_pids(pid):
429
+ """A process and all its descendants (pids), or [pid] if ps can't be read."""
430
+ try:
431
+ out = subprocess.run(["ps", "-axo", "pid=,ppid="], capture_output=True, text=True, timeout=5).stdout
432
+ except (OSError, subprocess.TimeoutExpired):
433
+ return [pid]
434
+ children = {}
435
+ for line in out.splitlines():
436
+ parts = line.split()
437
+ if len(parts) == 2:
438
+ children.setdefault(parts[1], []).append(parts[0])
439
+ tree, frontier = {str(pid)}, [str(pid)]
440
+ while frontier:
441
+ for child in children.get(frontier.pop(), []):
442
+ if child not in tree:
443
+ tree.add(child)
444
+ frontier.append(child)
445
+ return sorted(int(p) for p in tree)
446
+
447
+
290
448
  def group_cpu_seconds(pgid):
291
449
  """(user, kernel) CPU seconds used so far by a process group, or None."""
292
450
  try: