Source code for grid_process.optimization

# Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
#
# Distributed under the terms of the BSD 3-Clause License.
#
# SPDX-License-Identifier: BSD-3-Clause

"""
Module for optimization of computations
"""

import logging
import os
import threading
from collections import OrderedDict
from collections.abc import Callable, Hashable
from functools import wraps
from typing import Any, TypeVar, cast

logger = logging.getLogger(__name__)

F = TypeVar("F", bound=Callable[..., Any])


[docs] def memoize(maxsize: int = 10, key: Callable[..., Hashable] | None = None) -> Callable[[F], F]: """ In-memory least recently used (LRU) cache decorator for functions with arbitrary (e.g. unhashable numpy array) arguments. The cache key is either computed by the ``key`` function or given by the caller. Parameters ---------- maxsize : int, optional maximum number of cached results. When exceeded, the least recently used result is evicted. With ``maxsize <= 0``, nothing is cached and the decorator adds no overhead. Default is 10. key : callable, optional function called with the arguments of the decorated function, returning the (hashable) cache key, e.g. ``lambda arr, k: (arr.tobytes(), k)``. It is only called when caching is active (``maxsize > 0``), and the decorated function is then called with its usual arguments. If not given, the key is the optional keyword argument ``hkey`` passed at each call (e.g. ``func(arr, hkey=my_key)``), which is removed before calling the function. Calls without ``hkey`` (or with ``hkey=None``) are not cached. Returns ------- callable the decorator. Each decorated function has its own cache, kept in memory until the end of the process. The cache can be used from several threads. Each process has its own cache: a process created by ``fork`` starts with a copy of the parent's cache (the fork waits until no thread is reading or writing it), one created by ``spawn`` or ``forkserver`` with an empty cache. Note ---- The cache size of the functions cached by grid-process is read when the module is imported, from ``grid_process/config/grid_process.yaml`` or the environment variables ``GRID_PROCESS__cache_size__<name>`` (e.g. ``GRID_PROCESS__cache_size__nn_lookup=0`` to deactivate the cache of nearest neighbor lookups). """ def real_memoize(func: F) -> F: if maxsize <= 0: if key is not None: return func @wraps(func) def _no_cache(*args: Any, **kwargs: Any) -> Any: kwargs.pop("hkey", None) return func(*args, **kwargs) return cast(F, _no_cache) cache: OrderedDict[Hashable, Any] = OrderedDict() # held only for the cache reads and writes: nothing slow (e.g. func) or forking may run while it is held lock = threading.Lock() if hasattr(os, "register_at_fork"): # POSIX only # a fork waits until no other thread holds the lock: the child gets a consistent cache and a free lock os.register_at_fork(before=lock.acquire, after_in_parent=lock.release, after_in_child=lock.release) def _cached_call(hkey: Hashable, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: if hkey is not None: with lock: hit = hkey in cache if hit: cache.move_to_end(hkey) result = cache[hkey] if hit: logger.debug("@memoize: %s cached!", func.__name__) return result logger.debug("@memoize: %s computing ...", func.__name__) result = func(*args, **kwargs) if hkey is not None: with lock: cache[hkey] = result cache.move_to_end(hkey) while len(cache) > maxsize: cache.popitem(last=False) size = len(cache) logger.debug("@memoize: %s cache size is %s", func.__name__, size) return result if key is not None: get_key = key @wraps(func) def _memoize_key(*args: Any, **kwargs: Any) -> Any: return _cached_call(get_key(*args, **kwargs), args, kwargs) return cast(F, _memoize_key) @wraps(func) def _memoize_hkey(*args: Any, **kwargs: Any) -> Any: hkey = kwargs.pop("hkey", None) return _cached_call(hkey, args, kwargs) return cast(F, _memoize_hkey) return real_memoize