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.

MetricValue
wyn check (type check only)12ms
wyn build (hello world, dev)356ms
wyn build --release (hello world)1.18s
wyn run (hello world, already compiled)28ms

How it scales, on synthetic programs of the stated length:

Project sizewyn checkwyn buildwyn build --release
Hello world12ms356ms1.18s
500 LOC12ms359ms1.19s
1,000 LOC14ms376ms1.20s
5,000 LOC33ms505ms1.32s

Wyn's two-stage compilation (Wyn → C → binary) scales well: the Wyn-to-C step is a single-pass transpiler and most of the wall time is the C compiler invocation, which is close to constant at this size. Dev-build wall time is the noisiest figure on this page - repeated sessions on this machine gave 340-405ms for hello world, and the table is a median of 9.

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