coderouter-cli 2.9.3__py3-none-any.whl → 2.9.4__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.
- coderouter/config/schemas.py +50 -0
- coderouter/ingress/launcher_routes.py +796 -3
- coderouter/launcher_devices.py +518 -0
- coderouter/translation/anthropic.py +26 -5
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/METADATA +2 -2
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/RECORD +9 -8
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -40,8 +40,23 @@ from typing import Any
|
|
|
40
40
|
import httpx
|
|
41
41
|
from fastapi import APIRouter, HTTPException, Request
|
|
42
42
|
from fastapi.responses import HTMLResponse
|
|
43
|
-
from pydantic import BaseModel
|
|
44
|
-
|
|
43
|
+
from pydantic import BaseModel, Field
|
|
44
|
+
|
|
45
|
+
from coderouter.launcher_devices import (
|
|
46
|
+
DeviceSelection,
|
|
47
|
+
SweepPlan,
|
|
48
|
+
SweepState,
|
|
49
|
+
SweepStep,
|
|
50
|
+
build_auto_sweep_configs,
|
|
51
|
+
build_sweep_steps,
|
|
52
|
+
detect_llama_devices,
|
|
53
|
+
group_by_backend,
|
|
54
|
+
is_port_free,
|
|
55
|
+
load_latest_results,
|
|
56
|
+
render_bench_command,
|
|
57
|
+
selectable_devices,
|
|
58
|
+
suggest_tensor_split,
|
|
59
|
+
)
|
|
45
60
|
from coderouter.launcher_speculative import resolve_speculative
|
|
46
61
|
from coderouter.logging import get_logger
|
|
47
62
|
|
|
@@ -512,6 +527,7 @@ def _build_cmd(
|
|
|
512
527
|
extra_args: str,
|
|
513
528
|
binary: str | None = None,
|
|
514
529
|
spec_tokens: list[str] | None = None,
|
|
530
|
+
device_args: list[str] | None = None,
|
|
515
531
|
) -> list[str]:
|
|
516
532
|
"""Build the CLI command list for the given backend and options.
|
|
517
533
|
|
|
@@ -525,11 +541,22 @@ def _build_cmd(
|
|
|
525
541
|
the profile / extra args. They are trusted (the launcher, not the
|
|
526
542
|
caller, produced them) so they bypass the model-override guard even
|
|
527
543
|
though they may contain ``--model-draft``.
|
|
544
|
+
|
|
545
|
+
``device_args`` are pre-resolved llama.cpp device-selection flags
|
|
546
|
+
(``--device`` / ``--tensor-split``) produced by
|
|
547
|
+
:meth:`coderouter.launcher_devices.DeviceSelection.to_cli_args`. They
|
|
548
|
+
are inserted right after the port args and BEFORE ``spec_tokens`` for
|
|
549
|
+
llama.cpp only. When ``None``/empty the argv is byte-for-byte identical
|
|
550
|
+
to the pre-feature output (後方互換の核心 — 未選択なら ``--device`` を
|
|
551
|
+
一切足さない). They are launcher-produced (trusted) so they bypass the
|
|
552
|
+
model-override guard, and never apply to vllm/mlx (device 非対応)。
|
|
528
553
|
"""
|
|
529
554
|
exe = _resolve_binary(backend, binary)
|
|
530
555
|
|
|
531
556
|
if backend == "llama.cpp":
|
|
532
557
|
cmd: list[str] = [exe, "-m", model_path, "--port", str(port)]
|
|
558
|
+
if device_args:
|
|
559
|
+
cmd.extend(device_args)
|
|
533
560
|
if spec_tokens:
|
|
534
561
|
cmd.extend(spec_tokens)
|
|
535
562
|
elif backend == "vllm":
|
|
@@ -972,6 +999,11 @@ class StartRequest(BaseModel):
|
|
|
972
999
|
# or "off" (never emit speculative flags).
|
|
973
1000
|
draft_model_path: str | None = None
|
|
974
1001
|
mtp_mode: str = "auto"
|
|
1002
|
+
# デバイス選択(llama.cpp のみ)。既定は空 → ``DeviceSelection.to_cli_args``
|
|
1003
|
+
# が ``[]`` を返し、``_build_cmd`` の argv は現行と 1 バイトも変わらない
|
|
1004
|
+
# (既存 Web クライアント/テスト完全不変)。設計 §4.1。
|
|
1005
|
+
device_ids: list[str] = Field(default_factory=list)
|
|
1006
|
+
tensor_split: list[float] = Field(default_factory=list)
|
|
975
1007
|
|
|
976
1008
|
|
|
977
1009
|
# ---------------------------------------------------------------------------
|
|
@@ -1116,6 +1148,7 @@ async def spawn_process(
|
|
|
1116
1148
|
mtp_mode: str = "auto",
|
|
1117
1149
|
swap_managed: bool = False,
|
|
1118
1150
|
swap_model: str | None = None,
|
|
1151
|
+
device_args: list[str] | None = None,
|
|
1119
1152
|
) -> ManagedProcess:
|
|
1120
1153
|
"""Build argv, spawn the child process, and arm readiness/log tailing.
|
|
1121
1154
|
|
|
@@ -1164,6 +1197,7 @@ async def spawn_process(
|
|
|
1164
1197
|
cmd = _build_cmd(
|
|
1165
1198
|
backend, model_path, port, options, extra_args,
|
|
1166
1199
|
binary=configured_binary, spec_tokens=spec_tokens,
|
|
1200
|
+
device_args=device_args,
|
|
1167
1201
|
)
|
|
1168
1202
|
|
|
1169
1203
|
# Speculative flags qualify for the one-shot startup-crash fallback only
|
|
@@ -1178,6 +1212,7 @@ async def spawn_process(
|
|
|
1178
1212
|
fallback_cmd = _build_cmd(
|
|
1179
1213
|
backend, model_path, port, options, extra_args,
|
|
1180
1214
|
binary=configured_binary, spec_tokens=None,
|
|
1215
|
+
device_args=device_args,
|
|
1181
1216
|
)
|
|
1182
1217
|
except ValueError:
|
|
1183
1218
|
# A failure to rebuild the non-spec command simply disables the
|
|
@@ -1288,12 +1323,20 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
|
1288
1323
|
_require_launcher_token(request)
|
|
1289
1324
|
cfg = request.app.state.config
|
|
1290
1325
|
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1326
|
+
# デバイス選択 → CLI 断片(llama.cpp のみ・選択された場合のみ)。未指定なら
|
|
1327
|
+
# None を渡し、既存の argv と完全一致(後方互換)。
|
|
1328
|
+
device_args: list[str] | None = None
|
|
1329
|
+
if req.backend == "llama.cpp" and req.device_ids:
|
|
1330
|
+
device_args = DeviceSelection(
|
|
1331
|
+
device_ids=list(req.device_ids), tensor_split=list(req.tensor_split)
|
|
1332
|
+
).to_cli_args()
|
|
1291
1333
|
try:
|
|
1292
1334
|
proc = await spawn_process(
|
|
1293
1335
|
request.app, launcher_cfg,
|
|
1294
1336
|
name=req.name, backend=req.backend, model_path=req.model_path,
|
|
1295
1337
|
port=req.port, options=req.options, extra_args=req.extra_args,
|
|
1296
1338
|
draft_model_path=req.draft_model_path, mtp_mode=req.mtp_mode,
|
|
1339
|
+
device_args=device_args,
|
|
1297
1340
|
)
|
|
1298
1341
|
except ValueError as exc:
|
|
1299
1342
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
@@ -1387,6 +1430,408 @@ async def api_suggest(request: Request, model_path: str = "",
|
|
|
1387
1430
|
}
|
|
1388
1431
|
|
|
1389
1432
|
|
|
1433
|
+
# ---------------------------------------------------------------------------
|
|
1434
|
+
# デバイス検出 + ベンチスイープ(設計 §4.4 / §4.5)
|
|
1435
|
+
# ---------------------------------------------------------------------------
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _configured_binary_for(launcher_cfg: Any, backend: str) -> str | None:
|
|
1439
|
+
"""launcher.backends[backend].binary を取り出す(未設定なら None)。"""
|
|
1440
|
+
if launcher_cfg and getattr(launcher_cfg, "backends", None):
|
|
1441
|
+
bc = launcher_cfg.backends.get(backend)
|
|
1442
|
+
if bc and bc.binary:
|
|
1443
|
+
return bc.binary
|
|
1444
|
+
return None
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
@router.get("/api/launcher/devices")
|
|
1448
|
+
async def api_devices(
|
|
1449
|
+
request: Request, backend: str = "llama.cpp", refresh: int = 0
|
|
1450
|
+
) -> dict[str, Any]:
|
|
1451
|
+
"""``{binary} --list-devices`` を実行してデバイス一覧を返す(設計 §4.4)。
|
|
1452
|
+
|
|
1453
|
+
``?refresh=1`` で検出キャッシュを無視して再取得する。検出失敗
|
|
1454
|
+
(``ok=false``)時は空リスト + error を返し、UI は手入力へフォールバック
|
|
1455
|
+
する。
|
|
1456
|
+
|
|
1457
|
+
レスポンス:
|
|
1458
|
+
- ``devices``: 検出した全デバイス(表示用。``BLAS: Accelerate`` 等の
|
|
1459
|
+
``total_mib==0`` デバイスも情報として含む)。
|
|
1460
|
+
- ``suggested_tensor_split``: **バックエンド別**の VRAM 比提案。
|
|
1461
|
+
``{"CUDA": [0.57, 0.43], "Vulkan": [...]}`` の形。同一バックエンドに
|
|
1462
|
+
selectable(``total_mib>0``)が 2 枚以上あるものだけを含む。実機で
|
|
1463
|
+
CUDA+Vulkan が同一物理 GPU を重複列挙する/BLAS が 0 MiB で並ぶため、
|
|
1464
|
+
跨バックエンド・0 MiB 込みのフラット提案は不正になるのを避ける。
|
|
1465
|
+
- ``auto_configs``: スイープ構成候補(``build_auto_sweep_configs``)を
|
|
1466
|
+
JSON 化。``[{"label", "device_ids", "tensor_split"}]``。構成生成ロジック
|
|
1467
|
+
をサーバ側に一本化し、フロントはこれを使う。
|
|
1468
|
+
"""
|
|
1469
|
+
cfg = request.app.state.config
|
|
1470
|
+
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1471
|
+
configured = _configured_binary_for(launcher_cfg, backend)
|
|
1472
|
+
binary = _resolve_binary(backend, configured)
|
|
1473
|
+
# --list-devices はブロッキング subprocess → イベントループを止めない。
|
|
1474
|
+
probe = await asyncio.to_thread(
|
|
1475
|
+
detect_llama_devices, binary, use_cache=(refresh == 0)
|
|
1476
|
+
)
|
|
1477
|
+
# selectable(total_mib>0)だけをバックエンドごとに束ね、2 枚以上のときのみ
|
|
1478
|
+
# tensor-split を提案。跨バックエンド混成・BLAS(0 MiB)は除外される。
|
|
1479
|
+
per_backend_split: dict[str, list[float]] = {}
|
|
1480
|
+
if probe.ok:
|
|
1481
|
+
for backend_prefix, members in group_by_backend(
|
|
1482
|
+
selectable_devices(probe.devices)
|
|
1483
|
+
).items():
|
|
1484
|
+
if len(members) >= 2:
|
|
1485
|
+
per_backend_split[backend_prefix] = suggest_tensor_split(members)
|
|
1486
|
+
auto_configs = [
|
|
1487
|
+
{
|
|
1488
|
+
"label": label,
|
|
1489
|
+
"device_ids": sel.device_ids,
|
|
1490
|
+
"tensor_split": sel.tensor_split,
|
|
1491
|
+
}
|
|
1492
|
+
for label, sel in build_auto_sweep_configs(probe.devices)
|
|
1493
|
+
]
|
|
1494
|
+
return {
|
|
1495
|
+
**probe.as_dict(),
|
|
1496
|
+
"suggested_tensor_split": per_backend_split,
|
|
1497
|
+
"auto_configs": auto_configs,
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
class SweepConfigItem(BaseModel):
|
|
1502
|
+
"""スイープの 1 デバイス構成(ラベル + 選択デバイス)。"""
|
|
1503
|
+
|
|
1504
|
+
label: str
|
|
1505
|
+
device_ids: list[str] = Field(default_factory=list)
|
|
1506
|
+
tensor_split: list[float] = Field(default_factory=list)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
class SweepRequest(BaseModel):
|
|
1510
|
+
"""``POST /api/launcher/sweep/start`` のリクエストボディ(設計 §4.4)。"""
|
|
1511
|
+
|
|
1512
|
+
backend: str = "llama.cpp"
|
|
1513
|
+
model_path: str
|
|
1514
|
+
port: int = Field(ge=1024, le=65535)
|
|
1515
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
1516
|
+
extra_args: str = ""
|
|
1517
|
+
configs: list[SweepConfigItem] = Field(default_factory=list)
|
|
1518
|
+
bench_command: str | None = None # None なら launcher.bench 既定
|
|
1519
|
+
runs: int | None = None
|
|
1520
|
+
results_dir: str | None = None
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
class _SweepRunner:
|
|
1524
|
+
"""デバイス構成を順に「起動→readiness→外部ベンチ→停止→次」で回す。
|
|
1525
|
+
|
|
1526
|
+
既存の :func:`spawn_process` / :func:`stop_process` / ``proc.ready``
|
|
1527
|
+
(asyncio.Event)を再利用する薄い asyncio ランナー。同時に 1 スイープ
|
|
1528
|
+
のみ(``app.state.launcher_sweep``、swap と同じ排他方針)。ポートは
|
|
1529
|
+
スイープ専用に 1 本を構成間で使い回す(各 step は ``stop_process`` が
|
|
1530
|
+
プロセス終了=ポート解放まで待ってから次へ進む)。
|
|
1531
|
+
"""
|
|
1532
|
+
|
|
1533
|
+
def __init__(
|
|
1534
|
+
self,
|
|
1535
|
+
app: Any,
|
|
1536
|
+
launcher_cfg: Any,
|
|
1537
|
+
plan: SweepPlan,
|
|
1538
|
+
*,
|
|
1539
|
+
runs: int | None,
|
|
1540
|
+
readiness_timeout_s: float,
|
|
1541
|
+
) -> None:
|
|
1542
|
+
self.app = app
|
|
1543
|
+
self.launcher_cfg = launcher_cfg
|
|
1544
|
+
self.plan = plan
|
|
1545
|
+
self.runs = runs
|
|
1546
|
+
self.readiness_timeout_s = readiness_timeout_s
|
|
1547
|
+
self.sweep_id = uuid.uuid4().hex[:8]
|
|
1548
|
+
self.running = False
|
|
1549
|
+
self.current_index = -1
|
|
1550
|
+
self.log_tail: deque[str] = deque(maxlen=1000)
|
|
1551
|
+
self._abort = asyncio.Event()
|
|
1552
|
+
self._task: asyncio.Task[Any] | None = None
|
|
1553
|
+
|
|
1554
|
+
# -- lifecycle -----------------------------------------------------------
|
|
1555
|
+
|
|
1556
|
+
def start(self) -> None:
|
|
1557
|
+
self.running = True
|
|
1558
|
+
self._task = asyncio.create_task(self._run())
|
|
1559
|
+
_background_tasks.add(self._task)
|
|
1560
|
+
self._task.add_done_callback(_background_tasks.discard)
|
|
1561
|
+
|
|
1562
|
+
def abort(self) -> None:
|
|
1563
|
+
self._abort.set()
|
|
1564
|
+
|
|
1565
|
+
def status(self) -> dict[str, Any]:
|
|
1566
|
+
return {
|
|
1567
|
+
"sweep_id": self.sweep_id,
|
|
1568
|
+
"running": self.running,
|
|
1569
|
+
"current_index": self.current_index,
|
|
1570
|
+
"steps": [s.as_dict() for s in self.plan.steps],
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
# -- internals -----------------------------------------------------------
|
|
1574
|
+
|
|
1575
|
+
async def _run(self) -> None:
|
|
1576
|
+
try:
|
|
1577
|
+
for i, step in enumerate(self.plan.steps):
|
|
1578
|
+
self.current_index = i
|
|
1579
|
+
if self._abort.is_set():
|
|
1580
|
+
step.state = SweepState.ABORTED
|
|
1581
|
+
self.log_tail.append(f"[sweep] {step.label}: aborted (skipped)")
|
|
1582
|
+
continue
|
|
1583
|
+
await self._run_one(step)
|
|
1584
|
+
finally:
|
|
1585
|
+
self.current_index = -1
|
|
1586
|
+
self.running = False
|
|
1587
|
+
self.log_tail.append("[sweep] finished")
|
|
1588
|
+
|
|
1589
|
+
async def _await_ready(self, mp: ManagedProcess) -> str:
|
|
1590
|
+
"""ready / abort / timeout のいずれかまで待つ。
|
|
1591
|
+
|
|
1592
|
+
``proc.ready`` の待機に abort を絡めることで、readiness 待ち中でも
|
|
1593
|
+
中断要求に即応する(単に ``wait_for`` するだけだと timeout まで
|
|
1594
|
+
止まらない)。
|
|
1595
|
+
"""
|
|
1596
|
+
ready_wait = asyncio.ensure_future(mp.ready.wait())
|
|
1597
|
+
abort_wait = asyncio.ensure_future(self._abort.wait())
|
|
1598
|
+
try:
|
|
1599
|
+
done, _pending = await asyncio.wait(
|
|
1600
|
+
{ready_wait, abort_wait},
|
|
1601
|
+
timeout=self.readiness_timeout_s,
|
|
1602
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1603
|
+
)
|
|
1604
|
+
finally:
|
|
1605
|
+
for t in (ready_wait, abort_wait):
|
|
1606
|
+
if not t.done():
|
|
1607
|
+
t.cancel()
|
|
1608
|
+
if abort_wait in done:
|
|
1609
|
+
return "aborted"
|
|
1610
|
+
if ready_wait in done:
|
|
1611
|
+
return "ready"
|
|
1612
|
+
return "timeout"
|
|
1613
|
+
|
|
1614
|
+
async def _safe_stop(self, proc_id: str) -> None:
|
|
1615
|
+
# stop_process は SIGTERM→5s→SIGKILL でポート解放まで待つ。次構成の
|
|
1616
|
+
# 起動前に確実に空ける。失敗しても握りつぶす(スイープを止めない)。
|
|
1617
|
+
with contextlib.suppress(Exception):
|
|
1618
|
+
await stop_process(self.app, proc_id)
|
|
1619
|
+
|
|
1620
|
+
async def _run_one(self, step: SweepStep) -> None:
|
|
1621
|
+
step.state = SweepState.STARTING
|
|
1622
|
+
self.log_tail.append(f"[sweep] {step.label}: starting")
|
|
1623
|
+
device_args = step.selection.to_cli_args() or None
|
|
1624
|
+
try:
|
|
1625
|
+
mp = await spawn_process(
|
|
1626
|
+
self.app,
|
|
1627
|
+
self.launcher_cfg,
|
|
1628
|
+
name=f"sweep-{step.label}",
|
|
1629
|
+
backend=self.plan.backend,
|
|
1630
|
+
model_path=self.plan.model_path,
|
|
1631
|
+
port=self.plan.port,
|
|
1632
|
+
options=self.plan.options,
|
|
1633
|
+
extra_args=self.plan.extra_args,
|
|
1634
|
+
mtp_mode="off",
|
|
1635
|
+
device_args=device_args,
|
|
1636
|
+
)
|
|
1637
|
+
except Exception as exc: # spawn 自体が失敗 → FAILED(次構成へ継続)
|
|
1638
|
+
step.state = SweepState.FAILED
|
|
1639
|
+
step.error = f"spawn failed: {exc}"
|
|
1640
|
+
self.log_tail.append(f"[sweep] {step.label}: spawn failed: {exc}")
|
|
1641
|
+
return
|
|
1642
|
+
|
|
1643
|
+
outcome = await self._await_ready(mp)
|
|
1644
|
+
if outcome == "aborted":
|
|
1645
|
+
step.state = SweepState.ABORTED
|
|
1646
|
+
self.log_tail.append(f"[sweep] {step.label}: aborted during startup")
|
|
1647
|
+
await self._safe_stop(mp.id)
|
|
1648
|
+
return
|
|
1649
|
+
if outcome == "timeout":
|
|
1650
|
+
step.state = SweepState.FAILED
|
|
1651
|
+
step.error = "readiness timeout"
|
|
1652
|
+
self.log_tail.append(f"[sweep] {step.label}: readiness timeout")
|
|
1653
|
+
await self._safe_stop(mp.id)
|
|
1654
|
+
return
|
|
1655
|
+
if mp.status != "running":
|
|
1656
|
+
step.state = SweepState.FAILED
|
|
1657
|
+
step.error = f"status={mp.status}"
|
|
1658
|
+
self.log_tail.append(
|
|
1659
|
+
f"[sweep] {step.label}: not running (status={mp.status})"
|
|
1660
|
+
)
|
|
1661
|
+
await self._safe_stop(mp.id)
|
|
1662
|
+
return
|
|
1663
|
+
|
|
1664
|
+
if self._abort.is_set():
|
|
1665
|
+
step.state = SweepState.ABORTED
|
|
1666
|
+
self.log_tail.append(f"[sweep] {step.label}: aborted before bench")
|
|
1667
|
+
await self._safe_stop(mp.id)
|
|
1668
|
+
return
|
|
1669
|
+
|
|
1670
|
+
# ── 外部ベンチ実行 ──
|
|
1671
|
+
step.state = SweepState.BENCHING
|
|
1672
|
+
step.started_at = time.time()
|
|
1673
|
+
self.log_tail.append(f"[sweep] {step.label}: benching")
|
|
1674
|
+
argv = render_bench_command(
|
|
1675
|
+
self.plan.bench_cmd_template,
|
|
1676
|
+
port=self.plan.port,
|
|
1677
|
+
config_label=step.label,
|
|
1678
|
+
results_dir=self.plan.results_dir,
|
|
1679
|
+
runs=self.runs,
|
|
1680
|
+
)
|
|
1681
|
+
env = {
|
|
1682
|
+
**os.environ,
|
|
1683
|
+
"OPENAI_BASE_URL": f"http://localhost:{self.plan.port}/v1",
|
|
1684
|
+
}
|
|
1685
|
+
try:
|
|
1686
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1687
|
+
*argv,
|
|
1688
|
+
env=env,
|
|
1689
|
+
cwd=self.plan.results_dir or None,
|
|
1690
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1691
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
1692
|
+
limit=_LOG_STREAM_LIMIT,
|
|
1693
|
+
)
|
|
1694
|
+
except (FileNotFoundError, OSError) as exc:
|
|
1695
|
+
# bench 起動自体が例外 → FAILED(exit code とは別扱い、設計 §2.4)
|
|
1696
|
+
step.state = SweepState.FAILED
|
|
1697
|
+
step.error = f"bench spawn failed: {exc}"
|
|
1698
|
+
step.ended_at = time.time()
|
|
1699
|
+
self.log_tail.append(f"[sweep] {step.label}: bench spawn failed: {exc}")
|
|
1700
|
+
await self._safe_stop(mp.id)
|
|
1701
|
+
return
|
|
1702
|
+
|
|
1703
|
+
if proc.stdout is not None:
|
|
1704
|
+
async for raw in proc.stdout:
|
|
1705
|
+
self.log_tail.append(raw.decode("utf-8", "replace").rstrip())
|
|
1706
|
+
await proc.wait()
|
|
1707
|
+
step.bench_exit_code = proc.returncode
|
|
1708
|
+
step.ended_at = time.time()
|
|
1709
|
+
self.log_tail.append(
|
|
1710
|
+
f"[sweep] {step.label}: bench exit code {proc.returncode}"
|
|
1711
|
+
)
|
|
1712
|
+
# results 解析(results_dir 指定時のみ)。非ゼロ exit でも比較のため
|
|
1713
|
+
# DONE 扱い(失敗ではなく exit code で判別、設計 §2.4)。
|
|
1714
|
+
if self.plan.results_dir:
|
|
1715
|
+
step.results_path, step.summary = await asyncio.to_thread(
|
|
1716
|
+
load_latest_results, self.plan.results_dir, since=step.started_at
|
|
1717
|
+
)
|
|
1718
|
+
step.state = SweepState.DONE
|
|
1719
|
+
await self._safe_stop(mp.id)
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def _bench_defaults(launcher_cfg: Any) -> tuple[str, int, str | None, float]:
|
|
1723
|
+
"""launcher.bench(あれば)からベンチ既定を取り出す。無ければハードコード。"""
|
|
1724
|
+
default_template = "llmbench run --model local-openai --runs {runs}"
|
|
1725
|
+
default_runs = 5
|
|
1726
|
+
default_results_dir: str | None = None
|
|
1727
|
+
default_readiness = _DEFAULT_READINESS_TIMEOUT_S
|
|
1728
|
+
bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
|
|
1729
|
+
if bench_cfg is not None:
|
|
1730
|
+
default_template = bench_cfg.command_template
|
|
1731
|
+
default_runs = bench_cfg.runs
|
|
1732
|
+
default_results_dir = bench_cfg.results_dir
|
|
1733
|
+
default_readiness = bench_cfg.readiness_timeout_s
|
|
1734
|
+
return default_template, default_runs, default_results_dir, default_readiness
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
@router.post("/api/launcher/sweep/start")
|
|
1738
|
+
async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]:
|
|
1739
|
+
"""ベンチスイープを開始する(設計 §4.4 / §4.5)。書き込み系 → token 必須。"""
|
|
1740
|
+
_require_launcher_token(request)
|
|
1741
|
+
app = request.app
|
|
1742
|
+
|
|
1743
|
+
existing = getattr(app.state, "launcher_sweep", None)
|
|
1744
|
+
if existing is not None and existing.running:
|
|
1745
|
+
raise HTTPException(status_code=409, detail="A sweep is already running.")
|
|
1746
|
+
if not req.configs:
|
|
1747
|
+
raise HTTPException(status_code=400, detail="configs must not be empty.")
|
|
1748
|
+
|
|
1749
|
+
cfg = app.state.config
|
|
1750
|
+
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1751
|
+
default_template, default_runs, default_results_dir, default_readiness = (
|
|
1752
|
+
_bench_defaults(launcher_cfg)
|
|
1753
|
+
)
|
|
1754
|
+
bench_command = req.bench_command or default_template
|
|
1755
|
+
runs = req.runs if req.runs is not None else default_runs
|
|
1756
|
+
results_dir = req.results_dir if req.results_dir is not None else default_results_dir
|
|
1757
|
+
|
|
1758
|
+
# ポート競合: registry の使用中ポート照合 + best-effort な空きチェック。
|
|
1759
|
+
reg = _registry_for_app(app)
|
|
1760
|
+
for p in reg.all():
|
|
1761
|
+
if p.port == req.port and p.status in ("running", "loading", "starting"):
|
|
1762
|
+
raise HTTPException(
|
|
1763
|
+
status_code=400,
|
|
1764
|
+
detail=f"Port {req.port} is in use by process {p.name!r}.",
|
|
1765
|
+
)
|
|
1766
|
+
if not is_port_free(req.port):
|
|
1767
|
+
raise HTTPException(
|
|
1768
|
+
status_code=400, detail=f"Port {req.port} is not free."
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
labeled = [
|
|
1772
|
+
(
|
|
1773
|
+
item.label,
|
|
1774
|
+
DeviceSelection(
|
|
1775
|
+
device_ids=list(item.device_ids),
|
|
1776
|
+
tensor_split=list(item.tensor_split),
|
|
1777
|
+
),
|
|
1778
|
+
)
|
|
1779
|
+
for item in req.configs
|
|
1780
|
+
]
|
|
1781
|
+
steps = build_sweep_steps(labeled)
|
|
1782
|
+
plan = SweepPlan(
|
|
1783
|
+
steps=steps,
|
|
1784
|
+
model_path=req.model_path,
|
|
1785
|
+
backend=req.backend,
|
|
1786
|
+
port=req.port,
|
|
1787
|
+
bench_cmd_template=bench_command,
|
|
1788
|
+
results_dir=results_dir,
|
|
1789
|
+
options=req.options,
|
|
1790
|
+
extra_args=req.extra_args,
|
|
1791
|
+
)
|
|
1792
|
+
runner = _SweepRunner(
|
|
1793
|
+
app, launcher_cfg, plan, runs=runs, readiness_timeout_s=default_readiness
|
|
1794
|
+
)
|
|
1795
|
+
app.state.launcher_sweep = runner
|
|
1796
|
+
runner.start()
|
|
1797
|
+
return {"sweep_id": runner.sweep_id, "steps": [s.as_dict() for s in steps]}
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
@router.get("/api/launcher/sweep/status")
|
|
1801
|
+
async def api_sweep_status(request: Request) -> dict[str, Any]:
|
|
1802
|
+
"""現在(または直近)のスイープ状態を返す。読み取り系 → 認証なし。"""
|
|
1803
|
+
runner = getattr(request.app.state, "launcher_sweep", None)
|
|
1804
|
+
if runner is None:
|
|
1805
|
+
return {
|
|
1806
|
+
"sweep_id": None,
|
|
1807
|
+
"running": False,
|
|
1808
|
+
"current_index": -1,
|
|
1809
|
+
"steps": [],
|
|
1810
|
+
}
|
|
1811
|
+
return runner.status()
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
@router.post("/api/launcher/sweep/abort")
|
|
1815
|
+
async def api_sweep_abort(request: Request) -> dict[str, Any]:
|
|
1816
|
+
"""進行中スイープに中断要求を出す(設計 §4.4)。書き込み系 → token 必須。"""
|
|
1817
|
+
_require_launcher_token(request)
|
|
1818
|
+
runner = getattr(request.app.state, "launcher_sweep", None)
|
|
1819
|
+
if runner is None:
|
|
1820
|
+
raise HTTPException(status_code=404, detail="No sweep to abort.")
|
|
1821
|
+
runner.abort()
|
|
1822
|
+
return {"aborted": True}
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
@router.get("/api/launcher/sweep/logs")
|
|
1826
|
+
async def api_sweep_logs(request: Request, n: int = 200) -> dict[str, Any]:
|
|
1827
|
+
"""スイープの進行ログ末尾 N 行。読み取り系 → 認証なし。"""
|
|
1828
|
+
runner = getattr(request.app.state, "launcher_sweep", None)
|
|
1829
|
+
if runner is None:
|
|
1830
|
+
return {"logs": [], "total": 0}
|
|
1831
|
+
tail = list(runner.log_tail)
|
|
1832
|
+
return {"logs": tail[-n:], "total": len(tail)}
|
|
1833
|
+
|
|
1834
|
+
|
|
1390
1835
|
# ---------------------------------------------------------------------------
|
|
1391
1836
|
# HTML UI
|
|
1392
1837
|
# ---------------------------------------------------------------------------
|
|
@@ -1525,6 +1970,22 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1525
1970
|
<input id="f-extra" type="text" placeholder="-ngl 99 --threads 8" />
|
|
1526
1971
|
</div>
|
|
1527
1972
|
|
|
1973
|
+
<!-- デバイス選択 (llama.cpp のみ) -->
|
|
1974
|
+
<div id="device-block" class="hidden">
|
|
1975
|
+
<div class="flex items-center justify-between mb-1">
|
|
1976
|
+
<label class="block text-xs text-slate-400">デバイス (llama.cpp)</label>
|
|
1977
|
+
<button onclick="fetchDevices(true)" class="btn-sm btn-slate">🔍 検出</button>
|
|
1978
|
+
</div>
|
|
1979
|
+
<div id="device-list" class="text-xs text-slate-400 space-y-1 bg-slate-800/40 rounded p-2">
|
|
1980
|
+
<span class="text-slate-600">「🔍 検出」で --list-devices を実行</span>
|
|
1981
|
+
</div>
|
|
1982
|
+
<div id="tsplit-row" class="mt-2 hidden">
|
|
1983
|
+
<label class="block text-xs text-slate-400 mb-1">tensor-split (複数選択時・自動提案は上書き可)</label>
|
|
1984
|
+
<input id="f-tsplit" type="text" placeholder="0.57,0.43" oninput="markTsplitManual()" />
|
|
1985
|
+
<div id="tsplit-note" class="mt-1 text-xs text-yellow-400 hidden"></div>
|
|
1986
|
+
</div>
|
|
1987
|
+
</div>
|
|
1988
|
+
|
|
1528
1989
|
<button id="btn-launch" onclick="launchProcess()" class="btn-primary w-full mt-1">
|
|
1529
1990
|
▶ 起動
|
|
1530
1991
|
</button>
|
|
@@ -1555,6 +2016,67 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1555
2016
|
</div>
|
|
1556
2017
|
</section>
|
|
1557
2018
|
|
|
2019
|
+
<!-- Row 2.5: Bench sweep -->
|
|
2020
|
+
<section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
|
|
2021
|
+
<div class="flex items-center justify-between mb-3">
|
|
2022
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400">📊 Bench Sweep</h2>
|
|
2023
|
+
<div class="flex gap-2">
|
|
2024
|
+
<button id="sweep-start" onclick="startSweep()" class="btn-sm btn-indigo">▶ 開始</button>
|
|
2025
|
+
<button id="sweep-abort" onclick="abortSweep()" class="btn-sm btn-red">■ 中断</button>
|
|
2026
|
+
</div>
|
|
2027
|
+
</div>
|
|
2028
|
+
<p class="text-xs text-slate-500 mb-2">
|
|
2029
|
+
起動フォームの「モデルパス」を使用。各構成を 起動→readiness→ベンチ→停止 で順に回します。
|
|
2030
|
+
構成は「デバイス検出」後に候補が自動生成されます (単一デバイス環境では tensor-split 構成は出ません)。
|
|
2031
|
+
</p>
|
|
2032
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
2033
|
+
<div>
|
|
2034
|
+
<label class="block text-xs text-slate-400 mb-1">構成候補</label>
|
|
2035
|
+
<div id="sweep-configs" class="text-xs text-slate-400 space-y-1 bg-slate-800/40 rounded p-2 max-h-40 overflow-y-auto">
|
|
2036
|
+
<span class="text-slate-600">先に「🔍 検出」でデバイスを取得してください</span>
|
|
2037
|
+
</div>
|
|
2038
|
+
</div>
|
|
2039
|
+
<div class="space-y-2">
|
|
2040
|
+
<div>
|
|
2041
|
+
<label class="block text-xs text-slate-400 mb-1">ポート (スイープ専用・構成間で共用)</label>
|
|
2042
|
+
<input id="sweep-port" type="number" value="8090" min="1024" max="65535" />
|
|
2043
|
+
</div>
|
|
2044
|
+
<div>
|
|
2045
|
+
<label class="block text-xs text-slate-400 mb-1">ベンチコマンド ({port} {config} {base_url} {results_dir} {runs} を置換)</label>
|
|
2046
|
+
<input id="sweep-cmd" type="text" placeholder="llmbench run --model local-openai --runs {runs}" />
|
|
2047
|
+
</div>
|
|
2048
|
+
<div class="grid grid-cols-2 gap-2">
|
|
2049
|
+
<div>
|
|
2050
|
+
<label class="block text-xs text-slate-400 mb-1">runs</label>
|
|
2051
|
+
<input id="sweep-runs" type="number" value="5" min="1" max="1000" />
|
|
2052
|
+
</div>
|
|
2053
|
+
<div>
|
|
2054
|
+
<label class="block text-xs text-slate-400 mb-1">results_dir (任意)</label>
|
|
2055
|
+
<input id="sweep-results" type="text" placeholder="results/" />
|
|
2056
|
+
</div>
|
|
2057
|
+
</div>
|
|
2058
|
+
</div>
|
|
2059
|
+
</div>
|
|
2060
|
+
<div id="sweep-err" class="text-xs text-red-400 mt-2 hidden"></div>
|
|
2061
|
+
<div class="overflow-x-auto mt-3">
|
|
2062
|
+
<table class="w-full text-sm tabnum">
|
|
2063
|
+
<thead class="text-slate-500 text-left">
|
|
2064
|
+
<tr>
|
|
2065
|
+
<th class="pb-2 font-medium">CONFIG</th>
|
|
2066
|
+
<th class="pb-2 font-medium">DEVICES</th>
|
|
2067
|
+
<th class="pb-2 font-medium">STATE</th>
|
|
2068
|
+
<th class="pb-2 font-medium text-right">EXIT</th>
|
|
2069
|
+
<th class="pb-2 font-medium text-right">tok/s</th>
|
|
2070
|
+
<th class="pb-2 font-medium text-right">ttft(ms)</th>
|
|
2071
|
+
</tr>
|
|
2072
|
+
</thead>
|
|
2073
|
+
<tbody id="sweep-table" class="divide-y divide-slate-800">
|
|
2074
|
+
<tr><td colspan="6" class="py-3 text-slate-500 text-xs">スイープ未実行</td></tr>
|
|
2075
|
+
</tbody>
|
|
2076
|
+
</table>
|
|
2077
|
+
</div>
|
|
2078
|
+
</section>
|
|
2079
|
+
|
|
1558
2080
|
<!-- Row 3: Log viewer (hidden until a process is selected) -->
|
|
1559
2081
|
<section id="log-panel" class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 hidden">
|
|
1560
2082
|
<div class="flex items-center justify-between mb-2">
|
|
@@ -1800,6 +2322,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1800
2322
|
window.onBackendChange = () => {
|
|
1801
2323
|
populateProfileSelect();
|
|
1802
2324
|
renderBinaryHint();
|
|
2325
|
+
updateDeviceBlock();
|
|
1803
2326
|
};
|
|
1804
2327
|
|
|
1805
2328
|
// renderProfileArgs は下で const 宣言されるため、宣言前参照(TDZ)を避けて
|
|
@@ -1830,6 +2353,263 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1830
2353
|
return p ? p.args : {};
|
|
1831
2354
|
};
|
|
1832
2355
|
|
|
2356
|
+
// ── Devices (llama.cpp) ────────────────────────────────────────────────────
|
|
2357
|
+
|
|
2358
|
+
let _devices = []; // [{id,name,total_mib,free_mib,total_gb,free_gb}]
|
|
2359
|
+
let _deviceSel = {}; // id -> checked
|
|
2360
|
+
let _deviceOk = false;
|
|
2361
|
+
let _autoConfigs = []; // サーバ生成のスイープ構成候補
|
|
2362
|
+
|
|
2363
|
+
const isLlama = () => document.getElementById("f-backend").value === "llama.cpp";
|
|
2364
|
+
|
|
2365
|
+
// デバイス id → バックエンド接頭辞(末尾数字を除く)。launcher_devices の
|
|
2366
|
+
// backend_of と同じ規則。"CUDA0"→"CUDA" / "Vulkan2"→"Vulkan" / "MTL0"→"MTL"。
|
|
2367
|
+
const backendOf = (id) => id.replace(/\d+$/, "");
|
|
2368
|
+
|
|
2369
|
+
const updateDeviceBlock = () => {
|
|
2370
|
+
const blk = document.getElementById("device-block");
|
|
2371
|
+
if (isLlama()) blk.classList.remove("hidden");
|
|
2372
|
+
else blk.classList.add("hidden");
|
|
2373
|
+
};
|
|
2374
|
+
|
|
2375
|
+
// VRAM (total) 比の tensor-split を末尾で辻褄合わせして 1.0 にする。
|
|
2376
|
+
const splitByTotal = (devs) => {
|
|
2377
|
+
const total = devs.reduce((a, d) => a + d.total_mib, 0);
|
|
2378
|
+
if (!(total > 0) || devs.length < 2) return [];
|
|
2379
|
+
const s = devs.map(d => Math.round((d.total_mib / total) * 100) / 100);
|
|
2380
|
+
const head = s.slice(0, -1).reduce((a, b) => a + b, 0);
|
|
2381
|
+
s[s.length - 1] = Math.round((1 - head) * 100) / 100;
|
|
2382
|
+
return s;
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
window.fetchDevices = async (force) => {
|
|
2386
|
+
if (!isLlama()) { statusMsg("デバイス選択は llama.cpp のみ"); return; }
|
|
2387
|
+
const backend = document.getElementById("f-backend").value;
|
|
2388
|
+
const url = "/api/launcher/devices?backend=" + encodeURIComponent(backend)
|
|
2389
|
+
+ (force ? "&refresh=1" : "");
|
|
2390
|
+
const box = document.getElementById("device-list");
|
|
2391
|
+
box.innerHTML = '<span class="text-slate-600">検出中…</span>';
|
|
2392
|
+
try {
|
|
2393
|
+
const r = await fetch(url);
|
|
2394
|
+
const d = await r.json();
|
|
2395
|
+
_deviceOk = !!d.ok;
|
|
2396
|
+
_devices = d.devices || [];
|
|
2397
|
+
_autoConfigs = d.auto_configs || [];
|
|
2398
|
+
_deviceSel = {};
|
|
2399
|
+
renderDevices(d);
|
|
2400
|
+
buildSweepConfigs();
|
|
2401
|
+
} catch (e) {
|
|
2402
|
+
box.innerHTML = '<span class="text-red-400">検出失敗: ' + esc(e.message) + '</span>';
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
|
|
2406
|
+
const renderDevices = (d) => {
|
|
2407
|
+
const box = document.getElementById("device-list");
|
|
2408
|
+
if (!d.ok) {
|
|
2409
|
+
// 検出失敗 → カンマ区切り手入力へフォールバック
|
|
2410
|
+
box.innerHTML =
|
|
2411
|
+
'<div class="text-yellow-400 mb-1">検出失敗: ' + esc(d.error || "不明")
|
|
2412
|
+
+ ' — 手入力してください</div>'
|
|
2413
|
+
+ '<input id="device-fallback" type="text" placeholder="CUDA0,CUDA1 (カンマ区切り)" />';
|
|
2414
|
+
document.getElementById("tsplit-row").classList.add("hidden");
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
if (!_devices.length) {
|
|
2418
|
+
box.innerHTML = '<span class="text-slate-600">デバイスなし</span>';
|
|
2419
|
+
document.getElementById("tsplit-row").classList.add("hidden");
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
// total_mib==0 (BLAS: Accelerate 等) は GPU オフロード先にできないので
|
|
2423
|
+
// チェックボックスを無効化(表示は残す)。
|
|
2424
|
+
box.innerHTML = _devices.map(dev => {
|
|
2425
|
+
const disabled = !(dev.total_mib > 0);
|
|
2426
|
+
return `<label class="flex items-center gap-2 ${disabled ? "opacity-50" : "cursor-pointer"}">
|
|
2427
|
+
<input type="checkbox" style="width:auto" data-devid="${esc(dev.id)}" ${disabled ? "disabled" : ""} onchange="onDeviceToggle()" />
|
|
2428
|
+
<span class="font-mono text-slate-300">${esc(dev.id)}</span>
|
|
2429
|
+
<span class="text-slate-500 truncate">${esc(dev.name)}</span>
|
|
2430
|
+
<span class="text-slate-500 tabnum ml-auto shrink-0">${disabled ? "— (0 MiB)" : dev.free_gb + "/" + dev.total_gb + " GB"}</span>
|
|
2431
|
+
</label>`;
|
|
2432
|
+
}).join("");
|
|
2433
|
+
updateTsplitVisibility();
|
|
2434
|
+
};
|
|
2435
|
+
|
|
2436
|
+
window.onDeviceToggle = () => {
|
|
2437
|
+
_deviceSel = {};
|
|
2438
|
+
document.querySelectorAll('#device-list input[data-devid]').forEach(cb => {
|
|
2439
|
+
_deviceSel[cb.getAttribute("data-devid")] = cb.checked;
|
|
2440
|
+
});
|
|
2441
|
+
updateTsplitVisibility();
|
|
2442
|
+
};
|
|
2443
|
+
|
|
2444
|
+
const selectedDeviceIds = () => {
|
|
2445
|
+
if (_deviceOk) return _devices.map(d => d.id).filter(id => _deviceSel[id]);
|
|
2446
|
+
const fb = document.getElementById("device-fallback");
|
|
2447
|
+
if (!fb) return [];
|
|
2448
|
+
return fb.value.split(",").map(s => s.trim()).filter(Boolean);
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
// 単一デバイス環境 (総数<=1) or 選択<=1 では tensor-split 欄を隠す (§8.5)。
|
|
2452
|
+
// tensor-split の自動提案はバックエンド単位。跨バックエンド選択時は提案せず
|
|
2453
|
+
// その旨を表示する(手入力は可)。
|
|
2454
|
+
const updateTsplitVisibility = () => {
|
|
2455
|
+
const ids = selectedDeviceIds();
|
|
2456
|
+
const row = document.getElementById("tsplit-row");
|
|
2457
|
+
const note = document.getElementById("tsplit-note");
|
|
2458
|
+
if (_devices.length <= 1 || ids.length <= 1) { row.classList.add("hidden"); return; }
|
|
2459
|
+
row.classList.remove("hidden");
|
|
2460
|
+
const el = document.getElementById("f-tsplit");
|
|
2461
|
+
const backends = new Set(ids.map(backendOf));
|
|
2462
|
+
if (backends.size > 1) {
|
|
2463
|
+
// 跨バックエンド → 自動提案しない(自動値は消す。手入力は保持)。
|
|
2464
|
+
note.textContent = "バックエンド跨ぎのため tensor-split 自動提案なし(手入力可)";
|
|
2465
|
+
note.classList.remove("hidden");
|
|
2466
|
+
if (el.dataset.auto === "1") { el.value = ""; }
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
note.classList.add("hidden");
|
|
2470
|
+
// 同一バックエンド。ユーザーが手入力していれば尊重 (dataset.auto で判別)。
|
|
2471
|
+
if (!el.value.trim() || el.dataset.auto === "1") {
|
|
2472
|
+
const sel = _devices.filter(d => _deviceSel[d.id]);
|
|
2473
|
+
el.value = splitByTotal(sel).join(",");
|
|
2474
|
+
el.dataset.auto = "1";
|
|
2475
|
+
}
|
|
2476
|
+
};
|
|
2477
|
+
|
|
2478
|
+
window.markTsplitManual = () => {
|
|
2479
|
+
document.getElementById("f-tsplit").dataset.auto = "0";
|
|
2480
|
+
};
|
|
2481
|
+
|
|
2482
|
+
// ── Bench sweep ────────────────────────────────────────────────────────────
|
|
2483
|
+
|
|
2484
|
+
let _sweepConfigs = []; // [{label, device_ids, tensor_split, checked}]
|
|
2485
|
+
let _sweepPollTimer = null;
|
|
2486
|
+
|
|
2487
|
+
// スイープ構成候補はサーバの auto_configs(build_auto_sweep_configs)を使う。
|
|
2488
|
+
// 0 MiB 除外・バックエンド単位のグループ化・跨バックエンド混成の除外は
|
|
2489
|
+
// すべてサーバ側ロジックに一本化されている。
|
|
2490
|
+
const buildSweepConfigs = () => {
|
|
2491
|
+
_sweepConfigs = (_autoConfigs || []).map(c => ({
|
|
2492
|
+
label: c.label,
|
|
2493
|
+
device_ids: c.device_ids || [],
|
|
2494
|
+
tensor_split: c.tensor_split || [],
|
|
2495
|
+
checked: true,
|
|
2496
|
+
}));
|
|
2497
|
+
renderSweepConfigs();
|
|
2498
|
+
};
|
|
2499
|
+
|
|
2500
|
+
const renderSweepConfigs = () => {
|
|
2501
|
+
const box = document.getElementById("sweep-configs");
|
|
2502
|
+
if (!_sweepConfigs.length) {
|
|
2503
|
+
box.innerHTML = '<span class="text-slate-600">先に「🔍 検出」でデバイスを取得してください</span>';
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
box.innerHTML = _sweepConfigs.map((c, i) =>
|
|
2507
|
+
`<label class="flex items-center gap-2 cursor-pointer">
|
|
2508
|
+
<input type="checkbox" style="width:auto" data-cfg="${i}" ${c.checked ? "checked" : ""} onchange="onSweepCfgToggle()" />
|
|
2509
|
+
<span class="text-slate-300">${esc(c.label)}</span>
|
|
2510
|
+
<span class="text-slate-500 font-mono ml-auto">${esc(c.device_ids.join(","))}${c.tensor_split.length ? " · " + c.tensor_split.join(",") : ""}</span>
|
|
2511
|
+
</label>`
|
|
2512
|
+
).join("");
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
window.onSweepCfgToggle = () => {
|
|
2516
|
+
document.querySelectorAll('#sweep-configs input[data-cfg]').forEach(cb => {
|
|
2517
|
+
_sweepConfigs[parseInt(cb.getAttribute("data-cfg"))].checked = cb.checked;
|
|
2518
|
+
});
|
|
2519
|
+
};
|
|
2520
|
+
|
|
2521
|
+
const showSweepErr = (msg) => {
|
|
2522
|
+
const el = document.getElementById("sweep-err");
|
|
2523
|
+
if (msg) { el.textContent = msg; el.classList.remove("hidden"); }
|
|
2524
|
+
else { el.textContent = ""; el.classList.add("hidden"); }
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
window.startSweep = async () => {
|
|
2528
|
+
showSweepErr("");
|
|
2529
|
+
const model = document.getElementById("f-model").value.trim();
|
|
2530
|
+
if (!model) { showSweepErr("起動フォームでモデルパスを選択してください"); return; }
|
|
2531
|
+
const configs = _sweepConfigs.filter(c => c.checked)
|
|
2532
|
+
.map(c => ({label: c.label, device_ids: c.device_ids, tensor_split: c.tensor_split}));
|
|
2533
|
+
if (!configs.length) { showSweepErr("構成を 1 つ以上選択してください"); return; }
|
|
2534
|
+
const port = parseInt(document.getElementById("sweep-port").value);
|
|
2535
|
+
const cmd = document.getElementById("sweep-cmd").value.trim();
|
|
2536
|
+
const runs = parseInt(document.getElementById("sweep-runs").value);
|
|
2537
|
+
const results = document.getElementById("sweep-results").value.trim();
|
|
2538
|
+
const body = {
|
|
2539
|
+
backend: "llama.cpp", model_path: model, port, configs,
|
|
2540
|
+
bench_command: cmd || null,
|
|
2541
|
+
runs: isNaN(runs) ? null : runs,
|
|
2542
|
+
results_dir: results || null,
|
|
2543
|
+
};
|
|
2544
|
+
try {
|
|
2545
|
+
const r = await fetch("/api/launcher/sweep/start", {
|
|
2546
|
+
method: "POST", headers: authHeaders({"Content-Type": "application/json"}),
|
|
2547
|
+
body: JSON.stringify(body),
|
|
2548
|
+
});
|
|
2549
|
+
const d = await r.json();
|
|
2550
|
+
if (!r.ok) { showSweepErr(d.detail || "スイープ開始失敗"); return; }
|
|
2551
|
+
statusMsg("スイープ開始: " + d.sweep_id);
|
|
2552
|
+
startSweepPolling();
|
|
2553
|
+
} catch (e) { showSweepErr(e.message); }
|
|
2554
|
+
};
|
|
2555
|
+
|
|
2556
|
+
window.abortSweep = async () => {
|
|
2557
|
+
try {
|
|
2558
|
+
const r = await fetch("/api/launcher/sweep/abort", {method: "POST", headers: authHeaders()});
|
|
2559
|
+
if (r.ok) statusMsg("スイープ中断要求"); else statusMsg("中断対象なし", false);
|
|
2560
|
+
} catch (e) { showSweepErr(e.message); }
|
|
2561
|
+
};
|
|
2562
|
+
|
|
2563
|
+
const startSweepPolling = () => {
|
|
2564
|
+
if (_sweepPollTimer) return;
|
|
2565
|
+
_sweepPollTimer = setInterval(pollSweep, POLL_MS);
|
|
2566
|
+
pollSweep();
|
|
2567
|
+
};
|
|
2568
|
+
|
|
2569
|
+
const pollSweep = async () => {
|
|
2570
|
+
try {
|
|
2571
|
+
const r = await fetch("/api/launcher/sweep/status");
|
|
2572
|
+
const d = await r.json();
|
|
2573
|
+
renderSweepSteps(d.steps || []);
|
|
2574
|
+
if (d.running && !_sweepPollTimer) {
|
|
2575
|
+
// リロードでスイープ進行中を検知 → ポーリング再開。
|
|
2576
|
+
_sweepPollTimer = setInterval(pollSweep, POLL_MS);
|
|
2577
|
+
} else if (!d.running && _sweepPollTimer) {
|
|
2578
|
+
clearInterval(_sweepPollTimer); _sweepPollTimer = null;
|
|
2579
|
+
}
|
|
2580
|
+
} catch (_) {}
|
|
2581
|
+
};
|
|
2582
|
+
|
|
2583
|
+
const sweepStateColor = (s) => ({
|
|
2584
|
+
pending: "text-slate-500", starting: "text-yellow-400",
|
|
2585
|
+
benching: "text-indigo-300", done: "text-green-400",
|
|
2586
|
+
failed: "text-red-400", aborted: "text-slate-400"
|
|
2587
|
+
}[s] || "text-slate-400");
|
|
2588
|
+
|
|
2589
|
+
const renderSweepSteps = (steps) => {
|
|
2590
|
+
const tb = document.getElementById("sweep-table");
|
|
2591
|
+
if (!steps.length) {
|
|
2592
|
+
tb.innerHTML = '<tr><td colspan="6" class="py-3 text-slate-500 text-xs">スイープ未実行</td></tr>';
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
tb.innerHTML = steps.map(s => {
|
|
2596
|
+
const sm = s.summary || {};
|
|
2597
|
+
const tok = sm.tokens_per_sec != null ? sm.tokens_per_sec : "—";
|
|
2598
|
+
const ttft = sm.ttft_ms != null ? sm.ttft_ms : "—";
|
|
2599
|
+
const dev = (s.device_ids || []).join(",")
|
|
2600
|
+
+ ((s.tensor_split || []).length ? " · " + s.tensor_split.join(",") : "");
|
|
2601
|
+
const exit = s.bench_exit_code != null ? s.bench_exit_code : "—";
|
|
2602
|
+
return `<tr>
|
|
2603
|
+
<td class="py-2 pr-3">${esc(s.label)}</td>
|
|
2604
|
+
<td class="py-2 pr-3 font-mono text-slate-400">${esc(dev)}</td>
|
|
2605
|
+
<td class="py-2 pr-3 ${sweepStateColor(s.state)}">${esc(s.state)}</td>
|
|
2606
|
+
<td class="py-2 pr-3 text-right">${esc(String(exit))}</td>
|
|
2607
|
+
<td class="py-2 pr-3 text-right">${esc(String(tok))}</td>
|
|
2608
|
+
<td class="py-2 pr-3 text-right">${esc(String(ttft))}</td>
|
|
2609
|
+
</tr>`;
|
|
2610
|
+
}).join("");
|
|
2611
|
+
};
|
|
2612
|
+
|
|
1833
2613
|
// ── Launch ───────────────────────────────────────────────────────────────
|
|
1834
2614
|
|
|
1835
2615
|
window.launchProcess = async () => {
|
|
@@ -1846,6 +2626,16 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1846
2626
|
if (!model) { showLaunchErr("モデルパスを入力してください"); return; }
|
|
1847
2627
|
if (!port || port < 1024 || port > 65535) { showLaunchErr("ポートは 1024-65535"); return; }
|
|
1848
2628
|
|
|
2629
|
+
// デバイス選択 (llama.cpp のみ)。未選択なら空 → サーバ側で --device 不付与。
|
|
2630
|
+
let deviceIds = [], tensorSplit = [];
|
|
2631
|
+
if (backend === "llama.cpp") {
|
|
2632
|
+
deviceIds = selectedDeviceIds();
|
|
2633
|
+
const ts = document.getElementById("f-tsplit").value.trim();
|
|
2634
|
+
if (ts && deviceIds.length > 1) {
|
|
2635
|
+
tensorSplit = ts.split(",").map(x => parseFloat(x)).filter(x => !isNaN(x));
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
1849
2639
|
const btn = document.getElementById("btn-launch");
|
|
1850
2640
|
btn.disabled = true;
|
|
1851
2641
|
btn.textContent = "起動中…";
|
|
@@ -1856,7 +2646,8 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1856
2646
|
headers: authHeaders({"Content-Type": "application/json"}),
|
|
1857
2647
|
body: JSON.stringify({name, backend, model_path: model, port,
|
|
1858
2648
|
options: selectedProfileArgs(), extra_args: extra,
|
|
1859
|
-
draft_model_path: draft || null, mtp_mode: mtp
|
|
2649
|
+
draft_model_path: draft || null, mtp_mode: mtp,
|
|
2650
|
+
device_ids: deviceIds, tensor_split: tensorSplit}),
|
|
1860
2651
|
});
|
|
1861
2652
|
const d = await res.json();
|
|
1862
2653
|
if (!res.ok) { showLaunchErr(d.detail || "起動失敗"); return; }
|
|
@@ -1986,7 +2777,9 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1986
2777
|
// ── Init + polling ───────────────────────────────────────────────────────
|
|
1987
2778
|
|
|
1988
2779
|
const init = async () => {
|
|
2780
|
+
updateDeviceBlock();
|
|
1989
2781
|
await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses()]);
|
|
2782
|
+
pollSweep(); // 既存スイープがあれば表示 (running なら以後のポーリングを継続)
|
|
1990
2783
|
};
|
|
1991
2784
|
|
|
1992
2785
|
const poll = async () => {
|