Source code for sc_tools.memory.profiling
"""
Memory profiling and management utilities.
Provides functions for tracking memory usage, performing cleanup,
and estimating memory requirements for AnnData objects.
"""
from __future__ import annotations
import gc
import logging
import os
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import anndata as ad
logger = logging.getLogger(__name__)
# Try to import optional dependencies
try:
import psutil
PSUTIL_AVAILABLE = True
except ImportError:
PSUTIL_AVAILABLE = False
try:
import tracemalloc
TRACEMALLOC_AVAILABLE = True
except ImportError:
tracemalloc = None
TRACEMALLOC_AVAILABLE = False
[docs]
def get_memory_usage() -> dict[str, float]:
"""
Get current memory usage in MB.
Returns
-------
dict
Dictionary with memory usage metrics:
- 'rss_mb': Resident Set Size in MB
- 'vms_mb': Virtual Memory Size in MB (if psutil available)
- 'percent': Memory usage as percentage of process (if psutil available)
- 'system_available_mb': Available system memory in MB (if psutil available)
- 'system_percent': System memory usage percentage (if psutil available)
- 'tracemalloc_current_mb': Current traced memory (if tracemalloc active)
- 'tracemalloc_peak_mb': Peak traced memory (if tracemalloc active)
"""
memory_info = {}
if PSUTIL_AVAILABLE:
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
memory_info["rss_mb"] = mem_info.rss / 1024 / 1024 # Resident Set Size
memory_info["vms_mb"] = mem_info.vms / 1024 / 1024 # Virtual Memory Size
memory_info["percent"] = process.memory_percent()
# System memory
sys_mem = psutil.virtual_memory()
memory_info["system_available_mb"] = sys_mem.available / 1024 / 1024
memory_info["system_percent"] = sys_mem.percent
else:
memory_info["rss_mb"] = 0.0
memory_info["vms_mb"] = 0.0
memory_info["percent"] = 0.0
memory_info["system_available_mb"] = 0.0
memory_info["system_percent"] = 0.0
if TRACEMALLOC_AVAILABLE and tracemalloc is not None and tracemalloc.is_tracing():
current, peak = tracemalloc.get_traced_memory()
memory_info["tracemalloc_current_mb"] = current / 1024 / 1024
memory_info["tracemalloc_peak_mb"] = peak / 1024 / 1024
return memory_info
[docs]
def log_memory(
step_name: str,
adata: ad.AnnData | None = None,
logger_instance: logging.Logger | None = None,
) -> dict[str, float]:
"""
Log memory usage at a specific step.
Parameters
----------
step_name : str
Name of the step for logging
adata : AnnData, optional
Optional AnnData object to estimate its memory usage
logger_instance : Logger, optional
Custom logger instance. If None, uses module logger.
Returns
-------
dict
Memory usage dictionary (from get_memory_usage)
"""
log = logger_instance if logger_instance is not None else logger
mem = get_memory_usage()
msg = f"[MEMORY] {step_name}:"
msg += f" RSS={mem['rss_mb']:.1f}MB"
if mem["percent"] > 0:
msg += f" ({mem['percent']:.1f}% of process)"
if mem["system_available_mb"] > 0:
msg += f" | System: {mem['system_available_mb']:.1f}MB available ({mem['system_percent']:.1f}% used)"
if "tracemalloc_peak_mb" in mem:
msg += f" | Peak traced: {mem['tracemalloc_peak_mb']:.1f}MB"
if adata is not None:
# Estimate AnnData memory
x_mem = estimate_adata_memory(adata)
msg += f" | AnnData X: {x_mem:.1f}MB ({adata.shape[0]} spots x {adata.shape[1]} genes)"
log.info(msg)
return mem
[docs]
def aggressive_cleanup():
"""
Aggressively clean up memory.
Performs garbage collection and attempts to release memory
back to the operating system (platform-dependent).
"""
gc.collect()
gc.collect() # Call twice to handle circular references
if PSUTIL_AVAILABLE:
# Force Python to release memory (Linux/macOS)
try:
import ctypes
if sys.platform == "darwin":
libc = ctypes.CDLL("libc.dylib")
else:
libc = ctypes.CDLL("libc.so.6")
libc.malloc_trim(0)
except (OSError, AttributeError):
pass # Not available on this platform
[docs]
def estimate_adata_memory(adata: ad.AnnData) -> float:
"""
Estimate memory usage of AnnData object in MB.
Parameters
----------
adata : AnnData
AnnData object to estimate
Returns
-------
float
Estimated memory usage in MB
"""
total = 0
# X matrix
if hasattr(adata.X, "data"):
total += adata.X.data.nbytes
if hasattr(adata.X, "indices"):
total += adata.X.indices.nbytes
if hasattr(adata.X, "indptr"):
total += adata.X.indptr.nbytes
else:
total += adata.X.nbytes
# obs and var
total += adata.obs.memory_usage(deep=True).sum()
total += adata.var.memory_usage(deep=True).sum()
# obsm
for _key, value in adata.obsm.items():
if hasattr(value, "nbytes"):
total += value.nbytes
elif hasattr(value, "memory_usage"):
total += value.memory_usage(deep=True).sum()
return total / 1024 / 1024 # Convert to MB
[docs]
def check_memory_threshold(
threshold_mb: float = 8000,
threshold_percent: float = 85.0,
logger_instance: logging.Logger | None = None,
) -> bool:
"""
Check if memory usage exceeds thresholds.
Parameters
----------
threshold_mb : float
Maximum RSS memory in MB
threshold_percent : float
Maximum system memory usage percentage
logger_instance : Logger, optional
Custom logger instance. If None, uses module logger.
Returns
-------
bool
True if memory is below thresholds, False otherwise
"""
log = logger_instance if logger_instance is not None else logger
mem = get_memory_usage()
if mem["rss_mb"] > threshold_mb:
log.warning(
f"[MEMORY WARNING] RSS ({mem['rss_mb']:.1f}MB) exceeds threshold ({threshold_mb}MB)"
)
return False
if mem["system_percent"] > threshold_percent:
log.warning(
f"[MEMORY WARNING] System memory ({mem['system_percent']:.1f}%) exceeds threshold ({threshold_percent}%)"
)
return False
return True