【问题标题】:Python vs Perl: performance reading a gzipped filePython vs Perl:读取压缩文件的性能
【发布时间】:2016-08-02 06:15:00
【问题描述】:

我有一个包含一百万行的压缩数据文件:

$ zcat million_lines.txt.gz | head
1
2
3
4
5
6
7
8
9
10
...

我处理这个文件的 Perl 脚本如下:

# read_million.pl
use strict; 

my $file = "million_lines.txt.gz" ;

open MILLION, "gzip -cdfq $file |";

while ( <MILLION> ) {
    chomp $_; 
    if ($_ eq "1000000" ) {
        print "This is the millionth line: Perl\n"; 
        last; 
    }
}

在 Python 中:

# read_million.py
import gzip

filename = 'million_lines.txt.gz'

fh = gzip.open(filename)

for line in fh:
    line = line.strip()
    if line == '1000000':
        print "This is the millionth line: Python"
        break

无论出于何种原因,Python 脚本花费的时间几乎要长约 8 倍:

$ time perl read_million.pl ; time python read_million.py
This is the millionth line: Perl

real    0m0.329s
user    0m0.165s
sys     0m0.019s
This is the millionth line: Python

real    0m2.663s
user    0m2.154s
sys     0m0.074s

我尝试分析这两个脚本,但实际上没有多少代码可以分析。 Python 脚本大部分时间都花在for line in fh 上; Perl 脚本大部分时间都花在if($_ eq "1000000")

现在,我知道 Perl 和 Python 有一些预期的差异。例如,在 Perl 中,我使用 UNIX gzip 命令的子进程打开文件句柄;在 Python 中,我使用 gzip 库。

我可以做些什么来加速这个脚本的 Python 实现(即使我从来没有达到 Perl 的性能)?也许 Python 中的 gzip 模块很慢(或者我使用它的方式不好);有没有更好的解决方案?

编辑 #1

这是 read_million.py 逐行分析的样子。

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     2                                           @profile
     3                                           def main():
     4
     5         1            1      1.0      0.0         filename = 'million_lines.txt.gz'
     6         1          472    472.0      0.0         fh = gzip.open(filename)
     7   1000000      5507042      5.5     84.3         for line in fh:
     8   1000000       582653      0.6      8.9                 line = line.strip()
     9   1000000       443565      0.4      6.8                 if line == '1000000':
    10         1           25     25.0      0.0                         print "This is the millionth line: Python"
    11         1            0      0.0      0.0                         break

编辑 #2:

我现在也按照@Kirk Strauser 和其他人尝试了subprocess python 模块。它更快:

Python“子进程”解决方案:

# read_million_subproc.py 
import subprocess

filename = 'million_lines.txt.gz'
gzip = subprocess.Popen(['gzip', '-cdfq', filename], stdout=subprocess.PIPE)
for line in gzip.stdout: 
    line = line.strip()
    if line == '1000000':
        print "This is the millionth line: Python"
        break
gzip.wait()

这是我迄今为止尝试过的所有事情的比较表:

method                    average_running_time (s)
--------------------------------------------------
read_million.py           2.708
read_million_subproc.py   0.850
read_million.pl           0.393

【问题讨论】:

  • 您是否尝试过在 Python 中使用 Perl gzip 库或外部 gzip 管道?
  • IIRC,Python的gzip模块是用Python写的,所以性能很差。 OrangeDog 建议在外部运行 gzip 并将解压缩的输出传输到 Python 可能会加快速度。
  • 哇。对我来说,“炮轰”到 zcat 是理想的,这非常违反直觉......
  • @asf107:问题在于您在第一种情况下并没有真正使用 Perl。也就是说,Perl 是为文本处理设计的。如果任务只是消耗文本并处理它,那么 Perl 很可能会获胜。哎呀,根据我的经验,Perl 通常会更快一些(尽管当您使用其被破解的 OO 功能时它会失去优势),因为字符串可变(当您strip 或切片时,Python 正在复制,Perl 正在发生变异chomp),并改进了名称查找(Perl 在编译时链接非 OO 名称;Python 在运行时一遍又一遍地查找它们)。

