Skip to content

Benchmarks ​

Wyn compiles to C and runs at near-native speed, in small binaries. On an Apple M3 Pro: fib(35) in 42ms (23x faster than Python, 14% faster than Go), a 50KB hello-world binary, 1M StringBuilder appends in 5.6ms, and ~360ms to compile hello world from a fresh install.

Method. Apple M3 Pro (12 logical cores: 6 performance + 6 efficiency), macOS 26, Wyn 1.21.0 - and specifically the published release tarball, not a dev build, because that is what you install. Wyn binaries built with wyn build --release; C with clang -O2 (Apple clang 21); Go 1.26.5 with go build; Rust 1.96.0 with rustc -O; Python 3.14.6. Every table on this page was re-measured from scratch for v1.21.0, in one session on one machine - previous editions mixed numbers taken at different times, which is how a stale comparison survives.

Process-level rows are the median of at least 11 warm runs, timed by a fork/exec/wait4 harness rather than a shell loop, so no interpreter overhead is folded in. That still means a ~7ms process-startup floor is included in every process-level row - it is not subtracted, so short benchmarks look slower than the work they do. In-process rows (sorts, strings, concurrency) time the operation itself and say so. Discard the first run of any new binary on macOS: first-exec malware scanning adds 1-3s to any binary, Wyn's or C's.

Honest numbers, including the ones we lose. Run them yourself.

Compute ​

Whole-process wall clock, so the ~7ms startup floor is in every column.

BenchmarkWynPython 3.14Go 1.26Rust 1.96C
fib(35)41.6ms952ms48.2ms44.6ms46.1ms
Hello world, whole process6.9ms37.0ms7.8ms7.3ms6.9ms

Compute-heavy code runs at near-native speed because it is native code. CPU performance is largely the C compiler's (clang/gcc), not Wyn's.

Where we win, with a caveat: Wyn's 41.6ms on fib(35) is ahead of C's 46.1ms on this machine. That is a microbenchmark artifact - different inlining decisions on one tiny recursive function - not a claim that Wyn is faster than C. Both are clang output, and hello world lands on exactly the same number.

Sorting ​

These rows time the sort() call itself, not the whole process. All three languages were fed byte-identical input from the same LCG seed; each program prints the first element of the sorted result and all three agree, so this is the same work three times.

BenchmarkWyn (--release)Go 1.26Python 3.14
sort 10K ints0.50ms0.57ms1.6ms
sort 100K ints6.3ms7.0ms21.5ms
sort 1M ints73ms83ms291ms

A correction to an earlier edition of this page, which said Go was ~35% faster on a 1M-element sort (109ms vs 81ms) and made a paragraph out of it. That comparison was wrong in a way worth naming: the Wyn figure came from a dev build and the Go figure from go build, which optimises by default. A Wyn dev build really does take 110ms on this benchmark - the number was not invented - but this page's own stated method is --release, and --release does it in 73ms. Compare optimised against optimised and Wyn is ~12% ahead. The lesson is boring and general: label the build mode in the harness, not in the prose.

Strings ​

In-process timings.

BenchmarkWynPython 3.14
1M appends (StringBuilder)5.6ms36ms (list + join)
1M appends (s = s + "x")11.5s9.9s
1M .len()1.7ms99ms
100K method chain (.upper().trim())9.2ms15ms

Wyn uses reference-counted strings with a length cache in the RC header, which is why .len() is normally O(1) - 1.7ns per call, independent of length.

One documented exception, found while re-measuring this page. The length cache is attached when the string is created, and some StringBuilder.to_string() results arrive without it, in which case .len() falls back to an O(n) scan. On a 100,000-character builder result that is ~2.5μs per call instead of ~1.7ns, and a .len() inside a loop turns into the dominant cost of the program. If you are calling .len() repeatedly on a builder result, hoist it into a variable. This is filed against the compiler; the O(1) path covers literals, interpolation, concatenation, slices, .repeat(), .upper() and .join().

