【问题标题】:Conditional splitting of a HUGE file巨大文件的条件拆分
【发布时间】:2017-11-24 15:23:19
【问题描述】:

我有一个非常大的文件(>5 亿行),我想根据其中一列的前 3 个字符将其拆分为几个较小的文件。

看起来像这样,第 1 列和第 2 列的每个元素都是唯一的:

A0A023GPI8  A0A023GPI8.1    232300  1027923628
A0A023GPJ0  A0A023GPJ0.2    716541  765680613
A0A023PXA5  A0A023PXA5.1    559292  728048729
A0A023PXB0  A0A023PXB0.1    559292  728048786
A0A023PXB5  A0A023PXB5.1    559292  728048524
A0A023PXB9  A0A023PXB9.1    559292  728048769
A0A023PXC2  A0A023PXC2.1    559292  728050382

我使用以下脚本认为它会非常快,因为在我看来它涉及对整个文件的单次读取。但是,它已经运行了几天,还远未完成。有什么想法可以解释原因以及提出的解决方案吗?

while read line
do
    PREFIX=$(echo "$line" | cut -f2 | cut -c1-3)
    echo -e "$line" >> ../split_DB/$PREFIX.part
done < $file

【问题讨论】:

  • “fast”和“bash”是互不兼容的概念。 awk 可能会快几个数量级,尽管这取决于您最终得到多少输出文件。
  • 您期望有多少个唯一前缀?预先打开输出文件而不是一遍又一遍地重新打开同一个文件可能更有效。
  • Bash 是我选择的几乎所有的语言如果您真的需要,我>可以做这些事情。我会使用(perl|你的其他偏好)。
  • 你考虑过使用split吗?只需编写一个正则表达式来捕获要用作拆分标记的列部分。
  • 欢迎来到 Stack Overflow。请注意,在这里说“谢谢”的首选方式是投票赞成好的问题和有用的答案(一旦你有足够的声誉这样做),并接受对你提出的任何问题最有帮助的答案(这也给出了你的声誉小幅提升)。请查看About 页面以及How do I ask questions here?What do I do when someone answers my question?

标签: bash split


【解决方案1】:

read 效率不是很高;它必须一次读取一个字符以避免读取超过下一个换行符。然而,这里的一大开销来源是在每行调用cut 两次。我们可以通过再次使用read 进行拆分,并使用参数扩展提取第二列的第一个字符来避免这种情况。

while read -r line; do
    read -r _ col2 _ <<< "$line"
    prefix=${col2:0:3}
    # If the first column has a fixed width, you can forgo the
    # previous two lines and use
    #   prefix=${line:12:3}
    printf '%s\n' "$line" >> ../split_DB/$prefix.part
done < "$file"

但是,我不会花太多时间在bash 中尝试有效地做到这一点:这是一个快速而简单的 Python 脚本,它可以做同样的事情:

output_files = {}
with open(file) as fh:
    for line in fh:
        cols = line.strip().split()
        prefix = cols[1][0:3]
        # Cache the output file handles, so that each
        # is opened only once.
        outfh = output_files.setdefault(prefix, open("../split_DB/{}.part".format(prefix), "w"))
        print(line, file=outfh)
    # Close all the output files
    for f in output_files.values():
        f.close()

【讨论】:

  • 他的索引是第二列的前三个字符。你已经写了这两个,只取一个字符。否则,这很棒。
  • 我担心使用 python 脚本所有的“子文件”都会一直打开直到进程结束,所以主文件的 17G 将在内存中。不过我会测试这个解决方案并比较它的速度。
  • @dawg 啊,对。我认为这里的粘贴是将制表符转换为空格,这让我认为cut 1-3 是删除第二列 before 的两个空格。更新了两个脚本以处理 3 个字符的前缀。
  • @LucasA Python 没有将整个主文件读入内存;它一次只读取一行。 (或者更确切地说,它用多行填充一些固定大小的缓冲区,但内存使用量在任何情况下都是恒定的。)同样,子文件都是打开的,但不消耗它们使用的任何固定大小的写入缓冲区后面的任何内存。跨度>
【解决方案2】:

这可能很简单:

$ awk '{s=substr($2,1,3); print >> s}' file

&gt;&gt; 将打印重定向到附加给定名称的文件。名称由第二列的前 3 个字母组成。

这将比 Bash 处理此文件快得多。


注意:

通常,操作系统确实对同时打开的文件数有限制。这可能是个问题,具体取决于第二列前 3 个字符中潜在字符组合的数量。这将影响在处理给定文件时这些名称的文件保持打开状态的任何解决方案 - 而不仅仅是 awk。

