summaryrefslogtreecommitdiff
path: root/script
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-10-14 02:21:50 +0900
committertanaka takashi <isazaka@gmail.com>2026-10-14 02:21:50 +0900
commit583a56f1ac3d9be9ce2f8c1715d77423347d4a4e (patch)
treeec2789b91bfa4cfded83fa64adf044347926ecbf /script
parent213e738140859dbcc12c2540ce40d6b6d2875aa7 (diff)
fix: 画像生成モデルのロードを初回生成まで遅延するHEADmain
stdio MCPのツール一覧取得でも、受付開始前にModelManagerがSD本体・ 外部VAE・LoRAをロードし、CUDAへ転送していた。画像生成しない通常の 質問でもこの起動コストが発生するため、有効な生成要求の実行時まで モデル初期化を遅らせ、一覧取得時の不要な遅延とVRAM消費を除去する。 実装: - ModelManagerの構築時は未ロード状態とし、冪等なload()を追加する。 - 初期化ロックで二重ロードを防ぎ、成功時だけモデルを保持する。 失敗時は未ロード状態を維持し、次の要求で再試行できるようにする。 - 3種類の生成メソッドからload()を呼び、既存サービスの入力検証・ 生成ロックを経由したワーカースレッド内で初期化する。 - ControlNetと前処理モデルの既存の遅延ロード・モデル共有を維持する。 - 起動・設定読み込みと実際のモデルロードのログを分離し、ロードの 開始・完了・失敗をstderrへ出力する。 - benchmarkの計測区間で明示的にload()を呼び、ロード時間とVRAMの 計測の意味を維持する。READMEにロードタイミングと確認手順を追記する。 変更範囲: - stdioのプロセス起動・終了方式を維持し、常駐化やHTTP切り替え、 チャット側の接続管理・一覧キャッシュ・アイドル解放は追加しない。 - MCPスキーマ、設定形式、CLI引数、プロンプト・seed処理、scheduler、 生成サイズ、PNGメタデータ・保存仕様は変更しない。 - 設定とモデルファイルの存在確認は起動時に残し、モデル互換性・ CUDA・推論依存関係のエラー検出は初回生成時へ移す。 - 同一プロセス内ではモデルを再利用し、終了時に解放する。 別の生成プロセスでの再ロードは引き続き許容する。 回帰テスト: - 修正前の既存69件に対し、修正後は追加18件を含む87件すべて成功。 既存テストと期待値は変更していない。 - CLI/stdioでの一覧取得時にモデルロードと推論ライブラリのimportを 禁止しても成功することを確認する。 - 全生成方式の初回ロード、ワーカースレッド実行、同時要求、冪等性、 不正入力・無効なControlNet指定、失敗後の再試行、ログを検証する。 - ruff check .は成功、pyrightは0 errors / 0 warnings。 実GPU・stdioでの確認: - 現在の設定(外部VAE、2件のLoRA、LCM、6 steps)を維持して計測した。 - 起動から一覧取得までの時間は8.233秒から0.671秒に短縮した。 - 一覧取得前後のGPU全体の使用量は修正前808→3233 MiBに対し、 修正後682→682 MiBで増加しなかった。 - 同じ接続で2枚生成してもベースモデルのロードは1回で、一覧取得では 0回だった。ロード時間は7.812秒で、初回生成要求に移った。 - 同じ設定・プロンプト・seedによる修正前後の画像は画素が完全一致。 PNG形式・寸法・メタデータと、返却PNGおよび保存PNGの一致を確認した。 - 最終版スクリプトでの再確認でも一覧取得0.655秒、初回生成14.089秒、 2枚目1.221秒で、画素・保存結果の一致と生成プロセス終了を確認した。 - 通常質問の親アプリの処理経路でも、LLMのみをスタブ化し、実際の 設定済みuvコマンドで一覧取得・3ツールの登録を確認した。 - 計測スクリプトと詳細な検証報告を追加する。生のJSON・stderr・PNGは gitignore対象のdata/verification-lazy-loadに保存する。 確認の限界: - Windowsのプロセス別VRAMはN/Aのため、GPU全体の使用量・プロセス一覧・ tasklistによる終了確認で評価した。全体値には他プロセスの変動がある。 - 計測値は単一セッションの観測であり、統計的な速度保証ではない。 - ブラウザ操作と実LLMを含むチャット全体、および現在の設定で無効な ControlNetの実GPU生成は未実施。誘導生成・Inpaintのロード順は スタブを用いた自動テストで確認した。
Diffstat (limited to 'script')
-rw-r--r--script/benchmark_generate_image.py1
-rw-r--r--script/smoke_stdio.py153
2 files changed, 154 insertions, 0 deletions
diff --git a/script/benchmark_generate_image.py b/script/benchmark_generate_image.py
index 8ba74a5..2752d6d 100644
--- a/script/benchmark_generate_image.py
+++ b/script/benchmark_generate_image.py
@@ -108,6 +108,7 @@ async def run(args: argparse.Namespace) -> None:
config = load_config(args.config)
load_started = time.perf_counter()
backend = ModelManager(config)
+ backend.load()
torch.cuda.synchronize()
load_seconds = time.perf_counter() - load_started
model_allocated = torch.cuda.memory_allocated()
diff --git a/script/smoke_stdio.py b/script/smoke_stdio.py
new file mode 100644
index 0000000..9f0dfab
--- /dev/null
+++ b/script/smoke_stdio.py
@@ -0,0 +1,153 @@
+"""Measure CLI/stdio discovery and optional real generation without a resident server."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import base64
+import hashlib
+import io
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from mcp import ClientSession
+from mcp.client.stdio import StdioServerParameters, stdio_client
+from PIL import Image
+
+
+def gpu_snapshot() -> dict[str, object]:
+ """Observe externally; do not initialize CUDA in the client or server."""
+ result: dict[str, object] = {}
+ for name, query in (
+ ("gpu", "--query-gpu=index,memory.used,memory.total"),
+ ("processes", "--query-compute-apps=pid,used_gpu_memory"),
+ ):
+ try:
+ completed = subprocess.run(
+ ["nvidia-smi", query, "--format=csv,noheader,nounits"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ check=True,
+ )
+ result[name] = completed.stdout.strip().splitlines()
+ except (OSError, subprocess.SubprocessError) as exc:
+ result[name] = {"unavailable": str(exc)}
+ return result
+
+
+async def run(args: argparse.Namespace) -> None:
+ args.result.parent.mkdir(parents=True, exist_ok=True)
+ log_path = args.result.with_suffix(".stderr.log")
+ # Fixed seeds belong to this verification process, never the production CLI/schema.
+ bootstrap = (
+ "import os,sys; print('smoke_pid='+str(os.getpid()),file=sys.stderr,flush=True); "
+ "from mcp_sd15.service import secrets; "
+ f"secrets.randbelow=lambda _: {args.seed}; "
+ "from mcp_sd15.cli import main; raise SystemExit(main())"
+ )
+ params = StdioServerParameters(
+ command=sys.executable,
+ args=["-c", bootstrap, "--config", str(args.config.resolve())],
+ cwd=str(Path(__file__).resolve().parents[1]),
+ )
+ report: dict[str, object] = {
+ "config": str(args.config.resolve()),
+ "seed": args.seed,
+ "gpu_before": await asyncio.to_thread(gpu_snapshot),
+ "stderr": str(log_path.resolve()),
+ }
+ try:
+ with log_path.open("w", encoding="utf-8") as errlog:
+ started = time.perf_counter()
+ async with stdio_client(params, errlog=errlog) as (read, write):
+ report["spawn_seconds"] = round(time.perf_counter() - started, 3)
+ async with ClientSession(
+ read, write, read_timeout_seconds=args.timeout
+ ) as client:
+ initialized = time.perf_counter()
+ await client.initialize()
+ report["initialize_seconds"] = round(
+ time.perf_counter() - initialized, 3
+ )
+ listed = time.perf_counter()
+ tools = (await client.list_tools()).tools
+ report["list_seconds"] = round(time.perf_counter() - listed, 3)
+ report["discovery_seconds"] = round(time.perf_counter() - started, 3)
+ names = {tool.name for tool in tools}
+ if names != {"generate_image", "generate_guided_image", "inpaint_image"}:
+ raise RuntimeError(f"unexpected tools: {names}")
+ report["tools"] = sorted(names)
+ report["gpu_after_list"] = await asyncio.to_thread(gpu_snapshot)
+ report["stderr_after_list"] = log_path.read_text(encoding="utf-8")
+ images: list[dict[str, object]] = []
+ report["images"] = images
+ for index in range(args.images):
+ generated = time.perf_counter()
+ result = await client.call_tool(
+ "generate_image",
+ {
+ "positive_prompt": args.prompt,
+ "negative_prompt": "blurry, low quality",
+ "size": "square_512",
+ },
+ )
+ elapsed = time.perf_counter() - generated
+ if getattr(result, "is_error", getattr(result, "isError", False)):
+ raise RuntimeError(f"generation failed: {result}")
+ if len(result.content) != 1 or result.content[0].type != "image":
+ raise RuntimeError(f"unexpected content: {result}")
+ content = result.content[0]
+ if getattr(content, "mime_type", getattr(content, "mimeType", None)) != (
+ "image/png"
+ ):
+ raise RuntimeError("generation did not return image/png")
+ png = base64.b64decode(content.data, validate=True)
+ destination = args.result.with_name(f"{args.result.stem}-{index + 1}.png")
+ destination.write_bytes(png)
+ with Image.open(io.BytesIO(png)) as image:
+ image.load()
+ if image.format != "PNG" or image.size != (512, 512):
+ raise RuntimeError("generation returned unexpected format or size")
+ images.append(
+ {
+ "elapsed_seconds": round(elapsed, 3),
+ "format": image.format,
+ "dimensions": image.size,
+ "metadata": image.info,
+ "pixel_sha256": hashlib.sha256(image.tobytes()).hexdigest(),
+ "output": str(destination.resolve()),
+ "gpu": await asyncio.to_thread(gpu_snapshot),
+ }
+ )
+ report["stderr_after_calls"] = log_path.read_text(encoding="utf-8")
+ report["status"] = "passed"
+ except BaseException as exc:
+ report["status"] = "failed"
+ report["error"] = repr(exc)
+ raise
+ finally:
+ report["gpu_after_exit"] = await asyncio.to_thread(gpu_snapshot)
+ report["stderr_final"] = log_path.read_text(encoding="utf-8")
+ args.result.write_text(
+ json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
+ )
+ print(f"result={args.result.resolve()}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--config", type=Path, required=True)
+ parser.add_argument("--result", type=Path, required=True)
+ parser.add_argument("--images", type=int, choices=(0, 1, 2), default=0)
+ parser.add_argument("--seed", type=int, default=2026091201)
+ parser.add_argument("--timeout", type=float, default=300)
+ parser.add_argument("--prompt", default="a small red cube on a white table, studio photograph")
+ asyncio.run(run(parser.parse_args()))
+
+
+if __name__ == "__main__":
+ main()