Skip to content

Benchmarks

How SafeC compares to C, C++, Rust, Zig, Go, and Python on wall-clock time and peak memory, across a few classic microbenchmarks — in the spirit of The Computer Language Benchmarks Game and programming-language-benchmarks.vercel.app, scaled down to fit one CI-sized machine. Fastest and leanest cells are bolded per benchmark.

Read this before the numbers below

Single-machine, single-session, best-of-3 — not the Benchmarks Game's more rigorous multi-run methodology. Treat every number as "roughly this, on this machine, this day," not a universal truth about the language. Three benchmarks characterize these workloads, not overall language performance.

Methodology

  • Primary machine: Apple M1 Pro, 10 cores, 32 GB RAM, macOS 26.5.1 — safec 1.0.0 · Apple Clang 21.0.0 (C/C++) · Go 1.26.5 · Zig 0.16.0 · Rust 1.97.1 · Python 3.14.6 (+ NumPy 2.5.1 for SIMD).
  • Secondary machines (fib/n-body/binary-trees/multithreading/SIMD/web service, release build only): same AMD Ryzen 7 7800X3D box (no GPU), once under WSL2 Ubuntu 22.04 (clang 19.1.7, same Go/Zig/Rust versions) and once natively on Windows 11 (clang/LLVM ~19, MSVC-targeting). Different CPU architecture from the Mac, so treat cross-machine deltas as directional, not controlled.
  • Build flags — debug: -O0 (C/C++), unoptimized IR (SafeC), go build -gcflags="all=-N -l", Zig Debug, plain rustc. Release: -O2 (C/C++, and SafeC's clang -O2 backend — matching safeguard build --release), plain go build, zig build-exe -O ReleaseFast, rustc -O. Python has no debug/release distinction.
  • Timing: best (lowest) wall-clock of 3 runs — /usr/bin/time -l on macOS/WSL2, System.Diagnostics.Stopwatch on Windows (no /usr/bin/time equivalent there). Memory: peak RSS of the same runs, macOS/WSL2 only — Windows' Process.PeakWorkingSet64 consistently read 0 for these short-lived processes even after a refresh (an unresolved API quirk), so Windows memory is omitted throughout rather than shown unreliably.
  • Correctness: every binary's output is checked against the known-correct value before being included.
  • Every source file is linked inline next to its result.

fib(37) — recursive Fibonacci

Naive recursive Fibonacci — pure function-call and integer-arithmetic overhead, no allocation, no I/O.

LanguageDebugReleaseRelease peak memoryRelease compile time
SafeC0.14s0.08s1.3 MB (leanest)0.13s
C0.13s0.07s (fastest)1.3 MB (leanest)0.09s
C++0.13s0.07s (fastest)1.3 MB (leanest)0.09s
Rust0.18s0.09s1.5 MB0.18s
Zig0.16s0.08s1.4 MB5.60s
Go0.11s (fastest)0.08s4.0 MB0.08s
Python2.70s2.70s14.5 MBN/A (interpreted)

Release, across platforms:

LanguagemacOS (M1 Pro)WSL2 (7800X3D)Windows (7800X3D)
SafeC0.080s0.040s (fastest)0.046s
C0.070s0.040s (fastest)0.046s
C++0.070s0.040s (fastest)0.047s
Rust0.090s0.040s (fastest)0.049s
Zig0.080s0.040s (fastest)0.053s
Go0.080s0.080s0.092s
Python2.700s3.330s2.485s

Sources: fib.sc · fib.c · fib.cpp · fib.rs · fib.zig · fib.go · fib.py

n-body — 5-body orbital simulation

The classic Benchmarks Game n-body test (Sun/Jupiter/Saturn/Uranus/Neptune), 2,000,000 steps. Floating-point heavy, no allocation, tiny working set.

LanguageDebugReleaseRelease peak memoryRelease compile time
SafeC0.63s0.11s1.3 MB (leanest)0.13s
C0.42s0.10s (fastest)1.3 MB0.10s
C++0.41s0.11s1.3 MB (leanest)0.12s
Rust0.70s0.11s1.5 MB0.16s
Zig0.56s0.11s1.5 MB5.69s
Go0.28s (fastest)0.10s (fastest)4.1 MB0.09s
Python9.89s9.89s15.2 MBN/A (interpreted)

Release, across platforms:

LanguagemacOS (M1 Pro)WSL2 (7800X3D)Windows (7800X3D)
SafeC0.110s0.090s (fastest)0.102s
C0.100s0.090s (fastest)0.103s
C++0.110s0.090s (fastest)0.104s
Rust0.110s0.100s0.113s
Zig0.110s0.110s0.116s
Go0.100s0.100s0.113s
Python9.890s12.150s11.922s

Sources: nbody.sc · nbody.c · nbody.cpp · nbody.rs · nbody.zig · nbody.go · nbody.py

binary-trees — allocation/deallocation stress

Builds and discards millions of small binary trees (max depth 18) — exercises memory management rather than arithmetic. SafeC uses region/arena<R> (bump-pointer allocation, arena_reset<R>() discards a whole region in O(1)) instead of std::alloc/heap — this workload is all short-lived, same-scope allocations, exactly what regions are for.

LanguageDebugReleaseRelease peak memoryRelease compile time
SafeC0.29s (fastest)0.21s (fastest)25.4 MB0.14s
C1.67s1.56s17.4 MB (leanest)0.10s
C++1.87s1.86s17.4 MB (leanest)0.10s
Rust3.16s1.74s17.6 MB0.16s
Zig4.85s1.57s17.6 MB5.28s
Go2.52s1.26s40.8 MB0.09s
Python20.93s20.93s87.0 MBN/A (interpreted)

SafeC's plain-heap variant (std::alloc instead of arena) for reference: 1.15s release, 33.6 MB peak — arena is ~5.5x faster and ~23% leaner here.

Release, across platforms (SafeC uses arena on all three, for a fair comparison):

LanguagemacOS (M1 Pro)WSL2 (7800X3D)Windows (7800X3D)
SafeC (arena)0.210s0.130s (fastest)0.147s
C1.560s0.820s1.965s
C++1.860s0.930s2.025s
Rust1.740s0.900s2.184s
Zig1.570s0.880s2.020s
Go1.260s1.150s1.125s
Python20.930s20.130s10.019s

SafeC plain-heap variant, same three platforms: macOS 1.150s, WSL2 0.620s (fastest), Windows 1.119s.

Sources: binarytrees.sc · binarytrees_arena.sc · binarytrees.c · binarytrees.cpp · binarytrees.rs · binarytrees.zig · binarytrees.go · binarytrees.py

Collections — std::collections throughput (1,000,000 elements)

OperationThroughput
bst_insert1,577,110/sec
list_push_back65,427,899/sec
map_insert4,365,783/sec
map_get8,468,404/sec

bench_collections.sc

Multithreaded — binary-trees, 8 threads

Same binary-trees workload, parallelized across 8 worker threads — each thread builds/checksums an independent slice of the tree count at a given depth, joined before the next depth. Release only. SafeC: one region/arena<R> per thread (arena state isn't shared/locked) — measured against plain heap: 0.68s → 0.09s (~7.5x) on macOS.

LanguagemacOS 8-thread timePeak memoryvs. single-thread
SafeC0.09s (fastest)89.7 MB2.33×
C0.61s72.3 MB2.56×
C++0.63s72.5 MB2.95×
Rust0.68s72.2 MB (leanest)2.56×
Zig0.54s74.1 MB2.91×
Go0.44s144.2 MB2.86×
Python24.43s372.0 MB0.86× (slower than 1 thread)

8-thread time, across platforms (SafeC uses arena on all three):

LanguagemacOS (M1 Pro)WSL2 (7800X3D)Windows (7800X3D)
SafeC (arena)0.090s0.075s0.045s (fastest)
C0.610s0.274s0.549s
C++0.630s0.290s0.566s
Rust0.680s0.319s0.591s
Zig0.540s0.280s0.563s
Go0.440s0.358s0.320s
Python24.430s26.626s10.055s

SafeC plain-heap variant: macOS 0.680s, WSL2 0.308s (fastest), Windows 0.367s.

Sources: binarytrees_mt.sc · binarytrees_mt_arena.sc · binarytrees_mt.c · binarytrees_mt.cpp · binarytrees_mt.rs · binarytrees_mt.zig · binarytrees_mt.go · binarytrees_mt.py

SIMD — sum of squares over 20,000,000 f64 values

Plain scalar loop vs. each language's explicit vector type at -O2/release — isolates what explicit SIMD buys on top of the backend's auto-vectorizer, not "vectorized vs. deliberately crippled." SafeC: native vec<double,4> (lowers to LLVM's target-generic FixedVectorType, no per-architecture source needed). C/C++: GCC/Clang vector extensions. Zig: @Vector. Rust: stable-channel AArch64 NEON intrinsics on macOS (std::simd needs nightly) — the WSL2/Windows columns below use a separate x86_64/SSE2 source (simd_vec_x86_64.rs) written for this comparison. Go has no portable SIMD type, scalar only.

LanguagemacOS scalarmacOS explicit SIMDSpeedup
SafeC0.04s0.03s (fastest)1.33×
C0.04s0.03s (fastest)1.33×
C++0.04s0.03s (fastest)1.33×
Rust0.04s0.03s (fastest)1.33×
Zig0.04s0.03s (fastest)1.33×
Go0.06sN/A
Python3.50sN/A (0.14s with NumPy, 25.00×)

Across platforms — Apple Silicon wins outright on every language here, by a wide margin; treat that as a real result of this specific memory-bound microbenchmark on this hardware, not a general "M1 beats Ryzen" claim. Absolute times (tens of ms) are small enough that process-startup noise matters proportionally more than elsewhere.

LanguageScalar: macOS / WSL2 / WindowsExplicit SIMD: macOS / WSL2 / Windows
SafeC0.040s (fastest) / 0.099s / 0.067s0.030s (fastest) / 0.092s / 0.060s
C0.040s (fastest) / 0.099s / 0.077s0.030s (fastest) / 0.092s / 0.060s
C++0.040s (fastest) / 0.101s / 0.073s0.030s (fastest) / 0.092s / 0.073s
Rust0.040s (fastest) / 0.099s / 0.044s0.030s (fastest) / 0.093s / 0.039s
Zig0.040s (fastest) / 0.096s / 0.065s0.030s (fastest) / 0.089s / 0.069s
Go0.060s / 0.105s / 0.074sN/A
Python3.500s / 3.144s / 3.406s0.140s / 0.213s / 0.313s (NumPy)

Sources: simd_scalar.sc · simd_vec.sc · simd_scalar.c · simd_vec.c · simd_scalar.cpp · simd_vec.cpp · simd_scalar.rs · simd_vec.rs (macOS/NEON) · simd_vec_x86_64.rs (WSL2/Windows/SSE2) · simd_scalar.zig · simd_vec.zig · simd_scalar.go · simd_numpy.py · simd_scalar.py

Web service — JSON "hello world" endpoint

GET / returning {"message":"Hello, World!"} — the same shape as TechEmpower's "JSON serialization" test, via Apache Bench (ab -n 20000 -c 50, no keep-alive) on macOS/WSL2 against each language's own HTTP story: SafeC's std::http_serve_reactor; a minimal raw-socket accept loop for C/C++/Zig (macOS only — not ported to Winsock); Go's net/http; Python's FastAPI+uvicorn; Rust's axum (not Dioxus — Dioxus's fullstack server layer is axum underneath, so this measures what it actually runs through). Windows has no ab, so SafeC/Go/Rust/Python there use a small custom Go load generator at -n 5000 -c 20 instead (smaller scale — rapid same-port restarts hit Windows' long TIME_WAIT); treat the Windows column as directional relative to itself, not directly comparable to ab's numbers.

LanguagemacOS req/s (p50/p99)WSL2 req/s (p50/p99)Windows req/s (p50/p99)macOS peak memory
SafeC36055 (fastest) (1/5ms)17650 (3/4ms)2313 (7.5/19.6ms)7.2 MB
C25303 (2/2ms)N/AN/A1.4 MB (leanest)
C++24148 (2/4ms)N/AN/A1.6 MB
Rust27270 (2/2ms)18077 (3/3ms)465 (33.0/474.3ms)3.4 MB
Zig25742 (2/2ms)N/AN/A1.5 MB
Go25274 (2/3ms)18758 (fastest) (3/4ms)2467 (fastest) (7.8/16.9ms)19.7 MB
Python4790 (10/33ms)2357 (21/27ms)528 (37.3/52.5ms)54.9 MB

Every language completed every request with zero failures on every platform once measured in isolation.

Sources: server.sc · server_reactor.sc · server.c · server.cpp · main.rs · server.zig · server.go · server.py · io_nb_bsd.sc · io_nb.h · task.h

Machine learning — small MLP, training and inference

2-layer MLP (relu(X @ W1) @ W2, 128→256→64, batch 64, MSE loss, hand-rolled SGD, no bias) — 100 training steps then 1000 inference passes, fixed seed. std::ml covers CPU (Accelerate BLAS) and MPS (this shape is too small for GPU to beat CPU BLAS anywhere, SafeC included — the MPS row is for completeness, not competition).

FrameworkDeviceTrain (100 steps)ThroughputInference (1000 passes)Loss (sanity check)
SafeC (Accelerate BLAS)CPU16.8ms (fastest)380499 samples/s26.1ms100.333420
SafeCMPS39.4ms162581 samples/s61.1ms100.333290
PyTorchCPU23.1ms277232 samples/s13.5ms (fastest)100.018272
PyTorchMPS131.6ms48649 samples/s358.7ms103.423889
TensorFlowCPU83.0ms77103 samples/s245.0ms106.182991
TensorFlowGPU245.3ms26086 samples/s924.6ms106.182999
MLXGPU72.2ms88685 samples/s309.1ms96.632431

CPU training/inference, across platforms (SafeC uses BLAS on all three — Accelerate on macOS, OpenBLAS elsewhere; loss differs by platform because Windows has no drand48, so its data/init RNG is a different, still-seeded stream — see train_blas.sc):

FrameworkmacOS train / inferenceWSL2 train / inferenceWindows train / inference
SafeC (BLAS)16.8ms / 26.1ms (fastest)38.8ms / 74.0ms37.7ms / 101.9ms
PyTorch23.1ms / 13.5ms (fastest)25.4ms / 33.5ms21.7ms / 34.8ms

tensor_blas.h · tensor_blas.sc · train_blas.sc · tensor.h · tensor.sc · tensor_gpu.h · tensor_gpu.sc · gpu_mps.h · gpu_mps.sc · gpu_mps_kernels.metal · gen_mps_metallib.sh · time.sc · time.h · math.sc · math.h · train.sc · train_gpu_small.sc · PyTorch train.py · TensorFlow train.py · MLX train.py

Machine learning, bigger model — 512→1024→256

Same shape, scaled ~50x (batch 128). 50 training steps, 200 inference passes.

FrameworkDeviceTrain (50 steps)ThroughputInference (200 passes)Loss (sanity check)
SafeC (Accelerate BLAS)CPU89.2ms71713 samples/s61.4ms95.428856
SafeCMPS47.9ms (fastest)133433 samples/s47.3ms (fastest)95.428223
PyTorchCPU67.8ms94440 samples/s48.2ms106.548233
PyTorchMPS110.5ms57926 samples/s86.4ms108.710899
TensorFlowCPU148.8ms43003 samples/s197.6ms316.556885
TensorFlowGPU154.1ms41539 samples/s218.3ms317.200012
MLXGPU54.3ms117953 samples/s74.4ms88.762100

gpu_mps.h · gpu_mps.sc · gpu_mps_kernels.metal · tensor_gpu.h · tensor_gpu.sc · train_cpu.sc · train_cpu_blas.sc · train_gpu.sc · PyTorch train.py · TensorFlow train.py · MLX train.py

Machine learning, GPU backends — CUDA, ROCm, Vulkan/SPIR-V, WebGPU

This machine has no NVIDIA/AMD GPU, CUDA/ROCm toolkit, Vulkan SDK, or WebGPU library — nothing here is measured. Every function is hand-written against the real vendor C ABI and type-checks under safec, but is unlinkable/unrunnable here — "should be right," not "confirmed right." Each backend hits a different wall:

BackendElementwise opsMatmul (naive)Matmul (vendor BLAS)Gap
CUDAreal (PTX, text IR)real (PTX)real (cuBLAS)no NVIDIA GPU here
ROCmalways returns 0always returns 0real (rocBLAS)HSACO is binary IR; no ROCm toolchain to compile one
Vulkan/SPIR-Valways returns 0always returns 0n/aSPIR-V is binary IR; no glslc/glslangValidator here
WebGPUreal (WGSL, text IR)real (WGSL)n/ano wgpu-native/Dawn library here

gpu_cuda.h · gpu_cuda.sc · gpu_rocm.h · gpu_rocm.sc · gpu_spirv.h · gpu_spirv.sc · gpu_webgpu.h · gpu_webgpu.sc · tensor_cuda.h · tensor_cuda.sc · tensor_rocm.h · tensor_rocm.sc

Machine learning, fp16 / bf16 support

No native 16-bit float in SafeC's type system (Type.h's TypeKind has only Float32/Float64 — a real compiler feature, not a stdlib change). Instead, the standard workaround: fp16/bf16 carried as raw bits in unsigned short, with explicit correctly-rounded (round-to-nearest-even) conversion to/from float — and real native half/bfloat GPU compute on the MPS backend, not just halved storage.

CheckResult
Known bit patterns (fp16: 1.0, -1.0, 0.0, -0.0, 2.0, 0.5, max normal 65504, overflow→inf, smallest subnormal, inf; bf16: 1.0, -1.0, 0.0, 2.0, π)all exact
fp16 subnormal idempotence sweep (every mantissa 1–1023, fp16→f32→fp16)1023/1023 exact
fp16 normal-range idempotence sweep (every exponent × sampled mantissas, 330 patterns)330/330 exact
fp16 round-trip on representable valuesexact
bf16 round-trip (lossy by construction — 7 mantissa bits)within ~0.4% relative error

float16.h · float16.sc · gpu_mps.h · gpu_mps.sc · gpu_mps_kernels.metal · gpu_cuda.h · gpu_cuda.sc · gpu_webgpu.h · gpu_webgpu.sc · train_gpu_f16.sc · train_gpu_bf16.sc

Machine learning, device selection

Every backend names its op explicitly (tensor_matmul vs _blas vs _gpu vs _cuda) — precise, but awkward for a caller that wants to pick a device once. tensor_matmul_on(a, b, device) / tensor_relu_on(a, device) dispatch over a Device enum instead — verified bit-identical across CPU/CPU+BLAS/MPS:

DeviceY[0][0]Y[0][1]
CPU0.9000001.300000
CPU + BLAS0.9000001.300000
MPS0.9000001.300000

Building this exposed a real name collision: activations.sc (forward-only ops) and tensor_nn.sc (autograd ops, needed by every GPU backend) both defined tensor_sigmoid/tensor_relu/etc. — no program could link both, so no activations.sc-based layer could use a GPU backend. Fixed by suffixing activations.h's forward-only functions with _fwd and updating attention.sc/transformer.sc/rnn.sc's call sites.

jit_block_forward_on(block, x, device) threads device selection through a real layer (JiTBlock's Q/K/V/output projections + FFN) — verified bit-identical to the reference across CPU/CPU+BLAS/MPS:

Pathy[0]y[1]y[2]
jit_block_forward (reference)-0.479727-0.589425-0.537808
..._on(DEVICE_CPU)-0.479727-0.589425-0.537808
..._on(DEVICE_CPU_BLAS)-0.479727-0.589425-0.537808
..._on(DEVICE_MPS)-0.479727-0.589425-0.537808

Not yet device-routed: attention's internal QK^T/softmax/·V matmuls, DiTBlock, cnn.sc.

tensor_dispatch.h · tensor_dispatch.sc · transformer_dispatch.h · transformer_dispatch.sc · activations.h · activations.sc · attention.h · attention.sc · transformer.h · transformer.sc

Memory allocation — is std::alloc/dealloc slower than raw malloc/free?

std::alloc is a size-class caching allocator (same idea as PyTorch's CPU/CUDA caching allocators and MLX's Metal buffer cache): a freed block goes into a thread-local free list bucketed by power-of-two size class, and the next same-class alloc() is satisfied straight from there, skipping malloc()/free() entirely. Double-free/UAF detection is unaffected (a cached block still carries its "freed" magic word until reused).

std::alloc/dealloc runs faster than raw malloc/free on a binarytrees-shaped workload of many small, same-size, short-lived allocations (1210ms vs 1518ms, ~20% faster) and ~3.3x faster on an interleaved alloc/free microbenchmark (11–12ns/call vs 37–38ns/call). region/arena<R> is faster still (~5.9x over heap).

mem.h · mem.sc

Released under the MIT License.