Strings are immutable, and naive concatenation is a real trap. s = s + "x" copies the whole string every time, so accumulating in a loop is O(n²) - the same trap as Java string concat. At 1M iterations that costs 11.5 seconds, and Wyn is slower than Python on this exact shape (9.9s; CPython is quadratic here too, so being compiled buys nothing when the algorithm is quadratic). Use StringBuilder - the 5.6ms row above, ~2,000x faster - or build an array and .join() it.

Binary Size ​

ProgramSize
Hello world (--release)50KB (51,592 bytes)
Hello world (dev build)51KB (52,696 bytes)
REST API server (92 lines, web package)69KB (70,968 bytes)

For scale, hello world on the same machine: C 33KB (33,432 bytes), Rust 421KB (431,416 bytes), Go 2.4MB (2,492,466 bytes).

Binaries include the Wyn runtime (networking, file I/O, concurrency, ARC). No external runtime needed. Dead-code stripping removes unused functions, which is why dev and release builds are within a kilobyte of each other for a trivial program.

Compilation ​

Every row deletes its output first, so this is a real build every time - see the Go note below for why that matters.

Method note for the rows re-measured on 2026-09-22. Unlike the rest of this page (median of 11 warm runs), the compilation rows below are the minimum of 15-60 runs with a calibration reference interleaved, because the machine was not idle - it was sharing 12 cores with other build jobs, and on Apple Silicon a contended process also gets moved onto efficiency cores, which inflates a median badly. Contention only ever adds time, so the minimum is the best estimate of the uncontended cost. To show how close each batch got to idle, every batch interleaved clang -O2 on a C hello world - whose idle cost on this machine is the 101ms quoted further down, and whose 33,432-byte output is byte-identical to the C binary in the Binary Size table - and reported its minimum too. The batches behind the rows below landed at 1.07x and 1.10x of that reference, so the figures carry roughly 7-10% uncertainty. Re-run them on a quiet machine and they should come in at or just under what is printed here.

MetricValue
wyn check (type check only, hello world)9ms - but see below
wyn build (hello world, dev)356ms
wyn build --release (hello world)~1.5s
wyn run (hello world, already compiled)28ms

The --release figure was 1.18s on this page until 2026-09-22, and that was too low. Re-measured twice in independent batches: ~1.56s. A release build of hello world is essentially serial - child CPU time (user+sys) is 0.97x of wall - and the CPU time alone is ~1.33s, so 1.18s of wall clock was not reachable even in principle. The dev build, wyn run and binary sizes on this page all re-measured correctly; only --release moved.

wyn check on hello world is not a check-speed figure - it is process startup.wyn version, which does no work at all, takes 8.4ms on this machine. wyn check on a 3-line hello world takes 9.0ms. The checking is therefore about 0.6ms; everything else is the process. Quoting a single-digit millisecond "whole-program verdict" off a 3-line program says nothing about a real one, so this page now measures realistic code and states the process floor next to it.

How it scales. Each row is a real program - unique structs with impl blocks, enums with payloads, match, Option-returning lookups, generics, string interpolation, array building and cross-function calls, with distinct symbol names throughout - not one statement repeated N times. Every row type-checks clean and runs:

Project sizewyn checkwyn buildwyn build --release
Hello world (3 lines)9ms (8.4ms of it startup)356ms~1.5s
1,063 lines17ms370ms1.7s
5,065 lines72ms660ms3.0s

A correction to an earlier edition of this table, which claimed 12ms / 12ms / 14ms / 33ms of wyn check at hello world / 500 / 1,000 / 5,000 lines. The 5,000-line figure was more than 2x too low, and the reason is the fixture, not the machine: 5,000 lines of one repeated statement check in 19ms, while 5,000 lines of realistic code check in 72ms - same line count, 3.8x the time, measured back to back in the same batch. A synthetic program made by repetition re-walks one tiny symbol table; real code makes the checker resolve hundreds of distinct types. Line count is the wrong unit - wyn check scales with the number of declarations, and mildly superlinearly at that (4.8x the lines cost 8x the checking work once the startup floor is subtracted).

It is still fast in absolute terms - 72ms to type-check 5,000 lines is a usable edit-loop and agent-loop oracle - it is just not 33ms, and the honest way to quote it is with the fixture and the floor attached.

Wyn's two-stage compilation (Wyn → C → binary) keeps build time dominated by the C compiler invocation, which is why the build columns grow much more slowly than the check column. Dev-build wall time is the noisiest figure on this page - repeated sessions on this machine gave 340-405ms for hello world.

For scale, all forcing a real build: go build 189ms, rustc -O 185ms, cargo build from clean 357ms (dev) and 368ms (--release), clang -O2101ms.

Why the Go compile figure went up from ~96ms, and it is not Go getting slower.go build skips the link step when the output binary is already present and current, so a benchmark that rebuilds in place measures a no-op: 98ms with the binary left in place, 189ms with it deleted first. The old ~96ms came from the no-op path and was being compared against a Wyn build that always relinks. Wyn is still ~1.9x slower to build hello world than Go - just not 3.7x.

Why the dev-build number went up from 288ms. It did not: the old figure was measured in a source checkout, which carries a precompiled header built by make runtime. Parsing wyn_runtime.h is roughly two thirds of an unoptimised C compile, so the pch is worth ~70ms - and the release tarball deliberately does not ship one, because clang hard-errors if a pch's flags differ from the including file's, which would break builds for anyone whose toolchain differs from the one that produced it. So 288ms was real but not what you get. 356ms is the number from a fresh install; a source checkout with the pch present does the same build in ~285ms. Release builds are unaffected, which is why that column barely moved.

Concurrency ​

BenchmarkValue
spawn + await, 10K sequential14.9ms (~1.5μs/task)
spawn + await, 100K sequential159ms (~1.6μs/task)
1M fire-and-forget spawn0.69s (Go goroutines: 0.28s)
8 overlapping Time.sleep(100), awaited103ms (not 800ms)
8 overlapping Time.sleep(100) via await_all102ms
50 overlapping Time.sleep(200) via await_all204ms (not 10s)
50 overlapping Time.sleep(100) via await_all103ms (not 5s)
200 overlapping Time.sleep(100) via await_all104ms (not 20s)
4x fib(35) via spawn + await_all33ms - same as one fib(35) (true 4x)

Fire-and-forget spawn runs on the coroutine scheduler; awaited work overlaps cooperatively.

How we know "cooperatively" is the truth and not a slogan. The coroutine scheduler is the default executor for awaited work, and WYN_ASYNC_POOL=1 switches it back to the legacy fixed thread pool. That makes the claim falsifiable with one binary and one environment variable - the same program, back to back, awaiting N 100ms sleeps:

N awaited 100ms sleepsdefault (coroutines)WYN_ASYNC_POOL=1 (thread pool)
8105ms105ms
50103ms415ms
200104ms1,645ms

The pool column is the ceil(N / 13) x 100ms staircase you would expect from a fixed pool sized to this machine's 12 cores (plus the awaiting thread): 8 -> 1 round, 50 -> 4 rounds (415ms), 200 -> 16 rounds (1,645ms). The default column is flat. If awaited work were secretly on a thread pool, 200 sleeps could not finish in 104ms. Concurrency here is limited by what you are waiting for, not by a pool width you have to tune - which is the whole point of the design, and why there is no knob for it.

(CPU-bound work is different, and the pool width reappears as core count - see the scaling note below. The claim is about waiting, not about parallel throughput.)

The sleep rows are the headline result: overlapping I/O waits cost about as much as the single longest wait.

CPU-bound scaling, and where it stops. Four spawned fib(35)s finish in the time of one. Beyond four they queue: 4 tasks 34ms, 6 tasks 65ms, 8 tasks 99ms, 12 tasks 102ms, 24 tasks 120ms. So the useful figure is ~4 concurrent CPU-bound tasks on a 6+6 core machine, and past that you get throughput rather than latency.

