Apple Silicon · Performance Engineering

972 ms0.15 ms

0×faster than optimized NumPy

The same Mandelbrot workload. The same machine. What changes is how much of the M4 Max you actually use — SIMD lanes, all 14 CPU cores, algorithmic symmetry, the 40-core GPU, both engines on one frame at once, and finally the WWDC 2026 Metal 4 command model. Every step measured, verified, and documented below.

0.15 msHybrid on the Metal 4 command model
0.24 msMetal 3 hybrid — the prior ceiling
38 Gpx·it/sGPU throughput at 72 megapixels
99.8 %pixels within ±1 iteration of float64 reference
The platform

One chip, four engines

Peak performance on Apple Silicon isn't one trick — it's refusing to leave any of the M4 Max's execution resources idle.

NEON · SIMD

128-bit vectors, FMA

Four float32 lanes per instruction on every core, with fused multiply-add and branch-free lane masking.

14 CORES

10 P + 4 E, libdispatch

dispatch_apply fans work across performance and efficiency cores with work-stealing — no thread-pool code.

40-CORE GPU

Metal compute

Thousands of threads for embarrassingly parallel math, shaders compiled at runtime from source.

UNIFIED MEMORY

Zero-copy CPU⇄GPU

One physical memory. The GPU writes, the CPU reads the very same bytes — no PCIe transfer, no staging buffers.

The baseline

Six orders of magnitude on one chart

The repository benchmarks SQL engines (recursive CTEs!), Python and NumPy against native code. Same fractal, same escape semantics — from four minutes to about a sixth of a millisecond, an over-1.5-million-fold span.

Benchmark ladder: SQLite ~4 min single-run down to the Metal 4 hybrid at 0.15 ms, log scale spanning over six orders of magnitude
Single-run wall-clock, 1400×800 px × 256 iterations, Apple M4 Max. Log scale — each gridline is 10×.
Case study

Twelve optimizations, each one measured

Escape-time Mandelbrot: iterate z = z² + c per pixel until |z|² > 4. Simple to state, brutal to make fast — the loop is a serial dependency chain with unpredictable exit.

01

Compile for the exact silicon foundation

A dylib built at first import with clang++ -O3 -mcpu=apple-m4, called from Python via ctypes. The compiler schedules for the M4's real pipeline widths and latencies. Build cost paid once, outside the timed path.

02

NEON SIMD — 4 pixels per instruction the big unlock

The escape loop runs on float32x4_t vectors. Escaped lanes are masked out branch-free; iteration counts accumulate by subtracting the all-ones comparison mask. float32 over float64 doubles the lanes — at this zoom the pixel pitch is ~10⁴ ulp of f32, so precision is ample (verified: 99.8 % of pixels within ±1 iteration of the f64 reference).

float32x4_t zr2 = vmulq_f32(zr, zr), zi2 = vmulq_f32(zi, zi);
active = vandq_u32(active, vcleq_f32(vaddq_f32(zr2, zi2), four));
count  = vsubq_u32(count, active);  // +1 where lane still alive
zi = vmlaq_f32(ci, vmulq_f32(two, zr), zi);  // fused 2·zr·zi + ci
03

All 14 cores via libdispatch ~11× of ideal 14×

dispatch_apply spreads rows across P- and E-cores with built-in work-stealing. Two profiler-enforced findings: QOS_USER_INTERACTIVE was 4× worse than USER_INITIATED for this workload, and the pool is warmed by a tiny dispatch at import so the first timed call doesn't pay thread-creation latency.

04

Cardioid & bulb early-out skips the costliest 22 %

Points inside the main cardioid and period-2 bulb never escape — and they're the most expensive pixels, burning all 256 iterations. Both regions have closed-form membership tests: a handful of FLOPs replaces 256 iterations, evaluated vectorized before the loop.

// q·(q + (cr − ¼)) ≤ ¼·ci²  →  inside main cardioid
float32x4_t q = vmlaq_f32(ci2, crm, crm);
uint32x4_t in_set = vcleq_f32(vmulq_f32(q, vaddq_f32(q, crm)),
                              vmulq_f32(quarter, ci2));
05

Instruction-level parallelism: two chains in flight G=2 sweet spot

z = z² + c is a serial dependency chain — one 4-lane chain can't fill the M4's SIMD pipes. Interleaving two independent vector groups (8 pixels) keeps the FMA units busy while each chain waits on its own latency. Measured: G=1 → 0.71 ms, G=2 → 0.39 ms, G=3 → 0.46 ms (register pressure wins).

06

Conjugation symmetry — the free 2× algorithm beats micro-opt

The viewport is symmetric about the real axis, and conj(z² + c) = conj(z)² + conj(c) — with IEEE negation being exact, escape counts are bit-identical for mirrored rows. Compute the top half, memcpy the rest. The best optimization in the whole project is a line of mathematics.

07

