[翻译]Python模式──优化轶事

2008年05月29日 00:33

原文见http://www.python.org/doc/essays/list2str.html

翻译:令狐虫

前几天,一个朋友问了我一个看起来很简单的问题:将一个整数列表转换成一个字符串的最好方法是什么,假设这些整数都是ASCII值。例如,列表[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, "")

这个版本使用了与第一个完全相同的字符串操作集,但去除了for循环的开销,转用更快的reduce()函数中的隐式循环。

当然,我回答,但它这样做会在每一个列表项中产生函数调用(lambda函数)的成本。我跟你打赌它会更慢,因为在Python中,函数调用开销比for循环开销更大。

(OK,其实我已经做过比较了。 f2() 比 f1() 多花了60%的时间。你瞧,我说的没错吧 :-)

唔,朋友说,我得让它再快点儿。好吧,我说,这个版本如何:

def f3(list):
    string = ""
    for character in map(chr, list):
        string = string + character
    return string

让我们俩都感到吃惊的是,f3()比f1()快了整整两倍!令我们吃惊的原因是双重的:第一,它使用了更多的存储空间(map(chr, list)的结果是另一个相同长度的列表);第二,它包含了双重循环而不是单重(一个是map()函数中的隐式循环,一个是for循环)。

当然,空间换时间是一个众所皆知的代换方法,所以对于第一点我们不该感到吃惊。然而,为什么双重循环比单重循环还要更快呢?两点原因。

第一点,在f1()中,内置函数chr()在每次迭代中都要被查找,而在f3()中它只被查找了一次(作为map()的参数)。这个查找代价比较昂贵,我告诉朋友,因为Python的动态作用域规则意味着它首先查找当前模块的全局字典(不会成功),然后再找内置函数字典(这里找到了)。更糟的是,因为哈希链表的工作原理,不成功的字典查找(平均)要比成功查找慢一些。

第二点f3()为什么比f1()更快的原因在于对chr(item)的调用,被字节码解释器执行时,要比被map()函数执行慢一些。字节码解释器必须在每次调用时执行三条字节码指令(load 'chr', load 'item', call),而map()函数在C代码里做这些。

这里让我们做一个折衷,不要浪费额外的空间,但是加速一下对chr()函数的查找:

def f4(list):
    string = ""
    lchr = chr
    for item in list:
        string = string + lchr(item)

如我们所期,f4()比f3()要慢,但只慢了25%;它仍然比f1()快差不多40%。这是因为本地变量查找比全局或内置变量查找要快得多:Python“编译器”对大多数函数体进行了优化,因此对于本地变量,不需要进行字典查找,一个简单的数组索引操作足够了。跟f1()和f3()比较而言,f4()的相对速度暗示了为什么f3()是一个更快选择的全部原因,但第一个原因(更少的查找)比较重要一点。(要得到关于这一点更精确的数据,我们将不得不深入研究一下解释器。)

至此,我们最好的版本,f3(),也只比我们的最直观的版本f1()快了2倍。我们还能做得更好吗?

我怕那些算法的二次行为(译注:指算法时间非线性增长的行为)会搞死我们。目前为止我们已经用到了一个256个整数的列表作为测试数据,因为那是我朋友对此函数的需求。但如果把它用在一个两千个字符的列表上呢?我们将会拼接越来越长的字符串,每次一个字符。很容易看到,除了时间开销之外,用这种方法建立一个长度为N的列表,一共有1 + 2 + 3 + ... + (N-1)个字符需要被复制,也就是N*(N-1)/2,也就是0.5*N**2 - 0.5*N。除此之外,有N个字符串内存分配操作,但对于非常大的N来说,公式中包含的N**2将会超过限度。甚至,对于一个8倍长度的列表(2048项)来说,这个函数需要花费的时间远远超过8倍;事实上,是接近于16倍。我没敢尝试64倍长的列表。

有一个通用的技术可以避免像这样的算法二次行为。对于正好是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

不幸的是,对于256项的列表,这个版本比f3()要慢一些(20%以内)。由于写一个通用版本只会让它慢更多,我们就不费心在这条路上继续下去了。(除此之外我们也跟另一个没有使用map的变种做了一比较,当然那更慢了。)

最后,我尝试了一种完全不同的激进方法:使用隐式循环。注意到整个操作可以作如下描述:应用chr()到每一个列表项;然后将结果字符连接起来。我们已经在第一部分使用了一个隐式循环:map()。

幸运的是,在string模块中有几个用C实现的字符串连接函数。特别是string.joinfields(list_of_strings, delimiter)连接一个字符串列表,在每两个字符串之间放置一个选好的分割符。你瞧:

import string
def f6(list):
    return string.joinfields(map(chr, list), "")

这个函数跑起来比我们最快的竞争者f3()要快4-5倍。更重要的是,它没有像其它版本那样的二次行为。

获胜的是……

第二天,我想起了Python的一个犄角旮旯:array模块。它用于从一个Python整数列表生成一个单字节长度的整数数组,并且每一个数组可以作为二进制数据结构写入一个文件或一个字符串。这就是应用这些操作实现的函数:

import array
def f7(list):
    return array.array('B', list).tostring()

这比f6()大约快三倍,也就是比f3()快12-15倍!同时它使用了更少的中间存储空间──它只分配了2个N字节的对象(加上固定长度的额外空间),而f6()开始分配了N项的一个列表,实际占用4N字节(64位机器上是8N字节)──假设字符对象在程序的其它地方共享相同的对象(就像小整数,Python在大多数情况下,将它们缓存在长度为1的字符串里)。

够了,朋友说,在你得到负倍数之前──这对我的程序而言已经够快了。我同意,不过我还是想尝试多一种方法:将整个函数用C写。这可以做到最小存储空间需求(它可以分配正好N长的字符串)以及在C代码中节省据我所知在array模块中存在的一些指令,因为它的通用性(它支持宽度为1、2和4字节的整型)。然而,它不能避免每次从list分解出一项,以及从项中分解出C整型,这两项都是Python-C API中的耗时操作,因此我预期跟f7()相比最多只能得到适度的加速。跟编写和测试扩展所做的努力(对比那些快速完成的单行python代码),以及对非标准Python扩展的依赖,我决定不再继续这个选择……。

结论

如果你感受到对速度的需要,首选内置函数──你无法打败一个用C写成的循环。检查内置函数库手册,找到你想要的。如果没有,这里是一些优化循环的指南:

  • 第一规则:仅当速度被证实是瓶颈时才优化。仅优化最内部的循环。(这条规则是独立于Python的。不过它再怎么重复也不过分,因为它能节省大量的工作 :-)
  • 小就是美。既然Python已经有了完备的字节码指令和变量查找的管理,于是很少会达到这样的一种平衡,即为了省下一些工作,而增加额外的测试。
  • 使用内置操作。map()中的隐式循环比显式的for循环要快。使用显式循环计数器的while循环更慢。
  • 不要在你的内部循环中调用Python写的函数。包括lambda。在内部循环中内联代码(译注:这里指的是类似C++中inline函数,将代码在调用处展开)将节省很多时间。
  • 本地变量比全局的要快;如果你在循环中使用一个全局常量,在进入循环前将它复制到一个本地变量。并且,在Python中,函数名(全局的或内置的)也是全局常量!
  • 尝试使用map()、filter()或reduce()替换显式for循环,但仅在你可以使用内置函数的时候:使用内置函数的map胜过for循环,但使用内联代码的for循环胜过使用lambda函数的map()!
  • 检查算法的二次行为。但要注意更复杂的算法只对大的N有利——对于小的N,复杂不会带来什么好处。在我们的例子中,256算是够小的了,一个更简单的版本也还是很快。你的情况可能不同——这是值得去调研的。
  • 最后但并非最不重要的:收集数据。Python优秀的profile模块可以快速的显示你代码中的瓶颈。如果你决定尝试一个算法的不同版本,在一个密集循环中用time.clock()函数测试它。

顺便说一句,这是我所使用的计时函数。它以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)

尾声

几天后,我的朋友又带着问题回来了:如何做一个相反的操作?也就是说,通过一个字符串建立一个整数ASCII值的列表。哦不,又来了,这个念头在我脑海中一闪而过……。

不过这次,相对来说没什么痛苦。有两个候选者,直观的:

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()更快,无论你用什么方法对g2()结果做事后处理。(注意,在本文写作过程中,类型值'B'被加入了array模块,它保存非负字节码,所以再没有任何理由选择g1()了。)

示例代码

(略)

这一次,我是亲历者

2008年05月27日 20:38

这次灾难似乎正在离我们远去。但我总觉得有些事情没有做。

虽然之前我曾经做了一些事情,关注新闻,捐款,捐物,搞各种悼念活动。可我总是难以将自己代入,仿佛自己是个旁观者,带着同情和怜悯以及些许的优越感,帮助着他们。

但事实上,我也是一个亲历者!

虽然只有短短几分钟的头晕目眩有惊无险,但毕竟我感受着的,正是千里之外的那场灾难。

只是这一次,我比他们幸运。

叔叔的葬礼正好在这次灾难过程中举办,我同时体会着失去亲人和灾难发生的双重悲痛,感受特别深。我想,我可以体会那种感觉。

我突然特别希望能够真真切切的帮助一个人,帮助他一起成长。而不是对着抽象的死亡数字发出空洞的哀伤,捐出一笔不知道会到哪里的钱就心安理得。

我是亲历者,我要跟亲历者一起,走出灾难的阴霾。

很感谢zola,他提供了一些孩子的资料,让我可以真正面对具体的人,而不是一些抽象的图像。

很感谢BT群的每一个人,他们愿意跟我一起,完成大家共通的心愿。

全国哀悼日

2008年05月19日 19:03

今天是全国哀悼日的第一天,今天下午的2:28,全国人民默哀三分钟。原本我还担心一个人站着会不会太突兀,没想到一到时间,公司里大部分的人都站了起来,默默哀悼。非常感动。

可惜仍然有些人在议论和窃窃私语,破坏了原本肃穆沉重的场面。但我觉得我也能理解,毕竟这是第一次全国性的哀悼活动,不是每个人都能很快习惯和适应的。

