A self-contained, LoadGen-shaped harness for running MLPerf Inference workloads on the Apple Neural Engine
through aneforge. It mirrors the MLCommons LoadGen structure (SUT / QSL / scenarios) so the numbers are
methodology-comparable and the workloads can be re-pointed at the real mlperf_loadgen for an official
submission with little change.
First workload: ResNet-50 image classification (the canonical MLPerf entry - conv-heavy, fits resident on the ANE, clean top-1 accuracy target).
- It is the same measurement shape as MLPerf Inference Edge: the SingleStream and Offline scenarios, the SUT/QSL split, the p90-latency and throughput metrics.
- It is not an official MLPerf submission. The
loadgen_literuns have no MLCommons audit trail, and the default run lengths are short. Aloadgen_literun is flaggedofficial: falseunless it meets the MLPerf minimums (>= 1024 queries AND >= 600 s). - A real MLCommons LoadGen driver (
loadgen_official.py) runs the SAME workloads through the actual generator/logger (writesmlperf_log_summary.txt, uses LoadGen's early-stopping metric). Install it withpip install mlcommons-loadgen. On this hardware the two agree: real-LoadGen ResNet-50 SingleStream p90 = 0.773 ms vs lite p90 = 0.771 ms (ratio 0.998), so the lite numbers track LoadGen's.
./bench/mlperf/repro.shCompiles the MLPerf reference ResNet-50 onto the Apple Neural Engine and prints (1) the SingleStream p90
latency and (2) ANE fp16 / int8 vs onnxruntime fp32 fidelity on MLPerf-scale inputs (mean-subtracted, the
range where fp16 rounding matters). ~1-2 min, no ImageNet, runs on any Apple Silicon Mac; uses the real
MLCommons LoadGen if mlcommons-loadgen is installed, else the lite harness. For the full 50,000-image
accuracy + upstream submission_checker path:
./bench/mlperf/repro.sh --full --imagenet-val ~/Models/mlperf/val --val-map val_map.txtbench/mlperf/compare/compare.py places the ANE's ResNet-50 numbers next to official MLPerf Inference
results. Every competitor number is sourced to a mlcommons/inference_results_* path in
compare/competitors.csv; the ANE row is unofficial and self-measured.
| System | Category | MLPerf round | SingleStream p90 (ms) | Offline (samples/s) | Power class |
|---|---|---|---|---|---|
| Apple Neural Engine (Apple M-series) (unofficial -- self-measured) | Edge | unofficial | 0.773 | 1,149 | single-digit W (SoC block) |
| NVIDIA Jetson AGX Orin | Edge | v3.1 | 0.640 | 6,424 | 15-60 W module |
| NVIDIA H100-SXM-80GB (1 GPU) | Datacenter | v4.0 | -- | 88,714 | 350-700 W board |
On SingleStream latency the ANE (0.773 ms) is within ~1.2x of the Jetson AGX Orin (0.640 ms); on
Offline throughput it trails (the ANE program is batch-1, latency-bound), at a fraction of the power.
Run compare.py for the full legend: the power class is a device power envelope, not a MLPerf Power
measurement, and the rounds differ because ResNet-50 edge submissions from the big vendors thinned out
after ~v3.1.
# performance only, synthetic inputs, torchvision ResNet-50 (no dataset needed):
PYTHONPATH=. python3 bench/mlperf/run.py
# longer run / quantized ANE weights:
PYTHONPATH=. python3 bench/mlperf/run.py --count 1024 --int8
# top-1 accuracy over real ImageNet val (val/<wnid>/*.JPEG layout) + performance on those images:
PYTHONPATH=. python3 bench/mlperf/run.py --imagenet-val ~/data/imagenet/val --count 512
# reference-fidelity when there is no ImageNet: ANE fp16 + int8 vs onnxruntime fp32 on real images:
PYTHONPATH=. python3 bench/mlperf/run.py --images ~/Pictures
# LLM decode (TTFT / TPOT / tokens-per-second), cached Qwen3-0.6B:
PYTHONPATH=. python3 bench/mlperf/run_llm.py --gen 128
PYTHONPATH=. python3 bench/mlperf/run_llm.py --int8 --gen 128
# your own MLPerf-provided ResNet-50 ONNX (to match the official model exactly):
PYTHONPATH=. python3 bench/mlperf/run.py --onnx resnet50.onnx
# REAL MLCommons LoadGen (pip install mlcommons-loadgen) + differential vs the lite harness:
PYTHONPATH=. python3 bench/mlperf/run_official.py --count 2000 --compare
PYTHONPATH=. python3 bench/mlperf/run_official.py --count 1024 --min-duration 600 # official lengthEach run prints a summary and writes a JSON to bench/mlperf/results/.
Methodology-only numbers (short runs; not an audited submission), to show the shape of the output:
| Run | Result |
|---|---|
ResNet-50 fp16, SingleStream (official length: 785,720 queries / 600 s, official: true) |
p90 latency 0.776 ms (~1310 img/s) |
| ResNet-50 fp16, SingleStream under REAL LoadGen | p90 0.773 ms, Result is: VALID; lite/loadgen ratio 0.998 |
| ResNet-50, Offline | ~1310 samples/s (tracks SingleStream: latency-bound at batch 1) |
| ResNet-50 fidelity vs onnxruntime fp32 | fp16: 100% top-1 agreement, logit cosine 1.00000; int8: 100%, cosine 0.998 |
| Qwen3-0.6B decode, fp16 | TTFT ~520 ms, TPOT ~18.7 ms/token, ~37.7 tok/s |
| Qwen3-0.6B decode, int8 | TTFT ~460 ms, TPOT ~17.0 ms/token, ~41.7 tok/s |
The int8 ResNet-50 preserving every prediction (cosine 0.998 vs fp32) is evidence the ANE clears the accuracy
gate for a normalized-input model; the committed results/*.json carry the exact values.
run_accuracy.py runs the actual MLPerf reference ResNet-50 (a TF export from Zenodo; the trailing ArgMax
is stripped and the 1001-class background offset is auto-detected) with the MLPerf preprocessing (resize
256 / center-crop 224 / per-channel mean subtraction, no /255, no std). Fetch the model once:
curl -L -o ~/Models/mlperf/resnet50_v1_mlperf.onnx https://zenodo.org/record/2592612/files/resnet50_v1.onnx
PYTHONPATH=. python3 bench/mlperf/run_accuracy.py --preprocess mlperf \
--imagenet-val ~/Models/mlperf/val --val-map val_map.txt # full ILSVRC-2012 valNaively, the reference model lost ~10 points on the ANE (fp16 67.0% vs onnxruntime fp32 77.05% on val,
logit cosine 0.917). This is not an accumulation limit: per the ANE guide's numerics chapter the engine
reduces in a wide fp32-class accumulator, and the reference's convs are individually bit-exact on the ANE
(the stem conv reads back at cosine 1.0). The loss was localized to the one standalone BatchNormalization
(the stem; the other 52 layers already have BN folded into their conv). A standalone BN runs in the fp16
datapath on the large pre-BN values (~1000s), and that rounding then amplifies through the network.
The importer now folds Conv->BatchNorm into the conv (aneforge/onnx.py, exact) -- so the reference
model computes the stem as a single wide-accumulator conv, with no standalone fp16 BN. Effect on the exact
reference model with MLPerf preprocessing, over the full 50,000-image ILSVRC-2012 val set:
| Reference ResNet-50, MLPerf preprocessing, 50k val | before fold | after Conv->BN fold |
|---|---|---|
| ANE fp16 top-1 | ~67% | 76.16% (cosine 0.917 -> 0.99999 vs fp32) |
| ANE int8 top-1 | ~67% | 76.06% |
| onnxruntime fp32 | 76.16% | 76.16% |
ANE fp16 == fp32 == 76.16% over the full val set, on the exact MLPerf reference model -- clearing the Closed-division gate (>= 99% of the 76.46% reference = >= 75.70%). The ANE is exactly as accurate as full precision here. The fold is a general importer feature -- any TF-exported model with a standalone Conv->BN benefits. (Note: the ~0.3pt below the canonical 76.46% is a preprocessing detail -- this harness resizes with PIL bilinear; the MLPerf reference uses cv2 INTER_AREA. It moves fp32 and ANE together and does not affect the ANE-vs-fp32 equality; an exactly-official run would match the interpolation.)
A normalized-input model (torchvision ResNet-50 V2, --preprocess torch) is also fp32-faithful on the ANE,
over all 50,000 val images:
| Path | top-1 | fidelity vs fp32 |
|---|---|---|
| onnxruntime fp32 | 80.32% | -- |
| ANE fp16 | 80.33% | 99.9% agree, cosine 0.99999 |
| ANE int8 | 80.22% | 98.2% agree, cosine 0.9962 |
The tables above use the 1000-image preview. For the full 50,000-image ILSVRC-2012 val set (not committed --
ImageNet is licensed): download ILSVRC2012_img_val.tar, extract to a flat dir, and build a val_map (each
ILSVRC2012_val_*.JPEG -> its 0-999 label). The labels come from the public per-image WNID list; sorted-WNID
order is the torchvision/PyTorch class index:
tar xf ILSVRC2012_img_val.tar -C ~/Models/mlperf/val
curl -L -o labels.txt https://raw.githubusercontent.com/tensorflow/models/master/research/slim/datasets/imagenet_2012_validation_synset_labels.txt
python3 -c "w=[l.split()[0] for l in open('labels.txt')]; idx={x:i for i,x in enumerate(sorted(set(w)))}; open('val_map.txt','w').writelines(f'ILSVRC2012_val_{i:08d}.JPEG {idx[x]}\n' for i,x in enumerate(w,1))"
PYTHONPATH=. python3 bench/mlperf/run_accuracy.py --preprocess torch --imagenet-val ~/Models/mlperf/val --val-map val_map.txtThe runner streams (one decode per image, no 50k cache). Sanity: onnxruntime fp32 should read ~76% top-1.
Both submission paths now hold on the ANE: Open division (normalized-input model) and Closed division (the exact MLPerf reference model + preprocessing, via the Conv->BN fold) both reach fp32-level top-1.
run_submission.py produces the MLPerf submission directory (Closed, Edge, SingleStream) with the REAL LoadGen
for both runs -- a PerformanceOnly run (mlperf_log_summary.txt, Result is: VALID) and an AccuracyOnly run
(mlperf_log_accuracy.json, scored to accuracy.txt) -- plus systems/<system>.json (system_description),
measurements/.../{mlperf.conf,user.conf}, and a code/ pointer:
PYTHONPATH=. python3 bench/mlperf/run_submission.py \
--imagenet-val ~/Models/mlperf/val --val-map val_map.txt \
--count 1024 --min-duration 600 # official-length: 1024 queries AND 600s perf, full 50k accuracyThe tree is written under bench/mlperf/submission/ (gitignored -- regenerate it; it carries LoadGen logs).
Validate it with the upstream checker (needs the public mlcommons/inference repo, not auth-gated):
python3 inference/tools/submission/submission_checker.py --input bench/mlperf/submissionThe official-length tree passes the upstream MLCommons submission_checker (v5.1) with zero errors:
Closed Results = 3
Systems = 1
SUMMARY: submission looks OK
All three required edge scenarios validate at official length (600 s / full 50k) on the ANE:
| Scenario | performance | accuracy (50k) |
|---|---|---|
| SingleStream | VALID, p90 0.874 ms | 76.16% |
| MultiStream | VALID | 76.16% |
| Offline | VALID, 1149 samples/s | 76.16% |
with TEST01 + TEST04 compliance TEST PASS, accuracy logs truncated + hashed, and a complete
system_description. Reproduce:
PYTHONPATH=. python3 bench/mlperf/run_submission.py --imagenet-val ~/Models/mlperf/val --val-map val_map.txt \
--count 1024 --acc-count 0 --min-duration 600 --compliance-duration 600
python3 -m submission_checker.main --input bench/mlperf/submission --version v5.1 --submitter ANEForge # from inference/tools/submissionOnly MLCommons membership + submitting in an official round remain -- process, not engineering.
| File | Role |
|---|---|
loadgen_lite.py |
LoadGen-shaped core: QSL, SUT, run_single_stream, run_offline, run_llm_decode, Result (pure Python + numpy, unit-tested off-device in tests/test_mlperf_harness.py) |
resnet50.py |
ResNet-50 workload: ONNX -> ANE program wrapped as a SUT, synthetic + ImageNet-val QSLs, top-1 accuracy, reference-fidelity vs onnxruntime |
run.py |
ResNet-50 CLI: build the SUT, run the scenarios (+ accuracy / fidelity), write results |
llm_decode.py |
LLM decode workload: an aneforge LLM wrapped as a decode SUT (TTFT / TPOT / tokens-per-second) |
run_llm.py |
LLM CLI: build the decode SUT, run run_llm_decode, write results |
loadgen_official.py |
REAL MLCommons LoadGen driver behind the same SUT/QSL (import-gated on mlperf_loadgen) |
run_official.py |
ResNet-50 under real LoadGen + differential vs loadgen_lite |
run_accuracy.py |
ResNet-50 top-1 with the MLPerf reference model + preprocessing (Closed accuracy path) |
run_submission.py |
assemble an MLPerf submission tree (real LoadGen perf + AccuracyOnly, configs, system_description) |
-
SingleStream - one sample at a time; metric is the 90th-percentile latency. The ANE's strong scenario (latency-bound at batch 1).
-
Offline - the whole query set at once; metric is throughput (samples/s). The compiled program is fixed at batch 1, so Offline is run as sequential batch-1 calls; on a latency-bound engine its rate tracks the SingleStream rate, which is itself an honest finding.
-
LLMDecode - greedy token generation; metrics are TTFT (time to first token), TPOT (per-output-token latency, as the p50/p90) and output tokens/s -- the shape MLPerf uses for its LLM benchmarks. See
run_llm.py(defaults to a cached Qwen3-0.6B).
The Server scenario is intentionally omitted - it needs request concurrency the ANE does not favor.
- Real
mlperf_loadgen-- done (loadgen_official.py): the SUT/QSL map ontolg.ConstructSUT/lg.ConstructQSLand the run is driven byStartTestWithLogSettings; the workload code is unchanged. - Accuracy -- done (
run_accuracy.py): the exact MLPerf reference model + preprocessing reach fp32-level top-1 on the ANE and clear the Closed >= 99% gate, via the Conv->BN fold (see the section above). Remaining: emit accuracy through LoadGen AccuracyOnly (mlperf_log_accuracy.json) rather than the direct top-1 scorer. - Add the submission package:
mlperf.conf/user.conf,system_description.json, and pass the compliance tests (TEST01 / TEST04 / TEST05) andsubmission-checker.py. - Submit in an official Inference round (Edge, SingleStream + Offline) through an MLCommons member.
Implement a SUT subclass (an issue(qsl, indices) -> outputs method, or a decode(...) for LLMs) and a
QSL (a get(index) -> features), and drive them with the existing scenario runners. resnet50.py and
llm_decode.py are the two worked examples.