Skip to content

Epic: Apple-Silicon GPU acceleration for AMICATorchNG - #83

Merged
neuromechanist merged 5 commits into
mainfrom
feature/issue-74-epic-apple-gpu
Jul 8, 2026
Merged

Epic: Apple-Silicon GPU acceleration for AMICATorchNG#83
neuromechanist merged 5 commits into
mainfrom
feature/issue-74-epic-apple-gpu

Conversation

@neuromechanist

Copy link
Copy Markdown
Member

Summary

Epic #74 delivers Apple-Silicon GPU acceleration for AMICA, governed by one hard constraint the research (#72) surfaced: Apple GPUs have no FP64 hardware (neither PyTorch MPS nor MLX offers GPU float64), so the whole path was gated on a numerically stable float32 AMICA. Four phases, each a reviewed + squash-merged PR:

Net result

On Apple Silicon, use the MLX backend for single- and multi-model AMICA (~5-7x over CPU); avoid device="mps" (no gain). float64-CUDA stays the bit-safe NVIDIA path. Every phase preserved #24 Fortran parity (float64 byte-identical) and the full torch suite.

Merge strategy

Regular merge (NOT squash) to preserve the per-phase history above (per repo policy for epic branches). Each phase PR was already squash-merged into this branch with its own review.

Follow-ups (tracked separately)

  • MLX component sharing; a 128-256 ch / many-model sweep for the eventual MLX/CUDA crossover.
  • A comprehensive native-Fortran + CPU-core-scaling + CUDA cross-platform benchmark (its own epic, on a native-x86 Linux + CUDA host).

Closes #74

