Source code for quchip.utils.memory

"""Memory estimates, and checks against the memory available to this process.

Call sites name what they allocate, for example the eigensystem of a matrix of
dimension ``D``, and the estimators below turn that into bytes. Each backend
adds the cost of its own lowering and solvers. :func:`require_memory` refuses
an allocation that cannot fit before quchip makes it.
"""

from __future__ import annotations

from math import prod
from pathlib import Path
from typing import Any

import numpy as np
import psutil
from scipy import sparse
from scipy.sparse.csgraph import reverse_cuthill_mckee

# Bytes of one complex double and of one 64-bit index.
COMPLEX_BYTES = 16
INDEX_BYTES = 8
# A dense Hermitian eigensystem keeps the matrix, a working copy that becomes
# the eigenvectors, the returned eigenvectors, and the complex and real
# workspace of LAPACK's divide-and-conquer driver.
_EIGENSYSTEM_COPIES = 6
# The cgroup v2 files of this process's container, when it has one.
_CGROUP = Path("/sys/fs/cgroup")
# SuperLU keeps a complex value and a 32-bit row index for each entry of its factors.
_LU_ENTRY_BYTES = COMPLEX_BYTES + 4
# SuperLU's fill depends on its column ordering and on partial pivoting. On
# quchip's stationary generators, from driven feedlines of transmons and
# resonators to a resonator with 300 levels, the fill was between 0.32 and 2.2
# times the reverse Cuthill-McKee envelope of the symmetrized pattern, so a
# quarter of the envelope stayed below each measured fill.
_ENVELOPE_FILL_FRACTION = 0.25


[docs] def dense_bytes(*shape: int, copies: int = 1, itemsize: int = COMPLEX_BYTES) -> int: """Return the bytes of ``copies`` dense arrays of ``shape``. Parameters ---------- *shape : int Shape of one array. copies : int, default=1 Number of arrays that exist at the same time. itemsize : int, default=16 Bytes of one entry. The default is a complex double. """ return int(copies * itemsize * prod(shape))
[docs] def eigensystem_bytes(dimension: int) -> int: """Return the peak bytes of the dense Hermitian eigensystem of a square matrix. Parameters ---------- dimension : int Number of rows of the matrix. """ return dense_bytes(dimension, dimension, copies=_EIGENSYSTEM_COPIES)
[docs] def level_table_bytes(states: int, devices: int) -> int: """Return the bytes of a table that gives the level of each device in each product state. Parameters ---------- states : int Number of product states. devices : int Number of devices. """ return dense_bytes(states, devices, itemsize=INDEX_BYTES)
[docs] def krylov_bytes(size: int, vectors: int) -> int: """Return the bytes of a Krylov basis. Parameters ---------- size : int Number of entries of one vector. vectors : int Number of vectors that the basis keeps. """ return dense_bytes(size, vectors)
[docs] def sparse_lu_bytes(matrix: Any) -> int: """Return a low estimate of the bytes of the sparse LU factors of a square matrix. SuperLU knows its fill only after the factorization, so the estimate is a quarter of the reverse Cuthill-McKee envelope of the symmetrized pattern. For a few modes with many levels the envelope grows faster than the fill, and the estimate can exceed the fill of a resonator with many hundreds of levels. Parameters ---------- matrix : scipy.sparse matrix or array Square matrix. """ size = matrix.shape[0] if size == 0: return 0 if matrix.format not in ("csr", "csc"): matrix = sparse.csr_matrix(matrix) # Read as CSR, a CSC matrix is its transpose, which has the same symmetrized pattern. pattern = sparse.csr_matrix((np.ones(matrix.nnz, dtype=bool), matrix.indices, matrix.indptr), shape=(size, size)) pattern = (pattern + pattern.T).tocsr() position = np.empty(size, dtype=np.int64) position[reverse_cuthill_mckee(pattern, symmetric_mode=True)] = np.arange(size) # After the reordering, the envelope of each row runs from its first entry to the diagonal. first = position.copy() rows = np.flatnonzero(np.diff(pattern.indptr)) if rows.size: first[rows] = np.minimum(first[rows], np.minimum.reduceat(position[pattern.indices], pattern.indptr[rows])) envelope = size + 2 * int(np.sum(position - first)) return int(_ENVELOPE_FILL_FRACTION * _LU_ENTRY_BYTES * envelope)
[docs] def available_memory_bytes() -> int | None: """Return the memory this process can still allocate, or ``None`` when unknown. The value is the available system memory that ``psutil`` reports. Under a cgroup v2 memory limit, as in many Linux containers, the value is the remaining limit when that is smaller. """ try: limits = [int(psutil.virtual_memory().available)] except (OSError, psutil.Error): return None try: maximum = (_CGROUP / "memory.max").read_text().strip() if maximum != "max": limits.append(int(maximum) - int((_CGROUP / "memory.current").read_text())) except (OSError, ValueError): pass return max(min(limits), 0)
[docs] def fits_memory(required: float) -> bool: """Return whether an estimated allocation fits, and ``True`` when memory is unknown. Parameters ---------- required : float Estimated peak allocation in bytes. """ available = available_memory_bytes() return available is None or required <= available
[docs] def require_memory(required: float, *, task: str, remedy: str) -> None: """Raise :class:`MemoryError` before an allocation that cannot fit. Parameters ---------- required : float Estimated peak allocation in bytes. task : str What would allocate it, for the error message. remedy : str How to avoid the allocation. """ available = available_memory_bytes() if available is not None and required > available: raise MemoryError( f"{task} needs about {required / 1e9:.1f} GB, but only {available / 1e9:.1f} GB " f"is available. {remedy}" )