-1
\$\begingroup\$

I want to store a value in a dict (to behave like a cache) but I care about performances. I wonder if it is better to create a second variable in order to store the result of the sum compared to retrieving the value from the dict where the result is already stored.

Real code:

@property
def total_stats(self) -> Statistics:
    if self.items in self.__total_stat:
        return self.__total_stat[self.items]
    self.__total_stat[self.__total_stat] = \
        (res := sum((item.stats for item in self.items), Statistics()))
    return res

I use this function in order to sum the statistics for a list of items as a Statistics object, and I want to know what is the best option between a second assignment or get a value in a dict.

Sharing the rest of the class or anything else isn't necessary; the dict behaves like a cache without limits, and the sum creates a Statistics object, but that's not the point of the question.

\$\endgroup\$
  • \$\begingroup\$ While sharing the rest of the class isn't necessary indeed, it does help putting the code in context, and reviewers can never have too much context. \$\endgroup\$ – Mathieu Guindon Feb 17 at 17:23
  • \$\begingroup\$ Ok, I understand. I'll try to summarize the context correctly next time. Thanks for your help! \$\endgroup\$ – Dorian Turba Feb 19 at 9:02
6
\$\begingroup\$

might have gone a bit far with the microbenchmarking here, but I've stripped out the irrelevant parts of your above functions, giving:

def only_dict(d, i):
    d[i] = 0
    return d[i]

def without_walrus(d, i):
    r = 0
    d[i] = r
    return r

def with_walrus(d, i):
    d[i] = (r := 0)
    return r

i.e. just write the number zero into the dictionary instead of complicating things with also running sum(range(10)). note that as soon as your code is doing anything as complicated as sum(range(10)) (i.e. almost certainly) then the time of that will dominate and all of this doesn't matter

I've also written a special version which appears below as patched_walrus which is like with_walrus eliminates the store to r. it's similar to:

def patched_walrus(d, i):
    return (d[i] := 0)

AFAIK this can't be expressed in Python code, but the bytecode allows it and it was an interesting for me to include

it's important to reduce variance as much as possible, and because the code we're benchmarking is so small I'm using Timers directly as:

from timeit import Timer

functions = [only_dict, without_walrus, with_walrus, patched_walrus]
timers = [
    Timer('fn(d, 0)', 'fn = func; d = {}', globals=dict(func=fn))
    for fn in functions
]

out = [
    tuple(t.timeit() for t in timers) for _ in range(101)
]

