【问题标题】:Need serious help optimizing script for memory use需要认真帮助优化脚本以使用内存
【发布时间】:2019-05-22 00:16:20
【问题描述】:

[在实施人们的建议后,我已更改以下代码以反映我当前正在运行的内容]

让我先声明一下,我不是程序员,只是一个使用 Perl 尽我所能完成某些文本处理工作的人。

我有一个生成频率列表的脚本。它基本上做了以下事情:

  • 从格式为$frequency \t $item 的文件中读取行。任何给定的$item 都可能出现多次,$frequency 的值不同。
  • 根据$item的内容删除某些行。
  • 对所有相同$items 的频率求和,无论大小写如何,并将这些条目合并为一个。
  • 对结果数组执行反向自然排序。
  • 将结果打印到输出文件。

该脚本在最大约 1 GB 的输入文件上运行良好。但是,我需要处理高达 6 GB 的文件,但由于内存使用,这已被证明是不可能的。虽然我的机器有 32 GB 的 RAM,使用 zRam,并且为此目的在 SSD 上有 64 GB 的交换空间,但当内存使用量达到 70 GB 左右(92 GB)时,脚本将不可避免地被 Linux OOM 服务杀死总计)。

当然,真正的问题是我的脚本正在使用大量内存。我可以尝试添加更多交换,但我现在已经增加了两次,它就被吃光了。

所以我需要以某种方式优化脚本。这就是我在这里寻求帮助的原因。

下面是我现在正在运行的脚本的实际版本,保留了一些希望有用的 cmets。

如果您的 cmets 和建议包含足够的代码以实际允许我或多或少将其放入现有脚本中,我将非常感谢,因为我 没有 em> 一个程序员,正如我上面所说的,即使是像管道通过某个模块或另一个模块处理的文本这样看似简单的事情也会让我陷入严重的困境。

提前致谢!

