Python Patterns - An Optimization Anecdote
原作者:Guido van Rossum
http://www.python.org/doc/essays/list2str.html
原创翻译,转载请注明出处
http://blog.youkuaiyun.com/colinsun/article/details/7579788
有一天,一个朋友请教我一个简单的问题:“如何能更好的实现将一个整型的list转换成其对应的ACII码值的字符串”。比如,list[97, 98, 99]应该转换成字符串‘abc’。假如我们打算用一个函数来实现的话。
我立马想到的一个最直接的实现版本:
def f1(list):
string = ""
for item in list:
string = string + chr(item)
return string
我的那个朋友说,这并不是一个最高效的实现。看看这个:
def f2(list):
return reduce(lambda string, item: string + chr(item), list, "")
这个版本和上一个同样利用一组字符来实现,用reduce()来代替循环,从而摆脱for循环以提高效率。
不错,我答道,但是是以每使用一个list成员,牺牲一次调用函数(lambda函数)的花费为代价。我打赌这样会更慢,因为在Python中,调用函数的花费要比整个用循环来实现的花费大。
好吧,我已经比较过。f2()要比f1()多花费60%的时间。
“原来如此”,我朋友说,“我希望他能效率更高点”。“ok”,我说,“这个实现如何”:
def f3(list):
string = ""
for character in map(chr, list):
string = string + character
return string
让我们俩吃惊的是,f3()的效率要比f1()高一倍!让我们惊讶的因素有二个:首先,他消耗了更多的存储空间(map(chr, list)的返回值是一个新的和原来大小一样的list);第二,他包含了两个循环而不是一个(一个隐含在map()中,一个是for循环)。
当然,空间换取时间是个绝佳的交易,所以第一个因素并没有那么让我们惊讶。但是,为何两个循环的效率会高过一个呢?原因有二:
首先,在f1()的实现中,内建函数chr()会在每次迭代的时候被查找,而在f3()中只被查找一次(作为map()的入参)。这种查询的花费是相当的高的,我告诉我的朋友,由于Python动态域规则规定查询首先从当前module的全局字典里开始(未查询到),然后会查询内建字典(查询到)。更糟的是,由于哈希链表的方式而未成功的查询(平均来说)要比成功的查询要更慢。
其次,f3()比f1()更高效的原因是对chr(item)的调用是由字节码解释器执行的,这样会比执行map()函数要稍慢些——在每次for循环中,字节码解释器都要执行三条字节码指令,而map()函数却全部是用C来实现的。
这让我们想到了一个折中的方法,既不会浪费空间,又能提高查询chr()函数的效率:
def f4(list):
string = ""
lchr = chr
for item in list:
string = string + lchr(item)
return string
和我们想的一样,f4()只比f3()慢25%,却仍比f1()快40%。这是因为局部变量查询要比全局或内建变量查询要快得多:Python“编译器”优化了函数实体从而使局部变量的查询不需要使用字典,而是用一个简单的array索引操作便足够。为何f3()效率更高的原因也都是,f4()的效率处于f1()和f3()两者之间的原因,但是其中的第一个原因(更少的查询)要更重要些。(为了得到更精确的数据,我们必须编辑解释器)
到现在为止,我们最好的实现,f3(),只是比最直接的实现f1()快一倍。能够更好吗?
我担心两轮迭代(两个循环)的方式会很成问题。到目前为止,我们只用到了一个只有256个成员的list做测试用数据,这也是我的朋友的需求。但是如果list的成员个数有上千个呢?我们不得不串联更长的字符串,每次增加一个字符。很容易看到,到最后,用这样的方式创建一个长度为N的list,将需要总共拷贝1+2+3+...+(N-1)个字符,或者说N*(N-1)/2个,又或0.5*N**2-0.5*N个。另外,将会有N个分配字符串的操作,为了能满足长度为N的需要,要消耗N**2的资源。的确,一个8倍(于256)长度的list(2048个成员),将需要超过8倍时间来完成;事实上近乎于16倍的时长。我可不敢尝试一个所需64倍时长的list。
这里有一个避免两轮迭代的算法。是为超过256个字符长度的字符串编写的:
def f5(list):
string = ""
for i in range(0, 256, 16): # 0, 16, 32, 48, 64, ...
s = ""
for character in map(chr, list[i:i+16]):
s = s + character
string = string + s
return string
不幸的是,如果list只有256个成员,这个实现跑起来比f3()还是有一点慢(不超过20%)。由于写一个普遍版本的实现只会更慢,我没有过多的纠结(除非我们拿他和不使用map()的版本比较,显然又会陷入更慢的循环)。
最终,我尝试了一个彻底不同的方式:只使用隐式的循环。整个操作可以描述为:将每一个list成员用chr()转换;然后将所有转换后的字符相连。我们在第一部分已经使用了一个隐式循环:map()。幸运的是,有些连接字符的函数是用C实现的。尤其像string.joinfields(list_of_strings, delimiter)这样连接一个list的字符串的函数,在相邻的两个字符串之间可使用特定的分隔符。这种方式也适用于用空分隔符来连接一个list的字符(在Pyhon中也就是一个字符的字符串)为字符串。瞧:
import string
def f6(list):
return string.joinfields(map(chr, list), "")
这个函数的执行效率要比我们以为最快的实现,f3(),快4-5倍。而且避免了两轮迭代。
最终的胜利者是...
第二天,我想到Python中很少用到的:array模块。他刚好有个操作是将一个Python整数的list转换成一个单字节整数的array,并且每个array都能写到文件中或者转换为一个二进制数据结构的字符串(译者注:‘\x12\x34’)。这里是利用这种操作的函数实现:
import array
def f7(list):
return array.array('B', list).tostring()
效率是f6()的三倍左右,是f3()的12-15倍!而且在中间过程使用更少的存储空间——只分配了2个N字节的对象(加上固定消耗),而f6()开始就分配了一个拥有N个成员的list,这通常会消耗4N个字节(在64位机器上消耗8N个字节)——假设字符对象也被其他类似的对象共享(比如小整型,Python通常会在一个单字节中缓存下字符串长度)
“停”,我朋友说,“在你开始阐述负面因素之前”——“对我的程序而言这已经是一个够高效的实现了”。我同意,尽管如此,我仍打算再尝试一种实现:用C来实现整个函数。将会用最小的空间(立马分配一个N字节的字符串)并且能在C代码中因为泛型(支持整数长度为1,2,4字节)而在array模块中节省些指令。不管怎样,他将不可避免的从list中每次只取一个成员,然后又从中取C整型,这两个操作在Python-C API中都相当的消耗资源, 所以对f7()而言,我期望的是最适度的性能优化。花时间去写扩展并测试扩展(相对于轻松实现简短的Python语句而言)就如同依赖非标准的Python扩展一样,我决定不再纠结。
结论(有空再翻)
If you feel the need for speed, go for built-in functions - you can't beat a loop written in C. Check the library manual for a built-in function that does what you want. If there isn't one, here are some guidelines for loop optimization:- Rule number one: only optimize when there is a proven speed bottleneck. Only optimize the innermost loop. (This rule is independent of Python, but it doesn't hurt repeating it, since it can save a lot of work. :-)
- Small is beautiful. Given Python's hefty charges for bytecode instructions and variable look-up, it rarely pays off to add extra tests to save a little bit of work.
- Use intrinsic operations. An implied loop in map() is faster than an explicit for loop; a while loop with an explicit loop counter is even slower.
- Avoid calling functions written in Python in your inner loop. This includes lambdas. In-lining the inner loop can save a lot of time.
- Local variables are faster than globals; if you use a global constant in a loop, copy it to a local variable before the loop. And in Python, function names (global or built-in) are also global constants!
- Try to use map(), filter() or reduce() to replace an explicit for loop, but only if you can use a built-in function: map with a built-in function beats for loop, but a for loop with in-line code beats map with a lambda function!
- Check your algorithms for quadratic behavior. But notice that a more complex algorithm only pays off for large N - for small N, the complexity doesn't pay off. In our case, 256 turned out to be small enough that the simpler version was still a tad faster. Your mileage may vary - this is worth investigating.
- And last but not least: collect data. Python's excellent profile module can quickly show the bottleneck in your code. if you're considering different versions of an algorithm, test it in a tight loop using the time.clock() function.
顺便说一下,这里是我使用过的计时函数。他以a作为入参调用一个函数f,n*10次,并在消耗的时间之后打印函数名,四舍五入以毫秒计。10次重复的调用将计时函数的循环花费的影响降低到最小。你可以进一步的调用100次...同样值得注意的是range(n)表达式的计算未在计时中。如果你担心他的消耗,你可以先使用空循环计算的时间来矫正。
import time
def timing(f, n, a):
print f.__name__,
r = range(n)
t1 = time.clock()
for i in r:
f(a); f(a); f(a); f(a); f(a); f(a); f(a); f(a); f(a); f(a)
t2 = time.clock()
print round(t2-t1, 3)
后记
但是这一次,相对不那么费力。有两个候选的实现,首先显然是:
def g1(string):
return map(ord, string)
其次是:
import array def g2(string): return array.array('b', string).tolist()
通过计时可以发现g2()的效率是g1()的5倍左右。前提是:g2()返回-128..127之间的整型,而g1()返回0..255之间的整型。如果你需要正整数,使用g1()要比使用g2()后再去做转换要更快。
(注意:由于是手写的随笔,如果用Type code‘B’初始化array,可以让array存储无符号字节,如此就没有必要使用g1()了)
(译者注:应该是作者当时手写的时候没有注意到Type code‘B’这个细节,所以才有最后一段描述)