mac4DSTEM

Benchmarks

Each kit against the Python package it ports, same inputs, on an Apple M-series Mac. Tables are copied from each kit's docs/benchmarks.md at its latest tag; where the kit is slower than Python, the table says so.

FormatoKit 0.9.0

Full file and method

First table, 2026-10-21 (0.9.0 working tree). Small and typical sizes run in the session; the large rows are the nightly review's (tools/benchmarks/bench.py run --large). Same machine, one case at a time, no other benchmark running.

MachineApple M5 Pro, arm64, macOS 27.0.1
KitSwift 6.4 (swiftlang-6.4.0.34.1), swift build -c release, FormatoKit working tree on main after 0.8.0
ReferenceRosettaSciIO 0.14.1.dev96+g049e7d707 (the kit's pin), Python 3.14.7, NumPy 2.5.4, h5py 3.16.0, tifffile 2026.9.20, numba 0.68.0, sparse 0.19.2 (References/rsciio-venv)
Operationopen the file and have all its data in memory: file_reader(lazy=False) against the kit's signal load() (Velox SI: VeloxSpectrumImageSignals; HDF5 4D cube: h5py dataset[...], the floor, py4DSTEM is not an rsciio plugin)
Protocolone fresh process per side and case; one warm-up read, then five timed reads; median, with min to max in brackets; peak resident size of that process (includes the interpreter, NumPy and numba on the Python side)
Same workboth sides' data sums are compared (inputs are integer-valued, so the sums are exact)
Inputsgenerated by tools/benchmarks/bench.py into a scratch folder (planted DM4, Velox, TIFF, MRC, MIB, EMPAD, HDF5 and MSA files); nothing of the owner's data
FormatInputSizeKit CPU: median (min to max)RosettaSciIO: median (min to max)Python / kitPeak RSS kit / PythonSame data sum
DM4 image512² f32small0.3 ms (0.3 ms to 0.4 ms)0.2 ms (0.2 ms to 0.3 ms)0.67x15 / 106 MByes
DM4 image2048² f32typical1.9 ms (1.7 ms to 2.3 ms)1.0 ms (1.0 ms to 1.7 ms)0.54x85 / 169 MByes
DM4 image4096² f32largenightlynightlynightly
DM4 4D16²x64² u16small0.3 ms (0.3 ms to 0.4 ms)0.4 ms (0.3 ms to 0.4 ms)1.03x22 / 115 MByes
DM4 4D32²x128² u16typical3.3 ms (2.9 ms to 4.2 ms)2.1 ms (1.9 ms to 3.1 ms)0.63x227 / 303 MByes
DM4 4D128²x128² u16largenightlynightlynightly
Velox image1024² x1 u16small1.3 ms (1.2 ms to 1.6 ms)0.7 ms (0.7 ms to 1.1 ms)0.57x36 / 98 MByes
Velox image2048² x5 u16typical9.9 ms (9.7 ms to 10.3 ms)8.5 ms (8.4 ms to 8.8 ms)0.86x409 / 567 MByes
Velox image4096² x10 u16largenightlynightlynightly
Velox spectrum4096 ch u32small0.3 ms (0.2 ms to 0.4 ms)0.3 ms (0.3 ms to 0.5 ms)1.02x14 / 72 MByes
Velox SI (EDS)16² px, 4096 ch, 100 ev/pxsmall0.7 ms (0.6 ms to 0.8 ms)1.0 ms (0.9 ms to 1.5 ms)1.39x30 / 173 MByes
Velox SI (EDS)128² px, 4096 ch, 100 ev/pxtypical4.0 ms (3.9 ms to 14.4 ms)13.2 ms (12.1 ms to 15.0 ms)3.33x823 / 1508 MByes
Velox SI (EDS)512² px, 4096 ch, 100 ev/pxlargenightlynightlynightly
TIFF1024² u16small0.5 ms (0.5 ms to 0.6 ms)0.3 ms (0.3 ms to 0.3 ms)0.61x20 / 62 MByes
TIFF4096² u16typical4.3 ms (4.2 ms to 5.5 ms)2.0 ms (1.7 ms to 3.0 ms)0.46x209 / 250 MByes
TIFF8192² u16largenightlynightlynightly
MRC512² x1 f32small0.2 ms (0.2 ms to 0.3 ms)1.1 ms (0.9 ms to 1.4 ms)4.87x11 / 109 MByes
MRC1024² x10 f32typical6.0 ms (5.3 ms to 6.9 ms)8.8 ms (4.2 ms to 10.7 ms)1.46x196 / 311 MByes
MRC2048² x20 f32largenightlynightlynightly
MIB256² x64 U16small2.0 ms (1.8 ms to 2.4 ms)3.0 ms (2.7 ms to 3.3 ms)1.54x61 / 200 MByes
MIB256² x1024 U16typical35.0 ms (32.3 ms to 37.7 ms)28.8 ms (28.5 ms to 29.6 ms)0.82x879 / 1190 MByes
MIB256² x16384 U16largenightlynightlynightly
EMPAD16² scan, 128x130 f32small1.5 ms (1.3 ms to 2.2 ms)1.4 ms (1.2 ms to 1.9 ms)0.88x75 / 126 MByes
EMPAD48² scan, 128x130 f32typical13.7 ms (12.0 ms to 19.6 ms)24.4 ms (23.4 ms to 25.3 ms)1.79x611 / 799 MByes
EMPAD128² scan, 128x130 f32largenightlynightlynightly
HDF5 4D cube16²x64² f32small0.7 ms (0.7 ms to 0.9 ms)0.8 ms (0.8 ms to 0.8 ms)1.09x41 / 70 MByes
HDF5 4D cube32²x128² f32typical7.1 ms (6.3 ms to 9.1 ms)8.2 ms (6.9 ms to 11.3 ms)1.14x336 / 377 MByes
HDF5 4D cube64²x256² f32largenightlynightlynightly
MSA4096 pointssmall1.1 ms (1.1 ms to 1.1 ms)0.9 ms (0.9 ms to 0.9 ms)0.81x9 / 43 MByes
MSA65 536 pointstypical16.6 ms (16.5 ms to 17.0 ms)13.6 ms (13.2 ms to 14.1 ms)0.82x21 / 50 MByes

Reading it: "Python / kit" above 1 is the kit faster. The Velox SI row is the stream decode (RosettaSciIO: numba-jitted, warm). The MIB and MRC rows are byte-swap and copy at memory speed; MIB reads were 934 ms at the first measurement (see below).

Backend decision per function (D16)

Readers stay on the CPU: they are bound by file reads and memory copies, there is no arithmetic for a GPU to take over, and host-to-device copies of a freshly read array cost more than the read. Measured break-even: none exists, no Metal path is a work item for any reader. Threading and mmap: only the 4D loads above 100 MB could split a read over cores; at the typical size the 4D rows are already within 1.1x of h5py and of RosettaSciIO, so the question belongs to the nightly large rows (open-items).

FunctionBackendWhy
DM, TIFF, MRC, MIB, EMPAD, HDF5 4D, Velox images and spectra, MSACPU onlyread-bound; no per-element arithmetic beyond byte swaps
Velox spectrum-image decodeCPU onlysequential stream with data-dependent branches (markers); the kit is already 3.3x RosettaSciIO's numba loop at 128²

Slower than RosettaSciIO at the typical size

Listed first in docs/open-items.md with the cause. Found and fixed in this session (diagnosed with sample, then measured): MIB 934 ms to 35 ms (an ISO8601DateFormatter built per frame header, 1024 times, plus a per-pixel Data.append); MSA 111 ms to 17 ms (per-line allocation in the first reader).

ImagoKit 0.6.0

Full file and method

Run 2026-10-21 on the audit session (ImagoKit 0.6.0 sources). Header. Apple M5 Pro, macOS (Darwin 27.0.0), Swift 6.4 (swiftlang-6.4.0.34.1), swift test -c release; Python 3.12.14 from refs/skimage with NumPy 2.5.4, SciPy 1.18.1, scikit-image 0.26.0 (single-threaded np.fft/ndimage). Method. The same function, dtype and input size on both sides (uniform random pixels 30 to 70); one warm-up call, then five timed calls; the table shows the median and (min–max) in milliseconds. FFTs reuse their plan (FFT2D, FFT2DDouble; NumPy caches its own); rfft2, butterworth, the shifts, registrations and filters are the public one-call API. Caveat. The five other kit audits ran on the machine at the same time (load average 11 to 25 during these runs, 6 to 8 before): absolute times are noisy, and both sides were measured in the same minutes so the ratios are comparable but not exact. The sizes are the small (128²) and typical (512²) ones plus one non-power-of-two (500²); masked registration, which costs eleven transforms of (2n−1)², is at 64² and 256². Large sizes (2048², 4096², 8192²) are nightly: IMAGO_BENCH_LARGE=1 and --large add them; they were not run here.

Reproduce (under the suite's memory cap, one benchmark at a time machine-wide):

```sh

IMAGO_BENCH=1 IMAGO_BENCH_OUT=/tmp/swift.json ../tools/memcap.sh -- swift test -c release --filter ImagoBenchmarks ../tools/memcap.sh -- ../refs/skimage/bin/python -I -B tools/benchmarks/bench.py /tmp/python.json python3 tools/benchmarks/report.py /tmp/swift.json /tmp/python.json

```

The harness is Tests/ImagoBenchmarks/Benchmarks.swift (its one test returns at once unless IMAGO_BENCH=1); tools/benchmarks/bench.py is the Python side. "kit / Python" below 1 means the kit is faster.

Kit CPU against Python

functiondtypesizekit CPU ms: median (min–max)Python ms: median (min–max)kit / Python
butterworthfloat32128²0.152 (0.151–0.158)0.0788 (0.0755–0.102)1.92
butterworthfloat32512²2.61 (2.57–2.64)1.12 (1.06–1.23)2.34
butterworthfloat64128²0.283 (0.28–0.311)0.0993 (0.0956–0.121)2.85
butterworthfloat64512²6.44 (6.34–6.66)2.27 (2.02–2.64)2.83
difference_of_gaussiansfloat32128²0.286 (0.279–0.288)0.262 (0.242–0.274)1.09
difference_of_gaussiansfloat32512²4.46 (4.45–4.53)5.29 (5.16–5.49)0.84
fft2float32128²0.0191 (0.0175–0.0612)0.0867 (0.0855–0.0941)0.22
fft2float32500²1.89 (1.84–1.97)3.11 (2.68–3.37)0.61
fft2float32512²0.537 (0.528–0.537)3.61 (3.42–3.77)0.15
fft2float64128²0.0695 (0.0673–0.093)0.0759 (0.0716–0.0761)0.92
fft2float64500²4.63 (4.55–4.74)1.94 (1.78–1.99)2.38
fft2float64512²2.14 (2.06–2.23)2.66 (2.45–2.74)0.81
gaussian_filter sigma=3float32128²0.139 (0.136–0.157)0.127 (0.124–0.136)1.09
gaussian_filter sigma=3float32512²2.22 (2.18–2.23)2.52 (2.51–2.61)0.88
gaussian_filter sigma=3float64128²0.125 (0.124–0.129)0.145 (0.133–0.191)0.86
gaussian_filter sigma=3float64512²1.96 (1.94–2.01)3.09 (2.96–3.18)0.63
label connectivity=2bool128²0.154 (0.126–0.231)0.117 (0.0848–0.167)1.32
label connectivity=2bool512²4.37 (4.2–4.64)2.06 (2.04–2.1)2.13
masked phase_cross_correlationfloat3264²1.66 (1.65–1.74)0.704 (0.693–0.789)2.36
masked phase_cross_correlationfloat32256²29.6 (29.1–30.7)12.4 (12.2–13)2.38
masked phase_cross_correlationfloat6464²3.39 (3.2–4.65)1.03 (1.02–1.28)3.27
masked phase_cross_correlationfloat64256²66.5 (64.3–67.5)19.1 (18–19.7)3.48
phase_cross_correlationfloat32128²0.136 (0.135–0.15)0.177 (0.173–0.207)0.77
phase_cross_correlationfloat32512²2.62 (2.56–2.63)3.45 (3.19–3.63)0.76
phase_cross_correlationfloat64128²0.294 (0.293–0.334)0.273 (0.266–0.278)1.08
phase_cross_correlationfloat64512²7.57 (7.51–8.34)6.07 (5.52–6.59)1.25
phase_cross_correlation upsample=10float32128²0.317 (0.306–0.318)0.241 (0.237–0.251)1.32
phase_cross_correlation upsample=10float32512²5.25 (5.19–5.29)4.18 (3.83–4.61)1.26
profile_line order=1 width=5float32128²0.0114 (0.0112–0.0148)0.601 (0.594–0.634)0.02
profile_line order=1 width=5float32512²0.0449 (0.0444–0.0474)2.43 (2.42–2.71)0.02
profile_line order=3 width=5float32128²0.516 (0.502–0.527)0.801 (0.794–0.928)0.64
profile_line order=3 width=5float32512²5.33 (5.28–5.44)7.44 (7.21–7.6)0.72
rfft2float32128²0.0329 (0.0311–0.035)0.0405 (0.0401–0.0433)0.81
rfft2float32512²0.586 (0.509–0.598)1.52 (1.29–1.86)0.39
rfft2float64128²0.0799 (0.0755–0.0865)0.0313 (0.031–0.0322)2.55
rfft2float64512²2.12 (2.07–2.22)0.791 (0.724–0.888)2.68
shift order=1float32128²0.0466 (0.0464–0.0478)0.158 (0.154–0.159)0.29
shift order=1float32512²0.651 (0.587–0.895)2.51 (2.21–2.52)0.26
shift order=3float32128²0.491 (0.479–0.496)0.641 (0.605–0.655)0.77
shift order=3float32512²7.69 (7.67–7.79)10.9 (10.6–11.1)0.71
uniform_filter size=5float32128²0.0745 (0.0723–0.0846)0.0368 (0.0367–0.0381)2.02
uniform_filter size=5float32512²1.17 (1.17–1.18)1.27 (1.26–1.29)0.93
uniform_filter size=5float64128²0.052 (0.0517–0.0572)0.0466 (0.0455–0.0492)1.11
uniform_filter size=5float64512²0.778 (0.773–0.811)1.8 (1.77–2.05)0.43

Not in the table because there is no counterpart on the same inputs: PeakLocalMax.find (counterpart peak_local_max needs maximum_filter; not timed this session).

Backend decision per function (D16)

Today every function above is CPU (Accelerate, Float64 reference plus Float32 where it exists). No function has a Metal path, so no Metal break-even has been measured; the column says what the CPU table implies and what the nightly run must measure. A guess is not recorded as a measurement.

functionbackend todaydecision
fft2 Float32/Float64CPUCPU only at 128² to 512² (0.02 to 2 ms: a Metal dispatch cannot win). Work item: a Metal Float32 FFT prototype, break-even to be measured nightly at 2048², 4096², 8192²
rfft2, butterworth, masked registrationCPUCPU; first the real-input transform (open-items 1), then re-measure
gaussian_filter, uniform_filter, difference_of_gaussiansCPUCPU only at 128² to 512² (1 to 4 ms, memory bound). Metal candidate above 2048² for large sigma; to be measured nightly
shift order 1CPUCPU only: 0.65 ms at 512²
shift order 3 (prefilter plus taps)CPUCPU; the prefilter is sequential along lines, the tap sum is the Metal-friendly part: candidate above 2048², not measured
phase_cross_correlationCPUCPU; FFT-bound, follows fft2
profile_lineCPUCPU: small arrays stay on the CPU (D16)
labelCPUCPU: union-find is not a Metal workload
peak_local_maxCPUCPU; the window maximum is a Metal candidate only above 4096²

DiffractoKit 0.9.0

Full file and method

Measured 2026-10-21 on the first table of the kit audit (suite ROADMAP task 4, protocol step 2 and 3). Small and typical sizes only; the large rows are the nightly review's (DIFFRACTO_BENCH_LARGE=1). Best of five after one warm-up: the median is the figure, the spread (min-max) is in brackets. Same inputs on both sides (tools/benchmarks/gen_inputs.py writes them), Float64 unless the function is Float32 by design (ACOM, ellipse pattern pixels).

MachineApple M5 Pro, 64 GB, macOS 27.0.1 (Darwin 27.0.0)
ToolchainApple Swift 6.4, swift build -c release (separate package tools/benchmarks)
Python3.12.14, NumPy 2.5.4, SciPy 1.18.1; py4DSTEM 0.14.19 (refs/py4dstem); pymatgen 2026.10.16 and orix 0.15.0 and spglib 2.8.0 (refs/orix)
Load10.5 on a 12-core machine while five other kit sessions ran (uptime 17:39): every figure carries that noise, the single-core rows least. Re-run when the machine is quiet before quoting a ratio.
Pin noterefs/orix holds pymatgen 2026.10.16, the kit's parity pin is 2026.9.24; nothing here compares numbers, only time

Re-run: tools/benchmarks/gen_inputs.py (refs/orix Python), then ../../tools/memcap.sh --gb 8 -- tools/benchmarks/run.sh for the kit and tools/benchmarks/bench_python.py pymatgen|py4dstem for Python, merged by merge.py. The raw lines are tools/benchmarks/results/2026-10-21.jsonl.

Per-function table (ms per call; acom_match_scan is ms per pattern)

FunctionSizenKitPythonKit / Python (first Python row)
niggli_reducesmall1000.0679 ms (0.064-0.0839)11.3 ms (9.96-11.7) [cpu]<br>0.293 ms (0.29-0.296) [spglib]0.006
niggli_reducetypical10000.51 ms (0.508-0.512)163 ms (149-267) [cpu]<br>3.13 ms (2.88-3.5) [spglib]0.0031
niggli_reducelarge10000nightlynightly [cpu]<br>nightly [spglib]-
reflectionssmall100.0606 ms (0.0581-0.065)0.0261 ms (0.024-0.0353)2.3
reflectionstypical200.753 ms (0.56-0.916)0.115 ms (0.0952-0.191)6.6
reflectionslarge40nightlynightly-
kinematic_reflectionssmall100.98 ms (0.849-1.14)4.45 ms (3.96-55.3)0.22
kinematic_reflectionstypical207 ms (6.64-7.55)12.7 ms (11.4-34.7)0.55
kinematic_reflectionslarge30nightlynightly-
symmetry_detectionsmall46.87 ms (6.45-7.47)5.38 ms (4.78-5.69)1.3
symmetry_detectiontypical32361 ms (350-438)13.1 ms (11.9-16.5)28
symmetry_detectionlarge108nightlynightly-
cif_importsmall9620.652 ms (0.632-0.713)1.28 ms (1.27-1.31)0.51
cif_importtypical2364351 ms (329-506)2.52 ms (2.32-2.64)1.4e+02
cif_importlarge6483nightlynightly-
orientation_reducesmall10001.88 ms (1.87-5.39)22.1 ms (17.8-26)0.085
orientation_reducetypical1000019.3 ms (17.8-29.4)48.2 ms (42.2-50.7)0.4
orientation_reducelarge100000nightlynightly-
ellipse_fit_1dsmall1281.25 ms (1.2-1.41)1.34 ms (1.22-1.8)0.94
ellipse_fit_1dtypical2565.86 ms (4.35-17.7)5.26 ms (4.87-5.62)1.1
ellipse_fit_1dlarge512nightlynightly-
acom_librarysmall1007.81 ms (6.88-16.9)13.7 ms (12.4-40.2)0.57
acom_librarytypical60060.6 ms (49.4-80.7)62.5 ms (39-132)0.97
acom_librarylarge2400nightlynightly-
acom_match_scansmall641.85 ms (1.83-1.88) [cpu 1 worker]<br>0.245 ms (0.235-0.274) [cpu all cores]<br>0.294 ms (0.285-0.314) [metal]25.7 ms (14.4-37.9)0.072 / 0.0095 / 0.011
acom_match_scantypical10241.94 ms (1.91-1.98) [cpu 1 worker]<br>0.216 ms (0.211-0.236) [cpu all cores]<br>0.236 ms (0.215-0.285) [metal]17.5 ms (10.4-21.5)0.11 / 0.012 / 0.014
acom_match_scanlarge16384nightly [cpu 1 worker]<br>nightly [cpu all cores]<br>nightly [metal]--

What the rows compare, and what they do not

niggli_reduce: the kit's UnitCell.niggliReduced against pymatgen's Lattice.get_niggli_reduced_lattice (pure Python) and

spglib's C niggli_reduce; the kit is faster than both (the first Python row is pymatgen). Cells are built outside the timer.

reflections: not like for like. The kit's reflections(kMax:) also gives d, the absence flag, the rings and their

orbits; pymatgen's get_points_in_sphere returns the points only. The kit is 2.3 to 6.6 times slower on the points.

kinematic_reflections: the kit's kinematicReflections(kMax:retainingAll:false) against py4DSTEM's

Crystal.calculate_structure_factors on the same C2/m cell; the kit is 1.8 to 4.5 times faster. Both use the Lobato-Van Dyck table.

symmetry_detection and cif_import: the kit's CIFImport.read always detects the symmetry of the cell it reads;

pymatgen's CifParser only parses and SpacegroupAnalyzer is spglib in C. On a 32-atom P1 cell (an fcc supercell with 1 536 operations) the kit takes 361 ms against 13 ms, 28 times slower; on a four-atom cell they are level. Listed first in docs/open-items.md.

orientation_reduce: the kit reduces one matrix at a time (24 proper operators); orix reduces a vectorised batch

(Orientation.from_matrix + map_into_symmetry_reduced_zone). The kit is 2.5 to 12 times faster at these sizes.

ellipse_fit_1d: level with py4DSTEM's fit_ellipse_1D (0.94 and 1.1).

acom_library: level with orientation_plan for about 600 zone axes (0.97); py4DSTEM's count comes from its angular step

(630 at 1.6 degrees), the kit's from zoneAxisCount. 128 in-plane steps on both sides.

acom_match_scan: the kit's matchAll over the whole scan against py4DSTEM's match_single_pattern per pattern (one

core; Python's per-pattern time includes its own refinements, so this is a time, not a like-for-like algorithm). Per pattern the kit on one core is 14 times (8 x 8 scan) and 9 times (32 x 32) faster than py4DSTEM on one core, and on all cores 105 and 81 times.

Backend decision per function (D16)

FunctionBackendWhy / break-even
niggli_reduce, reflections, kinematic_reflections, symmetry_detection, cif_importCPU onlysmall arrays, branchy integer logic, readers (D16); the kit is already faster than Python except where docs/open-items.md lists it
orientation_reduceCPU only24 small matrix products per rotation; the batch (100 000 in 0.2 s) is bound by memory traffic, a GPU would pay the transfer for no gain at these sizes
ellipse_fit_1dCPU onlyone 1D least-squares fit per pattern; level with Python
acom_libraryCPU onlybuilt once per phase (60 ms for 600 axes), not per pattern
acom_match_scanCPU all cores; Metal not above any measured NMetal 0.294 ms per pattern at 64 patterns, 0.236 at 1 024, against 0.245 and 0.216 on all CPU cores: the break-even is not reached up to 1 024 patterns (the Metal path is 7 to 22 % slower); the 128 x 128 row (16 384 patterns) is the nightly's, and the Metal path stays an option (ACOMBackend.metal), not the default. A larger library (2 400 axes) is the other axis to measure there

Numbers in docs/parity.md are untouched by this table (time only).

SpectroKit 0.8.0

Full file and method

Written 2026-10-21 for 0.8.0. Harness: tools/benchmarks/run.sh [small,typical] (Swift: Tests/SpectroEDXTests/BenchmarkQuantificationTests.swift, Tests/SpectroEELSTests/BenchmarkThicknessTests.swift, built by swift test -c release, enabled by SPECTRO_BENCH_OUT; Python: tools/benchmarks/bench.py), under tools/memcap.sh (8 GB; measured Swift peak 1.3 GB at the typical size). Inputs are closed-form on both sides (formulas in the harness headers), float64. One warm-up, then five runs, median and (min-max).

Machine: Apple M5 Pro, 64 GB, macOS 27.0.1 (26A434), Swift 6.4 (swiftlang-6.4.0.34.1), Python 3.12.14, NumPy 2.5.4, HyperSpy 2.4.0, exSpy 0.3.2 (no Numba). Other sessions were running on the machine, so the Python spreads are wide; read ratios within about 30 %.

Results

functionsizepixelsSwift CPU, ms median (min-max)Python, ms median (min-max)Python / Swift
quantification maps: CLsmall640.03 (0.03-0.04)24.25 (22.87-54.62)739.62
quantification maps: CL+absorptionsmall640.20 (0.19-0.20)178.25 (160.09-233.19)913.51
quantification maps: zetasmall640.02 (0.02-0.02)30.13 (26.23-77.22)1409.82
quantification maps: zeta+absorptionsmall640.11 (0.10-0.12)107.16 (92.21-159.42)1002.64
quantification maps: cross_sectionsmall640.02 (0.02-0.03)61.30 (57.32-122.77)2464.49
quantification maps: CLtypical655369.02 (8.63-9.50)312.23 (307.89-322.30)34.60
quantification maps: CL+absorptiontypical6553639.38 (38.00-40.36)989.56 (933.26-1025.54)25.13
quantification maps: zetatypical655366.73 (6.38-7.32)38.62 (34.42-87.84)5.74
quantification maps: zeta+absorptiontypical6553629.90 (29.16-39.16)129.60 (92.88-168.46)4.33
quantification maps: cross_sectiontypical655369.99 (9.64-10.59)50.59 (43.01-90.63)5.06
t/lambda log ratio: fixed 3 eVsmall640.15 (0.15-0.17)3.62 (3.08-4.46)23.43
t/lambda log ratio: estimatedsmall640.25 (0.24-0.32)12.10 (11.87-12.86)47.61
t/lambda log ratio: fixed 3 eVtypical40969.90 (9.53-10.19)5.01 (4.71-8.40)0.51
t/lambda log ratio: estimatedtypical409616.34 (16.29-17.02)87.30 (82.73-123.58)5.34

Sizes: quantification small 8 x 8, typical 256 x 256 (3 elements), large 1024 x 1024; thickness small 8 x 8, typical 64 x 64 (2048 channels), large 256 x 256. Large: nightly (not run in the session).

Backend decision per function (D16)

functiondecisionwhy
quantification maps, all methods (the Python column converts to weight percent only; Swift returns both)CPU only (parallel over pixels since 0.8.0)9 to 40 ms for 65 536 pixels; per pixel the work is 10 to 500 ns of arithmetic, so a Metal dispatch pays only above about 1e6 pixels; the break-even is not measured (large size is nightly). Not a work item until the nightly shows a slow large image
t/lambda log ratio, fixed thresholdCPU only; slower than Python on the typical size (0.49x), first in docs/open-items.mdsingle-threaded scalar passes against NumPy's vectorised axis sums
t/lambda log ratio, estimated thresholdCPU only5x faster than Python at the typical size (Python's per-pixel Savitzky-Golay search dominates)
EDX line fit, auto ID, EELS background fit, edge quantificationnot benchmarked in 0.8.0need a Velox spectrum image or a planted EELS image on both sides; listed in docs/open-items.md

CuboKit 0.8.0

Full file and method

Kit against py4DSTEM, same inputs, Float32 cubes, one run of tools/benchmarks on 2026-10-21. Harness: Tests/CuboVirtualTests/BenchmarkHarness.swift (Swift, built with swift test -c release, skipped unless CUBOKIT_BENCH_DIR is set) and tools/benchmarks/bench.py (the py4DSTEM side and the merge; the commands are in its docstring). Both read the same .npy cubes. Best of five after one warm-up; cells are best / median (spread = slowest - best) in milliseconds; "CPU vs py4DSTEM" is py4DSTEM's best divided by the kit CPU's best (above 1 the kit is faster). The cubes are held in memory (MemoryCube; DataCube(data=ndarray)). The Metal columns copy each tile to the GPU on every call; the _resident rows hold the cube in a ResidentCube (one Metal buffer) first.

Sizes (scan rows x columns x detector rows x columns): small 32x32x32x32 (1.0 M values), mid 64x64x32x32 (4.2 M), typical 100x100x64x64 (41.9 M, 168 MB). The large size (330x330x64x64, 1.8 GB) and the Bragg, strain, DPC, parallax and ptychography functions are the nightly review's; this first table is what a Mac can measure while five other sessions use it. The machine was shared during the run (load average 5 to 11 from other sessions' builds), so the spreads are wider than on a quiet Mac.

Machine: Apple M5 Pro, macOS 27.0.1; Swift 6.4; py4DSTEM 0.14.19, NumPy 2.5.4, Python 3.12.14. Float32 cubes, best of five after one warm-up; cells are best / median (spread) in ms.

functionsizekit CPUkit Metalpy4DSTEMCPU vs py4DSTEM
bin_Q_2small1.3 / 1.3 (0.2)—3.4 / 3.6 (0.8)2.64x
bin_Q_2mid5.4 / 5.6 (0.9)—12.9 / 13.1 (0.2)2.40x
bin_Q_2typical54.0 / 55.3 (2.7)—116.5 / 120.0 (4.2)2.16x
bin_R_2small0.8 / 0.9 (0.1)—0.4 / 0.5 (0.0)0.53x
bin_R_2mid3.2 / 3.4 (0.3)—0.7 / 0.8 (0.6)0.22x
bin_R_2typical32.3 / 33.0 (1.5)—6.4 / 6.8 (2.0)0.20x
center_of_masssmall0.4 / 0.6 (0.5)1.6 / 1.6 (0.5)——
center_of_massmid1.2 / 1.7 (0.6)2.1 / 2.3 (1.7)——
center_of_masstypical12.8 / 13.2 (0.8)31.4 / 31.9 (0.9)——
dp_maxsmall0.1 / 0.1 (0.0)1.5 / 1.6 (0.5)0.2 / 0.2 (0.0)2.55x
dp_maxmid0.3 / 0.3 (0.0)3.5 / 3.5 (0.2)0.4 / 0.4 (0.0)1.48x
dp_maxtypical5.9 / 5.9 (0.4)28.7 / 29.1 (0.6)2.8 / 3.1 (3.9)0.48x
dp_meansmall0.8 / 0.8 (0.4)1.5 / 1.8 (0.7)0.2 / 0.2 (0.1)0.23x
dp_meanmid3.4 / 3.8 (0.7)3.4 / 3.5 (0.2)0.4 / 0.4 (0.1)0.12x
dp_meantypical22.0 / 23.6 (1.8)27.5 / 28.7 (3.1)2.8 / 3.2 (1.2)0.13x
dp_mean_residentsmall—0.5 / 0.5 (0.5)——
dp_mean_residentmid—1.6 / 1.7 (0.1)——
dp_mean_residenttypical—3.4 / 3.5 (0.2)——
dp_mediansmall8.6 / 9.1 (0.8)—13.8 / 14.2 (0.4)1.61x
dp_medianmid36.8 / 38.3 (4.9)—65.3 / 68.0 (5.1)1.77x
dp_mediantypical233.4 / 251.8 (34.5)—785.1 / 821.1 (51.6)3.36x
median_masked_pixelssmall0.5 / 0.5 (0.0)—0.5 / 0.8 (0.8)1.07x
median_masked_pixelsmid2.0 / 2.1 (0.2)—0.9 / 0.9 (0.3)0.42x
pad_Q_2small0.8 / 0.8 (0.2)—2.1 / 2.3 (0.4)2.72x
pad_Q_2mid2.9 / 3.0 (0.3)—8.3 / 8.8 (0.7)2.86x
pad_Q_2typical34.6 / 46.3 (23.0)—98.3 / 100.5 (10.6)2.84x
resample_Q_halfsmall0.6 / 0.6 (0.2)—10.0 / 10.8 (1.1)16.94x
resample_Q_halfmid2.4 / 2.4 (0.1)—39.3 / 45.6 (16.0)16.51x
selected_area_mean_masksmall1.3 / 1.4 (0.1)1.7 / 1.9 (0.3)0.5 / 0.6 (0.5)0.38x
selected_area_mean_maskmid4.5 / 5.1 (1.1)3.7 / 3.9 (0.3)1.3 / 1.4 (0.2)0.29x
selected_area_mean_masktypical47.6 / 49.4 (2.4)29.0 / 30.3 (2.4)10.8 / 12.8 (2.8)0.23x
selected_area_mean_mask_residentsmall—1.2 / 1.3 (0.3)——
selected_area_mean_mask_residentmid—1.8 / 2.0 (0.3)——
selected_area_mean_mask_residenttypical—4.7 / 6.2 (6.0)——
selected_area_mean_weightssmall1.5 / 1.7 (0.2)1.5 / 1.7 (0.4)2.9 / 3.1 (0.6)1.89x
selected_area_mean_weightsmid5.8 / 6.2 (1.3)3.5 / 3.6 (0.2)11.8 / 12.1 (0.6)2.03x
selected_area_mean_weightstypical61.7 / 63.1 (3.5)27.7 / 29.2 (2.3)166.5 / 168.1 (3.6)2.70x
selected_area_mean_weights_residentsmall—1.1 / 1.1 (0.0)——
selected_area_mean_weights_residentmid—1.7 / 1.8 (0.3)——
selected_area_mean_weights_residenttypical—3.6 / 3.8 (0.3)——
thin_R_2small0.1 / 0.1 (0.1)—0.6 / 0.6 (0.4)7.23x
thin_R_2mid0.3 / 0.3 (0.0)—0.8 / 0.8 (0.1)3.02x
thin_R_2typical2.4 / 2.7 (0.4)—4.5 / 5.0 (4.4)1.91x
virtual_image_circlesmall0.2 / 0.2 (0.0)0.9 / 0.9 (0.4)1.9 / 2.0 (0.2)10.09x
virtual_image_circlemid0.7 / 0.7 (0.0)2.2 / 2.3 (0.6)7.0 / 7.1 (0.2)9.88x
virtual_image_circletypical10.3 / 10.8 (1.0)23.5 / 24.1 (1.9)31.3 / 32.7 (2.8)3.04x

Backend decision per function (D16)

The break-even is read off the three sizes above; between two measured sizes it is interpolated, and says so. The Mac was loaded (load average 10 to 11) during the final run, so a crossing near a tie moves with load.

functiondecisionwhy
VirtualDiffraction.maxCPU alwaysblock-parallel since 0.8.0: 5.9 ms at the typical size against NumPy's 2.8 and 29 ms with the host copy; a vmax pass is memory-bound, so a copy to the GPU never pays
VirtualDiffraction.mean, meanAndMaxCPU; resident Metal alwaysthe compensated Double sum is compute-bound. Host-copy Metal ties the CPU at 4.2 M values (3.4 against 3.4 ms) and loses at 41.9 M on this loaded Mac (27.5 against 22.0), so it does not pay; the resident cube beats the CPU at every size (0.5 against 0.8 ms at 1.0 M, 3.4 against 22.0 at 41.9 M) and comes within 1.2x of NumPy (3.4 against 2.8 ms)
VirtualDiffraction.pattern with a maskMetal above about 4 M values; resident Metal alwaysCPU 47.6 ms, host-copy Metal 29.0, resident Metal 4.6 at the typical size; level at 4.2 M (4.5 against 3.7)
VirtualDiffraction.pattern with weightsMetal above about 1 M values; resident Metal alwaysthe CPU path pays Double products (62 ms at the typical size); host-copy Metal is 1.7x faster at 4.2 M (3.5 against 5.8) and 2.2x at 41.9 M; resident Metal 4.3 ms. The CPU path is already 2.7x faster than py4DSTEM
VirtualDiffraction.medianCPU onlyno Metal path; 3.4x faster than py4DSTEM at the typical size. A GPU sort per pixel needs the whole scan axis resident; revisit with the resident cube
VirtualImaging.imageCPU only for a non-resident cubethe CPU dot product is 9.5 ms at the typical size against 21 ms with the host copy; resident Metal is not measured (open item)
CenterOfMass.measureCPU onlyCPU 11.0 ms against Metal 26.7 at the typical size; the weighted-moment kernel is one thread per position
CubeEditing.binScan, thinScan, padDetector, resampleDetector, medianFilterMaskedPixels, ViewedCube binCPU onlymemory-bound copies and small per-pattern arithmetic; the copy to the GPU would cost more than the work. Faster than py4DSTEM except binScan and the masked-pixel median at 64 x 64 (open item 3)
Calibration, CalibrationMeasurement, BraggMaskingCPU onlyscalars and one pattern at a time

SimulatoKit 0.3.0

Full file and method

Per-function timings of SimulatoKit against abTEM 1.0.10 on the same inputs (Al [001], 200 keV, a plane wave or a 20 mrad probe, 10 slices of 2.025 Å, about 0.1 Å per pixel, infinite projection). One warm-up, then five timed runs; each cell is the median in milliseconds with (min-max). Written by tools/benchmarks/run.sh and merge.py.

Machine: Apple M5 Pro, macOS 27.0.1, Apple Swift version 6.4 (swiftlang-6.4.0.34.1 clang-2100.3.34.1)

Python 3.12.14 (NumPy 2.5.4, SciPy 1.18.1), abTEM 1.0.10, NumPy/SciPy from refs/abtem (CPU, NumPy's FFT; no GPU)

Kit CPU is float64 on Accelerate (one thread per call); Metal is float32 on MPSGraph; abTEM is shown in float64 and float32

Run while other sessions used the machine (load average 6 to 11): the spread columns show it; treat ratios within 20% as equal

Sizes: 64² to 256² small and typical; 1024² and above: nightly (not run in the session that wrote this table)

fft2+ifft2

sizekit CPU f64kit Metal f32abTEM f64abTEM f32kit CPU / abTEM f64best kit / abTEM f32
58²0.145 (0.134-0.149)0.104 (0.104-0.106)0.081 (0.078-0.155)0.72x0.56x
64²0.044 (0.041-0.062)0.056 (0.055-0.063)0.047 (0.045-0.08)1.27x1.07x
122²0.427 (0.427-0.513)0.627 (0.601-0.687)0.516 (0.467-0.537)1.47x1.21x
128²0.301 (0.299-0.301)0.266 (0.22-0.401)0.234 (0.215-0.329)0.88x0.78x
186²2.3 (1.74-3.64)0.898 (0.615-1.52)0.804 (0.765-1.11)0.39x0.35x
192²1.25 (1.12-1.34)0.353 (0.351-0.74)0.607 (0.458-0.936)0.28x0.49x
250²3.75 (3.28-4.94)0.572 (0.511-0.684)0.939 (0.678-1.26)0.15x0.25x
256²2.43 (1.92-2.95)1.04 (0.911-1.13)1.48 (0.999-1.71)0.43x0.61x
506²20.6 (13.8-36.2)9.34 (7.85-10.1)4.49 (4.27-4.62)0.45x0.22x
512²9.03 (8.82-13.5)7.29 (6.73-8.27)4.54 (4.45-5.47)0.81x0.50x

(ratios are abTEM time / kit time: above 1 the kit is faster)

potential build (infinite)

sizekit CPU f64kit Metal f32abTEM f64abTEM f32kit CPU / abTEM f64best kit / abTEM f32
64²0.626 (0.598-0.745)2.09 (1.76-2.7)1.68 (1.49-1.81)3.34x2.68x
128²5.53 (3.13-7.33)9.69 (9.57-10.5)4.86 (4.23-7.11)1.75x0.88x
192²13.2 (12.2-13.4)12.8 (12.2-13.5)9.97 (7.53-10.2)0.97x0.76x
256²31.3 (21.1-35.8)41.4 (38.5-48.6)29.8 (27.9-32.1)1.32x0.95x
512²155 (117-180)251 (220-379)120 (117-121)1.62x0.77x

(ratios are abTEM time / kit time: above 1 the kit is faster)

multislice 10 slices (plane wave, built potential)

sizekit CPU f64kit Metal f32abTEM f64abTEM f32kit CPU / abTEM f64best kit / abTEM f32
64²0.929 (0.907-1.25)4.99 (4.49-5.39)3.49 (3.21-4.55)3.02 (2.76-3.12)3.76x3.25x
128²11.6 (10.6-16.3)6.53 (5.5-10.5)17.5 (16.8-17.7)12 (11.1-12.6)1.50x1.84x
192²25.9 (23.6-66.3)7.84 (6.74-8.13)20.4 (19.1-21.5)19.1 (18.7-20)0.79x2.44x
256²60.1 (45.8-78.6)10.3 (9.34-10.7)71.4 (66.8-74.2)56.1 (53.7-65.6)1.19x5.42x
512²299 (230-451)10.2 (10.1-11.1)678 (642-724)228 (225-237)2.27x22.43x

(ratios are abTEM time / kit time: above 1 the kit is faster)

transmission functions

sizekit CPU f64kit Metal f32abTEM f64abTEM f32kit CPU / abTEM f64best kit / abTEM f32
64²0.527 (0.513-0.54)
128²5.65 (4.63-15.6)
192²13.6 (12-35.7)
256²27.6 (23.4-65.1)
512²107 (95.6-122)

(ratios are abTEM time / kit time: above 1 the kit is faster)

probe build

sizekit CPU f64kit Metal f32abTEM f64abTEM f32kit CPU / abTEM f64best kit / abTEM f32
64²0.27 (0.161-0.296)0.84 (0.732-1.15)0.657 (0.587-1.09)3.11x2.43x
128²0.824 (0.691-1.15)1.4 (1.28-1.66)1.62 (0.928-1.84)1.70x1.97x
192²3.02 (2.09-4.85)2.19 (2.08-2.38)2.33 (1.92-2.72)0.73x0.77x
256²3.64 (3.59-12.3)5.05 (4.32-5.49)3.04 (2.94-3.26)1.39x0.83x
512²15.2 (14.4-56.8)42.4 (28.8-46.4)10.2 (9.46-10.8)2.78x0.67x

(ratios are abTEM time / kit time: above 1 the kit is faster)

STEM scan 8x8, annular

sizekit CPU f64kit Metal f32abTEM f64abTEM f32kit CPU / abTEM f64best kit / abTEM f32
64²27.9 (19.5-39.8)9.39 (8.71-9.76)47.4 (47-51.7)33.8 (32-34.5)1.70x3.60x
128²176 (143-246)13.8 (13.7-18.8)461 (451-464)206 (190-211)2.62x14.93x
192²459 (386-611)35.3 (34.9-38.2)509 (493-559)367 (336-416)1.11x10.40x
256²714 (448-768)50.6 (49.1-52.1)2.39e+03 (2.34e+03-2.64e+03)1.62e+03 (1.52e+03-1.66e+03)3.35x32.02x
512²3.01e+03 (2.64e+03-3.34e+03)176 (174-179)8.84e+03 (8.75e+03-9.42e+03)8.39e+03 (8.1e+03-9.73e+03)2.94x47.55x

(ratios are abTEM time / kit time: above 1 the kit is faster)

frozen phonons 4 x multislice, built per configuration

sizekit CPU f64kit Metal f32abTEM f64abTEM f32kit CPU / abTEM f64best kit / abTEM f32
64²10.6 (6.03-25.2)23.7 (20.9-24.6)21.5 (21.2-22.1)2.24x2.03x
128²77.2 (58.9-119)130 (114-132)66.3 (61.2-78.9)1.68x0.86x
192²173 (113-308)155 (138-220)94.9 (81.1-124)0.90x0.55x
256²414 (314-559)472 (410-506)301 (290-458)1.14x0.73x
512²1.32e+03 (1.12e+03-1.39e+03)1.64e+03 (1.62e+03-1.64e+03)1.38e+03 (1.34e+03-1.46e+03)1.24x1.04x

(ratios are abTEM time / kit time: above 1 the kit is faster)

multislice 10 slices (plane wave, potential built on the GPU)

sizekit CPU f64kit Metal f32abTEM f64abTEM f32kit CPU / abTEM f64best kit / abTEM f32
64²6.04 (5.96-6.31)
128²8.14 (7.63-8.89)
192²10.1 (9.5-10.5)
256²7.85 (6.79-11.7)
512²15.7 (11.8-17.9)

(ratios are abTEM time / kit time: above 1 the kit is faster)

Backend decision per function (D16)

The smallest measured size at which Metal beats the CPU path. The break-even is bracketed by the sizes measured, not interpolated.

functiondecisionmeasured
fft2+ifft2CPU only: no Metal path yet (see open-items.md if it is a work item), or the work is CPU-side by nature
potential build (infinite)CPU only: no Metal path yet (see open-items.md if it is a work item), or the work is CPU-side by nature
multislice 10 slices (plane wave, built potential)Metal above 128² (break-even between 64² and 128²)64²: cpu 0.929 ms / metal 4.99 ms, 128²: cpu 11.6 ms / metal 6.53 ms, 192²: cpu 25.9 ms / metal 7.84 ms, 256²: cpu 60.1 ms / metal 10.3 ms, 512²: cpu 299 ms / metal 10.2 ms
transmission functionsCPU only: no Metal path yet (see open-items.md if it is a work item), or the work is CPU-side by nature
probe buildCPU only: no Metal path yet (see open-items.md if it is a work item), or the work is CPU-side by nature
STEM scan 8x8, annularMetal above 64² (break-even between <64² and 64²)64²: cpu 27.9 ms / metal 9.39 ms, 128²: cpu 176 ms / metal 13.8 ms, 192²: cpu 459 ms / metal 35.3 ms, 256²: cpu 714 ms / metal 50.6 ms, 512²: cpu 3.01e+03 ms / metal 176 ms
frozen phonons 4 x multislice, built per configurationCPU only: no Metal path yet (see open-items.md if it is a work item), or the work is CPU-side by nature
multislice 10 slices (plane wave, potential built on the GPU)Metal only (the GPU build of the Metal row above; its CPU counterpart is potential build + multislice)