如果您有000999,即打开了 999 个潜在文件;如果你有AAAZZZ,那就是 17,575;如果您有三个大小写字母数字,即 238,327 个 潜在 打开文件...如果您的数据只有几个唯一前缀,您可能无需担心这一点;如果您说明数据的详细信息,此处建议的解决方案可能会有所不同。

(您可以根据 3 个字符中允许的字母长度计算将 'ZZZ' 转换为十进制的潜在组合。('0'..'9','A'..'Z') 是基数 32 ('0'..'9','a'..'z','A'..'Z') 是基数 62,依此类推。)

如果需要(在合理范围内),您可以提高大多数 Unix 风格操作系统的限制,或者根据需要打开和关闭新文件。将文件限制提高到 238,327 是不切实际的。您还可以对数据进行排序并在之前的文件不再使用时关闭它。

【讨论】:

  • 最简单的解决方案,不错。我们确定执行“打印>>文件”后文件保持打开状态吗?如果不是,那么多次重新附加到同一个文件可能会很慢。所以,也许最好多读几遍源文件,但把每个分割文件都写完整。
  • @linuxfan:只要有空闲的文件描述符插槽,gawk 就会保持文件打开。另请参阅pubs.opengroup.org/onlinepubs/9699919799/utilities/…
  • 是的,除非使用awk 明确关闭,否则文件将保持打开状态
【解决方案3】:

为什么 shell 脚本很慢

速度慢的原因是,对于 5 亿行中的每一行,您都在强制您的 shell 创建 3 个进程,因此您的内核正在努力生成 15 亿个进程。假设它每秒可以处理一万个进程;你还在看 15 万秒,也就是 2 天。每秒 10k 进程很快;可能比你得到的要好十倍或更多。在我的 2016 年 15" MacBook Pro 上运行 macOS High Sierra 10.13.1,配备 2.7 GHz Intel Core i7、16 GB 2133 MHz LPDDR3 和 500 GB 闪存(大约 150 GB 空闲空间),我每秒处理大约 700 个进程,所以脚本名义上需要将近 25 天的时间来运行 5 亿条记录。

加快速度的方法

有一些方法可以使代码更快。您可以使用纯 shell、Awk、Python 或 Perl。请注意,如果您使用 Awk,它必须是 GNU Awk,或者至少不是 BSD (macOS) Awk — BSD 版本只是认为它没有足够的文件描述符。

我使用随机数据生成器创建了一个包含 100,000 个随机条目的文件,这与问题中的内容有些相似:

E1E583ZUT9  E1E583ZUT9.9    422255  490991884
Z0L339XJB5  Z0L339XJB5.0    852089  601069716
B3U993YMV8  B3U993YMV8.7    257653  443396409
L2F129EXJ4  L2F129EXJ4.8    942989  834728260
R4G123QWR2  R4G123QWR2.6    552467  744905170
K4Z576RKP0  K4Z576RKP0.9    947374  962234282
Z4R862HWX1  Z4R862HWX1.4    909520  2474569
L5D027SCJ5  L5D027SCJ5.4    199652  773936243
R5R272YFB5  R5R272YFB5.4    329247  582852318
G1I128BMI2  G1I128BMI2.6    359124  404495594

(使用的命令是即将重写的自制生成器。)前两列在模式X#X###XXX# 中具有相同的 10 个前导字符(X 表示字母,# 表示数字) ;唯一的区别在于后缀.#。这在脚本中没有被利用;一点也不重要。也不能保证第二列中的值是唯一的,如果出现 .2 条目,也不能保证出现 .1 条目作为键,等等。这些细节对于性能测量大多无关紧要。由于文件名使用字母数字字母前缀,因此 26 * 10 * 26 = 6760 个可能的文件前缀。对于 100,000 条随机生成的记录,这些前缀中的每一个都存在。

我编写了一个脚本来计时处理数据的各种方式。有 4 个 shell 脚本变体——由 OPLucas A 发布的一个变体;两个由chepner 发布(一个是 cmets),一个是我创建的。还有 dawg 创建的 Awk 脚本,chepner 发布的 Python 3 脚本的轻微修改版本,以及我编写的 Perl 脚本。

结果

结果可以通过这张表来总结(运行时间以经过时间或挂钟时间的秒数衡量):

