SCUA

How-to

Make scripts run faster with the JIT

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 const or let inside a loop, whether it holds a number, a boolean or an array;
  • min, max and abs of two ints or two floats, and floor, round and ceil, inside an if or outside one. An if whose arms each round a float, such as if 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 == nil and x != nil on an argument, inside an if or outside one, and an if arm that would read a nil argument but never runs;
  • calls to a short function, inside an if or outside one, which are compiled in place. The function may have a short while loop of its own, set fields of a record passed to it and append to an array, as a bit writer's put_bits(w, code, size) does. Calls to other functions compile as ordinary calls;
  • calls with up to 15 arguments, and nil as an argument, even for a parameter the function sets;
  • a short while in the loop that calls nothing, such as while nb >= 8 do … end, inside an if or outside one, and any while inside an if that 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 for inside 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 for or the while above. A while whose body calls a function counts as one, unless it sits inside an if and 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 for loop 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 short while can be absorbed.
  • A variable that holds different kinds of value on different paths, such as false on 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.