Skip to content

Phase C: MLX backend AMICAMLXNG (v1 MVP) - #79

Merged
neuromechanist merged 3 commits into
feature/issue-74-epic-apple-gpufrom
feature/issue-76-phaseC-mlx-backend
Jul 8, 2026
Merged

Phase C: MLX backend AMICAMLXNG (v1 MVP)#79
neuromechanist merged 3 commits into
feature/issue-74-epic-apple-gpufrom
feature/issue-76-phaseC-mlx-backend

Conversation

@neuromechanist

Copy link
Copy Markdown
Member

Summary

Adds AMICAMLXNG (pyAMICA/mlx_impl/core.py), an optional Apple-Silicon GPU backend that runs the natural-gradient EM E/M-step on the Apple GPU via MLX. Epic #74 Phase C. Now that Phase A made full-data float32 converge (the precision Apple GPUs are limited to), an MLX backend becomes viable.

v1 MVP scope: single-model, generalized Gaussian (pdftype=0), natural gradient. Newton, the other PDF families, component sharing, multi-model, outlier rejection and save/load are fast-follows (rejected with NotImplementedError, not silent).

Hybrid design (forced by MLX 0.32, verified by probing)

  • GPU is float32-only (Apple has no FP64) and all mlx.core.linalg is CPU-only. So the elementwise/matmul hot path runs on the GPU float32 stream (carrying the Phase A ufp/y divide-by-zero guard), while inv(A)/slogdet(W) run on the CPU stream, hoisted to once per iteration (the torch _forward recomputes slogdet 60x/iter). Measured: CPU linalg ~42 us/iter vs a ~13 ms GPU E-pass (0.3%) -- not the bottleneck; mx.eval placement is (one per iteration bounds the lazy graph).
  • lgamma/digamma are absent in MLX -- the GG normalizer and the rho update are computed host-side (SciPy) on the small rho array, so the GPU E-step has no special functions.

Optional dependency (CI-safe)

mlx is under [project.optional-dependencies], not the default deps; mlx_impl is imported lazily so import pyAMICA never requires MLX. CI (ubuntu) doesn't install mlx, so the MLX tests self-skip (pytest.importorskip + skip-if-no-Apple-GPU).

Test plan (real sample EEG, Apple GPU; NO MOCKS)

