New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[MRG] Caching and small run preparation improvements #832
Conversation
By design, these objects are mutable, therefore we can use them e.g. as keys in dictionaries. This will be useful for caching.
Now the cache uses the actual object identity (we normally pass references to the objects around) for Variables, except for Constant objects (which will be re-generated for each run)
This avoids having a transformation string->string that then has to be eval'ed
Closes #124
…ect` might be reused due to caching, but e.g. the size of a dynamic variable might have changed)
Hah, the latest mail on the mailing list made me realize there's actually a problem with my current implementation that we need to fix first: all the caches hold strong references to the |
This is the sort of change that needs careful thought and testing, so let's not rush but... great work! It will be fantastic to have this in Brian. I would argue that there is a benefit to cacheing to disk (e.g. people who run a script multiple times for parameter search rather than doing multiple run calls from within a single run of a script), and we can potentially make this safe. In the key for the (on-disk) dictionary, we can include Brian's version number (or better a hash of the source code if this is reasonably quick to compute once at the beginning). Another alternative is to include in the install script something that deletes the cache. There'll be some users who could shoot themselves in the foot, i.e. those who run from git and have their python path point directly to the git directory rather than installing. But that's probably only developers! The other really huge benefit is to standalone mode. There are a few things we could do to improve this. For example, if we detect that the code we are about to write to a file is the same as the code already in that file, we can just choose not to write to it. Like that the compiler knows that the file is already compiled and doesn't have to recompile it. Hmm, not sure how much benefit we get from that though given that the Last (techical) point. For |
Oh, and it might take me a bit of time to get around to testing and reviewing this because it's a biggie, but it's also important so will try to get to it as soon as possible. |
Yes, agreed! I will probably also add a few more tests with potential problems in mind (changing stuff between runs). Apart from that, there's the memory problem and apparently the test server failed for Python 3 and standalone, so there's still some work to do ;-)
I have to say I am quite hesitant to add on-disk-caching for now. This is really a non-trivial problem to get right:
Standalone is also a whole different beast, for stuff like parameter explorations I think there it would make sense to have a more explicit system that does not rely on automatic caching. Something like the parameter exploration stuff we discussed previously (run an already compiled simulation but inject different parameters). How about first getting the memory-based caching right and giving it some exposure? We can then tackle the disk-based cache as a new issue.
Dan-of-the-past already implemented this ~3 years ago 😄 https://github.com/brian-team/brian2/blob/master/brian2/devices/cpp_standalone/device.py#L112
Possibly. I wonder whether adding new functions would not be something reasonable to allow. If it is done at the beginning of a script, that should not lead to any problem (assuming only in-memory caching, of course). Another easy fix would be to simply use the |
This probably shouldn't matter though, right? In this case, certainly a version number for sympy would do the trick.
Agreed that we don't want to get into that! Is it definitely necessary though since those libraries won't use the cacheing mechanism by default? Or maybe they will because they'll be called by Brian which will do cacheing. Another idea. We make the cacheing to disk off by default and you need to explicitly enable it, either with a preference or maybe (even safer) with something in the code (e.g.
Yes, but if we could - at relatively low effort - get an intermediate solution that improves it a bit that would still be good because the compilation times for standalone can be pretty long and negates a lot of the benefit.
I'm getting to be like one of those old men who keep telling the same stories as if you'd never heard them before. In any case, apparently that alone isn't enough to stop recompilation. I might think about that idea for optimising the header files to save on recompilations.
Yeah that's probably good, although you don't necessarily want to have to hash that dictionary every time you access the cache? |
Sure, that was just to point out that there are many potential pitfalls that are not obvious at first sight.
Yes, the way it is currently implemented, a
Something like this might be a good solution (or a preference or something like that). However, I'd still prefer to finish/merge the memory-based caching before going the next step. I also wonder whether a proper explicit
Right, that's actually a bug. It should not recompile if you run exactly the same script twice (assuming that you used
I was worried about this for the |
That seems reasonable. Maybe let's not close #124 when this is merged then?
Indeed! That would be even better.
OK, will open a separate issue for that. Yeah, maybe Let me know when you've got this to a state where it's mergeable and I'll do a more detailed review? |
Sure. It will be closed automatically due to my issue comment, but we can of course reopen it.
Will do! |
…es to `ArrayVariable` objects
Otherwise, a cached `CodeObject` will keep all the variables it refers to alive.
Otherwise, a cached `CodeObject` will keep all the referenced variables alive
…hem from being garbage collected
… adding them to the namespace
… -- wrap them to make it possible.
…mpatible with Python 3)
I reverted the caching of |
…anslated from sympy (e.g. "Abs" -> "abs")
# Conflicts: # brian2/tests/test_network.py
I haven't forgotten! I promise. |
I'm going to start reviewing this now. Might take a few days for me to get it done. Also, I'm planning to ask a few more questions about particular lines of code than I normally would so as to be sure that everything is safe. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks all good. My main concern is still the creation of the keys for cacheing. I've put a few suggestions for how this might be automated to make it safer that we can discuss. There's also a couple of questions about particular code changes. All in all, I think this is pretty close to ready to go. All the tests (long and standalone) pass on my machine too.
brian2/codegen/translation.py
Outdated
cache_key = (code, _hashable(variables), dtype, optimise, blockname) | ||
if cache_key in _make_statements_cache: | ||
return _make_statements_cache[cache_key] | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Idea for improvement. What about making this into a decorator that puts each argument through _hashable
? Like that, if we ever change the signature of this function it automatically ensures that the cache is updated too. Also, might save on copy/pasted code elsewhere too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I did this (I'll push the commit in a minute) -- we still have to be careful whenever we add arguments and think about how they relate to the caching, but I think the most common issue that we could have is that the cache no longer works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah that's ideal if we make a change and it causes an error rather than silently making a mistake.
brian2/core/variables.py
Outdated
@@ -274,6 +275,9 @@ def __repr__(self): | |||
constant=repr(self.constant), | |||
read_only=repr(self.read_only)) | |||
|
|||
_state_tuple = property(lambda self: (self.dim, self.dtype, self.scalar, | |||
self.constant, self.read_only, | |||
self.dynamic, self.name)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar comment to above, is there any way we can make this more safe against our own future changes by automating the definition of _state_tuple
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we can automatize the _state_tuple
here: it's very point is to make the distinction between attributes that matter for cacheing and those that don't. E.g., for arrays all describing attributes (dtype
, dim
, etc.) matter, but value
does not. For a Constant
, on the other hand, the value
matters (because it might be used with its value in the generated code).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mention this a bit later, but what about a system where all attributes are assumed to matter unless we explicitly exclude them? Like that, if we change the class in the future we are forced to make a decision about the new attributes. I feel like this isn't a lot more work and minimises the chance of silent errors in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, sorry, overlooked this comment... Hmm, I guess we could use __dict__
to get all attributes and do something along these lines. I'll give it a go!
for name, value in code_object.variables.iteritems(): | ||
if isinstance(value, np.ndarray): | ||
self.static_arrays[name] = value | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the idea here that previously we looked for static arrays, but since the only way they can be accessed is by a function that we can just insert function namespaces instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More or less: it is part of a fundamental change how function namespaces (most important use case is TimedArray
) where added. Previously, we added them to the original variables
dictionary at some point. This screwed up caching, because the variables
were used as part of the cache key earlier and therefore should not change. For this specific bit of code we therefore no longer search for those arrays as part of the variables
dictionary, but explicitly add them from the Function
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK!
|
||
# : Set of identifiers in the code string | ||
self.identifiers = get_identifiers(code) | ||
|
||
code = property(lambda self: self._code, | ||
doc='The code string') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason for changing code from an attribute to a property?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's just to make the immutability of the CodeString
very clear: code
is a read-only attribute and should never be changed. We use this idiom in a few places and it seemed to make sense to use here as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK!
brian2/equations/equations.py
Outdated
self.expr, tuple(self.flags)), | ||
doc='A tuple representing the full state of this ' | ||
'object, used for hashing and equality ' | ||
'testing.') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment again. Here we could automate the creation of _state_tuple
from the arguments passed to __init__
, so either put a decorator there or have it derive from some class that automates the creation of the _state_tuple
. This solution could probably also be used for Variable
actually.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned above, I don't think we can automate this in a straightforward way (we could have a function where we define which attributes should be considered for caching and which not, but then I don't think this would make things clearer).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As above - it's not about making it clearer it's about protecting ourselves from our future selves. I think this is particularly important for this cacheing stuff because it would be so easy to make a mistake here that we have to go to some efforts to avoid doing so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see your point, but any change to those core classes has to be done very carefully -- there are a lot of other possibilities to break this apart from the _state_tuple
. I don't see a straightforward way to make this specific thing safer with regard to future changes, IMO having extensive tests is the most important...
elif op_name == 'Not': | ||
return sympy.Not(self.render_node(node.operand)) | ||
else: | ||
raise ValueError('Unknown unary operator: ' + op_name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is all rewritten to avoid going to a string and then turning them into a sympy object, right? That seems sensible, but just wondering why it was necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, before we took a string, parsed it's AST, created a string from it and then eval'ed it which again parsed its AST and created the sympy objects. Since this stuff is called a lot of times (less with the caching now, though), it made a measurable time difference. It also feels more logical to do it this way instead of making the string roundtrip. Oh, and I think it might even prevent some loss of precision due to converting floats to strings and back, but not sure that was actually an issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great!
brian2/parsing/sympytools.py
Outdated
@@ -148,7 +141,11 @@ def sympy_to_str(sympy_expr): | |||
str_expr : str | |||
A string representing the sympy expression. | |||
''' | |||
|
|||
if sympy_expr in _sympy_to_str_cache: | |||
return _sympy_to_str_cache[sympy_expr] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How safe is this in cases of changes in version of sympy?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what you mean -- this is still all about caching in memory, so I don't see how version changes can come into play.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah good point!
brian2/stateupdaters/base.py
Outdated
cache_key = (equations, _hashable(variables), method_key) | ||
if cache_key in StateUpdateMethod._state_update_cache: | ||
return StateUpdateMethod._state_update_cache[cache_key] | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like another case where wrapping _init__
or the class could work to automate this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep, see my upcoming commit.
brian2/utils/caching.py
Outdated
import collections | ||
|
||
|
||
class _CacheStatistics(object): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we use this or just there for potential future performance/debugging?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I used this just for seeing that the caching actually worked. We could potentially use this in tests I guess?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep - fine to leave it there anyway. We might find a use for it one way or the other.
That's all looking great - I would also have the system we mentioned for marking some variables/attributes as not part of the cache for |
The latest Sphinx version seems to be incompatible with sphinxcontrib-issuetracker
brian2/core/variables.py
Outdated
@@ -165,6 +164,16 @@ def __init__(self, name, dimensions=DIMENSIONLESS, owner=None, dtype=None, | |||
#: Whether the variable is an array | |||
self.array = array | |||
|
|||
#: Mark the list of attributes that should not be considered for caching | |||
#: of state update code, etc. | |||
self._cache_irrelevant_attributes = ['owner'] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could be a class attribute maybe? Not sure if it's worth doing but maybe slightly more logical.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed! It would even simplify the code a bit, since it wouldn't appear in __dict__
itself!
brian2/core/variables.py
Outdated
@property | ||
def _state_tuple(self): | ||
return tuple(value for key, value in self.__dict__.iteritems() | ||
if key not in (self._cache_irrelevant_attributes + |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to sort the items in a dict or is the order deterministic?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It shouldn't matter here since the order is arbitrary but deterministic, but I'm not 100% sure there are no corner cases. So, using sorted
sounds reasonable.
brian2/equations/equations.py
Outdated
hashing and equality testing.''' | ||
return tuple(value for key, value in self.__dict__.iteritems() | ||
if key not in (self._cache_irrelevant_attributes + | ||
['_cache_irrelevant_attributes'])) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could potentially be refactored since it's the same code as before?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered it, but given that the function is almost trivial, I'm not sure it's worth introducing a common super-class for SingleEquation
and Variable
given that the classes don't have much in common... Hmm, maybe something like CacheKey
could make sense, I'll have a look.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I was thinking a sort of mixin class.
I like the new changes, this is feeling much safer and cleaner to me. Hope you didn't find that too tedious to do. I made a couple of non-essential suggestions. Let me know when you're ready and I can do a final review. |
Ok, moved stuff into a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code looks great and all tests passing on my machine. Good to go! Hurray! Sorry for the epic delay in getting around to this.
No worries, it was a quite substantial change and I think the code improved quite a bit during the discussion. |
Sure! |
I got around to work a bit on the caching stuff we have been discussing for a long time (in particular, see #124), to avoid the overhead of repeated preparations when doing multiple runs.
For somewhat extreme cases (running a short simulation for a HH model in a loop), this PR has a great impact on performance (e.g. running a 20ms simulation of 100 HH neurons took ~2s instead of ~9s). Simulations with a single run are only affected in a minor way, and of course all this is only relevant for the preparation period, so it only matters when the run time for your actual simulation is short.
Contrary to the title of #124, I did not use joblib to do the caching, because I did not really see what this would buy us. The caching is only done in memory with simple dictionaries, I did not try to implement caching on disk (for which of course joblib would be helpful). The main reason is that I am seeing a lot of potential problems (e.g., we fix a bug in a state updater or in the code generation, and users still access an old cached version) and not much benefit.
Caching is done on multiple levels. The lowest levels are helpful even for single runs:
sympy_to_str
andstr_to_sympy
cache their results -- the mapping from one to the other should be fixed, I only see one hypothetical problem: if the user changes a function inDEFAULT_FUNCTIONS
so that it maps to another sympy name, this would not be taken into account. But I don't see why a user would do this in the first place, and even less so between two runs. I also made some minor tweaks to the two functions, making them slightly faster for non-cached calls.Equations.get_substituted_expressions
is cached. This is called several times during the preparation period and sinceEquations
are immutable by design, caching is safe here.Then there is caching on the abstract code level:
apply_stateupdater
, which also means that it remembers which method was chosen if none was specified explicitly. For complex models and/or integration methods, this part is where most of the time was spent during run preparation, so this has a big influence on the run time for repeated runs. To make this work, I had to makeCodeString
,SingleEquation
, andEquations
properly comparable/hashable, so that they compare equal when their content is equal (it probably would have worked with a simpler identity-based comparison as well, since we don't recreate these objects normally, but well).make_statements
). Since the time when we introduced optimizations this is a very costly operation as well.Finally, there is caching on the target-specific code generation level: if a
CodeObject
is created for the same code, same variables, same template, same target, etc., then it is re-used instead of re-created. Re-creating these objects was also taking a significant time. However, this caching was a bit trickier to get right, since previously the transformation from the variable dictionary to the namespace that was used for theCodeObject
was done during object initialization. We have to do this for every run, though, because things like e.g. thenum_...
variables for synapses have to be updated in case they changed between runs. This resulted in the changes you see in the last commit.I tried to err on the side of caution, and much of this relies on they way how
Variable
objects are compared/hashed. Standard variables (arrays) etc. are compared by identity -- if the object is the same, it is considered the same from the code generation point of view (of course the values it refers to may have changed, but our generated code only references them). The same is true for user-defined functions, however there it comes with a very minor disadvantage: user-defined Python-only functions (i.e., just defined withcheck_units
) will create a newFunction
object every time, so code referring to them will not benefit from caching. I don't think that is a problem that needs fixing, though. Finally,Constant
objects are compared by their actual value -- we re-create them at every run and codegen might have hard-coded them into the code, so we want to make sure to not hide changes in constant values by caching, while not making our caching completely useless at the same time :)That's it, it seems to be working fine as far as I can tell, so good to merge from my side.