【问题标题】:Why is ReversedLinesFileReader so slow?为什么 ReversedLinesFileReader 这么慢?
【发布时间】:2017-01-27 15:57:59
【问题描述】:

我有一个 21.6GB 的文件,我想从头到尾读取它,而不是像通常那样从头到尾读取。

如果我使用以下代码从头到尾读取文件的每一行,则需要 1 分 12 秒。

val startTime = System.currentTimeMillis()
File("very-large-file.xml").forEachLine {
    val i = 0
}
val diff = System.currentTimeMillis() - startTime
println(diff.timeFormat())

现在,我已经阅读了它以反向读取文件,然后我应该使用来自 Apache Commons 的ReversedLinesFileReader。为此,我创建了以下扩展函数:

fun File.forEachLineFromTheEndOfFile(action: (line: String) -> Unit) {
    val reader = ReversedLinesFileReader(this, Charset.defaultCharset())
    var line = reader.readLine()
    while (line != null) {
        action.invoke(line)
        line = reader.readLine()
    }

    reader.close()
}

然后按照下面的方式调用它,和之前的方式一样,只是调用forEachLineFromTheEndOfFile函数:

val startTime = System.currentTimeMillis()
File("very-large-file.xml").forEachLineFromTheEndOfFile {
    val i = 0
}
val diff = System.currentTimeMillis() - startTime
println(diff.timeFormat())

这需要 17 分 50 秒运行!

  • 我是否以正确的方式使用ReversedLinesFileReader
  • 我在 SSD 上运行带有 Ext4 文件系统的 Linux Mint。这和它有什么关系吗?
  • 难道只是文件不应该从头到尾读取吗?

【问题讨论】:

  • 你知道数据是什么样的吗?换行的统一程度如何?
  • @BrianKent,我不确定你所说的换行符有多统一是什么意思,但有问题的数据是来自 OpenStreetMap 项目的 XML 文件。
  • 您确定需要以相反的顺序处理文件吗?我怀疑您最好按照自然顺序读取行并将数据块保存到临时文件和/或数据库中,然后以这种中间格式处理数据,如果将其分块成较小的文件和/或使用数据库可以然后很容易以相反的顺序处理。
  • 等等,你怎么能把 XML 读回前面?
  • 我写了一个新的反向文件阅读器,它比你遇到的 17x 慢大约 4 倍。 stackoverflow.com/a/39583921/3679676

标签: java kotlin apache-commons-io


【解决方案1】:

调查此问题的正确方法是:

  1. 用纯 Java 编写此测试的一个版本。
  2. 对其进行基准测试以确保性能问题仍然存在。
  3. 对其进行分析以找出性能瓶颈所在。

问:我是否以正确的方式使用 ReversedLinesFileReader?

是的。 (假设使用 line reader 是一件合适的事情。这取决于你真正想要做什么。例如,如果你只是想倒数行数,那么你应该阅读 1 个字符时间并计算换行符序列。)

问:我在 SSD 上运行带有 Ext4 文件系统的 Linux Mint。这和它有什么关系吗?

可能。反向读取文件意味着操作系统用于提供快速 I/O 的预读策略可能不起作用。它可能与 SSD 的特性相互作用。

Q:难道只是文件不应该从头到尾读取吗?

可能。见上文。


您没有考虑的另一件事是您的文件实际上可能包含一些非常长的行。瓶颈可能是将字符组装成(长)行。

看看source code,当行很长时,O(N^2) 的行为似乎是可能的。关键部分是(我认为)FilePart 处理“翻转”的方式。请注意“剩余”数据的复制方式。

