自 Python 2.7 以来,I/O 变慢了吗?

Did I/O become slower since Python 2.7?

我目前有一个小的辅助项目,我想在我的机器上尽快对一个 20GB 的文件进行排序。这个想法是对文件进行分块,对块进行排序,合并块。我刚刚使用 pyenv 对不同 Python 版本的 radixsort 代码进行计时,发现 2.7.183.6.103.7.73.8.3 快得多和 3.9.0a。谁能解释为什么 Python 3.x 在这个简单的例子中比 2.7.18 慢?是否添加了新功能?

import os


def chunk_data(filepath, prefixes):
    """
    Pre-sort and chunk the content of filepath according to the prefixes.

    Parameters
    ----------
    filepath : str
        Path to a text file which should get sorted. Each line contains
        a string which has at least 2 characters and the first two
        characters are guaranteed to be in prefixes
    prefixes : List[str]
    """
    prefix2file = {}
    for prefix in prefixes:
        chunk = os.path.abspath("radixsort_tmp/{:}.txt".format(prefix))
        prefix2file[prefix] = open(chunk, "w")

    # This is where most of the execution time is spent:
    with open(filepath) as fp:
        for line in fp:
            prefix2file[line[:2]].write(line)

执行时间(多次运行):

完整代码为on Github, along with a minimal complete version

Unicode

是的,我知道 Python 3 和 Python 2 处理字符串的方式不同。我尝试以二进制模式 (rb / wb) 打开文件,请参阅 "binary mode" 评论。在几次运行中,它们的速度稍微快了一点。尽管如此,Python 2.7 在所有运行中都快得多。

尝试 1:字典访问

当我提出这个问题时,我认为字典访问可能是造成这种差异的原因。但是,我认为字典访问的总执行时间比 I/O 少得多。此外,timeit 没有显示任何重要信息:

import timeit
import numpy as np

durations = timeit.repeat(
    'a["b"]',
    repeat=10 ** 6,
    number=1,
    setup="a = {'b': 3, 'c': 4, 'd': 5}"
)

mul = 10 ** -7

print(
    "mean = {:0.1f} * 10^-7, std={:0.1f} * 10^-7".format(
        np.mean(durations) / mul,
        np.std(durations) / mul
    )
)
print("min  = {:0.1f} * 10^-7".format(np.min(durations) / mul))
print("max  = {:0.1f} * 10^-7".format(np.max(durations) / mul))

尝试二:复制时间

作为简化实验,我尝试复制 20GB 的文件:

Python的东西是由下面的代码生成的。

我的第一个想法是方差很大。所以这可能是原因。但是,chunk_data 执行时间的方差也很高,但 Python 2.7 的平均值明显低于 Python 3.x。所以这似乎不是像我在这里尝试的那样简单的 I/O 场景。

import time
import sys
import os


version = sys.version_info
version = "{}.{}.{}".format(version.major, version.minor, version.micro)


if os.path.isfile("numbers-tmp.txt"):
    os.remove("numers-tmp.txt")

t0 = time.time()
with open("numbers-large.txt") as fin, open("numers-tmp.txt", "w") as fout:
    for line in fin:
        fout.write(line)
t1 = time.time()


print("Python {}: {:0.0f}s".format(version, t1 - t0))

我的系统

这是多种效果的组合,主要是Python 3在文本模式下工作时需要执行unicode decoding/encoding,如果在二进制模式下工作,它将通过专用发送数据缓冲 IO 实现。

首先,使用 time.time 来衡量执行时间使用了墙时间,因此包括各种 Python 不相关的东西,例如 OS 级缓存和缓冲,如以及存储介质的缓冲。它还反映了对需要存储介质的其他进程的任何干扰。这就是为什么您在计时结果中看到这些巨大变化的原因。以下是我的系统的结果,每个版本连续运行七次:

py3 = [660.9, 659.9, 644.5, 639.5, 752.4, 648.7, 626.6]  # 661.79 +/- 38.58
py2 = [635.3, 623.4, 612.4, 589.6, 633.1, 613.7, 603.4]  # 615.84 +/- 15.09

尽管差异很大,但这些结果似乎确实表明了不同的时间,例如可以通过统计测试来确认:

>>> from scipy.stats import ttest_ind
>>> ttest_ind(p2, p3)[1]
0.018729004515179636

即只有 2% 的几率出现​​相同的分布。

