tuieval 0.2.0.dev25__py3-none-any.whl → 0.2.0.dev32__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 +278 -51
- tuieval/run_evals.py +27 -5
- tuieval/tui.py +171 -26
- {tuieval-0.2.0.dev25.dist-info → tuieval-0.2.0.dev32.dist-info}/METADATA +2 -2
- {tuieval-0.2.0.dev25.dist-info → tuieval-0.2.0.dev32.dist-info}/RECORD +10 -10
- {tuieval-0.2.0.dev25.dist-info → tuieval-0.2.0.dev32.dist-info}/WHEEL +0 -0
- {tuieval-0.2.0.dev25.dist-info → tuieval-0.2.0.dev32.dist-info}/entry_points.txt +0 -0
- {tuieval-0.2.0.dev25.dist-info → tuieval-0.2.0.dev32.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.dev32'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev32')
|
|
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,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
|
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,8 +290,11 @@ 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
|
-
s start (
|
|
297
|
+
s start (with a run going: add to it, or queue it) r results
|
|
295
298
|
a add a model (a GGUF, a model id, openrouter:<id>) m scan model folders for new GGUFs
|
|
296
299
|
e pick which tests of the highlighted pack run p save or load a selection (preset)
|
|
297
300
|
t tune the ticked models' speed flags here (after x hide or unhide a model
|
|
@@ -307,8 +310,9 @@ with the model's reasoning and answer as they stream, then recent results with t
|
|
|
307
310
|
|
|
308
311
|
[b]Keys[/b]
|
|
309
312
|
k skip the current model c cancel everything (finished answers are kept; it resumes next time)
|
|
310
|
-
f pause or resume auto-scroll r results n
|
|
311
|
-
w runs and queue esc back to Setup (the run keeps going)
|
|
313
|
+
f pause or resume auto-scroll r results n more evals: add them to this run or queue them
|
|
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,13 +867,19 @@ 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):
|
|
858
878
|
"""Says what Start will do when something is already running, here or in another window."""
|
|
859
879
|
app = self.app
|
|
860
880
|
if app.is_busy():
|
|
861
|
-
|
|
881
|
+
add = "add this to the run going or " if isinstance(app.active_session, RunScreen) else ""
|
|
882
|
+
return (f"\n[cyan]{app.busy_text()}: Start lets you {add}queue it as #{len(app.queue) + 1} "
|
|
862
883
|
"(w shows the queue).[/cyan]")
|
|
863
884
|
holder = app.engine.machine_holder()
|
|
864
885
|
if holder:
|
|
@@ -879,6 +900,8 @@ class SetupScreen(Screen):
|
|
|
879
900
|
if name in e.packs:
|
|
880
901
|
packs.select(name)
|
|
881
902
|
self.query_one("#repeat", Input).value = str(sel["repeat"]) if sel.get("repeat") else ""
|
|
903
|
+
if "parallel" in sel:
|
|
904
|
+
self.query_one("#parallel", Input).value = str(sel["parallel"]) if sel["parallel"] else ""
|
|
882
905
|
tier = sel.get("tier") or ("smoke" if sel.get("smoke") else None)
|
|
883
906
|
if tier in engine.TIERS:
|
|
884
907
|
self.query_one(f"#tier-{tier}", RadioButton).value = True
|
|
@@ -917,7 +940,8 @@ class SetupScreen(Screen):
|
|
|
917
940
|
self.notify("Select at least one eval pack and one model.", severity="warning")
|
|
918
941
|
return
|
|
919
942
|
picks = self.current_picks(suites)
|
|
920
|
-
sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks
|
|
943
|
+
sel = {"models": labels, "packs": suites, "repeat": repeat, "tier": tier, "force": force, "tests": picks,
|
|
944
|
+
"parallel": self.parallel()}
|
|
921
945
|
jobs = self.app.engine.plan(labels, suites, repeat, tier, force, picks)
|
|
922
946
|
rerun = False
|
|
923
947
|
if not any(j.status == "waiting" for j in jobs):
|
|
@@ -1441,8 +1465,8 @@ class QueuedRun:
|
|
|
1441
1465
|
def line(self):
|
|
1442
1466
|
return f"{self.title} [dim]~{fmt_secs(self.est)} · queued {time.strftime('%H:%M', time.localtime(self.queued))}[/dim]"
|
|
1443
1467
|
|
|
1444
|
-
def
|
|
1445
|
-
"""(
|
|
1468
|
+
def plan(self, eng):
|
|
1469
|
+
"""(jobs, None), or (None, why it can't run) now."""
|
|
1446
1470
|
sel = self.sel
|
|
1447
1471
|
jobs = eng.plan(sel["models"], sel["packs"], sel["repeat"], sel["tier"], sel["force"] or self.rerun,
|
|
1448
1472
|
self.tests(eng))
|
|
@@ -1453,7 +1477,12 @@ class QueuedRun:
|
|
|
1453
1477
|
if new:
|
|
1454
1478
|
return None, (f"it would now start over {', '.join(new)}, which wasn't confirmed when it was queued. "
|
|
1455
1479
|
"Start it again from Setup to see why")
|
|
1456
|
-
return
|
|
1480
|
+
return jobs, None
|
|
1481
|
+
|
|
1482
|
+
def make(self, eng):
|
|
1483
|
+
"""(RunScreen, None), or (None, why it can't start) when its turn comes."""
|
|
1484
|
+
jobs, problem = self.plan(eng)
|
|
1485
|
+
return (RunScreen(jobs, self.sel.get("parallel")), None) if jobs else (None, problem)
|
|
1457
1486
|
|
|
1458
1487
|
|
|
1459
1488
|
class QueuedTune:
|
|
@@ -1783,7 +1812,8 @@ class RunScreen(Screen):
|
|
|
1783
1812
|
Binding("k", "skip_model", "Skip model", priority=True),
|
|
1784
1813
|
Binding("c", "cancel_all", "Cancel all", priority=True),
|
|
1785
1814
|
Binding("f", "toggle_follow", "Pause scroll", priority=True),
|
|
1786
|
-
Binding("
|
|
1815
|
+
Binding("v", "watch_next", "Next model", priority=True),
|
|
1816
|
+
Binding("n", "new_run", "More evals", priority=True),
|
|
1787
1817
|
Binding("r", "results", "Results", priority=True),
|
|
1788
1818
|
Binding("w", "runs", "Runs", priority=True),
|
|
1789
1819
|
Binding("escape", "back", "Setup", priority=True),
|
|
@@ -1791,11 +1821,13 @@ class RunScreen(Screen):
|
|
|
1791
1821
|
]
|
|
1792
1822
|
kind = "Run"
|
|
1793
1823
|
LIVE_ONLY = {"skip_model", "cancel_all", "toggle_follow"}
|
|
1794
|
-
DONE_ONLY =
|
|
1824
|
+
DONE_ONLY = set()
|
|
1795
1825
|
|
|
1796
|
-
def __init__(self, jobs):
|
|
1826
|
+
def __init__(self, jobs, parallel=None):
|
|
1797
1827
|
super().__init__()
|
|
1798
1828
|
self.jobs = jobs
|
|
1829
|
+
self.parallel = parallel # models at a time (None: this machine's parallel_models)
|
|
1830
|
+
self.watch = None # the model whose answers stream (several run at a time)
|
|
1799
1831
|
self.running = True
|
|
1800
1832
|
self.follow = True
|
|
1801
1833
|
self.buf = {"reasoning": [], "answer": []}
|
|
@@ -1839,12 +1871,24 @@ class RunScreen(Screen):
|
|
|
1839
1871
|
self.notify("\n".join(lines), title=title, severity="error", timeout=30)
|
|
1840
1872
|
|
|
1841
1873
|
def check_action(self, action, parameters):
|
|
1874
|
+
if action == "watch_next":
|
|
1875
|
+
return self.running and self.side_by_side
|
|
1842
1876
|
if action in self.LIVE_ONLY:
|
|
1843
1877
|
return self.running
|
|
1844
1878
|
if action in self.DONE_ONLY:
|
|
1845
1879
|
return not self.running
|
|
1846
1880
|
return True
|
|
1847
1881
|
|
|
1882
|
+
@property
|
|
1883
|
+
def side_by_side(self):
|
|
1884
|
+
"""Several models run at a time in this run."""
|
|
1885
|
+
return min(self.parallel or self.app.engine.parallel_models(),
|
|
1886
|
+
len({j.label for j in self.jobs if j.status != "skipped"})) > 1
|
|
1887
|
+
|
|
1888
|
+
def active(self):
|
|
1889
|
+
"""Models being loaded or run now, in queue order."""
|
|
1890
|
+
return list(dict.fromkeys(j.label for j in self.jobs if j.status in ("loading", "running")))
|
|
1891
|
+
|
|
1848
1892
|
def counts(self):
|
|
1849
1893
|
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
1894
|
|
|
@@ -1908,14 +1952,18 @@ class RunScreen(Screen):
|
|
|
1908
1952
|
self.query_one("#bar", ProgressBar).update(total=max(self.total, 1), progress=0)
|
|
1909
1953
|
self.set_interval(0.1, self.flush_streams)
|
|
1910
1954
|
self.set_interval(1.0, self.tick)
|
|
1911
|
-
self.app.start_run(self.jobs, self.on_engine_event)
|
|
1955
|
+
self.app.start_run(self.jobs, self.on_engine_event, self.parallel)
|
|
1912
1956
|
self.tick()
|
|
1913
1957
|
|
|
1914
1958
|
# -- events from the engine thread
|
|
1915
1959
|
def on_engine_event(self, kind, **d):
|
|
1916
1960
|
if self.app.closing:
|
|
1917
1961
|
return # the app is shutting down; the UI thread may be waiting on the engine
|
|
1962
|
+
if kind in ("request_started", "model_loading") and self.watch not in self.active():
|
|
1963
|
+
self.watch = d["job"].label if "job" in d else d["label"] # follow the next model streaming
|
|
1918
1964
|
if kind == "delta": # very frequent: buffer, flushed every 100 ms on the UI thread
|
|
1965
|
+
if d.get("label", self.watch) != self.watch:
|
|
1966
|
+
return # another model running alongside: v switches to it
|
|
1919
1967
|
with self.buf_lock:
|
|
1920
1968
|
self.buf[d["stream"]].append(d["text"])
|
|
1921
1969
|
return
|
|
@@ -1936,8 +1984,15 @@ class RunScreen(Screen):
|
|
|
1936
1984
|
def event(self, text):
|
|
1937
1985
|
self.query_one("#events", Log).write_line(time.strftime("%H:%M:%S ") + text)
|
|
1938
1986
|
|
|
1987
|
+
def watched(self, kind, d):
|
|
1988
|
+
"""Whether an event belongs to the streaming model (always, with one model at a time)."""
|
|
1989
|
+
label = d["job"].label if "job" in d else d.get("label")
|
|
1990
|
+
return label is None or label == self.watch or not self.side_by_side
|
|
1991
|
+
|
|
1939
1992
|
def handle(self, kind, d):
|
|
1940
|
-
if kind == "request_started":
|
|
1993
|
+
if kind == "request_started" and not self.watched(kind, d):
|
|
1994
|
+
pass # another model running alongside: its rows still show in Recent results
|
|
1995
|
+
elif kind == "request_started":
|
|
1941
1996
|
self.flush_streams()
|
|
1942
1997
|
self.query_one("#reasoning", StreamView).clear()
|
|
1943
1998
|
self.query_one("#answer", StreamView).clear()
|
|
@@ -1948,7 +2003,8 @@ class RunScreen(Screen):
|
|
|
1948
2003
|
f"{' [magenta]🖼 image[/magenta]' if d['has_image'] else ''}\n"
|
|
1949
2004
|
f"{diff_badge(d.get('difficulty', 'unrated'))} {d['test']}")
|
|
1950
2005
|
elif kind == "request_done":
|
|
1951
|
-
self.
|
|
2006
|
+
if self.watched(kind, d):
|
|
2007
|
+
self.flush_streams()
|
|
1952
2008
|
job, r = d["job"], d["record"]
|
|
1953
2009
|
mark = "[green]✓[/green]" if r["pass"] else "[red]✗[/red]"
|
|
1954
2010
|
recent = self.query_one("#recent", DataTable)
|
|
@@ -1974,7 +2030,18 @@ class RunScreen(Screen):
|
|
|
1974
2030
|
recent.move_cursor(row=recent.row_count - 1)
|
|
1975
2031
|
if job:
|
|
1976
2032
|
self.update_job(job)
|
|
1977
|
-
self.
|
|
2033
|
+
if self.watched(kind, d):
|
|
2034
|
+
self.req_started = None
|
|
2035
|
+
self.update_overall()
|
|
2036
|
+
elif kind == "jobs_added":
|
|
2037
|
+
q = self.query_one("#queue", DataTable)
|
|
2038
|
+
for j in d["jobs"]:
|
|
2039
|
+
if j.key not in q.rows:
|
|
2040
|
+
q.add_row(j.label, j.pack.label, "", "", "", "", "", "", key=j.key)
|
|
2041
|
+
self.update_job(j)
|
|
2042
|
+
self.total += j.total
|
|
2043
|
+
self.query_one("#bar", ProgressBar).update(total=max(self.total, 1))
|
|
2044
|
+
self.event(f"added to this run: {', '.join(j.key for j in d['jobs'])}")
|
|
1978
2045
|
self.update_overall()
|
|
1979
2046
|
elif kind in ("job_started", "job_update", "job_done"):
|
|
1980
2047
|
job = d["job"]
|
|
@@ -2008,15 +2075,18 @@ class RunScreen(Screen):
|
|
|
2008
2075
|
self.current = "The other window finished; starting…"
|
|
2009
2076
|
self.event("the machine is free; starting")
|
|
2010
2077
|
elif kind == "model_loading":
|
|
2011
|
-
self.current = f"Loading [b]{d['label']}[/b]…"
|
|
2012
2078
|
self.event(f"{d['label']}: starting server")
|
|
2013
2079
|
if d.get("log"):
|
|
2014
2080
|
self.server_logs[d["label"]] = d["log"]
|
|
2015
|
-
self.query_one("#serverlog", Log).write_line(f"$ {' '.join(d['command'])}")
|
|
2016
|
-
self.
|
|
2017
|
-
|
|
2081
|
+
self.query_one("#serverlog", Log).write_line(self.log_prefix(d) + f"$ {' '.join(d['command'])}")
|
|
2082
|
+
if self.watched(kind, d):
|
|
2083
|
+
self.current = f"Loading [b]{d['label']}[/b]…"
|
|
2084
|
+
self.query_one("#reasoning", StreamView).clear()
|
|
2085
|
+
self.query_one("#answer", StreamView).clear()
|
|
2086
|
+
elif kind == "model_waiting":
|
|
2087
|
+
self.event(f"[yellow]{d['message']}[/yellow]")
|
|
2018
2088
|
elif kind == "server_log":
|
|
2019
|
-
self.query_one("#serverlog", Log).write_line(d["line"])
|
|
2089
|
+
self.query_one("#serverlog", Log).write_line(self.log_prefix(d) + d["line"])
|
|
2020
2090
|
elif kind == "server_stall":
|
|
2021
2091
|
self.event(f"[bold red]STALL[/bold red] {d['message']}")
|
|
2022
2092
|
self.notify(d["message"], severity="error", timeout=30)
|
|
@@ -2029,7 +2099,8 @@ class RunScreen(Screen):
|
|
|
2029
2099
|
elif kind == "model_ready":
|
|
2030
2100
|
loaded = f" in {d['load_s']:.0f}s" if d.get("load_s") else ""
|
|
2031
2101
|
self.event(f"{d['label']}: ready as {d['ids']}{loaded}")
|
|
2032
|
-
self.
|
|
2102
|
+
if self.watched(kind, d):
|
|
2103
|
+
self.current = f"[b]{d['label']}[/b] ready{loaded}, starting evals…"
|
|
2033
2104
|
elif kind == "resource":
|
|
2034
2105
|
self.mem[d["label"]] = d["rss_mb"]
|
|
2035
2106
|
elif kind == "server_retry":
|
|
@@ -2082,6 +2153,9 @@ class RunScreen(Screen):
|
|
|
2082
2153
|
self.app.session_finished(self)
|
|
2083
2154
|
self.refresh_current()
|
|
2084
2155
|
|
|
2156
|
+
def log_prefix(self, d):
|
|
2157
|
+
return f"[{d['label']}] " if self.side_by_side and d.get("label") else ""
|
|
2158
|
+
|
|
2085
2159
|
def update_job(self, j):
|
|
2086
2160
|
q = self.query_one("#queue", DataTable)
|
|
2087
2161
|
style = STATUS_STYLE.get(j.status, "")
|
|
@@ -2115,7 +2189,9 @@ class RunScreen(Screen):
|
|
|
2115
2189
|
speed += f" · {sorted(tps)[len(tps) // 2]:.0f} tok/s"
|
|
2116
2190
|
if ttfts:
|
|
2117
2191
|
speed += f" · TTFT {sorted(ttfts)[len(ttfts) // 2]:.1f}s"
|
|
2118
|
-
|
|
2192
|
+
active = self.active()
|
|
2193
|
+
mem = (sum(self.mem.get(l, 0) for l in active) if self.side_by_side and active # models side by side add up
|
|
2194
|
+
else max(self.mem.values()) if self.mem else 0)
|
|
2119
2195
|
if mem:
|
|
2120
2196
|
speed += f" · mem {mem / 1024:.1f} GB" if mem >= 1024 else f" · mem {mem:.0f} MB"
|
|
2121
2197
|
self.query_one("#overall", Static).update(
|
|
@@ -2132,6 +2208,12 @@ class RunScreen(Screen):
|
|
|
2132
2208
|
if self.req_first and now - self.req_first > 1:
|
|
2133
2209
|
rate = f" · ~{self.req_tokens / (now - self.req_first):.0f} tok/s"
|
|
2134
2210
|
extra = f" [dim]{fmt_secs(now - self.req_started)} · {ttft}~{self.req_tokens} tokens{rate}[/dim]"
|
|
2211
|
+
others = [l for l in self.active() if l != self.watch] if self.running and self.side_by_side else []
|
|
2212
|
+
if others:
|
|
2213
|
+
def where(label):
|
|
2214
|
+
j = next((j for j in self.jobs if j.label == label and j.status in ("loading", "running")), None)
|
|
2215
|
+
return f"{label} ({j.pack.label} {j.done}/{j.total})" if j and j.status == "running" else f"{label} (loading)"
|
|
2216
|
+
extra += f"\n[dim]Also running: {' · '.join(map(where, others))} · v streams the next one[/dim]"
|
|
2135
2217
|
self.query_one("#current", Static).update(self.current + extra)
|
|
2136
2218
|
|
|
2137
2219
|
def tick(self):
|
|
@@ -2144,8 +2226,28 @@ class RunScreen(Screen):
|
|
|
2144
2226
|
# -- actions
|
|
2145
2227
|
def action_skip_model(self):
|
|
2146
2228
|
if self.running:
|
|
2147
|
-
self.
|
|
2148
|
-
|
|
2229
|
+
if self.side_by_side and self.watch:
|
|
2230
|
+
self.app.engine.skip_model(self.watch)
|
|
2231
|
+
self.notify(f"Skipping {self.watch}; the others keep going…")
|
|
2232
|
+
else:
|
|
2233
|
+
self.app.engine.skip_model()
|
|
2234
|
+
self.notify("Skipping the current model…")
|
|
2235
|
+
|
|
2236
|
+
def action_watch_next(self):
|
|
2237
|
+
"""Stream the next model running alongside (from its next answer)."""
|
|
2238
|
+
active = self.active()
|
|
2239
|
+
if len(active) < 2:
|
|
2240
|
+
self.notify("Only one model is running right now.")
|
|
2241
|
+
return
|
|
2242
|
+
self.watch = active[(active.index(self.watch) + 1) % len(active)] if self.watch in active else active[0]
|
|
2243
|
+
with self.buf_lock:
|
|
2244
|
+
for v in self.buf.values():
|
|
2245
|
+
v.clear()
|
|
2246
|
+
self.query_one("#reasoning", StreamView).clear()
|
|
2247
|
+
self.query_one("#answer", StreamView).clear()
|
|
2248
|
+
self.req_started = None
|
|
2249
|
+
self.current = f"Streaming [b]{self.watch}[/b] from its next answer…"
|
|
2250
|
+
self.refresh_current()
|
|
2149
2251
|
|
|
2150
2252
|
def action_cancel_all(self):
|
|
2151
2253
|
if not self.running:
|
|
@@ -2176,6 +2278,9 @@ class RunScreen(Screen):
|
|
|
2176
2278
|
|
|
2177
2279
|
@on(Button.Pressed, "#done-new")
|
|
2178
2280
|
def action_new_run(self):
|
|
2281
|
+
if self.running:
|
|
2282
|
+
self.notify("Pick models and packs, then Start: add them to this run or queue them after it.",
|
|
2283
|
+
timeout=8)
|
|
2179
2284
|
self.app.back_to_setup() # Setup still holds the last selection
|
|
2180
2285
|
self.app.screen.query_one("#models").focus()
|
|
2181
2286
|
|
|
@@ -2548,6 +2653,11 @@ class ResultsScreen(Screen):
|
|
|
2548
2653
|
f"failed; both passed {h['both']}, both failed {h['neither']}. Fewer tokens: {a} on "
|
|
2549
2654
|
f"{h['fewer_a']}, {b} on {h['fewer_b']}" + (f" (median {a}/{b} ratio {h['ratio']:.2f}×)"
|
|
2550
2655
|
if h["ratio"] else "") + ".")
|
|
2656
|
+
shared_speed = sorted({c_m for t in shared for c_m, c in t["cells"].items() if c_m in models
|
|
2657
|
+
and any(r.get("alongside") for r in c["rows"])})
|
|
2658
|
+
if shared_speed:
|
|
2659
|
+
line += (f"\n[yellow]Some answers of {', '.join(shared_speed)} ran alongside other models, which "
|
|
2660
|
+
"shared the machine's speed: compare their times with care.[/yellow]")
|
|
2551
2661
|
title.update(line + " [dim]Fewest tokens / Fastest = questions won; ties count for both. Total tokens / "
|
|
2552
2662
|
"Total time add up each question's median. Avg tok/s = all tokens over all the time they took "
|
|
2553
2663
|
"(cached prompts left out).[/dim]")
|
|
@@ -2676,6 +2786,7 @@ class EvalsApp(App):
|
|
|
2676
2786
|
#options { height: 3; padding: 0 1; align-vertical: middle; }
|
|
2677
2787
|
#options Label { padding: 1 1 0 0; }
|
|
2678
2788
|
#options Input { width: 12; }
|
|
2789
|
+
#options #parallel { width: 8; }
|
|
2679
2790
|
#estimate { padding: 0 2; height: auto; min-height: 2; }
|
|
2680
2791
|
#buttons { height: 3; padding: 0 1; }
|
|
2681
2792
|
#buttons Button { margin-right: 2; }
|
|
@@ -2831,6 +2942,40 @@ class EvalsApp(App):
|
|
|
2831
2942
|
self.notify("Everything selected had results; running it again.")
|
|
2832
2943
|
self.add_session(screen)
|
|
2833
2944
|
return
|
|
2945
|
+
run = self.active_session
|
|
2946
|
+
if item.kind == "Run" and isinstance(run, RunScreen) and run.running \
|
|
2947
|
+
and item.sel["tier"] != run.jobs[0].tier:
|
|
2948
|
+
self.notify(f"The run going is {run.jobs[0].tier.title()} and this is {item.sel['tier'].title()}, "
|
|
2949
|
+
"so it can't join it.", timeout=10)
|
|
2950
|
+
elif item.kind == "Run" and isinstance(run, RunScreen) and run.running:
|
|
2951
|
+
def picked(choice):
|
|
2952
|
+
if choice == "add":
|
|
2953
|
+
self.add_to_run(run, item)
|
|
2954
|
+
elif choice == "queue":
|
|
2955
|
+
self.enqueue(item)
|
|
2956
|
+
what = f"{', '.join(item.sel['models'][:4])}{' …' if len(item.sel['models']) > 4 else ''} · " \
|
|
2957
|
+
f"{', '.join(item.sel['packs'][:4])}{' …' if len(item.sel['packs']) > 4 else ''}"
|
|
2958
|
+
self.push_screen(ChoiceScreen(
|
|
2959
|
+
f"A run is going: {run.live_text()}.\n\nAdd {what} to it? Its models run after the ones "
|
|
2960
|
+
"already in it.\nOr queue it as a separate run that starts when this one ends.",
|
|
2961
|
+
[("add", "Add to this run", "success"), ("queue", f"Queue it as #{len(self.queue) + 1}", "primary"),
|
|
2962
|
+
("keep", "Cancel", "default")]), picked)
|
|
2963
|
+
return
|
|
2964
|
+
self.enqueue(item)
|
|
2965
|
+
|
|
2966
|
+
def add_to_run(self, run, item):
|
|
2967
|
+
jobs, problem = item.plan(self.engine)
|
|
2968
|
+
added, why = self.engine.add_jobs(jobs) if jobs else ([], problem)
|
|
2969
|
+
if not added:
|
|
2970
|
+
self.notify(f"Not added: {why}. Queued it instead.", severity="warning", timeout=10)
|
|
2971
|
+
self.enqueue(item)
|
|
2972
|
+
return
|
|
2973
|
+
run.handle("jobs_added", {"jobs": added})
|
|
2974
|
+
models = sorted({j.label for j in added})
|
|
2975
|
+
self.notify(f"Added {len(added)} pack run(s) to the run going: {', '.join(models)}. w shows it.", timeout=10)
|
|
2976
|
+
self.refresh_live()
|
|
2977
|
+
|
|
2978
|
+
def enqueue(self, item):
|
|
2834
2979
|
ahead = self.busy_text()
|
|
2835
2980
|
self.queue.append(item)
|
|
2836
2981
|
self.notify(f"Queued #{len(self.queue)}: {item.title}. It starts by itself after: {ahead}. "
|
|
@@ -2965,10 +3110,10 @@ class EvalsApp(App):
|
|
|
2965
3110
|
self.worker = threading.Thread(target=work, name="tune")
|
|
2966
3111
|
self.worker.start()
|
|
2967
3112
|
|
|
2968
|
-
def start_run(self, jobs, on_event):
|
|
3113
|
+
def start_run(self, jobs, on_event, parallel=None):
|
|
2969
3114
|
self.engine.on_event = on_event
|
|
2970
3115
|
# 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")
|
|
3116
|
+
self.worker = threading.Thread(target=self.engine.run, args=(jobs, parallel), name="engine")
|
|
2972
3117
|
self.worker.start()
|
|
2973
3118
|
|
|
2974
3119
|
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.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.
|
|
@@ -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=YtBX5IRrDzkDypUA_3LD09E8ezHBpeqSVHqjkEVNvp4,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=xTDlcU2nPGcSvv6a45gLTsMU7p7ZvNxmzrkGPMe9vfQ,113950
|
|
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=thjGLDQCZ5YkFmtZ-xB3914ez7I5S2wePwZlFbcqggg,159589
|
|
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
|
|
@@ -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.dev32.dist-info/METADATA,sha256=ZIxzM44ul8qTlthu0MI02bX8jD_09tVkO_WI3cjbah4,13285
|
|
49
|
+
tuieval-0.2.0.dev32.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
50
|
+
tuieval-0.2.0.dev32.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
|
|
51
|
+
tuieval-0.2.0.dev32.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
|
|
52
|
+
tuieval-0.2.0.dev32.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|