Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Performance Guide

This guide provides detailed performance benchmarks and recommendations for choosing between CPU (NumPy) and GPU (PyTorch) backends in nltools algorithms.


Why Performance Matters for Neuroimaging

Neuroimaging analyses often involve:

A 10x speedup can transform workflows:


Backend Overview

nltools provides two computational backends:

NumPy Backend (CPU)

PyTorch Backend (GPU)

Auto Selection


Benchmark Results

Eshin-M3-Air

Host: Eshin-M3-Air
Platform: macOS-15.7.4-arm64-arm-64bit
Python: 3.11.14
NumPy: 2.4.4
PyTorch: 2.11.0
nltools: 0.5.1
GPU: MPS

GPU speedup

ridge — GPU speedup (mps)

ConditionCPUMPSSpeedup
ridge_cv[1000x20000f100]15.15 s14.71 s1.03×
ridge_cv[500x20000f50]12.96 s12.89 s1.01×

inference — GPU speedup (mps)

ConditionCPUMPSSpeedup
correlation[perm=1000]76.7 ms68.3 ms1.12×
correlation[perm=3000]110.6 ms193.6 ms0.57×
one_sample[perm=1000]310.9 ms179.3 ms1.73×
one_sample[perm=3000]576.3 ms438.5 ms1.31×
two_sample[perm=1000]264.9 ms173.3 ms1.53×
two_sample[perm=3000]397.9 ms501.5 ms0.79×

Memory scaling

collection — peak RSS: lazy vs in-memory .mean()

N subjectslazyin-memory
200.0 MB29.9 MB
500.0 MB103.3 MB

Full results

ridge

ConditionDeviceTimePeak RSSGPU mem
BrainData.fit[ridge,200x20000]cpu239.0 ms0.1 MB-
ridge_cv[1000x20000f100]cpu15.15 s0.0 MB-
ridge_cv[1000x20000f100]mps14.71 s0.2 MB-
ridge_cv[500x20000f50]cpu12.96 s0.0 MB-
ridge_cv[500x20000f50]mps12.89 s0.1 MB-

predict

ConditionDeviceTimePeak RSSGPU mem
roi[50parcels]cpu1.27 s0.5 MB-
searchlight[60x400]cpu452.0 ms0.2 MB-
whole_brain[200x20000]cpu630.2 ms0.0 MB-

inference

ConditionDeviceTimePeak RSSGPU mem
correlation[perm=1000]cpu76.7 ms0.0 MB-
correlation[perm=1000]mps68.3 ms0.0 MB-
correlation[perm=3000]cpu110.6 ms0.0 MB-
correlation[perm=3000]mps193.6 ms0.2 MB-
one_sample[perm=1000]cpu310.9 ms0.6 MB-
one_sample[perm=1000]mps179.3 ms0.0 MB-
one_sample[perm=3000]cpu576.3 ms16.6 MB-
one_sample[perm=3000]mps438.5 ms0.0 MB-
two_sample[perm=1000]cpu264.9 ms1.5 MB-
two_sample[perm=1000]mps173.3 ms1.9 MB-
two_sample[perm=3000]cpu397.9 ms10.9 MB-
two_sample[perm=3000]mps501.5 ms2.9 MB-

collection

ConditionDeviceTimePeak RSSGPU mem
apply[standardize,N=20,n_jobs=-1]cpu520.4 ms36.0 MB-
apply[standardize,N=20,n_jobs=1]cpu1.72 s12.9 MB-
apply[standardize,N=50,n_jobs=-1]cpu1.30 s28.1 MB-
apply[standardize,N=50,n_jobs=1]cpu4.29 s69.5 MB-
mean[in_memory,N=20]cpu2.84 s29.9 MB-
mean[in_memory,N=50]cpu7.13 s103.3 MB-
mean[lazy,N=20]cpu2.86 s0.0 MB-
mean[lazy,N=50]cpu7.19 s0.0 MB-

pikachu.ucsd.edu

Host: pikachu.ucsd.edu
Platform: Linux-6.17.0-1031-nvidia-aarch64-with-glibc2.39
Python: 3.11.15
NumPy: 2.4.6
PyTorch: 2.13.0+cu130
nltools: 0.5.1 @ 55e44f06
GPU: CUDA

