Why doesn't Python optimize away temporary variables? - python

Fowler's Extract Variable refactoring method, formerly Introduce Explaining Variable, says use a temporary variable to make code clearer for humans. The idea is to elucidate complex code by introducing an otherwise unneeded local variable, and naming that variable for exposition purposes. It also advocates this kind of explaining over comments.. Other languages optimize away temporary variables so there's no cost in time or space resources. Why doesn't Python do this?
In [3]: def multiple_of_six_fat(n):
...: multiple_of_two = n%2 == 0
...: multiple_of_three = n%3 == 0
...: return multiple_of_two and multiple_of_three
...:
In [4]: def multiple_of_six_lean(n):
...: return n%2 == 0 and n%3 == 0
...:
In [5]: import dis
In [6]: dis.dis(multiple_of_six_fat)
2 0 LOAD_FAST 0 (n)
3 LOAD_CONST 1 (2)
6 BINARY_MODULO
7 LOAD_CONST 2 (0)
10 COMPARE_OP 2 (==)
13 STORE_FAST 1 (multiple_of_two)
3 16 LOAD_FAST 0 (n)
19 LOAD_CONST 3 (3)
22 BINARY_MODULO
23 LOAD_CONST 2 (0)
26 COMPARE_OP 2 (==)
29 STORE_FAST 2 (multiple_of_three)
4 32 LOAD_FAST 1 (multiple_of_two)
35 JUMP_IF_FALSE_OR_POP 41
38 LOAD_FAST 2 (multiple_of_three)
>> 41 RETURN_VALUE
In [7]: dis.dis(multiple_of_six_lean)
2 0 LOAD_FAST 0 (n)
3 LOAD_CONST 1 (2)
6 BINARY_MODULO
7 LOAD_CONST 2 (0)
10 COMPARE_OP 2 (==)
13 JUMP_IF_FALSE_OR_POP 29
16 LOAD_FAST 0 (n)
19 LOAD_CONST 3 (3)
22 BINARY_MODULO
23 LOAD_CONST 2 (0)
26 COMPARE_OP 2 (==)
>> 29 RETURN_VALUE

Because Python is a highly dynamic language, and references can influence behaviour.
Compare the following, for example:
>>> id(object()) == id(object())
True
>>> ob1 = object()
>>> ob2 = object()
>>> id(ob1) == id(ob2)
False
Had Python 'optimised' the ob1 and ob2 variables away, behaviour would have changed.
Python object lifetime is governed by reference counts. Add weak references into the mix plus threading, and you'll see that optimising away variables (even local ones) can lead to undesirable behaviour changes.
Besides, in Python, removing those variables would hardly have changed anything from a performance perspective. The local namespace is already highly optimised (values are looked up by index in an array); if you are worried about the speed of dereferencing local variables, you are using the wrong programming language for that time critical section of your project.

Issue 2181 (optimize out local variables at end of function) has some interesting points:
It can make debugging harder since the symbols no longer
exist. Guido says only do it for -O.
Might break some usages of inspect or sys._getframe().
Changes the lifetime of objects. For example myfunc in the following example might fail after optimization because at the moment Python guarantees that the file object is not closed before the function exits. (bad style, but still)
def myfunc():
f = open('somewhere', 'r')
fd = f.fileno()
return os.fstat(fd)
cannot be rewritten as:
def bogus():
fd = open('somewhere', 'r').fileno()
# the file is auto-closed here and fd becomes invalid
return os.fstat(fd)
A core developer says that "it is unlikely to give any speedup in real-world code, I don't think we should add complexity to the compiler."

Related

Does nested if and if with multiple conditions require the same time complexity? [closed]

