Reducing CSV Parser Allocations with Okio Segments

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.

What Changed
Section titled “What Changed”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:
| Condition | Fast path behavior |
|---|---|
| UTF-8 input | Use OkioCsvLexer |
| ASCII delimiter and quote | Scan structural bytes directly |
| doubled-quote escaping | Preserve current CSV semantics |
| unsupported charset or settings | Fall back to CsvLexer |
Why UnsafeCursor
Section titled “Why UnsafeCursor”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.
Guardrails
Section titled “Guardrails”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.
Public Reader Result
Section titled “Public Reader Result”./gradlew :bluetape4k-csv:testBenchmark
| Benchmark | Reader baseline | Okio fast path | Speedup |
|---|---|---|---|
nativeCsvRead_small | 20,173.731 ops/s | 45,417.110 ops/s | 2.25x |
nativeCsvRead_medium | 296.944 ops/s | 683.434 ops/s | 2.30x |
nativeCsvRead_large | 17.312 ops/s | 40.115 ops/s | 2.32x |
Lexer-Level Comparison
Section titled “Lexer-Level Comparison”| Benchmark | Existing CsvLexer | OkioCsvLexer | Speedup |
|---|---|---|---|
nativeLexer_small | 21,043.679 ops/s | 45,062.565 ops/s | 2.14x |
nativeLexer_medium | 288.105 ops/s | 668.457 ops/s | 2.32x |
nativeLexer_large | 17.996 ops/s | 41.484 ops/s | 2.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.
What Did Not Ship
Section titled “What Did Not Ship”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.
Next Candidate: Okio CSV Writer
Section titled “Next Candidate: Okio CSV Writer”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.
Source Links
Section titled “Source Links”- Follow-up article: When the CSV Writer Took Off Its Ankle Weights
- Main implementation:
OkioCsvLexer - Benchmarks:
CsvParserBenchmark - Behavior tests:
OkioCsvLexerTest - Tests:
./gradlew :bluetape4k-csv:test-> 268 passing - Benchmark:
./gradlew :bluetape4k-csv:testBenchmark
Closing
Section titled “Closing”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.