在Swift中逐步编写大型文本文件的最佳方法

3

我正在撰写一个相当大的文本文件(实际上更像是ASCII编码的数据),它非常缓慢,而且使用了很多内存。

这是我正在使用的代码的最简版本,用于测试如何更快地写入文件。writeFileIncrementally在for循环中一次写入一行,而writeFileFromBigData创建一个大字符串,然后将其转储到磁盘上。我完全预计writeFileFromBigData会更快,但它快了20倍! 这比我预期的要多一些。对于size = 10_000_000,逐行写入需要20-25秒,而一次性写入只需要1-1.5秒。此外,逐步版本会随着进程的进行不断分配更多的内存。到最后,它已经达到GiB级别。我不明白这里发生了什么。

func writeFileIncrementally(toUrl url: URL, size: Int) {
    // ensure file exists and is empty
    try? "".write(to: url, atomically: true, encoding: .ascii)
    
    guard let handle = try? FileHandle(forWritingTo: url) else {return}
    
    defer {
        handle.closeFile()
    }
    
    for i in 0..<size {
        let s = "\(i)\n"
        handle.write(s.data(using: .ascii)!)
    }
}

func writeFileFromBigData(toUrl url: URL, size: Int) {
    let s = (0..<size).map{String($0)}.joined(separator: "\n")
    
    try? s.write(to: url, atomically: true, encoding: .ascii)
}

与Python相比,创建字符串并将其写入的速度也更快。这是合理的,但在Python中的区别是,逐步写入需要大约2.7秒(约98%的用户时间),而一次性写入(包括创建字符串)只需要约1秒钟。此外,逐步写入版本具有恒定的内存使用量。随着文件的编写,内存不会增加。

def writeFileIncrementally(path, size):
    with open(path, "w+") as f:
        for i in range(size):
            f.write(f"{i}\n")

def writeFileFromBigData(path, size):
    with open(path, "w+") as f:
        f.write("\n".join(str(i) for i in range(size)))

所以我的问题有两个:

  1. 为什么我的 writeFileIncrementally 函数会这么慢,而且为什么会使用那么多内存?我希望能够通过逐步写入来减少内存使用。
  2. 在Swift中,有没有更好的逐步写入大型文本文件的方法?
2个回答

8

有关内存,请参阅Duncan C的答案。您需要一个autoreleasepool。但是有关速度,您有一个小问题和一个大问题。

小问题是这一行:

    handle.write(s.data(using: .ascii)!)

重写代码可以节省大约40%的时间(经过我的测试,从27秒降至17秒):

    handle.write(Data(s.utf8)) 

字符串通常以UTF8格式存储在内部。尽管ASCII是它的一个完美子集,但您的代码需要检查是否有任何不是ASCII的字符。使用.utf8通常可以直接获取内部缓冲区。这还避免了创建和解包可选项。

但17秒仍然比1-2秒要多得多。这是由于您的主要问题引起的。

每次调用write都必须将数据传递到操作系统的文件缓冲区。虽然没有全部写入磁盘,但仍然是一项昂贵的操作。除非数据很重要,否则通常希望将其分成较大的块(4k非常常见)。如果这样做,则写入时间降至1.5秒:

let bufferSize = 4*1024
var buffer = Data(capacity: bufferSize)
for i in 0..<size {
    autoreleasepool {
        let s = "\(i)\n"
        buffer.append(contentsOf: s.utf8)
        if buffer.count >= bufferSize {
            handle.write(buffer)
            buffer.removeAll(keepingCapacity: true)
        }
    }
}
// Write the final buffer
handle.write(buffer)

在我的系统上,这与“大数据”函数的 1.1 秒非常接近。仍然有很多内存分配和清理工作要做。根据我的经验,至少最近来说,[UInt8] 比 Data 快得多。我不确定这总是正确的,但我最近在 Mac 上进行的所有测试都是这样的。因此,使用较新的 write(contentsOf:) 接口编写如下:

let bufferSize = 4*1024
var buffer: [UInt8] = []
buffer.reserveCapacity(bufferSize)
for i in 0..<size {
    autoreleasepool {
        let s = "\(i)\n"
        buffer.append(contentsOf: s.utf8)
        if buffer.count >= bufferSize {
            try? handle.write(contentsOf: buffer)
            buffer.removeAll(keepingCapacity: true)
        }
    }
}
// Write the final buffer
try? handle.write(contentsOf: buffer)

这比大数据函数更快速,因为它不必创建一份数据。 (我机器上为830毫秒)

然而,更妙的是这段代码并不需要自动释放池,如果你去掉它,我可以在730毫秒内完成此文件的编写。

let bufferSize = 4*1024
var buffer: [UInt8] = []
buffer.reserveCapacity(bufferSize)
for i in 0..<size {
    let s = "\(i)\n"
    buffer.append(contentsOf: s.utf8)
    if buffer.count >= bufferSize {
        try? handle.write(contentsOf: buffer)
        buffer.removeAll(keepingCapacity: true)
    }
}
// Write the final buffer
try? handle.write(contentsOf: buffer)

那么Python呢?为什么它不需要缓冲区就能实现高速运行?因为它默认就会提供缓冲区。你的open函数返回一个带有8k缓冲区的BufferedWriter对象,它的工作原理与上面的代码差不多。你需要以二进制模式写入并且传递参数buffering=0来关闭缓冲区。请参阅有关 open 的文档了解详情。


出色的答案(像往常一样)。已投票。 - Duncan C
1
太好了,这样就清楚了!我在缓冲区周围实现了一个轻微的抽象层,这样我就可以做一些类似于 var buffer = Buffer(); buffer.write(stuff); buffer.close() 的操作。它稍微减慢了速度,但仍然比以前快得多。 - Simon Lundberg
我最终做了更多或更少的这个:writeStringsWithBuffer(strings: stuff.lazy.map{foo($0)}, to: url)。通过使用一个接受字符串序列的函数,我可以给它一个惰性映射来将数据转换为字符串。它在0.7秒内写入了虚拟数据(前一千万个自然数),并且在使用时相当易读。再次感谢! - Simon Lundberg
一个跟进的问题:为什么它不再需要autoreleasepool了? - Simon Lundberg
纯Swift对象很少与autorelease池交互。通常,自动释放的对象表示底层的ObjC数据结构被桥接到Swift中。临时自动释放的对象在ObjC代码中非常常见。我不知道有什么绝对确定的方法可以确保不会在任何你调用的东西中创建自动释放的对象(例如FileHandle.write可能会,但实际上并没有),但是你越少接触Cocoa桥接类型,就越不可能出现这种情况。 - Rob Napier

2

我不确定为什么增量写入版本如此缓慢。

如果你担心内存使用,你可以通过在内部循环中调用autoreleasepool()来使内存占用更小:

        for i in 0..<size {
            autoreleasepool {
                let s = "\(i)\n"
                handle.write(s.data(using: .ascii)!)
                if i.isMultiple(of: 100000) {
                    print(i)
                }
            }
        }

(在Swift的ARC内存管理中,有时候会将临时存储分配到堆上,作为"自动释放",这意味着它会一直停留在内存中,直到当前调用链返回并应用重新访问事件循环。如果您有一个处理循环,它会分配一大堆本地变量,那么它们就会在堆上累积,直到您完成并返回。但只有当您接近设备的内存限制时,才会真正成为问题。)

编辑:

(我认为这可能是过早优化的情况。看起来,“一次写入所有”方式的最大内存消耗为1000万项约为150 MB,这对于能够运行当前iOS版本的设备来说不是问题。只需使用“一次全部写入”版本即可。如果您需要一次写入数十亿行,则编写混合代码,将其分成每次1000万个块,并将每个块附加到文件中。(内部循环包装在autoreleasepool()的调用中,如上所示。))

太好了,谢谢!至于过早优化,一千万这个数字实际上相当保守。将前一千万个自然数转储到文本文件中不到80 MB。我预计可能需要编写比这大一个数量级的文件。通过缓冲区逐个写入“几个”可能是最好的解决方案,也可能是Python在内部执行的操作。 - Simon Lundberg

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