Skip to content

Add MLX backend support for Nutpie compilation - #254

Open
cetagostini wants to merge 40 commits into
pymc-devs:mainfrom
cetagostini:cetagostini/adding_mlx_backend
Open

cetagostini wants to merge 40 commits into
pymc-devs:mainfrom
cetagostini:cetagostini/adding_mlx_backend

Conversation

@cetagostini

Copy link
Copy Markdown

Introduces MLX as a backend option in compile_pymc_model, allowing gradient computation via MLX or Pytensor. Updates dependency groups to include MLX, extends internal functions to handle MLX mode, and adds corresponding tests for deterministic sampling with MLX.

@aseyboldt

Copy link
Copy Markdown
Member

Thanks, that looks great!
I think we probably should call mlx.compile on the final functions though?

@cetagostini

cetagostini commented Oct 27, 2025 •

Copy link
Copy Markdown
Author

Thanks, that looks great! I think we probably should call mlx.compile on the final functions though?

Good point, that simple addition brings between 5% to 20% more performance! @aseyboldt

@cetagostini

Copy link
Copy Markdown
Author

@aseyboldt solve the test issue to work only on macs with intel chips.

@cetagostini

Copy link
Copy Markdown
Author

@aseyboldt can you give me a hand? The test failing its strange. My local pass everythig.

@cetagostini
cetagostini requested review from aseyboldt and jessegrabowski and removed request for aseyboldt October 30, 2025 12:46

@aseyboldt aseyboldt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That failure is annoying. For some reason the results seem to differ between different machines? I think we really should figure out what's going on here. Maybe it helps if we print the first couple of values in warmup_posterior to see if the initial values are already different, or if small differences accumulate?

Comment thread python/nutpie/compiled_pyfunc.py Outdated
updated.update(**updates)

# Convert to MLX arrays if using MLX backend (indicated by force_single_core)
if self._force_single_core:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not use that argument to detect mlx.
How about we add an attribute _convert_data_item or so to the dataclass, that contains a function that transforms data arrays? We could then also use that for jax.

cetagostini added a commit to cetagostini/nutpie that referenced this pull request Apr 28, 2026
Address review comments and CI failures on PR pymc-devs#254:

* MLX is not thread-safe (Metal command-buffer race,
  ml-explore/mlx#2133), so always set ``force_single_core=True`` for
  ``backend="mlx"`` regardless of ``gradient_backend``. This unblocks the
  default config (``gradient_backend="pytensor"``) with ``chains>=2``,
  which previously aborted with
  "A command encoder is already encoding to this command buffer".

* Decouple shared-data type conversion from ``_force_single_core``:
  ``PyFuncModel`` now carries an optional ``_shared_data_converter``
  callable (set to ``mx.array`` for MLX) used by ``with_data``. The old
  code was abusing ``_force_single_core`` as a "is MLX backend" proxy.

* Drop the stale ``raw_logp_fn`` plumbing in the MLX backend. The
  transform adapter is flowjax-based (JAX-only), so MLX could never
  expose a usable raw logp.

* Pin ``mlx<0.31`` in ``pyproject.toml`` extras and the CI install.
  mlx 0.31.x crashes inside ``Compiled::eval_gpu`` when the sampler
  worker thread evaluates auto-fused element-wise kernels
  (ml-explore/mlx#3329), which is the root cause of the macOS aarch64
  pymc test segfaults. mlx 0.29.x and 0.30.x are unaffected.

* Add a runtime guard in ``_compile_pymc_model_mlx`` that raises a
  helpful ``RuntimeError`` if mlx>=0.31 is installed anyway, instead of
  segfaulting partway through sampling.

* Make ``test_deterministic_sampling_mlx`` a smoke test (drop
  ``array_compare`` + reference file). MLX sampling is not bit-identical
  across machines/MLX versions, mirroring the situation already
  documented for ``test_normalizing_flow``.

* Mark ``test_dims_model[mlx-*]`` as ``xfail`` while
  pymc-devs/pytensor#1350 is open (PyTensor's MLX linker has no
  ``XTensorFromTensor`` op yet).

* Mirror logp's ``mx.array(_x)`` conversion in the expand closure for
  consistency.

* Ignore local AI tooling folders (``.ai/``, ``.cursor/``, ``.claude/``).

Made-with: Cursor
cetagostini and others added 20 commits October 1, 2026 07:37
Introduces MLX as a backend option in compile_pymc_model, allowing gradient computation via MLX or Pytensor. Updates dependency groups to include MLX, extends internal functions to handle MLX mode, and adds corresponding tests for deterministic sampling with MLX.
Bump MLX version requirement to >=0.29.0 in pyproject.toml for all relevant extras. In compile_pymc.py, JIT compile the logp function using mx.compile for improved performance, aligning with JAX backend behavior.
Address review comments and CI failures on PR pymc-devs#254:

* MLX is not thread-safe (Metal command-buffer race,
  ml-explore/mlx#2133), so always set ``force_single_core=True`` for
  ``backend="mlx"`` regardless of ``gradient_backend``. This unblocks the
  default config (``gradient_backend="pytensor"``) with ``chains>=2``,
  which previously aborted with
  "A command encoder is already encoding to this command buffer".

* Decouple shared-data type conversion from ``_force_single_core``:
  ``PyFuncModel`` now carries an optional ``_shared_data_converter``
  callable (set to ``mx.array`` for MLX) used by ``with_data``. The old
  code was abusing ``_force_single_core`` as a "is MLX backend" proxy.

* Drop the stale ``raw_logp_fn`` plumbing in the MLX backend. The
  transform adapter is flowjax-based (JAX-only), so MLX could never
  expose a usable raw logp.

* Pin ``mlx<0.31`` in ``pyproject.toml`` extras and the CI install.
  mlx 0.31.x crashes inside ``Compiled::eval_gpu`` when the sampler
  worker thread evaluates auto-fused element-wise kernels
  (ml-explore/mlx#3329), which is the root cause of the macOS aarch64
  pymc test segfaults. mlx 0.29.x and 0.30.x are unaffected.

* Add a runtime guard in ``_compile_pymc_model_mlx`` that raises a
  helpful ``RuntimeError`` if mlx>=0.31 is installed anyway, instead of
  segfaulting partway through sampling.

* Make ``test_deterministic_sampling_mlx`` a smoke test (drop
  ``array_compare`` + reference file). MLX sampling is not bit-identical
  across machines/MLX versions, mirroring the situation already
  documented for ``test_normalizing_flow``.

* Mark ``test_dims_model[mlx-*]`` as ``xfail`` while
  pymc-devs/pytensor#1350 is open (PyTensor's MLX linker has no
  ``XTensorFromTensor`` op yet).

* Mirror logp's ``mx.array(_x)`` conversion in the expand closure for
  consistency.

* Ignore local AI tooling folders (``.ai/``, ``.cursor/``, ``.claude/``).

Made-with: Cursor
Keep only the upstream-issue references (mlx#2133, mlx#3329) since
those are non-obvious context; drop the explanatory comments whose
content is already conveyed by the surrounding code.

Made-with: Cursor
Deep-dive into the cross-machine non-determinism flagged in review: the
MLX backend evaluates the logp/gradient on the Metal GPU, where float64
is unsupported, so ``mx.array(_x)`` silently downcasts nutpie's float64
positions to float32. Measured relative error is ~1e-7 (float32 eps), and
because NUTS is a chaotic integrator that error accumulates across the
leapfrog steps and varies with GPU/Metal/kernel fusion -- which is why the
array_compare reference test could never match between machines.

* Cast inputs explicitly to ``mx.float32`` in the logp and expand closures
  so the precision contract is intentional and cannot silently change with
  the MLX version or a global default-dtype override, and document the
  root cause at the conversion site.

* Replace the bare smoke test with a machine-independent statistical
  correctness check: sample HalfNormal and assert the posterior recovers
  the analytic moments within ~4x the Monte Carlo error.

* xfail ``test_pymc_model_store_extra[mlx-pytensor]``: PyTensor's MLX
  linker Split dispatch passes a numpy array to ``mx.split`` (read as an
  equal-split count) and breaks on the uneven splits in the
  ZeroSumNormal/Dirichlet gradient graphs. mlx/mlx is unaffected.

Made-with: Cursor
The pymc test suite (and pymc-dev) started crashing with SIGSEGV on
Linux/Windows. Reproduced in a clean uv venv matching CI exactly: the
fault is in native pandas (e.g. pd.date_range in the unmodified
test_zarr_store), not in nutpie or the MLX backend.

Root cause: pandas 3.0 wheels are compiled against the numpy 2.5 C ABI
but their metadata only requires numpy>=1.26, while numba caps numpy<2.5.
uv therefore resolves pandas 3.0.4 together with numpy 2.4.6, and the ABI
mismatch segfaults in pandas' compiled extensions. Pinning pandas<3.0
(which is built against the numpy 2.x ABI) resolves it; the full pymc
suite then passes. Mirrors the existing arviz<1.0.0 pin. Drop once numba
supports numpy>=2.5.

Made-with: Cursor
The pymc (dev) and docs jobs installed the locally-built wheel first
(--no-deps --no-index), then ran a second `uv pip install nutpie[extra]`
without --find-links dist. That second resolution pulled nutpie from
PyPI and downgraded it to a release predating the newly-added extras:
nutpie 0.13.4 has no 'dev' extra (pytest never installed -> "pytest:
command not found") and 0.16.8 has no 'docs' extra (jupyter/nbformat
never installed -> `quarto render` fails with ModuleNotFoundError).

Add --find-links dist to both extras installs so the local wheel stays
the preferred (highest-version) candidate and the new extras resolve
against it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jessegrabowski
jessegrabowski force-pushed the cetagostini/adding_mlx_backend branch from 584f115 to 5a1cd3d Compare October 1, 2026 13:10
@jessegrabowski

Copy link
Copy Markdown
Member

@cetagostini I rebased this branch onto main and force-pushed it, with my changes as new commits on top of yours. Your 13 commits come first, in their original order. The rebase dropped your two merge commits from upstream/main, and ci: stop pinning pytensor@main in dev constraints came out empty because main already made that change.

My commits start at 7897a6d. That first one restores the reparameterized_names handling your merge commit had added to the MLX path, so 7897a6d is where your work runs on top of current main. The commits after it drop the mlx gradient backend, let MLX chains run in parallel behind a process lock, and scope the pymc 6 floor to the pymc-mlx extra.

To keep the rebase but drop my changes:

git reset --hard 7897a6d

To go back to exactly what you had before the force-push:

git reset --hard 584f115

Either one followed by a force-push puts the PR branch back.

@jessegrabowski

Copy link
Copy Markdown
Member

Benchmarks for the MLX backend on an M4 Max (64 GB, macOS 26.4, mlx 0.32, jax 0.11, numba 0.67). Every backend computes in float32: models are built under floatX=float32 and passed through model_to_float32 from pymc-devs/pymc#8402, so the comparison is at equal precision.

At 4 chains, MLX is faster than both CPU backends in one case in the sweep: linear regression at 10^6 observations (1.48x). Below 10^5 observations it is 3-12x slower than the faster CPU backend. Each gradient call pays a GPU round trip of about 150-250 us, and NUTS cannot overlap it with other work, because each leapfrog step needs the gradient on the CPU before the next step starts.

Both runs below predate two removals from this branch, the float64 logp accumulator and an experimental chain-batching path. The accumulator's CPU hop cost 40-90 us per call: up to 21% of MLX's cost on small models and 0-4% at N >= 10^5. So the MLX numbers below overstate the current per-call cost by up to 21% on small models and by 4% or less at N >= 10^5.

Scaling sweep

Wall-clock us per gradient evaluation across all chains, warmup included, at 4 chains with tune=200 and draws=200. The speedup is the faster CPU backend divided by MLX.

model N MLX numba JAX MLX speedup
linear regression (20 covariates) 10^3 153 18 68 0.12x
10^4 160 36 70 0.22x
10^5 190 149 144 0.76x
10^6 765 1399 1129 1.48x
hierarchical logistic (100 groups) 10^3 185 20 83 0.11x
10^4 228 86 84 0.37x
10^5 264 675 167 0.63x
10^6 1318 6433 969 0.74x
marginal GP 100 581 49 110 0.08x
300 1239 210 431 0.17x
1000 16372 4207 4427 0.26x

At 4 chains the GPU is not saturated below about 10^5 observations, so the round trip dominates the cost. More chains put more work on the GPU per unit time. At 32 chains, MLX is 1.44x faster than the faster CPU backend on linear regression at 10^5 and 2.49x at 10^6, and 1.15x and 1.54x on the hierarchical logistic. At 10^3 and 10^4 observations it stays at 0.08-0.61x. The 32-chain runs used the chain-batching path, which evaluated all chains in one GPU call and is not part of this PR. How to make use of the many-short-chains regime in NUTS, where each chain runs its own warmup, is an open question and out of scope here.

pymc-model-catalogue

34 of 37 catalogue models ran, at 4 chains with tune=1000 and draws=1000. Two (sr14_correlated_varying_effects, GLM_discrete_choice_mixed_logit) fail inside model_to_float32 on every backend. lkj_cholesky_cov_mvnormal timed out on MLX because of the AdvancedIncSubtensor bug in PyTensor's MLX dispatch. It samples correctly with pymc-devs/pytensor#2397.

At 4 chains the catalogue models do not saturate the GPU. numba computes nearly every gradient in 3-50 us, while MLX's cost per gradient per chain stays at 0.2-0.7 ms from 2 to 1,603 parameters, which is the round trip and not compute. The smallest gap is item_response_nba (1,562 parameters), at 24.8 s against 26.6 s for numba and 13.7 s for JAX. ESS per gradient matches across the three backends, so the differences in ESS/s come from time per gradient.

Posterior accuracy was scored as Wasserstein-1 distance to a float64 numba reference, relative to the split-half Monte Carlo floor. MLX agrees with float64 wherever float32 numba and JAX do. Every model that disagrees with the reference on MLX also disagrees on float32 numba and JAX, so none of the mismatches is MLX-specific.

Catalogue sampling times
model dim sample s: MLX / numba / JAX ESS/s: MLX / numba / JAX ESS per 1k grads: MLX / numba / JAX
GLM_robust_ols 2 2.1 / 0.2 / 1.1 1846 / 24532 / 3713 171.9 / 185.2 / 188.1
GLM_binomial_regression 2 6.7 / 0.2 / 1.4 554 / 20559 / 2521 164.1 / 164.6 / 153.0
neals_funnel 2 2.8 / 0.2 / 1.9 77 / 1099 / 97 4.4 / 3.7 / 3.8
GLM_robust_studentt 3 6.9 / 0.2 / 2.0 256 / 9102 / 872 41.0 / 41.9 / 41.1
GLM_censored_regression 3 5.3 / 0.3 / 1.9 729 / 12545 / 2048 136.5 / 133.9 / 136.5
copula_marginal 3 4.3 / 0.2 / 1.5 1412 / 37259 / 4300 244.5 / 266.1 / 254.7
ar2 4 5.8 / 0.2 / 1.8 506 / 15222 / 1623 70.3 / 73.2 / 70.0
ab_testing_revenue 4 8.5 / 0.2 / 1.8 778 / 32017 / 3578 249.4 / 249.9 / 235.6
GLM_poisson_regression 4 16.2 / 0.7 / 6.1 35 / 888 / 89 3.5 / 3.7 / 3.4
counterfactuals_do_operator 5 5.3 / 0.2 / 1.8 1153 / 27090 / 3453 203.8 / 173.5 / 203.4
GLM_negative_binomial_regression 5 25.2 / 12.2 / 6.5 49 / 98 / 185 12.0 / 11.8 / 12.0
BEST 5 16.7 / 0.2 / 2.7 182 / 14047 / 1175 74.4 / 69.1 / 78.5
mediation_analysis 7 15.4 / 0.2 / 3.6 179 / 13158 / 803 35.6 / 35.1 / 36.4
eight_schools_centered 10 11.9 / 0.2 / 3.5 68 / 3019 / 156 7.6 / 6.6 / 5.6
eight_schools_noncentered 10 5.8 / 0.2 / 2.5 906 / 30112 / 2257 93.7 / 99.1 / 104.2
rugby_analytics 16 18.6 / 0.3 / 4.6 54 / 3188 / 212 9.6 / 8.7 / 9.3
hierarchical_partial_pooling 21 24.8 / 0.2 / 3.2 105 / 9793 / 942 38.4 / 35.0 / 45.6
spline_cherry_blossoms 21 8.0 / 0.3 / 2.8 358 / 10238 / 990 36.9 / 35.2 / 35.4
air_passengers_prophet 23 55.8 / 0.4 / 18.9 80 / 11967 / 216 8.3 / 7.9 / 7.7
sr08_wine_judges 41 18.7 / 0.3 / 6.1 125 / 8137 / 355 17.2 / 17.0 / 15.9
sr12_tadpole_predator 51 9.2 / 0.3 / 3.0 300 / 10008 / 945 42.9 / 41.3 / 44.9
election88_hierarchical_logistic 55 9.5 / 3.3 / 4.2 673 / 1849 / 1629 106.3 / 102.7 / 113.6
GLM_hierarchical_binomial_rat_tumor 73 19.8 / 0.3 / 3.8 205 / 16322 / 1124 50.7 / 52.5 / 53.5
multilevel_partial_pooling 88 9.4 / 0.3 / 3.6 505 / 17255 / 1288 69.9 / 70.7 / 70.5
german_credit_lognormal_scale 125 42.0 / 2.2 / 14.4 20 / 547 / 74 2.3 / 3.2 / 2.9
sr13_district_urban_contraception 126 17.1 / 1.1 / 5.3 360 / 5355 / 1086 55.8 / 52.6 / 53.0
multilevel_varying_intercept_slope_noncentered 175 17.3 / 0.4 / 5.1 402 / 18979 / 1374 61.3 / 62.3 / 61.8
multilevel_covariation_intercept_slope 176 25.8 / 0.6 / 8.4 257 / 5363 / 604 56.2 / 26.9 / 44.5
hsgp_basic_1d 203 45.7 / 2.5 / 11.5 109 / 1944 / 425 17.4 / 17.2 / 17.4
forecasting_structural_ar_trend_seasonal 225 22.9 / 0.3 / 5.5 307 / 19727 / 1285 56.0 / 54.4 / 55.6
item_response_nba 1562 24.8 / 26.6 / 13.7 260 / 225 / 423 47.6 / 43.9 / 42.2
malaria_hsgp 1603 36.4 / 1.3 / 10.7 251 / 6035 / 922 40.7 / 37.4 / 43.1

float32 logp without an accumulator

MLX with and without the float64 accumulator, at 4 chains with tune=1000 and draws=1000, gives posteriors and NUTS diagnostics that agree within Monte Carlo error, including at |logp| = 1.4e7, where a float32 logp resolves about 1 nat. The acceptance rate and energy-error spread are the diagnostics logp rounding would disturb first. This result is why I removed the accumulator.

model |logp| W1 marginal / floor W1 joint / floor accept: float32 / float64 acc. energy error sd: float32 / float64 acc.
linear regression, N = 10^5 1.2e6 1.05 1.00 0.804 / 0.809 0.475 / 0.462
linear regression, N = 10^6 1.4e7 1.07 0.99 0.811 / 0.806 0.452 / 0.464
hierarchical logistic, N = 10^6 6.9e5 1.33 1.19 0.807 / 0.791 0.490 / 0.532

The 10^5 row is measured against a float64 numba reference. The 10^6 rows compare the two MLX variants directly, with the floor taken from the accumulator run's split halves.

@ricardoV94

Copy link
Copy Markdown
Member

Each gradient call pays a GPU round trip of about 150-250 us

Isn't there room to improve here? What about the so fabled "unified memory"?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants