From 583a56f1ac3d9be9ce2f8c1715d77423347d4a4e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 14 Oct 2026 02:21:50 +0900 Subject: fix: 画像生成モデルのロードを初回生成まで遅延する MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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のロード順は スタブを用いた自動テストで確認した。 --- script/benchmark_generate_image.py | 1 + script/smoke_stdio.py | 153 +++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 script/smoke_stdio.py (limited to 'script') 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() -- cgit v1.2.3