Python与Perl:读取gzip文件的性能比较

10

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

$ 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:

我现在也尝试了使用subprocess Python模块,就像@Kirk Strauser和其他人建议的那样。 它更快:

Python“subproc”解决方案:

# 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

3
你尝试过使用 Perl gzip 库或 Python 中的外部 gzip 管道吗? - OrangeDog
据我所知,Python的gzip模块是用Python编写的,因此性能相当差。OrangeDog建议外部运行gzip并将解压缩的输出传输到Python可能会加快速度。 - user2357112
1
请查看 https://www.reddit.com/r/Python/comments/2olhrf/fast_gzip_in_python/ - Markus
1
哇,这对我来说非常不直观,"shelling out"到zcat是最理想的... - asf107
1
@asf107:问题在于你在第一个案例中并没有真正使用Perl。话说,Perl是为文本处理而设计的;如果任务只涉及消耗文本和处理它,那么Perl很可能会胜出。事实上,在我的经验中,Perl通常更快一些(尽管当你使用其混合的面向对象特性时会失去优势),因为它具有可变字符串(Python在strip或切片时会复制,Perl在chomp时会就地修改),以及改进的名称查找(Perl在编译时链接非面向对象名称;Python在运行时反复查找)。 - ShadowRanger
显示剩余5条评论
5个回答

7
经过测试,看起来这里最大的罪魁祸首是:
  1. 比较苹果和橙子:在您的原始测试用例中,Perl没有执行文件I/O或解压缩工作,而是由gzip程序执行(它是用C编写的,因此运行非常快);在那个版本的代码中,您正在将并行计算与串行计算进行比较。
  2. 解释器启动时间;在绝大多数系统上,Python需要更长的时间才能开始运行(我认为是因为启动时加载了更多的文件)。在我的机器上,解释器启动时间约占总墙钟时间的一半,用户时间的30%,大部分系统时间。 Python实际执行的工作被启动时间淹没,因此您的基准测试与其说是比较所需工作的时间,不如说是比较启动时间。 后来的补充:您可以通过使用 -E 开关(以禁用启动时对 PYTHON * 环境变量的检查)和 -S 开关(以禁用自动 import site ,从而避免了涉及磁盘I / O的许多动态 sys.path 设置/操作,但代价是无法访问任何非内置库)来进一步减少Python启动时的开销。
  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秒的墙上钟和用户时间。在其他评论中,您提到您的Python什么也不做就需要近0.6秒才能启动(但在其他方面似乎与我的表现类似),这可能表明您拥有更多的非默认软件包或环境自定义,并且-E -S可以为您节省更多时间。

Perl代码与您给我的代码一样(除了使用3个以上的arg open以删除字符串解析并存储从open返回的pid以在退出之前显式kill它),最佳时间为:

0.183s/0.216s/0.005s

无论如何,我们谈的都是微不足道的差异(从一次运行到另一次运行的时间抖动在墙钟和用户时间方面约为0.025秒,因此Python在墙钟时间上的胜利大多数是不显著的,尽管它在用户时间上确实有意义地节省了时间)。 Python和Perl都可以获胜,但非语言相关问题更为重要。


0

如果我是一个赌徒,我会打赌:

line = line.strip()

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

考虑到您准确知道数据的外观,我建议尝试将您的循环更改为以下内容:

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

我想我有点过于急躁并且回答得太快了。我相信你是对的:Perl通过在单独的进程中运行gzip来“作弊”。查看从subprocess.Popen异步读取stdout的方法,以了解在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()

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