GPU speedup

ridge — GPU speedup (cuda)

ConditionCPUCUDASpeedup
ridge_cv[1000x20000f100]19.25 s13.32 s1.45×
ridge_cv[500x20000f50]16.34 s12.05 s1.36×

inference — GPU speedup (cuda)

ConditionCPUCUDASpeedup
correlation[perm=1000]122.4 ms60.3 ms2.03×
correlation[perm=3000]184.8 ms179.9 ms1.03×
one_sample[perm=1000]433.5 ms98.7 ms4.39×
one_sample[perm=3000]774.0 ms279.9 ms2.77×
two_sample[perm=1000]285.9 ms97.5 ms2.93×
two_sample[perm=3000]609.9 ms285.3 ms2.14×

Memory scaling

collection — peak RSS: lazy vs in-memory .mean()

N subjectslazyin-memory
2097.3 MB198.3 MB
5099.8 MB326.3 MB

Full results

ridge

ConditionDeviceTimePeak RSSGPU mem
BrainData.fit[ridge,200x20000]cpu683.5 ms63.9 MB-
ridge_cv[1000x20000f100]cpu19.25 s223.9 MB-
ridge_cv[1000x20000f100]cuda13.32 s144.0 MB138 MB
ridge_cv[500x20000f50]cpu16.34 s175.9 MB-
ridge_cv[500x20000f50]cuda12.05 s40.0 MB86 MB

predict

ConditionDeviceTimePeak RSSGPU mem
roi[50parcels]cpu882.9 ms0.0 MB-
searchlight[60x400]cpu253.1 ms0.3 MB-
whole_brain[200x20000]cpu589.2 ms64.0 MB-

inference

ConditionDeviceTimePeak RSSGPU mem
correlation[perm=1000]cpu122.4 ms0.0 MB-
correlation[perm=1000]cuda60.3 ms0.4 MB34 MB
correlation[perm=3000]cpu184.8 ms0.0 MB-
correlation[perm=3000]cuda179.9 ms0.0 MB35 MB
one_sample[perm=1000]cpu433.5 ms40.4 MB-
one_sample[perm=1000]cuda98.7 ms21.0 MB655 MB
one_sample[perm=3000]cpu774.0 ms241.4 MB-
one_sample[perm=3000]cuda279.9 ms180.8 MB1895 MB
two_sample[perm=1000]cpu285.9 ms61.1 MB-
two_sample[perm=1000]cuda97.5 ms21.0 MB77 MB
two_sample[perm=3000]cpu609.9 ms240.1 MB-
two_sample[perm=3000]cuda285.3 ms180.8 MB158 MB

collection

ConditionDeviceTimePeak RSSGPU mem
apply[standardize,N=20,n_jobs=-1]cpu281.9 ms38.4 MB-
apply[standardize,N=20,n_jobs=1]cpu2.25 s131.1 MB-
apply[standardize,N=50,n_jobs=-1]cpu561.8 ms38.4 MB-
apply[standardize,N=50,n_jobs=1]cpu5.17 s258.7 MB-
mean[in_memory,N=20]cpu830.2 ms198.3 MB-
mean[in_memory,N=50]cpu1.95 s326.3 MB-
mean[lazy,N=20]cpu795.8 ms97.3 MB-
mean[lazy,N=50]cpu1.86 s99.8 MB-

Recommendations

When to Use Each Backend

Set the compute device on the public API with device= ('cpu' / 'gpu' / 'auto') — e.g. Ridge(device='gpu'), brain.fit(model='ridge', device='auto'), brain.bootstrap(stat='weights', device='gpu').

Use CPU (device='cpu', NumPy) when:

Use GPU (device='gpu', PyTorch) when:

Use Auto (device='auto') when:

Problem Size Thresholds

Based on our benchmarks:

# Effective problem size = n_samples × n_features × cv_folds
problem_size = n_samples * n_features * cv_folds

if problem_size < 10_000_000:
    # Use CPU (GPU overhead not worth it)
    device = 'cpu'
elif problem_size > 30_000_000:
    # Use GPU if available (significant speedup)
    device = 'gpu'
else:
    # Medium range: auto-select based on GPU availability
    device = 'auto'

Memory Considerations

Both backends use float32 precision to reduce memory usage:

