LuaJIT 中的一个 NYI 操作悄然毒害了无关的热循环

Lobsters Hottest 新闻

摘要

本文探讨了 LuaJIT 的一个陷阱:诸如 unpack 之类的未实现(NYI)操作会悄然导致 trace 黑名单化,从而使基准测试性能降低 20 倍,并提供了在 CI 中防范此类问题的方法。

<p><a href="https://lobste.rs/s/qls9sm/luajit_nyi_silently_poisoned_unrelated">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/06 14:10

# 悄悄污染无关热循环的 LuaJIT NYI 问题 - StreamHPC Source: https://streamhpc.com/blog/2026-08-05/the-luajit-nyi-that-silently-poisoned-an-unrelated-hot-loop 如果你之前没用过 Lua(https://www.lua.org/about.html),它是 Factorio 和《魔兽世界》等游戏,以及 Neovim(https://github.com/neovim/neovim)和 OpenResty 等应用的首选嵌入式脚本语言。LuaJIT(https://luajit.org/luajit.html),也就是它的即时编译器(https://en.wikipedia.org/wiki/Just-in-time_compilation),以速度著称,所以人们很容易认为自己的代码已经跑得够快了。但实际上,你可能在不知不觉中因为踩中 LuaJIT 的某个 NYI 而严重拖慢性能。 NYI 全称是 “Not Yet Implemented”(尚未实现),指的是 LuaJIT 无法将其转换为优化机器码的操作。接下来会发生什么取决于具体的 NYI,而这正是本文要厘清的主题。 我是在对 grug-for-lua(https://github.com/grug-lang/grug-for-lua)(我用 Lua 实现的 grug(https://github.com/grug-lang/grug)模组语言)做基准测试时遇到这个问题的。同样的基准测试,完全相同的代码和输入,有时报告 60 亿次迭代,有时只有 3 亿次,**20 倍的差距**。罪魁祸首是代码某处一个看似无害的操作,它悄悄导致 LuaJIT 将某个函数拉黑,而一个无关的热循环后来又依赖这个函数。 这篇文章记录了整个排查过程。即使你从未接触过 LuaJIT 或编译器,也能读懂,因此凡是未完整展开的地方,我都附上了背景阅读链接。我们来看看两个与 NYI 相关的陷阱,以及如何让你的 CI(https://en.wikipedia.org/wiki/Continuous_integration)对其进行防护。 ## 可疑代码 所有游戏函数都通过 `Entity:_run_game_fn` 调用,它大致长这样: 所有游戏函数都通过`Entity:\_run\_game\_fn`调用,大致代码如下: 它被这样调用: 乍一看,一切都很正常。`pcall`代表“protected call”(受保护调用),类似于 try/catch:它调用`game\_fn`并捕获任何错误,而不是让错误向上传播。用`unpack`转发参数、用`\.\.\.`接收参数是常见的 Lua 惯用法,但在 LuaJIT 热循环中,这段完全合法的代码却触发了编译器最隐蔽的优化陷阱之一。 ## 追踪缝合 LuaJIT 是追踪式 JIT 编译器:随着程序运行,它会记录最热的执行路径(traces)并将其编译为优化机器码。它无法记录进这些追踪的特性列表记录在LuaJIT Not Yet Implemented wiki 页面(https://github.com/tarantool/tarantool/wiki/LuaJIT-Not-Yet-Implemented)上,而`unpack`就是其中之一,标记为`2.1 stitch`。它不会立即失败,而是执行一次*缝合*(stitch),让 LuaJIT 在 NYI 指令执行后恢复追踪记录。实现细节参见*LuaJIT Internals \(Pt. 2/3\): Fighting the JIT Compiler* (https://pwner.gg/blog/2022-09-13-lua-jit-part2#nyi-and-trace-stitching)一文中的NYI and Trace Stitching(https://pwner.gg/blog/2022-09-13-lua-jit-part2#nyi-and-trace-stitching)章节。 我最初的假设是,缝合本身因为暂时回退到解释器而导致了性能下降。但当我用`2.1.1774896198`版本的 LuaJIT 配合`\-jv`对基准测试进行性能剖析时,`unpack`压根没有出现在追踪日志中。 ## 阅读追踪日志 追踪日志讲述的是另一个故事。一条追踪在从被调用者返回后立即失败,随后是反复出现的“NYI: return to lower frame”消息,最终收到该函数已被拉黑、不再参与编译的通知。 为了隔离问题,我写了一个 MRE(最小可复现示例(https://en.wikipedia.org/wiki/Minimal_reproducible_example)): 运行`luajit \-jv pcall\_mre\.lua`会产生两种截然不同的结果,取决于碰巧的时机。下面是一次快速运行: `empty\_fn`最终是否被拉黑,取决于在基准循环开始前,有多少次通过`pcall`的缝合尝试已经完成——这个结果由 JIT 的内部启发式决定,与代码本身无关。在足够多次失败尝试之后,LuaJIT 会拉黑`empty\_fn`的字节码,因此调用它的热循环不再能被 JIT 编译,只能回退到解释器执行,尽管循环本身从未触及`pcall`或`unpack`。这就是它遭受**14 倍性能下降**的原因。作为对照,在热循环中调用一个相同的`empty\_fn2\(\)`则始终保持快速,因为它从未被拉黑。 ## 修复方案 `db94c5a`中的修复去掉了最后的`unpack\(\)`调用,改为生成包装函数: 不再通过`unpack`转发参数,而是按参数个数生成专门的包装函数,直接索引`args`表。包装函数按参数个数缓存,因此代码生成成本只支付一次,而执行过程仍可被 LuaJIT 完整追踪。`pcall`从来不是这里的问题;LuaJIT 可以完整追踪它。只有`unpack`会触发缝合,这也是为什么每个生成的包装函数仍然调用`pcall`以保持原有的错误处理语义。 你可以直接对比`db94c5a`与其父提交`4523ea9`来查看差异。在`benchmarks/minimal`中,`4523ea9`的运行结果在快慢之间剧烈波动,取决于是否触发了早前的拉黑。而使用`db94c5a`后,性能稳定,20 倍的波动消失了。不过,阻止这种静默的追踪拉黑只是完整优化热循环的第一步。 ## 第二个 NYI:闭包 在调查拉黑问题的过程中,我开始检查 grug-for-lua 中每一条热路径是否还有其他 NYI。这让我发现了第二个问题,由闭包(嵌套函数)触发,代价更加高昂。它的失败模式不同:不会污染无关循环,而是拖慢它所在的那个循环本身。乍一看,这段代码像是编译器应该优化掉的,类似于 C++ 的 as-if 规则(https://en.wikipedia.org/wiki/As-if_rule)允许对无可观察副作用的代码所做的优化。 这个例子值得单独写一个最小可复现示例,`closure\_mre\.lua`: 当`nested\(\)`在循环内部定义时,`luajit \-jv closure\_mre\.lua`产生如下追踪日志: 这是慢速运行:反复分配闭包阻碍了正常的 JIT 编译,与优化后的版本相比造成**60 倍性能下降**。 把`nested\(\)`移到循环外,追踪输出就完全变了: `nested`不接受参数、不读取上值、函数体什么都不做,但 LuaJIT 仍然保留 Lua 5.1 的语义。正如 Lua 5.1 参考手册(https://www.lua.org/manual/5.1/manual.html#2.5.2)所述,每个新的函数对象都不同于之前存在的任何对象: > 两个对象只有在是*同一个*对象时才相等。每次你创建新对象(表、userdata、线程或**函数**)时,这个新对象都不同于之前存在的任何对象。 理论上,LuaJIT 可以优化掉这次分配,就像它已经对表使用 Allocation Sinking 优化(https://github.com/tarantool/tarantool/wiki/LuaJIT-Allocation-Sinking-Optimization)所做的那样。 与`unpack`不同,`FNEW`(创建新函数)是硬性 NYI,而不是可缝合的 NYI。记录器一到达这条指令就直接中止记录。NYI wiki 也相应的将其(https://github.com/tarantool/tarantool/wiki/LuaJIT-Not-Yet-Implemented#bytecode)`Compiled?`状态`列`为`no`。这里没有输赢之争;循环每次都只能困在解释器中。唯一有效的改动是把`nested`移到循环外,使循环体变成普通的函数调用,没有每次迭代的分配开销,性能提升 60 倍。 ## 尽早发现 NYI 我把 CI 的build.yml(https://github.com/grug-lang/grug-for-lua/blob/ca3e88b438cb787c5081ed1e6b28596ed3140294/.github/workflows/build.yml#L125-L140)配置为在`luajit \-jv`下运行基准测试。如果追踪日志中出现除已知偶发的`return to lower frame`之外的任何 NYI,或者出现任何`blacklisted`行,构建就会失败。这样,任何被引入热路径的 NYI 或拉黑都会被自动捕获。 ## 让 unpack() 从 NYI 列表中消失 为了向 Cloudflare 的 LuaJIT Hacking: Getting next() out of the NYI list(https://blog.cloudflare.com/luajit-hacking-getting-next-out-of-the-nyi-list/)致敬,我把我的 pull request 命名为perf: get unpack() out of the NYI list(https://github.com/openresty/luajit2/pull/269)。它针对的是`luajit2`——OpenResty 的 LuaJIT 分支,而不是上游 LuaJIT(https://github.com/LuaJIT/LuaJIT),因为上游只允许协作者在那里开 pull request。我计划将来通过 LuaJIT 的邮件列表把补丁发给 LuaJIT 的创建者 Mike Pall。与此同时,luajit2 是一个合理的落点:按照它自己的仓库描述:“不应将其视为分支,因为我们仍然定期与上游 LuaJIT 项目同步变更。” 下面是 PR 新增的、复杂而精巧的`recff\_unpack`函数,它教会追踪记录器直接编译`unpack`,而不是回退到缝合: 我写了 30 个测试来确认`recff\_unpack`不再缝合、不再抛出 NYI、不再被拉黑,但这还不足以让我确信它在所有边界情况下都正确,于是我写了unimut(https://pypi.org/project/unimut)(通用变异测试工具,设计用于任何编程语言)对它进行变异测试,结果发现了初始 30 个测试遗漏的另外 2 个边界情况。unimut 系统地变异函数的逻辑,并检查测试套件是否仍然能捕获每一次变化,从而暴露普通的行覆盖和分支覆盖无法发现的漏洞: 这里少量幸存的变异体是符合预期的。它们涉及对 LuaJIT 内部实现细节的检查,而 Lua 层级的测试无法也不应该覆盖这些。 即使这个补丁被合并,本文前面提到的`get\_pcall\_wrapper`变通方案也不会消失。几乎没有人专门使用 OpenResty 的`luajit2`分支,而且很多嵌入 LuaJIT 的程序从不更新它们所发布的版本。这个修复能让一小部分用户免费获得`unpack`的编译支持,而在可预见的未来,包装函数变通方案仍将是其他所有人的实用修复。 ## 结论 追踪式 JIT 更广泛的危险在于,性能问题并不总是出现在你预期的地方。对于`unpack`,一个无辜的调用悄悄拉黑了一个完全无关的热循环,导致基准测试和分析器指向了完全错误的位置。对于闭包,代价恰好出现在你预期的地方,却仍然被忽视,因为代码看起来没有任何问题。 人工抽查无法发现这两种失败模式。相反,应该使用`luajit \-jv`,把编译器的追踪输出当作 CI 中可测试的产物,这样任何由 NYI 引起的性能回归都会被自动捕获,而不是悄悄溜回来。 如果你想深入了解 NYI,可以看看 Cloudflare 的LuaJIT Hacking: Getting next() out of the NYI list(https://blog.cloudflare.com/luajit-hacking-getting-next-out-of-the-nyi-list/)和 api7.ai 的The JIT Compiler's Drawback: Why Avoid NYI?(https://api7.ai/learning-center/openresty/avoid-lua-not-yet-implemented-features)。 `unpack`现在已经从列表中移除,PR(https://github.com/openresty/luajit2/pull/269)中附带了测试和复现步骤,如果你想看看实际效果的话。NYI 列表中仍有许多未实现的功能,包括闭包。如果你想找个理由在`lj\_record\.c`里大展身手,那个页面是个不错的起点。

相似文章

整数无故发生神秘变化,而本应无代码生成影响

The Old New Thing (Raymond Chen)

一位开发者发现,交换两个等效宏竟导致无关函数中出现意外的整数变化,这篇博客文章深入探究了这一谜团,并对某个大语言模型(LLM)关于控制流保护的解释提出了质疑。

schrodingers-toctou: The binary you run is not the program you wrote

Lobsters Hottest

This research describes compiler-invented loads, where compiler optimizations create additional memory reads not present in source code, turning seemingly secure code into vulnerable binaries with TOCTOU races. It includes audits across kernels, hypervisors, enclaves, and firmware.

调试性能回退问题

Lobsters Hottest

Guix HPC 团队的一篇详细博客文章,描述他们如何在 Slingshot 互连上识别并调试 MPI 堆栈中的性能回退问题,展示了 Guix 的透明性和控制能力。