【问题标题】:Why is 'new_file += line + string' so much faster than 'new_file = new_file + line + string'? [duplicate]为什么'new_file += line + string'比'new_file = new_file + line + string'快得多? [复制]
【发布时间】:2017-04-21 04:14:53
【问题描述】:

当我们使用时,我们的代码需要 10 分钟来虹吸 68,​​000 条记录:

new_file = new_file + line + string

但是,当我们执行以下操作时,只需 1 秒:

new_file += line + string

代码如下:

for line in content:
import time
import cmdbre

fname = "STAGE050.csv"
regions = cmdbre.regions
start_time = time.time()
with open(fname) as f:
        content = f.readlines()
        new_file_content = ""
        new_file = open("CMDB_STAGE060.csv", "w")
        row_region = ""
        i = 0
        for line in content:
                if (i==0):
                        new_file_content = line.strip() + "~region" + "\n"
                else:
                        country = line.split("~")[13]
                        try:
                                row_region = regions[country]
                        except KeyError:
                                row_region = "Undetermined"
                        new_file_content += line.strip() + "~" + row_region + "\n"
                print (row_region)
                i = i + 1
        new_file.write(new_file_content)
        new_file.close()
        end_time = time.time()
        print("total time: " + str(end_time - start_time))

我用 python 编写的所有代码都使用第一个选项。这只是基本的字符串操作......我们正在从文件中读取输入,处理它并将其输出到新文件。我 100% 确定第一种方法的运行时间大约是第二种方法的 600 倍,但为什么呢?

正在处理的文件是 csv,但使用 ~ 而不是逗号。我们在这里所做的就是获取这个 csv,它有一个国家列,并为国家区域添加一个列,例如LAC、EMEA、NA 等... cmdbre.regions 只是一个字典,所有约 200 个国家作为键,每个地区作为值。

一旦我更改为附加字符串操作...循环在 1 秒内完成而不是 10 分钟... csv 中有 68,000 条记录。

【问题讨论】:

  • 您确定您使用的是字符串吗?不是列表?
  • 就地字符串添加不应该更快,因为字符串是不可变的。所以我对数据的类型有疑问。
  • 什么版本的 Python,在哪个平台上?请附上复制此问题的最小脚本。
  • 如果您认为它很快,只需尝试io.StringIO 或将您的行放在列表中,最后使用"".join()
  • @Jean-FrançoisFabre:CPython 有一个优化,在只有一个引用的情况下打破了不变性保证(因此从外部角度来看,如果它改变字符串,则没有有意义的区别)。

标签: python string string-concatenation cpython python-internals


【解决方案1】:

CPython(引用解释器)对就地字符串连接进行了优化(当附加到的字符串没有其他引用时)。在执行+ 时,它不能可靠地应用此优化,只有+=+ 涉及两个实时引用,赋值目标和操作数,前者不参与+ 操作,所以更难优化)。

不过,根据PEP 8,您不应该依赖这个:

代码的编写方式不应损害 Python 的其他实现(PyPy、Jython、IronPython、Cython、Psyco 等)。

例如,对于 a += b 或 a = a + b 形式的语句,不要依赖 CPython 对就地字符串连接的高效实现。即使在 CPython 中,这种优化也是脆弱的(它只适用于某些类型),并且在不使用引用计数的实现中根本不存在。在库的性能敏感部分,应该使用 ''.join() 形式。这将确保在各种实现中以线性时间发生连接。

根据问题编辑更新:是的,你破坏了优化。您连接了许多字符串,而不仅仅是一个,并且 Python 从左到右计算,所以它必须首先进行最左边的连接。因此:

new_file_content += line.strip() + "~" + row_region + "\n"

根本不一样:

new_file_content = new_file_content + line.strip() + "~" + row_region + "\n"

因为前者将所有 new 片段连接在一起,然后将它们一次全部附加到累加器字符串中,而后者必须使用不涉及 @ 的临时变量从左到右评估每个添加987654333@ 本身。为清楚起见添加括号,就像您所做的那样:

new_file_content = (((new_file_content + line.strip()) + "~") + row_region) + "\n"

因为它实际上不知道类型,直到它到达它们,它不能假设所有这些都是字符串,所以优化不会启动。

如果您将第二段代码更改为:

