The bug
Here’s a sentence that should terrify anyone who writes performance-sensitive code: a function that never executes can slow down a completely unrelated hot loop by 20x.
That’s not a hypothetical. That’s what was documented this week in an investigation on r/programming — 159 upvotes, 95% upvote ratio, because performance engineers recognize their nightmares in this story.
The setup: a LuaJIT program with a tight computational loop. The loop is hot. It runs millions of times. It’s the kind of code you write when you need Lua to go fast. And it’s slow. Not “a bit slow.” Twenty times slower than it should be.
The cause? A completely unrelated function elsewhere in the codebase that LuaJIT had classified as NYI — “Not Yet Implemented” — meaning the JIT compiler couldn’t optimize it and fell back to the interpreter. This function is never called. It doesn’t matter for correctness. But its mere existence in the trace poisoned the JIT’s optimization decisions for the hot loop.
Let that sink in. The presence of dead code — code that executes zero times — caused a 20x performance regression in code that executes millions of times.
NYI: the dark side of tracing JITs
To understand why this happens, you need to understand how LuaJIT works. It’s a tracing JIT, not a method JIT.
A method JIT (like the JVM’s C2 compiler or V8’s TurboFan) compiles entire functions. It looks at a function, analyzes it, optimizes it, and generates machine code for the whole thing. If a function calls another function, that’s a separate compilation unit.
A tracing JIT (like LuaJIT) works differently. It watches the program execute, identifies hot paths — sequences of instructions that execute frequently — and compiles those traces into linear machine code. The key word is “linear.” A trace is a straight line through the program. No branches. No function calls (they get inlined). Just one hot path.
This is brilliant for performance when it works. Traces eliminate function call overhead, branch prediction misses, and all the other costs of executing interpreted bytecode. LuaJIT can produce code that rivals C in speed.
The problem: traces are fragile. If the trace encounters something it can’t inline — like a call to an NYI function — several bad things can happen:
Trace aborts. The trace is cut short. The compiled code falls back to the interpreter at the call site. If this happens in your hot loop, every iteration pays the cost of switching from compiled code back to the interpreter.
Side traces. LuaJIT can generate side traces to handle the NYI case, but side traces have overhead. Too many side traces, and the JIT spends more time compiling than executing.
The poisoning effect (the bug). In some cases, the trace recorder sees an NYI function anywhere in the code path it’s exploring — even if that function isn’t actually called on the hot path — and makes pessimistic optimization decisions that affect the entire trace. This is what happened in the documented case.
The developer found the culprit by systematically bisecting their code: removing functions one by one until the hot loop suddenly became 20x faster. The guilty function? A logging utility that used an NYI string operation. It was never called. Removing it restored full performance.
Why this is a beautiful nightmare
I love this bug. Not because I want it to happen to me — I’ve spent enough nights debugging JIT weirdness to know I never want to debug this specific thing — but because it reveals something profound about software.
We build abstractions to hide complexity. We assume that adding dead code is harmless. We assume that the compiler will figure it out. We assume that performance is local — that a slow function only affects the code that calls it.
LuaJIT’s tracing architecture shatters all these assumptions. In a tracing JIT, every piece of code in the trace path is connected to every other piece. A decision the JIT makes about Function A can affect the optimization of Function Z, even if A and Z never interact at runtime.
This isn’t a bug in the traditional sense. It’s an emergent property of the tracing JIT architecture. The JIT is doing exactly what it was designed to do — optimizing hot paths aggressively — and the NYI poisoning is a side effect of that aggression. Fixing it would require architectural changes to how traces are formed and how NYI functions are handled during trace recording.
The LuaJIT developers are aware of this class of issues. Mike Pall, LuaJIT’s creator, has written extensively about the tradeoffs of tracing JITs. But awareness doesn’t mean a fix is easy or coming soon.
The other story: Lua’s community problem
The same week this investigation went viral on r/programming, another Lua-related post was climbing the charts: Hisham Muhammad’s “The Lua community needs to learn to move on” — 548 upvotes, 92% ratio.
The thesis: Lua is stuck. Not technically — LuaJIT remains one of the most impressive pieces of software engineering in existence, and Lua-the-language is elegant in ways that Rust and Go will never be. But the community is stuck in a loop of internal disputes, fragmentation, and an inability to evolve the ecosystem.
The specific grievances: the Lua/LuaJIT split (two incompatible implementations, both called “Lua”), the reluctance to adopt modern tooling (package managers, linting, formatting), the “not invented here” culture that leads to five different OOP libraries and zero consensus on which one to use, and the general vibe that suggesting improvements to Lua is an act of aggression.
I’ve felt this personally. Lua was my first “real” scripting language after bash. I loved it. It was fast, simple, embeddable. The manual fit in my head. But every time I tried to do something beyond a single-file script — use a package manager, set up a testing framework, find documentation for a library — I hit a wall. The tools existed, but they were maintained by one person, documented in a README from 2018, and incompatible with the other tools I needed.
This isn’t a language problem. It’s a community governance problem.
These are the same story
The NYI bug and the community stagnation are connected in a way that I don’t think either author intended.
The NYI bug exists because LuaJIT is maintained by a tiny number of people. Mike Pall is a genius — genuinely, no hyperbole, one of the best compiler engineers alive — but he’s essentially one person. The “Not Yet Implemented” list in LuaJIT isn’t a roadmap. It’s a list of things one person hasn’t had time to do.
If LuaJIT had the contributor base of, say, V8 (hundreds of engineers at Google), NYI functions would get implemented. Tracing bugs would get fixed. The documentation would be comprehensive. But LuaJIT doesn’t have that. It has a small, dedicated group of contributors doing heroic work with limited resources.
And why doesn’t it have more contributors? Partly because the Lua community is fragmented and resistant to change. Partly because contributing to LuaJIT requires deep knowledge of a codebase that’s famously complex. Partly because the ecosystem doesn’t attract the kind of corporate sponsorship that JavaScript and Python get.
The technical problems (NYI bugs, trace poisoning) and the social problems (community fragmentation, maintainer burnout) are two symptoms of the same disease: underinvestment in shared infrastructure.
What “moving on” would actually look like
Hisham’s call for the Lua community to “move on” is provocative but vague. What does moving on actually mean?
For me, it means three things:
1. Acknowledge that Lua and LuaJIT are different projects with different use cases. Lua is a small, embeddable scripting language. LuaJIT is a high-performance JIT compiler that happens to implement the Lua language. Conflating them hurts both. Lua-the-standard could be the “write once, embed anywhere” option. LuaJIT could be the “I need C-level performance from a scripting language” option. Both are valid. Neither replaces the other.
2. Invest in shared tooling. The Lua ecosystem needs a real package manager (LuaRocks is fine but feels abandoned), a real formatter, a real language server. These are boring problems that require sustained investment, not weekend projects. They need funding, not volunteers.
3. Make contributing easier. The barrier to contributing to Lua/LuaJIT core is astronomical. The codebases are complex, the documentation is sparse, and the culture is not welcoming to newcomers. Lowering this barrier — through mentorship programs, better documentation, clearer contribution paths — would expand the contributor base and reduce the bus factor.
The performance investigation as community therapy
There’s something poetic about the fact that the LuaJIT NYI investigation went viral in the same week as the “Lua must move on” article. The investigation is a masterpiece of the kind of deep technical work that makes the Lua community special. It’s detailed, rigorous, and reveals something genuinely surprising about how tracing JITs work.
It’s also exactly the kind of work that’s unsustainable without a healthy community. The person who wrote that investigation spent hours — probably days — bisecting their codebase to find a single performance regression caused by a function that was never called. That’s not something you do casually. That’s something you do because you care deeply about understanding your tools.
The Lua community has people who care this deeply. It always has. The question is whether the community can channel that care into building sustainable infrastructure — not just brilliant one-off investigations, but permanent improvements to the tools themselves.
I hope so. Lua deserves better than being the language everyone uses but nobody funds.