Skip to content

Reducing CSV Parser Allocations with Okio Segments

Editorial illustration of a CSV parser reading byte segments without unnecessary copies
A byte-first parser can remove a surprising amount of waste before any clever algorithm appears.

bluetape4k-csv originally parsed CSV through a Reader. That path was simple and correct, but it decoded every character before appending field data to a StringBuilder. For large UTF-8 CSV streams, the hot path was doing more allocation and decode work than necessary.

The parser optimization targeted that path. The final change kept the public API unchanged, added an internal Okio-backed lexer for supported UTF-8 CSV settings, and falls back to the existing reader lexer when the settings are outside the fast path.

CSV parser throughput comparison chart showing existing and Okio ops per second
A benchmark-backed account of the CSV parser optimization, from Reader parsing to Okio UnsafeCursor segment scanning.

The public entrypoint remains the same:

CsvRecordReader()
.read(input, UTF_8, skipHeaders = true)
.count()

Internally, supported UTF-8 reads now go through OkioCsvLexer:

if (encoding == UTF_8 && OkioCsvLexer.isSupported(settings)) {
OkioCsvLexer(input.source().buffer(), settings, skipHeaders).use { lexer ->
while (lexer.hasNext()) {
yield(transform(lexer.next()))
}
}
return@sequence
}

The fast path is deliberately narrow:

ConditionFast path behavior
UTF-8 inputUse OkioCsvLexer
ASCII delimiter and quoteScan structural bytes directly
doubled-quote escapingPreserve current CSV semantics
unsupported charset or settingsFall back to CsvLexer

Wrapping the stream in Okio was not enough. A simple byte loop was correct, but the large lexer benchmark only moved from about 17.5 to 21.3 ops/s. The useful gain came from scanning Okio segments directly with a read-only Buffer.UnsafeCursor.

private fun findTerminatorOffset(terminators: ByteString): Long {
source.buffer.readUnsafe().use { cursor ->
while (cursor.next() != -1) {
val data = cursor.data ?: continue
var index = cursor.start
while (index < cursor.end) {
if (isTerminator(data[index], terminators)) {
return cursor.offset + index - cursor.start
}
index++
}
}
}
return -1L
}

The cursor is read-only. It only finds delimiter, quote, CR, and LF bytes. Payload bytes are moved into an Okio Buffer, then decoded once when the field is complete.

As a reusable pattern, a read-only helper for finding the first of several target bytes in an Okio Buffer looks like this:

private fun Buffer.indexOfAnyByteUnsafe(targets: ByteArray): Long {
readUnsafe().use { cursor ->
while (cursor.next() != -1) {
val data = cursor.data ?: continue
var index = cursor.start
while (index < cursor.end) {
if (data[index] in targets) {
return cursor.offset + index - cursor.start
}
index++
}
}
}
return -1L
}

The important constraints are to close the cursor with use, read only cursor.start until cursor.end, and never mutate cursor.data in this scan.

UnsafeCursor needs behavior locks. The regression suite compares the Okio lexer against the existing reader lexer on RFC 4180 examples and on the real extra_words.csv fixture.

parseWithOkio(csv) shouldBeEqualTo parseWithReader(csv)

It also keeps maxCharsPerColumn checks. The implementation drains scanned source segments into the field buffer and checks the byte bound before final UTF-8 decoding, then checks the decoded character length as well.

./gradlew :bluetape4k-csv:testBenchmark

BenchmarkReader baselineOkio fast pathSpeedup
nativeCsvRead_small20,173.731 ops/s45,417.110 ops/s2.25x
nativeCsvRead_medium296.944 ops/s683.434 ops/s2.30x
nativeCsvRead_large17.312 ops/s40.115 ops/s2.32x
BenchmarkExisting CsvLexerOkioCsvLexerSpeedup
nativeLexer_small21,043.679 ops/s45,062.565 ops/s2.14x
nativeLexer_medium288.105 ops/s668.457 ops/s2.32x
nativeLexer_large17.996 ops/s41.484 ops/s2.31x

The public reader and direct lexer measurements point in the same direction. The improvement comes from the lexer input path, not from wrapper noise.

BufferedSource.indexOfElement() was attractive because it is compact and safe, but the experiment did not give the control needed for this parser path. A simple Okio byte loop was correct but too small a win. The read-only UnsafeCursor scan gave the expected segment-level benefit while preserving the existing parser behavior.

The next natural candidate is the pipeline that saves large data streams as CSV. bluetape4k-csv already has a streaming-shaped API: FlowCsvWriter.writeFile(rows: Flow<Iterable<*>>). The current implementation still writes through OutputStreamWriter and Writer.write(...).

For UTF-8 file output, a BufferedSink fast path could write delimiter, quote, and line separator bytes directly, then write field payloads to the sink only when needed.

suspend fun writeCsvFileWithOkio(
path: Path,
rows: Flow<Iterable<*>>,
delimiter: Byte = ','.code.toByte(),
quote: Byte = '"'.code.toByte(),
): Long = withContext(Dispatchers.IO) {
var count = 0L
path.sink().buffer().use { sink ->
rows.collect { row ->
var first = true
for (field in row) {
if (!first) sink.writeByte(delimiter.toInt())
first = false
val value = field?.toString()
if (value != null) {
sink.writeByte(quote.toInt())
for (ch in value) {
if (ch == '"') sink.writeUtf8("\"\"") else sink.writeUtf8CodePoint(ch.code)
}
sink.writeByte(quote.toInt())
}
}
sink.writeUtf8("\n")
count++
}
}
count
}

This is a blog sketch, not the exact production patch. A real implementation must preserve the existing DelimitedWriter contracts: null and empty-string roundtrip, quoteAll, TSV delimiter behavior, charset fallback, and close/flush semantics. The direction is still clear. The read path reduced allocations with BufferedSource and UnsafeCursor; the write path can likely use BufferedSink to reduce intermediate row strings and many small Writer.write calls.

The useful lesson is not “use Okio.” It is to use the right layer of Okio. BufferedSource gave a clean stream boundary, Buffer avoided early string materialization, and read-only UnsafeCursor made segment scanning fast enough to matter.

Comments

Leave a note or reaction with your GitHub account.