new_file_content = new_file_content + (line.strip() + "~" + row_region + "\n")

或者稍微慢一点,但仍然比你的慢代码快很多倍,因为它保持了 CPython 优化:

new_file_content = new_file_content + line.strip()
new_file_content = new_file_content + "~"
new_file_content = new_file_content + row_region
new_file_content = new_file_content + "\n"

所以对于 CPython 来说,积累是显而易见的,你可以解决性能问题。但坦率地说,只要您执行这样的逻辑附加操作,就应该只使用+=+= 的存在是有原因的,它为维护者和解释者提供了有用的信息。除此之外,就DRY 而言,这是一种很好的做法;为什么不需要命名变量两次?

当然,根据 PEP8 指南,即使在这里使用 += 也是错误的形式。在大多数具有不可变字符串的语言中(包括大多数非 CPython Python 解释器),重复的字符串连接是Schlemiel the Painter's Algorithm 的一种形式,这会导致严重的性能问题。正确的解决方案是构建一个list 的字符串,然后将它们全部合并为join,例如:

    new_file_content = []
    for i, line in enumerate(content):
        if i==0:
            # In local tests, += anonymoustuple runs faster than
            # concatenating short strings and then calling append
            # Python caches small tuples, so creating them is cheap,
            # and using syntax over function calls is also optimized more heavily
            new_file_content += (line.strip(), "~region\n")
        else:
            country = line.split("~")[13]
            try:
                    row_region = regions[country]
            except KeyError:
                    row_region = "Undetermined"
            new_file_content += (line.strip(), "~", row_region, "\n")

    # Finished accumulating, make final string all at once
    new_file_content = "".join(new_file_content)

即使 CPython 字符串连接选项可用,它通常也更快,并且在非 CPython Python 解释器上也将可靠地快速,因为它使用可变的 list 来有效地累积结果,然后允许 ''.join 预先计算字符串的总长度,一次性分配最终的字符串(而不是沿途增加调整大小),并精确填充一次。

旁注:对于您的具体情况,您根本不应该累积或连接。你有一个输入文件和一个输出文件,并且可以逐行处理。每次您要追加或累积文件内容时,只需将它们写出来(我已经清理了一些代码以符合 PEP8 和其他小的样式改进):

start_time = time.monotonic()  # You're on Py3, monotonic is more reliable for timing

# Use with statements for both input and output files
with open(fname) as f, open("CMDB_STAGE060.csv", "w") as new_file:
    # Iterate input file directly; readlines just means higher peak memory use
    # Maintaining your own counter is silly when enumerate exists
    for i, line in enumerate(f):
        if not i:
            # Write to file directly, don't store
            new_file.write(line.strip() + "~region\n")
        else:
            country = line.split("~")[13]
            # .get exists to avoid try/except when you have a simple, constant default
            row_region = regions.get(country, "Undetermined")
            # Write to file directly, don't store
            new_file.write(line.strip() + "~" + row_region + "\n")
end_time = time.monotonic()
# Print will stringify arguments and separate by spaces for you
print("total time:", end_time - start_time)

实现细节深入探讨

对于那些对实现细节感到好奇的人,CPython 字符串 concat 优化是在字节码解释器中实现的,而不是在 str 类型本身(技术上,PyUnicode_Append 进行突变优化,但它需要解释器的帮助才能修正引用计数,以便它知道它可以安全地使用优化;没有解释器的帮助,只有 C 扩展模块会从优化中受益)。

当解释器 detects that both operands are the Python level str type(在 C 层,在 Python 3 中,它仍然被称为 PyUnicode,不值得更改的 2.x 天的遗留物)时,它调用 a special unicode_concatenate function,它检查下一条指令是否是三个基本STORE_* 指令之一。如果是,并且目标与左操作数相同,它将清除目标引用,因此 PyUnicode_Append 将只看到对该操作数的单个引用,从而允许它调用具有单个引用的 str 的优化代码.

这意味着你不仅可以通过这样做来打破优化

a = a + b + c

您也可以在相关变量不是顶级(全局、嵌套或本地)名称时打破它。如果您正在对对象属性、list 索引、dict 值等进行操作,即使 += 也无济于事,它不会看到“简单的STORE”,所以它不清除目标引用,所有这些都得到超慢、非就地行为:

foo.x += mystr
foo[0] += mystr
foo['x'] += mystr

它也特定于str 类型;在 Python 2 中,优化对 unicode 对象没有帮助,在 Python 3 中,它对 bytes 对象没有帮助,并且在这两个版本中都不会针对 str 的子类进行优化;那些总是走慢路。

基本上,对于 Python 新手来说,优化是在最简单的常见情况下尽可能好,但对于稍微复杂的情况,它也不会造成严重的麻烦。这只是加强了 PEP8 的建议:如果您可以通过做正确的事情并使用 str.join每个 解释器上运行得更快,对于任何商店目标,依赖于解释器的实现细节是一个坏主意。

【讨论】:

  • PEP 声称它也可以优化a = a + b,我的本地测试表明在 Linux x64 上的 Python 3.5.2 上就是这种情况。这可能因 CPython 版本而异,或者您的特定测试以某种微妙的方式破坏了该优化。
  • 很好的答案,非常有见地,但你能澄清一下什么是没有优化的极简主义例子吗?喜欢a += b + ca = a + b + c 还是类似的?
  • @Chris_Rands:这是一个实现细节,所以不能保证(CPython 根本不保证这种优化),但是一般来说,只要你看到a = a + b + c优化被破坏了(我的本地测试是针对这种形式的,除了我什至没有为bc 使用变量,它只是s = s + "abc" + "d")。
  • 正如我的本地测试所示,即使bc 实际上是字符串文字,而不是变量名(带有优化编译器的编译语言可以预先连接),这种优化损失也适用; Python 不能这样做,因为在编译时,它不知道s/new_file_contents 的类型,并且它保证从左到右的评估顺序。如果它是具有重载__add__/+ 的类型,s + "abc" + "d" 的结果可能与s + "abcd" 非常不同,因此它必须单独评估每个添加,并且中间临时结果中断字符串连接优化。
  • @user3656612:无论如何,在执行逻辑就地操作时,请考虑使用+=DRY 是有原因的规则; a = a + b 易于阅读,但对于较长的名称,重复两次名称并使用赋值而不是增量意味着人们需要确保它是同一个变量,而不是两个名称相似的变量,并确保运算符优先级不会改变行为(例如 a = a + b +c 实际上 确实 改变了行为与 a += b + c 在这里)。当然,对于可变类型,+= 是高效且就地的,+ 两者都不是。
【解决方案2】:

实际上,两者都可能同样慢,但对于一些优化,这实际上是官方 Python 运行时 (cPython) 上的一个实现细节。

Python 中的字符串是不可变的——这意味着当你执行“str1 + str2”时,Python 必须创建第三个字符串对象,并将 str1 和 str2 中的所有内容复制到它——无论其中有多大这些部分是。

inplace 运算符允许 Python 使用一些内部优化,这样 str1 中的所有数据就不必再次复制 - 甚至可能它甚至允许一些缓冲区空间用于进一步的连接选项。

当人们对语言的工作原理有所了解时,从小字符串构建大文本正文的方法是创建一个包含所有字符串的 Python 列表,然后在循环结束后,对 @ 进行一次调用987654321@ 方法传入所有字符串组件。这将始终保持快速,即使跨 Python 实现也是如此,并且不依赖于能够触发的优化。

output = []
for ...:
    output.append(line)

new_file = "\n".join(output)

【讨论】:

  • 是的,我认为这是最有意义的。当您执行 a = a + b 60,000 次时,python 每次都将 a 加载到 cpu 或 rams 内存中。当 a 变大时,操作会变慢。 += 可能会保留它或添加指针或其他东西。仍然非常惊讶...在这种情况下,我使用过的大多数其他语言都不会产生不同的时间。
  • @user3656612:很少有其他语言尝试对此进行优化。如果你在 C++ 中这样做,你肯定会受苦; +=+ 快​​得多,因为 C++ 字符串是可变的,并且过度分配以优化重复连接,而 + 需要创建全新的 string 对象。
猜你喜欢
  • 2015-11-13
  • 2014-04-21
  • 2014-04-11
  • 1970-01-01
  • 2013-03-08
  • 2021-04-18
  • 2014-03-12
  • 1970-01-01
  • 2014-10-24
相关资源
最近更新 更多