【讨论】:

    【解决方案2】:

    您要求进行非常昂贵的手术。您不仅在块中使用随机访问来读取文件并向后读取(因此,如果文件系统正在向前读取,则它正在读取错误的方向),您还在读取一个 UTF-8 的 XML 文件,并且编码是比固定字节编码慢。

    除此之外,您使用的算法效率较低。它在处理编码和制作(不必要的?)部分字节数组的副本时向后读取一个块(它是否知道磁盘块大小?您是否设置块大小以匹配您的文件系统?)它变成一个字符串(你需要一个字符串来解析吗?)。它可以在没有副本的情况下创建字符串,并且真正创建字符串可能会被推迟,并且您可以直接从缓冲区工作,仅在需要时才解码(例如 XML 解析器也可以从 ByteArrays 或缓冲区工作)。还有其他数组副本,只是不需要,但代码更方便。

    它也可能有一个错误,即它在检查换行符时没有考虑如果该字符实际上是多字节序列的一部分,则该字符可能意味着不同的东西。它必须回顾一些额外的字符来检查可变长度编码,我不认为它会这样做。

    因此,您可以一次随机读取 1 个块,而不是对文件进行良好的仅重缓冲顺序读取(这是您在文件系统上可以做的最快的事情)。它至少应该读取多个磁盘块,以便它可以利用前进的动力(将块大小设置为磁盘块大小的倍数会有所帮助),并且还可以避免在缓冲区边界产生的“剩余”副本的数量。

    可能有更快的方法。但它不会像以正向顺序读取文件那样快。

    更新:

    好的,所以我尝试了一个相当愚蠢的版本,通过从 wikidata JSON 转储读取前 1000 万行并反转这些行来处理大约 27G 的数据。

    我的 2015 Mac Book Pro 上的时间安排(我所有的开发资料和许多 chrome 窗口一直打开吃内存和一些 CPU,大约 5G 的总内存是可用的,VM 大小是默认的,根本没有设置参数,不是在调试器下运行):

    reading in reverse order: 244,648 ms = 244 secs = 4 min 4 secs
    reading in forward order:  77,564 ms =  77 secs = 1 min 17 secs
    
    temp file count:   201
    approx char count: 29,483,478,770 (line content not including line endings)
    total line count:  10,050,000
    

    该算法是逐行读取原始文件,一次缓冲 50000 行,将这些行以相反的顺序写入一个编号的临时文件。然后在写入所有文件后,以相反的数字顺序逐行读取它们。基本上将它们分成原始的反向排序片段。它可以被优化,因为这是该算法的最天真的版本,没有调整。但它确实做了文件系统最擅长的事情,即顺序读取和顺序写入,以及大小合适的缓冲区。

    所以这比您使用的要快得多,并且可以从这里进行调整以提高效率。您可以用 CPU 换取磁盘 I/O 大小,也可以尝试使用 gzip 压缩文件,也许是一个双线程模型,以便在处理前一个缓冲区时对下一个缓冲区进行 gzip。更少的字符串分配,检查每个文件函数以确保没有额外的事情发生,确保没有双缓冲等等。

    丑陋但实用的代码是:

    package com.stackoverflow.reversefile
    
    import java.io.File
    import java.util.*
    
    fun main(args: Array<String>) {
        val maxBufferSize = 50000
        val lineBuffer = ArrayList<String>(maxBufferSize)
        val tempFiles = ArrayList<File>()
        val originalFile = File("/data/wikidata/20150629.json")
        val tempFilePrefix = "/data/wikidata/temp/temp"
        val maxLines = 10000000
    
        var approxCharCount: Long = 0
        var tempFileCount = 0
        var lineCount = 0
    
        val startTime = System.currentTimeMillis()
    
        println("Writing reversed partial files...")
    
        try {
            fun flush() {
                val bufferSize = lineBuffer.size
                if (bufferSize > 0) {
                    lineCount += bufferSize
                    tempFileCount++
                    File("$tempFilePrefix-$tempFileCount").apply {
                        bufferedWriter().use { writer ->
                            ((bufferSize - 1) downTo 0).forEach { idx ->
                                writer.write(lineBuffer[idx])
                                writer.newLine()
                            }
                        }
                        tempFiles.add(this)
                    }
                    lineBuffer.clear()
                }
    
                println("  flushed at $lineCount lines")
            }
    
            // read and break into backword sorted chunks
            originalFile.bufferedReader(bufferSize = 4096 * 32)
                    .lineSequence()
                    .takeWhile { lineCount <= maxLines }.forEach { line ->
                        lineBuffer.add(line)
                        if (lineBuffer.size >= maxBufferSize) flush()
                    }
            flush()
    
            // read backword sorted chunks backwards
            println("Reading reversed lines ...")
            tempFiles.reversed().forEach { tempFile ->
                tempFile.bufferedReader(bufferSize = 4096 * 32).lineSequence()
                    .forEach { line ->
                        approxCharCount += line.length
                        // a line has been read here
                    }
                println("  file $tempFile current char total $approxCharCount")
            }
        } finally {
            tempFiles.forEach { it.delete() }
        }
    
        val elapsed =  System.currentTimeMillis() - startTime
    
        println("temp file count:   $tempFileCount")
        println("approx char count: $approxCharCount")
        println("total line count:  $lineCount")
        println()
        println("Elapsed:  ${elapsed}ms  ${elapsed / 1000}secs  ${elapsed / 1000 / 60}min  ")
    
        println("reading original file again:")
        val againStartTime = System.currentTimeMillis()
        var againLineCount = 0
        originalFile.bufferedReader(bufferSize = 4096 * 32)
                .lineSequence()
                .takeWhile { againLineCount <= maxLines }
                .forEach { againLineCount++ }
        val againElapsed =  System.currentTimeMillis() - againStartTime
        println("Elapsed:  ${againElapsed}ms  ${againElapsed / 1000}secs  ${againElapsed / 1000 / 60}min  ")
    }
    

    【讨论】:

    • UTF-8 多字节序列不可能看起来像一个新行。 UTF-8 专门设计用于不表示看起来像 ASCII 字节的非 ASCII 字符。 ReversedLinesFileReader 正在利用该属性。所以没有错误。
    猜你喜欢
    • 2021-09-03
    • 2016-09-28
    • 2020-02-08
    • 2012-07-17
    • 2011-11-07
    • 2015-08-24
    • 2013-08-06
    • 2014-07-16
    • 2011-01-02
    相关资源
    最近更新 更多