parallel { } is narrower than spawn right now. On identical work, in the same process, back to back: four branches of fib(35) inside parallel { } take 62ms where four spawns plus await_all take 33ms. Two branches in parallel { } do overlap fully (34ms); three and four both take two dispatch rounds. An earlier edition of this page credited parallel { } with the 4x result - that number belongs to spawn/await_all. Use spawn + await_all when you need dependable overlap of more than two CPU-bound branches; the parallel { } width is filed as a defect.

Memory: 10K outstanding spawns peak at ~3.0MB RSS; 1M at ~84MB. Go's 1M goroutines peak at ~21MB in the equivalent harness, so Go is still far denser on massive fan-out - and Go's version waits for every goroutine to finish where Wyn's only dispatches, which makes that comparison conservative in Go's favour.

Web ​

Reproduce every row below with the committed harness:

./benchmarks/http_load.sh

ab against a release build on loopback, plain-text response, one coroutine per connection. Each configuration gets a warmup run that is thrown away and then a measured run of 20,000 (keep-alive) or 10,000 (connection-per-request) requests.

The figures below are the spread across two full sweeps, not a single best run, because the machine was not idle for either of them (load average 17 and 60 - macOS was indexing and syncing throughout). The harness warns about exactly this and says to re-run idle before publishing; treating both sweeps as a range is the honest alternative. They agree within ~5%, which is itself the useful signal.

ScenarioThroughput (2 sweeps)
HTTP/1.1 keep-alive, 50 concurrent~23,000 req/s, 0 failed
HTTP/1.1 keep-alive, 100 concurrent~22,100-23,200 req/s, 0 failed
HTTP/1.1 keep-alive, 200 concurrent~21,900-22,200 req/s, 0 failed
HTTP/1.1 keep-alive, 10 concurrent~20,900-22,400 req/s, 0 failed
No keep-alive (connection per request), 10 concurrent~6,600-7,100 req/s, 0 failed
No keep-alive (connection per request), 200 concurrent~6,500-6,700 req/s, 0 failed

Keep-alive is worth ~3.3x here - without it you pay a TCP handshake per request. Open file descriptors after each whole sweep: 9, so there is no per-request leak. v1.21.0 also changed this path: Http.accept now skips a connection that sends no request instead of returning "", so a port scan or health check no longer kills the server. Throughput is unchanged by that fix.

Two things worth knowing before you compare these to anything:

  • Warmup matters more than concurrency. The scheduler's worker pool spins up lazily, so the same configuration measures ~15,000 req/s cold and ~23,000 warm. That is why the harness discards a warmup run; a benchmark without one is mostly measuring pool startup. Concurrency past ~10 barely moves the number.
  • Keep-alive needs a handler that loops. Persistent connections are a property of your handler, not just the runtime: a handler that reads one request and returns has nothing left to serve a second request with. Write it as while true { req = web.read_request(conn) ... } (the shape used by the web package's examples/hello.wyn and by this harness) or every request pays for a new connection and you get the ~6,700 row instead.

Memory ​

BenchmarkValue
Hello world1.4MB peak RSS
1M StringBuilder appends3.4MB peak RSS
1M naive s = s + "x" concat6.2MB peak RSS

For scale, hello world peak RSS on the same machine: C 1.3MB, Rust 1.5MB, Go 3.9MB, Python 14.6MB.

Wyn uses Automatic Reference Counting (ARC) with scope-based cleanup. Strings, arrays, and HashMaps are freed deterministically at block exit. The runtime is continuously tested under AddressSanitizer and ThreadSanitizer in CI.

Sharing mutable state across concurrent tasks requires Shared or a channel. Plain arrays, HashMaps, HashSets and counters are not synchronized; as of v1.21.0 mutating one from two tasks panics with the fix named rather than corrupting silently (writer-vs-writer only - a read concurrent with a write is still unguarded).

See Also ​

MIT License - v1.21.0