Closed. This question needs details or clarity. It is not currently accepting answers.
Want to improve this question? Add details and clarify the problem by editing this post.
Closed 6 months ago.
Improve this question
Which one takes more time to compile in python? This one?
if age > 30:
if height > 5:
print('perfect')
or this one?
if age > 30 and height > 5:
print('perfect')
Python 3.8.10 (default, Jun 22 2022, 20:18:18)
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> def x():
... if age > 30 and height > 5:
... print('perfect')
...
>>> def y():
... if age > 30:
... if height > 5:
... print('perfect')
...
>>> import dis
>>> dis.dis(x)
2 0 LOAD_GLOBAL 0 (age)
2 LOAD_CONST 1 (30)
4 COMPARE_OP 4 (>)
6 POP_JUMP_IF_FALSE 24
8 LOAD_GLOBAL 1 (height)
10 LOAD_CONST 2 (5)
12 COMPARE_OP 4 (>)
14 POP_JUMP_IF_FALSE 24
3 16 LOAD_GLOBAL 2 (print)
18 LOAD_CONST 3 ('perfect')
20 CALL_FUNCTION 1
22 POP_TOP
>> 24 LOAD_CONST 0 (None)
26 RETURN_VALUE
>>> dis.dis(y)
2 0 LOAD_GLOBAL 0 (age)
2 LOAD_CONST 1 (30)
4 COMPARE_OP 4 (>)
6 POP_JUMP_IF_FALSE 24
3 8 LOAD_GLOBAL 1 (height)
10 LOAD_CONST 2 (5)
12 COMPARE_OP 4 (>)
14 POP_JUMP_IF_FALSE 24
4 16 LOAD_GLOBAL 2 (print)
18 LOAD_CONST 3 ('perfect')
20 CALL_FUNCTION 1
22 POP_TOP
>> 24 LOAD_CONST 0 (None)
26 RETURN_VALUE
>>>
In my test, they produced identical compiled bytecode.
Boolean conditions are evaluated using short-circuit logic. Any performance difference between the two would be negligible, if any.
Both the conditions are equivalent even in the case of time complexity. I'm attaching another post and you may view that both cases are equivalent since at compile the instruction sets are same.
nested if vs. and condition
This is what basically happens when you compare stuff with and:
def _and(*args):
for arg in args:
if not arg: return arg
return arg
As you can see, it does the same as the nested ifs. Therefore, there wouldn't be much difference between the two.
I'm not an expert for python in any way, but based on my knowledge of compilers for C and C++, here is my answer.
When you write a logical condition, the compiler will try to load the code it thinks is the most likely to come up next, in order to run faster.
so if you write
if (age_of_bob > 200) {
foo()
} else {
bar()
}
since the guy named bob has very little chance of being over 200 years old, the compiler will try to preload the bar() code instead of foo(). (the example is trash but you get it)
Of course this doesn't always work, but compilers are smart and very often, they load the correct code in advance. (read about instruction pipelining and branchless programming)
so in your example, which is python and not C, the interpreter would have to make this kind of guess twice in the first example, and once in the second. Of course this would matter more if you had else clauses.
Now, this is only a guess as I'm not quite sure that the python interpreter does things like the gcc compiler, but if there is a difference, it could come from here. The only way to make sure, is to do a benchmark. Beware of testing only this section of the code and not the whole code in case you have other variables that may change the results. Run it 100000 times and check if there really is a difference.

Where did I go wrong in this time complexity calculation?

