"""AdapterPool — Sprint 16 runtime 核心:共享 base model 上的 LoRA 熱插拔。 【這個模組在做什麼?】 Gemma 4 E4B 整個 ML service 只載入一份(共享 base model),但每位使用者可能 有自己的 stylist LoRA adapter。peft 的 `set_adapter()` 會直接改動模型狀態 — 不同併發請求如果沒序列化,後一個會看到前一個的 adapter,造成跨使用者資料污染。 AdapterPool 用 asyncio.Lock 把「load → set → yield → reset」整段序列化, 並搭配 LRU 管理已載入的 adapter 數量(避免 HF Spaces Free tier VRAM 爆掉)。 【關鍵設計決策】 1. **default adapter 永遠 pin 在 pool** 主流程(stylist 給新手 + 非訂閱者)都走 default;如果被驅逐會造成所有 無 adapter 請求每次都要 reload,CPU 100% 拉爆。 2. **asyncio.Lock 序列化 set_adapter** peft 的 set_adapter 是 mutation,不 serialize 會 race。這個 lock 粒度 粗一點(包整個 generate 區塊)比讓 peft 內部 lock 再 fine-grain 更安全。 trade-off:單 ML service process 併發容量 = 1(在 set_adapter 階段)。 對 Wardrobe OS 的流量規模(< 10 req/s)完全夠用。 3. **capacity 包含 default** 更精確地說:capacity 是 non-default adapter 數量的上限。default 不算進 LRU queue,永遠 pinned。所以 capacity=4 實際 loaded 總數最多是 5。 4. **HF Spaces Free tier 預設 capacity=4** review v8 P1-7 的選項 (c):免費方案 + capacity=4 + cold-start penalty acceptable。MVP 階段 marketplace subscribers 現實上 < 10 個,capacity=4 夠用;> 100 時再升級到 (a) 或 (b)。 【併發模型】 asyncio.Lock → FastAPI async 請求自然串起來。不是 threading.Lock(ML service 是 async,沒 GIL 問題;threading.Lock 會整個 event loop 掛掉)。 【對應測試】 apps/ml-service/tests/test_adapter_pool.py(用 FakePeftModel mock,不載實模型) """ from __future__ import annotations import asyncio import logging from collections import OrderedDict from contextlib import asynccontextmanager from typing import Any, AsyncIterator logger = logging.getLogger(__name__) class AdapterPool: """LRU pool for hot-swapping PEFT LoRA adapters on a shared base model.""" def __init__( self, model: Any, *, default_name: str, capacity: int = 4, ) -> None: """初始化 pool。 Args: model: peft.PeftModel 或相容物件(需要 load_adapter / set_adapter / delete_adapter 三個方法,以及 loaded_adapters 的存在性檢查能力)。 Production 會是實際的 PeftModel;測試會是 FakePeftModel。 default_name: 已經 pin 在 base model 上的 default adapter name。 AdapterPool 假設呼叫者在 __init__ 之前已經把 default 載入好。 capacity: non-default adapter 同時 loaded 的上限。default 不算進去。 """ if capacity < 1: raise ValueError(f"capacity must be >= 1, got {capacity}") self._model = model self._default = default_name self._capacity = capacity # LRU queue 只裝 non-default adapter。最近使用的放在尾端,最舊的在頭端。 # 用 OrderedDict 是因為 move_to_end 是 O(1),加上 popitem(last=False) 也是 O(1)。 # value 欄不重要(我們只關心 key 順序),存 hf_repo_id 方便 debug。 self._lru: OrderedDict[str, str] = OrderedDict() # asyncio.Lock — 整個 use() context 內 set_adapter + yield 都 hold 著 self._lock = asyncio.Lock() # ------------------------------------------------------------------ # 公開 API # ------------------------------------------------------------------ def is_loaded(self, name: str) -> bool: """檢查 adapter 是否在 pool 內(default 永遠是 True)。""" if name == self._default: return True return name in self._lru @asynccontextmanager async def use(self, name: str, hf_repo_id: str) -> AsyncIterator[Any]: """切到指定 adapter 執行 generation,離開時切回 default。 【使用方式】 async with pool.use("user-uuid", "hf://fengchen31/wardrobe-os-stylist-") as model: output = model.generate(..., use_cache=False) # ← Sprint 16 Task 2.0 incident 【契約】 - 進入時:adapter 被載入(如果還沒)並 set 為 active - 離開時:無論是否有 exception,adapter 都切回 default - 整段序列化:同時呼叫 use() 的請求會 FIFO 排隊 Args: name: adapter name(通常是使用者 UUID,或 "default")。 hf_repo_id: HF Hub 路徑,load_adapter 時用。name=="default" 時會被忽略。 Yields: 底層的 model 物件(方便 caller 直接拿來 generate)。 """ async with self._lock: # 載入 + 切換 await self._ensure_loaded(name, hf_repo_id) # peft 的 set_adapter 是同步 torch mutation — 放到 worker thread 以免 # 凍結 event loop(Gemma 11 capability 共用同一個 loop) await asyncio.to_thread(self._model.set_adapter, name) logger.debug("adapter_pool: set active → %s", name) try: yield self._model finally: # 一律切回 default — 防跨使用者污染,即使 caller 拋 exception await asyncio.to_thread(self._model.set_adapter, self._default) logger.debug("adapter_pool: reset → %s", self._default) # ------------------------------------------------------------------ # 內部 # ------------------------------------------------------------------ async def _ensure_loaded(self, name: str, hf_repo_id: str) -> None: """確保 adapter 已在 pool 內;該驅逐就驅逐,該載入就載入。""" if name == self._default: # default 永遠 loaded,no-op return if name in self._lru: # hit — 把它推到 LRU 最新端 self._lru.move_to_end(name, last=True) return # miss — 需要載入,先看是否要驅逐最舊的 if len(self._lru) >= self._capacity: oldest_name, oldest_repo = self._lru.popitem(last=False) logger.info( "adapter_pool: evict %s (hf=%s) to make room for %s", oldest_name, oldest_repo, name, ) try: await asyncio.to_thread(self._model.delete_adapter, oldest_name) except Exception as exc: # peft 的 delete_adapter 理論上不會失敗,但保險起見不讓它炸掉 # 整個 pool。記 warning 繼續 — LRU tracking 已經移除了。 logger.warning( "adapter_pool: delete_adapter(%s) failed: %s — LRU still evicted", oldest_name, exc, ) # 真正 load — HF Hub download + torch weight load,30s+ on cold miss # 必須 to_thread,否則整個 event loop 被這段凍住 logger.info("adapter_pool: load_adapter %s from %s", name, hf_repo_id) await asyncio.to_thread(self._model.load_adapter, hf_repo_id, adapter_name=name) self._lru[name] = hf_repo_id