Performance & Scalability#
Overview#
Pyralysis is designed for scalability and performance optimization, leveraging Dask for parallel processing, efficient memory management, and smart optimization strategies. This guide covers best practices for achieving optimal performance with both small and large datasets.
Note
Performance optimization in Pyralysis involves balancing memory usage, computation speed, and scalability across different hardware configurations.
Dask Integration#
Pyralysis leverages Dask for scalable parallel processing:
Key Benefits: - Out-of-core Processing: Handle datasets larger than available memory - Parallel Computation: Automatic parallelization across CPU cores - Lazy Evaluation: Defer computation until results are needed - Distributed Computing: Scale across multiple machines
Basic Dask Usage:
import dask.array as da
from pyralysis.reconstruction import Parameter
# Create large dask array
large_data = da.random.random((4096, 4096), chunks=(256, 256))
# Create parameter with dask backend
param = Parameter(
data=large_data,
cellsize=0.1*u.arcsec,
chunks=(256, 256)
)
Chunking Strategies#
Optimal chunking is crucial for performance:
Chunk Size Guidelines:
Small Images (< 1024x1024): Use chunks similar to image size
Medium Images (1024x1024 - 4096x4096): Use 256x256 or 512x512 chunks
Large Images (> 4096x4096): Use 256x256 chunks for memory efficiency
Chunking Examples:
# Manual chunking
param = Parameter(
data=data,
chunks=(256, 256) # Good for most use cases
)
# Automatic chunking
param = Parameter(
data=data,
chunks="auto" # Let Dask choose optimal chunks
)
# Custom chunking for specific dimensions
param = Parameter(
data=data,
chunks={"dim_0": 256, "dim_1": 256, "dim_2": 50}
)
Gridding performance (DirtyMapper / weighting)#
Visibility gridding uses da.delayed tasks per row chunk of flattened
u_pix / weights / data, then a pairwise tree combine of partial UV grids.
Visibility chunks (parallelism and memory)
Prefer fewer, larger row-aligned chunks when the padded grid is large (e.g.
chunks={"row": 50000, "chan": 1, "corr": 4}in MS read).Each chunk task allocates a full
(v_pad, u_pad, n_corrs)accumulator; peak RAM scales withconcurrent_tasks × 2 × v_pad × u_pad × n_corrs × dtype size.Keep
DirtyMapper.chunksfor the output UV cube (FFT/normalization) separate from visibility chunking;"auto"or ~256 px spatial blocks are typical.
CPU vs GPU
CPU: Numba
grid_block_unified_numba(serial per chunk).GPU: Numba CUDA one thread per visibility with
cuda.atomic.add; collection backend must match MS (array.backend=cupy). Tunable viaPYRALYSIS_GRIDDING_CUDA_THREADS(default 256). Compare backends withtests/unit/utils/gridding/test_kernels_backend.pyandexamples/scripts/gridding_profile_chunk.py(HD163296 chunk sweeps).
Regression tests
pytest tests/unit/utils/gridding/test_reduction.py -m "not gpu"pytest tests/unit/utils/gridding/test_performance.py -m slow(timing smoke)
Memory Management#
Efficient memory usage strategies:
Iterate state (required for Dask optimizers):
Each optimization iteration assigns new lazy arrays to the primary Parameter (and,
for FISTA, the accelerated iterate). Without collapsing that lineage, Dask graph size
grows O(iterations) and host or GPU memory can OOM even when individual forward
models fit in RAM.
Built-in optimizers call _finalize_iteration_state()
after each iteration. Custom optimizers must do the same for every carried
Parameter; see Optimizers (Dask iterate state).
This is separate from the optional caches below: persist_gradient collapses the
aggregate gradient, not the iterate; LBFGS persists correction pairs and search directions
because they are read multiple times within one iteration. SDMM always finalizes
auxiliary z/u arrays each outer iteration via _finalize_dask_arrays.
Gradient Persistence:
from pyralysis.optimization import ObjectiveFunction
# Enable gradient caching for repeated evaluations
obj_func = ObjectiveFunction(
terms=terms,
parameter=image,
persist_gradient=True # Cache gradients in memory
)
# Gradients are computed once and reused
for iteration in range(max_iterations):
obj_func.calculate_gradient(iteration=iteration)
Proximal Operator Persistence:
For proximal optimization methods (FISTA, SDMM), you can enable persistence of proximal operator results to maintain consistent iteration times:
from pyralysis.optimization.terms.regularizers import L1Norm, Tikhonov
# Enable proximal persistence for specific regularizers
l1_term = L1Norm(penalization_factor=0.01, persist_proximal=True)
tikh_term = Tikhonov(penalization_factor=0.001, persist_proximal=True)
# Proximal results are persisted in memory after first computation
# This maintains consistent iteration times in FISTA/SDMM
Memory Optimization Techniques:
# Persist frequently accessed data
param.persist()
# Compute when needed
if param.is_dask():
numpy_data = param.compute() # Convert to numpy
# Monitor memory usage
print(f"Memory usage: {param.memory_usage()}")
# Use in-place operations
param *= 2.0 # In-place multiplication
Performance Optimization#
Strategies for optimal performance:
1. Efficient Term Processing:
# Process terms efficiently
obj_func = ObjectiveFunction(terms=terms, parameter=image)
# Enable smart term processing
obj_func.persist_gradient = True
# Use appropriate chunking
obj_func.chunks = (256, 256)
2. Multi-channel Optimization:
# Handle multi-channel data efficiently
if image.ndim > 2:
# Process specific channels
single_channel = image[:, :, 0]
# Use parameter_index in regularizers
l1_term = L1Norm(parameter_index=0, penalization_factor=0.01)
3. Masking for Performance:
from pyralysis.reconstruction.mask import Mask
# Apply operations only to relevant regions
mask = Mask(data=region_of_interest)
# Use mask in regularization
l1_term.function(mask=mask) # Only processes masked pixels
Optimizer-Specific Performance Features#
L-BFGS Memory Management#
The optimized L-BFGS implementation includes several advanced performance features:
Efficient History Management:
from pyralysis.optimization.optimizer import LBFGS
# Standard memory-optimized configuration
optimizer = LBFGS(
parameter=image,
objective_function=obj_func,
max_corrections=10, # Max pairs (RAM + disk) for L-BFGS
linesearch=ls
)
# For very large parameters with memory constraints
optimizer = LBFGS(
parameter=large_image,
objective_function=obj_func,
max_corrections=10,
memory_threshold_gb=50.0, # Use sequential processing more aggressively
use_disk_storage=True, # Allow zarr spill when in_memory_limit is exceeded
in_memory_limit=5, # Keep only 5 pairs in RAM
linesearch=ls
)
Key Memory Optimizations:
Lazy allocation: Correction pairs live in
collections.dequestructures; nothing is allocated until the first accepted pair. The total stored is capped bymax_corrections(oldest dropped when full).Hybrid Processing: Automatically switches between vectorized Dask operations (for speed) and sequential processing (for memory efficiency) based on estimated memory usage.
Memory Threshold: Configurable threshold (
memory_threshold_gb, default: 100.0 GB) determines when to use sequential processing to avoid memory overflow.Chunking Normalization: All correction pairs are automatically rechunked to match parameter chunking, reducing fragmentation and improving Dask performance.
Computation Caching: Gamma scaling factor and rho array conversions are cached to avoid redundant computations.
Optional disk storage: With
use_disk_storage=Trueand an explicitin_memory_limit, oldest in-memory pairs can be written to zarr; total pairs still respectmax_corrections.
Large-Scale Computation:
# Optimized for large images with complex gradients
large_image = Image(data=complex_data, cellsize=0.01*un.arcsec)
optimizer = LBFGS(
parameter=large_image,
memory_threshold_gb=50.0, # Lower threshold for large arrays
use_disk_storage=True, # Allow spill to zarr
in_memory_limit=5, # Bound RAM; total pairs still <= max_corrections
...
)
# Uses lazy allocation and sequential processing when needed
Conjugate Gradient Robustness#
Enhanced CG variants with improved complexity handling:
Complex Parameter Support:
# All CG variants support complex numbers automatically
from pyralysis.optimization.optimizer import HagerZhang
optimizer = HagerZhang(
parameter=complex_polarization_image,
objective_function=obj_func,
linesearch=ls
)
# Uses optimized scalar computation for beta parameters
# Improved numerical stability for complex gradients
Scalability Features#
Pyralysis scales from single machines to distributed clusters:
Single Machine Optimization:
# Configure for single machine
import dask.config
# Set number of threads
dask.config.set(scheduler='threads')
# Configure chunking
param.chunks = (512, 512) # Larger chunks for single machine
Distributed Computing:
# Configure for distributed cluster
from dask.distributed import Client
# Connect to cluster
client = Client("scheduler-address:8786")
# Use distributed scheduler
import dask.config
dask.config.set(scheduler='distributed')
For simulation and imaging pipelines, you can drive the same idea through context configuration
and the built-in setup/teardown steps instead of manual Client wiring in user code; see
Pipelines and distributed Dask.
GPU Acceleration (Future):
# GPU support is in development
# Future: Automatic GPU acceleration for compatible operations
# Check GPU availability
try:
import cupy as cp
print("GPU acceleration available")
except ImportError:
print("GPU acceleration not available")
Benchmarking Performance#
Monitor and optimize performance:
Performance Metrics:
import time
# Time function evaluation
start_time = time.time()
function_value = obj_func.calculate_function()
eval_time = time.time() - start_time
print(f"Function evaluation time: {eval_time:.2f}s")
# Time gradient computation
start_time = time.time()
obj_func.calculate_gradient(iteration=1)
grad_time = time.time() - start_time
print(f"Gradient computation time: {grad_time:.2f}s")
Memory Profiling:
# Monitor memory usage
print(f"Parameter memory: {param.memory_usage()}")
print(f"Image memory: {image.memory_usage()}")
# Check chunk information
if param.current_chunks:
print(f"Current chunks: {param.current_chunks}")
Best Practices#
1. Chunking Strategy:
Start with automatic chunking (chunks=”auto”)
Adjust based on memory constraints and performance
Use smaller chunks for memory-constrained systems
Use larger chunks for single-machine setups
2. Memory Management:
Enable gradient persistence for repeated evaluations
Use appropriate chunking to balance memory and performance
Monitor memory usage during optimization
Use in-place operations when possible
3. Performance Monitoring:
Profile function and gradient evaluation times
Monitor memory usage patterns
Use appropriate chunking for your data size
Enable persistence for frequently accessed data
4. Scalability Planning:
Start with single-machine optimization
Scale to distributed computing for large datasets
Use appropriate chunking for your cluster size
Monitor cluster performance and resource usage
Example: Performance-Optimized Workflow
# Create parameter with optimal chunking
param = Parameter(
data=large_data,
cellsize=0.1*u.arcsec,
chunks=(256, 256) # Optimal for most systems
)
# Create objective function with performance features
obj_func = ObjectiveFunction(
terms=terms,
parameter=param,
persist_gradient=True, # Cache gradients
chunks="auto" # Automatic chunking
)
# Monitor performance
start_time = time.time()
# Optimization loop
for iteration in range(max_iterations):
function_value = obj_func.calculate_function()
obj_func.calculate_gradient(iteration=iteration)
# Monitor progress
if iteration % 10 == 0:
elapsed = time.time() - start_time
print(f"Iteration {iteration}: {elapsed:.2f}s elapsed")
total_time = time.time() - start_time
print(f"Total optimization time: {total_time:.2f}s")
Troubleshooting Performance Issues#
Common performance problems and solutions:
1. Memory Issues:
# Problem: Out of memory
# Solution: Reduce chunk size
param.chunks = (128, 128) # Smaller chunks
# Problem: Excessive memory usage
# Solution: Enable persistence selectively
obj_func.persist_gradient = False # Disable if memory is limited
# For proximal methods, disable proximal persistence if needed
l1_term.persist_proximal = False # Disable proximal persistence
2. Slow Computation:
# Problem: Small chunks causing overhead
# Solution: Increase chunk size
param.chunks = (512, 512) # Larger chunks
# Problem: No parallelization
# Solution: Check Dask configuration
import dask.config
print(f"Number of threads: {dask.config.get('num_workers')}")
3. Inefficient Operations:
# Problem: Repeated gradient computation
# Solution: Enable gradient persistence
obj_func.persist_gradient = True
# Problem: Large memory footprint
# Solution: Use appropriate chunking
param.chunks = (256, 256) # Balance memory and performance