Source code for abtem.core.chunks

"""Module for determining chunk sizes of Dask arrays."""

from __future__ import annotations

import itertools
from functools import reduce
from itertools import accumulate
from operator import mul
from typing import Generator, Optional, TypeGuard, Union

import numpy as np
from dask.utils import parse_bytes

from abtem.core import config

Chunks = Union[int, str, tuple[Union[int, str, tuple[int, ...]], ...]]
ChunksTuple = tuple[Union[int, str, tuple[int, ...]], ...]
ValidatedChunks = tuple[tuple[int, ...], ...]


[docs] def is_tuple_of_ints(x: Chunks) -> TypeGuard[tuple[int, ...]]: return isinstance(x, tuple) and all(isinstance(c, int) for c in x)
[docs] def is_tuple_of_tuple_of_ints(x: Chunks) -> TypeGuard[tuple[tuple[int, ...], ...]]: return isinstance(x, tuple) and all( isinstance(x1, tuple) and all(isinstance(c, int) for c in x1) for x1 in x )
[docs] def is_tuple_of_ints_or_tuple_of_ints( x: Chunks, ) -> TypeGuard[tuple[tuple[int, ...], ...]]: return isinstance(x, tuple) and all( isinstance(x1, int) or (isinstance(x1, tuple) and all(isinstance(c, int) for c in x1)) for x1 in x )
[docs] def is_tuple_of_ints_or_tuple_of_tuple_of_ints( x: Chunks, ) -> TypeGuard[tuple[int | tuple[int, ...], ...]]: return is_tuple_of_ints(x) or is_tuple_of_tuple_of_ints(x)
[docs] def is_validated_chunks(x: Chunks) -> TypeGuard[ValidatedChunks]: """ Check if the input is are valid chunk sizes. Parameters ---------- x : int or tuple of int or tuple of tuple of int or str The chunk sizes of the Dask array. Returns ------- TypeGuard[ValidatedChunks] True if the input is a valid chunk size. """ return is_tuple_of_tuple_of_ints(x)
[docs] def assert_chunks_match_shape(shape: tuple[int, ...], chunks: ValidatedChunks) -> None: if not all(sum(c) == s for s, c in zip(shape, chunks)): raise ValueError(f"chunks must match shape, got {chunks} for shape {shape}")
[docs] def chunk_ranges(chunks: ValidatedChunks) -> tuple[tuple[tuple[int, int], ...], ...]: """ Get the start and end indices for each chunk. Parameters ---------- chunks : tuple of tuple of int The chunk sizes of the Dask array. Returns ------- tuple of tuple of tuple of two int The range of indices for each chunk. """ return tuple( tuple((cumchunks - cc, cumchunks) for cc, cumchunks in zip(c, accumulate(c))) for c in chunks )
[docs] def iterate_chunk_ranges(chunks: ValidatedChunks): """ Iterate over the chunk ranges. Parameters ---------- chunks : tuple of tuple of int The chunk sizes of the Dask array. Yields ------ block_indices : tuple of int The indices of the current block. slices : tuple of slice The slices indexing the current block. """ chunk_shape = tuple(len(c) for c in chunks) for block_indices, chunk_range in zip( itertools.product(*(range(n) for n in chunk_shape)), itertools.product(*chunk_ranges(chunks)), ): slic = tuple(slice(*cr) for cr in chunk_range) yield block_indices, slic
[docs] def fill_in_chunk_sizes( shape: tuple[int, ...], chunks: tuple[int | tuple[int, ...], ...] ) -> ValidatedChunks: validated_chunks = [] for s, c in zip(shape, chunks): if isinstance(c, tuple): validated_chunks.append(c) elif isinstance(c, int): if c == -1: validated_chunks.append((s,)) else: chunk_size = (c,) * (s // c) if s % c: chunk_size += (s % c,) validated_chunks.append(chunk_size) else: raise RuntimeError("Invalid chunk type") return tuple(validated_chunks)
[docs] def check_chunks_match_shape_length(shape: tuple[int, ...], chunks: Chunks) -> None: if isinstance(chunks, tuple) and not len(shape) == len(chunks): raise ValueError(f"length of shape: {shape} does not match chunks {chunks}")
[docs] def validate_chunks( shape: tuple[int, ...], chunks: Chunks, max_elements: int | str = "auto", dtype: Optional[np.dtype] = None, device: str = "cpu", ) -> ValidatedChunks: """ Validate the chunks for a Dask array based on the shape and a maximum number of elements. Parameters ---------- shape : tuple of int The shape of the array. chunks : int or tuple of int or str The chunk sizes of the Dask array. If an integer, the array will be split into equal chunks. If a tuple, the array will be split into the specified chunks. If "auto", the chunks will be determined automatically based on the shape and the maximum number of elements. max_elements : int or str The maximum number of elements in a chunk. If "auto", the maximum number of elements will be determined based on the maximum number of bytes per chunk and the dtype. dtype : numpy.dtype The dtype of the array. device : str The device the array will be stored on. Returns ------- tuple of tuple of int The chunk sizes of the Dask array. """ check_chunks_match_shape_length(shape, chunks) if is_validated_chunks(chunks): validated_chunks = chunks elif chunks == -1: validated_chunks = validate_chunks(shape, shape) elif isinstance(chunks, int): max_elements = chunks chunks = ("auto",) * len(shape) validated_chunks = _auto_chunks( shape, chunks, max_elements, dtype=dtype, device=device ) elif isinstance(chunks, str): raise NotImplementedError() elif any(isinstance(c, str) for c in chunks): validated_chunks = _auto_chunks( shape, chunks, max_elements, dtype=dtype, device=device ) elif is_tuple_of_ints_or_tuple_of_ints(chunks): validated_chunks = fill_in_chunk_sizes(shape, chunks) else: raise ValueError( "chunks must be an integer, a tuple of integers a tuple of tuple of" f"integers or 'auto' got {chunks}" ) assert_chunks_match_shape(shape, validated_chunks) return validated_chunks
def _auto_chunks( shape: tuple[int, ...], chunks: ChunksTuple, max_elements: str | int = "auto", dtype: Optional[np.dtype] = None, device: str = "cpu", ) -> ValidatedChunks: """ Automatically determine the chunks for a Dask array based on the shape and a maximum number of elements. Parameters ---------- shape : tuple of int The shape of the array. chunks : tuple of int or str The chunk sizes of the Dask array. If an integer, the array will be split into equal chunks. If a tuple, the array will be split into the specified chunks. If "auto", the chunks will be determined automatically based on the shape and the maximum number of elements. max_elements : int or str The maximum number of elements in a chunk. If "auto", the maximum number of elements will be determined based on the maximum number of bytes per chunk and the dtype. dtype : numpy.dtype The dtype of the array. device : str The device the array will be stored on. Returns ------- tuple of tuple of int The chunk sizes of the Dask array. """ check_chunks_match_shape_length(shape, chunks) if max_elements == "auto": if device == "gpu": chunk_bytes = parse_bytes(config.get("dask.chunk-size-gpu")) elif device == "cpu": chunk_bytes = parse_bytes(config.get("dask.chunk-size")) else: raise RuntimeError(f"Unknown device: {device}") if dtype is None: raise ValueError("auto selecting chunk sizes requires dtype") max_elements = int(np.floor(chunk_bytes) / np.dtype(dtype).itemsize) elif isinstance(max_elements, str): max_elements = int( np.floor(parse_bytes(max_elements) / np.dtype(dtype).itemsize) ) elif not isinstance(max_elements, int): raise ValueError("limit must be an integer or a string") normalized_chunks = tuple(s if c == -1 else c for s, c in zip(shape, chunks)) # minimum_chunks = tuple( # 1 if c == "auto" else c for s, c in zip(shape, normalized_chunks) # ) current_chunks = [] max_chunks = [] for n, c in zip(shape, normalized_chunks): if c == "auto": current_chunks.append(1) max_chunks.append(n) elif isinstance(c, int): current_chunks.append(c) max_chunks.append(c) elif isinstance(c, tuple): current_chunks.append(max(c)) max_chunks.append(max(c)) else: raise RuntimeError() autodims = [i for i, c in enumerate(normalized_chunks) if c == "auto"] j = 0 while len(autodims): # autodims = [i for i in autodims if current_chunks[i] != maximum_chunks[i]] if len(autodims) == 0: break j = j % len(autodims) current_chunks[autodims[j]] = min( current_chunks[autodims[j]] + 1, shape[autodims[j]] ) total = reduce(mul, current_chunks) if total > max_elements: current_chunks[autodims[j]] -= 1 if current_chunks[autodims[j]] == 0: raise RuntimeError( "Object cannot be automatically chunked; consider increasing chunk-size parameter!" ) break if current_chunks == max_chunks: break j += 1 chunks = () for i, c in enumerate(normalized_chunks): if c == "auto": chunks += (current_chunks[i],) else: chunks += (c,) chunks = validate_chunks(shape, chunks, max_elements, dtype) return chunks
[docs] def equal_sized_chunks( num_items: int, num_chunks: Optional[int] = None, chunk_size: Optional[int] = None ) -> tuple[int, ...]: """ Split an n integer into m (almost) equal integers, such that the sum of smaller integers equals n. Parameters ---------- num_items: int The integer to split. num_chunks: int The number integers n will be split into. chunk_size: int The size of each chunk. Returns ------- tuple of int The split integers. """ if num_items == 0: return () if num_chunks is not None and chunk_size is not None: raise RuntimeError("specify either num_chunks or chunks, not both") if num_chunks is None: if chunk_size is not None: num_chunks = (num_items + (-num_items % chunk_size)) // chunk_size else: raise RuntimeError("either num_chunks or chunks must be specified") if num_items < num_chunks: raise RuntimeError( f"num_chunks ({num_chunks}) may not be larger than num_items ({num_items})" ) elif num_items % num_chunks == 0: chunks = tuple([num_items // num_chunks] * num_chunks) else: zp = num_chunks - (num_items % num_chunks) pp = num_items // num_chunks chunks = tuple(pp + 1 if i >= zp else pp for i in range(num_chunks)) assert sum(chunks) == num_items return chunks
[docs] def generate_chunks( num_items: int, num_chunks: Optional[int] = None, chunks: Optional[int] = None, start: int = 0, ) -> Generator[tuple[int, int], None, None]: """ Generate start and end indices for each chunks of equal sized chunks. Parameters ---------- num_items: int The integer to split. num_chunks: int The number integers n will be split into. chunks: int The size of each chunk. start: int The starting index. Yields ------ tuple of int The start and end indices of the current chunk. """ for batch in equal_sized_chunks(num_items, num_chunks, chunks): if num_items == 0: break end = start + batch yield start, end start = end
[docs] def estimate_potential_chunk_size( gpts: tuple[int, int], device: str = "cpu", dtype: np.dtype = None, ) -> int: """ Estimate the number of potential slices that fit in the memory budget. ``build()`` places the entire slice dimension into a single dask chunk, so the full potential must fit in memory at once. This function calculates how many slices can be held simultaneously when the potential is instead built in smaller chunks via ``generate_chunked_slices()``. On GPU the per-slice cost accounts for CuPy memory pool fragmentation — the pool may hold large contiguous blocks for live arrays (waves, probes) that prevent new allocations even when total free bytes suffice. The effective per-slice cost under fragmentation is empirically ~5× the raw slice size for scan workloads at 4096² grids. On CPU there is no pool fragmentation and system RAM is typically abundant. The default is therefore to place the entire potential in a single chunk (no chunking), matching pre-chunking behaviour. Set ``potential.slice-chunk-size`` in the configuration to a positive integer to enable CPU chunking when memory is genuinely limited. On GPU the budget uses the CUDA-reported free memory without calling ``free_all_blocks()`` first. Dead pool blocks represent recent memory pressure from build/propagation temporaries; leaving them gives a conservative estimate that self-adapts as the pool fills up over successive scan batches. Falls back to ``dask.chunk-size-gpu`` when CuPy is unavailable. Parameters ---------- gpts : tuple of int The number of grid points (y, x). device : str The device ('cpu' or 'gpu'). dtype : numpy.dtype, optional The dtype of the potential array. If None, uses float32. Returns ------- int The estimated number of slices that fit in memory. """ from abtem.core.utils import get_dtype if dtype is None: dtype = np.dtype(get_dtype(complex=False)) chunk_size_key = "potential.slice-chunk-size" chunk_size_setting = config.get(chunk_size_key, "auto") if chunk_size_setting != "auto": return int(chunk_size_setting) slice_bytes = gpts[0] * gpts[1] * dtype.itemsize if device == "gpu": # Deliberately no Bluestein-overhead factor here, unlike the sibling # estimate_scan_batch_size. Every FFT that touches a potential slice # is a single, unbatched 2D transform: build() and generate_slices() # emit one slice at a time (see _FieldBuilder.build), the projection # integrals in integrals.py transform a bare (ny, nx) array, and # multislice_step bandlimits one transmission function per step. The # cuFFT workspace for those transforms is therefore constant in # chunk_size, while effective_per_slice below is multiplied by it -- # folding an FFT-unfriendliness factor in would halve the chunk on a # Bluestein grid to pay for a cost that does not grow with it. The # probe batch is the only quantity whose FFT is batched over the # dimension being sized, which is why the factor belongs to # estimate_scan_batch_size alone. What residual Bluestein cost there # is (one cached plan) this function already sees: it runs at # computation time and reads the live free-VRAM figure below. try: import cupy as cp pool = cp.get_default_memory_pool() # Use CUDA-reported free memory without calling # free_all_blocks() first. Dead pool blocks represent recent # memory pressure and keep the estimate conservative, which # is the desired behaviour as the pool fills over successive # scan batches. free_mem, total_mem = cp.cuda.Device().mem_info # Cross-check against pool live usage. This guards the rare # case where dead blocks were released by someone else (e.g. # cuFFT plan cache eviction), making free_mem higher than the # live-data picture suggests. pool_used = pool.used_bytes() effective_free = min(free_mem, total_mem - pool_used) # Per-slice cost: output array (1×) + transmission function # (2×) + build temporaries (FFTs, Gaussian integrals) + # propagation FFT workspace. 5× is less conservative than the # original 8× now that the synchronous scheduler prevents # concurrent batch execution from multiplying peak VRAM. # # Budget: 35 % of effective-free VRAM at computation time. # At this point the probe batch is already resident, so # effective_free is already reduced by the probe allocation. # Together with the 40 % claimed by estimate_scan_batch_size # at graph-construction time, the joint budget is # 40 % + 35 % × (1 − probe_fraction) ≈ 73 %, leaving 27 % # headroom. (estimate_scan_batch_size uses effective_free too, # so both estimates operate on the same VRAM picture.) effective_per_slice = slice_bytes * 5 budget_bytes = int(effective_free * 0.35) except (ImportError, Exception): effective_per_slice = slice_bytes * 5 budget_bytes = parse_bytes(config.get("dask.chunk-size-gpu", "512 MB")) else: # On CPU, system RAM is typically abundant and there is no # memory-pool fragmentation. The default is therefore to place # the entire potential in a single chunk (no chunking), matching # the pre-chunking behaviour. The potential.slice-chunk-size # config key (checked above) allows an explicit override when # memory is genuinely limited. return 4096 chunk_size = max(1, int(budget_bytes / effective_per_slice)) return min(chunk_size, 4096)
def _nearest_power_of_two(n: int) -> int: """Round n to the nearest power of two. Prefers the *upper* power when it fits within 25 % of the raw estimate (i.e. when ``ceil_pot <= n * 1.25``). This lets the auto-sizing snap to GPU-friendly batch sizes (8, 16, 32, 64 …) without meaningfully exceeding the VRAM budget, since the raw estimate already uses only 50 % of free VRAM as headroom. Notes ----- 59 → 64 (int(59 * 1.25) = 73; 64 ≤ 73, use upper) 14 → 16 (int(14 * 1.25) = 17; 16 ≤ 17, use upper) 20 → 16 (int(20 * 1.25) = 25; 32 > 25, use lower) """ if n <= 1: return 1 floor_pot = 1 << (n.bit_length() - 1) # largest power of two ≤ n ceil_pot = floor_pot << 1 # smallest power of two ≥ n if n == floor_pot: return n # already a power of two if ceil_pot <= int(n * 1.25): return ceil_pot return floor_pot
[docs] def estimate_scan_batch_size( gpts: tuple[int, int], dtype, device: str, ) -> int: """ Estimate the maximum number of probe wavefunctions per scan batch. For GPU, queries free CUDA memory at graph-construction time and allocates up to half of it for probe wavefunctions. This is intentionally generous because ``estimate_potential_chunk_size`` is called at *computation* time (inside ``generate_chunked_slices``) when the probe batch is already resident in VRAM; it therefore sees the reduced free memory and sizes the potential chunk to fit in what remains. The two estimates are thus naturally coordinated without requiring explicit cross-referencing. The raw estimate is rounded to the nearest power of two (preferring the upper power when within 25 % headroom) so that batch sizes snap to GPU-friendly values such as 8, 16, 32, 64. For CPU, falls back to the ``dask.chunk-size`` configuration key. Parameters ---------- gpts : tuple of int Spatial grid size ``(ny, nx)`` of each probe wavefunction. dtype : dtype-like Wavefunction dtype (typically ``complex64``). device : str ``"gpu"`` or ``"cpu"``. Returns ------- int Maximum number of probe wavefunctions per batch (≥ 1). """ per_probe_bytes = int(np.prod(gpts)) * np.dtype(dtype).itemsize if device == "gpu": # ``gpts`` is the wavefunction grid every probe lives on, not the scan # grid: the scan positions form the FFT *batch* dimension, so only the # transform lengths (ny, nx) decide whether cuFFT falls back to # Bluestein. The radix check cannot fail and needs no GPU -- keep it # outside the try below, whose silent fallback is only for a # missing/failing CUDA memory probe. from abtem.core.fft import is_fast_fft_size overhead = 6 if not all(is_fast_fft_size(n) for n in gpts): overhead = 12 try: import cupy as cp pool = cp.get_default_memory_pool() free_mem, total_mem = cp.cuda.Device().mem_info pool_used = pool.used_bytes() # Use the same effective-free formula as estimate_potential_chunk_size # so both estimates operate on a consistent VRAM picture. Dead pool # blocks (cached but not live) are included in free_mem by CUDA, but # total_mem - pool_used excludes them; taking the min is conservative. effective_free = min(free_mem, total_mem - pool_used) # Allocate up to 50 % of effective VRAM for probe-batch overhead. # A 6× overhead factor accounts for transient copies during # transmission-function multiply, FFT workspaces, and cuFFT plan # buffers. Empirically: 4096² uses ~756 MB/probe (5.6× raw # wavefunction), 2048² uses ~147 MB/probe (4.4×). Those numbers # hold for fast-radix grids; a grid length with a prime factor # larger than 7 pushes cuFFT onto the Bluestein algorithm, whose # power-of-two-padded internal buffers roughly double the # transient footprint (observed >13× raw per probe on a # 2623×2271 complex128 grid), so the factor is doubled there. # # estimate_potential_chunk_size runs at *computation* time, when # the probe batch is already resident; it sees the reduced free # memory and sizes the potential chunk to fit in what remains. # Both functions now use the same pool-aware effective_free so # the two estimates operate on a consistent VRAM picture. probe_budget = int(effective_free * 0.50) per_probe_effective = max(1, int(per_probe_bytes * overhead)) n_probes = max(1, probe_budget // per_probe_effective) return _nearest_power_of_two(n_probes) except Exception: # noqa: BLE001 -- no CUDA memory probe available chunk_bytes = parse_bytes(config.get("dask.chunk-size-gpu", "512 MB")) else: chunk_bytes = parse_bytes(config.get("dask.chunk-size", "128 MB")) return max(1, chunk_bytes // max(1, per_probe_bytes))