Skip to content

perf(jit): cache GCC artifacts and make generated C reproducible - #25

Closed
esrrhs wants to merge 1 commit into
masterfrom
fix/jit-compile-cache-and-reproducible-codegen
Closed

esrrhs wants to merge 1 commit into
masterfrom
fix/jit-compile-cache-and-reproducible-codegen

Conversation

@esrrhs

@esrrhs esrrhs commented Oct 4, 2026

Copy link
Copy Markdown
Owner

问题

macOS 上单个 gtest 用例耗时 30 秒以上(应毫秒级),全量测试在本地实际跑不完。CI 三个 workflow 都是直接跑 unit_tests 二进制且无超时保护,所以问题一直只表现为"变慢"而非失败。

实测定位(4 个不同 dylib 路径):

场景 耗时
裸 gcc -O3 -dynamiclib 0.06 s
首次 dlopen 某新路径 dylib 4521 / 4504 / 5437 / 4608 ms
后续 dlopen 同一路径 0.2 ms

每个新路径的首次加载都要付约 4.5–5.4 秒走 macOS 代码签名验证(amfid / syspolicyd IPC),差值达 25000 倍。

排查中排除了两个假设,避免后来者重走:

  • ad-hoc 签名无效:codesign -s - 实测 6312/5106/5222 ms,-Wl,-adhoc_codesign 4852/4753/4672 ms,都不比基线好。
  • fork+dlopen 组合本身有问题:写隔离复现跑 15 轮全部秒过。真正定位靠"同路径 vs 新路径"的对照测量。

根因

生成的 C 代码里有两处随运行变化的进程级状态,导致同一输入编译出不同字节 —— 既破坏可复现构建,也让按内容缓存产物永远无法命中:

  1. State 指针被硬编码进产物:static void * _S = (void *) 0x1014b2aa0;,地址受 ASLR 影响。更严重的是产物一旦被跨 State 复用,_S 会指向别的 State。
  2. 常量字符串 ID 被内联进产物:FlSetTableStrId(ops, 35471392768, ...),该 ID 来自进程级自增计数器。

改动

  • _S 初值置 0,由宿主在加载产物后、调用 __fakelua_init 之前通过新导出的 __fakelua_set_state 注入。GCC 与 TCC 两条路径都注入。
  • 常量字符串 ID 不再内联,改为在 __fakelua_const_init 里经 FakeluaConstStrAlloc 注册后存入产物内的静态变量,代码引用变量。
    • 含 \0 或非文本字节的字符串(如 string.charpattern 里的 [\0-\255])自动改用长度 + 字节数组形式,避免 C 字符串字面量截断。
    • 依赖常量字符串 ID 的文件级全局变量改为零初始化并在 __fakelua_const_init 里赋值 —— 静态变量不是编译期常量,不能出现在静态初始化器中。
  • 新增按「生成的 C 代码 + 全部编译参数」摘要(xxhash-64)的产物缓存,命中时复用同一路径从而命中 dyld 的路径缓存。写入走「临时文件 + rename」保证原子。可用 StateGCCConfig::enable_compile_cache = false 关闭。
  • 缓存命中与正常编译两条路径共用 GccJitter::RegisterAndInit,避免行为漂移。条目失效时删除并退回重新编译,不让缓存成为故障源。

效果(macOS, Release)

范围 修复前 修复后(热缓存)
closure 套件(11 tests) 487 s 1.1 s
17 套件 1012 tests(含 jitter 差分) 29 m 12 s 94 s

副作用:exception 套件因编译去重从 36 s 降到 7.2 s。

测试:1012 个测试全部通过(含 254 个 .lua 差分语料驱动的 jitter 套件)。冷启动与热缓存各跑一遍,均全绿。

已知取舍

  • 缓存目录 $TMPDIR/fakelua-jit-cache 不自动清理,需手动删除。刻意不放在 $TMPDIR/fakelua/ 下 —— 该目录会被测试 runtime.generate_tmp_filename_creates_dir 用 remove_all 清掉。
  • 冷启动(首次或缓存清空后)仍需完整编译时间。

顺带修正文档

README 里的 ctest --test-dir build -V 实际不生效 —— 根 CTestTestfile.cmake 缺 subdirs("test"),执行结果是 No tests were found!!!。已改为直接运行二进制,并说明需显式开 -DFAKELUA_BUILD_TESTS=ON(默认为 OFF)。