标签: python performance perl


【解决方案1】:

如果我是博彩玩家,我会打赌:

line = line.strip()

是杀手。它正在做一个方法查找(即解析line.strip),然后调用它来创建另一个对象,然后将名称line 分配给新创建的对象。

鉴于您确切知道您的数据会是什么样子,我会看看将您的循环更改为这个是否会有所作为:

for line in fh: 
    if line == '1000000\n':
        ...

我想我是过火了,回答得太快了。我相信你是对的:Perl 通过在单独的进程中运行 gzip 来“作弊”。查看Asynchronously read stdout from subprocess.Popen 以了解在 Python 中执行相同操作的方法。它可能看起来像:

import subprocess

filename = 'million_lines.txt.gz'
gzip = subprocess.Popen(['gzip', '-cdfq', filename], stdout=subprocess.PIPE)
for line in iter(gzip.stdout.readline, ''): 
    line = line.strip()
    if line == '1000000':
        print "This is the millionth line: Python"
        break
gzip.wait()

完成后,请回来报告。我想看看这个实验的结果!

【讨论】:

  • 或者,在循环外,strip_without_lookup = str.split,在循环内:line = strip_without_lookup(line)
  • 我知道,我也这么认为!但是,您提出的建议并没有显着加快脚本速度......我将在我的原始帖子中发布 python 分析信息作为编辑
  • for line in iter(gzip.stdout.readline, ''): 是一种重新发明for line in gzip.stdout: 的愚蠢方式...另外,我怀疑你想terminate/kill 过程,而不是wait ;你还没有在这里消耗所有的stdout,所以当管道填满时它会阻塞,而你会阻塞等待它退出。
  • 好点。我将在上面进行建议的更改和更新
  • @ShadowRanger 你看到 J.F. Sebastian 使用它的理由了吗?
【解决方案2】:

你让我好奇……

在我的机器上,以下 Python 脚本的性能始终优于 Perl 解决方案:10,000,000 行的 3.2 秒与 3.6 秒(由三个运行 time 给出的实时经过)

import subprocess

filename = 'millions.txt.gz'
gzip = subprocess.Popen(
    ['gzip', '-cdfq', filename],
    bufsize = -1, stdout = subprocess.PIPE)

for line in gzip.stdout:
    if line[:-1] == '10000000':
        print "This is the 10 millionth line: Python"
        break

gzip.wait()

有趣的是,当查看在用户模式下花费的时间时,Perl 解决方案比 Python 解决方案略好。这似乎表明 Python 方案的进程间通信比 Perl 方案的进程间通信效率更高。

