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 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.dev25'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev25')
21
+ __version__ = version = '0.2.0.dev32'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev32')
23
23
 
24
24
  __commit_id__ = commit_id = None
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._skip = threading.Event()
681
- self._stall = None # set by the stall watcher: why the current server stalled
695
+ self._main_lane = Lane()
696
+ self._here = threading.local() # .lane: the lane this thread runs
697
+ self._lanes = {} # label -> Lane of each model being served now
698
+ self._lanes_lock = threading.Lock()
682
699
  self._procs = []
683
700
  self._stop_lock = threading.Lock()
684
- self._stream = None
701
+ self._run_lock = threading.Lock()
702
+ self._run = None # the run going: {"jobs", "pending", "open", "machine", "cond"} (add_jobs)
685
703
  self._header_cache = {}
686
704
  self._serving = {}
687
705
  self.tuning_dir = os.path.join(root, "tuning")
@@ -690,6 +708,34 @@ class Engine:
690
708
  self._catalogues = {}
691
709
  self._add_remote_models()
692
710
 
711
+ # ---- the lane this thread runs (see Lane)
712
+ def _lane(self):
713
+ return getattr(self._here, "lane", None) or self._main_lane
714
+
715
+ def _all_lanes(self):
716
+ with self._lanes_lock:
717
+ return [self._main_lane, *self._lanes.values()]
718
+
719
+ @property
720
+ def _stream(self):
721
+ return self._lane().stream
722
+
723
+ @_stream.setter
724
+ def _stream(self, value):
725
+ self._lane().stream = value
726
+
727
+ @property
728
+ def _stall(self):
729
+ return self._lane().stall
730
+
731
+ @_stall.setter
732
+ def _stall(self, value):
733
+ self._lane().stall = value
734
+
735
+ @property
736
+ def _skip(self):
737
+ return self._lane().skip
738
+
693
739
  def reload(self):
694
740
  self._serving = {}
695
741
  self.cfg = load_config(self.models_path)
@@ -1150,6 +1196,30 @@ class Engine:
1150
1196
  """models.toml [machines.<id>] (optional): memory_headroom_gb, name."""
1151
1197
  return self.cfg.get("machines", {}).get(machine_id or self.machine().id, {})
1152
1198
 
1199
+ def parallel_models(self):
1200
+ """How many models a run serves at a time on this machine: models.toml [machines.<id>]
1201
+ parallel_models (default 1)."""
1202
+ try:
1203
+ return max(1, int(self.machine_settings().get("parallel_models", 1)))
1204
+ except (TypeError, ValueError):
1205
+ return 1
1206
+
1207
+ def memory_need_gb(self, m):
1208
+ """GPU memory a model takes while served here: 0 for one we don't start (a hosted API or a
1209
+ server already running), the fit check's estimate for a local GGUF, else None (unknown)."""
1210
+ if not self.cfg["servers"][m["server"]].get("cmd"):
1211
+ return 0.0
1212
+ return self.serving(m).need_gb
1213
+
1214
+ def memory_available_gb(self):
1215
+ """GPU memory models may take here (what the fit check sizes contexts against)."""
1216
+ mc = self.machine()
1217
+ return mc.gpu_limit_gb - self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
1218
+
1219
+ def runs_alone(self, m):
1220
+ """A server whose before_start step may stop other servers never runs next to another model."""
1221
+ return bool(self.cfg["servers"][m["server"]].get("before_start"))
1222
+
1153
1223
  def gpu_residency_gb(self, machine=None):
1154
1224
  """GPU memory this machine's driver keeps resident without churning (see machines.py)."""
1155
1225
  mc = machine or self.machine()
@@ -1180,7 +1250,7 @@ class Engine:
1180
1250
  kv = m.get("kv_type") or server.get("kv_type")
1181
1251
  want = min(x for x in (server.get("max_ctx"), m.get("ctx"), m.get("max_context"), 10**9) if x)
1182
1252
  want = None if want == 10**9 else want
1183
- ctx, fits, note, mtp, size = want, True, "", 0, None
1253
+ ctx, fits, note, mtp, size, need = want, True, "", 0, None, None
1184
1254
  path = expand(m["model"])
