如何尽可能高效地合并多个二进制文件?

How do I merge many binary files as efficiently as possible?

我正在编写一个多连接下载器,它使用来自 UQ Foundation 的 multiprocess 库通过 32 个进程将单个文件分成 32 个部分下载,我想知道将这些部分合并回来的最有效方法合并到一个文件中。

文件是 32 个(几乎)相同大小的连续块,大小最多相差 1 个字节,并按以下公式命名:

'{0}.{1}.part'.format(filepath, str(i).zfill(2))

filepath 是一个 str 表示下载文件应该存储的位置,包括名称和扩展名。 i 是一个介于 0 和 32(不包括 32)之间的 int,然后零填充为 2 位 str 以避免对数字字符串按字母顺序排序。

以下可以完成工作,但速度慢且占用内存:

with open(filepath, 'wb') as dest:
    for file in files:
        f = open(file, 'rb')
        dest.write(f.read())
        f.close()
        os.remove(file)

这稍微好一点,但仍然很慢:

BLOCKSIZE = 4096
BLOCKS = 1024
chunk = BLOCKS * BLOCKSIZE
with open(filepath, "wb") as dest:
    for file in files:
        with open(file, "rb") as f:
            data = f.read(chunk)
            while data != b'':
                dest.write(data)
                data = f.read(chunk)
        os.remove(file)

(我实际上使用了第二种方法的变体,使用 pathlibmmap 所以 none 这些 with 子句,但基本思想是相同的)。

相反,我认为使用 32 个子进程并发读取文件内容然后将数据报告给父进程并让父进程将数据写入磁盘会更好,我有 4 CPU 核但是这些过程不会进行重要的计算,如何使用 multiprocess?

来完成

源和目标在同一个设备上,在同一个文件夹中,该设备是 HDD 或 SSD,并且可能是带有 NTFS 文件系统的设备(我的是带有 NTFS 的 Seagate EXOS 4TB HDD,块大小为 4KiB)。

文件可能会很大(我打算用它来下载“copylefted”“交互式数字艺术”,它可能超过 20GiB)而且我只有 16GiB RAM,而且我不希望任何用户拥有RAM 超过 16GiB,因此将整个文件加载到内存中是不可行的。

我使用 Windows 10 21H1 并且我的目标是 Windows 10.


我的带宽是 100mbps 或 11.92MiB/s,我使用 VPN 因为我在中国。

我观察过我用过的所有下载管理器下载文件比浏览器更快更稳定,它们都支持多连接下载,每次下载支持32个连接。

我知道大多数浏览器每次下载最多支持 8 个连接,几乎所有文件都是使用单个线程下载的,使用多连接的主要动机不是因为它使您的带宽更大,而是它最小化速度限制的影响,大多数服务器设置了连接可以拥有的配额,并且该限制通常远小于带宽,使用多个连接配额将与该倍数成比例增加,而我在哪里,你知道,政府如果不完全中断国际流量,则会主动限制国际流量,VPN 会增加延迟,因此会增加限制...

对于合并部分,我建议一开始就不要拆分文件,你可以创建并保留一个主文件大小的大文件,然后将其逻辑拆分成碎片并分配一个开始每个线程的字节。 例如,假设您有一个 4GB 的文件和 4 个线程,第一个线程从字节 0 开始,第二个线程从字节 1024^3 (1GB) 开始,第三个线程从 2GB 开始,依此类推。这样您就不必处理合并文件的问题。 我还应该提到这个解决方案存在一些同步问题,应该加以管理。

但总的来说,我认为您的示例中的瓶颈更多地与您的带宽有关,而不是与存储有关。而且我不认为下载文件的 32 个进程会使它更快。

我已经做了一些测试,首先你需要下载这个文件:http://ipv4.download.thinkbroadband.com/1GB.zip(直接link)使用你使用的任何下载管理器(建议不要使用浏览器),它是一个包含专门用于测试目的的 1GiB 垃圾数据的文件,它应该有这个散列:

5674e59283d95efe8c88770515a9bbc80cbb77cb67602389fd91def26d26aed2

将文件拆分成32个chunk(我把文件下载到D:\downloadsGB.zip,按需更改):

from pathlib import Path                                                      

i = 0
files = []
with Path('D:/downloads/1GB.zip').open('rb') as f:                            
    while (chunk := f.read(33554432)):
        path = 'D:/1GB.zip.{0}.part'.format(str(i).zfill(2))                                        
        Path(path).write_bytes(chunk)
        files.append(path)
        i += 1

我的硬盘是Seagate EXOS 7E8 4TB,连接SATA III 6.0Gb/s接口,文件系统是NTFS,簇大小为4KiB。

我做了以下测试:

方法一:

with Path('D:/1GB.zip').open('wb') as dest:
    for file in files:
        dest.write(Path(file).read_bytes())

方法二:

BLOCKSIZE = 4096
BLOCKS = 1024
CHUNKSIZE = BLOCKSIZE * BLOCKS

with Path('D:/1GB.zip').open('wb') as dest:
    for file in files:
        with Path(file).open('rb') as f:
            while (segment := f.read(CHUNKSIZE)):
                dest.write(segment)

两种方法都能产生预期的结果:

import hashlib
HASH = '5674e59283d95efe8c88770515a9bbc80cbb77cb67602389fd91def26d26aed2'

sha = hashlib.sha256()
with Path('D:/1GB.zip').open('rb') as f:
    while (chunk := f.read(1048576)):
        sha.update(chunk)

print(sha.hexdigest() == HASH)

在我的机器上,使用 timeit 魔法,第一种方法平均需要大约 3.25 秒才能完成,观察到的磁盘利用率最高可达 320MiB/s。

虽然方法 2 平均需要大约 1.25 秒,观察到的最大速度为 850 MiB/s。

理论上 SATA III 的带宽为 6.0Gb/s,转换为 SI 十进制单位的 750MB/s,然后转换为二进制单位的 715.2557373046875MiB/s,然后通过 8b/10b 编码减少最大传输速度为 600MB/s,二进制单位为 572.20458924375MiB/s。

第一种方法的最大写入速度约为320MiB/s,平均速度为315.076923MiB/s,而第二种方法的最大写入速度约为850MiB/s平均速度819.2MiB/s远超SATA 3.0的理论极限,看来我的硬盘真的比SATA的理论带宽好,压榨出的性能绝对超乎我的想象,看来我真的到了极限了使用 multiprocessing 无济于事,但我坚信使用 mmap 会使事情变得更快。

但下载就不是这样了,因为网络带宽远小于硬盘带宽,而且大多数时候带宽在下载过程中没有得到充分利用,而且大多数服务器限制了每个连接可以有多少带宽,拥有更多连接意味着您可以利用更多带宽,并且一个连接不良不会影响其他连接,使用多连接肯定会加快下载速度。