【讨论】:

    【解决方案3】:

    这个比 Perl 版本快,但它假设行结尾是 '\n':

    import subprocess
    
    filename = "million_lines.txt.gz"
    gzip = subprocess.Popen(['gzip', '-cdfq', filename], stdout=subprocess.PIPE)
    for line in gzip.stdout:
        if line == '1000000\n':
            print "This is the millionth line: Python"
            break
    gzip.terminate()
    

    测试

    $ time python Test.py 
    This is the millionth line: Python
    
    real    0m0.191s
    user    0m0.264s
    sys     0m0.016s
    
    $ time perl Test.pl 
    This is the millionth line: Perl
    
    real    0m0.404s
    user    0m0.488s
    sys     0m0.008s
    

    【讨论】:

    • 如果您将universal_newlines=True 作为额外参数传递给subprocess.Popen,则会为您执行行尾转换(因此您实际上可以保证行尾为'\n')。这也使代码可移植到 Python 3(其中 Popen 返回 bytes,而不是 str,除非您通过 universal_newlines=True)。或者,在循环外声明 needle = '1000000' + os.linesep,并在循环中测试 line == needle 以匹配 OS 行结束预期。
    • 有趣的是,上面的代码仍然在我的机器上运行得比较慢。
    • @asf107:在某个时刻,您所安排的大部分时间是启动解释器和执行实际解压缩的开销,而不是完成的工作。在我的机器上,当使用只打印一个空行的脚本重复启动时(在 Python 的情况下,导入 subprocess 而不使用它),两个程序 hash-ed,Perl 的启动速度要快得多。 time 报告 time perl noop.pl 对于真实/用户/系统大约需要 0.01s/0.002s/0.005s。 time python noop.py 大约需要 0.110s/0.035s/0.040s。 Python noop 时间是read_million 时间的 50%、25% 和 90%。
    • @asf107:续。当启动解释器的开销占工作时间的很大一部分时,您的基准测试就有缺陷;在“真实”程序中,启动开销(只要在人类时间尺度上不明显)很少重要;您需要比较单独完成工作的时间,或者做足够的工作以消除启动开销(理想情况下,在一个会话中多次完成工作,花费最短的时间以最大限度地减少时序抖动)。这里的工作太小了。
    • 哇....我的平均python启动时间接近0.600s!哎呀....感谢您指出这一点。
    【解决方案4】:

    看起来像 for line in 中使用的 gzip 文件的 next() 方法似乎很慢 - 大概是因为它正在谨慎地读取未压缩的流以寻找换行符,也许是为了将内存使用率保持在控制。

    当然,您是在将苹果与橙子进行比较,而其他人已经在 Python forking gunzip 和 Perl forking gunzip 之间进行了更好的比较。 这些可能运行良好,因为它们在单独的进程中将相对较大的未压缩字符串转储到其标准输出。

    一种非内存安全且可能造成浪费的方法是:

    import gzip
    
    filename = 'million_lines.txt.gz'
    
    fh = gzip.open(filename)
    
    whole_file = fh.read()
    for line in whole_file.splitlines():
        if line == "1000000":
            print "This is the millionth line: Python"
            break
    

    这会读入整个未压缩的文件,然后拆分。

    结果:

    $ time python test201604121.py
    This is the millionth line: Python
    
    real    0m0.183s
    user    0m0.133s
    sys    0m0.046s
    
    
    $ time perl test201604121.pl
    
    This is the millionth line: Perl
    
    real    0m0.192s
    user    0m0.167s
    sys    0m0.027s
    

    【讨论】:

    • 谢谢!我认为通过将整个文件内容读入内存可以加快速度;实际上,我通常会处理无法实现的超大 .gz 文件。
    【解决方案5】:

    在测试了多种可能性后,这里的罪魁祸首似乎是:

    1. 比较苹果和橘子:在您的原始测试用例中,Perl 没有进行文件 I/O 或解压缩工作,gzip 程序正在这样做(它是用 C 语言编写的,因此运行速度非常快);在该版本的代码中,您将并行计算与串行计算进行比较。
    2. 解释器启动时间;在绝大多数系统上,Python 需要更长的时间才能开始运行(我相信是因为在启动时会加载更多文件)。我机器上的解释器启动时间大约是挂钟总时间的一半、用户时间的 30% 和大部分系统时间。在 Python 中完成的实际工作被启动时间所淹没,因此您的基准测试既是比较启动时间,也是比较完成工作所需的时间。 稍后添加:您可以通过使用-E 开关(在启动时禁用对PYTHON* 环境变量的检查)和-S 调用python 来进一步减少Python 启动的开销开关(禁用自动import site,这避免了很多涉及磁盘I/O 的动态sys.path 设置/操作,代价是切断对任何非内置库的访问)。
    3. Python 的subprocess 模块比Perl 的open 调用的级别稍高,并且在Python 中实现(在较低级别的原语之上)。通用的subprocess 代码加载时间更长(加剧了启动时间问题)并增加了进程启动本身的开销。
    4. Python 2 的 subprocess 默认为无缓冲 I/O,因此除非您传递明确的 bufsize 参数(4096 到 8192 似乎可以正常工作),否则您将执行更多系统调用。
    5. line.strip() 调用涉及的开销比您想象的要多; Python 中的函数和方法调用比实际应该的要昂贵,并且line.strip() 不会像 Perl 的 chomp 那样改变 str 的方式(因为 Python 的 str 是不可变的,而 Perl 字符串是可变的)

    几个版本的代码可以绕过大部分这些问题。一、优化subprocess

    #!/usr/bin/env python
    
    import subprocess
    
    # Launch with subprocess in list mode (no shell involved) and
    # use a meaningful buffer size to minimize system calls
    proc = subprocess.Popen(['gzip', '-cdfq', 'million_lines.txt.gz'], stdout=subprocess.PIPE, bufsize=4096)
    # Iterate stdout directly
    for line in proc.stdout:
        if line == '1000000\n':  # Avoid stripping
            print("This is the millionth line: Python")
            break
    # Prevent deadlocks by terminating, not waiting, child process
    proc.terminate()
    

    第二,纯 Python,主要是基于内置(C 级)API 的代码(消除了大多数无关的启动开销,并表明 Python 的 gzip 模块与 gzip 程序没有明显区别),可笑地微优化为可读性/可维护性/简洁性/可移植性的代价:

    #!/usr/bin/env python
    
    import os
    
    rpipe, wpipe = os.pipe()
    
    def reader():
        import gzip
        FILE = "million_lines.txt.gz"
        os.close(rpipe)
        with gzip.open(FILE) as inf, os.fdopen(wpipe, 'wb') as outf:
            buf = bytearray(16384)  # Reusable buffer to minimize allocator overhead
            while 1:
                cnt = inf.readinto(buf)
                if not cnt: break
                outf.write(buf[:cnt] if cnt != 16384 else buf)
    
    pid = os.fork()
    if not pid:
        try:
            reader()
        finally:
            os._exit()
    
    try:
        os.close(wpipe)
        with os.fdopen(rpipe, 'rb') as f:
            for line in f:
                if line == b'1000000\n':
                    print("This is the millionth line: Python")
                    break
    finally:
        os.kill(pid, 9)
    

    在我的本地系统上,经过六次运行,subprocess 代码占用:

    0.173s/0.157s/0.031s wall/user/sys time.
    

    没有外部实用程序的基于原语的 Python 代码可以将其降低到以下最佳时间:

    0.147s/0.103s/0.013s
    

    (虽然这是一个异常值;一个好的挂钟时间通常更像是 0.165)。将-E -S 添加到调用中,通过消除设置导入机制以处理非内置函数的开销,又节省了 0.01-0.015 秒的挂钟和用户时间;在其他 cmets 中,您提到您的 Python 需要将近 0.6 秒才能启动,完全什么都不做(但在其他方面似乎与我的表现相似),这可能表明您在非默认包或环境方面还有很多定制正在进行中,-E -S 可能会为您节省更多。

    Perl 代码,未根据您给我的内容进行修改(除了使用 3+ arg open 删除字符串解析并将从 open 返回的 pid 存储到显式 kill 在退出之前)有一个最好的时间:

    0.183s/0.216s/0.005s
    

    无论如何,我们谈论的是微不足道的差异(对于挂钟和用户时间而言,从运行到运行的时间抖动大约为 0.025 秒,因此 Python 在挂钟时间上的优势几乎是微不足道的,尽管它确实有效地节省了用户时间)。 Python 可以胜出,Perl 也可以,但与语言无关的问题更为重要。

    【讨论】:

      猜你喜欢
      • 2015-01-12
      • 2014-06-20
      • 2012-05-20
      • 2014-07-12
      • 1970-01-01
      • 1970-01-01
      • 2013-11-16
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多