╔═════════════════╦════╦═════════╦═════════╦═════════╦═════════╗
║  Script Variant ║  N ║    Mean ║ Std Dev ║     Min ║     Max ║
╠═════════════════╬════╬═════════╬═════════╬═════════╬═════════╣
║   Lucas A Shell ║ 11 ║ 426.425 ║  16.076 ║ 408.044 ║ 456.926 ║
║ Chepner 1 Shell ║ 11 ║  39.582 ║   2.002 ║  37.404 ║  43.609 ║
║         Awk 256 ║ 11 ║  38.916 ║   2.925 ║  30.874 ║  41.737 ║
║ Chepner 2 Shell ║ 11 ║  16.033 ║   1.294 ║  14.685 ║  17.981 ║
║   Leffler Shell ║ 11 ║  15.683 ║   0.809 ║  14.375 ║  16.561 ║
║     Python 7000 ║ 11 ║   7.052 ║   0.344 ║   6.358 ║   7.771 ║
║        Awk 7000 ║ 11 ║   6.403 ║   0.384 ║   5.498 ║   6.891 ║
║       Perl 7000 ║ 11 ║   1.138 ║   0.037 ║   1.073 ║   1.204 ║
╚═════════════════╩════╩═════════╩═════════╩═════════╩═════════╝

原来的shell脚本比Perl慢2.5个数量级;当有足够的文件描述符可用时,Python 和 Awk 的性能几乎相同(如果没有足够的文件描述符可用,Python 就会停止;Perl 也是如此)。编写 shell 脚本的速度大约是 Python 或 Awk 的一半。

7000 表示需要打开的文件数 (ulimit -n 7000)。这是因为生成的数据中有 26 * 10 * 26 = 6760 个不同的 3 字符起始码。如果您有更多模式,您将需要更多打开的文件描述符以获得保持它们全部打开的好处,或者您将需要编写一种文件描述符缓存算法,有点像 GNU Awk 必须使用的算法,具有相应的性能损失。请注意,如果数据按排序顺序显示,以便每个文件的所有条目按顺序显示,那么您将能够调整算法,以便一次只打开一个输出文件。随机生成的数据没有按顺序排列,因此对任何缓存算法都有很大影响。

脚本

以下是本练习中测试的各种脚本。这些以及大部分支持材料都可以在 GitHub 上的soq/src/so-4747-6170 中获得。 并非所有使用的代码都存在于 GitHub 中。

Lucas A Shell — 又名 opscript.sh

cat "$@" |
while read line
do
    PREFIX=$(echo "$line" | cut -f2 | cut -c1-3)
    echo -e "$line" >> split_DB/$PREFIX.part
done

这是对cat 的一种并非完全无用的用法(参见UUoC — Useless Use of cat 进行比较)。如果没有提供参数,它将标准输入复制到while 循环;如果提供了任何参数,它们将被视为文件名并传递给cat,并将这些文件的内容复制到while 循环。原始脚本中有一个硬连线&lt; file。在这里使用cat 没有可衡量的性能成本。 Chepner 的 shell 脚本也需要进行类似的更改。

Chepner 1 Shell — 又名 chepner-1.sh

cat "${@}" |
while read -r line; do
    read -r _ col2 _ <<< "$line"
    prefix=${col2:0:3}
    printf '%s\n' "$line" >> split_DB/$prefix.part
done

Chepner 2 Shell — 又名 chepner-2.sh

cat "${@}" |
while read -r line; do
    prefix=${line:12:3}
    printf '%s\n' "$line" >> split_DB/$prefix.part
done

莱弗勒壳牌——又名jlscript.sh

sed 's/^[^ ]*  \(...\)/\1 &/' "$@" |
while read key line
do
    echo "$line" >> split_DB/$key.part
done

Awk 脚本 - 又名 awkscript.sh

exec ${AWK:-awk} '{s=substr($2,1,3); print >> "split_DB/" s ".part"}' "$@"

这在脚本的紧凑性方面胜出,并且在使用 GNU Awk 和足够的可用文件描述符运行时具有不错的性能。

Python 脚本——又名pyscript.py

这是一个 Python 3 脚本,是 Chepner 发布的内容的轻微修改版本。

import fileinput

output_files = {}
#with open(file) as fh:
#    for line in fh:
for line in fileinput.input():
    cols = line.strip().split()
    prefix = cols[1][0:3]
    # Cache the output file handles, so that each is opened only once.
    #outfh = output_files.setdefault(prefix, open("../split_DB/{}.part".format(prefix), "w"))
    outfh = output_files.setdefault(prefix, open("split_DB/{}.part".format(prefix), "w"))
    print(line, file=outfh)

# Close all the output files
for f in output_files.values():
    f.close()

