Recursive code, of the ordinary kinds people write, now runs as fast
under the JIT as the same thing written as a loop. That covers a
function that calls itself, a tail-recursive accumulator, a closure that
calls itself, and a method that recurses through self, on
Apple silicon and on x86-64.
#A new benchmark tier for single mechanics
The benchmark suite has a new tier. The existing tiers run programs shaped like the things people write, a game loop, an order book, a pathfinder, and the JIT had been ahead of LuaJIT on those for a while. The new tier is the opposite kind of program. Each entry isolates one mechanic of the VM and the JIT and measures only that: a plain recursion, a deep tail recursion, a self-calling closure, a recursive record method, a call with three arguments, a loop over two fixed records. They are dull to read and not something you would ship, which is the point. When the first board came back, LuaJIT was ahead on every one of them, and we were not competitive. The week went into closing that gap.
#How a tracing JIT works, and what SCUA does instead
LuaJIT is a tracing JIT. It watches the program run, records the exact path the hot code takes through every call and branch, and compiles that one path as a single straight line with everything inlined. For a tight loop that always goes the same way this is close to ideal. A recursive descent turns into a loop with the arguments in registers and no frames at all.
The cost shows up when the program does not go the same way every time. A trace knows one path. A branch that goes the other way leaves the trace and starts recording another, and code with many live paths can spend its time compiling traces that never get warm, so performance becomes hard to predict from the source. That is a large part of why Mozilla retired TraceMonkey, the tracing JIT in Firefox, in 2011 in favour of compiling whole functions, and why V8 and JavaScriptCore compile functions too. PyPy and LuaJIT are the two that made tracing work.
SCUA compiles functions. The JIT takes a whole function, or a hot loop inside one, and compiles it with speculation: it assumes the types and the callees it saw, guards those assumptions, and inlines calls two levels deep, including a function's calls to itself. When a guard fails, the compiled code hands that one frame back to the interpreter and carries on. Compiled code is a cache and never the state of the program, which is what lets a session hibernate mid-call and resume on another machine with none of it.
This week the JIT learned to treat a function's descent into itself as a loop. It keeps the depth, the budget and the callee in registers, and only builds real frames when something observable needs them: a traceback, a debugger, the frame limit. The same forms then went to x86-64, which has fewer registers to spend and needed its own version of each.
#Where the numbers stand
On our suite we now believe the JIT is on a par with LuaJIT overall:
ahead on the realistic tier, and close on the mechanics tier that
started the week. The interpreter, which is what runs when you do not
pass --jit, stays ahead of Lua 5.4 on the same suite.
We are not going to headline figures from our own suite on our own machines. The tables are in the repository with the exact setup and the date they were taken, but a benchmark chosen by the people it measures proves less than one chosen by someone else. Before we quote numbers here we want independent verification: someone outside the project running their own benchmarks, or an established industry suite, against SCUA and LuaJIT and publishing what they get.