diff --git a/capytaine/bem/engines.py b/capytaine/bem/engines.py index 89c6a448..c8eee507 100644 --- a/capytaine/bem/engines.py +++ b/capytaine/bem/engines.py @@ -14,7 +14,7 @@ from capytaine.matrices.block import BlockMatrix from capytaine.matrices.low_rank import LowRankMatrix, NoConvergenceOfACA from capytaine.matrices.block_toeplitz import BlockSymmetricToeplitzMatrix, BlockToeplitzMatrix, BlockCirculantMatrix -from capytaine.tools.lru_cache import delete_first_lru_cache +from capytaine.tools.lru_cache import lru_cache_with_strict_maxsize LOG = logging.getLogger(__name__) @@ -69,7 +69,7 @@ def __init__(self, *, linear_solver='lu_decomposition', matrix_cache_size=1): self.linear_solver = linear_solver if matrix_cache_size > 0: - self.build_matrices = delete_first_lru_cache(maxsize=matrix_cache_size)(self.build_matrices) + self.build_matrices = lru_cache_with_strict_maxsize(maxsize=matrix_cache_size)(self.build_matrices) self.exportable_settings = { 'engine': 'BasicMatrixEngine', @@ -153,7 +153,7 @@ class HierarchicalToeplitzMatrixEngine(MatrixEngine): def __init__(self, *, ACA_distance=8.0, ACA_tol=1e-2, matrix_cache_size=1): if matrix_cache_size > 0: - self.build_matrices = delete_first_lru_cache(maxsize=matrix_cache_size)(self.build_matrices) + self.build_matrices = lru_cache_with_strict_maxsize(maxsize=matrix_cache_size)(self.build_matrices) self.ACA_distance = ACA_distance self.ACA_tol = ACA_tol diff --git a/capytaine/tools/lru_cache.py b/capytaine/tools/lru_cache.py index 48c7805e..701c1609 100644 --- a/capytaine/tools/lru_cache.py +++ b/capytaine/tools/lru_cache.py @@ -1,29 +1,43 @@ +# Copyright (C) 2017-2024 Matthieu Ancellin +# See LICENSE file at +"""Tools for memoization of functions.""" from collections import OrderedDict from functools import wraps -def delete_first_lru_cache(maxsize=1): - """Behaves like functools.lru_cache(), but the oldest data in the cache is - deleted *before* computing a new one, in order to limit RAM usage when - stored objects are big.""" +import logging + +LOG = logging.getLogger(__name__) + + +def lru_cache_with_strict_maxsize(maxsize=1): + """Behaves mostly like functools.lru_cache(), but the oldest data in the cache is + deleted *before* computing a new one, in order to *never* have more that + `maxsize` items in memory. + This is useful to limit RAM usage when stored objects are big, like the interaction + matrices of Capytaine.""" def decorator(f): cache = OrderedDict() @wraps(f) def decorated_f(*args, **kwargs): - # /!\ cache only args + hashable_kwargs = tuple((k, v) for (k, v) in kwargs.items()) + # Might miss a cache hit if the order of kwargs is changed. + # But at least unlike a previous version, should not return a wrong value. - if args in cache: + if (args, hashable_kwargs) in cache: # Get item in cache - return cache[args] + LOG.debug("Get cached version of %s(%s, %s)", f.__name__, args, hashable_kwargs) + return cache[(args, hashable_kwargs)] if len(cache) + 1 > maxsize: # Drop oldest item in cache. cache.popitem(last=False) # Compute and store + LOG.debug("Computing %s(%s, %s)", f.__name__, args, hashable_kwargs) result = f(*args, **kwargs) - cache[args] = result + cache[(args, hashable_kwargs)] = result return result @@ -32,21 +46,4 @@ def decorated_f(*args, **kwargs): return decorator -# if __name__ == "__main__": -# import numpy as np -# from functools import lru_cache - -# # @lru_cache(maxsize=1) -# @delete_first_lru_cache(maxsize=1) -# def f(i): -# print(i) -# np.random.seed(i) -# a = np.random.random((20_000, 20_000)) -# print(a.nbytes / 2**30, "Go") -# return a - -# print(f(1)[0, 0]) -# print(f(1)[0, 0]) -# print(f(2)[0, 0]) -# print(f(1)[0, 0]) -# print(f(3)[0, 0]) +delete_first_lru_cache = lru_cache_with_strict_maxsize # For backward compatibility... diff --git a/docs/changelog.rst b/docs/changelog.rst index 5dfe10cd..0995c745 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -72,6 +72,8 @@ Internals * Fortran source files are not included in wheel anymore (:pull:`360`). +* The `delete_first_lru_cache` decorator has been renamed :func:`~capytaine.tools.lru_cache.lru_cache_with_strict_maxsize` and now supports keyword arguments in the memoized function (:pull:`442`). + * Improve some warnings and error messages. ------------------------------- diff --git a/pytest/test_bem_linear_combination_of_dofs.py b/pytest/test_bem_linear_combination_of_dofs.py index 0de7058c..77fffc1e 100644 --- a/pytest/test_bem_linear_combination_of_dofs.py +++ b/pytest/test_bem_linear_combination_of_dofs.py @@ -3,10 +3,11 @@ import numpy as np import capytaine as cpt -import pytest +import pytest solver = cpt.BEMSolver() -method = ['indirect','direct'] +method = ['indirect', 'direct'] + @pytest.mark.parametrize("method", method) def test_sum_of_dofs(method): @@ -27,7 +28,8 @@ def test_sum_of_dofs(method): body1_added_mass = dataset['added_mass'].sel(radiating_dof="body1__Heave", influenced_dof="body1__Heave").data body2_added_mass = dataset['added_mass'].sel(radiating_dof="body2__Heave", influenced_dof="body2__Heave").data - assert np.allclose(both_added_mass, body1_added_mass + body2_added_mass, rtol=1.1e-2) + assert np.allclose(both_added_mass, body1_added_mass + body2_added_mass, rtol=1e-2) + @pytest.mark.parametrize("method", method) def test_rotation_axis(method): @@ -57,4 +59,3 @@ def test_rotation_axis(method): A = dataset['added_mass'].sel(radiating_dof=["Yaw", "Sway"], influenced_dof=["Yaw", "Sway"]).data P = np.array([1, -l]) assert np.isclose(A_m, P.T @ A @ P) - diff --git a/pytest/test_bem_solver.py b/pytest/test_bem_solver.py index b8f5322b..9a8e5c96 100644 --- a/pytest/test_bem_solver.py +++ b/pytest/test_bem_solver.py @@ -140,4 +140,4 @@ def test_direct_solver(sphere): solver = cpt.BEMSolver() direct_result = solver.solve(problem, method='direct') indirect_result = solver.solve(problem, method='indirect') - assert direct_result.forces["Surge"] == pytest.approx(indirect_result.forces["Surge"], rel=1e-2) + assert direct_result.forces["Surge"] == pytest.approx(indirect_result.forces["Surge"], rel=1e-1) diff --git a/pytest/test_tool_lru_cache.py b/pytest/test_tool_lru_cache.py new file mode 100644 index 00000000..3173d4b5 --- /dev/null +++ b/pytest/test_tool_lru_cache.py @@ -0,0 +1,107 @@ +from functools import lru_cache + +import pytest +import numpy as np + +from capytaine.tools.lru_cache import lru_cache_with_strict_maxsize + + +def get_random_id(n): + # An impure function that shouldn't be memoized, but allows to test the cache. + return np.random.default_rng().integers(n) + +N = 100_000_000 + +def test_cache(): + f = lru_cache_with_strict_maxsize()(get_random_id) + a = f(N) + b = f(N) + assert a == b # randint was not run the second time, the cached value was used + + +def test_cache_maxsize(): + # 1/N chance of failing + f = lru_cache_with_strict_maxsize(maxsize=1)(get_random_id) + a = f(N) + f(10) + b = f(N) + assert a != b # the cached value of the first call had been deleted + + +def test_cache_kwargs(): + f = lru_cache_with_strict_maxsize()(get_random_id) + a = f(n=N) + b = f(n=N) + assert a == b # the cached value of the first call had been deleted + + +def test_delete_first(): + """This test is a convoluted way to test the main difference between the + built-in lru_cache and the lru_cache_with_strict_maxsize decorator defined + by Capytaine. The difference is that the latter make sure that objects in + the cache are deleted before computing and caching a new result. For + Capytaine, it is meant to limit the RAM usage. Here, it is tested by making + sure that there is never two instances of a Singleton class at the same + time.""" + + class Singleton: + nb_instances = [0] + # Use a one-element list as a mutable integer shared by all instances. + + def __init__(self): + if self.nb_instances[0] != 0: + raise ValueError("There can be only one Singleton at the same time!") + self.nb_instances[0] += 1 + + def __del__(self): + # When the singleton is deleted, update the counter + self.nb_instances[0] -= 1 + + assert Singleton.nb_instances[0] == 0 + a = Singleton() + assert Singleton.nb_instances[0] == 1 + del a + assert Singleton.nb_instances[0] == 0 + + ## NO CACHE + def new_instance(a): + return Singleton() + new_instance(1) + assert Singleton.nb_instances[0] == 0 # The Singleton created above is immediately garbage-collected. + + ## CAPYTAINE'S CACHE + @lru_cache_with_strict_maxsize(maxsize=1) + def new_instance(a): + return Singleton() + + new_instance(1) + assert Singleton.nb_instances[0] == 1 # It is not garbage collected only because it is still in the cache. + new_instance(2) # When we create a new instance, the old one is removed from the cache before creating the new one. + + del new_instance # Delete the cache before doing more tests. + + # STDLIB CACHE + @lru_cache(maxsize=1) + def new_instance(a): + return Singleton() + + new_instance(1) + with pytest.raises(ValueError): + new_instance(2) # lru_cache tries to create the new singleton, before deleting the old one from the cache. + + +# def benchmark_ram_usage(): +# # Would need a way to monitor the RAM usage to automate this test +# # The RAM usage should never be more than the ~3GB allocated by a single call +# +# # @delete_first_lru_cache(maxsize=1) +# @lru_cache(maxsize=1) +# def f(i): +# np.random.seed(i) +# a = np.random.random((20_000, 20_000)) +# print(f"Allocated {a.nbytes / 2**30:.2f} GB") +# return a +# +# for i in (1, 1, 2, 1, 3): +# print(f"Calling f({i})") +# f(i)