Solveig

Speed, and what measuring it found

Solveig had never been compared with another implementation. Every number in this repository was Solveig against an earlier Solveig, which tells you whether a change helped and nothing at all about where the whole thing stands.

So it was measured against CPython 3.14: nine matched programs, each pair verified to print the same answer before either was timed, run interleaved through bench.sol.

It started level and ended ahead — geometric mean 1.09, then 0.885 — and the distance between those two numbers is the interesting part. Nothing was rewritten and no algorithm changed. The comparison found three defects, and every one of them had been invisible from the inside.


The numbers

Benchmark What it does Solveig CPython Ratio
float 8,000,000 × square root, divide, add 674 ms 1412 ms 0.48
loop sum 1 to 10,000,000 in a while loop 453 ms 844 ms 0.55
array grow a 5,000,000-element array, then fold it 347 ms 634 ms 0.56
higher collect, select, inject over 4,000,000 431 ms 665 ms 0.67
object 4,000,000 objects made, filled, messaged 859 ms 955 ms 0.92
strloop scan 9,043,899 characters one at at a time 632 ms 643 ms 1.01
dict 1,500,000 string-keyed inserts, then lookups 1015 ms 874 ms 1.19
strlib join 2,000,000 strings, split them back 533 ms 359 ms 1.57
fib fib(34) by recursive message send — 18M calls 1038 ms 549 ms 1.96

Medians, with each runtime’s own startup subtracted so the figures are the work and not the launch. Geometric mean 0.885, ahead on five of the nine. The character scan is the one genuine dead heat: measured end to end Solveig wins it, and once its startup advantage is removed the two are within one percent.

  Solveig CPython
Startup, hello world 2.95 ms 21.43 ms
Compiling source 1.09M lines/s 268k lines/s

Apple M2 Pro, macOS 25.6.0. solvm 0.38.0 built -O2; CPython 3.14.7 from Homebrew, no JIT, GIL enabled.

Where it wins, it wins on representation. A Solveig integer is sixty-four bits in a register; a CPython integer is a heap object with a reference count, allocated and freed on every arithmetic operation. Summing to ten million costs Solveig an add instruction and costs CPython ten million allocations. Add conditionals and loops that compile to jumps and that is the whole of the first four rows.

Where it loses, it mostly loses on tuning. str.split and CPython’s compact dict have had years of SIMD scanning, key-sharing layouts and probe-sequence work poured into them. Solveig’s are correct, clear implementations of the same operations. There is nothing structurally wrong to fix, which is a different and much less interesting kind of gap — and it confirms from the outside what the editor’s substitute pass found from the inside: a library’s speed lives at the boundary with the primitives. That boundary is in the same place in both languages.


What the comparison found

Three fixes. Each was measured before it was believed, and none of them is an interesting idea.

A heap object per character. string:at answers a one-character string, there being no character type, and it ended in a fresh allocation every time — while "o" in the loop’s condition is a literal, and the machine built a literal fresh on every evaluation. A scan comparing each character against a constant was making two strings a pass. The machine keeps one string per byte value now, filled on first use, and the scan went from 2.13× CPython’s time to level. Interning every literal had been an open item for a month, held up because it wants a weak table so long literals can still die; there are 256 byte values and never more, so a table that size can be strong. The bounded case did not need the unbounded mechanism. 4.4.

A hash lookup per variable. This one the benchmarks had been hiding. In the integer loop’s profile, name lookup was 218 samples against the dispatch loop’s 304 — far too much for a program that sends add and inc and nothing else. The reason is that its counter is a global, because the loop is written at a script’s top level, so every read hashed the root object and every write hashed it again. The same loop with the counter as a block temporary is 1.255× faster. That is not a benchmark to correct for: it is every top-level script and every line typed at the prompt.

A function in the wrong file. The per-send receiver check asks three predictable questions, and it lived in object.c while its only caller lived in vm.c — so every send in every program paid a call across a translation unit for three branches. Profiling basic.sol interpreting 39,000 BASIC statements put it at 12.6% of the run, against the name lookup’s 13%. It had never been suspected of anything. 4.5 carries the last two.


What was refused

Two things were built and rejected on the measurements, which is worth more here than the two that landed.

