Repository navigation
Notes from moving a sparse spiking simulation to MLX: memory traffic, not dispatch count, was the bottleneck #4512
Kisame76
started this conversation in
Show and tell
Replies: 1 comment 2 replies
|
Holy moly, I read the first line and got hooked. Really great write up! Also regarding your questions, here are my answers from reading the source on current main:
|
2 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
I moved a leaky integrate-and-fire simulation of the fruit fly connectome to MLX with a few custom Metal kernels and wrote down what generalises beyond it. The workload is the published Shiu et al. model: 127,400 neurons, 14.7 M directed edges, 10,000 sequential steps per simulated second, and a data-dependent branch in every step. Nothing about the lessons depends on it being neuroscience.
Full notes with every table: docs/mlx-notes.md. Repository: Kisame76/drosophila-brain-mlx.
All numbers below come from those notes, measured on an Apple M4 Pro (24 GB) with MLX 0.32.2 on macOS 26.6.2. Across my own implementations the same model went from 22.0 s to 0.29 s per simulated second, and removing host synchronisation was a small part of that.
What I took away
mx.async_evalhelped less than I expected: 22.1 s to 19.4 s. Varying the network's activity by 5,000x moved the runtime by 2 %, which pointed at a fixed cost per step rather than at the data path.mx.fast.metal_kerneltook 0.0225 ms and moved 1.0 MiB. In the full engine, fusing the tick's chain took it from 0.75 s to 0.29 s per simulated second. I first attributed that to dispatch count, which was wrong:mx.evalis the synchronisation unit. 1, 2, 4, 8 or 16 trivial kernels inside one eval all cost about 0.11 ms in total..at[].add()is the accumulating scatter, but it cannot skip work.arr[idx] = valswith repeated indices keeps the last write;arr.at[idx].add(vals)accumulates, throughscatter_addwith GPU atomics. My baseline ran it densely over all 14.7 M edges every step, so its cost did not depend on how many neurons fired. Skipping the inactive edges needs a compaction whose size depends on the data, and that means a host read. A Metal kernel dispatched densely with an early exit and integer atomics gets the sparsity without one, and took the step from 19.6 s to 0.75 s. (Corrected: this point first said there was no accumulating scatter.)mx.compilechanged results here, through its constants. It prints captured scalar constants into the kernel source with 7 significant digits (mx.compile inlines float scalar constants with 7 significant digits, so compiled results differ from eager by 1 ulp #4503), and two of mine came back 1 ulp off. With a strict threshold downstream the runs diverged (48,308 against 47,499 events at 10,000 steps); passing the constants as arguments makes the compiled run bitwise identical. (Corrected: this point first blamed FMA contraction, butmx.compiledoes not contracta*b + c. A handwritten kernel that writes it as one expression does, and#pragma clang fp contract(off)in the header avoids that.)int32and scaling once afterwards made the kernel bit-reproducible regardless of thread order.Two questions, in case I missed something
mx.compile? (Not needed after all; see point 5.)scatter_addorbincountstyle) planned, or is.at[].add()the intended path for large index sets too? (It is the intended path; see point 4.)Corrections are welcome. I would rather fix the notes than leave a wrong explanation in them, which has already happened twice (see points 2 and 5).
All reactions