1185
1255
  if server.get("model_is_path") and os.path.isfile(path):
1186
1256
  try:
@@ -1192,7 +1262,7 @@ class Engine:
1192
1262
  headroom = self.machine_settings(mc.id).get("memory_headroom_gb", 4.0)
1193
1263
  f = machines.fit(path, mc, kv_type=kv or "f16", headroom_gb=headroom, extra_bytes=extra,
1194
1264
  want_ctx=want)
1195
- ctx, fits, note = f.max_ctx, f.fits, f.note
1265
+ ctx, fits, note, need = f.max_ctx, f.fits, f.note, f.need_gb
1196
1266
  except (OSError, ValueError) as e:
1197
1267
  note = f"couldn't read the GGUF header ({e}); context not checked"
1198
1268
  profile, perf, source = None, [], "n/a"
@@ -1221,7 +1291,7 @@ class Engine:
1221
1291
  # resident), so both are part of what a result is valid for.
1222
1292
  identity["machine"] = mc.id
1223
1293
  identity["perf"] = perf
1224
- sv = Serving(mc, ctx, kv, fits, note, perf, source, identity, profile, mtp)
1294
+ sv = Serving(mc, ctx, kv, fits, note, perf, source, identity, profile, mtp, need)
1225
1295
  self._serving[key] = sv
1226
1296
  return sv
1227
1297
 
@@ -1289,16 +1359,21 @@ class Engine:
1289
1359
  # ---- control
1290
1360
  def cancel(self):
1291
1361
  self._cancel.set()
1292
- self._abort()
1293
-
1294
- def skip_model(self):
1295
- self._skip.set()
1296
- self._abort()
1297
-
1298
- def _abort(self):
1299
- if self._stream:
1300
- self._stream.close()
1301
- for p in list(self._procs):
1362
+ self._abort(self._all_lanes(), list(self._procs))
1363
+
1364
+ def skip_model(self, label=None):
1365
+ """Skip a model being run (label), or every one being run now."""
1366
+ with self._lanes_lock:
1367
+ lanes = [self._lanes[label]] if label in self._lanes else list(self._lanes.values()) or [self._main_lane]
1368
+ for lane in lanes:
1369
+ lane.skip.set()
1370
+ self._abort(lanes, [p for lane in lanes for p in lane.procs] if self._lanes else list(self._procs))
1371
+
1372
+ def _abort(self, lanes, procs):
1373
+ for lane in lanes:
1374
+ if lane.stream:
1375
+ lane.stream.close()
1376
+ for p in procs:
1302
1377
  self._stop(p, grace=10)
1303
1378
 
1304
1379
  def _check(self):