An inline cache at the send site, proposed confidently as closing most of the recursion gap. Profiling says the proto-chain walk is 9.7% of that benchmark. Two programs differing only in how far the method sits from the receiver — its own first slot, against five objects up with four slots to skip at each — are 18% apart, so the entire walk in a deliberately deep case is about a sixth, and fib is not that case. Deferred, with the profile.

Computed-goto dispatch, the textbook optimisation for a bytecode interpreter, which the roadmap had put at 10–20% on the strength of the folklore. Built: it is 1% to 13% slower than the switch, on all nine benchmarks and on the real program. The disassembly says why — twenty-one hand-written dispatch sites were tail-merged by the compiler back into a single indirect branch, which is the shape a switch already compiles to, leaving 528 bytes of extra code around it for nothing. Refused, with the disassembly.

And two changes that each did no harm alone made deep recursion 8.5% slower together, on a program that uses neither. Nothing had been added to its path: it was instruction cache, a bigger switch body and the hot cases falling differently across it. Moving the slow paths out of the loop fixed the regression and improved everything else at the same time. A dispatch loop is a cache-resident thing, and code that runs once per site does not belong in it.


The build flag is worth more than any of this

Everything above is an -O2 build. The default CFLAGS in the Makefile is -std=c11 -Wall -Wextra -Wpedantic -g — no optimiser at all — and that is what make puts in bin/.

The shipped debug build is 1.9× to 4.1× slower than the same source at -O2, and against CPython it loses every one of the nine.

Nothing is wrong with a debug default: -g with no optimiser is the right thing to be developing against, and the sanitizers have a variable of their own. But the binaries in bin/ are not the ones these numbers describe, and a timing quoted without its build is a timing that means nothing.

make CFLAGS="-std=c11 -Wall -Wextra -Wpedantic -O2"

One further gain is measured and not taken: -flto is 5% to 29% faster across the suite with no source change, and it drops the exported sol_* surface from 147 symbols to zero, which silently breaks every loadable extension. The test suite catches it. Keeping both means naming those symbols deliberately, and that is the one candidate on the ideas page still unbuilt.


What one instruction costs, which nothing above says

The numbers above are whole-program times and the ratios between them — against CPython, or against an earlier Solveig. That is what the comparison was for, and it leaves a different question unanswered: what does one instruction cost? Nothing in this repository had ever said, because nothing had needed to.

4.3 nanoseconds per bytecode instruction, or 234 million a second.

programs/sha256sum.sol is where that came from, on 2026-08-31. It is the first program here with no I/O in its inner loop — sixty-four rounds of shifts, masks and additions per sixty-four bytes and nothing else — so its running time is the dispatch loop and nothing is hiding in a syscall or a split.

The instruction count is measured rather than counted, using a flag that was not put there for this. solvm --steps=N stops a program after N instructions and exits 124, so the smallest N that lets a run finish is that run’s exact count, and a binary search finds it:

bytes hashed instructions blocks per block
0 14,671 1  
64 28,049 2 13,378
640 147,767 11 13,302
6,400 1,344,947 101 13,302

13,302 instructions per 64-byte block — flat from ten blocks to a hundred, which is what says the figure is the loop and not the setup — and 207.8 instructions per byte of message. The same search on a megabyte answers 217,955,855 against 217,954,715 from the four rows, which is the fit confirmed to five figures; the difference is the sixteen chunk reads a megabyte needs and the small files do not. Ten megabytes take 9.30 s at -O2 on the M2 Pro, which is the 234M a second above.

It is one program and one kind of work: integer arithmetic on block locals, no allocation in the loop, no globals, no string building. A program that allocates or sends into a deep proto chain will do worse per instruction, and the fib row above is what that looks like. It is a floor rather than an average, and this project had no such number before.

  on a megabyte
/sbin/sha256sum ~1800 MB/s — C, and the M2’s SHA instructions
shasum -a 256 ~320 MB/s — Perl, calling a C library
sha256sum.sol, -O2 1.08 MB/s
sha256sum.sol, the -g build 0.22 MB/s

The Perl row is the one worth reading twice: an interpreter, about three hundred times faster, because it is not interpreting the hash. And the build flag costs 4.9x here against the 1.9x to 4.1x the nine benchmarks show, which is the largest gap measured for it — a program that is nothing but arithmetic inside the dispatch loop is the shape it matters most to.


The two instruments, and what each is blind to