我们可以通过测量过程时间而不是墙时间来获得更精确的图片。在 Python 2 中,这可以通过 time.clock while Python 3.3+ offers time.process_time 完成。这两个函数报告以下时间:

py3_process_time = [224.4, 226.2, 224.0, 226.0, 226.2, 223.7, 223.8]  # 224.90 +/- 1.09
py2_process_time = [171.0, 171.1, 171.2, 171.3, 170.9, 171.2, 171.4]  # 171.16 +/- 0.16

现在数据的分布要小得多,因为时间仅反映 Python 过程。

此数据表明 Python 3 的执行时间大约延长了 53.7 秒。考虑到输入文件中的大量行 (550_000_000),每次迭代大约需要 97.7 纳秒。

导致执行时间增加的第一个影响是 Python 中的 unicode 字符串 3. 从文件中读取二进制数据,解码,然后在写回时再次编码。在 Python 2 中,所有字符串都立即存储为二进制字符串,因此不会引入任何 encoding/decoding 开销。您在测试中看不到这种效果,因为它在各种外部资源引入的巨大变化中消失了,这些变化反映在墙上的时间差中。例如,我们可以测量从二进制到 unicode 再到二进制的往返时间:

In [1]: %timeit b'000000000000000000000000000000000000'.decode().encode()                     
162 ns ± 2 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

这确实包括两次属性查找和两次函数调用,因此实际需要的时间小于上面报告的值。要查看对执行时间的影响,我们可以将测试脚本更改为使用二进制模式 "rb""wb" 而不是文本模式 "r""w"。这减少了 Python 3 的计时结果,如下所示:

py3_binary_mode = [200.6, 203.0, 207.2]  # 203.60 +/- 2.73

这将每次迭代的处理时间减少了大约 21.3 秒或 38.7 纳秒。这与往返基准测试的计时结果减去名称查找和函数调用的计时结果一致:

In [2]: class C: 
   ...:     def f(self): pass 
   ...:                                                                                       

In [3]: x = C()                                                                               

In [4]: %timeit x.f()                                                                         
82.2 ns ± 0.882 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

In [5]: %timeit x                                                                             
17.8 ns ± 0.0564 ns per loop (mean ± std. dev. of 7 runs, 100000000 loops each)

此处 %timeit x 测量解析全局名称 x 的额外开销,因此属性查找和函数调用需要 82.2 - 17.8 == 64.4 秒。从上述往返数据中两次减去此开销得到 162 - 2*64.4 == 33.2 秒。

现在 Python 3 使用二进制模式和 Python 2 之间仍然有 32.4 秒的差异。这是因为 Python 3 中的所有 IO 都经过了(相当复杂)实施 io.BufferedWriter .write while in Python 2 the file.write method proceeds fairly straightforward to fwrite.

我们可以在两种实现中检查文件对象的类型:

$ python3.8
>>> type(open('/tmp/test', 'wb'))
<class '_io.BufferedWriter'>

$ python2.7
>>> type(open('/tmp/test', 'wb'))
<type 'file'>

这里还要注意,上面Python2的计时结果是使用文本方式得到的,不是二进制方式。二进制模式旨在支持所有实现 buffer protocol which results in additional work being performed also for strings (see also this question) 的对象。如果我们也为 Python 2 切换到二进制模式,那么我们将获得:

py2_binary_mode = [212.9, 213.9, 214.3]  # 213.70 +/- 0.59

实际上比 Python 3 个结果(18.4 ns / 迭代)大一点。

这两个实现在其他细节上也有所不同,例如 dict 实现。为了衡量这种效果,我们可以创建相应的设置:

from __future__ import print_function

import timeit

N = 10**6
R = 7
results = timeit.repeat(
    "d[b'10'].write",
    setup="d = dict.fromkeys((str(i).encode() for i in range(10, 100)), open('test', 'rb'))",  # requires file 'test' to exist
    repeat=R, number=N
)
results = [x/N for x in results]
print(['{:.3e}'.format(x) for x in results])
print(sum(results) / R)

这给出了 Python 2 和 Python 3 的以下结果:

  • Python 2: ~ 56.9 纳秒
  • Python 3: ~ 78.1 纳秒

对于完整的 550M 次迭代,这个大约 21.2 纳秒的额外差异相当于大约 12 秒。

上面的计时代码只检查了一个key的dict查找,所以我们还需要验证没有hash冲突:

$ python3.8 -c "print(len({str(i).encode() for i in range(10, 100)}))"
90
$ python2.7 -c "print len({str(i).encode() for i in range(10, 100)})"
90