@@ -1322,6 +1397,7 @@ class Engine:
1322
1397
  STALL_WINDOW_S, the GPU driver is churning memory: record why and abort the request."""
1323
1398
  mc = self.machine()
1324
1399
  limit = self.gpu_residency_gb(mc)
1400
+ lane = self._lane()
1325
1401
  samples = [] # (time, user, kernel)
1326
1402
 
1327
1403
  def run():
@@ -1343,15 +1419,15 @@ class Engine:
1343
1419
  continue
1344
1420
  share = dk / (du + dk)
1345
1421
  info["kernel_share_max"] = max(info.get("kernel_share_max") or 0, round(share, 2))
1346
- if share > self.STALL_KERNEL_SHARE and (du + dk) / dt > self.STALL_MIN_BUSY and not self._stall:
1422
+ if share > self.STALL_KERNEL_SHARE and (du + dk) / dt > self.STALL_MIN_BUSY and not lane.stall:
1347
1423
  over = f"{alloc:.1f} GB of GPU memory allocated; " if alloc else ""
1348
- self._stall = (f"{m['label']} stalled in the GPU driver on {mc.id}: {100 * share:.0f}% of its "
1424
+ lane.stall = (f"{m['label']} stalled in the GPU driver on {mc.id}: {100 * share:.0f}% of its "
1349
1425
  f"CPU time went to the kernel for {self.STALL_WINDOW_S}s while the GPU waited "
1350
1426
  f"({over}this machine keeps ~{limit:.0f} GB resident). "
1351
1427
  "Lower its GPU memory or retune it.")
1352
- self.emit("server_stall", label=m["label"], message=self._stall)
1353
- if self._stream:
1354
- self._stream.close()
1428
+ self.emit("server_stall", label=m["label"], message=lane.stall)
1429
+ if lane.stream:
1430
+ lane.stream.close()
1355
1431
  threading.Thread(target=run, daemon=True).start()
1356
1432
 
1357
1433
  # ---- processes
@@ -1360,6 +1436,7 @@ class Engine:
1360
1436
  p = subprocess.Popen(cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=out,
1361
1437
  stderr=subprocess.STDOUT, start_new_session=True)
1362
1438
  self._procs.append(p)
1439
+ self._lane().procs.append(p)
1363
1440
  return p
1364
1441
 
1365
1442
  def _stop(self, p, grace=30):
@@ -1381,6 +1458,9 @@ class Engine:
1381
1458
  p.wait()
1382
1459
  if p in self._procs:
1383
1460
  self._procs.remove(p)
1461
+ for lane in self._all_lanes():
1462
+ if p in lane.procs:
1463
+ lane.procs.remove(p)
1384
1464
 
1385
1465
  def _tail(self, path, label, stop):
1386
1466
  def run():
@@ -1472,45 +1552,40 @@ class Engine:
1472
1552
  return any(self.cfg["servers"][j.model["server"]].get("cmd") for j in jobs if j.status == "waiting")
1473
1553
 
1474
1554
  # ---- running
1475
- def run(self, jobs):
1476
- """Run the jobs (blocking). Jobs of the same model share one server start."""
1555
+ def run(self, jobs, parallel=None):
1556
+ """Run the jobs (blocking). Jobs of the same model share one server start. parallel: how many
1557
+ models are served at a time (default: parallel_models for this machine). add_jobs() can add
1558
+ more while it runs; they run after the ones before them."""
1477
1559
  self._cancel.clear()
1478
1560
  self.emit("queue_started", jobs=jobs)
1479
1561
  models = sorted({j.label for j in jobs if j.status == "waiting"})
1562
+ machine = bool(jobs) and self.needs_machine(jobs)
1480
1563
  lock = (self.machine_lock(f"{jobs[0].tier} run: {', '.join(models[:3])}"
1481
1564
  + (f" +{len(models) - 3}" if len(models) > 3 else ""))
1482
- if jobs and self.needs_machine(jobs) else contextlib.nullcontext())
1565
+ if machine else contextlib.nullcontext())
1566
+ order = []
1567
+ for j in jobs:
1568
+ if j.label not in order:
1569
+ order.append(j.label)
1570
+ groups = [(label, [j for j in jobs if j.label == label and j.status == "waiting"]) for label in order]
1571
+ with self._run_lock:
1572
+ self._run = {"jobs": jobs, "pending": [g for g in groups if g[1]], "open": True, "machine": machine,
1573
+ "cond": threading.Condition(self._run_lock)}
1483
1574
  try:
1484
1575
  with lock:
1485
- order = []
1486
- for j in jobs:
1487
- if j.label not in order:
1488
- order.append(j.label)
1489
- for label in order:
1490
- mine = [j for j in jobs if j.label == label and j.status == "waiting"]
1491
- if not mine:
1492
- continue
1493
- self._skip.clear()
1494
- try:
1495
- try:
1496
- self._run_model(mine)
1497
- except (Cancelled, ModelFailed):
1498
- raise
1499
- except Exception as e: # never let one model's surprise kill the whole queue
1500
- raise ModelFailed(f"unexpected error: {e!r}") from e
1501
- except ModelFailed as e:
1502
- for j in mine:
1503
- if j.status in ("waiting", "loading", "running"):
1504
- j.status = "skipped" if "by user" in str(e) else "failed"
1505
- j.note, j.finished = str(e).splitlines()[0], time.time()
1506
- self.emit("job_done", job=j)
1507
- self.emit("model_failed", label=label, message=str(e))
1576
+ self._run_lanes(max(1, int(parallel or self.parallel_models())))
1577
+ if self._cancel.is_set():
1578
+ raise Cancelled()
1508
1579
  except Cancelled:
1580
+ with self._run_lock:
1581
+ self._run["open"] = False
1509
1582
  for j in jobs:
1510
1583
  if j.status in ("waiting", "loading", "running"):
1511
1584
  j.status, j.note = "skipped", "cancelled (progress kept)" if j.done else "cancelled"
1512
1585
  self.emit("job_done", job=j)
1513
1586
  finally:
1587
+ with self._run_lock:
1588
+ self._run["open"] = False
1514
1589
  changes = []
1515
1590
  if any(j.status == "done" and j.started and j.tier != "smoke" for j in jobs):
1516
1591
  try:
@@ -1522,6 +1597,145 @@ class Engine:
1522
1597
  self.emit("verdicts", changes=changes)
1523
1598
  self.emit("queue_done", jobs=jobs, cancelled=self._cancel.is_set())
1524
1599
 
1600
+ def add_jobs(self, new):
1601
+ """Add planned jobs to the run going on, after its other jobs. Returns (added, why not): a
1602
+ pack already waiting or running in it isn't added twice, and nothing is added once the run is
1603
+ finishing or cancelled, or when it holds no machine lock (hosted only) and these need one.
1604
+ Runs on the caller's thread, so the caller shows what was added (no event)."""
1605
+ with self._run_lock:
1606
+ r = self._run
1607
+ if not r or not r["open"] or self._cancel.is_set():
1608
+ return [], "the run is finishing"
1609
+ if not r["machine"] and self.needs_machine(new):
1610
+ return [], "the run going uses only hosted models, and these need this machine"
1611
+ busy = {j.key for j in r["jobs"] if j.status in ("waiting", "loading", "running")}
1612
+ added = [j for j in new if j.status == "waiting" and j.key not in busy]
1613
+ r["jobs"].extend(added)
1614
+ for j in added: # a model still waiting takes them on; otherwise it comes again at the end
1615
+ group = next((g for g in r["pending"] if g[0] == j.label), None)
1616
+ if group:
1617
+ group[1].append(j)
1618
+ else:
1619
+ r["pending"].append((j.label, [j]))
1620
+ r["cond"].notify_all()
1621
+ return added, None if added else "everything picked is already in the run"
1622
+
1623
+ def _run_lanes(self, parallel):
1624
+ """Run each model's jobs in its own lane, up to `parallel` at a time, in order: the next
1625
+ model starts once a lane is free, nothing running stops it (runs_alone), and its memory fits
1626
+ next to the ones running (memory_need_gb; an unknown need is left to the setting). Lanes
1627
+ wait for jobs added meanwhile (add_jobs) until every model is done."""
1628
+ pending, running, told = self._run["pending"], {}, set()
1629
+ cond = self._run["cond"]
1630
+
1631
+ def blocked(label):
1632
+ """Why the next model can't start now (None: it can)."""
1633
+ if not running:
1634
+ return None
1635
+ if len(running) >= parallel:
1636
+ return ""
1637
+ m = self.model(label)
1638
+ if self.runs_alone(m) or any(self.runs_alone(self.model(l)) for l in running):
1639
+ alone = label if self.runs_alone(m) else next(l for l in running if self.runs_alone(self.model(l)))
1640
+ return (f"{alone}'s server runs a before_start step that may stop other servers, so it runs "
1641
+ "on its own")
1642
+ need = self.memory_need_gb(m)
1643
+ used = sum(n for n in running.values() if n)
1644
+ room = self.memory_available_gb()
1645
+ if need and used + need > room:
1646
+ return (f"needs ~{need:.0f} GB; {', '.join(running)} take ~{used:.0f} of {room:.0f} GB, so it "
1647
+ "starts when there's room")
1648
+ return None
1649
+
1650
+ def lane():
1651
+ while True:
1652
+ mine, note = None, None
1653
+ with cond: # events go out after the lock is released: the UI may be waiting for it (add_jobs)
1654
+ if self._cancel.is_set() or not pending and not running:
1655
+ self._run["open"] = False # nothing more can be added
1656
+ cond.notify_all()
1657
+ return
1658
+ # in order, except that a model added again while it runs waits for itself to end
1659
+ # without holding up the ones behind it
1660
+ nxt = next((i for i, g in enumerate(pending) if g[0] not in running), None)
1661
+ label = pending[nxt][0] if nxt is not None else None
1662
+ why = blocked(label) if label else "" # none can start: a model still runs, more may come
1663
+ if why is None:
1664
+ label, mine = pending.pop(nxt)
1665
+ running[label] = self.memory_need_gb(self.model(label))
1666
+ elif why and label not in told:
1667
+ told.add(label)
1668
+ note = f"{label} waits: {why}"
1669
+ else:
1670
+ cond.wait(1.0)
1671
+ if note:
1672
+ self.emit("model_waiting", label=label, message=note)
1673
+ if mine is None:
1674
+ continue
1675
+ try:
1676
+ self._run_lane(label, mine)
1677
+ finally:
1678
+ with cond:
1679
+ running.pop(label, None)
1680
+ cond.notify_all()
1681
+
1682
+ if parallel == 1:
1683
+ lane()
1684
+ return
1685
+ threads = [threading.Thread(target=lane, name=f"lane-{i + 1}") for i in range(parallel)]
1686
+ for t in threads:
1687
+ t.start()
1688
+ for t in threads:
1689
+ t.join()
1690
+
1691
+ def _run_lane(self, label, mine):
1692
+ """One model's jobs, in this thread's own lane. Other models served meanwhile are noted on
1693
+ each answer (ran_alongside), since they share the machine's GPU and memory bandwidth."""
1694
+ lane = Lane(label)
1695
+ m = mine[0].model
1696
+ with self._lanes_lock:
1697
+ for other in self._lanes.values():
1698
+ other.seen.add(label)
1699
+ lane.seen = set(self._lanes)
1700
+ if self.cfg["servers"][m["server"]].get("cmd"):
1701
+ lane.port = self._lane_port(m)
1702
+ self._lanes[label] = lane
1703
+ self._here.lane = lane
1704
+ try:
1705
+ try:
1706
+ self._run_model(mine)
1707
+ except (Cancelled, ModelFailed):
1708
+ raise
1709
+ except Exception as e: # never let one model's surprise kill the whole queue
1710
+ raise ModelFailed(f"unexpected error: {e!r}") from e
1711
+ except ModelFailed as e:
1712
+ for j in mine:
1713
+ if j.status in ("waiting", "loading", "running"):
1714
+ j.status = "skipped" if "by user" in str(e) else "failed"
1715
+ j.note, j.finished = str(e).splitlines()[0], time.time()
1716
+ self.emit("job_done", job=j)
1717
+ self.emit("model_failed", label=label, message=str(e))
1718
+ except Cancelled:
1719
+ pass # run() marks what's left
1720
+ finally:
1721
+ self._here.lane = None
1722
+ with self._lanes_lock:
1723
+ self._lanes.pop(label, None)
1724
+
1725
+ def _lane_port(self, m):
1726
+ """The server's port, or a free one when another model being served has it (call with
1727
+ _lanes_lock held)."""
1728
+ port = self.cfg["servers"][m["server"]].get("port")
1729
+ if port is None or not any(l.port == port for l in self._lanes.values()):
1730
+ return port
1731
+ taken = {l.port for l in self._lanes.values()}
1732
+ while True:
1733
+ with socket.socket() as s:
1734
+ s.bind(("127.0.0.1", 0))
1735
+ free = s.getsockname()[1]
1736
+ if free not in taken:
1737
+ return free
1738
+
1525
1739
  @contextlib.contextmanager
