Number text in Xo: moving a piece of the runtime out of C, one measurement at a time
Xo’s native backends (arm64 and LLVM) link a runtime written in C. Part of that runtime has to stay C: the allocator, reference counts, the scheduler, system calls. But much of it is policy over Xo values, such as how an Int is written as text or how a Float is parsed, and that part can be Xo. Code written in Xo is compiled with the program, so it also runs on the WebAssembly backend, which has no C at all.
The rule for moving any of it was set before the first line was ported (decision 0180): every workload of a port must be within 5 percent of the C it replaces, in instructions retired, on both native backends. If a piece misses, it stays C, and the C version stays selectable until the bar holds. This post is about step 3 of that port (decision 0193): the text of Ints and Floats, Float.parse, and hex and base64.
How it is measured
bench/perf/text runs each workload in its own process, 200,000
operations (100,000 for hex and base64) on 1,000 prepared inputs, and
counts instructions retired: the minimum of 5 runs. Instruction counts
are stable on a busy machine, which wall time is not. They are not wall
time, and a lower count does not always mean a faster program, so every
figure below is instructions.
Each workload is built twice with the same compiler: the default (the
Xo code) and with XO_LLRT_C=num, which emits the C runtime’s calls
exactly as before the port. The setup that prepares the inputs formats
about 3,000 Floats, which the C path does far more slowly, so comparing
whole processes would flatter Xo. The figures below subtract each
path’s setup first. As a check on that subtraction, the twelve
workloads this step did not touch come out between 0.998x and 1.003x.
Every workload prints a checksum, and the checksums are identical on both backends, on both paths, and equal to the Go backend’s.
The results
Xo over the C it replaced, instructions with the setup subtracted (lower is better; the bar is 1.05):
| Workload | arm64 | LLVM |
|---|---|---|
| Int to text | 0.800x | 0.734x |
| Float to text | 0.019x | 0.014x |
| Float.parse | 0.282x | 0.273x |
| base64 encode and decode | 0.385x | 0.310x |
| hex (decode in Xo, encode still C) | 1.003x | 0.942x |
On these workloads the ported code matches or beats the C it replaced. That is a statement about this code against that code, not about Xo against C in general, and the biggest number in the table shows why.
Float text: an algorithm, not a language
Float text takes 50 to 70 times fewer instructions, and almost all of
that is the algorithm. To find the shortest text that reads back as the
same Float, the old C called snprintf with more and more digits, up
to 17 times per Float. The Xo version uses Dragonbox, ported from Go
1.26’s ftoadbox.go, which computes the shortest digits directly. Nothing here says C could not
do the same with the same algorithm. What the port bought
is that the better algorithm now lives in code compiled with the
program, on every native backend and the WebAssembly one.
Float.parse: Go’s parser, whole
Float.parse is Go 1.26’s atof.go ported whole: special values, decimal
and hex text, underscores, the exact fast path, Eisel-Lemire, and the
800 digit big decimal fallback. One change was forced by measurement.
Parsing subnormal numbers (the tiniest Floats) through Go’s big decimal
took about 94 times the C’s instructions in a scratch program
(54,810,989,247 against 585,989,260 for 200,000 texts), and the whole
workload stood at 2.85x of the C. Rounding subnormals inside
Eisel-Lemire instead brought it to 0.28x.
Porting the whole parser also fixed a difference nobody had filed: the old C parser on the native backends took neither underscores nor hex floats nor texts over 4096 bytes, while the Go backend did. Native builds now parse exactly what the Go backend parses.
What the compiler needed
Dragonbox and Eisel-Lemire are 64 bit integer code. To write them in
Xo without a C call per operation, the compiler gained intrinsics that
the arm64 and LLVM emitters lower inline (the WebAssembly runtime has
them too): a 64 by 64 to 128 bit multiply (umulh on arm64), wrapping
add, subtract, multiply, and shifts, a Float’s bits and back, and an
unchecked 8 byte load whose callers check the range.
Two smaller backend changes came from the Int text work. The arm64
backend now takes every logical immediate that repeats a pattern
(0x3030303030303030 used to take four instructions to build), and
appending to an empty string literal, which is what "${x}" lowers to,
now makes the new string directly. That last change moved Int text from
1.139x on arm64 to 0.800x; the C path had always done it in one call.
The piece that lost
Hex encoding (Bytes.to_hex) was ported too. The Xo version went from
1.516x to 1.093x of the C on arm64, and a scratch program of 100,000
encodes measured 61,934,145 instructions against the C’s 53,477,421.
That misses the bar, so to_hex stays C; the decoder, which was even
with the C, moved. An attempt to batch the decoder’s writes was worse
(1.140x) and was taken out.
The same rule kept an earlier piece in C. Duration text, from step 1, is still 1.171x on arm64 and 1.087x on LLVM after the arm64 backend learned to divide by a constant with a multiply (more on that in Measure, don’t guess), so it stays C as well.
Correctness
A differential test feeds 336,386 lines to a native program on arm64
and LLVM, through both the Xo and the C paths, and compares every
output line with Go’s strconv, encoding/hex, and encoding/base64.
The inputs are Floats from random bits of every class, 19 digit texts
near the bottom of the range, exact halfway points between neighboring
Floats written with 800 digits (and just above and below them), powers
of ten from 1e-350 to 1e330, malformed texts, hex floats, underscores,
random Ints, and damaged base64. Every line matches. The golden tests
pass on arm64 and LLVM, and the self-hosted compiler’s SSA, arm64,
LLVM, and WebAssembly comparisons pass.
What is next
The C path for this step stays selectable with XO_LLRT_C=num for
now. Next in the port is std/json’s Float text and number parsing on
the same Dragonbox digits (step 4), then a second try at to_hex with
a wider write primitive. Each will be measured the same way, and each
stays C if it loses.
The figures in this post come from
bench/results/runtime-port-step3-2026-10-12.md and decision 0193 in
the repository.