Skip to content

Commit

Permalink
Fix issue with caching of matrices since capytaine#420 (capytaine#442)
Browse files Browse the repository at this point in the history
  • Loading branch information
mancellin authored Dec 14, 2023
1 parent 4cd198d commit a0e5730
Show file tree
Hide file tree
Showing 6 changed files with 141 additions and 34 deletions.
6 changes: 3 additions & 3 deletions capytaine/bem/engines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
49 changes: 23 additions & 26 deletions capytaine/tools/lru_cache.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,43 @@
# Copyright (C) 2017-2024 Matthieu Ancellin
# See LICENSE file at <https://github.com/capytaine/capytaine>
"""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

Expand All @@ -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...
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

-------------------------------
Expand Down
9 changes: 5 additions & 4 deletions pytest/test_bem_linear_combination_of_dofs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)

2 changes: 1 addition & 1 deletion pytest/test_bem_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
107 changes: 107 additions & 0 deletions pytest/test_tool_lru_cache.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit a0e5730

Please sign in to comment.