Reputation: 31354
There are many explanations of the difference between positional and keyword arguments for Python functions on StackOverflow and the difference in utility is clear to me. However, I was unable to find a clear answer (and perhaps explanation of) whether positional arguments are substantially faster that keyword arguments in some situations.
On the one hand, I can imagine they would be, as there has to be no work done to look up the arguments passed. But on the other hand, unless the arguments are passed as a dict
spread into the **kwargs
, or something like that, I imagine the compiler may just optimise this problem away and the bytecode might perform nearly identical for base cases.
So:
from timeit import timeit
def fun1(a, b, c):
pass # or some operation that wouldn't get this function optimised out altogether
def fun2(a, /, b, c=3):
pass # or some operation that wouldn't get this function optimised out altogether
fun1(1, 2, 3) # call 1
fun2(1, 2, 3) # call 2
fun2(1, 2, c=3) # call 3
fun2(1, b=2, c=3) # call 4
d = {'c': 3}
fun2(1, 2, *d) # call 5
My question is: which ones of these calls are slower than the others and why? And am I missing a key example that would focus on the worst performance hit here?
I would assume call 5 is clearly worst, as Python needs to assess at runtime what parameters have been passed. But it depends on optimisation whether calls 4 and 3 are dealt with more efficiently. And for call 1 to be faster than call 2, there would have to be little or no optimisation at all.
I appreciate that the answer may depend on the specific Python compiler / VM implementation, so I'm asking in particular about the default Python interpreter that most people will be using.
Of course one could just write a script to measure the difference, but I feel that's going to get muddied real quick by whatever the function does and perhaps some local circumstances, so I was hoping someone would read this question and actually know why there would be a difference once way or the other.
I did run a timer test and it confirmed my suspicions:
print(timeit(lambda: fun1(1, 2, 3), number=int(1e8))) # call 1
print(timeit(lambda: fun2(1, 2, 3), number=int(1e8))) # call 2
print(timeit(lambda: fun2(1, 2, c=3), number=int(1e8))) # call 3
print(timeit(lambda: fun2(1, b=2, c=3), number=int(1e8))) # call 4
d = {'c': 3}
print(timeit(lambda: fun2(1, 2, *d), number=int(1e8))) # call 5
7.7741496
8.472946400000001
9.2335016
9.755567000000003
18.6909664
(Note that I did additional runs randomising the order in which these functions are called and the relative results remain more or less the same)
My own reasoning: since Python would have to do a lot of analysis to know how a function is called throughout the program being run, and some calls may only execute conditionally, it can't really optimise for how the function is called. So, keyword arguments always incur a penalty and using keyword arguments with their keyword adds a penalty.
As pointed out in the comments: if you care enough about performance for this to really matter, you may want to reconsider picking Python on the standard VM as a platform. And obsessing about this type of performance issue tends to be premature regardless. The question however is not so much "how do I optimise", or "should I optimise", but "why is there actually a performance difference"?
Thanks for any comments or explanations in an answer.
Upvotes: 7
Views: 945
Reputation: 51
from dis import dis
def add(a,b): return a + b
call it with postional arguments
dis("add(1,2)")
1 0 LOAD_NAME 0 (add)
2 LOAD_CONST 0 (1)
4 LOAD_CONST 1 (2)
6 CALL_FUNCTION 2
8 RETURN_VALUE
call it with keyword arguments
dis("add(1, b=2)")
1 0 LOAD_NAME 0 (add)
2 LOAD_CONST 0 (1)
4 LOAD_CONST 1 (2)
6 LOAD_CONST 2 (('b',))
8 CALL_FUNCTION_KW 2
10 RETURN_VALUE
As you can see here how bytecode structions show, if you call a function with keyword arguments, pvm has to do extra work.
what CALL_FUNCTION_KW
does:
The creation of these data structure brings extra overhead
and in the case of
d = {'c': 3}
print(timeit(lambda: fun2(1, 2, *d), number=int(1e8))) # call 5
*d
would call d.__iter__
and d.__getitem__
so these overhead adds up.
Upvotes: 1