stdfox

Measuring Memory Bandwidth on a MacBook Pro M5

Running any serious computation, such as local model inference, eventually comes down to hardware limits: how much data is available, how fast it can be processed, and how much of it can be moved through the system per second. Manufacturers' numbers usually describe a theoretical ceiling, but real workloads live somewhere below it, and knowing where is the whole point of benchmarking a machine before relying on it.

Geekbench 7 and Geekbench AI are the obvious first choice because they are quick to run, widely used, and produce numbers people recognize. They are good at what they're built for: comparing backends, comparing machines, tracking scores over time. But they don't answer the specific question — how many GB/s can the chip move through memory?

For that, a different kind of tool is needed, one that measures the pipe.

Hardware and software

Every number comes from a single machine, my MacBook Pro M5 32 GB, and one macOS build:

MachineMacBook Pro (14-inch, M5, 2025)
IdentifierA3434
ChipApple M5, 10-core CPU / 10-core GPU
Memory32 GB unified
Bandwidth153 GB/s (LPDDR5X-9600)
macOSTahoe 26.5.1 (25F80)

Apple's chips use unified memory, where the CPU and GPU draw from the same physical pool rather than each holding a private one. The 32 GB in the table is actually 32 GiB, confirmed by sysctl hw.memsize (34,359,738,368 bytes), and GiB is the unit used for memory everywhere below. The Metal wired limit is set at its default (sysctl iogpu.wired_limit_mb=0), letting the GPU address about 78% of system memory, reported as 25,559.05 MiB.

Bandwidth, unlike memory, stays in decimal GB/s below, because that is how Apple quotes it and how each throughput figure is derived. The base M5's 153 GB/s is the number to watch for anything memory-bound, a limit set by the chip itself rather than by the configuration. Apple does not offer a base M5 with more than 32 GiB of memory without moving up to a Pro or Max chip, and at the time of writing, this is the top of the base-tier line.

STREAM

STREAM, authored by John McCalpin at the University of Virginia, has been the standard for pure memory bandwidth measurement since the early 1990s. No composite scoring, just Copy/Scale/Add/Triad kernels reporting raw GB/s. The source is a single open-source C file with optional OpenMP support, and it builds with clang on macOS. The measurements below use revision 5.10 (v 5.10 2013/01/17 16:01:06).

STREAM's C source already ships with dormant #pragma omp parallel for directives, and they activate with an OpenMP-capable compile:

xcode-select --install
brew install llvm libomp
curl -O https://www.cs.virginia.edu/stream/FTP/Code/stream.c
$(brew --prefix llvm)/bin/clang -DSTREAM_ARRAY_SIZE=44739242 -DNTIMES=10 -O3 -fopenmp -o stream stream.c

Array size needs to comfortably exceed the chip's cache: 44,739,242 elements per array, about 341.3 MiB each and 1 GiB across the three arrays, which clears the M5's 16 MiB L2 with plenty of margin. Pushing to 1 GiB per array (134,217,728 elements, 3 GiB of static data) causes a crash on launch, a known Apple Silicon quirk where large static array declarations can collide with the region macOS reserves for the dyld shared cache.

The sweep goes from 1 thread up to the full 10 cores, writing every run to its own UUID-named file:

for ((i=1; i<=10; i++)); do
  for ((j=1; j<=10; j++)); do
    OMP_NUM_THREADS=$i ./stream > "$(uuidgen | tr '[:upper:]' '[:lower:]').txt"
    sleep 30
  done
done

Since the goal is a ceiling rather than an average, each cell in the results table is the maximum over 10 separate runs, with the full spread of those runs staying within about 2%. Between runs the machine gets a 30-second cooldown pause, so one run's thermals don't bleed into the next. The MacBook stays on mains power throughout, with background load minimized.

ThreadsCopy, GB/sScale, GB/sAdd, GB/sTriad, GB/s
1110.2105.5114.8114.7
2127.8124.8118.7118.3
3127.6127.3118.1118.1
4125.5127.1117.2117.3
5128.8124.3123.9124.1
6132.8125.2124.2124.3
7134.6125.4123.0123.3
8133.6125.1121.9122.3
9134.2124.0124.1124.3
10134.8124.5124.2124.5

A single thread already moves close to 115 GB/s, and going up to 4 threads barely changes anything. There's a small step up around 5 threads, then a plateau: 5 through 10 threads all sit within a couple of GB/s of each other. Against Apple's quoted 153 GB/s, the best Triad result here at 124.5 GB/s is about 81% of the limit, reached with only a handful of threads and with no further gains beyond that point. Copy is the outlier: with two memory operations per element and no arithmetic, it keeps climbing to 134.8 GB/s at 10 threads (about 88%), while Scale and Add settle onto the same plateau as Triad.

