tuieval 0.2.0.dev25__tar.gz → 0.2.0.dev32__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 (74) hide show
  1. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/PKG-INFO +2 -2
  3. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/README.md +1 -1
  4. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/models.md +13 -1
  5. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/_version.py +2 -2
  6. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/compare.py +17 -3
  7. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/engine.py +278 -51
  8. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/run_evals.py +27 -5
  9. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/tui.py +171 -26
  10. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/tests/mock_server.py +5 -4
  11. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/tests/test_tuieval.py +310 -0
  12. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/.gitignore +0 -0
  13. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/LICENSE +0 -0
  14. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/RELEASING.md +0 -0
  15. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/README.md +0 -0
  16. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/favicon.ico +0 -0
  17. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/readme-header.png +0 -0
  18. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/social-preview.png +0 -0
  19. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-1024.png +0 -0
  20. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-128.png +0 -0
  21. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-16.png +0 -0
  22. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-16.svg +0 -0
  23. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-256.png +0 -0
  24. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-32.png +0 -0
  25. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-48.png +0 -0
  26. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-512.png +0 -0
  27. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-64.png +0 -0
  28. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  29. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon.svg +0 -0
  30. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/tui-setup.png +0 -0
  31. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/writing-packs.md +0 -0
  32. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/pyproject.toml +0 -0
  33. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/__init__.py +0 -0
  34. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/__main__.py +0 -0
  35. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/builtin.toml +0 -0
  36. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/cli.py +0 -0
  37. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/client.py +0 -0
  38. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/doctor.py +0 -0
  39. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/export.py +0 -0
  40. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/__init__.py +0 -0
  41. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/answer.py +0 -0
  42. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/code.py +0 -0
  43. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/rag.py +0 -0
  44. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/reply.py +0 -0
  45. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/tool_call.py +0 -0
  46. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/machines.py +0 -0
  47. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/onboard.py +0 -0
  48. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/packs.py +0 -0
  49. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/profiles.py +0 -0
  50. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/remove.py +0 -0
  51. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/scaffold.py +0 -0
  52. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/selftest.py +0 -0
  53. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/models.toml +0 -0
  54. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  55. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/answer/system.txt +0 -0
  56. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  57. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/code/pack.toml +0 -0
  58. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/code/system.txt +0 -0
  59. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  60. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  61. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/rag/system.txt +0 -0
  62. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  63. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  64. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/reply/system.txt +0 -0
  65. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  66. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  67. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  68. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  69. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  70. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/tune.py +0 -0
  71. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/verdict.py +0 -0
  72. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/watch_proxy.py +0 -0
  73. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/workspace.py +0 -0
  74. {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/yamlout.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - 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.
6
+ - More evals during a run: `n` opens Setup, and Start adds them to the run going (same tier) or queues them after it.
5
7
  - 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.
6
8
  - Less to take in at first: until something has run, the setup screen leaves out context sizes, tuning state and the Tune key; Presets and Hide are off the footer (`?` lists every key); `tuieval help` groups commands into Start here, Day to day and Advanced.
7
9
  - Results lead with a plain summary per model ("ready for Support; not decided yet for Coding → run Certify to finish"), in the TUI, `tuieval verdict` (with the command to run next) and the report; the statistics follow.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev25
3
+ Version: 0.2.0.dev32
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
@@ -137,7 +137,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
137
137
 
138
138
  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.
139
139
  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.
140
- 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). Runs started while one is going are queued.
140
+ 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.
141
141
  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.
142
142
 
143
143
  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.
@@ -111,7 +111,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
111
111
 
112
112
  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.
113
113
  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.
114
- 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). Runs started while one is going are queued.
114
+ 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.
115
115
  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.
116
116
 
117
117
  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.
@@ -71,7 +71,18 @@ The best server flags differ per model and per machine, so tuieval splits them b
71
71
 