1526
1740
  def serve(self, m, perf_args=None, port=None, log_name=None):
1527
1741
  """Start a model's server (or use the running one), wait until it serves the expected name,
@@ -1649,11 +1863,16 @@ class Engine:
1649
1863
  for j in jobs:
1650
1864
  j.status = "loading"
1651
1865
  self.emit("job_update", job=j)
1652
- with self.serve(m) as (base_url, info):
1866
+ lane = self._lane()
1867
+ with self.serve(m, port=lane.port) as (base_url, info):
1868
+ with self._lanes_lock:
1869
+ loaded_alongside = sorted(lane.seen) # served while this one loaded: its load time is shared
1870
+
1653
1871
  def run_info():
1654
1872
  return {"load_s": info["load_s"], "peak_rss_mb": info["peak_mb"](),
1655
1873
  "server_facts": info["facts"] or None, "gpu_peak_gb": info.get("gpu_peak_gb"),
1656
- "kernel_share_max": info.get("kernel_share_max"), **serving_info}
1874
+ "kernel_share_max": info.get("kernel_share_max"),
1875
+ **({"loaded_alongside": loaded_alongside} if loaded_alongside else {}), **serving_info}
1657
1876
  failed_in_row = 0
1658
1877
  for j in jobs:
1659
1878
  self._check()
@@ -1736,7 +1955,7 @@ class Engine:
1736
1955
  return tests
1737
1956
 
1738
1957
  # ---- sittings: a pack run over several sessions (stopped and resumed) keeps its total time
1739
- SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "kernel_share_max", "machine")
1958
+ SITTING_KEYS = ("load_s", "peak_rss_mb", "gpu_peak_gb", "kernel_share_max", "machine", "loaded_alongside")
1740
1959
 
1741
1960
  def _sitting(self, job, info):
1742
1961
  now = time.time()
@@ -1811,6 +2030,9 @@ class Engine:
1811
2030
  for k in ("peak_rss_mb", "gpu_peak_gb", "kernel_share_max"):
1812
2031
  if peak(k) is not None:
1813
2032
  out[k] = peak(k)
2033
+ along = sorted({l for s in sittings for l in s.get("loaded_alongside") or []})
2034
+ if along:
2035
+ out["loaded_alongside"] = along
1814
2036
  return out
1815
2037
 
1816
2038
  def _run_job(self, job, base_url, run_info, alive=lambda: True):
@@ -1862,6 +2084,9 @@ class Engine:
1862
2084
  if (test["id"], rep) in done_keys:
1863
2085
  continue
1864
2086
  self._check()
2087
+ lane = self._lane()
2088
+ with self._lanes_lock:
2089
+ lane.seen = set(self._lanes) - {job.label}
1865
2090
  self.emit("request_started", job=job, test=test["description"], test_id=test["id"],
1866
2091
  has_image=bool(test.get("image")), repeat=rep, difficulty=test.get("difficulty", "unrated"))
1867
2092
  waits = list(self.SERVER_RETRY_WAITS_S)
@@ -1870,7 +2095,7 @@ class Engine:
1870
2095
  try:
1871
2096
  body = self._body(job, test, rep)
1872
2097
  res = client.stream_chat(base_url, body,
1873
- lambda kind, text: self.emit("delta", stream=kind, text=text),
2098
+ lambda kind, text: self.emit("delta", stream=kind, text=text, label=job.label),
1874
2099
  timeout=timeout, stream=self._stream, headers=headers)
1875
2100
  except client.Cancelled:
1876
2101
  self._check()
@@ -1917,6 +2142,8 @@ class Engine:
1917
2142
  **({"checks": g["checks"]} if "checks" in g else {}),
1918
2143
  "machine": self.machine().id,
1919
2144
  **({"quantization": ep.get("quantization")} if ep else {}),
2145
+ # other models served while this answer ran: they shared the machine, so its speed did too
2146
+ **({"ran_alongside": sorted(lane.seen)} if lane.seen else {}),
1920
2147
  "sent": "+".join(msg["role"] for msg in body["messages"]), # proof: single turn
1921
2148
  **({"seed": body["seed"]} if "seed" in body else {}), **meta}
1922
2149
  # The same long answer twice for one question, with sampling on, means a cached response
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
- print(f"{RESET}\n{mark} {r['description']}{RESET}{calls} {DIM}({r.get('completion_tokens')} tokens, "
118
- f"{ttft}{speed}{r['total_s']:.1f}s) {r['reason'][:120]}{RESET}", flush=True)
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
- e = engine.Engine(Printer(), models_path=args.models, packs_dir=args.packs_dir,
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
- worker = threading.Thread(target=e.run, args=(jobs,))
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 (or queue it behind a run that's going) r results
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 set up a new run (this one keeps going)
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
- return (f"\n[cyan]{app.busy_text()}: Start adds this to the queue as #{len(app.queue) + 1} "
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 make(self, eng):
1445
- """(RunScreen, None), or (None, why it can't start) when its turn comes."""
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 RunScreen(jobs), None
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("n", "new_run", "New run", priority=True),
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 = {"new_run"}
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.flush_streams()
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.req_started = None
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.query_one("#reasoning", StreamView).clear()
2017
- self.query_one("#answer", StreamView).clear()
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.current = f"[b]{d['label']}[/b] ready{loaded}, starting evals…"
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
- mem = max(self.mem.values()) if self.mem else 0
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.app.engine.skip_model()
2148
- self.notify("Skipping the current model…")
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.dev25
3
+ Version: 0.2.0.dev32
4
4
  Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
5
5
  Project-URL: Homepage, https://github.com/ashe-wb/tuieval
6
6
  Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
@@ -137,7 +137,7 @@ See **[docs/models.md](docs/models.md)** for servers, OpenRouter endpoint pinnin
137
137
 
138
138
  1. **Pick packs and models** (space ticks; type to filter models by label or tag). Each model shows a short code per use case, e.g. `C✓ S?` (✓ pass, ✗ fail, ? inconclusive; grey = from earlier results). To run only some tests of a pack, highlight it and press `e`. Runs of different tests add up, and the pack can PASS once all its tests have run.
139
139
  2. **Pick a tier** (Smoke to check setup, Screen, Certify) and **press `s`**. The line above the buttons shows how many answers that is and roughly how long it will take. For each model, tuieval starts its server (or uses a running one), checks the right model is loaded, runs every selected pack, then stops it.
140
- 3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). Runs started while one is going are queued.
140
+ 3. **Watch the run:** progress with ETA, live score, tok/s, TTFT and memory, the current test with reasoning and answer side by side, recent results with the grader's reason. `k` skips a model, `c` cancels (finished work is kept and resumes next time). `n` sets up more evals while it runs: add them to this run (same tier) or queue them as the next run.
141
141
  4. **Press `r` for results:** production readiness, verdict history, scorecard, speed & tokens (★ marks models nothing beats on both accuracy and time), per question, is the difference real?, tests that separate models, by difficulty, failures, and test quality.
