主题: 把"每个请求都从零重建结果"这条热路径掐掉。 动机: 上一轮 Q4 审计给性能方向排了队,但排错了对象。这一版先 用测量推翻审计结论,再修真正的瓶颈。
Q4 审计写的是:
5 万张以上的 run,占位符会全部留在 DOM 里,内存与节点数不可控。
实测不成立。 用 20,000 行合成 run 量到的是:
| 指标 | 审计预期 | 实测 |
|---|---|---|
| 占位符节点 | ~50,000(全量) | 700 |
| DOM 总节点 | 不可控 | 7,362 |
| JS 堆 | 不可控 | 30 MB |
v2.18 的分片水合 + v2.24/v2.26 的窗口化去物化已经把 DOM 兜住了。 审计那条是纸面推演,没有量过。照它去改,会花一整版改一个不存在的问题。
真瓶颈在服务端,不在浏览器。
_build_results() 每次调用都完整重做一遍:
- pandas 重新解析整个
scores.csv; - 重新合并
annotations.jsonl; - 逐行重新推导 6 轴 rubric + advice 文案。
而一次页面加载会调它 5 次以上(页面本体 + 每个 /results_rows
水合分片各一次)。20,000 行 run 上量到:
| 路径 | v2.32.1 | v2.33 | 倍数 |
|---|---|---|---|
/results/<run> 首次(冷) |
6.2s | 7.7s¹ | — |
/results/<run> 之后(热) |
6.2s | 0.055s | ~140x |
/results_rows 每个水合分片 |
6.3s | 0.045s | ~140x |
400 张合成 run 走 HTTP 全链路复测:冷 1.04s → 热 0.023s。
¹ 冷启动没有变快也不该变快 —— CSV 总得解析一次。这一版消掉的是 第 2…N 次的重复劳动,而那才是 20k run 上真正卡住审片的部分。
原函数体原样改名 _build_results_uncached,外面套一层:
cache_key = (run_id, str(output_dir),
scores_path.stat().st_mtime_ns, ann_mtime)设计取舍:
- 键是输入文件的 mtime,不是显式失效调用。 重新打分或存一条标注
会自然改 mtime → 缓存自然失效。写入方不需要知道缓存存在,这正是
_JSONL_CACHE(_MtimeLRUCache)已经在用的纪律,不新造一套。 - 只键这两个文件,因为构建只读这两个文件。 用 AST 核过
_build_results_uncached的全部读取点:scores.csv+annotations.jsonl,没有第三个。run["vertical"]也读,但它在 run 创建时固定,全仓没有第二个写入点。 ann_mtime缺失记 0。 没有标注文件 ≡ 还没写过标注,与写之前是 同一个可观测状态,复用同一条缓存是正确的。output_dir进键。run_id只在单个 demo root 内唯一,测试会 重映射_DEMO_ROOT复用同名 run id;不带路径的键会串。- 构建在锁外。 这是数秒级的路径,持锁会把并发请求串成队列。两个 请求同时构建只是重复劳动,结果一致。
- 同一 run 的旧世代立刻清掉。 重新打分过的 run 否则会把自己好几份
多 MB 行表钉在缓存里等自然淘汰(
_MtimeLRUCache对它的单路径也是 这么做的)。
缓存把"看得见的慢"换成"看不见的错"。所以这一版真正花时间的是这三条:
(a) 缓存对象是按引用交出去的 → 任何调用方原地改写都会污染后续请求。
用 AST 追了 18 个调用点(含 for r in rows: / enumerate(rows) 的
循环变量别名),再追了所有接收 rows 的下游函数:
_build_face_clusters_info、_build_locations_info、
build_gallery_zip、generate_for_run、apply_style_guide —— 全部
只读。这条契约已写进函数上方的注释,后续改动要守住,或者改前先拷贝。
(b) 复合失效:_read_human_by_fn_cached 自己也是 mtime 缓存。
如果它的精度比我的粗,就会出现"我这层失效了、重建时又从它那儿拿到旧
标注"——然后把错的结果缓存起来。核过:它同样用 st_mtime_ns,同精度,
不存在这个窗口。
(c) 回归测试要有牙。 tests/test_results_cache.py(9 条)覆盖:热
命中不重建、存标注必失效、追加第二条标注再失效、重打分必失效、同名
run 跨 root 不串、缓存有界、旧世代不钉住、None 不入缓存。
做过变异验证:把键里的 ann_mtime 改成常量 0,恰好且仅有两条失效
测试变红 —— 证明它们不是空过。
验证失效时,我先在 owner 的真实照片标注 run(labelrun,121 张真
照片)上 POST 了一条 overall_label: cull 的测试标注。这正是
RESCORER-V3 那条教训里"伪造标签污染模型"的同一个坑 —— 真实标注必须
是 owner 的。已删除该条,并把后续验证全部改到合成 run 上。
另外,我一度据此误判"缓存失效失败了":真实观察到的是 decision 字段
没变。跟无缓存基线 A/B 后确认 decision 存的是规则栈原判定,人工判定
走 human_decided / n_human_decided,基线行为一致 —— 不是 bug。
结论:任何"这是 bug"的判断,先跟 stash 基线 A/B 再说。
- 门禁绿:1341 passed, 5 skipped(2 face fixture + 3 zeroconf,预期)。
- ruff:
serve_app.py162 → 162(未新增);新测试文件 all clean。 - 版本 2.32.1 → 2.33.0 lockstep(
pyproject.toml+pixcull/__init__.py)。 - 20k run 实测 + 400 张 HTTP 全链路实测,数字见上表。
- 近重复 O(N²)(20k=2.4s / 50k=13s):用时间窗剪枝,不是 ANN。 触发条件仍是真实的 20k+ 单 run 使用。
- 跑完自动入库(v2.32 计划里记的自然缺口):run 结束后自动
library index,目前仍需手动跑一次。 data-theme未设:/library、/history、/tether等独立页面 没有一个设data-theme,浅色主题在这些页从未生效(v2.29 之前就存在)。 要跨 5+ 个页面改,单独排一版。- ANN 压缩(int8/PQ):库超过约 30 万张再说。