Skip to content

Commit 2137ba6

Browse files
perf: add DCCM backend system + multi-threaded BLAS rewrites
Replace single-threaded numpy hot paths with multi-threaded BLAS GEMM or GPU kernels where the operation maps cleanly to a matmul. - New `analysis/_backends/_dccm.py` with five backends (numpy / numba / torch / jax / cupy) for the DCCM covariance kernel. Default is `numpy` (BLAS GEMM via reshape + matmul) -- replaces the single-threaded `np.einsum("fid,fjd->ij", ...)` that saturated one core regardless of OPENBLAS_NUM_THREADS. Measured 71x speedup over einsum at 5K x 200, 620x with the torch GPU backend at 10K x 200. - `compute_dccm` gains `backend=` kwarg dispatched via `BackendRegistry[DCCMBackendFn]`. Adds `DCCMBackend` Literal alias. - Rewrite clustering pairwise distances using the cdist identity (`||a-b||^2 = ||a||^2 + ||b||^2 - 2 a.b`) so they dispatch to BLAS GEMM. New private helper `_pairwise_sq_distances` used in `RegularSpace` inertia and `_make_feature_result` medoid loops -- avoids the (F, K, D) broadcast intermediate (8 GB at 50K x 200 x 200) and parallelises across cores. - `distances_torch` now uses `torch.inference_mode()` instead of the weaker `torch.no_grad()`; `dccm_torch` already uses it. JAX matmul kernels keep `precision=HIGHEST` to suppress TF32. - Tests: 14 new DCCM tests (public API + kernel-vs-float64 reference + parametrized cross-backend benchmark) and 2 new pairwise helper tests. All 70 clustering tests still pass.
1 parent 2666615 commit 2137ba6

8 files changed

Lines changed: 713 additions & 15 deletions

File tree

src/mdpp/analysis/_backends/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
)
1111
from mdpp.analysis._backends._registry import (
1212
BackendRegistry,
13+
DCCMBackend,
1314
DistanceBackend,
1415
RMSDBackend,
1516
)
1617

