One envelope schema, every upstream eval tool, one public HF dataset.
mlx-benchmarks is the result-envelope contract and publisher for benchmarking MLX-quantized and
locally hosted LLMs on Apple Silicon. It is the thin glue between upstream evaluation tools
(lm-eval, vllm benchmark_serving, agent-framework harnesses) and a single public HuggingFace
dataset, with a Gradio viewer on top.
What it does
- Defines envelope v1 in
schema.json: the authoritative, versioned contract every published shard validates against. - Provides
mlx-bench-publish, a command-line tool that converts raw tool output into the envelope, validates it, and uploads to the HF dataset with content-addressed filenames (data/run-<timestamp>-<git_sha>-<suite>-<model_slug>.parquet). - Owns converters for
lm-eval,vllm benchmark_serving, and framework-eval (OpenAI / Qwen-Agent / smolagents / ADK). - Auto-detects runtime metadata (OS, chip, memory, Python, MLX, lm-eval versions) via
detect_system()so envelopes are fully reproducible without hand-curation. - Deploys a Gradio viewer to HF Spaces on every
mainpush touchingspace/.
How it fits
The headline metric is cumulative, not decode-only
Cumulative tokens/second = (prompt tokens + completion tokens) ÷ total wall-clock. That is the headline figure; decode-only rate is a secondary diagnostic. Decode-only throughput hides prefill. A model that reads its prompt several times faster delivers a visibly faster answer, and a decode-only number reports none of that gain. So tuning against it tunes for something the user never experiences. Cumulative counts every token the request touched against the time the user actually waited. The two metrics can rank models differently, which is the point. Report both columns; lead with cumulative.Cumulative is meaningless without its regime
Cumulative tokens/second is highly sensitive to prompt size, because prompt tokens are counted in the numerator and prefill is far faster than decode. The same model, on the same hardware, scored 551 cumulative at a 2291-token prompt and 140.9 at a ~190-token prompt. Neither is wrong; they are answers to different questions. So a cumulative figure without its regime is not a fast or slow result. It is an uninterpretable one: A stated regime is not sufficient on its own, because a regime can match on paper and still differ in practice. On 2026-08-14 a candidate read 37.1 cumulative against the incumbent’s recorded 115.2. That regime looked identical on every published field. Re-measuring the incumbent with the same harness, in the same session put it at 138.8, not 115.2. The harness ran hot, and the naive comparison had understated the real gap by roughly 20%. It happened to understate; it could as easily have manufactured a win. Decode-only is the most regime-stable of the three rates. That makes it the honest basis for a cross-model comparison even though cumulative is the headline. When two numbers disagree about which model is faster, check whether they were taken in the same regime before you believe either.Long-context results need two context values
A benchmark’s requested prompt length is not its serving limit. Publish both values so a reader can tell what the worker was configured to admit and what it actually processed:- Configured total context: the request budget admitted by the serving profile. It includes input and reserved output.
- Actual prompt tokens: the input tokens reported by the server for this request.
- Output reservation: the completion tokens reserved before admission.
- Actual total context: actual prompt tokens plus the completion tokens returned.
Cache state and campaign results
Cache provenance is several independent facts, not onecache-busted label:
- Weights: exact model revision and quantization loaded by the worker.
- Model load: whether the process was already resident or started for the cell.
- Prompt prefix: whether a shared prompt prefix was reused; cache-cold rows use a unique full prefix and assert zero reported cached prompt tokens when the server exposes that telemetry.
- Operating-system and evaluation caches: state that can affect loading or evaluation even when prompt-prefix reuse is absent.
success contains valid measured
rates. failed preserves a completed but invalid run. capacity_gated records
a safety refusal. unsupported records a missing backend capability.
aborted records a run stopped by a safety guard. not_applicable means the
requested window or profile does not exist for that model. None of those status
rows is a zero-throughput result.
Published results are immutable shards. A new campaign appends a new shard and
run-index entry; it does not rewrite a historical row to make a comparison look
cleaner. The viewer distinguishes historical evidence from current campaign
evidence. Comparisons must name the run, serving profile, context values,
concurrency, cache state, and model revision that produced them.
Fresh 2026-08 context evidence
The current isolated campaign uses a 64-token output reservation, one request at a time, and a cache-busted full prompt for each measured repetition. It runs four warmed repetitions per successful cell. The first request is archived underreadiness with an explicit initial_model_state; it is never included in the
warmed median.
Clean-reset 2026-08-30 128k matrix
This separate immutable campaign began after a Studio reboot. The production model lifecycle was quiesced before each cell, and the preflight swap value was zero. The server used a 131,072-token window with a 128,000-token prompt and a 64-token output reservation. Each row has four resident repetitions. The cold first request is retained as readiness evidence and is excluded from the warm medians.
The clean-reset base and MTP c1 rows show comparable 128k prompt throughput.
The c2 result admitted two requests without swap, but its aggregate rate stayed
at the c1 rate. In this native MTP configuration, the long-prefill path queued
rather than increasing aggregate throughput. The c4 attempt is preserved as
transport-limited evidence, not a capacity or quality verdict.
Full-attention KV math informed the preflight only. 64 KiB per token per
sequence is about 7.816 GiB at 128,000 plus 64 tokens, or 31.266 GiB for four
sequences. Hybrid linear-attention state, weights, allocator behavior, and
scratch space are outside that calculation, so runtime telemetry remains the
fit result.
The selected Qwen3.8 profile has a 131k configured window, so 192k is
not_applicable, not a zero-speed result.
Runtime capability status
The MTP drafter was proven by live completions in an isolated native-MTP c1 profile. It is still not a base-model fallback or a serving default. The base and MTP rows use different serving backends, so their rate difference is not an MTP-only speed multiplier. That runtime status remains separate from the immutable measurement table. A production activation requires its own verified route and adds a labeled MTP row without changing any existing base-model evidence.A worked comparison, one stated regime
Isolated workers with loaded weights proven from the running command line, one model resident at a time, never through a proxy. Identical regime for all four rows: a unique filler prompt of 170–190 tokens withcached_tokens=0 asserted
per run, max_tokens=300, streaming with usage included. Cumulative is the
median of three runs, with the observed range in brackets.
All four returned a genuine
tool_calls array with
finish_reason: "tool_calls" and correctly parsed arguments.
The reasoning-first model is the cautionary row. It is competitive on every
rate and never exits thinking at max_tokens=300: zero answer characters
across all three runs, against 1022–1193 characters of reasoning. Fast, and
produces no answer. A throughput table cannot see that; a tool-call and
answer-length assertion can. Measure what the caller receives, not only how
quickly tokens arrive.
Getting started
1
Bring up the inference stack
From the
nix-darwin flake: darwin-rebuild switch --flake .. This starts
vllm-mlx + llama-swap on localhost:11434 via nix-ai. Or run vllm-mlx serve directly if you’re not on the Nix stack.2
Install and authenticate
git clone https://github.com/JacobPEvans/mlx-benchmarks && cd mlx-benchmarks && uv sync. Then export HF_TOKEN=... with write scope on the dataset namespace.3
Run a smoke benchmark
Point
lm-eval at the local endpoint:4
Publish (dry-run first)
.venv/bin/mlx-bench-publish ./run-output/<model-dir>/results_*.json --kind lm-eval --suite reasoning --dry-run validates the envelope locally against schema.json. Drop --dry-run to
push to the HF dataset.5
View results
Open the HF Space viewer. It auto-loads every published shard. Or
cd space && python app.py for a local copy.Related repos
nix-ai
Packages the inference stack:
vllm-mlx LaunchAgent, llama-swap, MLX module derivations. Where models actually run.nix-darwin
macOS host config. Composes
nix-ai into the system flake so benchmarks have a reproducible environment.ai-assistant-instructions
Model routing + permission policy. Tells AI clients which models to benchmark.
Local LLM
The serving stack, tuning, and model strategy these benchmarks measure.
Source on GitHub
Schema, publisher, converters, full README,
docs/architecture.md.