我知道,我也这么想过!然而,你提出的建议并没有显著加速脚本……我会在我的原始帖子中发布Python分析信息。 - asf107
1
for line in iter(gzip.stdout.readline, ''):жҳҜдёҖз§Қж„ҡи ўзҡ„ж–№ејҸжқҘйҮҚж–°еҸ‘жҳҺfor line in gzip.stdout:... еҸҰеӨ–пјҢжҲ‘жҖҖз–‘дҪ жғіиҰҒterminate/killиҝҷдёӘиҝӣзЁӢпјҢиҖҢдёҚжҳҜеңЁе®ғдёҠйқўwaitпјӣеӣ дёәдҪ иҝҳжІЎжңүж¶ҲиҖ—е®Ңе®ғзҡ„stdoutпјҢжүҖд»ҘеҪ“з®ЎйҒ“еЎ«ж»Ўж—¶пјҢе®ғе°Ҷдјҡиў«йҳ»еЎһпјҢиҖҢдҪ жӯЈеңЁзӯүеҫ…е®ғйҖҖеҮәгҖӮ - ShadowRanger
好的,我会进行建议的更改并更新上面的内容。 - asf107
@ShadowRanger,你看到J.F. Sebastian使用它的原因了吗? - Kirk Strauser
@KirkStrauser:我之前没有考虑过这个问题,但是看了一下,使用这种方法的主要原因似乎是为了避免从间歇性填充的管道中读取时出现延迟;readline(仅适用于Python 2;Python 3修复了这个差异)可以让你立即获取一行,而正常迭代可能会等待缓冲区填满才能找到并返回该行。在这种情况下,gzip正在以比我们读取和处理更快的速度向管道提供数据,所以这不是一个问题。 - ShadowRanger
显示剩余3条评论

0

你让我很好奇...

以下的Python脚本在我的电脑上始终比Perl解决方案表现更好:对于10,000,000行,它只需要3.2秒,而Perl则需要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解决方案更有效率。

0

这个版本比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

1
如果你在调用subprocess.Popen时传递了一个额外的参数universal_newlines=True,那么行尾转换就会自动执行(这样你实际上可以保证行尾是\n)。这还使得代码可以在Python 3中移植(除非你传递了universal_newlines=True,否则Popen返回的是bytes而不是str)。或者,你可以在循环外声明 needle='1000000' + os.linesep,并在循环中测试 line == needle 以匹配操作系统的行尾期望。 - ShadowRanger
有趣的是,即使在我的电脑上,上面的代码仍然运行得更慢。 - asf107
1
@asf107: 在某个点上,你正在计时的大部分都是解释器启动和实际解压缩执行的开销,而不是完成的工作。在我的机器上,当使用一个什么也不做但打印一个空行的脚本(在Python的情况下,导入“subprocess”但没有使用它)重复启动时,两个程序都被哈希,但是Perl启动速度要快得多。 time 报告了time perl noop.pl需要约0.01s / 0.002s / 0.005s进行真实/用户/系统方面的操作。 time python noop.py 需要约0.110s / 0.035s / 0.040s。 Python 的 noop 时间分别为读取一百万次时间的50%,25%和90%。 - ShadowRanger
1
@asf107: 续前。当解释器的启动开销在工作时间中占据了很大一部分时,您的基准测试就有缺陷了;在“真实”的程序中,启动开销(只要在人类时间尺度上不可见)很少有影响;您需要比较单独完成工作的时间,或者做足够的工作来淹没启动开销(最好在单个会话中多次进行工作,以最短时间完成工作,以最小化计时抖动)。这里的工作太少,不值一提。 - ShadowRanger
哇……我的 Python 启动时间平均接近 0.600 秒!天啊……谢谢你指出来。 - asf107
@asf107:考虑到你的解释器在其他方面表现类似于我的,这听起来像是你可能有一些环境定制或安装了大量第三方软件包,这会增加启动过程的开销。这很容易减慢 Python 的启动速度,并使你的结果在其他系统上无法重现。我在我的答案中添加了一个注释;你可以尝试使用 -E -S 运行你的基准代码以禁用额外功能。这不会让基准测试变得“好”,但至少会“不那么糟”。 - ShadowRanger

0

看起来gzip文件的next()方法,在for line in中使用时似乎非常缓慢 - 可能是因为它谨慎地读取未压缩的流以查找换行符,可能是为了控制内存使用。

当然,你正在比较苹果和橙子,其他人已经对Python分叉gunzip和Perl分叉gunzip进行了更好的比较。这些可能有效,因为它们在单独的进程中将相对较大的未压缩字符串转储到其stdout中。

一种非内存安全且潜在浪费的方法是:

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文件,这是不可能的。 - asf107

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接