SCUA 0.33.0 is a speed release. A loop that builds small records now runs as fast as LuaJIT, functions with typed parameters cost about the same as untyped ones, and typed arrays of every number type run at full speed. Actors use less memory, and a handler can now fail the whole run when a tool's work goes wrong. There is a short guide to upgrading at the end.
#Loops that build small records no longer allocate
Building a small record in a loop and reading it straight back is a common way to write game and simulation code. The optimizing JIT now sees when a record never leaves the loop, keeps its fields in registers and never builds the record at all:
let total = 0
for i in 0:2000000 do
let p = { x = i, y = i * 2, vx = 1, vy = -1 }
total = total + p.x * p.vx + p.y * p.vy
end
print(total)
$ scua particles.scua
-1999999000000
This loop took 27 ms on an Apple M4 Max with 0.32 and takes 5 ms now; LuaJIT takes 12 ms. On an Intel i7-10700 running Linux it went from 76 ms to 5 ms, level with LuaJIT. The loop also no longer starts the garbage collector. When your code does need the record, for example to pass it to a function or store it somewhere, SCUA builds it as before.
#Typed parameters cost about the same as untyped ones
A parameter with a record type checks its argument on every call. That check now remembers the last record it accepted, so a call that passes the same kind of record pays for one comparison:
record Npc { x: float, speed: float }
fn step(n: Npc, dt: float)
return n.x + n.speed * dt
end
Calls like step(n, 0.001) are about 3 times faster
without the JIT and 4 times faster with it, about as fast as the same
function without the type, and the JIT can now fold them into the loop
that calls them. This works for fields of every type, including
string, bool, decimal and
big, and for records of any width: a record with 20 fields
checks up to 100 times faster than before.
#Typed arrays of every number type run at full speed
Loops over { i32 }, { u32 },
{ u16 }, { u8 } and { f32 }
arrays now run as fast as { f64 } and plain arrays: up to
40 times faster with the JIT and about twice as fast without it. Filling
one with push is about 3 times faster.
A number that doesn't fit is reported with the range the array holds:
let level: { u8 } = []
for i in 0:10 do
push(level, i * 20)
end
print(level)
push(level, 300)
$ scua bytes.scua
[0, 20, 40, 60, 80, 100, 120, 140, 160, 180]
scua: bytes.scua:6: can't add 300 to a typed {u8} array: it holds whole numbers from 0 to 255
#Actors use less memory and stay fast
- Each actor now decides when to collect garbage from its own memory use. In a program with one large actor and sixty small ones, peak memory went from 2.38 GB to 547 MB.
- A program can revive thousands of actors, or run thousands that have each collected garbage, without running out of memory early.
- Actors in a script embedded through the C API cost about a quarter
of the memory and time they did, the same as under the
scuacommand. - A hot loop inside an actor that calls your functions or built-ins runs at full JIT speed, the same as at the top level.
- On x86-64, two actors running the same numeric loop went from 240 ms to under 30 ms, because a loop that leaves optimized code gets back into it within a few iterations.
#A handler can fail the run
sys.fail in a partition's handler now marks the whole
run as failed, so a tool whose work runs in a partition can report
failure through its exit status. The handler's turn ends, the partition
keeps its state and handles its next message, and the program exits with
the code when it finishes:
import sys
partition Checker
state seen = 0
on Check(path)
if path == "" then sys.fail("no path given") end
seen = seen + 1
print(`checked {path}`)
end
end
let c = Checker()
tell c.Check("a.txt")
tell c.Check("")
tell c.Check("b.txt")
$ scua check.scua; echo "exit $?"
checked a.txt
scua: check.scua:7: partition Checker, message Check: the turn ended with Error(no path given); state kept
checked b.txt
{"error":"no path given"}
exit 1
sys.exit with a non-zero code does the same without the
message, and sys.emit writes its result and ends the turn.
In a spawned task, the three stop that task.
return Error(e) from a handler still refuses one message
and leaves the exit status alone.
#Sandboxes exchange only data
Messages to a sandboxed actor and its replies now go through the same check as its starting state, so only data crosses in either direction. Code inside a sandbox can't start actors or bring a saved one back. Each actor gets its capabilities from the code that created it, so it never holds more than its creator.
#Smaller additions
- Files with a few hundred top-level functions, or a large table of distinct keys, now compile. The limit is 65,535 distinct constants in one function, and reaching it names the line.
- A value that can't be sent between partitions is named, with where
it sits:
argument 2 of `Take` (at `.cb`) is a function that captures variables. --max-opscounts creating an actor as one step.- Actors can use the
clustermodule with the run's secret. - A table column named
printorworldcan be used by its bare name infilterandwith.
#Upgrading to 0.33
Inside
filterandwith, a name you declared yourself is an error beside a column of the same name, even when it is also a built-in name. Writerow.yearfor the column, or rename your function:let sales = frame({ year = [2024, 2025, 2025], amount = [10, 20, 5] }) fn year(d) return d end print(sales.filter(row.year == 2025).rows()) -- 2--max-mem(and an embedder'smem_cap) is one total for the whole program, actors included. A program that creates many actors under a tight cap may need a larger one;--mem-statsshows the total asall-live.In 0.32, a variable that a hot
whileloop only assigned could read an earlier value after the loop. If you saw a number like that, 0.33 gives the right one.
#What's next
Work on actor memory continues, so programs with many actors take
less of it. Your own record types still get operators, so a vector or a
unit type you write can use + and < the way
the built-in ones do.
The full changelog has everything in 0.33.0.