tuieval 0.2.0.dev23__py3-none-any.whl → 0.2.0.dev28__py3-none-any.whl
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/_version.py +2 -2
- tuieval/compare.py +17 -3
- tuieval/engine.py +229 -46
- tuieval/graders/code.py +1 -0
- tuieval/run_evals.py +27 -5
- tuieval/tui.py +111 -19
- {tuieval-0.2.0.dev23.dist-info → tuieval-0.2.0.dev28.dist-info}/METADATA +1 -1
- {tuieval-0.2.0.dev23.dist-info → tuieval-0.2.0.dev28.dist-info}/RECORD +11 -11
- {tuieval-0.2.0.dev23.dist-info → tuieval-0.2.0.dev28.dist-info}/WHEEL +0 -0
- {tuieval-0.2.0.dev23.dist-info → tuieval-0.2.0.dev28.dist-info}/entry_points.txt +0 -0
- {tuieval-0.2.0.dev23.dist-info → tuieval-0.2.0.dev28.dist-info}/licenses/LICENSE +0 -0
tuieval/_version.py
CHANGED
|
@@ -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.dev28'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev28')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
tuieval/compare.py
CHANGED
|
@@ -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
|
tuieval/engine.py
CHANGED
|
@@ -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,12 @@ 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._stream = None
|
|
685
701
|
self._header_cache = {}
|
|
686
702
|
self._serving = {}
|
|
687
703
|
self.tuning_dir = os.path.join(root, "tuning")
|
|
@@ -690,6 +706,34 @@ class Engine:
|
|
|
690
706
|
self._catalogues = {}
|
|
691
707
|
self._add_remote_models()
|
|
692
708
|
|
|
709
|
+
# ---- the lane this thread runs (see Lane)
|
|
710
|
+
def _lane(self):
|
|
711
|
+
return getattr(self._here, "lane", None) or self._main_lane
|
|
712
|
+
|
|
713
|
+
def _all_lanes(self):
|
|
714
|
+
with self._lanes_lock:
|
|
715
|
+
return [self._main_lane, *self._lanes.values()]
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def _stream(self):
|
|
719
|
+
return self._lane().stream
|
|
720
|
+
|
|
721
|
+
@_stream.setter
|
|
722
|
+
def _stream(self, value):
|
|
723
|
+
self._lane().stream = value
|
|
724
|
+
|
|
725
|
+
@property
|
|
726
|
+
def _stall(self):
|
|
727
|
+
return self._lane().stall
|
|
728
|
+
|
|
729
|
+
@_stall.setter
|
|
730
|
+
def _stall(self, value):
|
|
731
|
+
self._lane().stall = value
|
|
732
|
+
|
|
733
|
+
@property
|
|
734
|
+
def _skip(self):
|
|
735
|
+
return self._lane().skip
|
|
736
|
+
|
|
693
737
|
def reload(self):
|
|
694
738
|
self._serving = {}
|
|
695
739
|
self.cfg = load_config(self.models_path)
|
|
@@ -1150,6 +1194,30 @@ class Engine:
|
|
|
1150
1194
|
"""models.toml [machines.<id>] (optional): memory_headroom_gb, name."""
|
|
1151
1195
|
return self.cfg.get("machines", {}).get(machine_id or self.machine().id, {})
|
|
1152
1196
|
|
|
1197
|
+
def parallel_models(self):
|
|
1198
|
+
"""How many models a run serves at a time on this machine: models.toml [machines.<id>]
|
|
1199
|
+
parallel_models (default 1)."""
|
|
1200
|
+
try:
|
|
1201
|
+
return max(1, int(self.machine_settings().get("parallel_models", 1)))
|
|
1202
|
+
except (TypeError, ValueError):
|
|
1203
|
+
return 1
|
|
1204
|
+
|
|
1205
|
+
def memory_need_gb(self, m):
|
|
1206
|
+
"""GPU memory a model takes while served here: 0 for one we don't start (a hosted API or a
|
|
1207
|
+
server already running), the fit check's estimate for a local GGUF, else None (unknown)."""
|
|
1208
|
+
if not self.cfg["servers"][m["server"]].get("cmd"):
|
|
1209
|
+
return 0.0
|
|
1210
|
+
return self.serving(m).need_gb
|
|
1211
|
+
|
|
1212
|
+
def memory_available_gb(self):
|
|
1213
|
+
"""GPU memory models may take here (what the fit check sizes contexts against)."""
|
|
1214
|
+
mc = self.machine()
|
|
1215
|
+
return mc.gpu_limit_gb - self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
|
|
1216
|
+
|
|
1217
|
+
def runs_alone(self, m):
|
|
1218
|
+
"""A server whose before_start step may stop other servers never runs next to another model."""
|
|
1219
|
+
return bool(self.cfg["servers"][m["server"]].get("before_start"))
|
|
1220
|
+
|
|
1153
1221
|
def gpu_residency_gb(self, machine=None):
|
|
1154
1222
|
"""GPU memory this machine's driver keeps resident without churning (see machines.py)."""
|
|
1155
1223
|
mc = machine or self.machine()
|
|
@@ -1180,7 +1248,7 @@ class Engine:
|
|
|
1180
1248
|
kv = m.get("kv_type") or server.get("kv_type")
|
|
1181
1249
|
want = min(x for x in (server.get("max_ctx"), m.get("ctx"), m.get("max_context"), 10**9) if x)
|
|
1182
1250
|
want = None if want == 10**9 else want
|
|
1183
|
-
ctx, fits, note, mtp, size = want, True, "", 0, None
|
|
1251
|
+
ctx, fits, note, mtp, size, need = want, True, "", 0, None, None
|
|
1184
1252
|
path = expand(m["model"])
|
|
1185
1253
|
if server.get("model_is_path") and os.path.isfile(path):
|
|
1186
1254
|
try:
|
|
@@ -1192,7 +1260,7 @@ class Engine:
|
|
|
1192
1260
|
headroom = self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
|
|
1193
1261
|
f = machines.fit(path, mc, kv_type=kv or "f16", headroom_gb=headroom, extra_bytes=extra,
|
|
1194
1262
|
want_ctx=want)
|
|
1195
|
-
ctx, fits, note = f.max_ctx, f.fits, f.note
|
|
1263
|
+
ctx, fits, note, need = f.max_ctx, f.fits, f.note, f.need_gb
|
|
1196
1264
|
except (OSError, ValueError) as e:
|
|
1197
1265
|
note = f"couldn't read the GGUF header ({e}); context not checked"
|
|
1198
1266
|
profile, perf, source = None, [], "n/a"
|
|
@@ -1221,7 +1289,7 @@ class Engine:
|
|
|
1221
1289
|
# resident), so both are part of what a result is valid for.
|
|
1222
1290
|
identity["machine"] = mc.id
|
|
1223
1291
|
identity["perf"] = perf
|
|
1224
|
-
sv = Serving(mc, ctx, kv, fits, note, perf, source, identity, profile, mtp)
|
|
1292
|
+
sv = Serving(mc, ctx, kv, fits, note, perf, source, identity, profile, mtp, need)
|
|
1225
1293
|
self._serving[key] = sv
|
|
1226
1294
|
return sv
|
|
1227
1295
|
|
|
@@ -1289,16 +1357,21 @@ class Engine:
|
|
|
1289
1357
|
# ---- control
|
|
1290
1358
|
def cancel(self):
|
|
1291
1359
|
self._cancel.set()
|
|
1292
|
-
self._abort()
|
|
1293
|
-
|
|
1294
|
-
def skip_model(self):
|
|
1295
|
-
|
|
1296
|
-
self.
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1360
|
+
self._abort(self._all_lanes(), list(self._procs))
|
|
1361
|
+
|
|
1362
|
+
def skip_model(self, label=None):
|
|
1363
|
+
"""Skip a model being run (label), or every one being run now."""
|
|
1364
|
+
with self._lanes_lock:
|
|
1365
|
+
lanes = [self._lanes[label]] if label in self._lanes else list(self._lanes.values()) or [self._main_lane]
|
|
1366
|
+
for lane in lanes:
|
|
1367
|
+
lane.skip.set()
|
|
1368
|
+
self._abort(lanes, [p for lane in lanes for p in lane.procs] if self._lanes else list(self._procs))
|
|
1369
|
+
|
|
1370
|
+
def _abort(self, lanes, procs):
|
|
1371
|
+
for lane in lanes:
|
|
1372
|
+
if lane.stream:
|
|
1373
|
+
lane.stream.close()
|
|
1374
|
+
for p in procs:
|
|
1302
1375
|
self._stop(p, grace=10)
|
|
1303
1376
|
|
|
1304
1377
|
def _check(self):
|
|
@@ -1322,6 +1395,7 @@ class Engine:
|
|
|
1322
1395
|
STALL_WINDOW_S, the GPU driver is churning memory: record why and abort the request."""
|
|
1323
1396
|
mc = self.machine()
|
|
1324
1397
|
limit = self.gpu_residency_gb(mc)
|
|
1398
|
+
lane = self._lane()
|
|
1325
1399
|
samples = [] # (time, user, kernel)
|
|
1326
1400
|
|
|
1327
1401
|
def run():
|
|
@@ -1343,15 +1417,15 @@ class Engine:
|
|
|
1343
1417
|
continue
|
|
1344
1418
|
share = dk / (du + dk)
|
|
1345
1419
|
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
|
|
1420
|
+
if share > self.STALL_KERNEL_SHARE and (du + dk) / dt > self.STALL_MIN_BUSY and not lane.stall:
|
|
1347
1421
|
over = f"{alloc:.1f} GB of GPU memory allocated; " if alloc else ""
|
|
1348
|
-
|
|
1422
|
+
lane.stall = (f"{m['label']} stalled in the GPU driver on {mc.id}: {100 * share:.0f}% of its "
|
|
1349
1423
|
f"CPU time went to the kernel for {self.STALL_WINDOW_S}s while the GPU waited "
|
|
1350
1424
|
f"({over}this machine keeps ~{limit:.0f} GB resident). "
|
|
1351
1425
|
"Lower its GPU memory or retune it.")
|
|
1352
|
-
self.emit("server_stall", label=m["label"], message=
|
|
1353
|
-
if
|
|
1354
|
-
|
|
1426
|
+
self.emit("server_stall", label=m["label"], message=lane.stall)
|
|
1427
|
+
if lane.stream:
|
|
1428
|
+
lane.stream.close()
|
|
1355
1429
|
threading.Thread(target=run, daemon=True).start()
|
|
1356
1430
|
|
|
1357
1431
|
# ---- processes
|
|
@@ -1360,6 +1434,7 @@ class Engine:
|
|
|
1360
1434
|
p = subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=out,
|
|
1361
1435
|
stderr=subprocess.STDOUT, start_new_session=True)
|
|
1362
1436
|
self._procs.append(p)
|
|
1437
|
+
self._lane().procs.append(p)
|
|
1363
1438
|
return p
|
|
1364
1439
|
|
|
1365
1440
|
def _stop(self, p, grace=30):
|
|
@@ -1381,6 +1456,9 @@ class Engine:
|
|
|
1381
1456
|
p.wait()
|
|
1382
1457
|
if p in self._procs:
|
|
1383
1458
|
self._procs.remove(p)
|
|
1459
|
+
for lane in self._all_lanes():
|
|
1460
|
+
if p in lane.procs:
|
|
1461
|
+
lane.procs.remove(p)
|
|
1384
1462
|
|
|
1385
1463
|
def _tail(self, path, label, stop):
|
|
1386
1464
|
def run():
|
|
@@ -1472,8 +1550,9 @@ class Engine:
|
|
|
1472
1550
|
return any(self.cfg["servers"][j.model["server"]].get("cmd") for j in jobs if j.status == "waiting")
|
|
1473
1551
|
|
|
1474
1552
|
# ---- running
|
|
1475
|
-
def run(self, jobs):
|
|
1476
|
-
"""Run the jobs (blocking). Jobs of the same model share one server start.
|
|
1553
|
+
def run(self, jobs, parallel=None):
|
|
1554
|
+
"""Run the jobs (blocking). Jobs of the same model share one server start. parallel: how many
|
|
1555
|
+
models are served at a time (default: parallel_models for this machine)."""
|
|
1477
1556
|
self._cancel.clear()
|
|
1478
1557
|
self.emit("queue_started", jobs=jobs)
|
|
1479
1558
|
models = sorted({j.label for j in jobs if j.status == "waiting"})
|
|
@@ -1486,25 +1565,10 @@ class Engine:
|
|
|
1486
1565
|
for j in jobs:
|
|
1487
1566
|
if j.label not in order:
|
|
1488
1567
|
order.append(j.label)
|
|
1489
|
-
for label in order
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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))
|
|
1568
|
+
groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
|
|
1569
|
+
self._run_lanes([g for g in groups if g[1]], max(1, int(parallel or self.parallel_models())))
|
|
1570
|
+
if self._cancel.is_set():
|
|
1571
|
+
raise Cancelled()
|
|
1508
1572
|
except Cancelled:
|
|
1509
1573
|
for j in jobs:
|
|
1510
1574
|
if j.status in ("waiting", "loading", "running"):
|
|
@@ -1522,6 +1586,112 @@ class Engine:
|
|
|
1522
1586
|
self.emit("verdicts", changes=changes)
|
|
1523
1587
|
self.emit("queue_done", jobs=jobs, cancelled=self._cancel.is_set())
|
|
1524
1588
|
|
|
1589
|
+
def _run_lanes(self, groups, parallel):
|
|
1590
|
+
"""Run each model's jobs in its own lane, up to `parallel` at a time, in order: the next
|
|
1591
|
+
model starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
|
|
1592
|
+
next to the ones running (memory_need_gb; an unknown need is left to the setting)."""
|
|
1593
|
+
pending, running, told = list(groups), {}, set()
|
|
1594
|
+
cond = threading.Condition()
|
|
1595
|
+
|
|
1596
|
+
def blocked(label):
|
|
1597
|
+
"""Why the next model can't start now (None: it can)."""
|
|
1598
|
+
if not running:
|
|
1599
|
+
return None
|
|
1600
|
+
if len(running) >= parallel:
|
|
1601
|
+
return ""
|
|
1602
|
+
m = self.model(label)
|
|
1603
|
+
if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in running):
|
|
1604
|
+
alone = label if self.runs_alone(m) else next(l for l in running if self.runs_alone(self.model(l)))
|
|
1605
|
+
return (f"{alone}'s server runs a before_start step that may stop other servers, so it runs "
|
|
1606
|
+
"on its own")
|
|
1607
|
+
need = self.memory_need_gb(m)
|
|
1608
|
+
used = sum(n for n in running.values() if n)
|
|
1609
|
+
room = self.memory_available_gb()
|
|
1610
|
+
if need and used + need > room:
|
|
1611
|
+
return (f"needs ~{need:.0f} GB; {', '.join(running)} take ~{used:.0f} of {room:.0f} GB, so it "
|
|
1612
|
+
"starts when there's room")
|
|
1613
|
+
return None
|
|
1614
|
+
|
|
1615
|
+
def lane():
|
|
1616
|
+
while True:
|
|
1617
|
+
with cond:
|
|
1618
|
+
while True:
|
|
1619
|
+
if self._cancel.is_set() or not pending:
|
|
1620
|
+
return
|
|
1621
|
+
label = pending[0][0]
|
|
1622
|
+
why = blocked(label)
|
|
1623
|
+
if why is None:
|
|
1624
|
+
label, mine = pending.pop(0)
|
|
1625
|
+
running[label] = self.memory_need_gb(self.model(label))
|
|
1626
|
+
break
|
|
1627
|
+
if why and label not in told:
|
|
1628
|
+
told.add(label)
|
|
1629
|
+
self.emit("model_waiting", label=label, message=f"{label} waits: {why}")
|
|
1630
|
+
cond.wait(1.0)
|
|
1631
|
+
try:
|
|
1632
|
+
self._run_lane(label, mine)
|
|
1633
|
+
finally:
|
|
1634
|
+
with cond:
|
|
1635
|
+
running.pop(label, None)
|
|
1636
|
+
cond.notify_all()
|
|
1637
|
+
|
|
1638
|
+
if parallel == 1 or len(groups) == 1:
|
|
1639
|
+
lane()
|
|
1640
|
+
return
|
|
1641
|
+
threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(min(parallel, len(groups)))]
|
|
1642
|
+
for t in threads:
|
|
1643
|
+
t.start()
|
|
1644
|
+
for t in threads:
|
|
1645
|
+
t.join()
|
|
1646
|
+
|
|
1647
|
+
def _run_lane(self, label, mine):
|
|
1648
|
+
"""One model's jobs, in this thread's own lane. Other models served meanwhile are noted on
|
|
1649
|
+
each answer (ran_alongside), since they share the machine's GPU and memory bandwidth."""
|
|
1650
|
+
lane = Lane(label)
|
|
1651
|
+
m = mine[0].model
|
|
1652
|
+
with self._lanes_lock:
|
|
1653
|
+
for other in self._lanes.values():
|
|
1654
|
+
other.seen.add(label)
|
|
1655
|
+
lane.seen = set(self._lanes)
|
|
1656
|
+
if self.cfg["servers"][m["server"]].get("cmd"):
|
|
1657
|
+
lane.port = self._lane_port(m)
|
|
1658
|
+
self._lanes[label] = lane
|
|
1659
|
+
self._here.lane = lane
|
|
1660
|
+
try:
|
|
1661
|
+
try:
|
|
1662
|
+
self._run_model(mine)
|
|
1663
|
+
except (Cancelled, ModelFailed):
|
|
1664
|
+
raise
|
|
1665
|
+
except Exception as e: # never let one model's surprise kill the whole queue
|
|
1666
|
+
raise ModelFailed(f"unexpected error: {e!r}") from e
|
|
1667
|
+
except ModelFailed as e:
|
|
1668
|
+
for j in mine:
|
|
1669
|
+
if j.status in ("waiting", "loading", "running"):
|
|
1670
|
+
j.status = "skipped" if "by user" in str(e) else "failed"
|
|
1671
|
+
j.note, j.finished = str(e).splitlines()[0], time.time()
|
|
1672
|
+
self.emit("job_done", job=j)
|
|
1673
|
+
self.emit("model_failed", label=label, message=str(e))
|
|
1674
|
+
except Cancelled:
|
|
1675
|
+
pass # run() marks what's left
|
|
1676
|
+
finally:
|
|
1677
|
+
self._here.lane = None
|
|
1678
|
+
with self._lanes_lock:
|
|
1679
|
+
self._lanes.pop(label, None)
|
|
1680
|
+
|
|
1681
|
+
def _lane_port(self, m):
|
|
1682
|
+
"""The server's port, or a free one when another model being served has it (call with
|
|
1683
|
+
_lanes_lock held)."""
|
|
1684
|
+
port = self.cfg["servers"][m["server"]].get("port")
|
|
1685
|
+
if port is None or not any(l.port == port for l in self._lanes.values()):
|
|
1686
|
+
return port
|
|
1687
|
+
taken = {l.port for l in self._lanes.values()}
|
|
1688
|
+
while True:
|
|
1689
|
+
with socket.socket() as s:
|
|
1690
|
+
s.bind(("127.0.0.1", 0))
|
|
1691
|
+
free = s.getsockname()[1]
|
|
1692
|
+
if free not in taken:
|
|
1693
|
+
return free
|
|
1694
|
+
|
|
1525
1695
|
@contextlib.contextmanager
|
|
1526
1696
|
def serve(self, m, perf_args=None, port=None, log_name=None):
|
|
1527
1697
|
"""Start a model's server (or use the running one), wait until it serves the expected name,
|
|
@@ -1649,11 +1819,16 @@ class Engine:
|
|
|
1649
1819
|
for j in jobs:
|
|
1650
1820
|
j.status = "loading"
|
|
1651
1821
|
self.emit("job_update", job=j)
|
|
1652
|
-
|
|
1822
|
+
lane = self._lane()
|
|
1823
|
+
with self.serve(m, port=lane.port) as (base_url, info):
|
|
1824
|
+
with self._lanes_lock:
|
|
1825
|
+
loaded_alongside = sorted(lane.seen) # served while this one loaded: its load time is shared
|
|
1826
|
+
|
|
1653
1827
|
def run_info():
|
|
1654
1828
|
return {"load_s": info["load_s"], "peak_rss_mb": info["peak_mb"](),
|
|
1655
1829
|
"server_facts": info["facts"] or None, "gpu_peak_gb": info.get("gpu_peak_gb"),
|
|
1656
|
-
"kernel_share_max": info.get("kernel_share_max"),
|
|
1830
|
+
"kernel_share_max": info.get("kernel_share_max"),
|
|
1831
|
+
**({"loaded_alongside": loaded_alongside} if loaded_alongside else {}), **serving_info}
|
|
1657
1832
|
failed_in_row = 0
|
|
1658
1833
|
for j in jobs:
|
|
1659
1834
|
self._check()
|
|
@@ -1736,7 +1911,7 @@ class Engine:
|
|
|
1736
1911
|
return tests
|
|
1737
1912
|
|
|
1738
1913
|
# ---- 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")
|
|
1914
|
+
SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "kernel_share_max", "machine", "loaded_alongside")
|
|
1740
1915
|
|
|
1741
1916
|
def _sitting(self, job, info):
|
|
1742
1917
|
now = time.time()
|
|
@@ -1811,6 +1986,9 @@ class Engine:
|
|
|
1811
1986
|
for k in ("peak_rss_mb", "gpu_peak_gb", "kernel_share_max"):
|
|
1812
1987
|
if peak(k) is not None:
|
|
1813
1988
|
out[k] = peak(k)
|
|
1989
|
+
along = sorted({l for s in sittings for l in s.get("loaded_alongside") or []})
|
|
1990
|
+
if along:
|
|
1991
|
+
out["loaded_alongside"] = along
|
|
1814
1992
|
return out
|
|
1815
1993
|
|
|
1816
1994
|
def _run_job(self, job, base_url, run_info, alive=lambda: True):
|
|
@@ -1862,6 +2040,9 @@ class Engine:
|
|
|
1862
2040
|
if (test["id"], rep) in done_keys:
|
|
1863
2041
|
continue
|
|
1864
2042
|
self._check()
|
|
2043
|
+
lane = self._lane()
|
|
2044
|
+
with self._lanes_lock:
|
|
2045
|
+
lane.seen = set(self._lanes) - {job.label}
|
|
1865
2046
|
self.emit("request_started", job=job, test=test["description"], test_id=test["id"],
|
|
1866
2047
|
has_image=bool(test.get("image")), repeat=rep, difficulty=test.get("difficulty", "unrated"))
|
|
1867
2048
|
waits = list(self.SERVER_RETRY_WAITS_S)
|
|
@@ -1870,7 +2051,7 @@ class Engine:
|
|
|
1870
2051
|
try:
|
|
1871
2052
|
body = self._body(job, test, rep)
|
|
1872
2053
|
res = client.stream_chat(base_url, body,
|
|
1873
|
-
lambda kind, text: self.emit("delta", stream=kind, text=text),
|
|
2054
|
+
lambda kind, text: self.emit("delta", stream=kind, text=text, label=job.label),
|
|
1874
2055
|
timeout=timeout, stream=self._stream, headers=headers)
|
|
1875
2056
|
except client.Cancelled:
|
|
1876
2057
|
self._check()
|
|
@@ -1917,6 +2098,8 @@ class Engine:
|
|
|
1917
2098
|
**({"checks": g["checks"]} if "checks" in g else {}),
|
|
1918
2099
|
"machine": self.machine().id,
|
|
1919
2100
|
**({"quantization": ep.get("quantization")} if ep else {}),
|
|
2101
|
+
# other models served while this answer ran: they shared the machine, so its speed did too
|
|
2102
|
+
**({"ran_alongside": sorted(lane.seen)} if lane.seen else {}),
|
|
1920
2103
|
"sent": "+".join(msg["role"] for msg in body["messages"]), # proof: single turn
|
|
1921
2104
|
**({"seed": body["seed"]} if "seed" in body else {}), **meta}
|
|
1922
2105
|
# The same long answer twice for one question, with sampling on, means a cached response
|
tuieval/graders/code.py
CHANGED
|
@@ -53,6 +53,7 @@ def _err(e):
|
|
|
53
53
|
return last[:200]
|
|
54
54
|
try:
|
|
55
55
|
exec(compile(CANDIDATE, "candidate.py", "exec"), ns)
|
|
56
|
+
ns = dict(ns) # tests get a copy, so their imports can't rebind the candidate's globals
|
|
56
57
|
exec(compile(SETUP, "setup.py", "exec"), ns)
|
|
57
58
|
setup_error = None
|
|
58
59
|
except BaseException as e:
|
tuieval/run_evals.py
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
tuieval run --tier smoke --only my-model # 3 tests per pack, 1 repeat: check paths and flags
|
|
16
16
|
tuieval run --tier smoke --only openrouter:qwen/qwen3-32b # any OpenRouter model, no models.toml edit
|
|
17
17
|
tuieval run --dry-run # print the plan and server commands only
|
|
18
|
+
tuieval run --parallel 2 # serve two models at a time (parallel_models in models.toml)
|
|
18
19
|
tuieval add ~/models/New-Model-Q4_K_M.gguf # register a model (--vision/--mmproj, --no-think, --tags)
|
|
19
20
|
tuieval scan # GGUFs in model_dirs that aren't registered yet
|
|
20
21
|
tuieval list # the models tuieval knows; hidden ones listed separately
|
|
@@ -53,6 +54,7 @@ class Printer:
|
|
|
53
54
|
def __init__(self):
|
|
54
55
|
self.lock = threading.Lock()
|
|
55
56
|
self.mode = None
|
|
57
|
+
self.side_by_side = False # several models at a time: one line per answer, no live streams
|
|
56
58
|
|
|
57
59
|
def __call__(self, kind, **d):
|
|
58
60
|
with self.lock:
|
|
@@ -73,6 +75,9 @@ class Printer:
|
|
|
73
75
|
def on_server_stall(self, label, message):
|
|
74
76
|
say(f"STALL: {message}", RED)
|
|
75
77
|
|
|
78
|
+
def on_model_waiting(self, label, message):
|
|
79
|
+
say(message, YELLOW)
|
|
80
|
+
|
|
76
81
|
def on_server_retry(self, label, message):
|
|
77
82
|
say(message, RED)
|
|
78
83
|
|
|
@@ -97,11 +102,15 @@ class Printer:
|
|
|
97
102
|
+ (f" ({earlier_s / 60:.0f} min over {sittings} earlier sitting(s))" if sittings else ""))
|
|
98
103
|
|
|
99
104
|
def on_request_started(self, job, test, test_id, has_image, repeat, difficulty="unrated"):
|
|
105
|
+
if self.side_by_side:
|
|
106
|
+
return
|
|
100
107
|
tag = {"easy": GREEN, "medium": "\033[33m", "hard": RED}.get(difficulty, DIM) + f"[{difficulty}]" + RESET
|
|
101
108
|
print(f"\n{BOLD}{CYAN}━━ {job.key} [{job.done + 1}/{job.total}]{RESET} {tag} {BOLD}{CYAN}{test}{RESET}", flush=True)
|
|
102
109
|
self.mode = None
|
|
103
110
|
|
|
104
|
-
def on_delta(self, stream, text):
|
|
111
|
+
def on_delta(self, stream, text, label=None):
|
|
112
|
+
if self.side_by_side:
|
|
113
|
+
return
|
|
105
114
|
if self.mode != stream:
|
|
106
115
|
sys.stdout.write(f"{DIM}[thinking] " if stream == "reasoning" else f"{RESET}\n{BOLD}[answer]{RESET} ")
|
|
107
116
|
self.mode = stream
|
|
@@ -114,8 +123,10 @@ class Printer:
|
|
|
114
123
|
calls = f" tool={r['tool_calls'][0]['name']}" if r.get("tool_calls") else ""
|
|
115
124
|
speed = f"{r['gen_tps']:.0f} tok/s, " if r.get("gen_tps") else ""
|
|
116
125
|
ttft = f"TTFT {r['ttft_s']:.1f}s, " if r.get("ttft_s") is not None else ""
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
where = f"{CYAN}{job.key} [{job.done}/{job.total}]{RESET} " if self.side_by_side else ""
|
|
127
|
+
print(f"{RESET}{'' if self.side_by_side else chr(10)}{where}{mark} {r['description']}{RESET}{calls} "
|
|
128
|
+
f"{DIM}({r.get('completion_tokens')} tokens, {ttft}{speed}{r['total_s']:.1f}s) {r['reason'][:120]}{RESET}",
|
|
129
|
+
flush=True)
|
|
119
130
|
|
|
120
131
|
def on_job_done(self, job):
|
|
121
132
|
color = GREEN if job.status == "done" else RED if job.status == "failed" else ""
|
|
@@ -683,6 +694,8 @@ def main(argv=None):
|
|
|
683
694
|
p.add_argument("--tier", choices=engine.TIERS, default="screen",
|
|
684
695
|
help="smoke: 3 tests; screen (default): a spread sample; certify: everything, with repeats")
|
|
685
696
|
p.add_argument("--smoke", action="store_const", const="smoke", dest="tier", help="same as --tier smoke")
|
|
697
|
+
p.add_argument("--parallel", type=int, metavar="N",
|
|
698
|
+
help="models served at a time (default: parallel_models for this machine in models.toml, else 1)")
|
|
686
699
|
p.add_argument("--dry-run", action="store_true", help="print the plan and commands without running")
|
|
687
700
|
p.add_argument("--brief", action="store_true", help="end with one line per model instead of the scorecards")
|
|
688
701
|
p.add_argument("--models", default=None, help="default: the workspace's models.toml")
|
|
@@ -691,8 +704,12 @@ def main(argv=None):
|
|
|
691
704
|
p.add_argument("--log-dir", help="server and request logs (default: logs/)")
|
|
692
705
|
args = p.parse_args(argv)
|
|
693
706
|
|
|
694
|
-
|
|
707
|
+
printer = Printer()
|
|
708
|
+
e = engine.Engine(printer, models_path=args.models, packs_dir=args.packs_dir,
|
|
695
709
|
results_dir=args.results_dir, log_dir=args.log_dir)
|
|
710
|
+
if args.parallel is not None and args.parallel < 1:
|
|
711
|
+
sys.exit("--parallel must be 1 or more")
|
|
712
|
+
parallel = args.parallel or e.parallel_models()
|
|
696
713
|
models = e.cfg["models"]
|
|
697
714
|
labels = [m["label"] for m in models if not m.get("remote")] # hosted APIs cost money: only by name
|
|
698
715
|
if args.only:
|
|
@@ -757,7 +774,12 @@ def main(argv=None):
|
|
|
757
774
|
|
|
758
775
|
signal.signal(signal.SIGINT, stop)
|
|
759
776
|
signal.signal(signal.SIGTERM, stop)
|
|
760
|
-
|
|
777
|
+
side_by_side = min(parallel, len({j.label for j in jobs if j.status == "waiting"}))
|
|
778
|
+
if side_by_side > 1:
|
|
779
|
+
printer.side_by_side = True
|
|
780
|
+
say(f"up to {side_by_side} models at a time: one line per answer; each answer notes the models "
|
|
781
|
+
"it ran alongside, since they share this machine's speed")
|
|
782
|
+
worker = threading.Thread(target=e.run, args=(jobs, parallel))
|
|
761
783
|
worker.start()
|
|
762
784
|
while worker.is_alive():
|
|
763
785
|
worker.join(0.5)
|
tuieval/tui.py
CHANGED
|
@@ -290,6 +290,9 @@ HELP = {
|
|
|
290
290
|
2. Pick a [b]tier[/b]: Smoke first to check the setup, then Screen, then Certify for finalists.
|
|
291
291
|
3. Press [b]s[/b]. The line above the buttons says how many answers that is and roughly how long.
|
|
292
292
|
|
|
293
|
+
[b]Models at a time[/b]: how many of the ticked models run side by side (blank: this machine's
|
|
294
|
+
parallel_models in models.toml, default 1). Answers note the models they ran alongside.
|
|
295
|
+
|
|
293
296
|
[b]Keys[/b]
|
|
294
297
|
s start (or queue it behind a run that's going) r results
|
|
295
298
|
a add a model (a GGUF, a model id, openrouter:<id>) m scan model folders for new GGUFs
|
|
@@ -308,7 +311,8 @@ with the model's reasoning and answer as they stream, then recent results with t
|
|
|
308
311
|
[b]Keys[/b]
|
|
309
312
|
k skip the current model c cancel everything (finished answers are kept; it resumes next time)
|
|
310
313
|
f pause or resume auto-scroll r results n set up a new run (this one keeps going)
|
|
311
|
-
w runs and queue esc back to Setup (the run keeps going)
|
|
314
|
+
w runs and queue esc back to Setup (the run keeps going)
|
|
315
|
+
v with several models at a time: stream the next one (k skips the one streaming)""",
|
|
312
316
|
"ResultsScreen": """[b]Results[/b]
|
|
313
317
|
|
|
314
318
|
Start with [b]Production readiness[/b]: one verdict per model and use case, and what's missing when
|
|
@@ -435,6 +439,7 @@ class AnswerScreen(ModalScreen):
|
|
|
435
439
|
f"{rec['gen_tps']:.0f} tok/s" if rec.get("gen_tps") else "",
|
|
436
440
|
f"{rec['total_s']:.0f}s total" if rec.get("total_s") is not None else "",
|
|
437
441
|
f"on {rec['machine']}" if rec.get("machine") else "",
|
|
442
|
+
f"[yellow]ran alongside {', '.join(rec['ran_alongside'])}[/yellow]" if rec.get("ran_alongside") else "",
|
|
438
443
|
f"seed {rec['seed']}" if "seed" in rec else ""]
|
|
439
444
|
self.query_one("#answer-head", Static).update(
|
|
440
445
|
f"{mark} {diff_badge(rec.get('difficulty', 'unrated'))} [b]{rich_escape(rec.get('description', rec['test']))}[/b]"
|
|
@@ -544,6 +549,8 @@ class SetupScreen(Screen):
|
|
|
544
549
|
with Horizontal(id="options"):
|
|
545
550
|
yield Label("Repeats")
|
|
546
551
|
yield Input("", id="repeat", type="integer", max_length=2, placeholder="1")
|
|
552
|
+
yield Label("Models at a time")
|
|
553
|
+
yield Input("", id="parallel", type="integer", max_length=1, placeholder="1")
|
|
547
554
|
yield Checkbox("Rerun existing results", id="force")
|
|
548
555
|
yield Static(id="estimate")
|
|
549
556
|
with Horizontal(id="buttons"):
|
|
@@ -761,6 +768,13 @@ class SetupScreen(Screen):
|
|
|
761
768
|
list(self.query_one("#suites", SelectionList).selected), repeat,
|
|
762
769
|
self.tier(), self.query_one("#force", Checkbox).value)
|
|
763
770
|
|
|
771
|
+
def parallel(self):
|
|
772
|
+
"""Models at a time for the next run: the field, or blank for this machine's parallel_models."""
|
|
773
|
+
try:
|
|
774
|
+
return max(1, int(self.query_one("#parallel", Input).value)) or None
|
|
775
|
+
except ValueError:
|
|
776
|
+
return None
|
|
777
|
+
|
|
764
778
|
def current_picks(self, suites=None):
|
|
765
779
|
"""{pack: picked test ids} for packs that still have those tests (and, given suites, are ticked)."""
|
|
766
780
|
e = self.app.engine
|
|
@@ -803,6 +817,7 @@ class SetupScreen(Screen):
|
|
|
803
817
|
e = self.app.engine
|
|
804
818
|
defaults = sorted({e.default_repeat(e.packs[p], tier) for p in suites}) or [1]
|
|
805
819
|
self.query_one("#repeat", Input).placeholder = str(defaults[0]) if len(defaults) == 1 else "pack"
|
|
820
|
+
self.query_one("#parallel", Input).placeholder = str(e.parallel_models())
|
|
806
821
|
if not e.packs:
|
|
807
822
|
est.update("[yellow]No packs yet.[/yellow] [dim]Quit, run tuieval new-pack <name>, "
|
|
808
823
|
"and open tuieval again.[/dim]")
|
|
@@ -852,6 +867,11 @@ class SetupScreen(Screen):
|
|
|
852
867
|
resumed = [j for j in run if "done earlier" in j.note]
|
|
853
868
|
if resumed:
|
|
854
869
|
text += f" [yellow]{len(resumed)} pack(s) continue from earlier results[/yellow]"
|
|
870
|
+
at_once = min(self.parallel() or e.parallel_models(), len({j.label for j in run}))
|
|
871
|
+
if at_once > 1:
|
|
872
|
+
text += (f"\n[cyan]Up to {at_once} models at a time[/cyan][dim] (the time above assumes one at a "
|
|
873
|
+
"time). Answers note the models they ran alongside, since those share the machine's "
|
|
874
|
+
"speed.[/dim]")
|
|
855
875
|
est.update(text + self.queue_hint())
|
|
856
876
|
|
|
857
877
|
def queue_hint(self):
|
|
@@ -879,6 +899,8 @@ class SetupScreen(Screen):
|
|
|
879
899
|
if name in e.packs:
|
|
880
900
|
packs.select(name)
|
|
881
901
|
self.query_one("#repeat", Input).value = str(sel["repeat"]) if sel.get("repeat") else ""
|
|
902
|
+
if "parallel" in sel:
|
|
903
|
+
self.query_one("#parallel", Input).value = str(sel["parallel"]) if sel["parallel"] else ""
|
|
882
904
|
tier = sel.get("tier") or ("smoke" if sel.get("smoke") else None)
|
|
883
905
|
if tier in engine.TIERS:
|
|
884
906
|
self.query_one(f"#tier-{tier}", RadioButton).value = True
|
|
@@ -917,7 +939,8 @@ class SetupScreen(Screen):
|
|
|
917
939
|
self.notify("Select at least one eval pack and one model.", severity="warning")
|
|
918
940
|
return
|
|
919
941
|
picks = self.current_picks(suites)
|
|
920
|
-
sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks
|
|
942
|
+
sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
|
|
943
|
+
"parallel": self.parallel()}
|
|
921
944
|
jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks)
|
|
922
945
|
rerun = False
|
|
923
946
|
if not any(j.status == "waiting" for j in jobs):
|
|
@@ -1453,7 +1476,7 @@ class QueuedRun:
|
|
|
1453
1476
|
if new:
|
|
1454
1477
|
return None, (f"it would now start over {', '.join(new)}, which wasn't confirmed when it was queued. "
|
|
1455
1478
|
"Start it again from Setup to see why")
|
|
1456
|
-
return RunScreen(jobs), None
|
|
1479
|
+
return RunScreen(jobs, sel.get("parallel")), None
|
|
1457
1480
|
|
|
1458
1481
|
|
|
1459
1482
|
class QueuedTune:
|
|
@@ -1783,6 +1806,7 @@ class RunScreen(Screen):
|
|
|
1783
1806
|
Binding("k", "skip_model", "Skip model", priority=True),
|
|
1784
1807
|
Binding("c", "cancel_all", "Cancel all", priority=True),
|
|
1785
1808
|
Binding("f", "toggle_follow", "Pause scroll", priority=True),
|
|
1809
|
+
Binding("v", "watch_next", "Next model", priority=True),
|
|
1786
1810
|
Binding("n", "new_run", "New run", priority=True),
|
|
1787
1811
|
Binding("r", "results", "Results", priority=True),
|
|
1788
1812
|
Binding("w", "runs", "Runs", priority=True),
|
|
@@ -1793,9 +1817,11 @@ class RunScreen(Screen):
|
|
|
1793
1817
|
LIVE_ONLY = {"skip_model", "cancel_all", "toggle_follow"}
|
|
1794
1818
|
DONE_ONLY = {"new_run"}
|
|
1795
1819
|
|
|
1796
|
-
def __init__(self, jobs):
|
|
1820
|
+
def __init__(self, jobs, parallel=None):
|
|
1797
1821
|
super().__init__()
|
|
1798
1822
|
self.jobs = jobs
|
|
1823
|
+
self.parallel = parallel # models at a time (None: this machine's parallel_models)
|
|
1824
|
+
self.watch = None # the model whose answers stream (several run at a time)
|
|
1799
1825
|
self.running = True
|
|
1800
1826
|
self.follow = True
|
|
1801
1827
|
self.buf = {"reasoning": [], "answer": []}
|
|
@@ -1839,12 +1865,24 @@ class RunScreen(Screen):
|
|
|
1839
1865
|
self.notify("\n".join(lines), title=title, severity="error", timeout=30)
|
|
1840
1866
|
|
|
1841
1867
|
def check_action(self, action, parameters):
|
|
1868
|
+
if action == "watch_next":
|
|
1869
|
+
return self.running and self.side_by_side
|
|
1842
1870
|
if action in self.LIVE_ONLY:
|
|
1843
1871
|
return self.running
|
|
1844
1872
|
if action in self.DONE_ONLY:
|
|
1845
1873
|
return not self.running
|
|
1846
1874
|
return True
|
|
1847
1875
|
|
|
1876
|
+
@property
|
|
1877
|
+
def side_by_side(self):
|
|
1878
|
+
"""Several models run at a time in this run."""
|
|
1879
|
+
return min(self.parallel or self.app.engine.parallel_models(),
|
|
1880
|
+
len({j.label for j in self.jobs if j.status != "skipped"})) > 1
|
|
1881
|
+
|
|
1882
|
+
def active(self):
|
|
1883
|
+
"""Models being loaded or run now, in queue order."""
|
|
1884
|
+
return list(dict.fromkeys(j.label for j in self.jobs if j.status in ("loading", "running")))
|
|
1885
|
+
|
|
1848
1886
|
def counts(self):
|
|
1849
1887
|
return (sum(j.done for j in self.jobs), sum(j.passed for j in self.jobs), sum(j.failed for j in self.jobs))
|
|
1850
1888
|
|
|
@@ -1908,14 +1946,18 @@ class RunScreen(Screen):
|
|
|
1908
1946
|
self.query_one("#bar", ProgressBar).update(total=max(self.total, 1), progress=0)
|
|
1909
1947
|
self.set_interval(0.1, self.flush_streams)
|
|
1910
1948
|
self.set_interval(1.0, self.tick)
|
|
1911
|
-
self.app.start_run(self.jobs, self.on_engine_event)
|
|
1949
|
+
self.app.start_run(self.jobs, self.on_engine_event, self.parallel)
|
|
1912
1950
|
self.tick()
|
|
1913
1951
|
|
|
1914
1952
|
# -- events from the engine thread
|
|
1915
1953
|
def on_engine_event(self, kind, **d):
|
|
1916
1954
|
if self.app.closing:
|
|
1917
1955
|
return # the app is shutting down; the UI thread may be waiting on the engine
|
|
1956
|
+
if kind in ("request_started", "model_loading") and self.watch not in self.active():
|
|
1957
|
+
self.watch = d["job"].label if "job" in d else d["label"] # follow the next model streaming
|
|
1918
1958
|
if kind == "delta": # very frequent: buffer, flushed every 100 ms on the UI thread
|
|
1959
|
+
if d.get("label", self.watch) != self.watch:
|
|
1960
|
+
return # another model running alongside: v switches to it
|
|
1919
1961
|
with self.buf_lock:
|
|
1920
1962
|
self.buf[d["stream"]].append(d["text"])
|
|
1921
1963
|
return
|
|
@@ -1936,8 +1978,15 @@ class RunScreen(Screen):
|
|
|
1936
1978
|
def event(self, text):
|
|
1937
1979
|
self.query_one("#events", Log).write_line(time.strftime("%H:%M:%S ") + text)
|
|
1938
1980
|
|
|
1981
|
+
def watched(self, kind, d):
|
|
1982
|
+
"""Whether an event belongs to the streaming model (always, with one model at a time)."""
|
|
1983
|
+
label = d["job"].label if "job" in d else d.get("label")
|
|
1984
|
+
return label is None or label == self.watch or not self.side_by_side
|
|
1985
|
+
|
|
1939
1986
|
def handle(self, kind, d):
|
|
1940
|
-
if kind == "request_started":
|
|
1987
|
+
if kind == "request_started" and not self.watched(kind, d):
|
|
1988
|
+
pass # another model running alongside: its rows still show in Recent results
|
|
1989
|
+
elif kind == "request_started":
|
|
1941
1990
|
self.flush_streams()
|
|
1942
1991
|
self.query_one("#reasoning", StreamView).clear()
|
|
1943
1992
|
self.query_one("#answer", StreamView).clear()
|
|
@@ -1948,7 +1997,8 @@ class RunScreen(Screen):
|
|
|
1948
1997
|
f"{' [magenta]🖼 image[/magenta]' if d['has_image'] else ''}\n"
|
|
1949
1998
|
f"{diff_badge(d.get('difficulty', 'unrated'))} {d['test']}")
|
|
1950
1999
|
elif kind == "request_done":
|
|
1951
|
-
self.
|
|
2000
|
+
if self.watched(kind, d):
|
|
2001
|
+
self.flush_streams()
|
|
1952
2002
|
job, r = d["job"], d["record"]
|
|
1953
2003
|
mark = "[green]✓[/green]" if r["pass"] else "[red]✗[/red]"
|
|
1954
2004
|
recent = self.query_one("#recent", DataTable)
|
|
@@ -1974,7 +2024,8 @@ class RunScreen(Screen):
|
|
|
1974
2024
|
recent.move_cursor(row=recent.row_count - 1)
|
|
1975
2025
|
if job:
|
|
1976
2026
|
self.update_job(job)
|
|
1977
|
-
self.
|
|
2027
|
+
if self.watched(kind, d):
|
|
2028
|
+
self.req_started = None
|
|
1978
2029
|
self.update_overall()
|
|
1979
2030
|
elif kind in ("job_started", "job_update", "job_done"):
|
|
1980
2031
|
job = d["job"]
|
|
@@ -2008,15 +2059,18 @@ class RunScreen(Screen):
|
|
|
2008
2059
|
self.current = "The other window finished; starting…"
|
|
2009
2060
|
self.event("the machine is free; starting")
|
|
2010
2061
|
elif kind == "model_loading":
|
|
2011
|
-
self.current = f"Loading [b]{d['label']}[/b]…"
|
|
2012
2062
|
self.event(f"{d['label']}: starting server")
|
|
2013
2063
|
if d.get("log"):
|
|
2014
2064
|
self.server_logs[d["label"]] = d["log"]
|
|
2015
|
-
self.query_one("#serverlog", Log).write_line(f"$ {' '.join(d['command'])}")
|
|
2016
|
-
self.
|
|
2017
|
-
|
|
2065
|
+
self.query_one("#serverlog", Log).write_line(self.log_prefix(d) + f"$ {' '.join(d['command'])}")
|
|
2066
|
+
if self.watched(kind, d):
|
|
2067
|
+
self.current = f"Loading [b]{d['label']}[/b]…"
|
|
2068
|
+
self.query_one("#reasoning", StreamView).clear()
|
|
2069
|
+
self.query_one("#answer", StreamView).clear()
|
|
2070
|
+
elif kind == "model_waiting":
|
|
2071
|
+
self.event(f"[yellow]{d['message']}[/yellow]")
|
|
2018
2072
|
elif kind == "server_log":
|
|
2019
|
-
self.query_one("#serverlog", Log).write_line(d["line"])
|
|
2073
|
+
self.query_one("#serverlog", Log).write_line(self.log_prefix(d) + d["line"])
|
|
2020
2074
|
elif kind == "server_stall":
|
|
2021
2075
|
self.event(f"[bold red]STALL[/bold red] {d['message']}")
|
|
2022
2076
|
self.notify(d["message"], severity="error", timeout=30)
|
|
@@ -2029,7 +2083,8 @@ class RunScreen(Screen):
|
|
|
2029
2083
|
elif kind == "model_ready":
|
|
2030
2084
|
loaded = f" in {d['load_s']:.0f}s" if d.get("load_s") else ""
|
|
2031
2085
|
self.event(f"{d['label']}: ready as {d['ids']}{loaded}")
|
|
2032
|
-
self.
|
|
2086
|
+
if self.watched(kind, d):
|
|
2087
|
+
self.current = f"[b]{d['label']}[/b] ready{loaded}, starting evals…"
|
|
2033
2088
|
elif kind == "resource":
|
|
2034
2089
|
self.mem[d["label"]] = d["rss_mb"]
|
|
2035
2090
|
elif kind == "server_retry":
|
|
@@ -2082,6 +2137,9 @@ class RunScreen(Screen):
|
|
|
2082
2137
|
self.app.session_finished(self)
|
|
2083
2138
|
self.refresh_current()
|
|
2084
2139
|
|
|
2140
|
+
def log_prefix(self, d):
|
|
2141
|
+
return f"[{d['label']}] " if self.side_by_side and d.get("label") else ""
|
|
2142
|
+
|
|
2085
2143
|
def update_job(self, j):
|
|
2086
2144
|
q = self.query_one("#queue", DataTable)
|
|
2087
2145
|
style = STATUS_STYLE.get(j.status, "")
|
|
@@ -2115,7 +2173,9 @@ class RunScreen(Screen):
|
|
|
2115
2173
|
speed += f" · {sorted(tps)[len(tps) // 2]:.0f} tok/s"
|
|
2116
2174
|
if ttfts:
|
|
2117
2175
|
speed += f" · TTFT {sorted(ttfts)[len(ttfts) // 2]:.1f}s"
|
|
2118
|
-
|
|
2176
|
+
active = self.active()
|
|
2177
|
+
mem = (sum(self.mem.get(l, 0) for l in active) if self.side_by_side and active # models side by side add up
|
|
2178
|
+
else max(self.mem.values()) if self.mem else 0)
|
|
2119
2179
|
if mem:
|
|
2120
2180
|
speed += f" · mem {mem / 1024:.1f} GB" if mem >= 1024 else f" · mem {mem:.0f} MB"
|
|
2121
2181
|
self.query_one("#overall", Static).update(
|
|
@@ -2132,6 +2192,12 @@ class RunScreen(Screen):
|
|
|
2132
2192
|
if self.req_first and now - self.req_first > 1:
|
|
2133
2193
|
rate = f" · ~{self.req_tokens / (now - self.req_first):.0f} tok/s"
|
|
2134
2194
|
extra = f" [dim]{fmt_secs(now - self.req_started)} · {ttft}~{self.req_tokens} tokens{rate}[/dim]"
|
|
2195
|
+
others = [l for l in self.active() if l != self.watch] if self.running and self.side_by_side else []
|
|
2196
|
+
if others:
|
|
2197
|
+
def where(label):
|
|
2198
|
+
j = next((j for j in self.jobs if j.label == label and j.status in ("loading", "running")), None)
|
|
2199
|
+
return f"{label} ({j.pack.label} {j.done}/{j.total})" if j and j.status == "running" else f"{label} (loading)"
|
|
2200
|
+
extra += f"\n[dim]Also running: {' · '.join(map(where, others))} · v streams the next one[/dim]"
|
|
2135
2201
|
self.query_one("#current", Static).update(self.current + extra)
|
|
2136
2202
|
|
|
2137
2203
|
def tick(self):
|
|
@@ -2144,8 +2210,28 @@ class RunScreen(Screen):
|
|
|
2144
2210
|
# -- actions
|
|
2145
2211
|
def action_skip_model(self):
|
|
2146
2212
|
if self.running:
|
|
2147
|
-
self.
|
|
2148
|
-
|
|
2213
|
+
if self.side_by_side and self.watch:
|
|
2214
|
+
self.app.engine.skip_model(self.watch)
|
|
2215
|
+
self.notify(f"Skipping {self.watch}; the others keep going…")
|
|
2216
|
+
else:
|
|
2217
|
+
self.app.engine.skip_model()
|
|
2218
|
+
self.notify("Skipping the current model…")
|
|
2219
|
+
|
|
2220
|
+
def action_watch_next(self):
|
|
2221
|
+
"""Stream the next model running alongside (from its next answer)."""
|
|
2222
|
+
active = self.active()
|
|
2223
|
+
if len(active) < 2:
|
|
2224
|
+
self.notify("Only one model is running right now.")
|
|
2225
|
+
return
|
|
2226
|
+
self.watch = active[(active.index(self.watch) + 1) % len(active)] if self.watch in active else active[0]
|
|
2227
|
+
with self.buf_lock:
|
|
2228
|
+
for v in self.buf.values():
|
|
2229
|
+
v.clear()
|
|
2230
|
+
self.query_one("#reasoning", StreamView).clear()
|
|
2231
|
+
self.query_one("#answer", StreamView).clear()
|
|
2232
|
+
self.req_started = None
|
|
2233
|
+
self.current = f"Streaming [b]{self.watch}[/b] from its next answer…"
|
|
2234
|
+
self.refresh_current()
|
|
2149
2235
|
|
|
2150
2236
|
def action_cancel_all(self):
|
|
2151
2237
|
if not self.running:
|
|
@@ -2548,6 +2634,11 @@ class ResultsScreen(Screen):
|
|
|
2548
2634
|
f"failed; both passed {h['both']}, both failed {h['neither']}. Fewer tokens: {a} on "
|
|
2549
2635
|
f"{h['fewer_a']}, {b} on {h['fewer_b']}" + (f" (median {a}/{b} ratio {h['ratio']:.2f}×)"
|
|
2550
2636
|
if h["ratio"] else "") + ".")
|
|
2637
|
+
shared_speed = sorted({c_m for t in shared for c_m, c in t["cells"].items() if c_m in models
|
|
2638
|
+
and any(r.get("alongside") for r in c["rows"])})
|
|
2639
|
+
if shared_speed:
|
|
2640
|
+
line += (f"\n[yellow]Some answers of {', '.join(shared_speed)} ran alongside other models, which "
|
|
2641
|
+
"shared the machine's speed: compare their times with care.[/yellow]")
|
|
2551
2642
|
title.update(line + " [dim]Fewest tokens / Fastest = questions won; ties count for both. Total tokens / "
|
|
2552
2643
|
"Total time add up each question's median. Avg tok/s = all tokens over all the time they took "
|
|
2553
2644
|
"(cached prompts left out).[/dim]")
|
|
@@ -2676,6 +2767,7 @@ class EvalsApp(App):
|
|
|
2676
2767
|
#options { height: 3; padding: 0 1; align-vertical: middle; }
|
|
2677
2768
|
#options Label { padding: 1 1 0 0; }
|
|
2678
2769
|
#options Input { width: 12; }
|
|
2770
|
+
#options #parallel { width: 8; }
|
|
2679
2771
|
#estimate { padding: 0 2; height: auto; min-height: 2; }
|
|
2680
2772
|
#buttons { height: 3; padding: 0 1; }
|
|
2681
2773
|
#buttons Button { margin-right: 2; }
|
|
@@ -2965,10 +3057,10 @@ class EvalsApp(App):
|
|
|
2965
3057
|
self.worker = threading.Thread(target=work, name="tune")
|
|
2966
3058
|
self.worker.start()
|
|
2967
3059
|
|
|
2968
|
-
def start_run(self, jobs, on_event):
|
|
3060
|
+
def start_run(self, jobs, on_event, parallel=None):
|
|
2969
3061
|
self.engine.on_event = on_event
|
|
2970
3062
|
# A plain (non-daemon) thread, so quitting can wait for servers to be stopped.
|
|
2971
|
-
self.worker = threading.Thread(target=self.engine.run, args=(jobs,), name="engine")
|
|
3063
|
+
self.worker = threading.Thread(target=self.engine.run, args=(jobs, parallel), name="engine")
|
|
2972
3064
|
self.worker.start()
|
|
2973
3065
|
|
|
2974
3066
|
def on_unmount(self):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tuieval
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev28
|
|
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
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
tuieval/__init__.py,sha256=myVXVX7htbwezW6k3x_FuKgn1RSZ2WcTaDEMByGn6Wg,312
|
|
2
2
|
tuieval/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
-
tuieval/_version.py,sha256=
|
|
3
|
+
tuieval/_version.py,sha256=mCD5WpGBJi5o6Jnxl_I1TiLi_4xG9de502jCBwE897Y,535
|
|
4
4
|
tuieval/builtin.toml,sha256=Kxw22RZCJhURUyu5Wz8tj5Y1nM85A8hFVRbA4ocoPUg,4293
|
|
5
5
|
tuieval/cli.py,sha256=7joEA07W2we-kcg4VXxfCSXazj7dWBCKlQlSXpXQfTc,4390
|
|
6
6
|
tuieval/client.py,sha256=Vui7ucVv_GiKObGoVS_EWSZFUKPou9PWmutqMTrahXk,9313
|
|
7
|
-
tuieval/compare.py,sha256=
|
|
7
|
+
tuieval/compare.py,sha256=SHN_o_nlppFcHMBqBZRITIkeQDPaR_JxhUZItQLMBwo,26270
|
|
8
8
|
tuieval/doctor.py,sha256=y1C_ggjd8oikXTrovgTBf26wjThP8EoVkfTVeO_n5dQ,6070
|
|
9
|
-
tuieval/engine.py,sha256=
|
|
9
|
+
tuieval/engine.py,sha256=k9DtWDJLfTXSPMaIR4qw-0SadTD0ts5hb3Uv38oTY4g,111278
|
|
10
10
|
tuieval/export.py,sha256=dbHQgL_ubWtIPM1sLzAew_gETnrDJyd0zgqVA5vkpXM,9657
|
|
11
11
|
tuieval/machines.py,sha256=ImEihNj8QlBRdCHLED4QIdjJUvjVtZgo6DiVU5h6Zzs,13415
|
|
12
12
|
tuieval/onboard.py,sha256=IYETVDWCCo9vF0Dtj4_rr9VanYeCky8PwP3-2g5A_l0,6215
|
|
13
13
|
tuieval/packs.py,sha256=tiLmxsrX_M5EUMuR4Yvb7pLBLEI15wamHSjflD6wjoY,11299
|
|
14
14
|
tuieval/profiles.py,sha256=soeLxjdB-l-OxSY8jZ4yjwfdSnsBV-QICcwKxRJS7nQ,3663
|
|
15
15
|
tuieval/remove.py,sha256=Vwh43nm9lVb9U9aN-h6CMYbkTwdR3UWfZObwweF72n0,5157
|
|
16
|
-
tuieval/run_evals.py,sha256=
|
|
16
|
+
tuieval/run_evals.py,sha256=ZGx4XcZ15qD3vIg15xCfdX4SxTDHXBp_GYoiGexchdw,43668
|
|
17
17
|
tuieval/scaffold.py,sha256=YBOqniJpf_GtKKAFF3_OHxxMGbmTWdFjcClGcOnrrKE,12130
|
|
18
18
|
tuieval/selftest.py,sha256=nExkrOidgymlmvkxImEBfrQG5iTUN5WuRMIKtqusCCs,9835
|
|
19
|
-
tuieval/tui.py,sha256=
|
|
19
|
+
tuieval/tui.py,sha256=pMH15LkHEeIdr8GlB55Znxdy4OuTFDtNTkGrp7ItlvI,156704
|
|
20
20
|
tuieval/tune.py,sha256=jXwYuSjfdqbLCE3WonP2xaUHwk_FnhI67twB1BaHggI,38992
|
|
21
21
|
tuieval/verdict.py,sha256=Muo1a05mCv781MlEJsXwDsGaKbRi0JS4Lv8HdvK_Aco,19054
|
|
22
22
|
tuieval/watch_proxy.py,sha256=z916NRhEur04a9RnLm6ZpIEY1SnnUGPiHYbmlD9ktQ0,11035
|
|
@@ -24,7 +24,7 @@ tuieval/workspace.py,sha256=V_HhkaN1odr0OpjNsPYradRWLpzDldM7SwpvxohBL4U,895
|
|
|
24
24
|
tuieval/yamlout.py,sha256=r1IokjLqW-Ztpg1fSn33ZW3VK00v1VvxymmU5Qy7GEw,606
|
|
25
25
|
tuieval/graders/__init__.py,sha256=_YCFY5euaInIQdX4RoQGS3kMuiK6q5J7HF2e4i0ou3Q,5940
|
|
26
26
|
tuieval/graders/answer.py,sha256=pTETs2LzOWPhtuuowMVXpLFwdnNjMw-7sELqrFV6ocQ,2125
|
|
27
|
-
tuieval/graders/code.py,sha256=
|
|
27
|
+
tuieval/graders/code.py,sha256=Q97J0pRhg4xPlhLJoF1theV3vAECJB8jkM6JQMZnIes,3796
|
|
28
28
|
tuieval/graders/rag.py,sha256=j_gEjmaXmY_hgKu7-7pVQpQ8Cwb49gadnwFurih-1Hw,1447
|
|
29
29
|
tuieval/graders/reply.py,sha256=BdkL7EWSSMoyjwCOFa97TTQMR1QyrpMH-ntcgjzDPEg,2199
|
|
30
30
|
tuieval/graders/tool_call.py,sha256=_QXURtOk4ZKBnxXVMzFL1c9jWLWksRlLZ4VqOCL0t90,3578
|
|
@@ -45,8 +45,8 @@ tuieval/templates/packs/tool_call/pack.toml,sha256=n4bx0lGJHSmW41uVqhfIXJ2BWZpmT
|
|
|
45
45
|
tuieval/templates/packs/tool_call/system.txt,sha256=mKxu-lB-0YjLHXdgeFJa8x2C180OvcNz5cHv2N5Bg3U,254
|
|
46
46
|
tuieval/templates/packs/tool_call/tests.yaml,sha256=Nv7c4syrzJ9cU8EBKSK_1ZSFx9V02aCjW4-2TBCPT7s,1859
|
|
47
47
|
tuieval/templates/packs/tool_call/tools.yaml,sha256=HgxCkh0On2VwbHSsjAGM8tNCK7ZtKLGrs7lMCn3Fprs,1048
|
|
48
|
-
tuieval-0.2.0.
|
|
49
|
-
tuieval-0.2.0.
|
|
50
|
-
tuieval-0.2.0.
|
|
51
|
-
tuieval-0.2.0.
|
|
52
|
-
tuieval-0.2.0.
|
|
48
|
+
tuieval-0.2.0.dev28.dist-info/METADATA,sha256=rVlRwOpwJXIudx4BD9v-tcTnH4RMb4SqOhtnLut5Xos,13227
|
|
49
|
+
tuieval-0.2.0.dev28.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
50
|
+
tuieval-0.2.0.dev28.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
|
|
51
|
+
tuieval-0.2.0.dev28.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
|
|
52
|
+
tuieval-0.2.0.dev28.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|