macOS 上首次 dlopen 一个新路径的 dylib 需要约 4.5~5.4 秒走代码签名验证
(amfid / syspolicyd IPC),而同一路径的后续加载只要约 0.2 毫秒。由于
GccJitter::Compile 每次都生成全新路径的临时 dylib,反复编译同一份 C 代码时
这笔固定开销成倍累积:单个 gtest 用例耗时 30 秒以上,全量测试在本地实际
无法跑完。CI 三个 workflow 都是直接跑 unit_tests 二进制且无超时保护,因此
问题一直只表现为"变慢"而非失败。

根因是生成的 C 代码里有两处随运行变化的进程级状态,导致同一输入编译出不同
字节,既破坏可复现构建,也让按内容缓存产物永远无法命中:

1. State 指针被硬编码进产物(static void * _S = (void *) <addr>;)。地址受
   ASLR 影响。更严重的是产物一旦被跨 State 复用,_S 会指向别的 State。
2. 常量字符串 ID 被内联进产物(FlSetTableStrId(ops, 35471392768, ...))。该 ID
   来自进程级自增计数器。

改为:

- _S 初值置 0,由宿主在加载产物后、调用 __fakelua_init 之前通过新导出的
  __fakelua_set_state 注入。GCC 与 TCC 两条路径都做注入。
- 常量字符串 ID 不再内联,改为在 __fakelua_const_init 里经
  FakeluaConstStrAlloc 注册后存入产物内的静态变量,代码引用变量。含 \0 或
  非文本字节的字符串(如 string.charpattern)自动改用长度 + 字节数组形式,
  避免 C 字符串字面量截断。依赖常量字符串 ID 的文件级全局变量改为零初始化
  并在 __fakelua_const_init 里赋值,因为静态变量不是编译期常量、不能出现在
  静态初始化器中。
- 新增按「生成的 C 代码 + 全部编译参数」摘要(xxhash-64)的产物缓存,命中时
  复用同一路径从而命中 dyld 的路径缓存。写入走「临时文件 + rename」保证原子。
  可通过 StateGCCConfig::enable_compile_cache = false 关闭。
- cache 命中与正常编译两条路径共用 GccJitter::RegisterAndInit,避免行为漂移。
  缓存条目失效时删除并退回重新编译,不让缓存成为故障源。

效果(macOS,Release):

- closure 套件(11 tests):487 s → 热缓存 1.1 s
- 17 个套件 1012 个测试(含 jitter 差分):29 m 12 s → 94 s
- 副作用:exception 套件因编译去重从 36 s 降到 7.2 s

已知取舍:缓存目录 $TMPDIR/fakelua-jit-cache 不自动清理,需手动删除。刻意不放在
$TMPDIR/fakelua/ 下 —— 该目录会被测试 runtime.generate_tmp_filename_creates_dir
用 remove_all 清掉。

另外修正文档:README 里的 ctest --test-dir build 实际不生效(根 CTestTestfile
缺 subdirs("test")),已改为直接运行二进制并说明需开 -DFAKELUA_BUILD_TESTS=ON。
@esrrhs

esrrhs commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

回滚:改动破坏了 State 隔离性,且与既有设计意图冲突。

原因

  1. State 隔离性被破坏(CI 实测失败)

    test_log.same_thread_states_isolated 验证两个 State 的日志互不干扰。我的改动让两个 State 复用同一个缓存产物,__fakelua_set_state 被后加载的 State 覆盖,导致 State a 的日志写进 State b 的文件。CI 日志可见 test_log.cpp:490 的 LOG_ERROR(a, ...) 之后紧跟着 cpp-error-B。

    这个测试只走 TCC 路径,说明 bug 出在给 TCC 加的注入逻辑上,不限于缓存。

  2. 与既有设计意图冲突

    生成的 C 代码把 State 指针与常量字符串 ID 内联,是为了让产物代码直接用立即数/地址,避免运行期查表。把它们改成运行期注入 + 静态变量查表,是在热路径上增加间接寻址 —— 性能上是回退。这部分我不该动。

我犯的错

我只在 macOS 上验证了 1012 个测试全绿就提交了,而这个 bug 只在 Linux CI 上暴露(macOS 的缓存命中路径行为不同)。基线对比也不充分:中途我用 git stash 只撤部分文件测出「46 失败」,那是代码处于不一致状态的假象,完整回退后 baseline 其实是 105 个 exception 测试全绿。

遗留

_S 内联 State 指针本身在原始设计下是正确的(每次编译生成新产物,不跨 State 复用),不是 bug。真正需要解决的是 macOS 上每次 dlopen 新路径 dylib 的 4.5~5.4 秒代码签名验证开销 —— 这需要不牺牲产物确定性的方案,我会另开 issue 讨论。

@esrrhs esrrhs closed this Oct 4, 2026
@esrrhs
esrrhs deleted the fix/jit-compile-cache-and-reproducible-codegen branch October 4, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant