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.
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/CHANGELOG.md +2 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/PKG-INFO +2 -2
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/README.md +1 -1
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/models.md +13 -1
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/compare.py +17 -3
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/engine.py +278 -51
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/run_evals.py +27 -5
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/tui.py +171 -26
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/tests/mock_server.py +5 -4
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/tests/test_tuieval.py +310 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/.gitignore +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/LICENSE +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/docs/writing-packs.md +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/builtin.toml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/cli.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/client.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/__init__.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/machines.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/packs.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/verdict.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/watch_proxy.py +0 -0
- {tuieval-0.2.0.dev25 → tuieval-0.2.0.dev32}/src/tuieval/workspace.py +0 -0
- {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.
|
|
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).
|
|
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).
|
|
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)
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2, 0, '
|
|
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.
|
|
681
|
-
self.
|
|
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.
|
|
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
|
-
|
|
1296
|
-
self.
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
1353
|
-
if
|
|
1354
|
-
|
|
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
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
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"),
|
|
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
|