(顺便说一句,我在 Ubuntu 16.04 LTS x64 上使用 Perl 5.22.1 x64。

#!/usr/bin/env perl

use strict;
use warnings;
use warnings qw(FATAL utf8);
use Getopt::Long qw(:config no_auto_abbrev);

# DEFINE VARIABLES
my $delimiter            = "\t";
my $split_char           = "\t";

my $input_file_name  = "";
my $output_file_name = "";
my $in_basename      = "";
my $frequency        = 0;
my $item             = "";

# READ COMMAND LINE OPTIONS
GetOptions (
             "input|i=s"         => \$input_file_name,
             "output|o=s"        => \$output_file_name,
           );

# INSURE AN INPUT FILE IS SPECIFIED
if ( $input_file_name eq "" ) {
    die
      "\nERROR: You must provide the name of the file to be processed with the -i switch.\n";
}

# IF NO OUTPUT FILE NAME IS SPECIFIED, GENERATE ONE AUTOMATICALLY
if ( $output_file_name eq "" ) {

    # STRIP EXTENSION FROM INPUT FILE NAME
    $in_basename = $input_file_name;
    $in_basename =~ s/(.+)\.(.+)/$1/;

    # GENERATE OUTPUT FILE NAME FROM INPUT BASENAME
    $output_file_name = "$in_basename.output.txt";
}

# READ INPUT FILE
open( INPUTFILE, '<:encoding(utf8)', $input_file_name )
    or die "\nERROR: Can't open input file ($input_file_name): $!";

# PRINT INPUT AND OUTPUT FILE INFO TO TERMINAL
print STDOUT "\nInput file:\t$input_file_name";
print STDOUT "\nOutput file:\t$output_file_name";
print STDOUT "\n\n";

# PROCESS INPUT FILE LINE BY LINE
my %F;

while (<INPUTFILE>) {

    chomp;

    # PUT FREQUENCY IN $frequency AND THEN PUT ALL OTHER COLUMNS INTO $item
    ( $frequency, $item ) = split( /$split_char/, $_, 2 );

    # Skip lines with empty or undefined content, or spaces only in $item
    next if not defined $frequency or $frequency eq '' or not defined $item or $item =~ /^\s*$/;

    # PROCESS INPUT LINES
    $F{ lc($item) } += $frequency;
}
close INPUTFILE;

# OPEN OUTPUT FILE
open( OUTPUTFILE, '>:encoding(utf8)', "$output_file_name" )
    || die "\nERROR: The output file \($output_file_name\) couldn't be opened for writing!\n";

# PRINT OUT HASH WITHOUT SORTING
foreach my $item ( keys %F ) {
    print OUTPUTFILE $F{$item}, "\t", $item, "\n";
}

close OUTPUTFILE;

exit;

以下是来自源文件的一些示例输入。它是制表符分隔的,第一列是$frequency,其余的一起是$item

2   útil    volver  a   valdivia
8   útil    volver  la  vista
1   útil    válvula de  escape
1   útil    vía de  escape
2   útil    vía fax y
1   útil    y   a   cabalidad
43  útil    y   a   el
17  útil    y   a   la
1   útil    y   a   los
21  útil    y   a   quien
1   útil    y   a   raíz
2   útil    y   a   uno

【问题讨论】:

  • 也许使用分而治之的方法?将大文件拆分成几个小文件,处理它们,汇总结果。
  • 这不起作用,因为 $item 的给定值(例如,“apple”)可能在整个输入文件中出现 1000 次,每次都以不同的频率出现。而且我需要合并“苹果”(或其他)的所有实例并将它们的所有频率相加。将输入文件分成多个文件只会在每个拆分文件中实现这一点,而不是全局。
  • 全局发生在合并和聚合结果时。如果不以某种方式拆分它,您将无法找到一种方法。
  • 我实际上已经在预处理步骤中拆分了它,以清除无效条目。这将总文件大小减少了 3-5%,这仍然给我留下了大量文件,当我将它们合并回来时我无法处理这些文件。我相信,关键是我设法让 Perl 在处理 3-6 GB 的文件时使用 70+ GB 的内存。因此,我确信我在编程级别做错了什么。我的意思是,老实说——如果有 IgNobel 编码奖,我就赢了。
  • 为什么在 while 循环中有 END { ... } 块?这没有多大意义。您确定 %F 哈希是内存使用的罪魁祸首吗?使用Devel::Size 进行验证。

标签: perl


【解决方案1】:

更新 在我的测试中,哈希占用的内存是其数据“单独”占用的内存的 2.5 倍。然而,program 对我来说是变量的 3-4 倍。这会将6.3Gb 数据文件转换为~ 15Gb 散列,用于~ 60Gb 程序,正如cmets 中报告的那样。

所以6.3Gb == 60Gb,可以这么说。这仍然足以改善起始情况,因此可以解决当前问题,但显然不是解决方案。请参阅下面的(更新的)另一种方法,了解在不加载整个哈希的情况下运行此处理的方法。


没有什么明显的东西会导致数量级的内存爆炸。然而,小错误和低效率可能会加起来,所以让我们先清理一下。最后查看其他方法。

这里是对核心程序的简单重写,先试试。

# ... set filenames, variables 
open my $fh_in, '<:encoding(utf8)', $input_file_name
    or die "\nERROR: Can't open input file ($input_file_name): $!";

my %F;    
while (<$fh_in>) {    
    chomp;
    s/^\s*//;                                              #/trim  leading space
    my ($frequency, $item) = split /$split_char/, $_, 2;

    # Skip lines with empty or undefined content, or spaces only in $item
    next if not defined $frequency or $frequency eq '' 
         or not defined $item      or $item =~ /^\s*$/;

    # ... increment counters and aggregates and add to hash
    # (... any other processing?)
    $F{ lc($item) } += $frequency;
}
close $fh_in;

# Sort and print to file
# (Or better write: "value key-length key" and sort later. See comments)
open my $fh_out, '>:encoding(utf8)', $output_file_name 
    or die "\nERROR: Can't open output file ($output_file_name\: $!";

foreach my $item ( sort { 
        $F{$b} <=> $F{$a} || length($b) <=> length($a) || $a cmp $b 
    } keys %F )
{
    print $fh_out $F{$item}, "\t", $item, "\n";
}
close $fh_out;

一些cmets,如果需要更多,请告诉我。

  • 始终将$! 添加到与错误相关的打印中,以查看实际错误。见perlvar

  • 使用词法文件句柄(my $fh 而不是IN),这样会更好。

  • 1234563 p>

这里的sort 必须至少复制其输入,并且在多个条件下需要更多内存。

占用的内存不应超过哈希大小的 2-3 倍。虽然最初我怀疑内存泄漏(或过多的数据复制),但通过将程序简化为基础,表明“正常”程序大小是(可能的)罪魁祸首。这可以通过设计自定义数据结构和经济地打包数据来进行调整。

当然,如果您的文件会变得越来越大,所有这些都是摆弄,因为它们往往会这样做。

另一种方法是写出未排序的文件,然后使用单独的程序进行排序。这样,您就不会将处理过程中可能出现的内存膨胀与最终排序结合起来。

但即使这样也突破了极限,因为与数据相比,内存占用大大增加,因为哈希占用了数据大小的 2.5 倍,而整个程序仍然是 3-4 大。

然后找到一个算法将数据逐行写入输出文件。这很简单,因为通过显示的处理,我们只需要为每个项目累积频率

open my $fh_out, '>:encoding(utf8)', $output_file_name 
    or die "\nERROR: Can't open output file ($output_file_name\: $!";

my $cumulative_freq;

while (<$fh_in>) {
    chomp;
    s/^\s*//;  #/ leading only
    my ($frequency, $item) = split /$split_char/, $_, 2;

    # Skip lines with empty or undefined content, or spaces only in $item
    next if not defined $frequency or $frequency eq '' 
         or not defined $item      or $item =~ /^\s*$/;

    $cumulative_freq += $frequency;  # would-be hash value

    # Add a sort criterion, $item's length, helpful for later sorting
    say $fh_out $cumulative_freq, "\t", length $item, "\t", lc($item);

    #say $fh_out $cumulative_freq, "\t", lc($item);
}
close $fh_out;

现在我们可以使用系统的sort,它针对非常大的文件进行了优化。由于我们编写了一个包含所有排序列的文件value key-length key,因此在终端中运行

sort -nr -k1,1 -k2,2 output_file_name | cut -f1,3-  > result

该命令按第一个字段的数字排序,然后按第二个字段(然后它本身按第三个排序)并颠倒顺序。这通过管道传输到cut,它从STDIN 中提取第一个和第三个字段(使用制表符作为默认分隔符),需要什么结果。

系统的解决方案是使用数据库,一个很方便的就是DBD::SQLite


我使用Devel::Size 来查看变量使用的内存。

【讨论】:

  • 评论不用于扩展讨论;这个对话是moved to chat
  • @GilWilliams 长时间的交流被转移到聊天中,请查看我的最后评论。一个重要部分:您是否在(已编辑)另一种方法下看到了我对答案的补充(或者是否有理由不适合您)?它逐行读取并打印到文件,应该可以解决内存问题。
【解决方案2】:

对输入进行排序需要将所有输入保存在内存中,因此您不能在一个进程中完成所有操作。

但是,可以考虑排序:您可以轻松地将输入排序到可排序的存储桶中,然后处理这些存储桶,并通过以反向排序的存储桶顺序组合输出来生成正确的输出。频率计数也可以按桶进行。

所以只要保留你拥有的程序,但在它周围添加一些东西:

  1. 将您的输入分区到桶中,例如按第一个字符或前两个字符
  2. 在每个存储桶上运行您的程序
  3. 以正确的顺序连接输出

您的最大内存消耗将略高于原始程序在最大存储桶上的消耗。所以如果你的分区选得好,你可以随意把它往下压。

您可以将输入存储桶和每个存储桶的输出存储到磁盘,但您甚至可以直接使用管道连接步骤(为每个存储桶处理器创建一个子进程) - 这将创建大量并发进程,因此操作系统将疯狂地进行分页,但如果你小心,它就不需要写入磁盘。

这种分区方式的一个缺点是您的存储桶的大小最终可能会非常不均匀。另一种方法是使用保证平均分配输入的分区方案(例如,通过将每 n 行输入放入第 n 个桶中),但这使得合并输出更复杂。

【讨论】:

  • 使用 GNU sort 实用程序,排序实际上是一件轻而易举的事!虽然需要大约 3 小时(在 Core i7 上)和 60+ GB 的 RAM 和交换来运行 Perl 代码,该代码读取源文件并对 3.6 GB 文件上相同项目的频率求和,但对类似大小的输出文件进行排序sort 仅使用大约 10 GB 的 RAM 并且不到 20 分钟,使用 parallel=7 标志来使用我的 8 个内核中的 7 个。
  • 它可能使用相同的原理。我知道它使用磁盘上的临时文件。
猜你喜欢
  • 1970-01-01
  • 2012-01-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-01-27
  • 1970-01-01
相关资源
最近更新 更多