不加载到内存中打乱大型项目列表

19 投票
8 回答
8271 浏览
提问于 2025-04-18 11:35

我有一个大约有20亿行文本的文件(大约200GB)。我想生成一个新文件,里面的文本行顺序是随机打乱的。但是我无法把所有数据都放在内存里。有没有什么好的方法可以用Python或者命令行来实现,时间上能接受(几天内完成)?

我在想,我可以先创建50个空文件。然后逐行读取这20亿行的文件,把每一行随机分配到这50个空文件中。最后再把这50个文件合并起来。这样做会不会有什么大的系统性偏差呢?

8 个回答

0

你可以创建一个迭代器,用来生成排列。每次读取文件时,你可以根据它提供的排列来调整读取的位置。因为这个迭代器会给出不同的排列,所以你永远不会重复读取相同的数据。

对于N个元素的集合,所有的排列都可以通过交换来生成。交换就是把第0个元素和第i个元素的位置互换,而其他元素的位置保持不变。因此,你可以通过组合一些随机选择的交换来生成一个随机的排列。下面是一个用Python写的例子:

import random

class Transposer:
    def __init__(self,i):
        """
        (Indexes start at 0)
        Swap 0th index and ith index, otherwise identity mapping.
        """
        self.i = i
    def map(self,x):
        if x == 0:
            return self.i
        if x == self.i:
            return 0
        return x

class RandomPermuter:
    def __init__(self,n_gens,n):
        """
        Picks n_gens integers in [0,n) to make transposers that, when composed,
        form a permutation of a set of n elements. Of course if there are an even number of drawn
        integers that are equal, they cancel each other out. We could keep
        drawing numbers until we have n_gens unique numbers... but we don't for
        this demo.
        """
        gen_is = [random.randint(0,n-1) for _ in range(n_gens)]
        self.trans = [Transposer(g) for g in gen_is]
    def map(self,x):
        for t in self.trans:
            x = t.map(x)
        return x

rp = RandomPermuter(10,10)

# Use these numbers to seek into a file
print(*[rp.map(x) for x in range(10)])
2

在你的情况下,最简单的方法就是进行递归的洗牌和分割 - 洗牌 - 合并。你需要定义两个数字:一个是你想把一个文件分成多少个小文件,这个数字叫做 N(通常在32到256之间),另一个是你可以在内存中直接洗牌的文件大小,这个大小叫做 M(通常大约是128MB)。然后你可以用伪代码表示:

def big_shuffle(file):
    if size_of(file) < M :
        memory_shuffle(file)
    else:
        create N files
        for line in file:
            write_randomly_to_one_of_the_N_files
        for sub_file in (N_files):
            big_shuffle(file)
        merge_the_N_files_one_line_each

由于每个子文件都经过洗牌,所以不会有偏差。

这个方法的速度会比Alex Reynolds的方案慢很多(因为涉及到大量的磁盘读写),但你唯一的限制就是磁盘空间。

5

你可以看看我的 HugeFileProcessor 工具。这个工具和 @Alex-Reynolds 的 sample 类似,但应该会快很多,因为它不需要跳来跳去。

下面是关于打乱实现的细节。它需要你指定 batchSize,也就是在写入输出时保留在内存中的行数。这个数字越大越好(除非你的内存不够),因为总的打乱时间可以用 (源文件中的行数) / batchSize * (读取源文件所需的时间) 来计算。请注意,这个程序是 对整个文件进行打乱,而不是按批次来处理。

算法大致如下。

  1. 先计算 sourceFile 中的行数。这可以通过逐行读取整个文件来完成。(你可以在 这里 查看一些比较。)这也能让我们知道读取整个文件一次需要多长时间。因此,我们可以估算出完成一次完整打乱需要多少次读取,因为这需要 Ceil(行数 / batchSize) 次完整的文件读取。

  2. 现在我们知道了总的 linesCount,我们可以创建一个大小为 linesCount 的索引数组,并使用 Fisher–Yates 算法对它进行打乱(在代码中称为 orderArray)。这将给我们一个想要在打乱后的文件中行的顺序。注意,这个顺序是针对整个文件的,而不是按批次或块来处理。

  3. 接下来是实际的代码。我们需要按照刚刚计算的顺序从 sourceFile 中获取所有行,但我们不能把整个文件都读到内存中。所以我们将任务分成几部分来处理。

    • 我们会遍历 sourceFile,读取所有行,并只在内存中存储 orderArray 中前 batchSize 的行。当我们获取到这些行后,就可以按照需要的顺序将它们写入 outFile,这就完成了 batchSize/linesCount 的工作。
    • 接下来,我们会重复这个过程,继续处理 orderArray 中的下一部分,每次都从头到尾读取 sourceFile。最终,整个 orderArray 都会被处理完,我们就完成了。

