swift
When intuition lies: lessons from a CSV parser
When intuition lies: lessons from a CSV parser • Part 2: Correct, Then Fast
One misplaced quote is enough to break the parser. How to fix the edge case and bring indexing down to just 65 milliseconds.
A GTFS file with 260 MB, five and a half million rows, and the discovery that reading it takes 1% of the time: everything else goes into turning bytes into objects. I'd made three structural decisions (read in blocks, index only the start of rows, ask the user if there's a header instead of guessing) and written the first version. It took twenty seconds, almost double the obvious code, and it wasn't the algorithm's fault: it was the debug build.
In release it was 0.38 seconds. The parser was fast.
What remained to discover was that it wasn't correct.
When one quote ruins the whole thing
This first version handles a newline between quotes well when those quotes completely wrap a column’s value.
We avoid treating the data as the end of a row:
Mario,"Via Roma 1
00100 Roma",RM
But what if we have " characters that aren’t in a quoted field?
product,price
monitor 24" full HD,199
keyboard,49
Those quotes clearly represent inches on the screen. Our code, however, sees a quote and toggles the inQuotes state.
I sketched out a little table by hand to clear up the flow: no benchmark, no test, just four bytes traced manually on a made-up file of three rows, and that was enough to shatter my assumption.
| Byte | inQuotes before | What it does |
|---|---|---|
\n end row 1 | false | registers the row - OK! |
" in 24" | false | toggle → true - FAIL |
\n end row 2 | true | skips - SKIP |
\n end row 3 | true | skips - SKIP |
From that point on, the rest of the file is misinterpreted, turning into complete garbage.
The good news is that the CSV RFC gives us a simple rule to handle it all: a quote opens a quoted field only if it’s the first byte of the field.
"Via Roma 1\n00100 Roma"the quote is at field start, the field is quoted, the newline inside is datamonitor 24" full HDthe quote is mid-field, so it’s just a character
That’s why we need if atFieldStart.
Handle double quotes for free
Another potentially tricky case involved literal (escaped) quotes. Inside a quoted field, you write a literal quote by doubling it:
Mario,"said ""hello"" and left"
You might think you need to handle this explicitly: detect a quote, peek ahead at the next one, if it’s also a quote then it’s an escape.
Actually you don’t. The toggle handles it by itself:
"hello""world"
^ inQuotes = true
^ inQuotes = false
^ inQuotes = true ← the two cancel out
^ inQuotes = false
Two consecutive state changes don’t change anything, and the final state is correct.
This seems like a detail, but it’s the thing that makes the whole block-reading work: without peeking ahead, the boundary of the 64 KB block stops being a special case. A "" straddling two blocks doesn’t need special handling, because there’s nothing to handle: the first byte comes in one block, the second in the next, and the math still works.
If I’d chosen to peek ahead I’d have had to handle that edge case by hand, and edge cases written by hand are exactly the ones you most easily lose track of.
The Test That Speaks Clearly
At this point there’s only one thing that works better than a debugger to validate all this, and it’s a test:
// There are three \n in the bytes but only two real rows: the one at position 4 is inside quotes,
// and the final one produces a phantom row to discard.
// If you get [0, 5, 10] quote tracking isn't working at all;
// if you get [0, 10, 16] the phantom row isn't being discarded.
// The test tells apart the two failures, which is half its value.
@Test func indexIgnoreNewlineInsideQuote() {
// a , " x \n y " , b \n
// indices 0 1 2 3 4 5 6 7 8 9
let bytes = Array("a,\"x\ny\",b\n1,2,3\n".utf8)
let offsets = bytes.withUnsafeBufferPointer { buf in
CSVReader.indexLines(
base: buf.baseAddress!,
count: buf.count,
quoteByte: 0x22,
delimiterByte: 0x2C,
trackQuotes: true
)
}
#expect(offsets == [0, 10])
}let bytes = Array("a,\"x\ny\",b\n1,2,3\n".utf8)
// indices: 0 1 2 3 4 5 6 7 8 9 …
#expect(offsets == [0, 10])
Three \n in the bytes, only two real rows: the one at position 4 is inside quotes, and the final one produces a phantom row to discard.
But the test’s value isn’t in the check. It’s in what the failure modes mean:
| Result | Diagnosis |
|---|---|
[0, 10] | correct |
[0, 5, 10] | quote tracking isn’t running at all |
[0, 10, 16] | it’s running, but isn’t discarding the phantom final row |
Two different bugs produce two different numbers. The test doesn’t say “wrong”: it says which wrong.
And it worked. First run: [0, 5, 10].
Which is exactly the output of a parser that ignores quotes: so the problem wasn’t the quote logic, it was that this logic wasn’t running. I went to look at the only place it could happen, and the culprit was a bad else:
} else { // fires on any byte
} else if !inQuotes { // fires only outside quotes
The separator branch was activating even inside a quoted field. A \n protected by quotes was getting registered anyway.
I never opened the debugger.
I don’t ask a test to tell me I got it wrong: I already know that.
I ask it to tell me where.
Optimized for nothing
At this point the parser was correctly generating an array with all the row start indices.
Except the data, read in 64 KB blocks, had already been discarded: each block overwrote the previous one.
To actually extract a row I had to go back and grab those bytes, and there were few roads for doing it:
- Reread row by row, jumping each time to the right position: that means one system call per row, six million calls to read an average of forty-six bytes each. Definitely ruled out.
- Reread in blocks and deliver the rows as you go, but that means traversing the file a second time and redoing all those copies I wanted to avoid. Plus a row can start in one block and end in the next: to deliver it whole I need to save the tail of the first and attach it to the head of the second, with a special case to write and remember. Also unappealing.
- Map the file and keep it all addressable at once.
And that’s when I noticed something that had been in front of me from the start: the index I’d just built contains absolute positions inside the file. They’re not offsets relative to a block, but numbers giving the absolute position.
Which is exactly the language of memory-mapped files, where the file is an array.
I’d actually already done half the work that makes mmap convenient:
let fd = open(fileURL.path, O_RDONLY)
defer {
// Once the mapping is created, the file descriptor isn't needed anymore.
// The mapping keeps its own reference, and the memory stays valid even with the file closed.
close(fd)
}
var info = stat()
fstat(fd, &info)
let size = Int(info.st_size)
// mmap doesn't give you the bytes: it just reserves a range of addresses in the process's memory space
// and tells the kernel "when someone touches these addresses, go get the pieces of that file."
let mapped = mmap(nil, size, PROT_READ, MAP_PRIVATE, fd, 0)!
defer { munmap(mapped, size) } // releases the reservation at the end
// creates a typed pointer to the memory
let base = UnsafePointer(mapped.assumingMemoryBound(to: UInt8.self))
The indexing part suddenly simplified, without the while, without readData, and without needing to maintain a fileOffset.
Plus the three state variables, which were outside the loop to track between block reads, were now just regular local variables in a loop that goes through the file from start to finish.
The edge case of a quoted field split across two read, —which I hadn’t solved yet—simply vanished.
var offsets: [Int] = [0]
var atFieldStart = true
var fieldIsQuoted = false
var inQuotes = false
for i in 0..<size {
let b = base[i]
// ...identical to before, the same four branches byte for byte
}
Besides the simplification of code, I expected some performance improvement; I tried measuring the difference with block reading:
@Test(.enabled(if: gtfsAvailable))
func stageComparison() throws {
let clock = ContinuousClock()
// Warm-up: pulls the 260 MB into the page cache, otherwise the first stage
// measured is timing the disk and looks like the worst of the three.
_ = try StageMemchr.indexLines(fileURL: gtfsURL)
let t0 = clock.now
let chunks = try StageChunks.indexLines(fileURL: gtfsURL)
let chunksTime = clock.now - t0
let t1 = clock.now
let mapped = try StageMmap.indexLines(fileURL: gtfsURL)
let mappedTime = clock.now - t1
let t2 = clock.now
let memchr = try StageMemchr.indexLines(fileURL: gtfsURL)
let memchrTime = clock.now - t2
// Again: same result, or the timings mean nothing.
#expect(mapped == chunks)
#expect(memchr == chunks)
let attributes = try FileManager.default.attributesOfItem(atPath: gtfsURL.path)
let gigabytes = Double((attributes[.size] as? Int) ?? 0) / 1_073_741_824
func throughput(_ d: Duration) -> String {
String(format: "%.2f GB/s", gigabytes / seconds(d))
}
print("""
── line indexing, \(chunks.count) rows ──
1. 64 KB blocks \(String(format: "%7.3f", seconds(chunksTime)))s \(throughput(chunksTime))
2. mmap \(String(format: "%7.3f", seconds(mappedTime)))s \(throughput(mappedTime))
3. mmap + memchr \(String(format: "%7.3f", seconds(memchrTime)))s \(throughput(memchrTime))
1 → 2: \(String(format: "%.1f", seconds(chunksTime) / seconds(mappedTime)))x
2 → 3: \(String(format: "%.1f", seconds(mappedTime) / seconds(memchrTime)))x
total: \(String(format: "%.1f", seconds(chunksTime) / seconds(memchrTime)))x
""")
}| Release | |
|---|---|
| 64 KB blocks | 0.384s |
mmap | 0.373s |
Nothing. One point zero.
Even madvise(MADV_SEQUENTIAL) didn’t help, I nibbled away just another hundredth.
It's a way to tell the kernel how we want to use a memory-mapped region, so it can adjust accordingly. With MADV_SEQUENTIAL we're saying "I'll read this file from start to finish, once only".
Two things change. The first is readahead: when we touch a page, the kernel preloads a certain number of following pages in advance, instead of waiting for us to ask one by one. Without the hint it has to first figure out we're going in order, so it starts more cautiously.
The second is that pages we've left behind get freed sooner, because you promised we won't look back at them. On a 260 MB file it's the difference between keeping it all resident and keeping just a scrolling window.
It's a hint, not an order: the kernel can ignore it if it wants.
That it didn’t work though makes perfect sense.
The bottleneck was never how the bytes arrive, but the loop that looks at one at a time asking it four questions. I changed how the file is prepared, not how it’s read, and all the time was actually spent processing.
Now, keeping a change that gains nothing looks a lot like stubbornness.
The difference between the two lies entirely in being able to say what it bought us, even when it’s not speed.
Here it was less code and one fewer edge case to remember, but above all a stable pointer: a valid address for the reader’s whole lifetime, that I can have fields point to without copying them from anywhere. What it’s really for we’ll see in a moment.
For now the timer says 0.373 seconds, and hasn’t budged a millimeter.
Search instead of scan
Inside my indexing loop we ask four questions for every byte.
But those are usually questions that don’t matter: the vast majority of bytes are data, not control characters (quotes, commas, newlines).
A lot of questions whose answer we don’t care about, and which in any case would be “no”.
The goal was therefore to avoid examining every byte: just find the next interesting byte in the sequence.
Fortunately C has long had a dedicated function: memchr takes a block of memory and a byte, and returns the position of the first occurrence. There’s nothing magical inside, just SIMD hand-written code: instead of comparing one byte at a time it loads sixteen in a register and compares them all together.
There’s a problem though, and it’s big: memchr searches for just one byte. My parser doesn’t need to find a byte, it needs to know whether it’s currently inside or outside quotes. That’s a state, and you can’t express a state with a search.
Pulling the right thread
It seemed like a dead end, until I asked myself a different question: when is the state not needed?
The answer is almost obvious once stated: If the entire file has no quotes in it, then no field can be quoted. And if no field is quoted, no newline can be inside a field. So searching for newlines alone isn’t an approximation: it’s provably correct.
And to know it you just need one memchr searching for a quote across the whole file.
// Is there at least one quote in all 260 MB?
let hasQuotes = memchr(base, Int32(quoteByte), size) != nil
guard hasQuotes else {
// No. Then no field is quoted, and finding newlines is enough.
var pos = 0
while pos < size, let hit = memchr(base + pos, 0x0A, size - pos) {
let i = UnsafeRawPointer(hit) - UnsafeRawPointer(base)
offsets.append(i + 1)
pos = i + 1
}
return offsets
}
// Yes. Then we need the state machine, byte by byte, like before.
What I like most about this approach isn’t just the speed: it’s that I’m not asking anything of the library user. No options to turn on, no promises to make about their data. The code looks at the file and decides on its own which shortcut it can take.
Was it worth it?
@Test(.enabled(if: gtfsAvailable))
func stageComparison() throws {
let clock = ContinuousClock()
// Warm-up: pulls the 260 MB into the page cache, otherwise the first stage
// measured is timing the disk and looks like the worst of the three.
_ = try StageMemchr.indexLines(fileURL: gtfsURL)
let t0 = clock.now
let chunks = try StageChunks.indexLines(fileURL: gtfsURL)
let chunksTime = clock.now - t0
let t1 = clock.now
let mapped = try StageMmap.indexLines(fileURL: gtfsURL)
let mappedTime = clock.now - t1
let t2 = clock.now
let memchr = try StageMemchr.indexLines(fileURL: gtfsURL)
let memchrTime = clock.now - t2
// Again: same result, or the timings mean nothing.
#expect(mapped == chunks)
#expect(memchr == chunks)
let attributes = try FileManager.default.attributesOfItem(atPath: gtfsURL.path)
let gigabytes = Double((attributes[.size] as? Int) ?? 0) / 1_073_741_824
func throughput(_ d: Duration) -> String {
String(format: "%.2f GB/s", gigabytes / seconds(d))
}
print("""
── line indexing, \(chunks.count) rows ──
1. 64 KB blocks \(String(format: "%7.3f", seconds(chunksTime)))s \(throughput(chunksTime))
2. mmap \(String(format: "%7.3f", seconds(mappedTime)))s \(throughput(mappedTime))
3. mmap + memchr \(String(format: "%7.3f", seconds(memchrTime)))s \(throughput(memchrTime))
1 → 2: \(String(format: "%.1f", seconds(chunksTime) / seconds(mappedTime)))x
2 → 3: \(String(format: "%.1f", seconds(mappedTime) / seconds(memchrTime)))x
total: \(String(format: "%.1f", seconds(chunksTime) / seconds(memchrTime)))x
""")
}| Release | |
|---|---|
mmap, scalar loop | 0.373s |
mmap + sniff + memchr | 0.065s |
A 5.7x improvement—our first real performance leap.
There’s also an unexpected side effect. Before the sniff I had a configuration variable—“I promise no field contains a newline”, which gave the fast path to anyone who turned it on. After, it was worthless: speed comes anyway. I ripped it out.
The comparison between the two, in debug, is rigged in favor of the first by a factor of thirty.
Measuring in the wrong configuration doesn't just give you the wrong number, it gives you the wrong suggestion about what's worth optimizing. Which, as we'll see in a moment, is exactly what I'd just finished doing.
A row that contains nothing
At this point I had correctly gotten an array with the start index of every row.
Great! …Except I still didn’t know how to actually retrieve row number n.
Two problems, one solution
Before moving forward we need to represent a row. Could it be something like this?
struct CSVRow {
let fields: [String] // the various fields of each row
}
We could read it with a cursor that advances, one row at a time:
while let row = reader.nextRow() {
print(row.field(at: 0))
}
It’s definitely the simplest approach, but also the most expensive: to build it I’d need to grab bytes from the file, copy them to a new string, and put it in an array.
For every field 56 million strings, each one with its own heap allocation.
This is actually the approach you get through components(separatedBy:), and it would have completely eclipsed all the tiny optimization steps we’d taken above.
But thinking about it, the data’s already in memory: it’s in the mapped file, contiguous and already ready. Is it really worth copying it somewhere else to make it visible?
struct CSVRow {
let base: UnsafePointer<UInt8> // reference to the mapped file
let fields: UnsafeBufferPointer<Range<Int>> // the ranges of each field
}
In this version the row text never gets copied (zero-copy); you just create a window onto data that’s already loaded elsewhere, and is only read and processed when it’s requested (kind of like Substring does).
But this approach creates a problem too: a row that doesn’t contain its own data is a row that depends on someone else to stay valid in memory; in our case it’ll depend on the existence of the map in the CSVReader class.
var row: CSVRow?
do {
let reader = try CSVReader(fileURL: url)
row = reader.nextRow()
} // the reader dies here, and with it the munmap
print(row!.field(at: 0)) // SIGBUS
The compiler won’t complain, but at runtime, once the memory is unmapped, the process will crash with a SIGBUS.
A second problem is again about allocations.
To give access to the various columns for each row you need an array of ranges: one array per row, that’s 5.6 million more allocations.
Sound familiar?
It’s exactly the problem we started with when reading rows, applied now to columns.
The two problems have a common root: there’s no moment when I know a row has stopped being useful. If I did, I could reuse the same buffer for the next row, and I could guarantee no row survives the map. That moment I can build, and it’s the end of a closure:
reader.forEachRow { row in
// here `row` is valid, and only here
} // not anymore
Inside the closure the reader is alive by definition, so the map is there.
And on exit I know that row is done, so I can clear the field ranges buffer and fill it with the next row.
This translates to one allocation for the entire file, instead of one per row.
The trade-off
This choice has a price: you lose the while let, which is more familiar. You lose early exit: to stop at the first row that matters you need a workaround, because a closure that returns Void can’t say “enough” (nothing complicated though, returning a Bool on exit will do).
Obviously nothing stops me from capturing the row inside the closure and taking it with me, and if I do that I’m back to the SIGBUS from before.
For now it’s a convention, not a rule: it’s written in the documentation, not in the compiler. Swift has the tools to make it a compile error, but that’s a story for another time.
At this point I had everything: a fast index, rows that don’t copy anything, fields that materialize only if someone asks for them.
What was missing was measuring how much it really cost to read that file.