Amortized escape checks 0.79 → 0.39 ms with #6

The horizontal reduce (vmaxvq_u32) + branch asking "any lane alive?" serializes the loop. It now runs every 4th iteration. Per-lane masking stays exact — dead lanes idle through up to 3 wasted iterations, and inf/NaN comparisons can never resurrect them.

08

Metal compute shader, compiled at runtime enter the GPU

The MSL kernel ships as a source string — newLibraryWithSource compiles it at import, no offline toolchain. One thread per top-half pixel; each writes its pixel and the mirrored one. The cardioid early-out returns whole 32-wide SIMD groups early in bulk regions.

kernel void mandel(device ushort *out [[buffer(0)]],
                   constant uint4 &p  [[buffer(1)]],
                   uint2 gid [[thread_position_in_grid]])
{
    // … escape loop …
    out[gid.y * w + gid.x]           = (ushort)it;
    out[(h - 1 - gid.y) * w + gid.x] = (ushort)it; // mirror row
}
09

Unified memory, zero-copy 0.41 → 0.30 ms

The GPU writes into a StorageModeShared buffer; Python receives a numpy view of the same physical memory. No 2.2 MB memcpy, no staging, hazard tracking off — waitUntilCompleted is the only synchronization. This is the Apple Silicon advantage discrete GPUs can't match at small sizes.

10

Threadgroup shape measured, not guessed

Swept 32×4 / 32×8 / 32×16 / 32×32 threadgroups: flat through 16, clear regression at 32 (occupancy). Shipped 32×8 = 256 threads per group, matching Apple's guidance — but only after the sweep confirmed it.

11

Hybrid: CPU and GPU on the same frame 0.31 → 0.24 ms

Unified memory's endgame. The Metal kernel takes ~56 % of the rows with an async commit; while the GPU renders, a clang ext_vector float8 kernel computes the remaining rows on all 14 CPU cores — into the same shared MTLBuffer. Disjoint rows, hazard tracking off, one waitUntilCompleted. The split is calibrated once at import. A discrete GPU would need a PCIe round-trip here; this win is pure Apple Silicon architecture.

12

WWDC 2026: the Metal 4 command model 0.24 → 0.15 ms — new ceiling

The one WWDC 2026 feature that fits a divergent escape loop — not tensors, not the Neural Accelerators (those are M5, and this is matmul hardware anyway). Metal 4 replaces the per-dispatch bookkeeping with a pre-baked MTL4ArgumentTable (resources bound by raw GPU address), an explicit MTLResidencySet, and a reusable MTL4CommandAllocator/MTL4CommandBuffer committed on an MTL4CommandQueue. At this size the ~0.26 ms fixed submit latency dominates, so cutting it matters: GPU-only drops ~6 %, but in the hybrid the cheaper submit lets the split shift to 76 % GPU with a tighter async overlap — a robust ~31 % win, 0.24 → 0.15 ms (faster on 90 % of paired samples). Same kernel, bit-identical output; only the submission path changed.

Optimization journey: NumPy 972 ms, v1 NEON 0.79 ms, v2 symmetry 0.39 ms, v3 Metal GPU 0.30 ms, v4 hybrid 0.24 ms, v5 Metal 4 0.15 ms
The journey on one chart. Log scale; labels show cumulative speedup vs NumPy.
CPU vs GPU

The crossover — and why overhead is destiny

A Metal dispatch carries a fixed ~0.2 ms command-buffer cost. At sub-millisecond workloads the 14-core CPU is nearly even; scale up and the 40-core GPU runs away.

WorkloadC++ NEON · 14 coresMetal · 40-core GPUGPU advantage
1400×800 (1.1 Mpx)0.52 ms0.34 ms1.5×
2800×1600 (4.5 Mpx)1.38 ms0.61 ms2.2×
5600×3200 (18 Mpx)4.94 ms1.58 ms3.1×
11200×6400 (72 Mpx)24.5 ms1.87 ms13.1×
1400×800 @ 4096 iter3.05 ms0.80 ms3.8×
CPU vs GPU scaling chart, log-log, gap grows with workload size
Log–log scaling. CPU grows linearly with pixels; the GPU barely notices until tens of megapixels.
72 megapixels of 256-iteration Mandelbrot in 1.87 ms is ≈ 38 billion pixel-iterations per second — on a laptop, on battery, in silence.
Language shootout

Seventy-three languages, one algorithm

Same SIMD strategy, same early-outs, same symmetry, same escape semantics — reimplemented in seventy-three languages to separate language speed from algorithm speed.