142
142
 
143
143
  Other keys: `t` tunes the ticked models' speed flags, `a` adds a model, `m` scans for GGUFs, `p` saves or loads a preset, `x` hides a model. `?` explains any screen.
@@ -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=jkFBO1NDLAWZMcQgvtkutxfpSaQvK83P67EheKl8tZI,535
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=b-rVcMwq0Tos3oNKcpUP1HXULKhAQNAS4rlXYSY7t6k,25460
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=aRZn-PgB8e4DCLJNPsMhIsF6Kk-CHB_CIsP2ECRizy0,103183
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=nGkbMj0qE4G4RSgIK1lradOFD_8weITk0w54TRNxqI0,42426
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=aG-KMsD_8ma27fsHvw01S7V_g10pzJ-nrWi5pq28DA8,151175
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.dev25.dist-info/METADATA,sha256=dsC0AUbQRdPTzQzc7h6KGTmhkYpZswqme7BSCDSdYUo,13227
49
- tuieval-0.2.0.dev25.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
50
- tuieval-0.2.0.dev25.dist-info/entry_points.txt,sha256=7eIhi1GpFxBP2OLJV02Nn8LwsNuO512eKOGt1IBbj04,45
51
- tuieval-0.2.0.dev25.dist-info/licenses/LICENSE,sha256=7wfRgGtBGH1aXhZkIn47oUznqKC18kEPMvUVgol7Ffc,1077
52
- tuieval-0.2.0.dev25.dist-info/RECORD,,
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,,