diff --git a/README.md b/README.md index 6274aaf..f5baa48 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,86 @@ Each recommendation result includes `cost_ms`, the wall-clock time spent in the native search itself. Batch results report this value independently for every request. +## Pool reuse + +A `recommend` call spends most of its time building the candidate pool and only +a small fraction searching it. When the same user, masterdata and options are +queried repeatedly, build the pool once and search it many times: + +```python +pool = engine.build_pool(options) +result = pool.recommend() # search only +top5 = pool.recommend(limit=5) # limit and timeout_ms may be overridden +print(pool.card_count) # candidates in the pool +``` + +Measured on one dataset (672-card account, `multi` / `score`, 194 candidates): +`engine.recommend()` 4549 us versus `pool.recommend()` 448 us at the median, +about 10x, saving roughly 4.1 ms per call. Both paths return identical decks. + +### What a pool is bound to + +A pool captures the user data, the masterdata and the options it was built from. +**It does not observe later changes to any of them**, so reusing a stale pool +silently returns results computed from outdated inputs. Rebuild when: + +| Change | Effect | +|---|---| +| `update_masterdata` / `update_musicmetas` | every pool for that region is stale | +| the user's cards change (new cards, levels, master ranks) | that user's pools are stale | +| any option other than `limit` / `timeout_ms` | needs its own pool | + +`limit` and `timeout_ms` affect only the search stage and can be passed per call. + +### Memory + +A pool holds its candidate set, search context and resolved card details until +it is released. Measured per pool: + +| Account cards | Candidates | Per pool | +|---|---|---| +| 672 | 141-194 | 221-257 KB | +| 1249 | 156-260 | 391-465 KB | + +Roughly 0.2-0.5 MB each, so keeping 100 pools costs about 22-47 MB. + +### Caller-owned cache + +No pool cache is built in: the right bound and the right invalidation depend on +the caller, and pools cost memory that the library should not claim on its own. +A bounded LRU is a few lines: + +```python +from collections import OrderedDict + + +class PoolCache: + def __init__(self, engine, max_pools=64): + self._engine = engine + self._max = max_pools + self._pools = OrderedDict() + + def recommend(self, key, options, limit=None): + pool = self._pools.pop(key, None) + if pool is None: + pool = self._engine.build_pool(options) + self._pools[key] = pool # newest last + while len(self._pools) > self._max: + self._pools.popitem(last=False) # evict oldest + return pool.recommend(limit=limit) + + def drop_user(self, user_id): + for key in [k for k in self._pools if k[0] == user_id]: + del self._pools[key] + + def clear(self): + self._pools.clear() +``` + +`key` must cover everything the pool is bound to; a workable one is +`(user_id, user_data_revision, options_fingerprint)`. Call `drop_user` when that +user's cards change and `clear` after reloading masterdata. + ## License MIT diff --git a/python/allium_deck/__init__.py b/python/allium_deck/__init__.py index baf5ffc..c608e04 100644 --- a/python/allium_deck/__init__.py +++ b/python/allium_deck/__init__.py @@ -2,8 +2,8 @@ DeckRecommendOptions as RecommendOptions, DeckRecommendResult as RecommendResult, DeckRecommendUserData as UserData, + PreparedCardPool as CardPool, SekaiDeckRecommend as Engine, ) -__all__ = ["Engine", "RecommendOptions", "RecommendResult", "UserData"] - +__all__ = ["CardPool", "Engine", "RecommendOptions", "RecommendResult", "UserData"] diff --git a/python/sekai_deck_recommend_cpp/__init__.py b/python/sekai_deck_recommend_cpp/__init__.py index efb5c6c..81ef2ac 100644 --- a/python/sekai_deck_recommend_cpp/__init__.py +++ b/python/sekai_deck_recommend_cpp/__init__.py @@ -145,6 +145,28 @@ def recommend(self, options: DeckRecommendOptions) -> DeckRecommendResult: ) return DeckRecommendResult.from_dict(json.loads(payload)) + def build_pool(self, options: DeckRecommendOptions) -> PreparedCardPool: + """Build a reusable search pool for `options`. + + A `recommend` call spends most of its time building the candidate pool + and only a small fraction searching it. When the same user, masterdata + and options are queried repeatedly, build the pool once and search it + many times. + + The pool is bound to the user data, masterdata and options it was built + from, and does not observe later changes to any of them. Rebuild it when + any of those change; holding pools costs extra memory. See the pool + reuse section of the README. + """ + region = self._validate_options(options) + user_data = self._resolve_user_data(options) + native_pool = self._require_native().build_pool( + region, + json.dumps(options._to_native_dict(), separators=(",", ":")), + user_data._native, + ) + return PreparedCardPool(native_pool) + def recommend_batch( self, options_list: list[DeckRecommendOptions] ) -> list[DeckRecommendResult]: @@ -234,6 +256,51 @@ def calculate_exact_live( return json.loads(payload) +class PreparedCardPool: + """A pre-built search pool returned by `SekaiDeckRecommend.build_pool`. + + Searching a pool skips pool construction, which is the dominant cost of a + `recommend` call. The pool holds the candidate set, the search context and + the resolved card details, so it occupies memory proportional to the + candidate count until it is released. + + It does not track the user data, masterdata or options it was built from. + If any of those change, discard the pool and build a new one. + """ + + __slots__ = ("_native",) + + def __init__(self, native) -> None: + self._native = native + + @property + def card_count(self) -> int: + """Number of candidate cards in the pool.""" + return self._native.card_count + + @property + def limit(self) -> int: + """The `limit` the pool was built with.""" + return self._native.limit + + @property + def timeout_ms(self) -> int: + """The `timeout_ms` the pool was built with.""" + return self._native.timeout_ms + + def recommend( + self, limit: int | None = None, timeout_ms: int | None = None + ) -> DeckRecommendResult: + """Search the pool. + + `limit` and `timeout_ms` affect only the search stage and may be + overridden per call. Every other option is fixed at build time; changing + one requires building a new pool. + """ + payload = self._native.recommend(limit, timeout_ms) + return DeckRecommendResult.from_dict(json.loads(payload)) + + __all__ = [ "DeckRecommendCardConfig", "DeckRecommendGaOptions", @@ -242,6 +309,7 @@ def calculate_exact_live( "DeckRecommendSaOptions", "DeckRecommendSingleCardConfig", "DeckRecommendUserData", + "PreparedCardPool", "RecommendCard", "RecommendDeck", "RecommendSupportDeckCard", diff --git a/python/sekai_deck_recommend_cpp/__init__.pyi b/python/sekai_deck_recommend_cpp/__init__.pyi index 4c76166..2c3f46c 100644 --- a/python/sekai_deck_recommend_cpp/__init__.pyi +++ b/python/sekai_deck_recommend_cpp/__init__.pyi @@ -181,6 +181,7 @@ class SekaiDeckRecommend: def update_musicmetas(self, file_path: str, region: str) -> None: ... def update_musicmetas_from_string(self, data: Union[str, bytes], region: str) -> None: ... def recommend(self, options: DeckRecommendOptions) -> DeckRecommendResult: ... + def build_pool(self, options: DeckRecommendOptions) -> PreparedCardPool: ... def recommend_batch( self, options_list: List[DeckRecommendOptions] ) -> List[DeckRecommendResult]: ... @@ -204,4 +205,15 @@ class SekaiDeckRecommend: fever_music_score_json: Optional[str] = None, ) -> Dict[str, Any]: ... +class PreparedCardPool: + @property + def card_count(self) -> int: ... + @property + def limit(self) -> int: ... + @property + def timeout_ms(self) -> int: ... + def recommend( + self, limit: int | None = None, timeout_ms: int | None = None + ) -> DeckRecommendResult: ... + def set_engine_thread_count(threads: int) -> None: ... diff --git a/src/lib.rs b/src/lib.rs index ff7f876..5859ea7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -139,6 +139,49 @@ impl NativeEngine { .map_err(PyRuntimeError::new_err) } + /// 构建可复用的搜索池。付一次建池成本,之后每次 `NativeCardPool.recommend` + /// 只付搜索成本。池不随 masterdata 或 user_data 的后续变化自动失效, + /// 何时重建由调用方负责。 + fn build_pool( + &self, + py: Python<'_>, + region: &str, + options_json: &str, + user_data: &NativeUserData, + ) -> PyResult { + let game = self + .regions + .read() + .map_err(lock_error)? + .get(region) + .map(|data| Arc::clone(&data.game)) + .ok_or_else(|| { + PyRuntimeError::new_err(format!("masterdata for region {region} is not loaded")) + })?; + let params = parse_build_params_json(options_json) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + let user = Arc::clone(&user_data.profile); + py.allow_threads(move || -> Result { + let owned: &OwnedGameData = &game; + let game_ref = owned.as_ref(); + let (pool, ctx, details) = build_card_pool_with_details(&user, &game_ref, ¶ms) + .map_err(|error| error.to_string())?; + let cultivated = cultivated_cards_by_id(&user, owned, ¶ms); + Ok(NativeCardPool { + pool, + ctx, + details, + cultivated, + user: Arc::clone(&user), + target: params.target, + target_bonus_list: params.target_bonus_list, + limit: params.limit, + timeout_ms: params.timeout_ms, + }) + }) + .map_err(PyRuntimeError::new_err) + } + fn get_world_bloom_support_cards( &self, py: Python<'_>, @@ -509,6 +552,72 @@ fn build_game( OwnedGameData::from_sources(&sources) } +/// 预构建的搜索池:构建一次,可重复搜索。 +/// +/// 池与 `(user_data, masterdata, options)` 三者绑定。任意一项变化后必须重建, +/// 否则搜索会基于过期数据得出错误结果——本类型不会自动侦测这些变化。 +/// 缓存策略与生命周期完全由调用方决定;持有池会占用额外内存。 +#[pyclass] +struct NativeCardPool { + pool: CardPool, + ctx: SearchContext, + details: Vec, + cultivated: HashMap, + user: Arc, + target: ScoreTarget, + target_bonus_list: Vec, + limit: usize, + timeout_ms: u64, +} + +#[pymethods] +impl NativeCardPool { + /// 池内候选卡数量。 + #[getter] + fn card_count(&self) -> usize { + self.pool.count() + } + + /// 构建该池时使用的 `limit`。 + #[getter] + fn limit(&self) -> usize { + self.limit + } + + /// 构建该池时使用的 `timeout_ms`。 + #[getter] + fn timeout_ms(&self) -> u64 { + self.timeout_ms + } + + /// 在池上搜索。`limit` 与 `timeout_ms` 只作用于搜索阶段,可安全覆盖; + /// 其余选项已固化在池中,需要改动就得重建池。 + #[pyo3(signature = (limit=None, timeout_ms=None))] + fn recommend( + &self, + py: Python<'_>, + limit: Option, + timeout_ms: Option, + ) -> PyResult { + let top_k = limit.unwrap_or(self.limit); + let deadline = timeout_ms.unwrap_or(self.timeout_ms); + py.allow_threads(|| { + search_and_materialize( + &self.pool, + &self.ctx, + &self.details, + &self.cultivated, + &self.user, + self.target, + &self.target_bonus_list, + top_k, + deadline, + ) + }) + .map_err(PyRuntimeError::new_err) + } +} + fn recommend_json( user: &UserProfile, owned: &OwnedGameData, @@ -517,39 +626,63 @@ fn recommend_json( let game = owned.as_ref(); let (pool, ctx, details) = build_card_pool_with_details(user, &game, params).map_err(|error| error.to_string())?; - let search_params = SearchParams { - top_k: params.limit, - timeout_ms: params.timeout_ms, - }; + let cultivated = cultivated_cards_by_id(user, owned, params); + search_and_materialize( + &pool, + &ctx, + &details, + &cultivated, + user, + params.target, + ¶ms.target_bonus_list, + params.limit, + params.timeout_ms, + ) +} + +fn cultivated_cards_by_id( + user: &UserProfile, + owned: &OwnedGameData, + params: &allium_deck::handler::BuildParams, +) -> HashMap { + cultivated_user_cards(user, &owned.as_ref(), params) + .into_iter() + .map(|card| (card.card_id, card)) + .collect() +} + +/// 在已构建的池上搜索并渲染结果。`recommend_json` 与预构建池共用此路径, +/// 保证两条入口的输出完全一致。 +#[allow(clippy::too_many_arguments)] +fn search_and_materialize( + pool: &CardPool, + ctx: &SearchContext, + details: &[FullPrecisionCard], + cultivated: &HashMap, + user: &UserProfile, + target: ScoreTarget, + target_bonus_list: &[i32], + top_k: usize, + timeout_ms: u64, +) -> Result { + let search_params = SearchParams { top_k, timeout_ms }; let search_started = Instant::now(); - let results = if params.target_bonus_list.is_empty() { - search(&pool, &ctx, &search_params) + let results = if target_bonus_list.is_empty() { + search(pool, ctx, &search_params) } else { - search_bonus_targets(&pool, &ctx, &search_params, ¶ms.target_bonus_list).0 + search_bonus_targets(pool, ctx, &search_params, target_bonus_list).0 }; let cost_ms = search_started.elapsed().as_secs_f64() * 1000.0; - if results.is_empty() && params.target_bonus_list.is_empty() { + if results.is_empty() && target_bonus_list.is_empty() { return Err(format!( "Cannot recommend any deck in {} cards", user.user_cards.len() )); } - let cultivated = cultivated_user_cards(user, &game, params) - .into_iter() - .map(|card| (card.card_id, card)) - .collect::>(); let decks = results .iter() .filter_map(|result| { - materialize_deck( - result.cards, - &pool, - &ctx, - &details, - &cultivated, - user, - params.target, - ) + materialize_deck(result.cards, pool, ctx, details, cultivated, user, target) }) .collect::>(); serde_json::to_string(&json!({ @@ -756,6 +889,7 @@ fn lock_error(error: std::sync::PoisonError) -> PyErr { fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add_class::()?; + module.add_class::()?; Ok(()) } diff --git a/tests/test_lunabot_contract.py b/tests/test_lunabot_contract.py index cd3ba91..dbd4f5e 100644 --- a/tests/test_lunabot_contract.py +++ b/tests/test_lunabot_contract.py @@ -30,6 +30,7 @@ def test_public_module_exports_the_complete_deck_api(): "DeckRecommendSaOptions", "DeckRecommendSingleCardConfig", "DeckRecommendUserData", + "PreparedCardPool", "RecommendCard", "RecommendDeck", "RecommendSupportDeckCard", diff --git a/tests/test_pool_reuse.py b/tests/test_pool_reuse.py new file mode 100644 index 0000000..b30d203 --- /dev/null +++ b/tests/test_pool_reuse.py @@ -0,0 +1,107 @@ +import os +from pathlib import Path + +import pytest +from sekai_deck_recommend_cpp import ( + DeckRecommendOptions, + DeckRecommendResult, + DeckRecommendUserData, + PreparedCardPool, + SekaiDeckRecommend, +) + + +def _fixture_paths(): + keys = ("ALLIUM_MASTERDATA", "ALLIUM_MUSIC_METAS", "ALLIUM_USER_DATA") + if not all(os.environ.get(key) for key in keys): + pytest.skip("native integration fixture paths are not configured") + return tuple(Path(os.environ[key]) for key in keys) + + +def _engine_and_options(): + masterdata, music_metas, user_path = _fixture_paths() + engine = SekaiDeckRecommend() + engine.update_masterdata(str(masterdata), "cn") + engine.update_musicmetas(str(music_metas), "cn") + + user = DeckRecommendUserData() + user.load_from_file(str(user_path)) + options = DeckRecommendOptions() + options.region = "cn" + options.algorithm = "dfs" + options.user_data = user + options.live_type = "multi" + options.event_id = 133 + options.music_id = 1 + options.music_diff = "master" + options.target = "score" + options.limit = 1 + return engine, options + + +def test_pool_search_matches_direct_recommend(): + engine, options = _engine_and_options() + direct = engine.recommend(options) + pool = engine.build_pool(options) + + assert isinstance(pool, PreparedCardPool) + assert pool.card_count > 0 + assert pool.limit == options.limit + + via_pool = pool.recommend() + assert isinstance(via_pool, DeckRecommendResult) + assert len(via_pool.decks) == len(direct.decks) + + expected, actual = direct.decks[0], via_pool.decks[0] + assert actual.score == expected.score + assert actual.total_power == expected.total_power + assert actual.event_bonus_rate == expected.event_bonus_rate + assert [card.card_id for card in actual.cards] == [ + card.card_id for card in expected.cards + ] + + +def test_pool_is_reusable_across_searches(): + engine, options = _engine_and_options() + pool = engine.build_pool(options) + first = pool.recommend() + second = pool.recommend() + assert [card.card_id for card in first.decks[0].cards] == [ + card.card_id for card in second.decks[0].cards + ] + assert first.decks[0].score == second.decks[0].score + + +def test_pool_search_stage_options_are_overridable(): + engine, options = _engine_and_options() + options.limit = 1 + pool = engine.build_pool(options) + assert len(pool.recommend().decks) == 1 + + widened = pool.recommend(limit=3) + assert 1 < len(widened.decks) <= 3 + + options.limit = 3 + direct = engine.recommend(options) + assert [card.card_id for card in widened.decks[0].cards] == [ + card.card_id for card in direct.decks[0].cards + ] + + +def test_pool_rejects_unknown_region(): + masterdata, music_metas, user_path = _fixture_paths() + engine = SekaiDeckRecommend() + engine.update_masterdata(str(masterdata), "cn") + engine.update_musicmetas(str(music_metas), "cn") + user = DeckRecommendUserData() + user.load_from_file(str(user_path)) + options = DeckRecommendOptions() + options.region = "jp" + options.user_data = user + options.live_type = "multi" + options.event_id = 133 + options.music_id = 1 + options.music_diff = "master" + options.target = "score" + with pytest.raises(RuntimeError): + engine.build_pool(options)