今天工作很忙,但仍然花了一点时间将整个页面改成了黑底白字。这个风格将持续3天,也算是对全国哀悼日的一个小小的响应吧。

不和谐的声音:

随着救援艰难时刻的过去,很多我们熟悉的东西又回来了。并且可能比平日更加严苛。

一些不和谐的声音和画面也开始逐步的浮出了水面。隐约的看得到一些与主流媒体不同的画面。

这些东西让我感觉到,这次灾难很可能只是一个特例,奢望因为这次灾难推动整个国家大踏步前进,还是不太可能的。灾难过后,尘归尘土归土,一切又将恢复平静。

我只希望这次灾难过去之后,能留下一些什么,不要让所有的进步都随着灾难的消失而灰飞烟灭。另外,我特别希望那些那些出来混的,把该还的还了。几万人的生命,难道还不够换一个良心么?那些发灾难财、成为间接凶手的人们,你们就不怕冤魂缠身么?

沉痛悼念在地震中失去生命的同胞们

该做的终于都做了

2008年05月18日 22:26

不管做得早还是晚,总比不做要好

中国地震局已将汶川地震震级从7.8级修订为8级

国务院公告:5月19日至21日为全国哀悼日

为表达全国各族人民对四川汶川大地震遇难同胞的深切哀悼,国务院决定,2008年5月19日至21日为全国哀悼日。在此期间,全国和各驻外机构下半旗志哀,停止公共娱乐活动,外交部和我国驻外使领馆设立吊唁簿。5月19日14时28分起,全国人民默哀3分钟,届时汽车、火车、舰船鸣笛,防空警报鸣响。

北京奥组委决定:奥运圣火19日起停传3天

灾难

2008年05月16日 13:41

叔叔去世没几天,丧事还在继续的时候,地震发生了。

这是一场更大的灾难,已经有万余人失去了生命。处于双重悲痛中的我,感触特别深。只是在老家无法上网,不能上blog写下自己的感受,不能为灾区尽一份力,也是非常惭愧。只能通过手机短信的方式,先捐上有限的几元钱,表达自己的一份心意。

今天终于回到公司,又可以上网。比较欣慰的事情是现在救灾情况已经开始慢慢好转,不像初期那样艰苦,救出的人也在慢慢的增加。我也没有什么可做的,再捐些钱,希望能帮助到灾区的人们。

希望这就是今年最坏的情况了。天佑中华。

另外我想说,这次地震的确暴露出很多的问题,但现在追究有些太早了。目前还是把精力关注在救助上吧,那些问题日后可以慢慢追究慢慢反思。出来混,迟早要还的。

爱心行动:

爱德基金会

寻找灾区的亲人

一切为了孩子.msn彩虹行动

天灾

2008年05月13日 08:58

四川7.8级强震近万人死亡

为大地震的死难者默哀。

胡言乱语

2008年05月08日 23:44

叔叔突然走了。非常突然。

下午1点多的时候他还给我奶奶打了电话,十几分钟之后,单位就发现他晕倒在位子上,赶紧送医院,已经回天乏力。

一个人从生到死,需要多久?

而我在做什么?

晚上还在跟同事聚餐,说一些开心的笑话。接到消息后赶回家,居然还打开电脑,一条条的看着Google Reader上的消息,在SNS网络里检查,回复,仿佛没事人。

狼心狗肺的家伙。一个声音对我说。

事实上,我跟叔叔感情并算不深。我从小在杭州长大,他在江苏。我们的生活没有任何交集,只是从父母的嘴里知道我的老家在江苏,江苏有奶奶,有叔叔婶婶。然而当时,他们对于我而言,只是一个称谓。

稍大一些的时候,我转去江苏念书,寄宿在奶奶家,于是才跟叔叔有了来往。然而这种来往仍然并不十分频繁,短短三年之后我又离开江苏回到杭州,从此除了回老家拜年,叔叔又变成了一个抽象概念。直至今日,我甚至说不出我印象中的叔叔究竟是个什么样子。

然而很快的,还是有一股悲意涌上了心头,让我决定写下这篇blog。是的,虽然叔叔对我而言,更多的只是一个称谓,然而毕竟他是我的亲人。我知道在不远的地方,有这样一个人,他关心着我的成长,见面的时候会给我祝福和鼓励,他有自己独立的思想,我可以跟他讨论各种问题,从而产生碰撞的火花。即使他的轮廓如此模糊,我依然真切的感受到,他存在着,我们会见面的。

然而突然,他不再存在,我们也永远不能再见。我真的失去了一位亲人。在风雨飘摇中,我突然之间少了一份依靠,哪怕这依靠更多是凭空幻想而得,但毕竟是一份依靠。

叔叔的儿子正在准备考试,还不知道这件事,家人怕影响他没有跟他说。我觉得这是一个非常残忍的决定,但却又无可奈何。死亡这件事说大不大说小不小,对于逝者,只是一瞬间的事,而对于生者,则意味着很多很多。

是以为凭。

Design downloaded from free website templates.