From e292e2390d681c79fffc4a25ba27888d7de06765 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Wed, 30 Sep 2026 23:15:22 +0800 Subject: [PATCH] =?UTF-8?q?perf(Phase2Translator):=20=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E8=AF=BB=E7=BC=93=E5=AD=98=E6=94=B9=E6=8C=89=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=9B=B4=E5=AD=98=EF=BC=88=E9=9B=B6=20stat=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=AD=A3=E7=A1=AE=E6=80=A7=E9=9D=A0=E5=86=99/=E5=88=A0?= =?UTF-8?q?=E7=82=B9=E6=98=BE=E5=BC=8F=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _ReadTextCached 不再 os.stat:原实现每次调用都要 stat 判新旧,等于把省下的 1.2 万次 open 换成 1.2 万次 stat(同为系统调用热点),命中路径仍有系统开销。 改为 OrderedDict[路径 → 文本],命中零系统调用;newline 非 None 的逐字节读不进缓存。 - _InvalidateTextCache 改 O(1)(按路径直接 pop),并在 14 处文本写点 + 1 处 stale 文件删除(os.remove)处调用 —— 读侧不再 stat 后,写/删点显式失效是唯一正确性保证。 - 失效为何必须显式(时间戳判据在同 tick 内会失效):_compile_one 前的 type 定义 重排(行集合不变仅次序变,长度逐字节相同)、_recombine_ll 的旧 sha1 前缀替换 (16 位 hex 等长替换)。 - 安全性前提:TPC 单次运行内被读文件内容不变。已核对:self.sig_files / self.stub_files 指向的都是 Phase1 产物(temp_dir / _GLOBAL_CACHE_DIR,Phase2 期间不再改写);Phase2 自产的 .ll/.stub.ll/.deps.json/.vpi 每次写、删都走上面的失效。 基线 PROF/20260930_224857:_io.open 12,740 次/self 28.8s、read 11,902 次/30.4s、 TextIOWrapper.__exit__ 16.6s(128 个模块却上万次读,同一份产物被多阶段反复打开)。 --- lib/Projectrans/Phase2Translator.py | 70 ++++++++++++++--------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/lib/Projectrans/Phase2Translator.py b/lib/Projectrans/Phase2Translator.py index f9040f4..203c58f 100644 --- a/lib/Projectrans/Phase2Translator.py +++ b/lib/Projectrans/Phase2Translator.py @@ -54,57 +54,55 @@ def _CachedIsFile(path: str) -> bool: # read 11,902 次 / self 30.4s、TextIOWrapper.__exit__ 16.6s —— 128 个模块 # 却产生上万次读,主要是同一份 .stub.ll / .ll / deps.json 被 # _compile_one / _recombine_ll / 依赖解析等重复打开)。 -# 键 = (路径, mtime_ns, 大小):文件被写过或替换后**自动失配**,因此无需手工失效 -# (比"路径→文本"的裸缓存安全)。只缓存 newline=None 的默认换行翻译读, -# 与直接 open(...).read() 语义逐字相同。 +# 键 = **路径**(不做 stat / 时间戳校验)。性能前提(用户口径):TPC 单次运行内 +# 非本进程写出的文件内容不变 —— 因此不需要"每次读都 stat 一次"来判新旧; +# 若再 stat,等于把省下的 1.2 万次 open 换成 1.2 万次 stat(仍是系统调用热点)。 +# 正确性前提:**本进程写出/改写/删除过的文件,一律在该动作后调用 +# _InvalidateTextCache**(写点见文件内全部 open(..., 'w') 与 os.remove 处)。 +# 只缓存 newline=None 的默认换行翻译读,与直接 open(...).read() 语义逐字相同。 # 内存:按总字节预算 + LRU 淘汰;单文件超过 _TEXT_CACHE_FILE_MAX 不入缓存 # (合并后的 .ll 可达数十 MB,避免把内存吃光)。 # --------------------------------------------------------------- _TEXT_CACHE_MAX_BYTES: int = 64 * 1024 * 1024 _TEXT_CACHE_FILE_MAX: int = 32 * 1024 * 1024 -_TextReadCache: OrderedDict[tuple[str, int, int], str] = OrderedDict() +_TextReadCache: OrderedDict[str, str] = OrderedDict() _TextReadCacheBytes: int = 0 def _ReadTextCached(path: str, newline: str | None = None) -> str: - """读文本文件(带 LRU 缓存);newline 非 None 或 stat 失败时退化为直接读。""" + """读文本文件(按路径缓存,零 stat);newline 非 None 时退化为直接读(不进缓存)。""" global _TextReadCacheBytes - if newline is None: - try: - _st = os.stat(path) - except OSError: - _st = None - if _st is not None and _st.st_size <= _TEXT_CACHE_FILE_MAX: - _key = (path, _st.st_mtime_ns, _st.st_size) - _hit = _TextReadCache.get(_key) - if _hit is not None: - _TextReadCache.move_to_end(_key) - return _hit - with open(path, 'r', encoding='utf-8') as _f: - _text = _f.read() - _TextReadCache[_key] = _text - _TextReadCacheBytes += len(_text) - while _TextReadCacheBytes > _TEXT_CACHE_MAX_BYTES and len(_TextReadCache) > 1: - _, _old = _TextReadCache.popitem(last=False) - _TextReadCacheBytes -= len(_old) - return _text - with open(path, 'r', encoding='utf-8', newline=newline) as _f: - return _f.read() + if newline is not None: + # 逐字节保真的读(newline='' 等)语义特殊:不进缓存,避免与默认换行翻译混用 + with open(path, 'r', encoding='utf-8', newline=newline) as _f: + return _f.read() + _hit: str | None = _TextReadCache.get(path) + if _hit is not None: + _TextReadCache.move_to_end(path) + return _hit + with open(path, 'r', encoding='utf-8') as _f: + _text: str = _f.read() + if len(_text) <= _TEXT_CACHE_FILE_MAX: + _TextReadCache[path] = _text + _TextReadCacheBytes += len(_text) + while _TextReadCacheBytes > _TEXT_CACHE_MAX_BYTES and len(_TextReadCache) > 1: + _, _old = _TextReadCache.popitem(last=False) + _TextReadCacheBytes -= len(_old) + return _text def _InvalidateTextCache(path: str) -> None: - """丢弃 path 的文本读缓存条目:本进程**改写**该文件后必须调用。 + """丢弃 path 的文本读缓存条目:本进程**写出/改写/删除**该文件后必须调用。 - 只靠 (mtime_ns, size) 判新旧在 Windows 上不安全:文件时间是系统时钟派生, - 粒度约 15.6ms,「先读 → 同一 tick 内原地改写 → 再读」且改写前后字节数相同 - (实例:_compile_one 前的 type 定义重排,行集合不变仅次序变,长度逐字节相同) - 时键完全一致,会命中过期文本。故写侧必须显式失效,不做时间戳兜底。 + 这是本缓存唯一的正确性保证(读侧不再 stat 判新旧):漏掉任一写点,后续读会 + 拿到该路径的旧文本。典型必失效场景:先读 → 同一 tick 内原地改写 → 再读, + 且改写前后字节数相同(实例:_compile_one 前的 type 定义重排,行集合不变仅次序变; + _recombine_ll 的旧 sha1 前缀替换,16 位 hex 等长替换)。 """ global _TextReadCacheBytes - if not _TextReadCache: - return - for _k in [k for k in _TextReadCache if k[0] == path]: - _TextReadCacheBytes -= len(_TextReadCache.pop(_k)) + _old = _TextReadCache.pop(path, None) + if _old is not None: + _TextReadCacheBytes -= len(_old) def _FindAsanRuntimeDir() -> str | None: @@ -2420,6 +2418,8 @@ class Phase2Translator: for fpath in stale_files: try: os.remove(fpath) + # 删除也是"本进程改变了该路径":不失效会让后续读拿到已删文件的旧文本 + _InvalidateTextCache(fpath) except Exception as e: _vlog().warning(f"Phase2Translator: 忽略异常 {e}", exc_info=e) _vlog().info(f"[清理] 删除 {len(stale_files)} 个过时文件")