I have this function in Python:
digit_sum = 0
while number > 0:
digit_sum += (number % 10)
number = number // 10
For determining the time complexity, I applied the following logic:
Line 1: 1 basic operation (assignment), gets executed 1 time so gets a value of 1
Line 2: 2 basic operations (reading the variable 'number' and comparing against zero), gets executed n+1 times so gets a value of 2*(n+1)
Line 3: 4 basic operations (reading the variable 'number', %10, computing the sum, and assignment), gets executed n times so gets a value of 4*n
Line 4: 3 basic operations (reading the variable 'number', //10 and assignment), gets executed n times so gets a value of 3*n
This brings me to a total of 1 + 2n+2 + 4n + 3n = 9n+3
But my textbook has a solution of 8n+3. Where did I go wrong in my logic?
Thanks,
Alex
When talking about complexity all you really care about is asymptotic complexity. Here, O(n). The 8 or 9 or 42 doesn't really matter, especially as there is no way for you to know.
Thus counting "operations" is pointless. It exposes the architectural details of the underlying processor (be it an actual hw proc or an interpreter). The only way to actually get the "real" count of operations would be to have a look at a specific implementation (for instance, say CPython 3.6.0) and see the bytecode it generates from your program.
Here is what my CPython 2.7.12 does:
>>> def test(number):
... digit_sum = 0
... while number > 0:
... digit_sum += (number % 10)
... number = number // 10
...
>>> import dis
>>> dis.dis(test)
2 0 LOAD_CONST 1 (0)
3 STORE_FAST 1 (digit_sum)
3 6 SETUP_LOOP 40 (to 49)
>> 9 LOAD_FAST 0 (number)
12 LOAD_CONST 1 (0)
15 COMPARE_OP 4 (>)
18 POP_JUMP_IF_FALSE 48
4 21 LOAD_FAST 1 (digit_sum)
24 LOAD_FAST 0 (number)
27 LOAD_CONST 2 (10)
30 BINARY_MODULO
31 INPLACE_ADD
32 STORE_FAST 1 (digit_sum)
5 35 LOAD_FAST 0 (number)
38 LOAD_CONST 2 (10)
41 BINARY_FLOOR_DIVIDE
42 STORE_FAST 0 (number)
45 JUMP_ABSOLUTE 9
>> 48 POP_BLOCK
>> 49 LOAD_CONST 0 (None)
52 RETURN_VALUE
I let you draw your own conclusions as to what you want to actually count as a basic operation. Python interpreter interprets bytecodes one after the other, so arguably you have 15 "basic operations" inside your loop. That's the closest you can get to a meaningful number. Still, every operation in there has different runtimes so that 15 carries no valuable information.
Also, keep in mind this is specific to CPython 2.7.12. It's very likely another version will generate something else, taking advantage of new bytecodes that might make it possible to express some operations in a simpler way.

Is this code thread-safe in python 2.7?

Should I use atomic counter with Locking or can I use this?
def somefunc(someparam):
if someparam:
dic['key'] +=1
No, your code is not threadsafe, because using an += augmented assignment on a dictionary value takes 3 opcodes to execute:
>>> dis.dis(compile("dic['key'] += 1", '', 'exec'))
1 0 LOAD_NAME 0 (dic)
3 LOAD_CONST 0 ('key')
6 DUP_TOPX 2
9 BINARY_SUBSCR
10 LOAD_CONST 1 (1)
13 INPLACE_ADD
14 ROT_THREE
15 STORE_SUBSCR
16 LOAD_CONST 2 (None)
19 RETURN_VALUE
The opcode at position 9, BINARY_SUBSCR retrieves the current value from the dictionary. Anywhere between opcodes 9 and 15 (where STORE_SUBSCR puts the value back in), a thread-switch could take place and a different thread could have updated the dictionary.
Python's built-in structures are thread safe for single operations. The GIL (global interpreter lock) takes care of that. But it is mostly difficult to see where a statement becomes more operations.
Adding a lock will give you peace of mind:
def somefunc(someparam):
if someparam:
with lock:
dic['key'] +=1

what and how is the dissembler function used for in python?

I just ran across the dissembler function in python. But i couldn't make out what it means. Can anyone explain the working and use, based on the results of the factorial function (based on recursion and loop)
The recursive code and the corresponding dis code:
>>> def fact(n):
... if n==1:
... return 1
... return n*fact(n-1)
...
>>> dis.dis(fact)
2 0 LOAD_FAST 0 (n)
3 LOAD_CONST 1 (1)
6 COMPARE_OP 2 (==)
9 POP_JUMP_IF_FALSE 16
3 12 LOAD_CONST 1 (1)
15 RETURN_VALUE
4 >> 16 LOAD_FAST 0 (n)
19 LOAD_GLOBAL 0 (fact)
22 LOAD_FAST 0 (n)
25 LOAD_CONST 1 (1)
28 BINARY_SUBTRACT
29 CALL_FUNCTION 1
32 BINARY_MULTIPLY
33 RETURN_VALUE
And the factorial function using loop gives the following result:
def factor(n):
... f=1
... while n>1:
... f*=n
... n-=1
...
>>> dis.dis(factor)
2 0 LOAD_CONST 1 (1)
3 STORE_FAST 1 (f)
3 6 SETUP_LOOP 36 (to 45)
>> 9 LOAD_FAST 0 (n)
12 LOAD_CONST 1 (1)
15 COMPARE_OP 4 (>)
18 POP_JUMP_IF_FALSE 44
4 21 LOAD_FAST 1 (f)
24 LOAD_FAST 0 (n)
27 INPLACE_MULTIPLY
28 STORE_FAST 1 (f)
5 31 LOAD_FAST 0 (n)
34 LOAD_CONST 1 (1)
37 INPLACE_SUBTRACT
38 STORE_FAST 0 (n)
41 JUMP_ABSOLUTE 9
>> 44 POP_BLOCK
>> 45 LOAD_CONST 0 (None)
48 RETURN_VALUE
Can anyone tell me how to determine which one is faster?
To measure how fast something is running, use the timeit module, which comes with Python.
The dis module is used to get some idea of what the bytecode may look like; and its very specific to cpython.
One use of it is to see what, when and how storage is assigned for variables in a loop or method. However, this is a specialized module that is not normally used for efficiency calculations; use timeit to figure out how fast something is, and then dis to get an understanding of what is going on under the hood - to arrive at a possible why.
It's impossible to determine which one will be faster simply by looking at the bytecode; each VM has a different cost associated with each opcode and so runtimes can vary widely.
The dis.dis() function disassembles a function into its bytecode interpretation.
Timing
As stated by Ignacio, the pure length of the bytecode does not accurately represent the running time due to differences in how python interpreters actually run opcode and the timeit module would be what you want to use there.
Actual Purpose
There are several uses of this function, but they are not things that most people would end up doing. You can look at the output to help as part of the process of optimizing or debugging speed issues. It would also likely prove useful in working directly on the python interpreter, or writing your own. You can look at the documentation here to see a full list of the opcodes (though, just as that page will state, it's perfectly likely to change between versions of python).
Overall, this is not something you'd really use much in a production application (unless your application is a python disassembler!) but when you really, really need to optimize your code and debug at the lowest level, this is where the function would come in handy.

Is it better to save the length of a list that I use several time?

I know about inlining, and from what I checked it is not done by the Python's compiler.
My question is : is there any optimizations with the python's compiler which transforms :
print myList.__len__()
for i in range(0, myList.__len__()):
print i + myList.__len__()
to
l = myList.__len__()
print l
for i in range(0, l):
print i + l
So is it done by the compiler ?
If it is not : is it worth it to do it by myself ?
Bonus question (not so related) : I like to have a lot of functions (better for readability IMHO)... like there is no inlining in Python is this something to avoid (lots of functions) ?
No, there isn't. You can check what Python does by compiling the code to byte-code using the dis module:
>>> def test():
... print myList.__len__()
... for i in range(0, myList.__len__()):
... print i + myList.__len__()
...
>>> import dis
>>> dis.dis(test)
2 0 LOAD_GLOBAL 0 (myList)
3 LOAD_ATTR 1 (__len__)
6 CALL_FUNCTION 0
9 PRINT_ITEM
10 PRINT_NEWLINE
3 11 SETUP_LOOP 44 (to 58)
14 LOAD_GLOBAL 2 (range)
17 LOAD_CONST 1 (0)
20 LOAD_GLOBAL 0 (myList)
23 LOAD_ATTR 1 (__len__)
26 CALL_FUNCTION 0
29 CALL_FUNCTION 2
32 GET_ITER
>> 33 FOR_ITER 21 (to 57)
36 STORE_FAST 0 (i)
4 39 LOAD_FAST 0 (i)
42 LOAD_GLOBAL 0 (myList)
45 LOAD_ATTR 1 (__len__)
48 CALL_FUNCTION 0
51 BINARY_ADD
52 PRINT_ITEM
53 PRINT_NEWLINE
54 JUMP_ABSOLUTE 33
>> 57 POP_BLOCK
>> 58 LOAD_CONST 0 (None)
61 RETURN_VALUE
As you can see, the __len__ attribute is looked up and called each time.
Python cannot know what a given method will return between calls, the __len__ method is no exception. If python were to try to optimize that by assuming the value returned would be the same between calls, you'd run into countless different problems, and we haven't even tried to use multi-threading yet.
Note that you would be much better off using len(myList), and not call the __len__() hook directly:
print len(myList)
for i in xrange(len(myList):
print i + len(myList)
No, the optimization you're asking about is not done by the CPython compiler. In fact hardly any optimizations are done by the CPython compiler.
To see for yourself, import dis and disassemble a function with code like you're asking about: dis.dis(func).
The reason this isn't optimized is that it is entirely possible that an attribute (even a method like __len__) will be a completely different object the next time it is accessed. This rarely happens, of course, but Python supports it.
Attribute access does consume time, so storing a reference to an attribute you will be using repeatedly (especially in a local variable) can make your code run faster. However, it decreases readability, so I'd wait until you know that a given piece of code is a bottleneck before applying it. In your case, the time spent printing is easily going to overwhelm the attribute access.
In the final analysis, if performance were paramount you'd be using something other than Python in the first place, no?

Categories