I ignore the first few runs as these tend to be slower due to various things like warming up the cache, e.g. your first two runs are noticeably slower because of this. using Timer directly helps because it will compile the code once (rather than every time you call timeit and then the compiled code can remain hot in the cache.

next we can plot these in order:

idx vs walrus

which helps to see if your machine was busy as this can bias the results. I've drawn outliers as dots and connected the rest. the small plot on the right has KDEs of the non-outlier distributions.

we can see that:

  1. only_dict is about 10 nanoseconds per invocation faster, i.e. a tiny difference but we can reliably measure it now
  2. without_walrus and with_walrus are still basically the same
  3. my special patched_walrus is a measurable 2 nanoseconds faster, but so fiddly to create it's almost certainly not worth it. you'd be better writing a CPython extension module directly if you really care about performance
\$\endgroup\$
  • 3
    \$\begingroup\$ That's a cool plot. How'd you plot it? \$\endgroup\$ – Peilonrayz Feb 10 at 16:12
  • 3
    \$\begingroup\$ @Peilonrayz it's just matplotlib with the ticks seaborn style. the actual code is a bit of a mess, but i've posted it here in case it's useful \$\endgroup\$ – Sam Mason Feb 10 at 18:13
6
\$\begingroup\$

LRU pattern

The meaning of the function is to retrieve already computed value stored in a dict.With the LRU pattern, there is no need of dict, and everything in the @property. This code is ok if functools module is used with the:

def total_stats(self) -> Statistics:
    return sum((item.stats for item in self.items), Statistics())

Python ≥ 3.2 - functools.lru_cache:

@property
@lru_cache
def total_stats(self) -> Statistics:
    return sum((item.stats for item in self.items), Statistics())

Python ≥ 3.8 - functools.cached_property:

EDIT: In this context, it cannot be used! In the question specific case, the list of items can change without creating a Statistics object. Then, if the list change, the cached_property will remain and return an outdated Statistics object.

Transform a method of a class into a property whose value is computed once and then cached as a normal attribute for the life of the instance. Similar to property(), with the addition of caching. Useful for expensive computed properties of instances that are otherwise effectively immutable.

Example:

>>> from functools import cached_property
>>> class CustomType:
    def __init__(self):
        self._some_value = list(range(5))
    @cached_property
    def some_value(self):
        print('cache result')
        return self._some_value

>>> a = CustomType()
>>> a.some_value
cache result
[0, 1, 2, 3, 4]
>>> a._some_value = 0
>>> a.some_value
[0, 1, 2, 3, 4]
>>> a._some_value
0

With the cacheout module:

For other cache implementation.

  • FIFO (First-In, First-Out)
  • LIFO (Last-In, First-Out)
  • LRU (Least Recently Used)
  • MRU (Most Recently Used)
  • LFU (Least Frequently Used)
  • RR (Random Replacement)
\$\endgroup\$
  • \$\begingroup\$ didn't realise that was what you're trying to do, think I got distracted by your initial benchmark!. those decorators are certainly nicer to use if you can, but the checks in your question don't make it obvious that these would apply \$\endgroup\$ – Sam Mason Feb 11 at 10:56
  • \$\begingroup\$ I know, don't worry. That's why I ask this on meta : codereview.meta.stackexchange.com/questions/9442/… \$\endgroup\$ – Dorian Turba Feb 11 at 10:57
  • \$\begingroup\$ wow, that's a lot more effort than most people put into using this site! I really should have asked some clarifying comments first, and mostly wrote the answer after seeing what effect "optimal" bytecode would have \$\endgroup\$ – Sam Mason Feb 11 at 11:18
  • \$\begingroup\$ I respect people spending time helping me, that's all :) \$\endgroup\$ – Dorian Turba Feb 11 at 12:12
2
\$\begingroup\$

No significant difference

With this code, I test 10*100_000 each functions. One with a single dict (only_dict()), one with a second variable (without_walrus()) and a third with a walrus operator in order to be more pythonic in python-3.8 (with_walrus())

Benchmarck

from timeit import timeit


def only_dict(d, i):
    d[i] = sum(range(10))
    return d[i]

def without_walrus(d, i):
    r = sum(range(10))
    d[i] = r
    return r

def with_walrus(d, i):
    d[i] = (r := sum(range(10)))
    return r

if __name__ == '__main__':
    print('only_di', 'without', 'with_wa')
    for _ in range(10):
        t1 = timeit('only_dict(d, 10)',
                    setup='from __main__ import only_dict; d = dict()',
                    number=100_000)
        t2 = timeit('without_walrus(d, 10)',
                    setup='from __main__ import without_walrus; d = dict()',
                    number=100_000)
        t3 = timeit('with_walrus(d, 10)',
                    setup='from __main__ import with_walrus; d = dict()',
                    number=100_000)
        print(f'{t1:.5f}', f'{t2:.5f}', f'{t3:.5f}')

Results

only_di without with_wa
0.05248 0.05062 0.05023
0.05517 0.05389 0.04902
0.04654 0.04587 0.05096
0.04846 0.04607 0.04593
0.04765 0.04722 0.04789
0.04833 0.04839 0.04797
0.04914 0.04691 0.04620
0.04725 0.04710 0.04495
0.04652 0.04494 0.04728
0.05279 0.05144 0.05151
\$\endgroup\$
  • 1
    \$\begingroup\$ note that those measurements indicate no significant difference between those functions. i.e. most of the time is spent executing sum(range(10)). I'd therefore suggest doing whichever is easiest given the rest of the code. personally I like without_walrus the most, but with_walrus compiles to slightly nicer bytecode \$\endgroup\$ – Sam Mason Feb 7 at 17:05

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.