Perl 脚本 — 又名 jlscript.pl

#!/usr/bin/env perl
use strict;
use warnings;

my %fh;

while (<>)
{
    my @fields = split;
    my $pfx = substr($fields[1], 0, 3);
    open $fh{$pfx}, '>>', "split_DB/${pfx}.part" or die
        unless defined $fh{$pfx};
    my $fh = $fh{$pfx};
    print $fh $_;
}

foreach my $h (keys %fh)
{
    close $fh{$h};
}

测试脚本——又名test-script.sh

#!/bin/bash
#
# Test suite for SO 4747-6170

set_num_files()
{
    nfiles=${1:-256}
    if [ "$(ulimit -n)" -ne "$nfiles" ]
    then if ulimit -S -n "$nfiles" 
         then : OK
         else echo "Failed to set num files to $nfiles" >&2
              ulimit -HSa >&2
              exit 1
         fi
     fi
}

test_python_7000()
{
    set_num_files 7000
    timecmd -smr python3 pyscript.py "$@"
}

test_perl_7000()
{
    set_num_files 7000
    timecmd -smr perl jlscript.pl "$@"
}

test_awk_7000()
{
    set_num_files 7000
    AWK=/opt/gnu/bin/awk timecmd -smr sh awkscript.sh "$@"
}

test_awk_256()
{
    set_num_files 256   # Default setting on macOS 10.13.1 High Sierra
    AWK=/opt/gnu/bin/awk timecmd -smr sh awkscript-256.sh "$@"
}

test_op_shell()
{
    timecmd -smr sh opscript.sh "$@"
}

test_jl_shell()
{
    timecmd -smr sh jlscript.sh "$@"
}

test_chepner_1_shell()
{
    timecmd -smr bash chepner-1.sh "$@"
}

test_chepner_2_shell()
{
    timecmd -smr bash chepner-2.sh "$@"
}

shopt -s nullglob

# Setup - the test script reads 'file'.
# The SOQ global .gitignore doesn't permit 'file' to be committed.
rm -fr split_DB
rm -f file
ln -s generated.data file

# Ensure cleanup
trap 'rm -fr split_DB; exit 1' 0 1 2 3 13 15

for function in \
    test_awk_256 \
    test_awk_7000 \
    test_chepner_1_shell \
    test_chepner_2_shell \
    test_jl_shell \
    test_op_shell \
    test_perl_7000 \
    test_python_7000
do
    mkdir split_DB
    boxecho "${function#test_}"
    time $function file
    # Basic validation - the same information should appear for all scripts
    ls split_DB | wc -l
    wc split_DB/* | tail -n 2
    rm -fr split_DB
done

trap 0

此脚本是使用命令行符号运行的:

time (ulimit -n 7000; TRACEDIR=. Trace bash test-script.sh)

Trace 命令将所有标准输出和标准错误记录到一个 lgo 文件,并将其回显到它自己的标准输出,它报告广义上的“环境”(环境变量、ulimit 设置、日期、时间、命令、当前目录、用户/组等)。运行完整的测试集只用了不到 10 分钟,其中四分之三用于运行 OP 的脚本。

【讨论】:

  • @MarkSetchell:是的。我已经更新了答案以明确指出,并指出这是挂钟时间,而不是 CPU 时间。谢谢!
  • 哇,干得好。我测试了 perl 解决方案,它在几个小时内完成了这项工作,而我的 bash 脚本自上周五以来仍在运行......感谢脚本,此外,非常感谢您的解释
  • @LucasA:很高兴你发现它有帮助。 Perl 通常是没有自定义 C 程序的最快替代方案。请注意,如果数据按排序顺序显示,而不是像我测试的样本数据那样完全随机显示,则不需要过多的文件描述符。在管道中添加排序并没有太大改变整体运行时间,但确实意味着不再需要处理文件描述符的数量。但是,我正在使用 1/5000 比例文件,这些文件都可以放入内存中进行排序。在全范围内,排序可能使用磁盘,速度较慢。
  • 如果数据是按排序顺序生成的,你应该利用它。
  • 多么棒的答案。谢谢。不过有一条评论:如果我算一算,('0'..'9','A'..'Z') 的三位数字本质上是'ZZZ' 的 Base32 解码,即 46,655。如果将该字母表扩展为('0'..'9','a'..'z','A'..'Z') 的三位数字,则给出三位数字是238,327 个潜在字符串。 (只需对'ZZZ'进行Base62转换)所以我认为对7,000个文件的分析处理潜力很低。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-03-24
  • 2019-07-14
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多