Acton v0.31.0: Faster Programs, Better Tools
This release brings a broad performance boost, from iteration and arithmetic to actor scheduling and timers. It also introduces our own fork of the Boehm garbage collector, lazy generator expressions, new immutable and compact collections, and better tools for measuring performance. There are improvements to project builds and fixes throughout the standard library.
Less work in everyday operations
Iteration now finishes through ordinary control flow. next() returns a
maybe value, using just(value) for an item and nothing() for exhaustion,
instead of throwing an exception when the iterator runs out. The compiler can
often eliminate the allocation of that result. Fewer allocations reduce
pressure on the memory subsystem and leave less garbage for the collector to
reclaim later. Some iteration-heavy applications now run more than twice as
fast.
There are savings in other common operations, too. Direct loops over range()
avoid heap allocations, fixed-width numeric conversions avoid unnecessary
boxing, and hashing strings, bytes, and integers creates fewer temporary
objects.
The runtime also spends less time moving actors through queues and coordinating workers. On Linux and macOS, timed messages now use kernel timers instead of spinning while they wait. In timer-heavy workloads, that reduces CPU time by more than 90%.
Lazy generator expressions
Generator expressions bring comprehension syntax to lazy iteration. They produce values as a consumer asks for them, without first building a list:
total = sum(n * n for n in range(1000) if n % 2 == 0)
A generator is a single-pass iterator: once consumed, it is exhausted. The outermost iterable is evaluated when the generator is created; result expressions, filters, and nested iterables are evaluated as iteration advances and must be pure.
The compiler can fuse a generator with a consumer such as sum() or a
for loop, turning the whole operation into direct loops. It can also do
this when the generator is stored in a local variable and consumed once.
Fusion avoids allocating the intermediate iterator pipeline while preserving
the lazy evaluation rules.
The new flatmap() builtin adds lazy flattening: a pure callback returns an
iterator for each input, and its elements are consumed before moving to the
next input.
Our own garbage collector fork
Acton now uses a maintained fork of the Boehm garbage collector. This gives us room to improve collection for Acton's workloads and expose settings that applications can measure and tune.
Several improvements are enabled by default, including parallel mark-stack
range stealing and immediate use of thread-local free lists. Other choices
are opt-in. Applications can adjust mark layout, dirty-page tracking, heap
growth, and allocation budgets through build_options in Build.act.
acton.rts.get_gc_info exposes the active mode and settings, making it
easier to check which configuration a program is actually using.
The useful setting depends on the workload. The performance tools described below let you compare configurations on the same program and input, so you can choose settings for the work your application actually does.
Linux and macOS applications can also select mimalloc for ordinary C and native-library allocations. This is a separate choice: Acton objects continue to use garbage collection.
Measuring performance and how it scales
Performance and scaling studies use ordinary Acton test definitions. Benchmarks
mark their measured body with t.loop(), so setup and cleanup outside that loop
do not contribute to operation time or allocation totals. Process peak memory
still includes setup, which matters when a benchmark prepares a large input.
Compare a benchmark with acton test perf
acton test perf has been reworked throughout. It builds optimized code by
default, calibrates a workload size when needed, warms up, and then measures
repeated invocations. Reports show wall time, allocation volume, peak memory
use, and GC statistics.
The old runner forced collections in an attempt to improve memory statistics. The new runner leaves GC on its natural schedule, with no forced collections between invocations. Collections that happen during the measured work remain part of the reported wall time.
Where supported, we now collect retired instructions and CPU cycles across all threads in the test process, alongside CPU time and instructions per cycle (IPC). These counters give another view of the work behind a timing result.
The measurement techniques and terminal UI take inspiration from poop. We've also adopted its lightning (⚡) and poop (💩) markers for clear improvements and regressions.
Measurements can be compared with a saved report or another Git revision:
acton test perf --name my_test --compare git:main
This measures main and the current working tree, including uncommitted
changes, at the same workload size. Fresh processes and interleaved pairs help
reduce noise, and the report includes an estimate of uncertainty. Both project
versions use the running compiler and runtime; utils/perf-compare supports
comparisons of changes to Acton's compiler, runtime, or builtins themselves.
Comparing appends with repeated list concatenation at 4,096 elements, using a saved baseline. Both implementations use the same compiler and runtime.
Explore growth with acton test scale
acton test scale studies how a benchmark's cost grows with its workload.
The benchmark defines what scale means, such as the number of elements to
sort, and reads the selected size with t.scale().
The study increases that size, usually doubling it, and measures each size in fresh processes. It continues until observed growth settles over a broad range or a time or memory limit stops it. Results include a table of growth between sizes and terminal charts of time, time per unit of scale, and allocated bytes. Ghostty and Kitty display those charts as inline images.
Scaling studies can also compare saved results or Git revisions:
acton test scale --name my_test --compare git:main
With the default settings, both versions are measured at the same completed workload sizes and their curves are overlaid. That shows where a change saves work and whether the savings hold as the input grows.
Repeated concatenation (orange baseline) versus appends (solid curves), from 1,024 to 65,536 elements. Logarithmic axes keep both visible and show the cost of repeatedly copying the growing list. Both implementations use the same compiler and runtime.
New collections for different workloads
The new ilist, idict, and iset types provide immutable collection APIs.
freeze() turns mutable collections into their immutable forms, and hashable
iset values can be used as dictionary keys or nested inside other sets.
For numeric and Boolean workloads, array, bitarray, and bitset provide
compact storage. The new matrix module adds numeric matrices with row,
column, transposed, and reshaped views that share the underlying data. You can
work with a different view of a matrix without copying all its elements.
Mutable collections also gain in-place augmented assignment. Operations
such as += on lists and bytearrays and |= on sets update the existing
collection, avoiding copies and making those changes visible through aliases.
Cleaner builds and a more reliable standard library
Generated Zig build files now live under out/zig/, keeping them out of the
project root. Dependencies can select a subdirectory of a monorepo archive,
so several packages can share one repository. Focused test builds and watch
mode also handle dependency refreshes and cancellation more reliably.
The standard library has fixes across string and byte operations, Unicode handling, regular expressions, dictionaries, and file I/O. File writes now preserve the correct position and complete partial writes, and TCP servers drain queued data before closing a connection.
Before upgrading
A few changes deserve a look when updating an existing project:
- Custom iterators must return
just(value)for an item andnothing()at exhaustion. Direct callers ofnext()must handle the returnedmaybe. - Generic APIs that only read collections should use the new read-only
protocols, such as
ISequenceandIMapping. - In-place augmented assignment changes what aliases observe. Check code that relied on a list or set being replaced by a copy.
- Native extensions must use the lengths of
bytesandbytearraybuffers; those buffers no longer have a trailing NUL byte. - Predicates passed to
filter()must returnboolexplicitly.
The full changelog has the complete list of changes and compatibility notes. The installation guide covers getting Acton on your system.

