5

So this page about memoization got me curious. I ran my own benchmarks.

1) Mutable default dictionary:

%%timeit
def fibo(n, dic={}) :
    if n not in dic :
        if n in (0,1) :
            dic[n] = 1
        else :
            dic[n] = fibo(n-1)+fibo(n-2)
    return dic[ n ]
fibo(30)

Out:

100000 loops, best of 3: 18.3 µs per loop

2) Same idea, but following the principle “Easier to ask forgiveness than permission”:

In [21]:

%%timeit
def fibo(n, dic={}) :
    try :
        return dic[n]
    except :
        if n in (0,1) :
            dic[n] = 1
        else :
            dic[n] = fibo(n-1)+fibo(n-2)
        return dic[ n ]
fibo(30)

Out:

10000 loops, best of 3: 46.8 µs per loop

My questions

  • Why is 2) so slow compared to 1)?

Edit

As @kevin suggest in the comments, I got the decorator completely wrong so I removed it. The remainder is still valid! (I hope)

Community
  • 1
  • 1
usual me
  • 8,338
  • 10
  • 52
  • 95
  • 1
    That's a very unusual decorator. Usually when you decorate a function, somewhere in the decorator, you'll end up calling that function after doing some work. But your decorator never calls `f`. When you decorate `fibo` you're effectively doing "throw away the definition of this function, and replace it with the function from example 1" – Kevin Aug 25 '14 at 12:19
  • 5
    Catching exceptions (which means stack trace) can be very expensive: https://docs.python.org/2/faq/design.html#how-fast-are-exceptions – Dmitry Bychenko Aug 25 '14 at 12:23
  • @DmitryBychenko You should post that as an answer, I think. – Sylvain Leroux Aug 25 '14 at 12:44
  • Where does the principle "Easier to ask forgiveness than permission" comes from? :0 In the programming context it sounds rather unreasonable. – BartoszKP Aug 25 '14 at 12:59
  • @BartoszKP It's a fairly common principle in Python, generally used to dissuade people used to statically typed languages from doing excessive type checking before using variables. In reality, it only holds if a) the code isn't in a performance-sensitive area or b) You expect that most of the time, you're not going to need to ask for forgiveness :) – dano Aug 25 '14 at 14:33
  • @dano In case of type checking there is no forgiveness at all, from what I've seen. `TypeError` or `NameError` usually just collapse the application and that's it :-) – BartoszKP Aug 25 '14 at 15:22
  • 1
    @BartoszKP This is true. And most of the time, the user will probably give you the right thing. Which is why it's better to assume they gave you the right thing, and just handle the exception if its wrong. That way you avoid the overhead of a `hasattr` or `if key in d:`. The other area this principle is frequently used is in checking for the existence of keys/attributes prior to trying to use them, which is (sometimes) more likely to actually be forgiven. – dano Aug 25 '14 at 15:46

2 Answers2

7

Catching exception means stack tracing which can be very expensive:

https://docs.python.org/2/faq/design.html#how-fast-are-exceptions

Exceptions are very efficient in two cases:

  1. try ... finally
  2. try ... except, providing that no exception is thrown

However, when exception occured and caught the required stack tracing adds great overhead.

Dmitry Bychenko
  • 180,369
  • 20
  • 160
  • 215
0

Approach one does three lookups in total (n not in dic:, insertion dic[n] = and return dic[n]) . The second approach does also three lookups in the worst case situation (retrieval attempt dic[n] = , insertion dic[n] = and return dic[n]), and additionally involves exception handling.

If one approach does the same work as the other one, and adds something to it, it obviously can't be faster, and most probably will be slower.

Consider comparing the efficiency in a scenario when the memoization has more occasions to be useful - i.e. run the function multiple times, to compare amortized complexities. This way the worst case situation for the second approach will happen less often, and you could gain something from one lookup less.

Version1:

def fibo(n, dic={}) :
    if n not in dic :
        if n in (0,1) :
            dic[n] = 1
        else :
            dic[n] = fibo(n-1)+fibo(n-2)
    return dic[ n ]

for i in range(10000):
    fibo(i)

Version2:

def fibo(n, dic={}) :
    try :
        return dic[n]
    except :
        if n in (0,1) :
            dic[n] = 1
        else :
            dic[n] = fibo(n-1)+fibo(n-2)
        return dic[ n ]

for i in range(10000):
    fibo(i)

And the test:

C:\Users\Bartek\Documents\Python>python -m timeit -- "import version1"
1000000 loops, best of 3: 1.64 usec per loop

C:\Users\Bartek\Documents\Python>python -m timeit -- "import version2"
1000000 loops, best of 3: 1.6 usec per loop

When the function is used more often, the cache is filled with more values, which in turn lowers the chances for exception.

Community
  • 1
  • 1
BartoszKP
  • 34,786
  • 15
  • 102
  • 130