为什么这样有效?

因为我们做的就是从头到尾读取源文件。没有前后跳转,这正是硬盘喜欢的方式。文件会根据内部硬盘缓冲区、文件系统块、CPU缓存等被分块读取,所有内容都是顺序读取的。

一些数字

在我的机器上(Core i5,16GB内存,Win8.1,Toshiba DT01ACA200 2TB硬盘,NTFS),我能在大约5小时内用 batchSize 为 3,500,000 的设置打乱一个132GB(84,000,000行)的文件。使用 batchSize 为 2,000,000 时,花了大约8小时。读取速度大约是每秒118,000行。

7

这样怎么样:

import mmap
from random import shuffle

def find_lines(data):
    for i, char in enumerate(data):
        if char == '\n':
            yield i 

def shuffle_file(in_file, out_file):
    with open(in_file) as f:
        data = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
        start = 0
        lines = []
        for end in find_lines(data):
            lines.append((start, end))
            start = end + 1
        shuffle(lines)

        with open(out_file, 'w') as out:
            for start, end in lines:
                out.write(data[start:end+1])

if __name__ == "__main__":
    shuffle_file('data', 'result')

这个解决方案只需要存储文件中每一行的位置信息,也就是每行需要2个字的空间,加上一些额外的存储开销。

8

如果你能为这个程序预留16GB的内存,我写了一个叫sample的程序,它可以通过读取文件中每一行的字节偏移量来打乱文件的行顺序。具体做法是先打乱这些偏移量,然后根据打乱后的偏移量在文件中查找并输出内容。每个64位的偏移量占用8个字节,所以对于一个有20亿行的输入文件,就需要16GB的内存。

这个程序的速度可能不快,但在内存足够的系统上,sample可以处理那些大到GNU shuf无法处理的文件。此外,它还使用了mmap的方式,尽量减少第二次读取文件时的输入输出开销。它还有一些其他选项,具体可以查看--help获取更多信息。

默认情况下,这个程序会进行不重复的抽样,并逐行打乱。如果你想要重复抽样,或者你的输入是FASTA、FASTQ或其他多行格式,可以添加一些选项来调整抽样方式。(或者你可以参考我下面链接的Perl代码,虽然sample已经处理了这些情况。)

如果你的FASTA序列每两行一组,也就是说一行是序列头,下一行是序列数据,你仍然可以使用sample进行打乱,而且只需要一半的内存,因为你只需打乱一半的偏移量。可以使用--lines-per-offset选项;例如,你可以指定2来打乱成对的行。

对于FASTQ文件,它们的记录每四行分开一次。你可以指定--lines-per-offset=4来用四分之一的内存打乱一个FASTQ文件,而不是打乱单行文件所需的全部内存。

另外,我在这里有一个用Perl写的代码片段这里,它可以从FASTA文件中进行不重复的抽样,而不考虑序列的行数。需要注意的是,这和打乱整个文件并不完全相同,但你可以把它作为一个起点,因为它会收集偏移量。你可以去掉第47行的代码,这行代码是用来排序打乱后的索引的,然后使用文件查找操作直接读取文件,使用打乱后的索引列表。

再次强调,这个过程不会很快,因为你是在一个非常大的文件中无序跳转,但存储偏移量比存储整行数据便宜得多,添加mmap的方式可能会对这些随机访问操作有所帮助。而且如果你处理的是FASTA文件,你需要存储的偏移量会更少,所以你的内存使用量(不算一些相对不重要的容器和程序开销)最多应该是8GB,实际上可能更少,具体取决于文件的结构。

撰写回答