72
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
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) and `gpu_residency_gb` (see the stall guard).
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).
75
+
76
+ ## Several models at a time
77
+
78
+ By default a run serves one model at a time. On a machine with room for more, set `parallel_models = 2` (or more) under `[machines.<id>]`, type a number in **Models at a time** on the TUI's setup screen (blank uses the machine's setting), or pass `tuieval run --parallel N`.
79
+
80
+ - Models start in queue order, each in its own lane. The next one waits while its fit-check memory estimate wouldn't fit next to the ones running (without one, e.g. a server that sizes its own memory, the setting decides). Contexts are never shrunk to make room.
81
+ - A second model on the same server gets a free port, so a server's `cmd` must take `{port}`.
82
+ - A server with `before_start` (which may stop other servers) always runs on its own. Tuning is always one model at a time.
83
+ - Answers are judged exactly as in a run of one model. Each answer records the models served while it ran (`ran_alongside`), and a load time measured next to others is marked (`loaded_alongside`), since models side by side share the machine's GPU and memory bandwidth. The speed table and the answer view show it.
84
+ - In the TUI, one model's answers stream at a time: `v` switches to the next, `k` skips the one streaming. `tuieval run` prints one line per answer instead of streaming.
85
+ - Only one run uses the machine at a time across tuieval windows; models at a time applies within a run.
75
86
 
76
87
  ## Tuning
77
88
 
@@ -255,5 +266,6 @@ Optional sections:
255
266
  # [machines.m3max-64gb]
256
267
  # memory_headroom_gb = 3 # GPU memory kept free for macOS and other apps (default 4)
257
268
  # gpu_residency_gb = 12 # GPU memory the driver keeps resident before churning (default: half of RAM)
