Speed up stdlib readLine function (#3185)

There are several performance optimizations:

* ByteBuffer/CharBuffer/StringBuilder objects pre-allocated and are
  reused on each call to readLine.
* The state for readLine is lazily allocated via JVM classloading
  (using a singleton object).
* There is an auto-detection heuristic for "directEOL" encodings which
  represent LF ('\n') directly as the corresponding byte
  (UTF-8 and many single-byte encodings are like that).
  When "directEOL" encoding is used, then bytes are batched into
  ByteBuffer for a single call to CharsetDecoder.decode which
  results in higher throughput. Otherwise (UTF-16, etc), slower
  byte-by-byte approach is used.
* Bytes and chars are directly moved in/out of byte/char arrays and
  ByteBuffer/CharBuffer wrappers are used only to interface with
  JVM CharsetDecoder class (which is the slowest piece).
* StringBuilder is not used at all for short lines (<=32 chars).

There are also some function improvements to readLine functionality:

* Restriction on "max chars per byte" is lifted, so readLine works with
  all encodings that JVM supports.
* It support on-the-fly changes to system default charset, because
  it rechecks current charset on each call and updates it decoder
  when needed.

All the other features of readLine function are retained:

* It does not read more bytes from System.in than needed, so it
  is compatible with other ways to read System.in. On-the-fly
  changes to System.in are supported.
* It is thread-safe. Its internal mutable state is protected by
  synchronization.
* There is an internal method for tests that supports explicit
  charset specification, but the name of this method has changed.

There are additional tests:

* Check all supported encodings on JVM to make sure that readLine
  works correctly with them all.
* Check unicode code points of different bits length with all standard
  unicode encodings (UTF-8, UTF-16, and UTF-32 in LE/HE byte orders).

Benchmarks that compare different implementations of readLine,
including this one (readLine6NoLV in the set) can be found here:
https://github.com/elizarov/ReadLineBenchmark

Taking BufferedReader as 100% baseline we see that:

* Current readLine is 7.5 times slower than BufferedReader baseline.
* New implementation in this commit is 2.5 timer slower than baseline.
  It is ~3 times faster than existing implementation of readLine.

Altogether these optimizations are enough to enable reading of
~500K lines in sports programming setting under 2s time-limit with
plenty of headroom in time. Example that is using this version of
readLine can be found here:
https://codeforces.com/contest/1322/submission/73005366

#KT-37416 Fixed
This commit is contained in:
Roman Elizarov
2020-03-23 14:36:55 +03:00
committed by GitHub
parent aae6319c39
commit e26a3ad033
2 changed files with 188 additions and 71 deletions
+116 -67
View File
@@ -8,11 +8,11 @@
package kotlin.io
import java.io.InputStream
import java.nio.Buffer
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.nio.charset.Charset
import java.nio.charset.CharsetDecoder
import java.nio.charset.CoderResult
/** Prints the given [message] to the standard output stream. */
@kotlin.internal.InlineOnly
@@ -140,88 +140,137 @@ public actual inline fun println() {
System.out.println()
}
private const val BUFFER_SIZE: Int = 32
private const val LINE_SEPARATOR_MAX_LENGTH: Int = 2
private val decoder: CharsetDecoder by lazy { Charset.defaultCharset().newDecoder() }
/**
* Reads a line of input from the standard input stream.
*
* @return the line read or `null` if the input stream is redirected to a file and the end of file has been reached.
*/
fun readLine(): String? = readLine(System.`in`, decoder)
fun readLine(): String? = LineReader.readLine(System.`in`, Charset.defaultCharset())
internal fun readLine(inputStream: InputStream, decoder: CharsetDecoder): String? {
require(decoder.maxCharsPerByte() <= 1) { "Encodings with multiple chars per byte are not supported" }
// Singleton object lazy initializes on the first use, internal for tests
internal object LineReader {
private const val BUFFER_SIZE: Int = 32
private lateinit var decoder: CharsetDecoder
private var directEOL = false
private val bytes = ByteArray(BUFFER_SIZE)
private val chars = CharArray(BUFFER_SIZE)
private val byteBuf: ByteBuffer = ByteBuffer.wrap(bytes)
private val charBuf: CharBuffer = CharBuffer.wrap(chars)
private val sb = StringBuilder()
val byteBuffer = ByteBuffer.allocate(BUFFER_SIZE)
val charBuffer = CharBuffer.allocate(LINE_SEPARATOR_MAX_LENGTH * 2) // twice for surrogate pairs
val stringBuilder = StringBuilder()
var read = inputStream.read()
if (read == -1) return null
do {
byteBuffer.put(read.toByte())
if (decoder.tryDecode(byteBuffer, charBuffer, false)) {
if (charBuffer.endsWithLineSeparator()) {
break
/**
* Reads line from the specified [inputStream] with the given [charset].
* The general design:
* * This function contains only fast path code and all it state is kept in local variables as much as possible.
* * All the slow-path code is moved to separate functions and the call-sequence bytecode is minimized for it.
*/
@Synchronized
fun readLine(inputStream: InputStream, charset: Charset): String? { // charset == null -> use default
if (!::decoder.isInitialized || decoder.charset() != charset) updateCharset(charset)
var nBytes = 0
var nChars = 0
while (true) {
val readByte = inputStream.read()
if (readByte == -1) {
// The result is null only if there was absolutely nothing read
if (sb.isEmpty() && nBytes == 0 && nChars == 0) {
return null
} else {
nChars = decodeEndOfInput(nBytes, nChars) // throws exception if partial char
break
}
} else {
bytes[nBytes++] = readByte.toByte()
}
if (charBuffer.remaining() < 2) {
charBuffer.offloadPrefixTo(stringBuilder)
// With "directEOL" encoding bytes are batched before being decoded all at once
if (readByte == '\n'.toInt() || nBytes == BUFFER_SIZE || !directEOL) {
// Decode the bytes that were read
byteBuf.limit(nBytes) // byteBuf position is always zero
charBuf.position(nChars) // charBuf limit is always BUFFER_SIZE
nChars = decode(false)
// Break when we have decoded end of line
if (nChars > 0 && chars[nChars - 1] == '\n') {
byteBuf.position(0) // reset position for next use
break
}
// otherwise we're going to read more bytes, so compact byteBuf
nBytes = compactBytes()
}
}
read = inputStream.read()
} while (read != -1)
with(decoder) {
tryDecode(byteBuffer, charBuffer, true) // throws exception if undecoded bytes are left
reset()
// Trim the end of line
if (nChars > 0 && chars[nChars - 1] == '\n') {
nChars--
if (nChars > 0 && chars[nChars - 1] == '\r') nChars--
}
// Fast path for short lines (don't use StringBuilder)
if (sb.isEmpty()) return String(chars, 0, nChars)
// Copy the rest of chars to StringBuilder
sb.append(chars, 0, nChars)
// Build the result
val result = sb.toString()
if (sb.length > BUFFER_SIZE) trimStringBuilder()
sb.setLength(0)
return result
}
with(charBuffer) {
var length = position()
if (length > 0 && get(length - 1) == '\n') {
length--
if (length > 0 && get(length - 1) == '\r') {
length--
// The result is the number of chars in charBuf
private fun decode(endOfInput: Boolean): Int {
while (true) {
val coderResult: CoderResult = decoder.decode(byteBuf, charBuf, endOfInput)
if (coderResult.isError) {
resetAll() // so that next call to readLine starts from clean state
coderResult.throwException()
}
}
flip()
repeat(length) {
stringBuilder.append(get())
val nChars = charBuf.position()
if (!coderResult.isOverflow) return nChars // has room in buffer -- everything possible was decoded
// overflow (charBuf is full) -- offload everything from charBuf but last char into sb
sb.append(chars, 0, nChars - 1)
charBuf.position(0)
charBuf.limit(BUFFER_SIZE)
charBuf.put(chars[nChars - 1]) // retain last char
}
}
return stringBuilder.toString()
}
private fun CharsetDecoder.tryDecode(byteBuffer: ByteBuffer, charBuffer: CharBuffer, isEndOfStream: Boolean): Boolean {
val positionBefore = charBuffer.position()
byteBuffer.flip()
with(decode(byteBuffer, charBuffer, isEndOfStream)) {
if (isError) throwException()
// Slow path -- only on long lines (extra call to decode will be performed)
private fun compactBytes(): Int = with(byteBuf) {
compact()
return position().also { position(0) }
}
return (charBuffer.position() > positionBefore).also { isDecoded ->
if (isDecoded) byteBuffer.clear() else byteBuffer.flipBack()
// Slow path -- only on end of input
private fun decodeEndOfInput(nBytes: Int, nChars: Int): Int {
byteBuf.limit(nBytes) // byteBuf position is always zero
charBuf.position(nChars) // charBuf limit is always BUFFER_SIZE
return decode(true).also { // throws exception if partial char
// reset decoder and byteBuf for next use
decoder.reset()
byteBuf.position(0)
}
}
// Slow path -- only on charset change
private fun updateCharset(charset: Charset) {
decoder = charset.newDecoder()
// try decoding ASCII line separator to see if this charset (like UTF-8) encodes it directly
byteBuf.clear()
charBuf.clear()
byteBuf.put('\n'.toByte())
byteBuf.flip()
decoder.decode(byteBuf, charBuf, false)
directEOL = charBuf.position() == 1 && charBuf.get(0) == '\n'
resetAll()
}
// Slow path -- only on exception in decoder and on charset change
private fun resetAll() {
decoder.reset()
byteBuf.position(0)
sb.setLength(0)
}
// Slow path -- only on long lines
private fun trimStringBuilder() {
sb.setLength(BUFFER_SIZE)
sb.trimToSize()
}
}
private fun CharBuffer.endsWithLineSeparator(): Boolean {
val p = position()
return p > 0 && get(p - 1) == '\n'
}
private fun Buffer.flipBack() {
position(limit())
limit(capacity())
}
/** Extracts everything except the last char into [builder]. */
private fun CharBuffer.offloadPrefixTo(builder: StringBuilder) {
flip()
repeat(limit() - 1) {
builder.append(get())
}
compact()
}