SCUA compiles hot code as it runs. When a script spends its time in loops — physics and simulation, math over vectors and matrices, walking large tables — a JIT (just-in-time compiler) turns those loops into native code so they run much faster. This is on by default: you don't have to do anything. The output is exactly the same either way, so you can turn it off and on freely. The only exception is the order in which lines from several busy tasks or partitions interleave, which was never promised and can shift slightly.
#It's already on
Just run your script:
fn walk(steps)
let p = vec2(0.0, 0.0)
let v = vec2(1.0, 0.5)
for i in 0:steps do
p = p + v
end
return p
end
let p = walk(1000000)
print(`{p.x}, {p.y}`)
$ scua walk.scua
1000000.0, 500000.0
With --jit=off the same file runs on the interpreter and
prints the same thing, just slower. The JIT doesn't change what the
program does; it compiles the loop that runs a million times down to
native code once it's clear the loop is hot, and runs the rest of the
way from there.
#What gets faster
The JIT pays off where the same code runs many times:
- Hot loops. A loop body that runs thousands or millions of times is the case it's built for.
- Math-heavy code. Vector, quaternion, and matrix work (the math types) benefits most.
- Table-heavy code. Repeated access to string-keyed tables gets faster too.
Code that runs briefly gains little, because nothing runs long enough
to be worth compiling: startup, one-shot scripts, and glue that calls
out to the host and waits. The JIT isn't free — compiling takes a moment
— so on a very short script it can even cost a little. That cost is
small enough not to be worth managing, which is why it's simply on; if
you are timing something tiny and want the difference,
--jit=off is there.
#The output never changes
The JIT is a speed tier, not a different language. A program produces the same results with it on or off, so it's safe to toggle: develop with it off, turn it on for a heavy run, and compare. (The examples in this manual are checked both ways in CI, on the interpreter and with the JIT forced on, and required to match.)
#Which platforms it runs on
The JIT compiles native code on ARM64 (Apple Silicon and ARM Linux)
and on x86-64 (macOS, Linux and Windows). Anywhere else — Windows on ARM
today — the script runs on the interpreter with exactly the same output.
You don't have to think about which platform you're on: you get native
code where it's available and the interpreter everywhere else, and the
program prints the same thing either way. If you ask for the JIT
explicitly with --jit=on somewhere it can't run, SCUA says
so on stderr rather than silently ignoring you.
#See it working
Add --jit-stats to print a one-line summary to stderr
when the run ends. It only reports — it never changes what runs, so
scua --jit=off --jit-stats app.scua really does measure the
interpreter.
$ scua --jit-stats walk.scua
1000000.0, 500000.0
[jit] mode=on compiled=2 native-entries=6 deopts=4 ...
compiled= is how many functions it compiled and
native-entries= how many times it ran native code; a
deopt is a fall back to the interpreter. The exact numbers
are diagnostics and vary between runs and releases. What you're looking
for is compiled= above zero with native entries to match —
that's your hot loop running as native code. All zeros means nothing ran
long enough to compile, which is your cue that the JIT has nothing to do
for this script.
#When a loop stays slow
--jit-stats also names each loop the JIT tried to
compile and couldn't, with the line it starts on and the reason:
[jit] refused loop in run at line 6: a while loop nested inside this one (line 8) (x1)
A refused loop still runs, and still gives the same answer, but at the speed of the code around it rather than as native code. These all compile now, so there is no need to rewrite them by hand:
- reading a module-level
constorletinside a loop, whether it holds a number, a boolean or an array; min,maxandabsof two ints or two floats, andfloor,roundandceil, inside anifor outside one. Anifwhose arms each round a float, such asif r >= 0.0 then q = floor(r + 0.5) else q = -floor(0.5 - r) end, runs both arms without a branch, so it costs the same whichever way the sign falls;- shifts (
<<,>>) and~anywhere in the loop; x == nilandx != nilon an argument, inside anifor outside one, and anifarm that would read anilargument but never runs;- calls to a short function, inside an
ifor outside one, which are compiled in place. The function may have a shortwhileloop of its own, set fields of a record passed to it and append to an array, as a bit writer'sput_bits(w, code, size)does. Calls to other functions compile as ordinary calls; - calls with up to 15 arguments, and
nilas an argument, even for a parameter the function sets; - a short
whilein the loop that calls nothing, such aswhile nb >= 8 do … end, inside anifor outside one, and anywhileinside anifthat seldom runs; - a loop that counts a number's bits,
while a > 0 do a = a >> 1; n = n + 1 end, which runs as one step; - indexing an array read from a record's field
(
t.co[i]); - loops in long functions, up to about 4,000 instructions;
- a short
forinside another loop, when its bounds are numbers written in the code and it runs at most eight times (for x in 0:8). Its body is copied into the loop around it once per step, so the two compile as one.
The ones that still keep a loop out, and what to do about each:
- A loop inside another loop, unless it is the short
foror thewhileabove. Awhilewhose body calls a function counts as one, unless it sits inside anifand seldom runs. Only the innermost loop compiles. If the outer loop matters, move the inner loop into a function. - A function longer than about 4,000 instructions. A
very long function's loops can't follow values through an
if. Move the loop into its own function. - Many calls in one loop. Each value the loop needs after a call must be kept somewhere the call can't overwrite, and with several calls there may not be room ("more values are live at once than there are registers"). ARM machines, such as Macs with Apple silicon, have room for a few calls; x86 has less. Calling fewer functions in the loop's body, or moving it into a smaller function, helps.
- A call to a function with a
forloop of its own, or a loop inside a loop. The call runs, but the loop around it can't absorb it. A function whose only loop is a shortwhilecan be absorbed. - A variable that holds different kinds of value on different
paths, such as
falseon one branch and a number on the other. Give it the same kind on both paths.
min, max, abs and the rounding
functions are only free inside a loop the JIT compiled. In a refused
loop they are ordinary calls, which is one more reason to look at what
--jit-stats refuses before tuning anything by hand.
#The other modes
--jit=off runs the interpreter only. Reach for it if you
ever suspect the JIT of changing an answer — it shouldn't, and the two
tiers are checked against each other on every release, but it's the
escape hatch and it isn't going away. --jit=on is the
explicit form of the default. --jit=always compiles eagerly
instead of waiting for a loop to warm up; it's slower to start and is
mainly there for testing that the JIT and the interpreter agree.
See the command-line reference for the full flag surface.