# Example: 1000 samples × 200k features
n_samples, n_features = 1000, 200000

# NumPy memory (approx)
memory_numpy = n_samples * n_features * 4 bytes / 1e9  # ~0.8 GB

# PyTorch memory (includes intermediate arrays)
memory_torch = memory_numpy * 2  # ~1.6 GB (rough estimate)

GPU memory limits:


Troubleshooting

Common Issues

GPU Not Detected

from nltools.algorithms.backends import check_gpu_available

available, info = check_gpu_available()
if not available:
    print(f"GPU not available: {info['device_name']}")
    print("Using CPU backend instead")

Solutions:

MPS (Apple Silicon) SVD Limitation

Symptom: Warning about linalg_svd falling back to CPU

UserWarning: The operator 'aten::linalg_svd' is not currently supported on the MPS backend
and will fall back to run on the CPU.

Explanation:

Solutions:

Out of GPU Memory

Symptom: RuntimeError: CUDA out of memory or similar

Solutions:

  1. Reduce problem size (subset voxels, downsample data)

  2. Use the CPU device: device='cpu'

  3. Process in batches if applicable

  4. Use systems with more GPU memory

Slow First Run

Symptom: First GPU computation is slower than subsequent runs

Explanation:

Solution: This is normal. Subsequent runs will be faster.


Best Practices

1. Profile Before Optimizing

import time
import numpy as np
from nltools.algorithms.ridge import ridge_svd
from nltools.algorithms.backends import Backend

X = np.random.randn(300, 100000)
y = np.random.randn(300)

# Time NumPy (algorithm-layer solvers use the internal `parallel=` name)
start = time.time()
coef_np = ridge_svd(X, y, parallel='cpu')
time_np = time.time() - start

# Time PyTorch
start = time.time()
coef_torch = ridge_svd(X, y, parallel='gpu')
time_torch = time.time() - start

print(f"NumPy: {time_np:.3f}s")
print(f"PyTorch: {time_torch:.3f}s")
print(f"Speedup: {time_np/time_torch:.1f}x")

2. Use Auto-Selection for Portability

# Good: Works optimally everywhere
result = ridge_cv(X, y, parallel='auto')

# Less portable: Assumes GPU available
result = ridge_cv(X, y, parallel='gpu')

3. Batch GPU Operations

# Good: Minimize host-device transfers
backend = Backend('torch')
X_device = backend.to_device(X)
results = [ridge_svd(X_device, y, parallel=backend) for y in y_list]

# Less efficient: Transfer on every call
results = [ridge_svd(X, y, parallel='gpu') for y in y_list]

4. Monitor Resource Usage

# GPU monitoring (NVIDIA)
watch -n 1 nvidia-smi

# CPU/Memory monitoring (all systems)
htop  # or top

Benchmark Methodology

Hardware & Software

The exact host, platform, Python, NumPy, and PyTorch versions for each run are recorded per section in Benchmark Results (auto-generated from each artifact’s .env.json). The runs currently committed are an Apple-Silicon MPS box and a GB10/aarch64 CUDA box (pikachu).

Benchmark domains

The harness (benchmarks/) sweeps four domains at realistic neuroimaging scale:

  1. ridgeridge_cv + BrainData.fit(model='ridge'), CPU vs GPU.

  2. predictBrainData.predict across whole_brain / roi / searchlight.

  3. inference — one-/two-sample and correlation permutation tests, CPU vs GPU.

  4. collectionBrainCollection over N subjects: lazy vs in-memory, n_jobs.

Metrics

Data Generation

# Synthetic data matching neuroimaging dimensions
X = np.random.randn(n_samples, n_features).astype(np.float32)
y = np.random.randn(n_samples).astype(np.float32)

# Ensures realistic memory/computation patterns
# Results generalizable to real fMRI data

See Also


Note on MPS Performance: These benchmarks run on Apple Silicon with MPS backend. PyTorch’s SVD operation is not yet fully optimized for MPS and falls back to CPU, limiting speedup compared to CUDA GPUs. On NVIDIA GPUs with CUDA, expect significantly higher speedups (10-30x for large problems).

To regenerate benchmarks on your system: uv run python -m benchmarks.run then uv run python -m benchmarks.build_docs (see benchmarks/benchmarking-guide.md).