The section above measures a program with --steps=N and calls the answer exact, which it is. Exact is not the same as relevant, and on 2026-09-04 a day of work on sort.sol found the gap in both of the flags this repository measures with.

--steps counts sends, and a send is not a unit of work

sort’s reader drained its buffer with copyFrom(at + 1, size)one send, and a memcpy of everything behind the line. Replacing it with a reader that splits each piece once moved the instruction count by 1% and the clock by 14%:

584,997 bytes, 11,350 lines pipe named file
the buffer reader 15,448,496 15,443,861
the line reader 15,288,867 15,274,324
  −1.0% −1.1%

Wall clock on 4.7 MB, same build, best of three: 2.97 s → 2.55 s on the named route. The 4.3 ns above is a floor for arithmetic on block locals, and it was already said to be one; what this adds is a measured case of the other end. A single send here costs about fourteen times what the count implies, and it is the first measurement in this repository where the two disagree by more than a rounding.

The reading rule. An instruction count compares two versions of the same shape — a loop made shorter, a branch removed — and it is the right instrument for that, because it is exact and repeatable and free of the build flag. It is the wrong instrument the moment one of the versions moves bytes the other does not. copyFrom, concat, join, split and readFile are all one send and unbounded work, and a change that touches any of them owes a clock as well as a count.

--memory=N reports a threshold, not a live set

The smallest limit a run survives is where the collector’s heap threshold next lands. It steps. gzip.sol inflating the same 187,655 bytes out of seven differently-sized inputs gives 6,615,294 for four of them and 8,713,853 for three, one jump of 2,095,509 bytes and nothing in between; five compression levels of one file, 200 KB of boxed integers between the extremes, give 6,615,294 to the byte, all five. The step here is about a third of the figure.

The reading rule. These numbers are ceilings with a coarse grain. They are sound for what they are always used for here — the same program measured both ways on the same input, where a difference larger than a step is real — and they are not a statement of what a program holds. A difference smaller than a step is invisible, so no change in a --memory figure is not evidence of no change.

Both were found the same way, which is the part worth keeping: by asking what a number meant rather than what it was. The --steps finding came from a constant whose larger value was somehow the slower one; the --memory finding came from wanting to show that a saving was the input rather than infer it from a difference. Neither instrument was wrong. Both were being read as answering a question they do not answer.


What is not fair here, said plainly


How it was measured, and how to re-run it

The programs are in comparisons/python/ — nine matched pairs and the four probes — with a runner:

make CFLAGS="-std=c11 -Wall -Wextra -Wpedantic -O2"   # the build being measured
comparisons/python/run.sh

make test compiles them and runs none of them: each is sized to take about a second, so the nine belong to somebody who meant to run them rather than to a suite that takes eight seconds. Compiling them is enough to catch the rot that happens to code nothing builds.

The measuring is done by bench.sol, which is what it was written for. It interleaves the two commands and flips a coin for which goes first each round, so a machine drifting over a minute cannot be mistaken for a difference, and it reports a bootstrap interval on the ratio rather than a test that assumes a normal distribution. Given the same command twice it answers 1.001 and says the runs cannot tell them apart — the check it has to pass before any other answer it gives is worth reading.

./bin/solvm programs/bench.sob 21 ./solvm loop.sob -- python3 loop.py
A:  ./solvm loop.sob                B:  python3 loop.py
  runs     21                        runs     21
  min       563.090 ms               min       784.914 ms
  median    572.593 ms               median    811.216 ms
  mean      595.385 ms  +/- 63.185   mean      816.629 ms  +/- 24.707

A / B    0.706 times, 95% interval 0.698 to 0.717
         A is faster

Every pair of programs prints the same answer, checked before anything was timed and again after every change to the machine. A benchmark whose two halves compute different things measures nothing.

The profiles are sample against a running process. The one that mattered was of a real program rather than a loop written to be timed — which is how the receiver check was found, having been invisible in every synthetic benchmark.


The part worth keeping

Four things looked worth doing to the dispatch loop. The two that paid were a missing inline and a table lookup; the two that did not were the ones with reputations. Both of those were refused on measurements taken after building them, not before.

That is the argument for measuring against something outside. A project measured only against itself gets steadily better at the things it already thought were important. It took another implementation to point at a one-byte string allocated nine million times, and at a variable read that had been a hash lookup since the beginning.