Xo is experimental. The source code is not public yet; until then, try Xo in your browser.

Number text in Xo: moving a piece of the runtime out of C, one measurement at a time

2026-10-12

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):

Workloadarm64LLVM
Int to text0.800x0.734x
Float to text0.019x0.014x
Float.parse0.282x0.273x
base64 encode and decode0.385x0.310x
hex (decode in Xo, encode still C)1.003x0.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.