Go vs transpiled C# — runtime performance comparison

A small, targeted benchmark suite answering the question people ask first: how fast is transpiled C# compared to the original Go? — startup time and memory, on both the normal JIT runtime and Native AOT (self-contained, the closest deployment analog to a Go binary).

Each benchmark is a tiny Go program (same shape as the behavioral tests), chosen to exercise one Go construct with a real C# cost model — slices, strings, maps, channels, interface dispatch — plus raw compute loops where the two runtimes should be close. This is not an exhaustive benchmark game; it gives the common expected range of differences. The short version: transpiled C# is usually slower than Go, but not universally — maps and the stack-string path run at parity or faster in both C# variants (maps dramatically so under Native AOT), while the rest ranges from ~1.5× on tight compute to the structural-interface assert, the honest outlier at the other end.

The benchmarks

Benchmark What it exercises
Startup Empty workload: pure process start + runtime init + one fmt round-trip. Wall time.
Fib Recursive Fibonacci (fib(34) ×5): function-call and integer-op overhead.
Sieve Sieve of Eratosthenes to 10M ×3: slice allocation, indexing, tight loops (slice<T> bounds/header emulation).
MatMul 256×256 float64 matrix multiply ×4: floating-point throughput, nested slice-of-slice access.
String 10M iterations of byte-slice append → string conversion, indexing, concatenation (@string emulation).
StringView 20M iterations of keyword checks string(buf) == "null"/"true"/"false" over a fixed buffer — the idiom the converter’s stack-string (sstring) emission optimizes: a zero-copy view compared against a u8 literal span, no per-comparison allocation.
StringMatch 20M iterations of literal-string hot paths: switch-on-string dispatch, strings.HasPrefix with a literal prefix, literal returns, literal map-key counters — the shapes where a naive conversion would allocate a fresh @string per evaluation while Go allocates nothing (string literals live in RODATA). Instrument for the literal-comparison optimizations (span operators, u8 casts, hoisted literals).
Map 2M inserts + 2M comma-ok lookups + 1M deletes on map[int]int (map<K,V> emulation).
Sort sort.Ints on 2M deterministic pseudo-random ints (sort.Interface dispatch through the runtime’s reflection-bound Interface<T>).
Channel 1M ints producer→consumer through a buffered channel with one goroutine (channel<T> + goroutine scheduling emulation).
IfaceCall 50M iterations of pure interface method dispatch — interface values of statically-known types built once, called in a megamorphic hot loop; no asserts, no switches. The row that answers “what does calling an interface method cost?”
Iface 20M iterations of the common interface cases: method dispatch, concrete comma-ok assertions, and a type switch over a closed set — all resolved by the compile-time (nominal) machinery: generated adapters and cast-shaped asserts. What ordinary Go interface code costs.
IfaceShell 5M iterations × 2 duck-typed interface asserts + forwarded calls — one on a value-typed dynamic value (the reflective object shell), one on a pointer-sourced one (the delegate-bound generic shell). The one path with no compile-time answer, and the only shared mechanism whose Native AOT behavior is otherwise unexercised.

Every benchmark prints a deterministic checksum (verified byte-identical across Go, C# JIT, and C# AOT before anything is measured) plus its own workload time measured in-program via time.Now().UnixNano() — so the headline numbers exclude process startup, which is reported separately by the Startup row.

Methodology / fairness notes

Running it

cd src/tests/Performance
./run-performance.ps1                    # full run: transpile, build (incl. AOT), verify, measure
./run-performance.ps1 --no-aot           # skip AOT publishes, faster while iterating
./run-performance.ps1 --filter Map       # one benchmark
./run-performance.ps1 --runs 10 --update-readme   # refresh the results block below

Requirements: Go toolchain, .NET 9 SDK, and — for the AOT column — a native linker and toolchain, which differs by host:

Host Native AOT prerequisite
Windows MSVC C++ build tools — Visual Studio 2022’s “Desktop development with C++” workload, which supplies the link.exe ILC needs. The runner prepends the VS Installer directory to PATH so the SDK’s vswhere probe finds it.
Linux clang and zlib1g-dev (Debian/Ubuntu: sudo apt install clang zlib1g-dev; Fedora: clang zlib-devel). ILC shells out to clang for the native link step.
macOS The Xcode command line tools (xcode-select --install), which supply clang and ld.

Deliberately not scripted: run-performance.ps1 does not install any of these. A benchmark harness that silently mutates the machine’s toolchain is not a harness anyone should trust with a performance claim. --no-aot drops the whole column and needs none of them (F13, PLAN-linux-operation.md).

The environment line of the results block reads the CPU name from the registry on Windows and from /proc/cpuinfo on Linux, so a published table always names the part that produced it.

The runner (PerformanceRunner), a dependency-free console app structured like the behavioral suite’s BehavioralRunner, runs Transpile → Build → Verify → Measure. Verify requires all three binaries to produce identical (timing-filtered) stdout before anything is timed, so the table can never silently report a benchmark that computes something different in C#.

Results

Environment: AMD Ryzen 5 PRO 6650U with Radeon Graphics · Microsoft Windows 10.0.26200 · go1.23.1 · .NET SDK 9.0.316 · 2026-08-13

C# builds: JIT = framework-dependent Release; Native AOT = -p:PublishAot=true self-contained, partial trim. Median of 5 runs (1 discarded warmup). Workload time is measured in-program and excludes process startup; the Startup row is pure process wall time. Ratios are relative to Go.

Execution time (milliseconds – lower is better):

Benchmark Go C# (JIT) C# (Native AOT)
Startup 25.2 223.3 (8.88×) 77.8 (3.09×)
Fib 116.5 191.2 (1.64×) 172.8 (1.48×)
Sieve 65.6 105.4 (1.61×) 215.2 (3.28×)
MatMul 104.3 173.0 (1.66×) 523.2 (5.02×)
String 103.0 1,006.3 (9.77×) 1,257.2 (12.21×)
StringView 19.7 20.5 (1.04×) 18.5 (0.94×)
StringMatch 189.0 978.0 (5.18×) 1,220.2 (6.46×)
Map 617.2 478.2 (0.77×) 180.5 (0.29×)
Sort 136.8 429.6 (3.14×) 438.3 (3.20×)
Channel 39.3 90.4 (2.30×) 110.0 (2.80×)
IfaceCall 179.6 432.4 (2.41×) 451.1 (2.51×)
Iface 94.9 543.6 (5.73×) 452.5 (4.77×)
IfaceShell 21.4 865.2 (40.50×) 1,288.6 (60.32×)
RefLower 226.3 660.6 (2.92×) 1,827.8 (8.08×)

Peak memory (working set, MB – lower is better):

Benchmark Go C# (JIT) C# (Native AOT)
Startup 3.0 45.1 78.4
Fib 5.7 45.3 78.6
Sieve 26.3 65.6 98.2
MatMul 10.3 52.4 84.9
String 5.8 55.1 87.3
StringView 5.8 45.5 78.5
StringMatch 5.8 55.5 88.7
Map 158.8 164.8 197.0
Sort 21.9 61.4 94.0
Channel 5.7 51.0 86.3
IfaceCall 5.7 46.2 78.7
Iface 5.7 45.7 78.9
IfaceShell 5.7 66.2 97.7
RefLower 5.7 46.3 78.6

Reading the results