Metalstream

The figures above describe the CPU's path to memory, and the plateau they settle on is a property of that path: core clusters, cache policy, how many outstanding requests the cores keep in flight. Inference on this machine runs on the GPU, which reaches the same DRAM through its own route, so the CPU ceiling cannot simply be reused.

To measure the other side I wrote metalstream, a port of the same idea to Metal: the four kernels as compute shaders over float4 arrays in GPU-private storage, timed with the command buffer's GPU timestamps and validated on device afterwards.

It builds with clang against the Foundation and Metal frameworks:

clang -O3 -fobjc-arc -framework Foundation -framework Metal -o metalstream metalstream.m

The default configuration is 100,000,000 floats per array (381.47 MiB each, 1.12 GiB in total), the same order of working set as the CPU sweep and far beyond any cache. A single invocation times each kernel over 10 iterations after a warm-up and reports the rate from the best time. On top of that, the measurement repeats the STREAM discipline: 10 runs on mains power with a 30-second cooldown between them:

for ((i=1; i<=10; i++)); do
  ./metalstream > "$(uuidgen | tr '[:upper:]' '[:lower:]').txt"
  sleep 30
done

As with STREAM, the table keeps the maximum per kernel across the 10 runs. The spread was tight anyway, within about 2% for Copy and under 1% for the rest:

KernelBest rate, GB/s
Copy133.6
Scale134.0
Add129.5
Triad129.9

The GPU comes out slightly ahead of the CPU. Triad reaches 129.9 GB/s against 124.5 from the CPU sweep, about 85% of the quoted 153 GB/s, and the two-array kernels land at 133.6 and 134.0, essentially the CPU Copy result. Rounded to 130 GB/s, the Triad figure becomes the reference for the inference check below, since decode exercises a similar GPU path.

Verifying with llama.cpp

A bandwidth figure is only as good as its predictions, so the benchmark results can be checked against real inference. The models below were measured with llama-bench (llama.cpp build 7347430f4 (10090)) on the Metal backend, running tg128 at increasing depths. The run-to-run spread llama-bench reports stays within about 1% of the rate, so the tables keep just the central values. Each recorded rate is also converted to implied bandwidth, and the percentage columns show how much it reaches of the spec-sheet 153 GB/s, the metalstream reference of 130 GB/s, and the CPU STREAM plateau of 124 GB/s.

While decode stays memory-bound, generating one token means reading the active weights plus the accumulated KV cache once, which gives a simple ceiling formula:

B ≈ (W + K × d) × t/s(d)
SymbolMeaning
BAchievable memory bandwidth
WBytes of active weights read on every decode step
KKV cache bytes per token of depth
dContext depth, the number of tokens already in the KV cache
t/s(d)Measured decode throughput at depth d

For MoE models, W is the shared weights plus the experts a token activates, not the full mapped buffer, and with several parallel slots the union of activated experts grows sub-linearly rather than by the slot count. For hybrid architectures where only some layers grow their cache, K counts just the layers with a growing KV, plus a fixed term for the rest. Everything below is single-slot, so the plain formula applies.

Llama-3.2-3B-Instruct is a dense 3.21 B-parameter model. The llama.cpp load log reports a weights buffer of 3,255.90 MiB and a 14,336 MiB KV cache for a 131,072-token context across 28 layers:

W = 3,255.90 MiB ≈ 3.414 GB
K = 14,336 MiB / 131,072 tokens = 114,688 bytes ≈ 114.7 kB
B ≈ (3.414 GB + 114.7 kB × d) × t/s(d)
llama-bench -hf unsloth/Llama-3.2-3B-Instruct-GGUF:Q8_0 -d 0,512,1024,2048,4096,8192,16384,32768,65536,130944 -p 0 -n 128 2>/dev/null
DepthMeasured, t/sMeasured, GB/s153 GB/s, %130 GB/s, %124 GB/s, %
035.89122.5380.1%94.3%98.8%
51235.24122.3880.0%94.1%98.7%
102434.92123.3280.6%94.9%99.5%
204834.03124.1781.2%95.5%100.1%
409632.12124.7581.5%96.0%100.6%
819228.97126.1282.4%97.0%101.7%
1638424.05127.3083.2%97.9%102.7%
3276817.84127.9583.6%98.4%103.2%
6553610.81118.1677.2%90.9%95.3%
1309445.54102.1166.7%78.5%82.3%

