0xDEADBEEF

[RSS]
««« »»»

Java IO & NIO - jak nejrychleji načíst soubor z disku

24. 12. 2018

Bez vět­šího důvodu jsem se začal za­jí­mat jak v Javě co nej­rych­leji načíst data z disku. Java nabízí ně­ko­lik způ­sobů, jednak starý java.io způsob přes FileInputStreamBufferedInputStream a pak novou java.nio cestu přes FileChannel. Je v nich nějaký rozdíl, po­pří­padě jak velký?

Proto jsem napsal JMH ben­chmark (zdro­ják níže), který načte zdro­jové texty části k47čky sra­žené do jed­noho sou­boru (9.7 MB textu) a počítá kolik je v něm '\n' bajtů. Není nijak dů­le­žité, co se s daty dělá, jen s nimi chci dělat co nejmenší množ­ství práce, která může být aspoň okra­jově uži­tečná.

Na Ope­n­JDK 10.0.2 jsou vý­sledky ná­sle­du­jící:

Benchmark                                     (bufferSize)   Mode  Cnt    Score    Error  Units
readFile.countNewlines_io_InputStream                 1024  thrpt    4  108,321 ±  2,235  ops/s
readFile.countNewlines_io_InputStream                 8192  thrpt    4  206,031 ±  1,553  ops/s
readFile.countNewlines_io_InputStream                65536  thrpt    4  219,326 ±  4,065  ops/s
readFile.countNewlines_io_BufferedInputStream         1024  thrpt    4  177,596 ± 36,830  ops/s
readFile.countNewlines_io_BufferedInputStream         8192  thrpt    4  206,120 ±  8,747  ops/s
readFile.countNewlines_io_BufferedInputStream        65536  thrpt    4  226,468 ±  1,681  ops/s
readFile.countNewlines_nio                            1024  thrpt    4  119,426 ±  1,142  ops/s
readFile.countNewlines_nio                            8192  thrpt    4  213,778 ±  1,433  ops/s
readFile.countNewlines_nio                           65536  thrpt    4  168,841 ±  3,932  ops/s
readFile.countNewlines_nio_newInputStream             1024  thrpt    4  108,176 ±  4,005  ops/s
readFile.countNewlines_nio_newInputStream             8192  thrpt    4  176,283 ±  0,728  ops/s
readFile.countNewlines_nio_newInputStream            65536  thrpt    4  220,651 ±  1,712  ops/s
readFile.countNewlines_nio_newInputStream_buffered    1024  thrpt    4  173,761 ± 36,901  ops/s
readFile.countNewlines_nio_newInputStream_buffered    8192  thrpt    4  199,666 ± 13,302  ops/s
readFile.countNewlines_nio_newInputStream_buffered   65536  thrpt    4  220,418 ±  0,790  ops/s

Je z nich vidět, že mezi jed­not­li­vými me­to­dami není více méně žádný rozdíl. Záleží jen na gra­nu­la­ritě ope­rací. Když čtu data do pole/ByteBufferu ve­li­kosti 64kB, je to rych­lejší než v pří­padě 1kB bu­f­feru. BufferedInputStream ob­sa­huje vlastní buffer (ve vý­cho­zím stavu 8kB) a to zlepší výkon, když čteme data do malých 1kB polí.

Nějaká ano­má­lie se vy­sky­tuje v pří­padě nio a 64kB bu­f­feru, ale té bych ne­při­klá­dal velkou váhu. Možná jde o dů­sle­dek po­u­žití pří­mého buffer alo­ko­va­ného v na­tivní paměti (OS by do něj měl přímo zko­pí­ro­vat data a nemusí je nejdřív ko­pí­ro­vat do C pole a z něj pak do Java pole, takže by to mohlo být o něco efek­tiv­nější), kdo ví. Když čtu data s 8kB bu­f­fe­rem, v tomto pří­padě ne­zá­leží, jakým způ­so­bem se to děje. Pokud se tedy soubor na­chází v cache OS a nejsem li­mi­to­ván pro­pust­ností disku (testy čtou téměř 2GB/s).

To je jedna věc. Druhou je, co se s těmi bajty bude dít potom. Pro­tože když do rov­nice za­po­čí­tám utf-8 de­kó­do­vání, pro­pust­nost spadne 5-10x.


Ben­chmark:

import org.openjdk.jmh.annotations._

@State(Scope.Thread)
class readFile {
  import java.nio.file._
  import java.nio._
  import java.io._

  val f = "k47merged.txt"

  @Param(Array("1024", "8192", "65536"))
  var bufferSize: Int = _

  @Benchmark
  def countNewlines_nio = {
    var count = 0
    val bb = ByteBuffer.allocateDirect(bufferSize)
    val ch = new RandomAccessFile(f, "r").getChannel
    while (ch.read(bb) != -1) {
      bb.flip()
      while (bb.hasRemaining) {
        if (bb.get == '\n') count += 1
      }
      bb.clear()
    }
    ch.close()
    count
  }

  @Benchmark
  def countNewlines_nio_newInputStream =
    countNewlines(Files.newInputStream(Paths.get(f)), bufferSize)

  @Benchmark
  def countNewlines_nio_newInputStream_buffered =
    countNewlines(new BufferedInputStream(Files.newInputStream(Paths.get(f))), bufferSize)

  @Benchmark
  def countNewlines_io_InputStream =
    countNewlines(new FileInputStream(f), bufferSize)

  @Benchmark
  def countNewlines_io_BufferedInputStream =
    countNewlines(new BufferedInputStream(new FileInputStream(f)), bufferSize)

  def countNewlines(is: InputStream, buffer: Int) = {
    var count = 0
    val bb = new Array[Byte](buffer)
    var n = is.read(bb)
    while (n != -1) {
      var i = 0; while (i < n) {
        if (bb(i) == '\n') count += 1
        i += 1
      }
      n = is.read(bb)
    }
    is.close()
    count
  }
}

+1: Do­da­tek pro úpl­nost: mmap v pří­padě takto vel­kého sou­boru není rych­lejší.

píše k47 (@kaja47, k47)