coderouter-cli 2.9.2__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.
@@ -0,0 +1,518 @@
1
+ """llama.cpp デバイス検出 + tensor-split 提案 + ベンチスイープ・コアロジック。
2
+
3
+ GUI版 (launcher_gui.py) と Web版 (ingress/launcher_routes.py) の双方から
4
+ 共有する純粋ロジック層。実際の起動/停止/readiness はランタイムモデルが
5
+ 異なる (threading vs asyncio) ため各側で薄く実装し、ここには「データ構造 +
6
+ 純関数 + best-effort な検出プリミティブ」だけを置く。hardware.py の
7
+ 5-deps 不変則(標準ライブラリのみ・best-effort・失敗は握りつぶして degrade)
8
+ に合わせる。
9
+
10
+ このモジュールは pydantic を使わず dataclass のみで構成する。standalone
11
+ GUI(coderouter パッケージ非同梱)からも import できることが要件で、かつ
12
+ 検出・提案・展開・解析はいずれも純粋関数なので、外部依存を持たせない。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import json
19
+ import os
20
+ import re
21
+ import shlex
22
+ import socket
23
+ import subprocess # controlled: fixed argv, no shell
24
+ import threading
25
+ import time
26
+ from collections.abc import Callable, Sequence
27
+ from dataclasses import dataclass, field
28
+ from enum import StrEnum
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ __all__ = [
33
+ "DeviceProbe",
34
+ "DeviceSelection",
35
+ "LlamaDevice",
36
+ "SweepPlan",
37
+ "SweepState",
38
+ "SweepStep",
39
+ "backend_of",
40
+ "build_auto_sweep_configs",
41
+ "build_sweep_steps",
42
+ "detect_llama_devices",
43
+ "group_by_backend",
44
+ "is_port_free",
45
+ "load_latest_results",
46
+ "parse_list_devices",
47
+ "render_bench_command",
48
+ "reset_device_cache",
49
+ "selectable_devices",
50
+ "suggest_tensor_split",
51
+ "summarize_results",
52
+ ]
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # 2.1 デバイス検出
57
+ # ---------------------------------------------------------------------------
58
+
59
+ # llama.cpp の --list-devices は ggml バックエンドを問わず
60
+ # "<name>: <description> (<total> MiB, <free> MiB free)"
61
+ # の共通形式で出力する(ggml_backend_dev_name / _description / メモリ)。
62
+ # バックエンド名は CUDA0 / Metal / Vulkan0 / SYCL0 / CPU 等さまざま
63
+ # なので id は \S+ で汎用に受ける(CUDA 固定にしない)。行末は $ で
64
+ # 固定せず余分な後置トークンにも耐える。
65
+ # name(description)は貪欲最小 (.+?) で受け、メモリ括弧は「行末側の
66
+ # (<total> MiB, <free> MiB free)」に固定する。これにより description 中の
67
+ # 入れ子括弧(例 "AMD Radeon Graphics (RADV GFX1151)")を巻き込まず、
68
+ # 最後のメモリ括弧だけを取り出す。
69
+ # 例(実機出力):
70
+ # " CUDA0: NVIDIA GeForce RTX 5090 (32149 MiB, 31610 MiB free)" (Linux/Win CUDA)
71
+ # " Vulkan2: AMD Radeon Graphics (RADV GFX1151) (114166 MiB, 113602 MiB free)" (入れ子括弧)
72
+ # " MTL0: Apple M3 Max (53084 MiB, 53083 MiB free)" (macOS Metal, id は MTL0)
73
+ # " BLAS: Accelerate (0 MiB, 0 MiB free)" (0 MiB=選択不可・情報のみ)
74
+ # " SYCL0: Intel Arc ... (16384 MiB, 16000 MiB free)" (SYCL)
75
+ # id は末尾数字を含む物理バックエンド名(CUDA0/Vulkan1/MTL0/SYCL0/BLAS/CPU)。
76
+ # 同一物理 GPU が CUDA0 と Vulkan1 のように複数バックエンドで重複列挙される
77
+ # ことがある(backend_of / group_by_backend でグループ化)。
78
+ _DEVICE_LINE_RE = re.compile(
79
+ r"^\s+(?P<id>\S+):\s+(?P<name>.+?)\s+"
80
+ r"\((?P<total>\d+)\s*MiB,\s+(?P<free>\d+)\s*MiB\s+free\)"
81
+ )
82
+ _LIST_DEVICES_TIMEOUT_S = 5.0
83
+ _DEVICE_CACHE_TTL_S = 60.0
84
+ _MIB_PER_GB = 1024.0 # MiB→GiB 表示用(1024 MiB ≒ 1 GiB)
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class LlamaDevice:
89
+ """``llama-server --list-devices`` の 1 行分。"""
90
+
91
+ id: str # 例 "CUDA0" — そのまま --device に渡す名前(--list-devices が正)
92
+ name: str # 例 "NVIDIA GeForce RTX 5090"
93
+ total_mib: int
94
+ free_mib: int
95
+
96
+ @property
97
+ def total_gb(self) -> float:
98
+ return round(self.total_mib / _MIB_PER_GB, 1)
99
+
100
+ @property
101
+ def free_gb(self) -> float:
102
+ return round(self.free_mib / _MIB_PER_GB, 1)
103
+
104
+ def as_dict(self) -> dict[str, Any]:
105
+ return {
106
+ "id": self.id,
107
+ "name": self.name,
108
+ "total_mib": self.total_mib,
109
+ "free_mib": self.free_mib,
110
+ "total_gb": self.total_gb,
111
+ "free_gb": self.free_gb,
112
+ }
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class DeviceProbe:
117
+ """検出結果。ok=False のとき UI は手入力フォールバックへ。"""
118
+
119
+ devices: list[LlamaDevice]
120
+ ok: bool
121
+ error: str | None = None
122
+ raw: str = ""
123
+
124
+ def as_dict(self) -> dict[str, Any]:
125
+ return {
126
+ "ok": self.ok,
127
+ "error": self.error,
128
+ "devices": [d.as_dict() for d in self.devices],
129
+ }
130
+
131
+
132
+ def parse_list_devices(output: str) -> list[LlamaDevice]:
133
+ """``--list-devices`` 標準出力をパース。
134
+
135
+ マッチしない行は無視する(前後の ``Available devices:`` ヘッダや将来の
136
+ 追記、VRAM 表記の無い CPU 行に耐える)。
137
+ """
138
+ devices: list[LlamaDevice] = []
139
+ for line in output.splitlines():
140
+ m = _DEVICE_LINE_RE.match(line)
141
+ if not m:
142
+ continue
143
+ devices.append(
144
+ LlamaDevice(
145
+ id=m["id"],
146
+ name=m["name"].strip(),
147
+ total_mib=int(m["total"]),
148
+ free_mib=int(m["free"]),
149
+ )
150
+ )
151
+ return devices
152
+
153
+
154
+ # ── キャッシュ(hardware.py と同じ RLock+TTL パターン。binary パス別) ──
155
+ _cache_lock = threading.RLock()
156
+ _cache: dict[str, tuple[float, DeviceProbe]] = {}
157
+
158
+
159
+ def detect_llama_devices(
160
+ binary: str,
161
+ *,
162
+ timeout_s: float = _LIST_DEVICES_TIMEOUT_S,
163
+ use_cache: bool = True,
164
+ runner: Callable[[], subprocess.CompletedProcess[str]] | None = None,
165
+ ) -> DeviceProbe:
166
+ """``{binary} --list-devices`` を実行してデバイス一覧を返す。
167
+
168
+ 失敗(バイナリ無し/タイムアウト/非ゼロ終了/パース 0 件)時は
169
+ ``ok=False`` の :class:`DeviceProbe` を返し、呼び出し側は手入力へ
170
+ フォールバックする。``runner`` はテスト用に ``subprocess.run`` を
171
+ 差し替えるフック。
172
+ """
173
+ key = str(Path(binary).expanduser())
174
+ now = time.monotonic()
175
+ if use_cache:
176
+ with _cache_lock:
177
+ hit = _cache.get(key)
178
+ if hit and (now - hit[0]) < _DEVICE_CACHE_TTL_S:
179
+ return hit[1]
180
+
181
+ def _default_runner() -> subprocess.CompletedProcess[str]:
182
+ return subprocess.run( # fixed argv, no shell
183
+ [key, "--list-devices"],
184
+ capture_output=True,
185
+ text=True,
186
+ encoding="utf-8",
187
+ errors="replace", # ★ Windows cp932 対策
188
+ timeout=timeout_s,
189
+ check=False,
190
+ )
191
+
192
+ run = runner or _default_runner
193
+ try:
194
+ out = run()
195
+ raw = (out.stdout or "") + (out.stderr or "") # 版により stderr 出力
196
+ devices = parse_list_devices(raw)
197
+ if not devices:
198
+ probe = DeviceProbe(
199
+ [], ok=False, error="デバイスを検出できませんでした", raw=raw
200
+ )
201
+ else:
202
+ probe = DeviceProbe(devices, ok=True, raw=raw)
203
+ except FileNotFoundError:
204
+ probe = DeviceProbe([], ok=False, error=f"バイナリが見つかりません: {key}")
205
+ except subprocess.TimeoutExpired:
206
+ probe = DeviceProbe([], ok=False, error="--list-devices がタイムアウトしました")
207
+ except (OSError, subprocess.SubprocessError) as exc:
208
+ probe = DeviceProbe([], ok=False, error=str(exc))
209
+
210
+ if use_cache:
211
+ with _cache_lock:
212
+ _cache[key] = (now, probe)
213
+ return probe
214
+
215
+
216
+ def reset_device_cache() -> None:
217
+ """テスト用。検出キャッシュを破棄。"""
218
+ with _cache_lock:
219
+ _cache.clear()
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # 2.2 デバイス選択 → CLI
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ def _fmt_split(x: float) -> str:
228
+ return f"{x:g}" # 0.57 / 0.43 のように末尾ゼロを落とす
229
+
230
+
231
+ @dataclass
232
+ class DeviceSelection:
233
+ """通常起動 / スイープ 1 構成のデバイス指定。"""
234
+
235
+ device_ids: list[str] = field(default_factory=list) # ["CUDA0","CUDA1"]
236
+ tensor_split: list[float] = field(default_factory=list)
237
+
238
+ @property
239
+ def active(self) -> bool:
240
+ return bool(self.device_ids)
241
+
242
+ def to_cli_args(self) -> list[str]:
243
+ """llama.cpp 用 CLI 断片。選択が無ければ空(=現状挙動を完全維持)。"""
244
+ if not self.device_ids:
245
+ return [] # ★ 未選択時は何も足さない
246
+ args = ["--device", ",".join(self.device_ids)]
247
+ if len(self.device_ids) > 1 and self.tensor_split:
248
+ args += ["--tensor-split", ",".join(_fmt_split(x) for x in self.tensor_split)]
249
+ return args
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # 2.3 tensor-split 自動提案
254
+ # ---------------------------------------------------------------------------
255
+
256
+
257
+ def suggest_tensor_split(
258
+ devices: Sequence[LlamaDevice],
259
+ *,
260
+ by: str = "total", # "total" | "free"
261
+ decimals: int = 2,
262
+ ) -> list[float]:
263
+ """VRAM 比の tensor-split を提案。
264
+
265
+ 合計が丸め後もちょうど 1.0 になるよう最後の要素で辻褄を合わせる
266
+ (llama.cpp 側でも正規化されるが表示の一貫性のため)。単一デバイス
267
+ (Metal 単体・単基 CUDA 等)は ``[]`` を返し tensor-split を提案しない。
268
+ """
269
+ if len(devices) <= 1:
270
+ return []
271
+ caps = [float(d.free_mib if by == "free" else d.total_mib) for d in devices]
272
+ total = sum(caps)
273
+ if total <= 0:
274
+ return []
275
+ props = [round(c / total, decimals) for c in caps]
276
+ props[-1] = round(1.0 - sum(props[:-1]), decimals) # 丸め誤差を末尾に集約
277
+ return props
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # 2.3b デバイス分類ヘルパ(選択可否 / バックエンドグループ化)
282
+ # ---------------------------------------------------------------------------
283
+
284
+ # 末尾の連番(CUDA0→CUDA / Vulkan12→Vulkan / MTL0→MTL)を落として
285
+ # 「バックエンド接頭辞」を得る。数字を持たない BLAS / CPU / Metal は
286
+ # そのまま返す。
287
+ _BACKEND_SUFFIX_RE = re.compile(r"\d+$")
288
+
289
+
290
+ def selectable_devices(devices: Sequence[LlamaDevice]) -> list[LlamaDevice]:
291
+ """GPU オフロード先として選べるデバイスだけを返す。
292
+
293
+ ``total_mib == 0`` のデバイス(macOS の ``BLAS: Accelerate (0 MiB, ...)``
294
+ 等)は VRAM を持たず tensor-split やスイープ構成に参加できないため除外
295
+ する。一覧表示からは落とさない(:func:`parse_list_devices` は情報として
296
+ 全行を返す)——本関数は「選択・提案・スイープ自動生成」の入力を絞るための
297
+ フィルタ。
298
+ """
299
+ return [d for d in devices if d.total_mib > 0]
300
+
301
+
302
+ def backend_of(device_id: str) -> str:
303
+ """デバイス id からバックエンド接頭辞を返す。
304
+
305
+ ``"CUDA0"→"CUDA"`` / ``"Vulkan2"→"Vulkan"`` / ``"MTL0"→"MTL"`` /
306
+ ``"SYCL0"→"SYCL"``。末尾に数字を持たない ``"BLAS"`` / ``"CPU"`` /
307
+ ``"Metal"`` はそのまま返す。同一物理 GPU が CUDA と Vulkan の両方で
308
+ 列挙されるケースをバックエンド単位で束ねるための正規化。
309
+ """
310
+ return _BACKEND_SUFFIX_RE.sub("", device_id)
311
+
312
+
313
+ def group_by_backend(
314
+ devices: Sequence[LlamaDevice],
315
+ ) -> dict[str, list[LlamaDevice]]:
316
+ """デバイスをバックエンド接頭辞ごとにグループ化する(挿入順を保持)。"""
317
+ groups: dict[str, list[LlamaDevice]] = {}
318
+ for d in devices:
319
+ groups.setdefault(backend_of(d.id), []).append(d)
320
+ return groups
321
+
322
+
323
+ def build_auto_sweep_configs(
324
+ devices: Sequence[LlamaDevice],
325
+ *,
326
+ by: str = "total",
327
+ ) -> list[tuple[str, DeviceSelection]]:
328
+ """検出デバイスからスイープ構成(ラベル + :class:`DeviceSelection`)を自動生成。
329
+
330
+ 生成規則:
331
+ 1. **単体構成**: selectable な各デバイスにつき 1 構成(``"CUDA0 単体"`` 等)。
332
+ 2. **バックエンド内複数枚構成**: 同一バックエンド(:func:`backend_of`)に
333
+ selectable が 2 枚以上あるバックエンドのみ、その全枚数を束ねた 1 構成を
334
+ 追加し、そのバックエンド内 VRAM 比で tensor-split を自動提案。
335
+
336
+ **バックエンド跨ぎの混成構成は自動生成しない**(CUDA と Vulkan、あるいは
337
+ 同一物理 GPU が両バックエンドで重複列挙されるケースを混ぜない)。手動での
338
+ 任意選択は :class:`DeviceSelection` を直接構築すれば妨げられない。
339
+ ``total_mib == 0`` のデバイス(BLAS 等)は :func:`selectable_devices` で
340
+ 除外され、単体構成にもグループにも入らない。
341
+ """
342
+ sel = selectable_devices(devices)
343
+ configs: list[tuple[str, DeviceSelection]] = [
344
+ (f"{d.id} 単体", DeviceSelection(device_ids=[d.id])) for d in sel
345
+ ]
346
+ for backend, members in group_by_backend(sel).items():
347
+ if len(members) < 2:
348
+ continue
349
+ ids = [m.id for m in members]
350
+ split = suggest_tensor_split(members, by=by)
351
+ configs.append(
352
+ (
353
+ f"{backend} x{len(members)}",
354
+ DeviceSelection(device_ids=ids, tensor_split=split),
355
+ )
356
+ )
357
+ return configs
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # 2.4 スイープ計画・状態
362
+ # ---------------------------------------------------------------------------
363
+
364
+
365
+ class SweepState(StrEnum):
366
+ PENDING = "pending"
367
+ STARTING = "starting" # サーバ起動→readiness 待ち
368
+ BENCHING = "benching" # 外部ベンチ実行中
369
+ DONE = "done"
370
+ FAILED = "failed"
371
+ ABORTED = "aborted"
372
+
373
+
374
+ @dataclass
375
+ class SweepStep:
376
+ label: str # "CUDA0 単体" 等(表示 + {config} 置換に使用)
377
+ selection: DeviceSelection
378
+ state: SweepState = SweepState.PENDING
379
+ bench_exit_code: int | None = None
380
+ results_path: str | None = None # 読めた llmbench results JSON
381
+ summary: dict[str, Any] | None = None # 抽出済み主要メトリクス
382
+ error: str | None = None
383
+ started_at: float = 0.0
384
+ ended_at: float = 0.0
385
+
386
+ def as_dict(self) -> dict[str, Any]:
387
+ return {
388
+ "label": self.label,
389
+ "device_ids": self.selection.device_ids,
390
+ "tensor_split": self.selection.tensor_split,
391
+ "state": self.state.value,
392
+ "bench_exit_code": self.bench_exit_code,
393
+ "results_path": self.results_path,
394
+ "summary": self.summary,
395
+ "error": self.error,
396
+ }
397
+
398
+
399
+ @dataclass
400
+ class SweepPlan:
401
+ steps: list[SweepStep]
402
+ model_path: str
403
+ backend: str # 当面 "llama.cpp"
404
+ port: int
405
+ bench_cmd_template: str
406
+ results_dir: str | None = None
407
+ options: dict[str, Any] = field(default_factory=dict)
408
+ extra_args: str = ""
409
+
410
+
411
+ def build_sweep_steps(
412
+ labeled: Sequence[tuple[str, DeviceSelection]],
413
+ ) -> list[SweepStep]:
414
+ return [SweepStep(label=lbl, selection=sel) for lbl, sel in labeled]
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # 2.5 ベンチコマンド展開
419
+ # ---------------------------------------------------------------------------
420
+
421
+ _BENCH_PLACEHOLDERS = ("port", "config", "base_url", "results_dir", "runs")
422
+
423
+
424
+ def render_bench_command(
425
+ template: str,
426
+ *,
427
+ port: int,
428
+ config_label: str,
429
+ results_dir: str | None = None,
430
+ runs: int | None = None,
431
+ ) -> list[str]:
432
+ """テンプレ内の {port} {config} {base_url} {results_dir} {runs} を置換して
433
+ argv 化。
434
+
435
+ ``str.format`` は JSON 波括弧等で誤爆するため単純置換を使う。
436
+ """
437
+ mapping = {
438
+ "port": str(port),
439
+ "config": config_label,
440
+ "base_url": f"http://localhost:{port}/v1",
441
+ "results_dir": results_dir or "",
442
+ "runs": str(runs) if runs is not None else "",
443
+ }
444
+ rendered = template
445
+ for k, v in mapping.items():
446
+ rendered = rendered.replace("{" + k + "}", v)
447
+ # ★ Windows: shlex の POSIX モードはバックスラッシュをエスケープ文字
448
+ # として食い潰し `C:\tools\llmbench.exe` を壊す。os.name で分岐して
449
+ # Windows では posix=False(バックスラッシュを素通し)にする。
450
+ return shlex.split(rendered, posix=(os.name != "nt"))
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # 2.6 results 解析
455
+ # ---------------------------------------------------------------------------
456
+
457
+
458
+ def load_latest_results(
459
+ results_dir: str | Path,
460
+ *,
461
+ since: float,
462
+ ) -> tuple[str | None, dict[str, Any] | None]:
463
+ """``results_dir`` 内で mtime>=since の最新 ``*.json`` を読む。
464
+
465
+ ``(path, summary)`` を返す。読めなければ ``(None, None)``。llmbench の
466
+ スキーマに強く依存せず、既知キーを防御的に拾う。
467
+ """
468
+ base = Path(results_dir).expanduser()
469
+ if not base.is_dir():
470
+ return None, None
471
+ cands = [p for p in base.glob("*.json") if p.stat().st_mtime >= since - 1.0]
472
+ if not cands:
473
+ return None, None
474
+ newest = max(cands, key=lambda p: p.stat().st_mtime)
475
+ try:
476
+ data = json.loads(newest.read_text(encoding="utf-8"))
477
+ except (OSError, ValueError):
478
+ return str(newest), None
479
+ return str(newest), summarize_results(data)
480
+
481
+
482
+ # llmbench の JSON スキーマは変わりうるので、複数の別名を許容して拾う
483
+ _SUMMARY_KEYS = {
484
+ "tokens_per_sec": ("tokens_per_sec", "tok_s", "throughput", "tps"),
485
+ "ttft_ms": ("ttft_ms", "time_to_first_token_ms", "ttft"),
486
+ "latency_ms": ("latency_ms", "avg_latency_ms", "latency"),
487
+ "runs": ("runs", "n_runs", "count"),
488
+ }
489
+
490
+
491
+ def summarize_results(data: dict[str, Any]) -> dict[str, Any]:
492
+ """llmbench results JSON から比較用の主要メトリクスを best-effort 抽出。"""
493
+ flat = data.get("summary", data) if isinstance(data, dict) else {}
494
+ out: dict[str, Any] = {}
495
+ for canon, aliases in _SUMMARY_KEYS.items():
496
+ for a in aliases:
497
+ if isinstance(flat, dict) and a in flat:
498
+ out[canon] = flat[a]
499
+ break
500
+ out["_raw_keys"] = sorted(flat.keys()) if isinstance(flat, dict) else []
501
+ return out
502
+
503
+
504
+ # ---------------------------------------------------------------------------
505
+ # 2.7 ポート補助
506
+ # ---------------------------------------------------------------------------
507
+
508
+
509
+ def is_port_free(port: int, host: str = "127.0.0.1") -> bool:
510
+ """best-effort な空きポート判定(TOCTOU 窓は残る)。"""
511
+ with (
512
+ contextlib.suppress(OSError),
513
+ socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s,
514
+ ):
515
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
516
+ s.bind((host, port))
517
+ return True
518
+ return False