Benchmarks
Tulpar’s tagline is “as easy as Python, as fast as C”. On CPU it beats C on
fib and strcat even when C is given -O3 -march=native -flto, ties it on
sieve and intloop, and is indistinguishable from C on pure floating-point
arithmetic — but runs 5–27× behind on floating-point arrays (measured,
below); on HTTP
its multi-core listen_pool server out-throughputs Go’s net/http and leaves
FastAPI far behind — all from a single self-contained binary with no runtime to
ship.
CPU benchmarks
Section titled “CPU benchmarks”Nine languages, eight microbenchmarks, one machine. Lower is faster; the
fastest entry in each column is bold. Every language reads its workload size
from the BENCH_N environment variable, so no compiler can fold the loop to a
closed form and “win” without executing it. Best of 7 runs, 2026-09-08, on one
Linux box (Tulpar built against LLVM 22), commit 3edb4f4.
| Language | fib(32) | sieve(5M) | strcat(2M) | arrayiter(5M) | intloop(50M) |
|---|---|---|---|---|---|
| Tulpar AOT | 0.6 | 7.7 | 13.2 | 1.2 | 134.5 |
| C (gcc -O2) | 1.6 | 7.6 | 37.6 | 2.2 | 134.4 |
| C++ (g++ -O2) | 1.9 | 8.0 | 14.7 | 2.7 | 134.9 |
| Rust (-O3) | 3.8 | 8.1 | 18.7 | 1.5 | 144.0 |
| Go | 6.7 | 8.5 | 24.3 | 4.3 | 134.5 |
| Java | 12.4 | 19.7 | 32.9 | 18.2 | 144.1 |
| C# (.NET) | 20.3 | 20.9 | 31.2 | 18.3 | 149.3 |
| Node.js | 24.6 | 28.2 | 96.4 | 18.5 | 712.2 |
| Python | 140.8 | 448.1 | 200.0 | 410.2 | 3127.3 |
Empty-program baseline on the same box — process start-up, and it is included in every number above: C 0.17 · Tulpar 0.23 · C++ 0.44 · Python 5.6 · C# 8.2 · Node 10.7 ms.
Tulpar AOT beats C on fib and ties it on sieve and intloop. The
strcat and arrayiter entries above are not wins over C once C is given
equal flags and the same integer-formatting routine — see the two sections
below before quoting anything from this table.
Three things to read carefully before quoting a row:
intloopandsieveare effectively a four-way tie. In both, the top four languages sit within 0.5 ms of each other; at that spread the rank order moves between runs. The band is the signal, not the position in it.- C is third from last on
strcat(37.6 ms) — a hand-rolledrealloc+snprintfloop, 2.5× slower than C++‘sstd::string. Container choice beats language choice, and “C is always fastest” is not a law. - Java and C# pay start-up in these numbers. C#‘s empty program alone costs 8.2 ms. The benchmark measures wall clock, which is what a user feels, so the cost is shown rather than subtracted — but it is most of those two rows on the short workloads.
Does the lead survive C’s best compiler flags?
Section titled “Does the lead survive C’s best compiler flags?”The table compiles C with gcc -O2 — the same generic baseline Tulpar itself
targets (its LLVM target CPU is "generic" by default; the -march=native
equivalent is opt-in and not used for these numbers). That is a fair default,
but “faster than C” is an extraordinary claim, so it was re-measured with C
given its best flags. 12 interleaved repetitions, median ± MAD, on one
AMD Ryzen 7 9800X3D (Zen 5, 5.27 GHz):
| Benchmark | C -O2 | C -O3 -march=native -flto | Tulpar (generic) | Verdict |
|---|---|---|---|---|
fib(32) | 2.49 | 2.08 ± 0.04 | 0.77 ± 0.02 | Tulpar 2.7× — holds |
strcat(2M) | 37.95 | 36.98 ± 0.37 | 13.27 ± 0.21 | Tulpar 2.8× — holds |
sieve(5M) | 7.86 | 7.75 ± 0.14 | 7.77 ± 0.10 | tie (inside MAD) |
arrayiter(5M) | 2.50 | 1.85 | 2.03 | C wins with native |
So the honest statement is narrower than “beats C”: fib and strcat are
robust wins that survive C’s best flags, sieve and intloop are ties, and
arrayiter is a Tulpar win only at equal generic flags. Going -O2 → -O3
alone changes almost nothing (fib 2491 → 2290 µs, strcat 37951 → 38133); what
flips arrayiter is -march=native.
Two disclosures the numbers don’t carry on their own:
-
strcatcompares different tools, not only different compilers. The C side is not naive — it uses a geometric-growth buffer (cap *= 2) — but it formats each number withsnprintf, a general-purpose formatter, while Tulpar uses a specialised integer-to-string routine optimised for exactly this path. A C programmer hand-rollingitoawould close much of the gap. -
fib’s lead is algorithmic, not micro-architectural (measured and named 2026-09-11). With process start-up subtracted using the same binary, the growth exponent per +1 inn(naive tree φ = 1.618):exponent clang -O3 1.604 gcc -O2 1.607 Tulpar, chain disabled 1.612 Tulpar 1.488 gcc’s exponent is naive too — its
fibbody is 264 instructions against clang’s 20, so aggressive unrolling, but a constant-factor win. Tulpar’s clone chain inlinesfibonce, givingfib(n) = fib(n-2) + 2·fib(n-3) + fib(n-4), and common-subexpression elimination merges the duplicatedfib(n-3). Three calls remain (confirmed in the disassembly), the recurrence becomesT(n) = T(n-2)+T(n-3)+T(n-4), and its root is 1.4656 — measured 1.488.⚠ Consequence: the
fibratio is not a constant — it grows withn. Start-up subtracted, against gcc-O2: 12.3× at n=34 · 12.0× at n=36 · 14.5× at n=38 · 17.3× at n=40. Thefib(32)row in the table above is one point on a diverging curve — the gap narrows for smallernand widens for larger. The honest statement: same complexity class, smaller base — both sides are exponential; the difference is in the base (1.488 vs 1.607), not the class. “Different class” would mean polynomial-vs- exponential, which this is not. The ratio grows as(1.607/1.488)ⁿ, so it is unbounded — but the growth stays inside the same family.
Floating point: there is no single answer, there are two
Section titled “Floating point: there is no single answer, there are two”All five kernels above are integer or string work. “What about float?” was
measured on 2026-09-11 with three new kernels — and it turned out the question
cannot be answered with one number, because there are two distinct costs:
- value representation — boxing/unboxing on every operation
- storage representation — bytes per element inside an array
The kernels were chosen to separate them:
| Kernel | What it measures | Uses arrays? | C (gcc -O2) | Tulpar | Ratio |
|---|---|---|---|---|---|
mandelbrot(2000) | pure floating-point arithmetic | no | 158.6 | 158.4 | 1.00× |
nbody(3M) | arithmetic + small arrays + sqrt | yes, small | 114.8 | 1326.1 | 11.6× |
matmul(640) | floating-point arrays | only that | 31.0 | 824.5 | 26.6× |
On pure floating-point arithmetic Tulpar is indistinguishable from C —
158.4 ms against 158.6 ms. Python in the same run: 15,721 ms. So float
values are not boxed; arithmetic runs on raw doubles in registers.
Arrays invert the picture. The cause was measured — same loop, only the element type changes (20M elements):
| time | bytes/element | |
|---|---|---|
int[], C (long long*) | 13.9 ms | 8.1 |
int[], Tulpar | 20.6 ms | 4.1 |
float[], C (double*) | 16.0 ms | 8.1 |
float[], Tulpar | 87.0 ms | 16.1 |
Tulpar’s integer array is narrower than C’s (4.1 bytes per element —
values that fit in 32 bits are narrowed automatically). In the same engine a
floating-point array takes twice the space, because unboxed storage
currently exists only for integers. matmul’s 26.6× exceeds the 4.2× of a
plain traversal: boxed elements cannot enter the “proven access” fast path,
and 16-byte elements also stop the compiler from vectorising the inner loop.
⚠ This table is also a record of why measuring one kernel misleads: someone
looking only at mandelbrot would say “float is free”, someone looking only
at matmul would say “float is 26× slower”. Both are wrong, because both
are true.
What this suite does not establish
Section titled “What this suite does not establish”Five integer/string kernels on one machine cannot support “fastest language”.
Not covered: hand-written SIMD, allocation pressure and hash-map/JSON
workloads — where the arena model’s costs would actually show — pointer
chasing, sorting, multi-threaded scaling, RSS, and sustained-load p99 latency.
The defensible reading: Tulpar is in C’s performance class on integer,
string and scalar floating-point kernels, decisively ahead of
Node/Python/Java/C#, ahead of C specifically on fib and strcat — and
measurably behind on floating-point arrays (the FP section above).
Machine: AMD Ryzen 7 9800X3D (Zen 5, 8C/16T, 96 MB 3D V-Cache), Linux. Toolchains: gcc 16.2.1 · rustc 1.89.0 · go 1.27.1 · Tulpar AOT (LLVM 22).
How these numbers are kept honest
Section titled “How these numbers are kept honest”An earlier version of this page put Tulpar at “1.37×–1.9× the runtime of C”. That
table was audited in September 2026 and thrown out — it had three flaws, each
enough to invalidate the result on its own, the worst being that only Tulpar read
its workload size at run time while gcc -O2 and rustc -O3 constant-folded
theirs away. The replacement suite lives in
benchmarks/fair/
and fixes all three:
- Workload size comes from the environment in every language, so nothing can be folded at compile time.
- Same algorithm, same data structure — with each language using its own
idiomatic tool (
StringBuilder/std::string/strings.Builder/Int32Array/int[]). Forcing a naive form on somebody measures the trap, not the language. - Outputs are compared across languages. If they disagree, the row is reported invalid instead of published. All five rows above agree.
- Warm-up discarded, best and median recorded, and the empty-program baseline printed alongside so you can see how much of a number is start-up.
Reproduce with cd benchmarks/fair && python3 run.py. Missing toolchains drop
their own row and the rest still runs.
HTTP throughput
Section titled “HTTP throughput”benchmarks/loadtest (a native C load generator) hammers each server with
GET / returning JSON {"hello":"world"} over keep-alive connections,
concurrency swept 1–12 (kept under the box’s core count so the load generator
never starves the server), 4 s per level, best run confirmed by a second pass.
Box: 14-vCPU WSL2. Each runtime in its recommended single-process config.
These HTTP figures come from a separate, earlier run on a different machine than the CPU table above — compare rows within each table, not across them.
| Server | req/sec | p50 latency | Configuration |
|---|---|---|---|
Tulpar listen_pool | ~36k | 0.32 ms | all 14 cores, 1 process |
Go net/http | ~30k | 0.38 ms | all cores (default), 1 process |
Node.js http | ~8.7k | 1.06 ms | 1 thread (default) |
| FastAPI (uvicorn) | ~3.5k | 3.31 ms | 1 worker (default) |
Tulpar listen | ~4–4.7k | 0.22 ms | 1 thread, serial accept loop |
Threading models differ and are labelled above: listen_pool and Go’s
net/http use every core out of the box, while Node and a single uvicorn worker
default to one. Tulpar’s single-thread listen() is a serial accept loop —
it has the lowest per-request latency (0.22 ms p50) but serialises keep-alive
connections, so for throughput use listen_pool (or listen_async). Notably,
single-thread listen() trails single-thread Node here; Tulpar’s lead comes
from listen_pool scaling cleanly across cores (p50 stays at 0.32 ms at 36k
req/s, with a clean sub-millisecond tail).
Versus FastAPI specifically, Tulpar also wins decisively on latency (~10× lower p50) and footprint — see the dedicated Wings vs FastAPI writeup (p50 0.31 ms vs 28 ms under load, 6.7 MB vs 54 MB RSS, 2 MB self-contained binary vs Python + ~50 MB of deps).
What got us here
Section titled “What got us here”Since the first fair measurement (2026-09-02), where Tulpar made the top three in
none of the five benchmarks: strcat 233.5 → 13.2 ms (17.7×), fib(32)
5.8 → 0.6 ms (9.7×), sieve 60.4 → 7.7 ms (7.8×), arrayiter
6.6 → 1.2 ms (5.5×). intloop never moved, as expected — it is a serial
dependency chain, so it measures the CPU, not the compiler.
Compiler / CPU
Section titled “Compiler / CPU”- Unboxed numeric arrays. An array is either a boxed
VMValuevector or a raw integer buffer; element access is an inlined GEP + load in codegen, with TBAA separating the element store from the array header, and a loop-invariant shape cache that reads the pointer and length once per loop instead of once per iteration. - Loop versioning. The body of a
for/whileis emitted twice; the fast version has no bounds check at all, because a single test at loop entry proved it redundant. The proof is semantic, not syntactic. - 32-bit element storage. C/Rust/Go use 4-byte elements in a sieve; we used
8. Arrays now start 32-bit and widen (never box) when a value that doesn’t
fit is stored — the language’s
intstays 64-bit. The width branch lives in version selection, not in element access, so both widths stay branch-free on the hot path. - Self-recursion chain. LLVM will not inline a function into itself (gcc
will — that was the only reason it beat every LLVM language on
fibby 2.4×). The backend emits four clones wired into a ring, so each edge is a call between two different functions and the ordinary inliner unrolls them. - Start-up cost. Every binary was loading OpenSSL and linking libstdc++ dynamically: empty program 1.15 → 0.23 ms.
- String building.
StringBuilderplus a boxing-freesb_appendand a fastitoa; string literals are interned. - Untyped path.
%had no boxed inline fast path and every boxed global assignment paid an unconditional runtime call; both are closed. Boxed functions also moved to a value ABI (arguments and return in registers): untypedfib11.9 → 7.7 ms.
Every one of those was found by measurement, and the ones that were measured and
rejected are recorded alongside them in
docs/mindmap/Performance.md.
Server hot path (HTTP)
Section titled “Server hot path (HTTP)”call(handler_name)dlsym cache (256-slot FNV-1a hash) — eliminates the symbol-table walk per request.- TCP_NODELAY on accept — removes Nagle’s 40ms batching delay on small JSON responses (+13% req/sec).
- Static thread-local recv buffer — drops a 64 KB malloc/free pair per keep-alive request.
- Per-request arena reset + per-request malloc region — bounded memory on long-running servers without leaking.
- Thread-local scratch buffers in built-ins — non-TLS statics raced under
listen_pool(atoStringbuffer caused ~1.1% spurious 404s until fixed). break/continuereal codegen — was silently no-op’d before, prevents LLVM from emitting suboptimal phi nodes around induction variables.
Trade-offs we explicitly skipped (analysed, low value):
- Object-key inline caching (
req["method"]~0.3 % of HTTP path). - String concat coalescing (
a + b + c~0.1 % of HTTP path).
Methodology
Section titled “Methodology”- CPU: nine toolchains on one Linux box,
gcc -O2/g++ -O2/rustc -O3/ Go / Java / .NET / Node / CPython / Tulpar AOT (LLVM 22). Warm-up discarded, best of 7 wall-clock runs, workload size read fromBENCH_Nat run time so no compiler can constant-fold it, outputs compared across languages. - HTTP: native
benchmarks/loadtest, single box, keep-alive, concurrency kept ≤ core count so the load generator and server don’t fight for CPU. Toolchains: Go 1.23, Node 22, Python 3.14 + FastAPI/uvicorn. Servers run in their default single-process configuration (core usage labelled per row). - Absolute numbers are box-specific; treat the ratios and latency as the
portable signal. Reproduce CPU via
cd benchmarks/fair && python3 run.py(the olderrun_benchmarks.shharness is the one whose results were retired); the HTTP servers + driver used here are minimal equivalents returning the same JSON. - These are microbenchmarks — tight loops on one machine. They isolate compiler and runtime cost and make the languages comparable on the same shape of work. They do not model production traffic: cold starts, large payloads, distributed clients, p99 tails, or GC pressure under sustained load.