269
+ # parallel_models = 2 # models a run serves at a time (default 1)
258
270
  ```
259
271
 
@@ -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.dev25'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev25')
21
+ __version__ = version = '0.2.0.dev32'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev32')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -37,7 +37,8 @@ def _load_native(data):
37
37
  "settings": data.get("settings", {}), "load_s": run.get("load_s"),
38
38
  "peak_rss_mb": run.get("peak_rss_mb"), "pack": pack["name"], "pack_fp": pack["fingerprint"],
39
39
  # hosted (OpenRouter) models: the provider endpoint and its declared quantization
40
- "endpoint": run.get("endpoint"), "quantization": run.get("quantization")}
40
+ "endpoint": run.get("endpoint"), "quantization": run.get("quantization"),
41
+ "loaded_alongside": run.get("loaded_alongside") or []}
41
42
  rows = []
42
43
  for r in data["results"]:
43
44
  rows.append({
@@ -54,6 +55,8 @@ def _load_native(data):
54
55
  "severity": r.get("severity"), "group": r.get("group"), "repeat": r.get("repeat", 0),
55
56
  "difficulty": r.get("difficulty", "unrated"),
56
57
  "machine": r.get("machine") or run.get("machine"), "prompt_tokens": r.get("prompt_tokens"),
58
+ # other models served at the same time (parallel_models): its speed was shared with them
59
+ "alongside": r.get("ran_alongside") or [],
57
60
  })
58
61
  return rows, info
59
62
 
@@ -210,6 +213,13 @@ def timing(r, machine=None, speeds=None):
210
213
  return None, None, None, None, None
211
214
 
212
215
 
216
+ def alongside_note(rows):
217
+ """' · n/N ran alongside others' when some answers ran while other models were served (their
218
+ speed was shared with them), else ''."""
219
+ shared = sum(bool(r.get("alongside")) for r in rows)
220
+ return f" · {shared}/{len(rows)} ran alongside others" if shared else ""
221
+
222
+
213
223
  def speed(rows, infos, machine=None, speeds=None):
214
224
  """(header, table, frontier): speed and token use per model.
215
225
 
@@ -227,7 +237,9 @@ def speed(rows, infos, machine=None, speeds=None):
227
237
  load = {}
228
238
  for i in infos:
229
239
  cur = load.setdefault(i["label"], {"load_s": None, "peak_rss_mb": None, "tags": i["tags"],
230
- "think": i["settings"].get("enable_thinking"), "served": set()})
240
+ "think": i["settings"].get("enable_thinking"), "served": set(),
241
+ "loaded_alongside": False})
242
+ cur["loaded_alongside"] |= bool(i.get("loaded_alongside"))
231
243
  if i.get("endpoint"):
232
244
  q = i.get("quantization")
233
245
  cur["served"].add(f"{'undeclared' if q in (None, 'unknown') else q} via {i['endpoint']}")
@@ -247,6 +259,7 @@ def speed(rows, infos, machine=None, speeds=None):
247
259
  here = machine is None or machine in where # load time and memory only where it ran
248
260
  if machine is None:
249
261
  kind = "measured on " + (", ".join(where) or "?")
262
+ kind += alongside_note(v)
250
263
  table.append([
251
264
  "★" if m in frontier else "", m, "; ".join(sorted(info.get("served") or [])) or "-",
252
265
  ",".join(info.get("tags") or []),
@@ -256,7 +269,8 @@ def speed(rows, infos, machine=None, speeds=None):
256
269
  _fmt(median(x[3] for x in t), ".0f"), _fmt(median(r["tokens"] for r in v), ".0f"),
257
270
  _fmt(median(r["reasoning_tokens"] for r in v), ".0f"), _fmt(median(r["answer_tokens"] for r in v), ".0f"),
258
271
  f"{sum(r['tokens'] for r in v) / passes:,.0f}" if passes else "-",
259
- _fmt(info.get("load_s") if here else None, ".0f"),
272
+ _fmt(info.get("load_s") if here else None, ".0f")
273
+ + (" (alongside others)" if here and info.get("load_s") and info.get("loaded_alongside") else ""),
260
274
  _fmt(info["peak_rss_mb"] / 1024 if info.get("peak_rss_mb") and here else None, ".1f"),
261
275
  ])
262
276
  return header, table, frontier
@@ -229,6 +229,7 @@ class Serving:
229
229
  identity: dict # output-affecting settings, part of the results fingerprint
230
230
  profile: dict | None = None
231
231
  mtp_layers: int = 0
232
+ need_gb: float | None = None # estimated GPU memory at ctx (from the fit check); None when unknown
232
233
 
233
234
 
234
235
  def infer_server(cfg, model):
@@ -664,6 +665,20 @@ def holder_text(holder):
664
665
  "One eval or tune per machine: this starts when it ends")
665
666
 
666
667
 
668
+ class Lane:
669
+ """One model being run: its own request stream, stall reason and skip switch, so models run
670
+ side by side (parallel_models) stop independently. Runs and tunes in one lane use the engine's
671
+ main lane."""
672
+ def __init__(self, label=None):
673
+ self.label = label
674
+ self.stream = None
675
+ self.stall = None # set by the stall watcher: why this model's server stalled
676
+ self.skip = threading.Event()
677
+ self.procs = []
678
+ self.port = None
679
+ self.seen = set() # other models served during this model's current load or request
680
+
681
+
667
682
  class Engine:
668
683
  def __init__(self, on_event=None, root=None, models_path=None, packs_dir=None,
669
684
  results_dir=None, log_dir=None):
@@ -677,11 +692,14 @@ class Engine:
677
692
  self.pack_errors = []
678
693
  self.packs = packs_mod.load_packs(self.packs_dir, self.pack_errors)
679
694
  self._cancel = threading.Event()
680
- self._skip = threading.Event()
681
- self._stall = None # set by the stall watcher: why the current server stalled
695
+ self._main_lane = Lane()
696
+ self._here = threading.local() # .lane: the lane this thread runs
697
+ self._lanes = {} # label -> Lane of each model being served now
698
+ self._lanes_lock = threading.Lock()
682
699
  self._procs = []
683
700
  self._stop_lock = threading.Lock()
684
- self._stream = None
701
+ self._run_lock = threading.Lock()
702
+ self._run = None # the run going: {"jobs", "pending", "open", "machine", "cond"} (add_jobs)
685
703
  self._header_cache = {}
686
704
  self._serving = {}
687
705
  self.tuning_dir = os.path.join(root, "tuning")
@@ -690,6 +708,34 @@ class Engine:
690
708
  self._catalogues = {}
691
709
  self._add_remote_models()
692
710
 
711
+ # ---- the lane this thread runs (see Lane)
712
+ def _lane(self):
713
+ return getattr(self._here, "lane", None) or self._main_lane
714
+
715
+ def _all_lanes(self):
716
+ with self._lanes_lock:
717
+ return [self._main_lane, *self._lanes.values()]
718
+
719
+ @property
720
+ def _stream(self):
721
+ return self._lane().stream
722
+
723
+ @_stream.setter
724
+ def _stream(self, value):
725
+ self._lane().stream = value
726
+
727
+ @property
728
+ def _stall(self):
729
+ return self._lane().stall
730
+
731
+ @_stall.setter
732
+ def _stall(self, value):
733
+ self._lane().stall = value
734
+
735
+ @property
736
+ def _skip(self):
737
+ return self._lane().skip
738
+
693
739
  def reload(self):
694
740
  self._serving = {}
695
741
  self.cfg = load_config(self.models_path)
@@ -1150,6 +1196,30 @@ class Engine:
1150
1196
  """models.toml [machines.<id>] (optional): memory_headroom_gb, name."""
1151
1197
  return self.cfg.get("machines", {}).get(machine_id or self.machine().id, {})
1152
1198
 
1199
+ def parallel_models(self):
1200
+ """How many models a run serves at a time on this machine: models.toml [machines.<id>]
1201
+ parallel_models (default 1)."""
1202
+ try:
1203
+ return max(1, int(self.machine_settings().get("parallel_models", 1)))
1204
+ except (TypeError, ValueError):
1205
+ return 1
1206
+
1207
+ def memory_need_gb(self, m):
1208
+ """GPU memory a model takes while served here: 0 for one we don't start (a hosted API or a
1209
+ server already running), the fit check's estimate for a local GGUF, else None (unknown)."""
1210
+ if not self.cfg["servers"][m["server"]].get("cmd"):
1211
+ return 0.0
1212
+ return self.serving(m).need_gb
1213
+
1214
+ def memory_available_gb(self):
1215
+ """GPU memory models may take here (what the fit check sizes contexts against)."""
1216
+ mc = self.machine()
1217
+ return mc.gpu_limit_gb - self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
1218
+
1219
+ def runs_alone(self, m):
1220
+ """A server whose before_start step may stop other servers never runs next to another model."""
1221
+ return bool(self.cfg["servers"][m["server"]].get("before_start"))
1222
+
1153
1223
  def gpu_residency_gb(self, machine=None):
1154
1224
  """GPU memory this machine's driver keeps resident without churning (see machines.py)."""
1155
1225
  mc = machine or self.machine()
@@ -1180,7 +1250,7 @@ class Engine:
1180
1250
  kv = m.get("kv_type") or server.get("kv_type")
1181
1251
  want = min(x for x in (server.get("max_ctx"), m.get("ctx"), m.get("max_context"), 10**9) if x)
1182
1252
  want = None if want == 10**9 else want
1183
- ctx, fits, note, mtp, size = want, True, "", 0, None
1253
+ ctx, fits, note, mtp, size, need = want, True, "", 0, None, None
1184
1254
  path = expand(m["model"])
1185
1255
  if server.get("model_is_path") and os.path.isfile(path):
1186
1256
  try:
@@ -1192,7 +1262,7 @@ class Engine:
1192
1262
  headroom = self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
1193
1263
  f = machines.fit(path, mc, kv_type=kv or "f16", headroom_gb=headroom, extra_bytes=extra,
1194
1264
  want_ctx=want)
1195
- ctx, fits, note = f.max_ctx, f.fits, f.note
1265
+ ctx, fits, note, need = f.max_ctx, f.fits, f.note, f.need_gb
1196
1266
  except (OSError, ValueError) as e:
1197
1267
  note = f"couldn't read the GGUF header ({e}); context not checked"
1198
1268
  profile, perf, source = None, [], "n/a"
@@ -1221,7 +1291,7 @@ class Engine:
1221
1291
  # resident), so both are part of what a result is valid for.
1222
1292
  identity["machine"] = mc.id
1223
1293
  identity["perf"] = perf
1224
- sv = Serving(mc, ctx, kv, fits, note, perf, source, identity, profile, mtp)
1294
+ sv = Serving(mc, ctx, kv, fits, note, perf, source, identity, profile, mtp, need)
1225
1295
  self._serving[key] = sv
1226
1296
  return sv
1227
1297
 
@@ -1289,16 +1359,21 @@ class Engine:
1289
1359
  # ---- control
1290
1360
  def cancel(self):
1291
1361
  self._cancel.set()
1292
- self._abort()
1293
-
1294
- def skip_model(self):
1295
- self._skip.set()
1296
- self._abort()
1297
-
1298
- def _abort(self):
1299
- if self._stream:
1300
- self._stream.close()
1301
- for p in list(self._procs):
1362
+ self._abort(self._all_lanes(), list(self._procs))
1363
+
1364
+ def skip_model(self, label=None):
1365
+ """Skip a model being run (label), or every one being run now."""
1366
+ with self._lanes_lock:
1367
+ lanes = [self._lanes[label]] if label in self._lanes else list(self._lanes.values()) or [self._main_lane]
1368
+ for lane in lanes:
1369
+ lane.skip.set()
1370
+ self._abort(lanes, [p for lane in lanes for p in lane.procs] if self._lanes else list(self._procs))
1371
+
1372
+ def _abort(self, lanes, procs):
1373
+ for lane in lanes:
1374
+ if lane.stream:
1375
+ lane.stream.close()
1376
+ for p in procs:
1302
1377
  self._stop(p, grace=10)
1303
1378
 
1304
1379
  def _check(self):
@@ -1322,6 +1397,7 @@ class Engine:
1322
1397
  STALL_WINDOW_S, the GPU driver is churning memory: record why and abort the request."""
1323
1398
  mc = self.machine()
1324
1399
  limit = self.gpu_residency_gb(mc)
1400
+ lane = self._lane()
1325
1401
  samples = [] # (time, user, kernel)
1326
1402
 
1327
1403
  def run():
@@ -1343,15 +1419,15 @@ class Engine:
1343
1419
  continue
1344
1420
  share = dk / (du + dk)
1345
1421
  info["kernel_share_max"] = max(info.get("kernel_share_max") or 0, round(share, 2))
1346
- if share > self.STALL_KERNEL_SHARE and (du + dk) / dt > self.STALL_MIN_BUSY and not self._stall:
1422
+ if share > self.STALL_KERNEL_SHARE and (du + dk) / dt > self.STALL_MIN_BUSY and not lane.stall:
1347
1423
  over = f"{alloc:.1f} GB of GPU memory allocated; " if alloc else ""
1348
- self._stall = (f"{m['label']} stalled in the GPU driver on {mc.id}: {100 * share:.0f}% of its "
1424
+ lane.stall = (f"{m['label']} stalled in the GPU driver on {mc.id}: {100 * share:.0f}% of its "
1349
1425
  f"CPU time went to the kernel for {self.STALL_WINDOW_S}s while the GPU waited "
1350
1426
  f"({over}this machine keeps ~{limit:.0f} GB resident). "
1351
1427
  "Lower its GPU memory or retune it.")
1352
- self.emit("server_stall", label=m["label"], message=self._stall)
1353
- if self._stream:
1354
- self._stream.close()
1428
+ self.emit("server_stall", label=m["label"], message=lane.stall)
1429
+ if lane.stream:
1430
+ lane.stream.close()
1355
1431
  threading.Thread(target=run, daemon=True).start()
1356
1432
 
1357
1433
  # ---- processes
@@ -1360,6 +1436,7 @@ class Engine:
1360
1436
  p = subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=out,
1361
1437
  stderr=subprocess.STDOUT, start_new_session=True)
1362
1438
  self._procs.append(p)
1439
+ self._lane().procs.append(p)
1363
1440
  return p
1364
1441
 
1365
1442
  def _stop(self, p, grace=30):
@@ -1381,6 +1458,9 @@ class Engine:
1381
1458
  p.wait()
1382
1459
  if p in self._procs:
1383
1460
  self._procs.remove(p)
1461
+ for lane in self._all_lanes():
1462
+ if p in lane.procs:
1463
+ lane.procs.remove(p)
1384
1464
 
1385
1465
  def _tail(self, path, label, stop):
1386
1466
  def run():
@@ -1472,45 +1552,40 @@ class Engine:
1472
1552
  return any(self.cfg["servers"][j.model["server"]].get("cmd") for j in jobs if j.status == "waiting")
1473
1553
 
1474
1554
  # ---- running
1475
- def run(self, jobs):
1476
- """Run the jobs (blocking). Jobs of the same model share one server start."""
1555
+ def run(self, jobs, parallel=None):
1556
+ """Run the jobs (blocking). Jobs of the same model share one server start. parallel: how many
1557
+ models are served at a time (default: parallel_models for this machine). add_jobs() can add
1558
+ more while it runs; they run after the ones before them."""
1477
1559
  self._cancel.clear()
1478
1560
  self.emit("queue_started", jobs=jobs)
1479
1561
  models = sorted({j.label for j in jobs if j.status == "waiting"})
1562
+ machine = bool(jobs) and self.needs_machine(jobs)
1480
1563
  lock = (self.machine_lock(f"{jobs[0].tier} run: {', '.join(models[:3])}"
1481
1564
  + (f" +{len(models) - 3}" if len(models) > 3 else ""))
1482
- if jobs and self.needs_machine(jobs) else contextlib.nullcontext())
1565
+ if machine else contextlib.nullcontext())
1566
+ order = []
1567
+ for j in jobs:
1568
+ if j.label not in order:
1569
+ order.append(j.label)
1570
+ groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
1571
+ with self._run_lock:
1572
+ self._run = {"jobs": jobs, "pending": [g for g in groups if g[1]], "open": True, "machine": machine,
1573
+ "cond": threading.Condition(self._run_lock)}
1483
1574
  try:
1484
1575
  with lock:
1485
- order = []
1486
- for j in jobs:
1487
- if j.label not in order:
1488
- order.append(j.label)
1489
- for label in order:
1490
- mine = [j for j in jobs if j.label == label and j.status == "waiting"]
1491
- if not mine:
1492
- continue
1493
- self._skip.clear()
1494
- try:
1495
- try:
1496
- self._run_model(mine)
1497
- except (Cancelled, ModelFailed):
1498
- raise
1499
- except Exception as e: # never let one model's surprise kill the whole queue
1500
- raise ModelFailed(f"unexpected error: {e!r}") from e
1501
- except ModelFailed as e:
1502
- for j in mine:
1503
- if j.status in ("waiting", "loading", "running"):
1504
- j.status = "skipped" if "by user" in str(e) else "failed"
1505
- j.note, j.finished = str(e).splitlines()[0], time.time()
1506
- self.emit("job_done", job=j)
1507
- self.emit("model_failed", label=label, message=str(e))
1576
+ self._run_lanes(max(1, int(parallel or self.parallel_models())))
1577
+ if self._cancel.is_set():
1578
+ raise Cancelled()
1508
1579
  except Cancelled:
1580
+ with self._run_lock:
1581
+ self._run["open"] = False
1509
1582
  for j in jobs:
1510
1583
  if j.status in ("waiting", "loading", "running"):
1511
1584
  j.status, j.note = "skipped", "cancelled (progress kept)" if j.done else "cancelled"
1512
1585
  self.emit("job_done", job=j)
1513
1586
  finally:
1587
+ with self._run_lock:
1588
+ self._run["open"] = False
1514
1589
  changes = []
1515
1590
  if any(j.status == "done" and j.started and j.tier != "smoke" for j in jobs):
1516
1591
  try:
@@ -1522,6 +1597,145 @@ class Engine:
1522
1597
  self.emit("verdicts", changes=changes)
1523
1598
  self.emit("queue_done", jobs=jobs, cancelled=self._cancel.is_set())
1524
1599
 
1600
+ def add_jobs(self, new):
1601
+ """Add planned jobs to the run going on, after its other jobs. Returns (added, why not): a
1602
+ pack already waiting or running in it isn't added twice, and nothing is added once the run is
1603
+ finishing or cancelled, or when it holds no machine lock (hosted only) and these need one.
1604
+ Runs on the caller's thread, so the caller shows what was added (no event)."""
1605
+ with self._run_lock:
1606
+ r = self._run
1607
+ if not r or not r["open"] or self._cancel.is_set():
1608
+ return [], "the run is finishing"
1609
+ if not r["machine"] and self.needs_machine(new):
1610
+ return [], "the run going uses only hosted models, and these need this machine"
1611
+ busy = {j.key for j in r["jobs"] if j.status in ("waiting", "loading", "running")}
1612
+ added = [j for j in new if j.status == "waiting" and j.key not in busy]
1613
+ r["jobs"].extend(added)
1614
+ for j in added: # a model still waiting takes them on; otherwise it comes again at the end
1615
+ group = next((g for g in r["pending"] if g[0] == j.label), None)
1616
+ if group:
1617
+ group[1].append(j)
1618
+ else:
1619
+ r["pending"].append((j.label, [j]))
1620
+ r["cond"].notify_all()
1621
+ return added, None if added else "everything picked is already in the run"
1622
+
1623
+ def _run_lanes(self, parallel):
1624
+ """Run each model's jobs in its own lane, up to `parallel` at a time, in order: the next
1625
+ model starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1626
+ next to the ones running (memory_need_gb; an unknown need is left to the setting). Lanes
1627
+ wait for jobs added meanwhile (add_jobs) until every model is done."""
1628
+ pending, running, told = self._run["pending"], {}, set()
1629
+ cond = self._run["cond"]
1630
+
1631
+ def blocked(label):
1632
+ """Why the next model can't start now (None: it can)."""
1633
+ if not running:
1634
+ return None
1635
+ if len(running) >= parallel:
1636
+ return ""
1637
+ m = self.model(label)
1638
+ if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in running):
1639
+ alone = label if self.runs_alone(m) else next(l for l in running if self.runs_alone(self.model(l)))
1640
+ return (f"{alone}'s server runs a before_start step that may stop other servers, so it runs "
1641
+ "on its own")
1642
+ need = self.memory_need_gb(m)
1643
+ used = sum(n for n in running.values() if n)
1644
+ room = self.memory_available_gb()
1645
+ if need and used + need > room:
1646
+ return (f"needs ~{need:.0f} GB; {', '.join(running)} take ~{used:.0f} of {room:.0f} GB, so it "
1647
+ "starts when there's room")
1648
+ return None
1649
+
1650
+ def lane():
1651
+ while True:
1652
+ mine, note = None, None
1653
+ with cond: # events go out after the lock is released: the UI may be waiting for it (add_jobs)
1654
+ if self._cancel.is_set() or not pending and not running:
1655
+ self._run["open"] = False # nothing more can be added
1656
+ cond.notify_all()
1657
+ return
1658
+ # in order, except that a model added again while it runs waits for itself to end
1659
+ # without holding up the ones behind it
1660
+ nxt = next((i for i, g in enumerate(pending) if g[0] not in running), None)
1661
+ label = pending[nxt][0] if nxt is not None else None
1662
+ why = blocked(label) if label else "" # none can start: a model still runs, more may come
1663
+ if why is None:
1664
+ label, mine = pending.pop(nxt)
1665
+ running[label] = self.memory_need_gb(self.model(label))
1666
+ elif why and label not in told:
1667
+ told.add(label)
1668
+ note = f"{label} waits: {why}"
1669
+ else:
1670
+ cond.wait(1.0)
1671
+ if note:
1672
+ self.emit("model_waiting", label=label, message=note)
1673
+ if mine is None:
1674
+ continue
1675
+ try:
1676
+ self._run_lane(label, mine)
1677
+ finally:
1678
+ with cond:
1679
+ running.pop(label, None)
1680
+ cond.notify_all()
1681
+
1682
+ if parallel == 1:
1683
+ lane()
1684
+ return
1685
+ threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(parallel)]
1686
+ for t in threads:
1687
+ t.start()
1688
+ for t in threads:
1689
+ t.join()
1690
+
1691
+ def _run_lane(self, label, mine):
1692
+ """One model's jobs, in this thread's own lane. Other models served meanwhile are noted on
1693
+ each answer (ran_alongside), since they share the machine's GPU and memory bandwidth."""
1694
+ lane = Lane(label)
1695
+ m = mine[0].model
1696
+ with self._lanes_lock:
1697
+ for other in self._lanes.values():
1698
+ other.seen.add(label)
1699
+ lane.seen = set(self._lanes)
1700
+ if self.cfg["servers"][m["server"]].get("cmd"):
1701
+ lane.port = self._lane_port(m)
1702
+ self._lanes[label] = lane
1703
+ self._here.lane = lane
1704
+ try:
1705
+ try:
1706
+ self._run_model(mine)
1707
+ except (Cancelled, ModelFailed):
1708
+ raise
1709
+ except Exception as e: # never let one model's surprise kill the whole queue
1710
+ raise ModelFailed(f"unexpected error: {e!r}") from e
1711
+ except ModelFailed as e:
1712
+ for j in mine:
1713
+ if j.status in ("waiting", "loading", "running"):
1714
+ j.status = "skipped" if "by user" in str(e) else "failed"
1715
+ j.note, j.finished = str(e).splitlines()[0], time.time()
1716
+ self.emit("job_done", job=j)
1717
+ self.emit("model_failed", label=label, message=str(e))
1718
+ except Cancelled:
1719
+ pass # run() marks what's left
1720
+ finally:
1721
+ self._here.lane = None
1722
+ with self._lanes_lock:
1723
+ self._lanes.pop(label, None)
1724
+
1725
+ def _lane_port(self, m):
1726
+ """The server's port, or a free one when another model being served has it (call with
1727
+ _lanes_lock held)."""
1728
+ port = self.cfg["servers"][m["server"]].get("port")
1729
+ if port is None or not any(l.port == port for l in self._lanes.values()):
1730
+ return port
1731
+ taken = {l.port for l in self._lanes.values()}
1732
+ while True:
1733
+ with socket.socket() as s:
1734
+ s.bind(("127.0.0.1", 0))
1735
+ free = s.getsockname()[1]
1736
+ if free not in taken:
1737
+ return free
1738
+
1525
1739
  @contextlib.contextmanager
1526
1740
  def serve(self, m, perf_args=None, port=None, log_name=None):
1527
1741
  """Start a model's server (or use the running one), wait until it serves the expected name,
@@ -1649,11 +1863,16 @@ class Engine:
1649
1863
  for j in jobs:
1650
1864
  j.status = "loading"
1651
1865
  self.emit("job_update", job=j)
1652
- with self.serve(m) as (base_url, info):
1866
+ lane = self._lane()
1867
+ with self.serve(m, port=lane.port) as (base_url, info):
1868
+ with self._lanes_lock:
1869
+ loaded_alongside = sorted(lane.seen) # served while this one loaded: its load time is shared
1870
+
1653
1871
  def run_info():
1654
1872
  return {"load_s": info["load_s"], "peak_rss_mb": info["peak_mb"](),
1655
1873
  "server_facts": info["facts"] or None, "gpu_peak_gb": info.get("gpu_peak_gb"),
1656
- "kernel_share_max": info.get("kernel_share_max"), **serving_info}
1874
+ "kernel_share_max": info.get("kernel_share_max"),
1875
+ **({"loaded_alongside": loaded_alongside} if loaded_alongside else {}), **serving_info}
1657
1876
  failed_in_row = 0
1658
1877
  for j in jobs:
1659
1878
  self._check()
@@ -1736,7 +1955,7 @@ class Engine:
1736
1955
  return tests
1737
1956
 
1738
1957
  # ---- sittings: a pack run over several sessions (stopped and resumed) keeps its total time
1739
- SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "kernel_share_max", "machine")
1958
+ SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "kernel_share_max", "machine", "loaded_alongside")
1740
1959
 
1741
1960
  def _sitting(self, job, info):
1742
1961
  now = time.time()
@@ -1811,6 +2030,9 @@ class Engine:
1811
2030
  for k in ("peak_rss_mb", "gpu_peak_gb", "kernel_share_max"):
1812
2031
  if peak(k) is not None:
1813
2032
  out[k] = peak(k)
2033
+ along = sorted({l for s in sittings for l in s.get("loaded_alongside") or []})
2034
+ if along:
2035
+ out["loaded_alongside"] = along
1814
2036
  return out
1815
2037
 
1816
2038
  def _run_job(self, job, base_url, run_info, alive=lambda: True):
@@ -1862,6 +2084,9 @@ class Engine:
1862
2084
  if (test["id"], rep) in done_keys:
1863
2085
  continue
1864
2086
  self._check()
2087
+ lane = self._lane()
2088
+ with self._lanes_lock:
2089
+ lane.seen = set(self._lanes) - {job.label}
1865
2090
  self.emit("request_started", job=job, test=test["description"], test_id=test["id"],
1866
2091
  has_image=bool(test.get("image")), repeat=rep, difficulty=test.get("difficulty", "unrated"))
1867
2092
  waits = list(self.SERVER_RETRY_WAITS_S)
@@ -1870,7 +2095,7 @@ class Engine:
1870
2095
  try:
1871
2096
  body = self._body(job, test, rep)
1872
2097
  res = client.stream_chat(base_url, body,
1873
- lambda kind, text: self.emit("delta", stream=kind, text=text),
2098
+ lambda kind, text: self.emit("delta", stream=kind, text=text, label=job.label),
1874
2099
  timeout=timeout, stream=self._stream, headers=headers)
1875
2100
  except client.Cancelled:
1876
2101
  self._check()
@@ -1917,6 +2142,8 @@ class Engine:
1917
2142
  **({"checks": g["checks"]} if "checks" in g else {}),
1918
2143
  "machine": self.machine().id,
1919
2144
  **({"quantization": ep.get("quantization")} if ep else {}),
2145
+ # other models served while this answer ran: they shared the machine, so its speed did too
2146
+ **({"ran_alongside": sorted(lane.seen)} if lane.seen else {}),
1920
2147
  "sent": "+".join(msg["role"] for msg in body["messages"]), # proof: single turn
1921
2148
  **({"seed": body["seed"]} if "seed" in body else {}), **meta}
1922
2149
  # The same long answer twice for one question, with sampling on, means a cached response