perf(Phase2Translator): 文本读缓存改按路径直存(零 stat),正确性靠写/删点显式失效

- _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 个模块却上万次读,同一份产物被多阶段反复打开)。
This commit is contained in:
TermiNexus committed 2026-09-30 23:15:22 +08:00
1 parent 1748940d30
commit e292e2390d
1 file changed
+35 -35
+35 -35
View File
@@ -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)} 个过时文件")