pyAMICA/tests/mlx_tests/test_mlx_backend.py (4 tests, all pass locally in ~20s):

  • Sufficient stats vs the NumPy float64 reference (one block, identical shared-seed params): rtol 1e-3 (measured max relerr ~1.6e-4). Tight algorithmic faithfulness against an independent oracle.
  • Converged LL vs the PyTorch float32 backend (the epic's acceptance): matched settings/seed, |gap| < 1e-2 -- measured gap ~2e-6 (both reach the same NG-GG fixed point).
  • Full-data stability: finite ll_history, non-degenerate stop, ascent (the Phase A guard holds in MLX).
  • MVP boundaries fail loudly (NotImplementedError for n_models>1, do_newton, pdftype!=0).
  • Full non-slow torch suite unaffected (124 passed).

Honest note

This is an enablement + validation step, not a proven speed win at 32 channels -- whether MLX actually beats CPU/PyTorch-MPS is what Phase B (dimension-sweep benchmark) measures.

Closes #76
Part of epic #74

Epic #74 Phase C: an optional Apple-Silicon GPU backend that runs the
natural-gradient EM E/M-step on the Apple GPU via MLX. v1 MVP scope:
single-model, generalized Gaussian (pdftype=0), natural gradient; Newton, the
other PDF families, component sharing, multi-model, outlier rejection and
save/load are fast-follows (rejected with NotImplementedError).

Hybrid design forced by MLX 0.32: the GPU has no float64 and all mlx.core.linalg
is CPU-only. So the elementwise/matmul hot path runs on the GPU in float32 (with
the Phase A ufp/y divide-by-zero guard carried over), while inv(A)/slogdet(W) run
on the CPU stream, hoisted to once per iteration (measured ~42 us/iter vs a ~13 ms
GPU E-pass). lgamma/digamma are absent in MLX, so the GG normalizer and the rho
update are computed host-side via SciPy on the small rho array. Exactly one
mx.eval per iteration bounds the lazy graph.

MLX is an optional dependency (Apple Silicon only): mlx_impl is imported lazily so
import pyAMICA never requires it, and the mlx extra keeps it out of the default
install, so CI (ubuntu) skips the MLX tests.

Tested (real sample EEG, Apple GPU): per-block sufficient stats match the NumPy
float64 reference to rtol ~1e-4; the converged LL matches the PyTorch float32
backend to ~2e-6 (< 1e-2 gate); full-data fit is finite and non-degenerate. Full
non-slow torch suite unaffected (124 passed). Whether MLX beats CPU/MPS is
Pathway B's question.
Silent-failure review (parity gaps vs the torch backend's guards):
- fit() now checks parameter finiteness each iteration (A/mu/alpha/beta/rho) and
  stops with a degenerate "nan_params" reason + nan final_ll, so a final-iteration
  M-step blow-up cannot complete as max_iter with silently NaN params (the torch
  backend backstops this in state_dict; the MLX MVP had no backstop).
- Reset a NaN rho update to rho0 with a warning, matching AMICATorchNG, so it does
  not poison the lgamma table and every subsequent E-step unattributably.
- doscaling: multiply mu by safe_scale, not raw scale, so a zero-norm (collapsed)
  column leaves mu unchanged like A/beta (raw scale silently zeroed mu).
- Comment that a singular-A LinAlg error surfaces at the fit() mx.eval, not in
  _update_unmixing_matrices (lazy graph).
- transform() now raises a clear NotImplementedError instead of AttributeError.

Comment/doc review (accuracy):
- _preprocess: MLX CPU eigh is full float64 (only the GPU stream is unsupported),
  so using numpy is a code-reuse choice, not a precision workaround.
- Fix the A-update citation (core.py:1176-1184, not 1156-1164) and tighten the
  _score_gg/_log_pdf_gg/_get_block_updates line ranges.
- Module docstring: state accurately which deferrals raise NotImplementedError vs
  are simply absent.
- mps_pathways.md: reword the leftover "Cost: ... v2 option" paragraph that
  contradicted the "MVP LANDED" status above it.

MLX tests still green (4 passed).
@neuromechanist

Copy link
Copy Markdown
Member Author

Review summary (Sonnet reviewers, worktree-isolated)

Two reviewers completed with substantive findings (silent-failure, comments/docs); the comment reviewer ran the actual MLX suite on Apple Silicon and verified the numeric claims (converged-LL gap 2.15e-6, sufficient-stat max relerr 1.59e-4, import isolation, MLX-linalg-CPU-only). The code and test reviewers were stopped after they ballooned to ~40 min running full suites inside their isolated worktrees; the code reviewer had already reached a clean verdict (no blocking findings), and the test reviewer's angle overlaps the verified suite run. All findings below addressed in cfd22b2.

Silent-failure (parity gaps vs the torch backend's guards)

  • CRITICAL: fit() had no parameter finiteness check, so a final-iteration M-step blow-up could complete as stop_reason="max_iter" with silently NaN params (torch backstops this in state_dict; the MLX MVP had none). Now checks A/mu/alpha/beta/rho each iteration and stops with a degenerate "nan_params" reason + nan final_ll_.
  • HIGH: missing NaN-in-rho reset-to-rho0 + warning (present in torch) -- ported, so a rho blow-up doesn't poison the lgamma table unattributably.
  • MEDIUM (real bug): doscaling multiplied mu by raw scale, silently zeroing mu on a zero-norm column while A/beta were left intact -- fixed to safe_scale (matches torch's masked handling).
  • Comment on the lazy-graph exception site; transform() now raises a clear NotImplementedError instead of AttributeError.

Comment/doc accuracy

  • _preprocess docstring wrongly claimed MLX eigh is float32 -- the reviewer tested and MLX's CPU eigh is full float64 (only the GPU stream is unsupported), so using numpy is a code-reuse choice, not a precision workaround. Corrected.
  • Fixed the A-update citation (core.py:1176-1184, was 1156-1164) and tightened the _score_gg/_log_pdf_gg/_get_block_updates ranges.
  • Module docstring now states accurately which deferrals raise NotImplementedError vs are simply absent.
  • Reworded the leftover mps_pathways.md "Cost: ... v2 option" paragraph that contradicted the "MVP LANDED" status above it.

MLX suite still green (4 passed); the finiteness checks did not materially slow the fit.

The MLX backend (pyAMICA/mlx_impl) requires MLX + an Apple GPU, so CI (ubuntu,
no mlx) cannot exercise it and measured it at 0%, dropping total coverage to
73.7% and failing the --cov-fail-under=80 gate. It is covered locally by
tests/mlx_tests/ on Apple hardware; omit it from the coverage metric (like the
subprocess-only CLI entrypoint). Coverage returns to 82.0%.
@neuromechanist
neuromechanist merged commit 4ff3a11 into feature/issue-74-epic-apple-gpu Jul 8, 2026
5 checks passed
@neuromechanist
neuromechanist deleted the feature/issue-76-phaseC-mlx-backend branch July 8, 2026 16:01
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.

1 participant