Suggestion: auto CPU/GPU dispatch heuristic + GPU-side extended-precision ("decimal") arithmetic #4362
Replies: 1 comment
|
Follow-up: I built a small from-scratch PyTorch-API-compatible layer on top of Speed comparison, with torch-mlx's own MLX-backed timings shown explicitly (not just ratios) alongside real PyTorch CPU and MPS/Apple GPU, median of 8 iterations, full sync forced before timing stops on every leg:
All computation in the "torch-mlx (MLX)" column runs on The wins are consistent with the residency+fusion finding above: this project's core never calls Benchmark code, methodology, and full results: https://github.com/bahaehmimdi/torch-mlx/tree/speed-benchmarks (see |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Two suggestions from a benchmarking pass I did on
mlx.coreCPU/GPUdispatch, both aimed at making it easier to get good performance (and
more numeric precision) out of MLX without the caller having to hand-manage
device placement.
Setup: Apple Silicon Mac, 34GB unified memory, MLX 0.31.2.
1. Built-in adaptive CPU/GPU dispatch based on residency + fusion
Right now getting a real GPU speedup requires the caller to already know
two non-obvious things: keep data resident on the GPU stream across
calls (don't convert per-call), and fuse multiple ops into one
@mx.compilekernel. Get either wrong and GPU can actually lose toplain NumPy.
What I measured (payload:
sqrt(a*b + a/b) - (a-b)**2/(a+b+1), 5ops/element, timed with
mx.eval()forced before stopping the clock):mx.arrayevery call: ~1.2x overNumPy at 16000x16000, loses at smaller sizes.
~4.3-4.5x at 4000x8000.
the same sizes — this was the single biggest lever, bigger than
fusion or size scaling combined. Kept growing with size, 12.9x →
46.2x from 4000x4000 up to 28000x28000 (784M elements).
mx.where-basedconditional logic too (22-29.5x), not just arithmetic.
numba.njit, which itself beatsplain NumPy by 1.5-4.6x at small scale) wins outright — GPU
dispatch/conversion overhead isn't worth paying.
Suggestion: could
mlx.coreexpose (or default to, where safe) adispatch heuristic that keeps ops on whichever stream currently holds
their operands, and warns or auto-recommends
mx.compilewhen itdetects an unfused chain of ops repeatedly hitting the same tensors?
Even a documented rule of thumb in the perf guide (data residency +
fusion matter far more than raw op count) would help — right now this
is easy to get backwards and quietly eat all the GPU gain.
2. GPU-side extended-precision ("decimal") arithmetic
Apple GPUs don't do hardware double precision, so
mlx.corehas nofloat64on the GPU stream today (only CPU, viastream=mx.cpu). Forwork that needs more than float32 precision but doesn't want to fall
back to CPU entirely, I prototyped a digit-decomposition approach
that stays on GPU:
single fused kernel:
value = Σ digit_i × 10^-i(parallel across alldigits in one
@mx.compilecall).mx.where(sum > 9, ...), so multi-digit addition can run as onevectorized GPU pass instead of a serial float64 add.
x - floor(x), masked bymx.where(mask, ...)) for isolating and processing the sub-integerpart separately from the integer part, which is where most precision
loss in float32 actually happens.
This is essentially software-emulated extended precision (comparable in
spirit to "double-single"/compensated-arithmetic techniques used
elsewhere in GPGPU code), built entirely from ops MLX already has —
mx.where,mx.floor,mx.compile. It got real precision back foraddition/reconstruction workloads without ever leaving the GPU stream.
Suggestion: would there be interest in
mlx.coreshipping somethinglike this as a first-class extended-precision dtype/helper (e.g. a
mx.decimalarray type backed by a digit array under the hood), ratherthan every user who needs float32-beyond precision on GPU reinventing
digit-decomposition themselves? Happy to share the prototype code if
useful as a starting point.
Benchmark details (rerun just now, same machine, MLX 0.31.2)
A. Single op (
a * b), converting NumPy→mx.arrayevery call:B. 5-op fused formula (
sqrt(a*b + a/b) - (a-b)**2/(a+b+1)), still converting every call:C. Same fused formula, data already resident on GPU (no per-call conversion):
D. Same resident+fused pattern applied to
mx.where-based conditional logic (diff = a>b ? a-b : (b-a)*2, then clampdiff>50 -> 50):E. Task-level hybrid (8 independent 3000x3000 formula-evaluations, half dispatched to CPU + half to GPU concurrently via
ThreadPoolExecutor, vs. running the whole batch on one backend):GPU-only wins outright — splitting work across both backends is slower than just using the faster one, because coordination overhead isn't amortized once GPU alone is already far ahead.
F. Small-data CPU comparison (same formula,
numba.njit(cache=True)vs plain NumPy):Code
Suggestion 1 — the adaptive dispatcher (routes on element count; small → Numba, large → GPU-resident fused kernel):
Suggestion 2 — GPU-side digit-decomposition ("decimal") arithmetic prototype:
All reactions