Performance Notes
How fast JaiScript is, what fast script looks like, and how to measure it. This is the one page on the site that holds benchmark numbers.
Read ratios within one table, never across tables. Every comparison with another runtime was measured on the reference machine (Core Ultra 9 285HX, MSVC x64 Release with LTCG, pinned to one P core). The embedder cost tables were measured on the report machine, a 2016-era 4-core/8-thread laptop, with a different build of the VM. The reference machine runs everything several times faster.
The headline
Whole programs, each written the ordinary way in every language, hash-checked against each other, timing their own workload so start-up and compilation stay out. Lower is faster; the last column is the VM's time over Lua 5.4's.
| program | Jai VM | Lua 5.4 | Squirrel 3.2 | VM / Lua |
|---|---|---|---|---|
| OSSUARY (raymarched scene, float math) | 127.1 ms/frame | 167.7 | 388.5 | 0.76 |
| bytecode VM written in script (dispatch loop over integer arrays) | 673 ms | 744 | 1,104 | 0.90 |
| Gator Quest simulation (6,000 ticks, 4 bots) | 1.354 ms/tick | 1.151 | n/a | 1.18 |
| GLOOM (software-rendered shooter) | 0.634 ms/tick | 0.397 | 1.165 | 1.60 |
| Richards (scheduler: small methods over linked objects) | 788 ms | 334 | 609 | 2.36 |
| DeltaBlue (constraint solver: deep hierarchy, virtual dispatch) | 948 ms | 398 | 588 | 2.38 |
| spatial hash, 100,000 entities (a map of per-cell arrays) | 2,644 ms | 972 | 1,130 | 2.72 |
The pattern: typed loops over locals and typed arrays run level with or ahead of Lua; programs made of many small calls over shared objects take about 2.4 to 2.7 times Lua's time. Per-call and per-object cost is the VM's slowest ground. The four app rows are medians of seven interleaved processes; the game rows are detailed below.
Kernels and microbenchmarks
The kernel suite. Over the sixteen interpreter kernels of daScript's
dasProfile, run under its protocol (one process per sample repeating
the kernel for 0.5 s, median of five, implementations interleaved, checksums equal), idiomatic typed JaiScript
takes a geometric mean of 1.09× Lua 5.5.1's time, and the same algorithms with var
everywhere 1.35×.
| VM time / Lua time | kernels |
|---|---|
| widest wins | mandelbrot 0.04 (two float locals against Lua's complex-number tables), sha256 0.12, float-to-string 0.30, string-to-float 0.88 |
| widest losses | treap 5.5, host calls 4.4, n-queens 3.3, dictionary 2.6, n-bodies 2.1, recursive fibonacci 1.8 |
| LuaJIT's JIT, for scale | 0.01 to 0.73 across the suite |
Microbenchmarks, µs per iteration, fastest of five runs:
| benchmark | Jai VM | Lua 5.4 | Squirrel | ChaiScript |
|---|---|---|---|---|
Hot loop, 1000 iterations (sum += i) | 9 | 3 | 10 | n/a |
BST 15 nodes, & parameters | 18 | 8 | 11 | n/a |
| Fibonacci(15) | 69 | 25 | 55 | n/a |
| For loop, 100 iterations | 1 | 1 | 3 | 12 |
| String concat, 20 iterations | 1 | 1 | 3 | 19 |
This harness prints whole microseconds, so it resolves only the rows above. Method invocation, class creation, array push/pop and map insert-and-lookup print 0 for the VM, 1 to 2 for Lua, 2 to 3 for Squirrel and 10 to 17 for ChaiScript: that orders the runtimes and gives no ratio. What the table does show is Lua ahead on the tight loop (about 3×), call-dense recursion (2.8×) and the script-built tree (2.3×), Squirrel ahead on the last two (1.25× and 1.6×), and ChaiScript 12 to 19 times slower wherever it runs.
Whole games
Measured 2026-11-04 on the reference machine; every run's frame or state hash matches across runtimes. Each row is the median of three to six runs, the builds interleaved. Small differences are code layout, not speed: relinking the same program with unused padding moves most rows by under 2%, OSSUARY's serial row and GLOOM's parallel row by up to 9%, and LuaJIT's by over 20%.
| workload | result |
|---|---|
| DOOM: E1M1, textured software render at 120×78 plus status bar, six monsters awake and firing, 300-frame autopilot session | 1.3 ms/frame (sim 0.39, render 0.71, blit 0.21): 21 times inside the 28.5 ms frame budget, with enemy brains written as coroutines. The 3,984-tic timedemo replays at 1.389 ms/tic, about 20× real time. |
| GLOOM: 600 ticks after 600 warm-up, 100×40 output | VM serial 0.634 ms/tick, VM 8 workers 0.461; Lua 5.4.8 0.397, Lua 5.5.1 0.389, Squirrel 3.2 1.165, LuaJIT JIT 0.097. Eight workers give 1.38×: only the stages whose loops the engine proves safe run in parallel. |
| OSSUARY: the demoreel's raymarched scene at 100×40, five frames after five warm-up | VM serial 127.1 ms/frame, VM 6 workers 23.7 (5.4×); Lua 5.4.8 167.7, Squirrel 3.2 388.5. |
| Demoreel: all seventeen scenes headless, 1,700 frames at 100×40, one core | VM 9.9 ms/frame, under a third of the 33.3 ms budget at 30 fps. |
| Gator Quest: simulation only (mobiles, combat, items, quests, bots), 6,000 ticks | VM 1.354 ms/tick; Lua 5.4.8 and 5.5.1 1.151, Luau 0.741 interpreter 1.126, Luau native 0.655, LuaJIT interpreter 0.777, LuaJIT JIT 0.190. |
LuaJIT's JIT generates machine code at run time, which some shipping targets (iOS) forbid: it is a ceiling, not a target. Style is nearly free: a flat rewrite of GLOOM in free functions and globals produces identical hashes and runs within 2% of the class-based version, so choose the architecture you want to maintain.
Patterns that matter
// The fast shape: typed locals, typed arrays, & params, compound element stores.
array<int> pix = [];
for (int i = 0; i < 64; ++i) { pix.push(0); }
function shade(array<int>& dst, int base) { // & = no copy, writes land in the caller
for (int i = 0; i < dst.size(); ++i) {
dst[i] += base + (i & 7); // compound element store: one fused op
}
}
shade(pix, 100);
int sum = 0;
for (int i = 0; i < pix.size(); ++i) { sum += pix[i]; }
print("sum = {}", sum);
function square(int x) { return x * x; }
array<int> nums = [];
for (int i = 1; i <= 8; ++i) { nums.push(i); }
var squares = parallel_transform(nums, square); // deterministic at any worker count
print(to_json(squares));sum = 6624
[1,4,9,16,25,36,49,64]In priority order:
- Declare types.
int,floatandautolocals take the typed fast paths;varis the dynamic opt-out and costs accordingly (1.09× against 1.35× Lua on the kernel suite). - Pass and hold anything heavy by
&. Assignment and by-value parameters copy, as in C++. A copy that is only read binds in place for free, but treat that as a bonus (Value Semantics). Walk containers withfor (auto& u : units). - Use
array<int>andarray<float>for bulk numeric data. Same API, raw primitive storage: a 13,600-element grid takes 109 KB instead of 435 KB, a copy is amemcpy, and element access skips boxing. - Write compound element stores.
a[i] += vis one instruction, cheaper than the spelled-out read and write. - Keep calls out of the innermost loop. A call costs about ten fused loop iterations. A self tail
call (
return f(args)) is the exception: it reuses the frame (about 40 ns a call) and runs at any depth. - Parallelize the bulk stages.
parallel_forandparallel_transformrun bodies the engine proves safe on a worker pool, with identical results at any worker count. - Define classes once. Executing a
classdeclaration does redefinition and hot-reload bookkeeping (about 250 µs for a 30-line class script on the report machine). Per-frame scripts call; they do not declare. - Reuse engines. Creating one and registering the stdlib costs hundreds of warm executes.
Strings need no builder: s += x, s = s + a + "-" + b, template strings and
format() append into the string's own buffer, amortized O(1), whether s is a local, a global, a
field, an element or a string& parameter.
What is slower, and what to use instead
| shape | cost | instead |
|---|---|---|
| Call-dense non-tail recursion; object graphs walked through many small methods | fib(15) at 2.8× Lua, Richards and DeltaBlue at 2.4×: per-call frame setup | iterate, or use accumulator-style tail calls |
| By-value parameters on big structures | the BST benchmark by value runs 74 µs against 18 µs with
&: a subtree copy at every level | & parameters, var& declarations |
| Per-pixel loops (tens of thousands of scripted element operations a frame) | GLOOM at 1.6× Lua | typed
arrays, then parallel_transform for the bulk stages, then one C++ function for the innermost loop |
| Re-executing class declarations every frame | about 250 µs per 30-line class script | declare once, call per frame |
The cost model for embedders
What an operation costs
Report machine, VM, Release. Use the orders of magnitude.
| operation | cost |
|---|---|
| fused loop iteration | about 45 ns (about 9 ns on the reference machine) |
| script function call in a tight loop | 0.44 µs; a method call about 1 µs |
warm execute of a repeated one-line string | 0.60 µs (0.38 µs as a jaibite) |
warm execute of a string never seen before, one line | 8.4 µs: parse 3.8 µs, compile about 1.5 µs |
| warm execute of a 6-line function script, as a jaibite | 1.69 µs |
| 30-line class-and-loop script, warm | about 241 µs, nearly all class redefinition |
| engine creation plus first trivial execute | about 90 µs |
| stdlib registration | about 311 µs |
Compilation costs about a third of parsing and is cached with the parsed source, so a repeated script pays both once. The automatic jaibite disk cache makes file loads IO-bound: a 535-line game file's load-and-run drops from 5.8 ms to 2.4 ms.
What each aliasing strategy costs
One computation over a mid-sized object (a string, an 8-int array, 4 scalars), VM, µs per call, report machine:
| strategy | µs/call | against & |
|---|---|---|
read-only, Creature c by value | 6.6 | 1.4×: the copy, about 1.8 µs here, grows with the payload |
read-only, Creature& c | 4.8 | 1.0 |
read-only, shared_ptr<Creature> c | 4.9 | tie |
mutate through Unit& or shared_ptr<Unit> | 1.4 | tie |
method call on a value, an auto& or a shared_ptr | 4.7 | tie: the holder does not matter |
On the 15-node BST, & parameters run 1.35× faster than returning and reassigning a
shared_ptr, and both about 3.5× faster than by value.
What the debugger costs
The 1000-iteration hot loop, VM, report machine, µs per run:
| configuration | µs |
|---|---|
| no debugger constructed | 47 |
| controller constructed, no session | 46 |
| session armed, no breakpoints | 50 |
| session armed, breakpoint in a cold file | 51 |
| session armed, breakpoint in another file on the hot statement's line number | 64 |
The debugger is free until a session arms, then costs about 10%. The last row is the worst case: one string compare per line-number collision.
Measuring yourself
- Release only. A Debug build of the engine runs script 25 to 40 times slower.
- Pin to one core, interleave the builds you compare, take the median of five or more, and check that every run prints the same result hash.
- Time the workload inside the script after a warm-up, so start-up and compilation stay out.
- Distrust small deltas. A change smaller than a relink's spread (2% on most programs) is code layout.
| tool | what it gives |
|---|---|
jaiscript_tests "Performance Benchmarks" --verbose | JaiScript's own microbenchmarks, in whole µs.
Configure with -DJAISCRIPT_ENABLE_BENCHMARKS=ON
(docs/BUILD.md). |
jai_gloom --smoke, jai_demoreel --smoke | each game headless, ms per tick or frame beside
its hash (flags in each example's README). DOOM, GLOOM, the demoreel and Gator Quest all ship in examples/. |
-DJAISCRIPT_VM_COVERAGE=ON | a diagnostic build that counts every instruction by function and
source line, and why the compiler left it on the general path. engine::vm_coverage_report(top = 25) ranks
the reasons with their top sites, vm_coverage_summary() returns the totals as numbers,
vm_coverage_reset() drops a warm-up, and the runner's --coverage=FILE writes the report at
exit. engine::vm_coverage_available() says whether the build counts; without it the report and reset calls throw
std::logic_error. |
The ports of these programs to Lua, Squirrel, Luau and ChaiScript and the harness that drives them are not part of the JaiScript package, so the package reproduces the JaiScript column of every table and not the others.