1718
__all__ = [
1819
"BackendRegistry",
20+
"DCCMBackend",
1921
"DistanceBackend",
2022
"RMSDBackend",
2123
"clean_cupy_cache",
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
"""DCCM (dynamic cross-correlation) covariance backends.
2+
3+
Provides five backends for computing the per-atom covariance
4+
``cov[i, j] = <(r_i - <r_i>) . (r_j - <r_j>)>`` averaged over frames:
5+
6+
- ``numpy`` -- BLAS GEMM via reshape + matmul.
7+
- ``numba`` -- Numba-parallel CPU kernel.
8+
- ``cupy`` -- CuPy matmul on GPU.
9+
- ``torch`` -- PyTorch matmul on GPU/CPU.
10+
- ``jax`` -- JAX/XLA matmul on GPU/CPU.
11+
12+
Why a backend system at all
13+
---------------------------
14+
15+
The original implementation of :func:`mdpp.analysis.compute_dccm`
16+
called ``np.einsum("fid,fjd->ij", fluct, fluct)``. NumPy's einsum
17+
falls back to a single-threaded contraction loop for this 4-index
18+
pattern -- it does **not** dispatch to BLAS even when ``optimize=True``
19+
is set, because the underlying ``tensordot`` requires reshaping the
20+
operands first. The result is one core saturated regardless of
21+
``OPENBLAS_NUM_THREADS`` / ``MKL_NUM_THREADS``, with the contraction
22+
becoming the bottleneck for any non-trivial trajectory.
23+
24+
The ``numpy`` backend here flattens the fluctuations to a 2D
25+
``(N, F*3)`` matrix and uses ``A @ A.T``; matmul on 2D arrays
26+
dispatches to BLAS GEMM, which is multi-threaded. On a 16-core host
27+
this gives roughly an order-of-magnitude speedup at F=100k, N=500
28+
with no extra dependencies and is therefore the default backend.
29+
30+
The numba and GPU backends layer on top for users who want either
31+
pure-Python parallelism (numba) or GPU acceleration (torch/jax/cupy).
32+
33+
Backend signature
34+
-----------------
35+
36+
All backends accept a single ``(F, N, 3)`` float positions array and
37+
return an ``(N, N)`` covariance matrix in the backend's **native**
38+
dtype (float32 for numpy / GPU backends, float64 for numba). Mean
39+
subtraction is performed inside each backend so that GPU kernels can
40+
keep the entire pipeline on-device. The public :func:`compute_dccm`
41+
wrapper casts with ``copy=False`` and derives the final correlation
42+
matrix from the covariance.
43+
"""
44+
45+
from __future__ import annotations
46+
47+
from typing import Protocol
48+
49+
import numpy as np
50+
from numba import njit, prange
51+
from numpy.typing import NDArray
52+
53+
from mdpp.analysis._backends._imports import (
54+
clean_cupy_cache,
55+
clean_torch_cache,
56+
require_cupy,
57+
require_jax,
58+
require_torch,
59+
)
60+
from mdpp.analysis._backends._registry import BackendRegistry
61+
62+
63+
class DCCMBackendFn(Protocol):
64+
"""Callable signature for a DCCM covariance backend.
65+
66+
Backends accept atom positions of shape ``(F, N, 3)`` already
67+
sliced to the atom subset of interest, and return an ``(N, N)``
68+
floating-point covariance matrix in the backend's native dtype.
69+
Centering (mean subtraction) is performed inside the backend so
70+
that GPU kernels keep the full pipeline on-device.
71+
"""
72+
73+
def __call__(
74+
self,
75+
positions_nm: NDArray[np.floating],
76+
) -> NDArray[np.floating]: ...
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# numpy backend (multi-threaded via BLAS GEMM)
81+
# ---------------------------------------------------------------------------
82+
83+
84+
def dccm_numpy(positions_nm: NDArray[np.floating]) -> NDArray[np.floating]:
85+
"""Compute DCCM covariance using BLAS GEMM via reshape + matmul.
86+
87+
Reshapes the fluctuations from ``(F, N, 3)`` to ``(N, F*3)`` and
88+
computes ``A @ A.T``, which dispatches to a multi-threaded BLAS
89+
GEMM kernel. ``np.einsum("fid,fjd->ij", ...)`` -- the original
90+
formulation -- saturates a single core regardless of
91+
``OPENBLAS_NUM_THREADS``, so this rewrite gives roughly linear
92+
speedup with cores at no API cost.
93+
94+
Args:
95+
positions_nm: Atom positions of shape ``(F, N, 3)`` in nm.
96+
97+
Returns:
98+
Covariance matrix of shape ``(N, N)`` in the input dtype
99+
(typically float32 for mdtraj coordinates). The public
100+
:func:`compute_dccm` wrapper casts with ``copy=False`` so no
101+
redundant ``(N, N)`` copy is made when dtypes match.
102+
"""
103+
n_frames, n_atoms, _ = positions_nm.shape
104+
fluct = positions_nm - positions_nm.mean(axis=0, keepdims=True)
105+
# ``ascontiguousarray`` realises the transposed view as a contiguous
106+
# row-major buffer once, so the BLAS call sees a clean stride
107+
# pattern instead of paying a hidden copy inside matmul.
108+
matrix = np.ascontiguousarray(fluct.transpose(1, 0, 2).reshape(n_atoms, n_frames * 3))
109+
return (matrix @ matrix.T) / float(n_frames)
110+
111+
112+
# ---------------------------------------------------------------------------
113+
# numba backend (parallel CPU)
114+
# ---------------------------------------------------------------------------
115+
116+
117+
@njit(parallel=True, cache=True)
118+
def _dccm_numba_kernel(
119+
positions: NDArray[np.float32],
120+
) -> NDArray[np.floating]: # pragma: no cover - JIT-compiled
121+
"""Two-pass numba kernel: per-atom mean, then symmetric covariance.
122+
123+
The outer atom loops use ``prange`` so each thread owns a disjoint
124+
slice of output rows -- no cross-thread reduction is required.
125+
Inner reductions promote to C ``double`` via ``float()`` so the
126+
result has float64 precision; the wrapper casts to the user's
127+
resolved dtype with ``copy=False`` afterward.
128+
"""
129+
n_frames, n_atoms, _ = positions.shape
130+
131+
# Pass 1: per-atom mean.
132+
means = np.zeros((n_atoms, 3), dtype=np.float64)
133+
for i in prange(n_atoms):
134+
sx = 0.0
135+
sy = 0.0
136+
sz = 0.0
137+
for f in range(n_frames):
138+
sx += float(positions[f, i, 0])
139+
sy += float(positions[f, i, 1])
140+
sz += float(positions[f, i, 2])
141+
means[i, 0] = sx / n_frames
142+
means[i, 1] = sy / n_frames
143+
means[i, 2] = sz / n_frames
144+
145+
# Pass 2: symmetric covariance.
146+
cov = np.empty((n_atoms, n_atoms), dtype=np.float64)
147+
for i in prange(n_atoms):
148+
mxi = means[i, 0]
149+
myi = means[i, 1]
150+
mzi = means[i, 2]
151+
for j in range(i, n_atoms):
152+
mxj = means[j, 0]
153+
myj = means[j, 1]
154+
mzj = means[j, 2]
155+
s = 0.0
156+
for f in range(n_frames):
157+
dxi = float(positions[f, i, 0]) - mxi
158+
dyi = float(positions[f, i, 1]) - myi
159+
dzi = float(positions[f, i, 2]) - mzi
160+
dxj = float(positions[f, j, 0]) - mxj
161+
dyj = float(positions[f, j, 1]) - myj
162+
dzj = float(positions[f, j, 2]) - mzj
163+
s += dxi * dxj + dyi * dyj + dzi * dzj
164+
value = s / n_frames
165+
cov[i, j] = value
166+
cov[j, i] = value
167+
return cov
168+
169+
170+
def dccm_numba(positions_nm: NDArray[np.floating]) -> NDArray[np.floating]:
171+
"""Compute DCCM covariance using a Numba-parallel CPU kernel.
172+
173+
Parallelises the outer atom loop with ``prange`` so each thread
174+
owns a disjoint band of output rows. Returns float64 (numba's
175+
natural dtype via ``float()`` promotion); the public wrapper
176+
casts to the user's resolved dtype with ``copy=False``.
177+
178+
Args:
179+
positions_nm: Atom positions of shape ``(F, N, 3)`` in nm.
180+
181+
Returns:
182+
Covariance matrix of shape ``(N, N)`` in **float64**.
183+
"""
184+
return _dccm_numba_kernel(np.ascontiguousarray(positions_nm, dtype=np.float32))
185+
186+
187+
# ---------------------------------------------------------------------------
188+
# CuPy backend (GPU)
189+
# ---------------------------------------------------------------------------
190+
191+
192+
@clean_cupy_cache
193+
def dccm_cupy(positions_nm: NDArray[np.floating]) -> NDArray[np.floating]:
194+
"""Compute DCCM covariance on GPU using CuPy.
195+
196+
Mean subtraction and the covariance GEMM both run on-device; only
197+
the final ``(N, N)`` matrix is transferred back to host memory.
198+
199+
Args:
200+
positions_nm: Atom positions of shape ``(F, N, 3)`` in nm.
201+
202+
Returns:
203+
Covariance matrix of shape ``(N, N)`` in **float32** (cupy
204+
inherits the float32 of mdtraj coordinates).
205+
206+
Raises:
207+
ImportError: If CuPy is not installed.
208+
"""
209+
cp = require_cupy()
210+
n_frames, n_atoms, _ = positions_nm.shape
211+
pos_gpu = cp.asarray(positions_nm)
212+
fluct = pos_gpu - pos_gpu.mean(axis=0, keepdims=True)
213+
matrix = cp.ascontiguousarray(fluct.transpose(1, 0, 2).reshape(n_atoms, n_frames * 3))
214+
cov = (matrix @ matrix.T) / float(n_frames)
215+
return cp.asnumpy(cov)
216+
217+
218+
# ---------------------------------------------------------------------------
219+
# Torch backend (GPU / CPU)
220+
# ---------------------------------------------------------------------------
221+
222+
223+
@clean_torch_cache
224+
def dccm_torch(positions_nm: NDArray[np.floating]) -> NDArray[np.floating]:
225+
"""Compute DCCM covariance using PyTorch (CUDA if available).
226+
227+
Uses CUDA when visible, otherwise falls back to CPU torch (still
228+
multi-threaded via ATen). Wrapped in ``torch.inference_mode``
229+
to skip autograd bookkeeping; the GEMM dispatches to cuBLAS
230+
(GPU) or MKL/OpenBLAS (CPU).
231+
232+
Args:
233+
positions_nm: Atom positions of shape ``(F, N, 3)`` in nm.
234+
235+
Returns:
236+
Covariance matrix of shape ``(N, N)`` in **float32**.
237+
238+
Raises:
239+
ImportError: If PyTorch is not installed.
240+
"""
241+
torch = require_torch()
242+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
243+
n_frames, n_atoms, _ = positions_nm.shape
244+
with torch.inference_mode():
245+
pos = torch.as_tensor(np.ascontiguousarray(positions_nm), device=device)
246+
fluct = pos - pos.mean(dim=0, keepdim=True)
247+
matrix = fluct.permute(1, 0, 2).reshape(n_atoms, n_frames * 3).contiguous()
248+
cov = (matrix @ matrix.T) / float(n_frames)
249+
return cov.cpu().numpy()
250+
251+
252+
# ---------------------------------------------------------------------------
253+
# JAX backend (GPU / TPU / CPU)
254+
# ---------------------------------------------------------------------------
255+
256+
257+
def dccm_jax(positions_nm: NDArray[np.floating]) -> NDArray[np.floating]:
258+
"""Compute DCCM covariance using JAX (auto GPU/TPU/CPU).
259+
260+
The matmul explicitly passes ``precision=HIGHEST`` to disable JAX's
261+
default TF32 / tensor-core accumulation on GPU, which would
262+
otherwise drop the GEMM to ~19 bits of mantissa and pick up
263+
visible error on small fixtures. Deliberately not wrapped with a
264+
cache-cleanup decorator -- ``jax.clear_caches()`` clears JIT
265+
compilation caches (not device memory) and trashing them after
266+
every call forces a multi-second recompile on the next invocation.
267+
268+
Args:
269+
positions_nm: Atom positions of shape ``(F, N, 3)`` in nm.
270+
271+
Returns:
272+
Covariance matrix of shape ``(N, N)`` in **float32**.
273+
274+
Raises:
275+
ImportError: If JAX is not installed.
276+
"""
277+
jax_mod, jnp = require_jax()
278+
n_frames, n_atoms, _ = positions_nm.shape
279+
pos = jnp.asarray(positions_nm)
280+
fluct = pos - pos.mean(axis=0, keepdims=True)
281+
matrix = jnp.transpose(fluct, (1, 0, 2)).reshape(n_atoms, n_frames * 3)
282+
cov = jnp.matmul(matrix, matrix.T, precision=jax_mod.lax.Precision.HIGHEST) / float(n_frames)
283+
return np.asarray(cov)
284+
285+
286+
# ---------------------------------------------------------------------------
287+
# Registry
288+
# ---------------------------------------------------------------------------
289+
290+
dccm_backends: BackendRegistry[DCCMBackendFn] = BackendRegistry(default="numpy")
291+
dccm_backends.register("numpy", dccm_numpy)
292+
dccm_backends.register("numba", dccm_numba)
293+
dccm_backends.register("cupy", dccm_cupy)
294+
dccm_backends.register("torch", dccm_torch)
295+
dccm_backends.register("jax", dccm_jax)

src/mdpp/analysis/_backends/_distances.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,11 @@ def distances_torch(
213213
torch = require_torch()
214214

215215
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
216-
with torch.no_grad():
216+
# ``inference_mode`` is strictly stronger than ``no_grad``: it
217+
# also disables view tracking and version counter increments,
218+
# which gives a small but real speedup on the fancy-indexing
219+
# path used here. Both rule out grad bookkeeping.
220+
with torch.inference_mode():
217221
xyz_t = torch.as_tensor(np.ascontiguousarray(traj.xyz), device=device)
218222
pairs_t = torch.as_tensor(pairs.astype(np.int64), device=device)
219223
diffs = xyz_t[:, pairs_t[:, 0], :] - xyz_t[:, pairs_t[:, 1], :]

src/mdpp/analysis/_backends/_registry.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
type RMSDBackend = Literal["mdtraj", "numba", "cupy", "torch", "jax"]
1111
"""Valid backend names for pairwise RMSD matrix computation."""
1212

13+
type DCCMBackend = Literal["numpy", "numba", "cupy", "torch", "jax"]
14+
"""Valid backend names for DCCM covariance computation.
15+
16+
Default is ``"numpy"`` rather than ``"mdtraj"``: mdtraj has no native
17+
DCCM kernel, and the numpy backend's BLAS GEMM is multi-threaded so it
18+
already saturates available cores without optional dependencies.
19+
"""
20+
1321

1422
class BackendRegistry[F]:
1523
"""Map backend name strings to callables of type ``F``.

0 commit comments

Comments
 (0)