gpt-oss-20b is a 20.91 B-parameter MoE model, 11.27 GiB on disk, of which a decode step touches only the attention weights, the activated experts, and the output head. Its KV term is the hybrid case from above — the buffer covers only the full-attention layers, while the sliding-window layers keep a fixed 128-token window and add no per-depth cost:

W = 0.68 GB  (attention Q/K/V/O, all 24 layers)
  + 1.27 GB  (4 of 32 experts, MXFP4)
  + 0.615 GB (output head, Q8_0)
  = 2.565 GB

K = 3,072.00 MiB / 131,072 tokens = 24,576 bytes ≈ 24.576 kB
B ≈ (2.565 GB + 24.576 kB × d) × t/s(d)
llama-bench -hf ggml-org/gpt-oss-20b-GGUF:MXFP4 -d 0,512,1024,2048,4096,8192,16384,32768,65536,130944 -p 0 -n 128 2>/dev/null
DepthMeasured, t/sMeasured, GB/s153 GB/s, %130 GB/s, %124 GB/s, %
046.43119.0977.8%91.6%96.0%
51245.86118.2177.3%90.9%95.3%
102445.63118.1977.2%90.9%95.3%
204845.03117.7777.0%90.6%95.0%
409643.41115.7275.6%89.0%93.3%
819241.49114.7775.0%88.3%92.6%
1638438.03112.8673.8%86.8%91.0%
3276832.77110.4572.2%85.0%89.1%
6553625.19105.1868.7%80.9%84.8%
13094412.5472.5247.4%55.8%58.5%

Both models fall roughly 20% short of the quoted 153 GB/s before the first token of context is even in place, so the spec-sheet figure is not a usable planning number. Nor is the CPU plateau the right reference, and the dense model shows why: between 2K and 32K its implied bandwidth edges past 124 GB/s, which would be strange for a genuine ceiling but is expected once decode is held against the GPU path it actually uses.

Against metalstream's 130 GB/s the two architectures separate. Llama-3.2 tracks the reference within 2–6% up to 32K and closes on it as depth grows, while gpt-oss starts 8% below and drifts away monotonically, reaching 15% by 32K. The widening gap is a measurable cost of MoE decode, because each token gathers its own handful of experts, so the reads scatter across the expert pool instead of sweeping one dense buffer.

Past 64K both models fall away from any prediction, and that is the boundary of the formula itself, which holds only while decode remains memory-bound. At such depths the attention arithmetic over the accumulated cache stops being negligible. Time shifts from reads to compute, so implied GB/s falls while the bus stays as fast as before, and a pure bandwidth model starts to overpredict.

What this doesn't cover

Every GB/s here is effective payload bandwidth: the bytes a kernel reads and writes by design, not the physical traffic on the memory controller, so cache behaviour and prefetch are folded into the number rather than observed, and actual DRAM residency is never verified.

The llama.cpp check narrows that gap only indirectly, by showing that decode lands near the predicted figure, and its own inputs are load-log accounting: W and K are what the logs report as mapped and allocated, not what provably moves on every step. For Llama-3.2 3B the accounting happens to be exact, since the model ties its input embeddings to the output head, so the whole weights buffer really is read on each token. A model with untied embeddings reads only one embedding row per step, and the same procedure would systematically overcount W.

The ceiling formula likewise stops at single-slot, memory-bound decode. Prefill speed, batching, and anything compute-bound are out of scope.

Conclusions

Geekbench still earns its place as the first look: it ranks this machine against others, tracks scores across macOS builds, and compares compute backends, while the INT8 Neural Engine figure hints at available inference headroom. But memory bandwidth itself needed purpose-built tools to measure.

STREAM gives a defensible answer for the CPU: roughly 115–124 GB/s depending on thread count, with most of the available bandwidth already reachable at a modest number of threads and no meaningful gains from pushing further. For the GPU, metalstream reports 130 GB/s, a little above the CPU plateau and about 85% of Apple's 153 GB/s.

The GPU figure is the one worth carrying into anything memory-bound on this machine, and the llama.cpp check shows how much of it real decode collects: the dense model comes within 2–6% at practical depths, while the MoE gives up a further 8–15% that grows with context.

The spec sheet overpredicts throughout, and the best single result in the whole set is 83.6% of 153 GB/s. Landing at 75–85% of a quoted peak is typical memory efficiency across platforms, and the spec number is a bus limit, not a target a workload can be expected to hit.