* Guard float32 0/0 in mu-denominator ufp/y (#75)

float32 diverged to NaN on the full 30504-sample data across every seed
(Newton on and off), while float64 converged. Root cause: the mu denominator
sbeta*sum(ufp/y) (ufp=u*fp); at a sample sitting on a mixture mean, float32
rounds the scaled activation y to exactly 0, and fp(0)=0 for every family, so
that term is 0/0=NaN and one NaN summand poisons dmu_d. float64 never rounds y
to exactly 0.

Diagnostics ruled out summation precision (accumulating block partials in
float64, and Neumaier compensated summation, did not help) and the density /
responsibilities (float64 there did not help either). Only guarding the ufp/y
division does. The guard (ufp / where(y==0, 1, y), contributing 0 for the
measure-zero sample) is a no-op in float64 (y is never exactly 0), so
single-model #24 parity stays bit-identical, and it needs no float64, so it
also stabilizes the MPS/float32 path (Apple GPUs have no FP64) -- epic #74
Phase A.

Tested: float32 now converges across 5 seeds x Newton on/off on the real
sample EEG, matching the float64 LL to ~5 significant digits; full non-slow
torch suite green (124 passed), including the NumPy-parity and byte-identity
anchors. Docs updated (perf_findings, mps_pathways, AGENTS, benchmark_gpu).

* Address PR #78 review findings

- Correct the guard comment: the true ufp/y limit is NOT 0 (nonzero constant at
  rho=2, integrable singularity diverging for rho<2), so the guard drops an
  unrepresentable singular term, not a removable zero. Measured: it fires <=1
  sample/iteration on the sample EEG (5 of 150 iters), and float32 still matches
  the float64 LL to ~5 sig digits -- a bounded, negligible bias.
- Scope the "fp(0)=0 for every family" claim to the supported rho>=1 (for rho<1
  the GG fp is itself NaN at 0; out of scope, default minrho=1.0).
- test: reference AMICATorchNG._DEGENERATE_STOP_REASONS instead of duplicating
  the ("nan_ll","singular_ll") tuple; shorten the float64-tracking quality check
  to 100 iters (it tracks float64 from iter 1; the 150-iter sweep remains the
  regression guard).
- docs: finish the mps_pathways.md update -- intro to past tense, and Pathways B
  and C no longer gate on Pathway A as unmet (it is done, #75).
* Add MLX backend AMICAMLXNG (v1 MVP, #76)

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.

* Address PR #79 review findings

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).

* Omit optional MLX backend from coverage

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%.
* Add cross-platform dimension-sweep benchmark (#77)

Epic #74 Phase B: measure both results and performance for every AMICA backend
across CPU/MPS/CUDA/MLX on real 70-channel EEG (OpenNeuro ds002718 sub-002),
sweeping channel count and n_models with component sharing on/off.

benchmarks/benchmark_dimsweep.py auto-detects the host's backends
(numpy/torch-cpu/torch-mps/torch-cuda/mlx), records ms/iteration (warmed,
min-of-repeats) and converged LL at matched settings, and emits JSON so a Mac
run and a CUDA host run merge via --report. MLX (single-model MVP) auto-excludes
from multi-model/sharing configs. Data is fetched from OpenNeuro, not committed
(README_dimsweep.md; benchmarks/data + result JSONs gitignored).

Findings (.context/issue-77/benchmark_findings.md):
- MLX is the Apple-GPU win: ~15-25 ms/it, flat across 16-70 ch, ~7x over
  torch-CPU and faster than an RTX 4090 (CUDA ~36 ms) at EEG scale.
- PyTorch-MPS never wins (162-255 ms/it, at or worse than CPU) -- use MLX, not
  device="mps", on Apple hardware.
- Results agree across cpu/mps/cuda/mlx and f32/f64 to ~3 digits on real data.
- Multi-model has no GPU path yet (MLX MVP is single-model; MPS loses), so
  multi-model MLX is the top fast-follow.

Docs updated: mps_pathways.md Pathway B (DONE), AGENTS.md perf note.

* Match numpy block_size in the benchmark (PR #80 review)

The numpy adapter didn't pass block_size, so AMICA_NumPy used its default
(block_size=128, do_opt_block=True) and ran an in-fit wall-clock block-size
auto-tune search inside the timed region -- unmatched vs torch/mlx (which pin
block_size=512) and timing pollution that ~doubled numpy's ms/it. Pass
block_size=512, do_opt_block=False. Corrected numpy: 142-622 ms/it (was
310-1350), so numpy is ~10-25x slower than MLX, not 50-70x. Results (LL)
unchanged (block_size does not affect the accumulated sufficient stats). Findings
table updated; the qualitative conclusions (MLX ~7x win, MPS never wins) are
unaffected.
* Add multi-model support to AMICAMLXNG (#81)

Epic #74 Phase D: extend the MLX Apple-GPU backend to n_models > 1, porting the
multi-model machinery from AMICATorchNG -- comp_list indirection, per-model
W=inv(A[:,comp_list]) + slogdet (CPU stream), cross-model responsibilities
v=softmax(logV), the per-model exact-EM bias c update, and the gm-weighted
A-update scattered through comp_list. Single-model (#76) stays byte-for-byte
unchanged (the loop runs once, gm=1, identity comp_list). Component sharing
remains a fast-follow.

Validated on real sample EEG: multi-model (n_models=2) one-iteration sufficient
stats match AMICATorchNG float32 to float32 precision, and the converged LL
matches to ~1e-5. New tests/mlx_tests test_multimodel_matches_torch_float32; the
single-model byte-identity and torch-float32 tests still pass.

Benchmark (benchmark_dimsweep.py) now runs MLX in the multi-model configs: MLX
wins multi-model too -- ~38-45 ms/it, ~5x over torch-CPU (MPS still loses),
matching the ~7x single-model win. Findings updated (.context/issue-77).

* Address PR #82 review findings

Silent-failure review:
- Port the dead-model warning to the multi-model c update (a zero-responsibility
  model kept its prior c but was surfaced-free; matches AMICATorchNG).
- Add gm and c to the per-iteration mx.eval and the params_finite guard: they are
  new multi-model state that feeds the next E-step, so a last-iteration blow-up
  must be caught (nan_params) and c's cross-iteration dependency must be
  materialized each iteration rather than growing the lazy graph unbounded.

Comment/doc review:
- Fix two wrong AMICATorchNG citations (c-update 1083-1092, A-update 1231-1247)
  and the stale _lgamma_table shape comment ((n_mix, n_comps)).
- Drop the leftover "MVP"/"v1" framing that contradicted the new multi-model
  scope (module/class docstrings, transform(), the module title now #76/#81).

Test review:
- Expand the multi-model one-iteration stat comparison to every accumulator,
  including dWtmp (the gm-weighted A-update input, transposed to align the
  (n_models,n,n) vs (n,n,n_models) layouts) and the scattered mixture stats.
- Add a direct multi-model c-update check (responsibility-weighted data mean, and
  the two models' c must differ) and a single-model c==0 regression guard.

mlx suite green (5 passed); the port is unchanged (code reviewer verified
single-model byte-identity independently).
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.44%. Comparing base (9354a65) to head (1b09766).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #83      +/-   ##
==========================================
+ Coverage   79.43%   79.44%   +0.01%     
==========================================
  Files          14       14              
  Lines        1974     1975       +1     
  Branches      337      337              
==========================================
+ Hits         1568     1569       +1     
  Misses        301      301              
  Partials      105      105              
Files with missing lines Coverage Δ
pyAMICA/torch_impl/core.py 93.47% <100.00%> (+<0.01%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Epic #74 final integration review (PR #83) found Phase D's multi-model MLX (#81)
never propagated to docs outside its own commit, and a few untested MLX paths.

Docs (the shipped state was inconsistent): update the 7 stale "single-model MVP"
claims to "single- and multi-model" across AGENTS.md (architecture map + perf
note), .context/mps_pathways.md (Pathway C status + fast-follow lists),
benchmarks/README_dimsweep.md (backend list + example comment),
pyAMICA/mlx_impl/__init__.py; add mlx_impl to README.md's file structure. The
inline code docstrings were already correct; only these external docs drifted.

Tests: add three MLX tests caught by the review -- the degenerate-stop path
(NaN data -> nan_ll stop + nan final_ll, the machinery behind the new nan_params
guard), a direct shared-seed init-parity pin vs AMICATorchNG (single- and
multi-model), and the multi-model dead-model c guard (dgm[h]==0 keeps prior c).

mlx suite green (8 passed). No code change; docs + tests only.
@neuromechanist

Copy link
Copy Markdown
Member Author

Integration review (3 Sonnet reviewers, worktree-isolated)

The epic composes cleanly on every functional axis -- the code reviewer confirmed the Phase A ufp/y guard carries correctly into MLX single- and multi-model, the public surface stays MLX-free (import pyAMICA never needs mlx), the optional-dep + coverage-omit + CI-skip discipline is correct, and the torch/NumPy #24 parity path is untouched except the 6-line guard. The test reviewer confirmed no code lost coverage, parity is preserved, and NO-MOCKS / relational tolerances held throughout.

Findings addressed in 1b09766 (docs + tests only, no code change):

Cross-phase doc drift (code + doc reviewers): Phase D's multi-model MLX (#81) never propagated to docs outside its own commit, leaving 7 stale "single-model MVP" claims. Fixed across AGENTS.md (architecture map + perf note), .context/mps_pathways.md (Pathway C status + fast-follow lists), benchmarks/README_dimsweep.md (backend list + example), pyAMICA/mlx_impl/__init__.py; added mlx_impl to README.md. The inline code docstrings were already correct.

Test hardening (test reviewer): added the degenerate-stop path (NaN data -> nan_ll stop + NaN final_ll, the machinery behind the new nan_params guard -- previously only the happy path was tested), a direct shared-seed init-parity pin vs AMICATorchNG (single- and multi-model, rather than inferring it from downstream LL), and the multi-model dead-model c guard (dgm[h]==0 keeps prior c). mlx suite now 8 passed.

Noted, not blocking (follow-ups): the non-default PDF families aren't full-data float32-regression-tested (the guard is family-independent and the fp(0)=0 invariant is tested per-family); the rho-NaN-reset branch is untested in both backends; and a dead model still takes down a fit via alpha/A (caught loudly by nan_params, matches torch) -- a "freeze gracefully like c" refactor is a cross-backend follow-up.

@neuromechanist
neuromechanist merged commit 9700752 into main Jul 8, 2026
5 checks passed
@neuromechanist
neuromechanist deleted the feature/issue-74-epic-apple-gpu branch July 8, 2026 18:45
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.

Epic: Apple-Silicon GPU acceleration for AMICATorchNG (float32 -> MLX -> benchmark)

1 participant