diff --git a/lib/Projectrans/Phase2Translator.py b/lib/Projectrans/Phase2Translator.py index e41e5b6..5b796dd 100644 --- a/lib/Projectrans/Phase2Translator.py +++ b/lib/Projectrans/Phase2Translator.py @@ -222,6 +222,142 @@ _parallel_translate_worker = _ProfileWorker(_parallel_translate_worker) _parallel_translate_batch = _ProfileWorker(_parallel_translate_batch) +# ================================================================== +# worker pyi 缓存预热:ProcessPoolExecutor(spawn) 下每个 worker 重新 +# import 模块后,HandlesImports.ImportHandle 的类级 _pyi_cache 全空, +# 每进程首次任务重新 vast.parse 全部依赖 .vpi/.pyi +# (cProfile: 4479 次 parse / 245s)。 +# 方案:主进程把 temp 下全部 .vpi 的 vast AST 树 pickle 落盘 +# (temp/_pyi_cache_v1.pkl,任务 B),worker initializer 启动时 +# pickle.load 一次填充 _pyi_cache(任务 A);load 失败回退逐文件 +# vast.parse。文件系统共享即为"共用缓存",不做跨进程共享内存(任务 C)。 +# 环境变量 TPC_WORKER_WARMUP=0 可关闭(回归对比基线)。 +# ================================================================== +_PYI_CACHE_PKL_NAME: str = '_pyi_cache_v1.pkl' +_PYI_CACHE_SIG_NAME: str = '_pyi_cache_v1.sig.json' +_PYI_CACHE_VERSION: int = 1 + + +def _ScanPyiFiles(temp_dir: str) -> list[str]: + """扫描 temp 目录下全部 .vpi/.pyi 文件,返回绝对路径列表(与 + _TryLoadClassMembersFromPyi 的 _pyi_cache 键格式一致)。""" + try: + names: list[str] = os.listdir(temp_dir) + except OSError: + return [] + paths: list[str] = [] + for fname in sorted(names): + if fname.startswith('_'): + continue + if fname.endswith('.vpi') or fname.endswith('.pyi'): + paths.append(os.path.join(temp_dir, fname)) + return paths + + +def _PyiCacheSignature(paths: list[str]) -> list[tuple[str, int, float]]: + """vpi 清单签名:(basename, size, mtime) 元组列表,用于缓存失效判断。""" + sig: list[tuple[str, int, float]] = [] + for p in paths: + try: + st = os.stat(p) + sig.append((os.path.basename(p), st.st_size, st.st_mtime)) + except OSError: + sig.append((os.path.basename(p), -1, -1.0)) + return sig + + +def _LoadPyiCachePkl(temp_dir: str) -> dict | None: + """pickle.load temp/_pyi_cache_v1.pkl,返回 {abs_path: vast树|None}。 + 文件不存在/损坏/版本不符时返回 None(调用方回退逐文件解析)。""" + pkl_path: str = os.path.join(temp_dir, _PYI_CACHE_PKL_NAME) + try: + with open(pkl_path, 'rb') as f: + data: dict = pickle.load(f) + if not isinstance(data, dict) or data.get('version') != _PYI_CACHE_VERSION: + return None + cache = data.get('cache') + if not isinstance(cache, dict): + return None + return cache + except Exception: + return None + + +def _BuildPyiCacheFile(temp_dir: str) -> str | None: + """构建/刷新 temp/_pyi_cache_v1.pkl:遍历全部 .vpi 做 vast.parse 后 + pickle.dump(含版本号与签名)。签名(basename,size,mtime)未变时跳过。 + 返回 pkl 路径;失败(无 vpi / pickle 不兼容等)返回 None。 + 注意:vast AST 可 pickle 性由 Nodes.py 侧保证;若实际 pickle.dump + 失败,此处静默回退,worker 的 initializer 将改为逐文件 vast.parse。""" + paths: list[str] = _ScanPyiFiles(temp_dir) + if not paths: + return None + sig: list[tuple[str, int, float]] = _PyiCacheSignature(paths) + pkl_path: str = os.path.join(temp_dir, _PYI_CACHE_PKL_NAME) + sig_path: str = os.path.join(temp_dir, _PYI_CACHE_SIG_NAME) + try: + if os.path.isfile(pkl_path) and os.path.isfile(sig_path): + with open(sig_path, 'r', encoding='utf-8') as f: + stored = json.load(f) + # json 往返后 tuple→list,需规范化再比较 + if stored == [[n, s, m] for n, s, m in sig]: + return pkl_path + except Exception: + pass + cache: dict[str, object] = {} + for p in paths: + try: + with open(p, 'r', encoding='utf-8') as f: + cache[p] = ast.parse(f.read()) + except Exception: + cache[p] = None + try: + with open(pkl_path, 'wb') as f: + pickle.dump({'version': _PYI_CACHE_VERSION, 'signature': sig, 'cache': cache}, f) + with open(sig_path, 'w', encoding='utf-8') as f: + json.dump(sig, f) + return pkl_path + except Exception as e: + _vlog().warning(f"Phase2Translator: pyi 缓存落盘失败({e}),worker 将回退逐文件解析") + return None + + +def _PyiCacheWorkerInitializer(temp_dir: str) -> None: + """worker 进程启动时预热 ImportHandle._pyi_cache(类级缓存)。 + + spawn 下每 worker 重新 import 模块后 _pyi_cache 为空,首次任务会 + 重新 vast.parse 全部依赖 .vpi(4479 次 parse / 245s)。此处启动时 + 一次性填充:优先 pickle.load 主进程落盘的 _pyi_cache_v1.pkl; + 失败(文件不存在/损坏/版本不符/pickle 不兼容)回退逐文件 vast.parse。 + 预热只解析 .vpi(pyi 树小);stub 索引保留 _build_stub_index 懒加载。 + TPC_WORKER_WARMUP=0 时关闭(便于回归对比)。 + """ + if os.environ.get('TPC_WORKER_WARMUP', '1') != '1': + return + if not os.path.isdir(temp_dir): + return + from lib.core.Handles.HandlesImports import ImportHandle + cache: dict | None = _LoadPyiCachePkl(temp_dir) + if cache is not None: + ImportHandle._pyi_cache = cache + ImportHandle._pyi_cache_dir = os.path.abspath(temp_dir) + return + # 回退:逐文件 vast.parse 预热(worker 单线程,无竞争) + try: + new_cache: dict[str, object] = {} + for p in _ScanPyiFiles(temp_dir): + try: + with open(p, 'r', encoding='utf-8') as f: + new_cache[p] = ast.parse(f.read()) + except Exception: + new_cache[p] = None + if new_cache: + ImportHandle._pyi_cache = new_cache + ImportHandle._pyi_cache_dir = os.path.abspath(temp_dir) + except Exception: + pass + + def _count_braces_outside_strings(text: str) -> int: """统计花括号差值,跳过 c"..." 字符串字面量内部的括号""" count = 0 @@ -377,6 +513,12 @@ class Phase2Translator: if sha1 in self.sha1_map and sha1 not in self.stub_files: self.stub_files[sha1] = os.path.join(_GLOBAL_CACHE_DIR, fname) + # 构建 pyi AST pickle 缓存(temp/_pyi_cache_v1.pkl)供 worker 预热: + # Phase1 已生成全部 .vpi,此处把 vast 树落盘,避免每个 worker 首次任务 + # 重新 vast.parse(4479 次 parse / 245s)。签名未变时零开销跳过。 + if os.environ.get('TPC_WORKER_WARMUP', '1') == '1': + _BuildPyiCacheFile(self.temp_dir) + def _build_struct_sha1_map(self) -> dict[str, str]: struct_sha1_map = {} valid_sha1_keys = set(self.sha1_map.keys()) @@ -571,7 +713,11 @@ class Phase2Translator: # 复用 self._executor_pool:避免局部变量 GC 时 finalizer 阻塞 ~99s # (join_executor_internals 在 GC 时同步等待 worker 退出队列) if self._executor_pool is None: - self._executor_pool = ProcessPoolExecutor(max_workers=n_workers) + # initializer 预热 ImportHandle._pyi_cache(pickle.load 落盘缓存), + # 避免每个 worker 首次任务重新 vast.parse 全部依赖 .vpi + self._executor_pool = ProcessPoolExecutor(max_workers=n_workers, + initializer=_PyiCacheWorkerInitializer, + initargs=(self.temp_dir,)) executor = self._executor_pool try: # 批次子任务模型:多个文件包装为一个子任务(batch), @@ -2312,7 +2458,13 @@ class Phase2Translator: } # 复用 self._executor_pool(与 App 源文件阶段共享),避免 GC 阻塞 if self._executor_pool is None: - self._executor_pool = ProcessPoolExecutor(max_workers=n_workers) + # 新池:刷新 pyi pickle 缓存,把 _register_compiled_include 新增的 + # include .vpi 一并落盘,使 worker 预热包含它们(签名未变时零开销) + if os.environ.get('TPC_WORKER_WARMUP', '1') == '1': + _BuildPyiCacheFile(self.temp_dir) + self._executor_pool = ProcessPoolExecutor(max_workers=n_workers, + initializer=_PyiCacheWorkerInitializer, + initargs=(self.temp_dir,)) executor = self._executor_pool try: completed_paths: set[str] = set()