Почему ReversedLinesFileReader такой медленный?

У меня есть файл размером 21,6 ГБ, и я хочу прочитать его с конца до начала, а не с начала до конца, как вы обычно это делаете.

Если я прочитаю каждую строку файла от начала до конца, используя следующий код, то это займет 1 минуту 12 секунд.

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

Теперь я прочитал, что для чтения в файле в обратном порядке я должен использовать ReversedLinesFileReader из Apache Commons. Я создал следующую функцию расширения, чтобы сделать именно это:

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?
  • Я использую Linux Mint с файловой системой Ext4 на SSD. Может ли это иметь какое-то отношение к этому?
  • Только ли файлы не должны читаться с конца в начало?

person Arthur    schedule 19.09.2016    source источник
comment
Вы знаете, как выглядят данные? Насколько равномерны разрывы строк?   -  person Brian Kent    schedule 20.09.2016
comment
@BrianKent, я не уверен, что вы имеете в виду, говоря о том, насколько однородны разрывы строк, но рассматриваемые данные представляют собой XML-файл из проекта OpenStreetMap.   -  person Arthur    schedule 20.09.2016
comment
Вы уверены, что вам нужно обработать файл в обратном порядке? Я подозреваю, что вам было бы лучше читать строки в их естественном порядке и сохранять фрагменты данных во временные файлы и/или базу данных, а затем обрабатывать данные в этом промежуточном формате, который, если он разбит на более мелкие файлы и/или с базой данных, может затем легко обрабатываются в обратном порядке.   -  person mfulton26    schedule 20.09.2016
comment
Подожди, как ты можешь читать XML задом наперёд?   -  person user207421    schedule 20.09.2016
comment
Я написал новый обратный считыватель файлов, который примерно в 4 раза медленнее, чем чтение вперед, вместо 17-кратного, с которым вы столкнулись. stackoverflow.com/a/39583921/3679676   -  person Jayson Minard    schedule 20.09.2016
comment
@ mfulton26 mfulton26 - мне не обязательно читать в обратном порядке. Файл OSM состоит из отношений, путей и узлов. Я знаю, каких отношений я хочу, исходя из определенных критериев. Каждое отношение имеет несколько идентификаторов путей. Затем я могу искать каждый интересующий меня способ на основе идентификатора, затем они содержат список узлов. Затем я могу искать узлы на основе идентификаторов. На данный момент все это работает, анализируя три раза, но я подумал, что смогу прочитать файл в обратном порядке, так как это может сэкономить время.   -  person Arthur    schedule 20.09.2016


Ответы (2)


Вы просите очень дорогую операцию. Вы не только используете произвольный доступ в блоках для чтения файла и возвращаетесь назад (поэтому, если файловая система читает вперед, она читает в неправильном направлении), вы также читаете файл XML, который является UTF-8, и кодировка медленнее, чем кодирование с фиксированным байтом.

Кроме того, вы используете менее эффективный алгоритм. Он читает блок в момент неудобного размера (знает ли он размер блока диска? Вы устанавливаете размер блока в соответствии с вашей файловой системой?) в обратном направлении при обработке кодирования и делает (ненужную?) копию частичного массива байтов, а затем поворачивает в строку (вам нужна строка для разбора?). Он может создать строку без копии, и действительное создание строки, вероятно, может быть отложено, и вы работаете непосредственно с декодированием буфера, если вам нужно (парсеры XML, например, также работают с ByteArrays или буферами). И есть другие копии массива, которые просто не нужны, но для кода удобнее.

У него также может быть ошибка в том, что он проверяет новые строки, не учитывая, что символ может означать что-то другое, если он на самом деле является частью многобайтовой последовательности. Чтобы проверить это на наличие кодировок переменной длины, ему пришлось бы оглянуться назад на несколько дополнительных символов, я не вижу, чтобы он это делал.

Таким образом, вместо последовательного чтения файла с хорошей буферизацией только вперед, которое является самой быстрой вещью, которую вы можете сделать в своей файловой системе, вы выполняете случайное чтение по 1 блоку за раз. Он должен, по крайней мере, читать несколько дисковых блоков, чтобы он мог использовать импульс вперед (поможет установить размер блока, кратный размеру вашего дискового блока), а также избежать количества «оставшихся» копий, сделанных на границах буфера.

Вероятно, есть более быстрые подходы. Но это будет не так быстро, как чтение файла в прямом порядке.

ОБНОВЛЕНИЕ:

Хорошо, поэтому я провел эксперимент с довольно глупой версией, которая обрабатывает около 27 ГБ данных, читая первые 10 миллионов строк из дампа викиданных JSON и обращая эти строки.

Тайминги на моем Mac Book Pro 2015 года (со всеми моими разработчиками и многими открытыми хромированными окнами, которые постоянно потребляют память и часть процессора, около 5 ГБ общей памяти свободно, размер виртуальной машины по умолчанию без каких-либо параметров, не работает под отладчиком ):

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 строк за раз, записывая строки в обратном порядке в пронумерованный временный файл. Затем, после того, как все файлы записаны, они считываются в обратном порядке номеров вперед по строкам. В основном разделяя их на фрагменты в обратном порядке сортировки оригинала. Его можно оптимизировать, потому что это самая наивная версия этого алгоритма без настройки. Но он делает то, что файловые системы делают лучше всего, последовательное чтение и последовательную запись с буферами хорошего размера.

Так что это намного быстрее, чем тот, который вы использовали, и его можно настроить отсюда, чтобы он был более эффективным. Вы можете обменять ЦП на размер дискового ввода-вывода и попробовать также использовать 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  ")
}
person Jayson Minard    schedule 20.09.2016
comment
Многобайтовые последовательности UTF-8 не могут выглядеть как новая строка. UTF-8 был специально разработан, чтобы не иметь представления символов, отличных от ASCII, которые выглядят как байты ASCII. ReversedLinesFileReader использует это свойство. Так что бага нет. - person Holger; 10.07.2019

Правильным способом исследования этой проблемы будет:

  1. Напишите версию этого теста на чистой Java.
  2. Сравните его, чтобы убедиться, что проблема с производительностью все еще существует.
  3. Профилируйте его, чтобы выяснить, где находится узкое место в производительности.

Q: Правильно ли я использую ReversedLinesFileReader?

да. (Предположим, что вообще целесообразно использовать считыватель строк. Это зависит от того, что вы действительно пытаетесь сделать. Например, если вы просто хотите считать строки в обратном направлении, тогда вы должны читать 1 символ за раз. время и подсчет последовательностей новой строки.)

В: Я использую Linux Mint с файловой системой Ext4 на SSD. Может ли это иметь какое-то отношение к этому?

Возможно. Чтение файла в обратном порядке означает, что стратегии опережающего чтения, используемые ОС для обеспечения быстрого ввода-вывода, могут не работать. Это может быть связано с характеристиками SSD.

Q: Это просто тот случай, когда файлы не должны читаться с конца в начало?

Возможно. См. выше.


Еще одна вещь, которую вы не учли, это то, что ваш файл может содержать очень длинные строки. Узким местом может быть сборка символов в (длинные) строки.

Глядя на исходный код, может показаться, что существует вероятность поведения O(N^2) при очень длинных строках. Критическая часть (я думаю) заключается в том, как «переворот» обрабатывается FilePart. Обратите внимание, как копируются «оставшиеся» данные.

person Stephen C    schedule 19.09.2016