Skip to content

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.

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.

Languagefib(32)sieve(5M)strcat(2M)arrayiter(5M)intloop(50M)
Tulpar AOT0.67.713.21.2134.5
C (gcc -O2)1.67.637.62.2134.4
C++ (g++ -O2)1.98.014.72.7134.9
Rust (-O3)3.88.118.71.5144.0
Go6.78.524.34.3134.5
Java12.419.732.918.2144.1
C# (.NET)20.320.931.218.3149.3
Node.js24.628.296.418.5712.2
Python140.8448.1200.0410.23127.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:

  • intloop and sieve are 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-rolled realloc + snprintf loop, 2.5× slower than C++‘s std::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):

BenchmarkC -O2C -O3 -march=native -fltoTulpar (generic)Verdict
fib(32)2.492.08 ± 0.040.77 ± 0.02Tulpar 2.7× — holds
strcat(2M)37.9536.98 ± 0.3713.27 ± 0.21Tulpar 2.8× — holds
sieve(5M)7.867.75 ± 0.147.77 ± 0.10tie (inside MAD)
arrayiter(5M)2.501.852.03C 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:

  • strcat compares 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 with snprintf, a general-purpose formatter, while Tulpar uses a specialised integer-to-string routine optimised for exactly this path. A C programmer hand-rolling itoa would 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 in n (naive tree φ = 1.618):

    exponent
    clang -O31.604
    gcc -O21.607
    Tulpar, chain disabled1.612
    Tulpar1.488

    gcc’s exponent is naive too — its fib body is 264 instructions against clang’s 20, so aggressive unrolling, but a constant-factor win. Tulpar’s clone chain inlines fib once, giving fib(n) = fib(n-2) + 2·fib(n-3) + fib(n-4), and common-subexpression elimination merges the duplicated fib(n-3). Three calls remain (confirmed in the disassembly), the recurrence becomes T(n) = T(n-2)+T(n-3)+T(n-4), and its root is 1.4656 — measured 1.488.

    Consequence: the fib ratio is not a constant — it grows with n. 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. The fib(32) row in the table above is one point on a diverging curve — the gap narrows for smaller n and 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:

KernelWhat it measuresUses arrays?C (gcc -O2)TulparRatio
mandelbrot(2000)pure floating-point arithmeticno158.6158.41.00×
nbody(3M)arithmetic + small arrays + sqrtyes, small114.81326.111.6×
matmul(640)floating-point arraysonly that31.0824.526.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):

timebytes/element
int[], C (long long*)13.9 ms8.1
int[], Tulpar20.6 ms4.1
float[], C (double*)16.0 ms8.1
float[], Tulpar87.0 ms16.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.

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

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.

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.

Serverreq/secp50 latencyConfiguration
Tulpar listen_pool~36k0.32 msall 14 cores, 1 process
Go net/http~30k0.38 msall cores (default), 1 process
Node.js http~8.7k1.06 ms1 thread (default)
FastAPI (uvicorn)~3.5k3.31 ms1 worker (default)
Tulpar listen~4–4.7k0.22 ms1 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).

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.

  • Unboxed numeric arrays. An array is either a boxed VMValue vector 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/while is 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 int stays 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 fib by 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. StringBuilder plus a boxing-free sb_append and a fast itoa; 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): untyped fib 11.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.

  • 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 (a toString buffer caused ~1.1% spurious 404s until fixed).
  • break / continue real 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).
  • 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 from BENCH_N at 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 older run_benchmarks.sh harness 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.