Benchmark Methodology¶
This page documents how all benchmarks in this project were conducted — the test battery, measurement approach, hardware configuration, and scripts used. Follow these instructions to reproduce the results on your own machine.
Verified on: MacBook Pro M1 Max (64 GB), macOS 26.6 (Tahoe).
Hardware configuration¶
Test Machine¶
| Component | Specification |
|---|---|
| Machine | MacBook Pro (2021) |
| SoC | Apple M1 Max |
| CPU | 10 cores (8 performance + 2 efficiency) |
| GPU | 24 cores |
| Unified Memory | 64 GB |
| OS | macOS 26.6 |
| Storage | 2 TB SSD (APFS) |
| Thermal | Passive cooling (laptop chassis) |
Software Versions¶
| Software | Version |
|---|---|
| Ollama | 0.6.x |
| MLX | 0.28.x |
| mlx-lm | 0.28.x |
| Rapid-MLX | 0.3.x |
| oMLX | 0.2.x |
| Python | 3.12.x |
Measurement approach¶
Throughput Measurement¶
Throughput is measured as tokens per second during generation only (prefill excluded) unless otherwise noted.
import time
import ollama
def measure_throughput(model, prompt, max_tokens=512):
"""Measure generation throughput in tok/s."""
start = time.time()
first_token_time = None
token_count = 0
stream = ollama.generate(
model=model,
prompt=prompt,
stream=True,
options={"num_predict": max_tokens}
)
for chunk in stream:
if first_token_time is None:
first_token_time = time.time()
if chunk.get("response"):
token_count += 1
end = time.time()
generation_time = end - first_token_time
throughput = token_count / generation_time if generation_time > 0 else 0
return {
"throughput_tok_per_s": round(throughput, 2),
"cold_start_s": round(first_token_time - start, 2),
"total_time_s": round(end - start, 2),
"tokens_generated": token_count
}
Cold Start Measurement¶
Cold start is the time from sending the first request to receiving the first token. This includes:
- Model loading (if not cached in memory)
- Prompt processing / prefill
- First token generation
Cold start is measured as first_token_time - request_start_time in the script above.
Effective Throughput¶
Effective throughput accounts for all tokens (prefill + generation) divided by total wall time:
This is the metric that matters for real-world latency perception. A model with fast generation but slow prefill can feel sluggish in interactive use.
Quality Scoring¶
Quality scores (1–10) are based on:
- Reasoning quality: Ability to follow complex multi-step instructions
- Coherence: Output consistency and logical flow
- Instruction following: Adherence to system prompts and formatting requirements
- Factual accuracy: Correctness of generated information (on known topics)
Scores are averaged across a test suite of 50 prompts covering chat, coding, reasoning, and creative tasks.
Perplexity Measurement¶
Perplexity is measured on a held-out validation set of 1,000 documents (not seen during training):
# Using llama.cpp's perplexity tool
./perplexity -m model.gguf -f validation_set.bin -ngl 99
# Using MLX's perplexity tool
mlx_lm.perplexity --model model-path --data validation_set.txt
Test battery¶
Standard Test Prompt¶
All throughput benchmarks use a standardized prompt:
You are a helpful assistant. Please provide a detailed response to the following:
[2,000 tokens of structured context — technical documentation, code snippets, and natural language]
Based on the above, explain the key architectural decisions and their tradeoffs.
This prompt ensures: - Consistent prefill load (2,000 tokens) - Realistic context for generation - Comparable across all models
Generation Length¶
All throughput tests generate 512 tokens unless otherwise noted. This is long enough to measure stable generation speed but short enough to avoid context window issues.
Repetition and Reporting¶
Each test is run 5 times and the median value is reported. This mitigates variance from:
- Thermal throttling (though not observed on the M1 Max laptop)
- System background processes
- Memory pressure from other applications
- First-run vs cached model loading
Test scripts¶
The full benchmark suite script is available in the project repository. Key measurement functions:
Throughput Measurement¶
import time
import ollama
def measure_throughput(model, prompt, max_tokens=512):
"""Measure generation throughput in tok/s."""
start = time.time()
first_token_time = None
token_count = 0
stream = ollama.generate(
model=model,
prompt=prompt,
stream=True,
options={"num_predict": max_tokens}
)
for chunk in stream:
if first_token_time is None:
first_token_time = time.time()
if chunk.get("response"):
token_count += 1
end = time.time()
generation_time = end - first_token_time
throughput = token_count / generation_time if generation_time > 0 else 0
return {
"throughput_tok_per_s": round(throughput, 2),
"cold_start_s": round(first_token_time - start, 2),
"total_time_s": round(end - start, 2),
"tokens_generated": token_count
}
MLX Engine Comparison Script¶
#!/usr/bin/env python3
"""Compare MLX engines on the same model."""
import subprocess
import time
import json
import requests
MODEL = "Qwen3.5-35B-A3B-abliterated-4bit"
PROMPT = "Explain the key differences between RISC-V and ARM architectures."
MAX_TOKENS = 512
engines = {
"mlx_lm": {
"start": ["mlx_lm.server", "--model", MODEL, "--port", "8080"],
"endpoint": "http://localhost:8080/v1/completions"
},
"rapid-mlx": {
"start": ["rapid-mlx", "serve", "--model", MODEL, "--port", "8081"],
"endpoint": "http://localhost:8081/v1/completions"
},
"omlx": {
"start": ["omlx", "serve", "--model", MODEL, "--port", "8082"],
"endpoint": "http://localhost:8082/v1/completions"
}
}
def measure_engine(name, config):
"""Start engine, send request, measure, stop."""
process = subprocess.Popen(
config["start"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# Wait for server to be ready
time.sleep(5)
start = time.time()
response = requests.post(
config["endpoint"],
json={
"prompt": PROMPT,
"max_tokens": MAX_TOKENS,
"stream": False
}
)
total_time = time.time() - start
data = response.json()
tokens = len(data.get("choices", [{}])[0].get("text", "").split())
process.terminate()
process.wait()
return {
"engine": name,
"total_time_s": round(total_time, 2),
"tokens_generated": tokens,
"throughput_tok_per_s": round(tokens / total_time, 2)
}
# Run for each engine
results = []
for name, config in engines.items():
print(f"Testing {name}...")
result = measure_engine(name, config)
results.append(result)
print(f" {result['throughput_tok_per_s']} tok/s")
print(json.dumps(results, indent=2))
Memory measurement¶
KV Cache Size¶
KV cache size is measured by:
- Loading the model with a specific context length
- Sending a prompt that fills the context window
- Reading the process's memory usage before and after generation
- The difference is the KV cache size
# Measure KV cache for a model at 128K context
ollama run gemma4:e4b-nvfp4 --context-length 131072
# In another terminal, measure memory
ps aux | grep ollama | awk '{print $6}' # RSS in KB
Total Memory Budget¶
Total memory budget = model file size (on disk) + KV cache (at target context length) + ~5% overhead for runtime structures.
Reproducibility notes¶
Factors That Affect Results¶
- Thermal state: A cold machine shows different results than one that has been running for hours. The M1 Max laptop maintains consistent performance under sustained load.
- Background processes: Close browsers, IDEs, and other memory-intensive applications before benchmarking.
- Model cache: Ollama caches loaded models. First request after a restart includes model loading time; subsequent requests skip it.
- Prompt structure: Different prompts of the same length can have different prefill times due to attention pattern differences.
- Random seeds: Generation is deterministic with a fixed seed. Set
seed: 42in Ollama options for reproducible results.
Ensuring Reproducibility¶
# Set a fixed seed for deterministic generation
ollama run gemma4:e4b-nvfp4 --seed 42
# Clear model cache before first measurement
ollama stop gemma4:e4b-nvfp4
# Monitor system state
memory_pressure
Data collection date¶
All benchmarks in this project were collected on August 1, 2026. Results differ with newer software versions, model updates, or different hardware configurations.