More
Where the time goes.
Grasm does not beat Rust. The geometric mean over the microbenchmark suite is 0.965× of it — 0.955× with the harness's own floor taken out of both sides — which clears the 0.95× goal and is not the same as a win, and the systems suite below loses a benchmark by 3.6×. Both tables here are rendered from the files the runs wrote, not copied onto the page.
The microbenchmark suite
Eight programs, against Rust, C, Zig and Go. Five are at or above parity:
wordfreq 1.67×, sort 1.12×, sieve
1.03×, fib 1.01× (also ahead of Zig) and
parwords 1.01×. matmul at 0.92× is behind Rust
and ahead of Zig and Go.
tree at 0.51× is what the
geomean carries, and the cause is the representation rather than any operation:
a list of value structs is held as a box per node. Rust written the way
Grasm represents it — an Rc per node — runs 2.4×
slower than Grasm, so the gap to idiomatic Rust is the boxes and nothing else.
Storing those nodes inline was built and measured at 1.33× slower:
a node's release becomes a runtime call where it was one inlined decrement,
which costs more than the box it removes. The release has to get cheaper
first.
The eighth program, parmap, has no ratio here on
purpose. Parallel rows on this machine swing about ±10% run to run, so a
single number for one would be a number about the machine. A serial proxy is
used instead when a change needs measuring.
The systems suite
Five realistic workloads — a chat fan-out, an ECS update loop, a pointer-heavy graph build and drop, a 1 GiB ETL decode, and a TCP proxy — against Rust and Go. This is the recorded table:
| benchmark | metric | Grasm | Rust | Go |
|---|---|---|---|---|
| chat | deliveries/s (median of 3) | 590,078 | 693,534 | 131,735 |
| chat | peak RSS MB | 8.4 | 69.4 | 264.0 |
| ecs (1M × 100) | wall ms | 277 | 238 | 313 |
| ecs | RSS MB | 47.3 | 47.2 | 50.1 |
| dirtyheap (5M) | wall ms | 1,860 | 1,240 | 2,720 |
| dirtyheap | drop ms | 266 | 326 | 83 (GC, concurrent) |
| dirtyheap | RSS MB | 718 | 789 | 914 |
| etl (1 GiB) | wall ms | 10,260 | 2,854 | 10,429 |
| etl | RSS MB | 5.7 | 4.0 | 10.6 |
| proxy (256 conns) | req/s (median of 3) | 42,045 | 40,159 | 47,563 |
| proxy | p99 ms (median of 3) | 29.1 | 27.0 | 18.5 |
| proxy | RSS MB | 7.2 | 8.3 | 37.1 |
Grasm wins memory everywhere — 8×
on chat, 8.4 MB against Rust's
69.4, and dirtyheap now uses less than Rust's
arena while dropping faster than it. It loses etl,
dirtyheap, ecs and chat on time, and the
proxy tail to both.
What is behind each loss, measured rather than guessed. etl:
Grasm makes a value per string field and validates UTF-8 per slice where
Rust's decoder borrows from the line — borrowed string and bytes views are
the next lever, and that row is 10,260 ms against
2,854. dirtyheap: the URL is built as a string and
then copied into the field, where Rust formats it into place.
ecs: the update loop is at parity and the setup loop is behind on
instructions retired. The proxy tail is
unexplained, which is the honest state of it — see
below.
Two published conclusions that a better measurement removed
The proxy row was first taken as the median of three
sequential runs, and this machine drifts further than that between
them: the same binary, five times in a row, gave 52,157 down to 35,893 requests
a second. Re-run interleaved — grasm, go, rust, grasm, go, rust,
five rounds — two recorded conclusions did not survive:
| Grasm | Rust | Go | |
|---|---|---|---|
| req/s | 50,366 | 52,308 | 53,128 |
| p99 ms | 16.7 | 12.8 | 14.1 |
Throughput is parity, not 12% behind Go. Rust leads the tail, where the recorded row had it behind Grasm. What stands is that Grasm's tail is last — 1.18× Go's and 1.30× Rust's, a real gap and a much smaller one than the 1.57× that was published.
And the explanation that had been given for that tail is wrong.
proxy/README.md attributed it to connection affinity: a worker
owns the clients it accepted, so a busy worker stalls them while others idle.
That predicts the tail improves as workers are added, and it is disprovable
without building a work-stealing scheduler, because one worker per
connection is perfect distribution by construction. 32 workers against
256, for 256 connections: 28.7 ms and 27.1 ms at the p99. No change. So work
stealing would have bought nothing, and the cause is now unknown rather than
known-and-unfixed — which is a worse-sounding and more useful place to
be.
Compile speed
535–547k lines a second
through parse and typecheck, against a 500k goal, and
p50 21 ms / p95 40 ms
edit-to-diagnostic through grasm lsp on the largest program here.
Four things got it there, all found by measuring: three superlinear defects
(a field chain that was exponential, a method chain that was exponential, a
nested tuple literal that was cubic); memoized inference, added for those
chains and worth 2.2× on ordinary code too; not
freeing the syntax tree at exit, which was a third of grasm check
walking a tree node by node immediately before the operating system reclaimed
the whole address space in one step; and a non-cryptographic hasher for tables
keyed by byte offsets in a file the compiler is already reading.
The incremental figure is met at this repository's scale and the goal is written for a million lines. There is no query engine: the server re-checks the whole program on every change, and at 3,500 lines that is fast enough to hide the problem.
Binary size and task scaling
A hello-world went 351.1 KB → 85.7 KB, and the scheduler went 40,000 tasks in 199 s → 1,000,000 in 22 s. Both are on the scorecard with what moved them →
How any of this is measured
Every rule below exists because of a specific mistake, and the mistake is worth more than the rule.
| Rule | The mistake that bought it |
|---|---|
| Interleave the A and B runs | Sequential runs drift about 2% on this machine. Alternating the two binaries turned a reported 1.036× and 0.982× into 1.010× and 1.001× — both of the original readings were the drift. |
| Refuse to benchmark a busy machine | bench.sh stops above a third of the cores being busy. The same build measured 478k LOC/s at load 9 and 571k at load 3; a number without its load is not a number. |
| Price the ceiling before building | Counting instructions overestimated three optimisations by 6–20×. The way to bound a gain is to break the thing — remove the work and measure what is left — not to model it. |
| A profiler's leaves lie about cost | A leaf worth 28% of sample's samples was worth 1% of the clock, twice. Price a symbol by removing it, not by its share of samples. |
| Static frequency is not cost | Reference counting is 43% of every runtime call the compiler emits — 210,661 call sites to grasm_val_dec across 256 programs — and it does not appear in a profile at all. Those sites are cold. Allocation and thread-local lookups are the real cost, which is the same answer every measurement gives. |
| Subtract the harness | A constant added to every cell compresses every ratio toward 1. A 21 ms one made a losing suite read as parity, and it was published in three places before it was found in one. |
| macOS charges for a new executable | About 450 ms the first time a freshly built binary runs, flat with its size. Reuse the inode or measure that tax instead of the program. |
And a load artefact looks exactly like a defect.
A native run killed under load exits -9 with empty output, which
reads as a mismatch. Seven fuzzer disagreements, two cross-compile failures and
a hung concurrency test were all that, and all passed on re-run. The rule is to
re-run before debugging — which is also why the crosstest figure
on the scorecard is annotated with the load it was
taken at.