Language shootout, 73 languages from Metal 0.31 ms to Bash 5.6 s, plus the CPU+GPU record hybrid at 0.15 ms on top, log scale
Best-of-N runs, log scale. Gold = the overall CPU+GPU record (the Metal 4 and Metal 3 hybrids — not single languages, shown for reference). Teal = explicit SIMD (GPU, vector types, intrinsics, Vector API, hand-written assembly); blue = scalar.
Objective-C is the fastest CPU entry — generic clang ext_vector_type beat hand-picked NEON intrinsics. The top seven all compile through LLVM; hand-written assembly loses ~55 % to the best of them. WebAssembly beats plain C. Vector-API JVMs cluster within ~4× of native. And the COBOL entry only became viable after swapping floats for Q28 fixed-point — its float math routes through a decimal library. Python's escape hatches reach the compiled tier: Cython 1.53 ms, Numba 2.23 ms, Pythran 1.4 ms — ~2 000× past the interpreter they extend. Gleam runs the same BEAM as Erlang 2× faster — typed, monomorphized code boxes less. And two languages with no float type at all still finish: Rexx in decimal string arithmetic (1.7 s), Bash in Q26 fixed-point shell integers (5.6 s) — both still ~10× ahead of SQLite.
The "even faster" hunt came up empty — on purpose. Three explicit-SIMD challengers were added to try to dethrone the 0.34 ms Objective-C CPU crown: C3 (float[<8>] → NEON) at 0.80 ms, ISPC (SPMD, neon-i32x8) at 0.91 ms, Halide (schedule-DSL, JIT) at 7.16 ms. None came close — all three share one wound: a vector-group escape loop can't retire a lane the moment it escapes, so every 8-wide gang iterates to max_iter for its slowest pixel. The hand-written NEON kernel wins because it amortizes the escape check instead. The CPU crown holds at 0.34 ms — but the "even faster" hunt did land elsewhere: the WWDC 2026 Metal 4 command model cut the hybrid from 0.24 to 0.15 ms (see step 12). Language choice couldn't beat the kernel; the submission API could.

Read the source — and where each language hits its ceiling

Each card links to the exact file in the GitHub repository, with an honest note on what limits that language on this workload.

Negative results

What didn't work

Performance engineering is empirical. These all sounded right and measured wrong.

QOS_USER_INTERACTIVE

"Highest priority = fastest," right? Median regressed 4× vs USER_INITIATED. The scheduler knows things you don't.

G=3, G=4 interleave

More chains in flight should hide more latency — but past G=2, register pressure and spills beat the gains.

1024-thread groups

Maxing threadgroup size cost measurable occupancy. 256 threads/group won the sweep.

Deeper unrolling (v1 era)

Until symmetry halved the work, the kernel was overhead-bound — unroll factors measured as pure noise. Fix the algorithm before polishing the loop.

Trust, but verify

Every step was checked before it was kept

Bit-exactness across restructures. v2's top half is bit-identical to v1; every ILP variant produced identical output before timing was even considered.

Accuracy vs float64. Every escaped pixel compared against the f64 reference: ≥ 99.76 % within ±1 iteration at every step (CPU f32: 99.81 %, GPU fast-math: 99.77 %). The differences live on chaotic boundary filaments.

Identical semantics. Same escape convention as the repo's Python and SQL implementations — count = iterations survived before |z|² > 4.

Honest timing. Best and median of 15–31 runs for steady state; fresh-process single calls for what a benchmark harness actually sees; contenders re-measured on an idle machine.

Reproduce it

Clone, build, measure

Everything is open source — the kernels, the harness, the charts, this page.

git clone https://github.com/jirakj/sql-mandelbrot-benchmark
cd sql-mandelbrot-benchmark

# The whole comparison, every engine + all 73 languages:
uv run python main.py

Every single test runs the same way — one file per implementation, named <lang>brot.py. Running it clones nothing else, builds itself, then measures: the wrapper compiles the native kernel (or spawns the interpreter/VM pool), runs a warm-up, computes the frame, and writes images/<lang>brot.png as proof. No per-language setup, no build scripts to read — the Python file is the build system.

# The record and the crowns
uv run python hybrid4brot.py # Hybrid, Metal 4 command model — 0.15 ms (record)
uv run python hybridbrot.py  # Hybrid, Metal 3 — 0.24 ms
uv run python metalbrot.py   # Metal GPU — 0.30 ms
uv run python objcbrot.py    # Objective-C ext_vector — 0.34 ms (fastest CPU)

# Any language in the shootout — same pattern:
uv run python cppbrot.py     # C++ NEON            0.39 ms
uv run python ispcbrot.py    # ISPC SPMD           0.91 ms
uv run python tsbrot.py      # TypeScript          2.34 ms
uv run python haskellbrot.py # Haskell             1.94 ms
uv run python cobolbrot.py   # COBOL (Q28 fixed)   201 ms
uv run python bashbrot.py    # Bash (Q26 fixed)    5.6 s
# …73 in total — swap in any name from the cards above.

Toolchains the native entries need (ispc, halide, gambit, gforth, …) install via Homebrew; each wrapper's docstring names its compiler. Full write-up with every measurement: OPTIMIZATIONS.md