注意: 虽然 JavaScript 对于本网站不是必需的,但您与内容的互动将受到限制。请开启 JavaScript 以获得完整的体验。

Python 模式 - 优化趣闻

警告

此页面因历史原因保留,可能包含过时或不正确的信息。

前几天,一个朋友问了我一个看似简单的问题:如果假设整数是 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 循环开销更大。”

(好吧,所以我已经做了比较。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() 函数执行时慢一点——字节码解释器必须为每个调用执行三条字节码指令(加载 'chr',加载 'item',调用),而 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“编译器”优化了大多数函数体,使得对于局部变量,无需进行字典查找,简单的数组索引操作就足够了。f4() 相对于 f1() 和 f3() 的相对速度表明 f3() 更快的两个原因都有贡献,但第一个原因(查找次数更少)更为重要。(要获得更精确的数据,我们需要对解释器进行插桩。)

尽管如此,我们最好的版本 f3() 仍然只比最直接的版本 f1() 快两倍。我们能做得更好吗?

我担心算法的二次行为会拖垮我们。到目前为止,我们一直使用包含 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
        return string

不幸的是,对于 256 个项目的列表,这个版本的运行速度略慢于 f3()(尽管在 20% 以内)。由于编写通用版本只会使其更慢,我们没有再进一步追究这条路径(除了我们还将其与一个不使用 map() 的变体进行了比较,当然那个更慢)。

最后,我尝试了一种截然不同的方法:_只_使用隐含的循环。请注意,整个操作可以描述如下:对每个列表项应用 chr();然后连接生成的字符。我们已经为第一部分使用了隐含循环:map()。幸运的是,string 模块中有一些字符串连接函数是用 C 实现的。特别是,string.joinfields(list_of_strings, delimiter) 连接字符串列表,在每两个字符串之间放置一个选择的分隔符。没有什么能阻止我们连接字符列表(在 Python 中,字符只是长度为一的字符串),使用空字符串作为分隔符。瞧!

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

这个函数比我们最快的竞争者 f3() 快四到五倍。此外,它没有其他版本的二次行为。

赢家是...

第二天,我忽然想起 Python 的一个不为人知的角落:array 模块。它恰好有一个操作,可以从 Python 整数列表中创建 1 字节宽的整数数组,并且每个数组都可以写入文件或转换为字符串作为二进制数据结构。以下是使用这些操作实现的函数

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

这比 f6() 快大约三倍,或者比 f3() 快 12 到 15 倍!它还使用了更少的中间存储——它只分配了 N 字节的 2 个对象(加上固定的开销),而 f6() 首先分配了一个包含 N 个项目的列表,这通常需要 4N 字节(在 64 位机器上为 8N 字节)——假设字符对象与程序中其他地方的类似对象共享(就像小整数一样,Python 在大多数情况下会缓存长度为一的字符串)。

“停,”我的朋友说,“在你还没进入负值时间之前——这已经足够快了,我的程序。”我同意了,尽管我本来还想尝试一种方法:用 C 语言编写整个函数。这可以实现最小的存储要求(它会立即分配一个长度为 N 的字符串)并在 C 代码中节省一些指令,我知道这些指令存在于 array 模块中,因为它具有通用性(它支持 1、2 和 4 字节的整数宽度)。然而,它无法避免必须一个接一个地从列表中提取项目,并从中提取 C 整数,这两者在 Python-C API 中都是相当昂贵的操作,所以我预计与 f7() 相比,速度提升最多是适度的。考虑到编写和测试扩展的努力(与快速编写 Python 单行代码相比),以及对非标准 Python 扩展的依赖,我决定不再追求这个选项……

结论

如果你需要速度,请选择内置函数——你无法超越用 C 编写的循环。查阅库手册,看看是否有你想要的内置函数。如果没有,这里有一些循环优化的指导方针

  • _第一条规则:_ 只有在有确凿证据表明存在速度瓶颈时才进行优化。只优化最内层的循环。(这条规则与 Python 无关,但重复它并无坏处,因为它能节省大量工作。:-))
  • 小即是美。鉴于 Python 在字节码指令和变量查找方面的巨大开销,增加额外的测试以节省一点点工作量通常不划算。
  • 使用内在操作。map() 中的隐含循环比显式 for 循环更快;带有显式循环计数器的 while 循环甚至更慢。
  • 避免在内层循环中调用用 Python 编写的函数。这包括 lambda 函数。将内层循环内联可以节省大量时间。
  • 局部变量比全局变量快;如果在一个循环中使用全局常量,请在循环之前将其复制到局部变量中。在 Python 中,函数名(全局或内置)也是全局常量!
  • 尝试使用 map()、filter() 或 reduce() 来替换显式 for 循环,但前提是你能够使用内置函数:map 结合内置函数胜过 for 循环,但带有内联代码的 for 循环胜过 map 结合 lambda 函数!
  • 检查你的算法是否存在二次行为。但请注意,更复杂的算法只对大 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() 的五倍。不过有一个问题:g2() 返回的整数范围是 -128..127,而 g1() 返回的整数范围是 0..255。如果你需要正整数,g1() 会比你对 g2() 结果进行任何后处理都要快。(注意:自本文撰写以来,array 模块中添加了 'B' 类型代码,它存储无符号字节,因此不再有理由偏好 g1()。)

示例代码