为什么我的“爆炸” Python 代码反而运行得更快?
我在一门入门的计算机科学课上(之前已经做了几年网页编程),对我的一行代码到底能提高多少速度产生了好奇。
for line in lines:
numbers.append(eval(line.strip().split()[0]))
于是我写了同样的代码,但用了很明确的变量赋值,然后把它们放在一起比较。
for line in lines:
a = line.split()
b = a[0]
c = b.strip()
d = eval(c)
numbers.append(d)
结果第二种写法的运行速度稳定快了30毫秒(在我的FreeBSD shell账户上;见编辑#2),输入文件有10万行!当然,这个总运行时间是3秒,所以这个百分比并不大……但我真的很惊讶,看到那些明确的变量赋值居然有帮助。
最近有一个关于函数性能和内联代码性能的讨论,但这看起来更基础。到底是怎么回事?我是不是应该写得详细一点,告诉那些嘲笑我的同事这是为了性能考虑?(幸运的是,列表推导的版本运行得更快,大约快10毫秒,所以我心爱的简洁性并没有完全失去。)
编辑:感谢大家指出我代码扩展得不够好的问题。你们都说得对,第二种写法应该是:
for line in lines:
a = line.strip()
b = a.split()
c = b[0]
d = eval(c)
numbers.append(d)
不过,即使我修正了这个问题,我的运行时间分别是2.714秒、2.652秒和2.624秒,分别对应一行代码、完全展开的形式和列表推导(未显示)。所以我的问题依然存在!
编辑#2:有趣的是,似乎连一群知识渊博的人都觉得这个答案不明显,这让我对这个问题感到好一些!我可能会在类似情况下自己再研究一下,看看会有什么发现。如果你们想继续讨论这个话题,当然可以,但我决定把我的结论定为“嗯,这很有趣;一定有什么深层次的原因。”特别是因为正如steveha指出的那样,这种表现并不是在所有机器上都一致,我在Debian和Windows上的结果正好相反。感谢所有参与讨论的人!
8 个回答
老实说,第一种版本把所有东西都放在一行里,真的是看得让人头疼。
第二种版本可能有点啰嗦(中间的那种会更好),但绝对比第一种好。
我觉得没必要太在意微小的优化,因为Python的内部机制,还是应该专注于写出可读性强的代码。
顺便提一下,这两个(最初的)版本其实做的事情不一样。
在第一个版本里,你是先去掉空格,然后再分割;而在第二个版本里,你是先分割,再去掉空格(而且只对第一个元素去掉空格)。
我觉得你可能忽略了这一点,因为第一个版本真的很难集中注意力去看。
接着,分析这两个(更新后的)版本时用到了dis
(Python反汇编工具),结果显示这两段代码之间没有实质性的差别,唯一不同的就是函数名称查找的顺序。这可能会对性能有影响。
既然我们提到这个,你可以通过在循环之前把eval绑定到一个局部变量上来获得一些性能提升。我预计在做了这个改动后,这两个版本的执行时间应该没有区别。
比如:
eval_ = eval
for line in lines:
a = line.strip()
b = a.split()
c = b[0]
d = eval_(c)
numbers.append(d)
我们主要讨论的是微小的优化,但这种别名技术在某些情况下其实是非常有用的。
你的代码执行的顺序不一样。紧凑版的执行顺序是:
A > B > C > D > E
而你展开的版本执行顺序是:
B > C > A > D > E
这样一来,strip()这个操作被推迟了两步,这可能会影响性能,具体取决于输入是什么。
我没有进行过性能测试,但时间差异的一个原因是第二个函数需要进行多次变量查找。
这是因为查找局部变量的速度比查找全局变量或内置变量要快得多:Python的“编译器”会优化大部分函数的内容,对于局部变量来说,不需要查找字典,而只需简单的数组索引操作就可以了。
所以,局部变量的查找确实是有成本的。我们来看看这些函数的反汇编结果:
首先,确保我定义的函数和你的一样:
>>> def a(lines):
for line in lines:
numbers.append(eval(line.strip().split()[0]))
>>> def b(lines):
for line in lines:
a = line.strip()
b = a.split()
c = b[0]
d = eval(c)
numbers.append(d)
现在,让我们比较它们的反汇编值:
>>> import dis
>>> dis.dis(a)
2 0 SETUP_LOOP 49 (to 52)
3 LOAD_FAST 0 (lines)
6 GET_ITER
>> 7 FOR_ITER 41 (to 51)
10 STORE_FAST 1 (line)
3 13 LOAD_GLOBAL 0 (numbers)
16 LOAD_ATTR 1 (append)
19 LOAD_GLOBAL 2 (eval)
22 LOAD_FAST 1 (line)
25 LOAD_ATTR 3 (strip)
28 CALL_FUNCTION 0
31 LOAD_ATTR 4 (split)
34 CALL_FUNCTION 0
37 LOAD_CONST 1 (0)
40 BINARY_SUBSCR
41 CALL_FUNCTION 1
44 CALL_FUNCTION 1
47 POP_TOP
48 JUMP_ABSOLUTE 7
>> 51 POP_BLOCK
>> 52 LOAD_CONST 0 (None)
55 RETURN_VALUE
>>> dis.dis(b)
2 0 SETUP_LOOP 73 (to 76)
3 LOAD_FAST 0 (lines)
6 GET_ITER
>> 7 FOR_ITER 65 (to 75)
10 STORE_FAST 1 (line)
3 13 LOAD_FAST 1 (line)
16 LOAD_ATTR 0 (strip)
19 CALL_FUNCTION 0
22 STORE_FAST 2 (a)
4 25 LOAD_FAST 2 (a)
28 LOAD_ATTR 1 (split)
31 CALL_FUNCTION 0
34 STORE_FAST 3 (b)
5 37 LOAD_FAST 3 (b)
40 LOAD_CONST 1 (0)
43 BINARY_SUBSCR
44 STORE_FAST 4 (c)
6 47 LOAD_GLOBAL 2 (eval)
50 LOAD_FAST 4 (c)
53 CALL_FUNCTION 1
56 STORE_FAST 5 (d)
7 59 LOAD_GLOBAL 3 (numbers)
62 LOAD_ATTR 4 (append)
65 LOAD_FAST 5 (d)
68 CALL_FUNCTION 1
71 POP_TOP
72 JUMP_ABSOLUTE 7
>> 75 POP_BLOCK
>> 76 LOAD_CONST 0 (None)
79 RETURN_VALUE
信息量很大,但我们可以看到第二种方法中有很多STORE_FAST
和LOAD_FAST
的组合,这是因为使用了局部变量。这可能是导致你观察到的小时间差的原因之一,此外还有其他人提到的不同操作顺序。