swift
When intuition lies: lessons from a CSV parser
When intuition lies: lessons from a CSV parser • Part 3: The 9%
Once the entire pipeline is measured, the surprise lands: the phase I'd obsessed over accounts for just 9% of the real work.
A parser that doesn't get fooled by a quote in the middle of a field, built on top of a file mapped into memory.
Indexing dropped from 0.38 to 0.065 seconds thanks to `memchr` and a check that decides on its own when it can take the shortcut. Rows don't copy a single byte, and fields materialize only if someone asks for them.
All measured, all faster than before.
Except I'd never timed the complete work.The 9%
It was time to grab the timer again and measure the phases separately. The trick is running the loop with an empty body:
reader.forEachRow { _ in }
This splits every row into its fields and then throws everything away without building a single value. The difference between this time and the time when the loop does real work is, by subtraction, the cost of materializing the objects.
Three measurements to take:
- opening the file
- the empty loop
- the real loop
@Test func stopTimes() throws {
let clock = ContinuousClock()
// Warm-up: one empty run brings the 260 MB into page cache.
// Without it, the first measurement times the disk instead of the code—the same
// read goes from ~0.19s to ~0.44s depending on whether pages are there or not.
do {
let warmup = try CSVReader(fileURL: gtfsURL, config: .init(hasHeaders: true))
#expect(warmup.rowCount > 0)
}
// 1 — indexing (the init: mmap, sniff, scan for offsets)
let rssBefore = residentMemoryMB()
let t0 = clock.now
let reader = try CSVReader(fileURL: gtfsURL, config: .init(hasHeaders: true))
let indexing = clock.now - t0
let rssAfterIndex = residentMemoryMB()
guard let iTrip = reader.columnIndex("trip_id"),
let iArr = reader.columnIndex("arrival_time"),
let iDep = reader.columnIndex("departure_time"),
let iStop = reader.columnIndex("stop_id"),
let iSeq = reader.columnIndex("stop_sequence"),
let iHead = reader.columnIndex("stop_headsign"),
let iPick = reader.columnIndex("pickup_type"),
let iDrop = reader.columnIndex("drop_off_type"),
let iDist = reader.columnIndex("shape_dist_traveled"),
let iTime = reader.columnIndex("timepoint")
else {
Issue.record("header differs from expected: \(reader.columnNames)")
return
}
// 2a — just iteration: splitFields on every row, empty body.
// Isolates the cost of splitting into fields from materializing values.
var rowsVisited = 0
let tSplit = clock.now
reader.forEachRow { _ in rowsVisited += 1 }
let splitOnly = clock.now - tSplit
// 2b — direct access, building the exact same StopTime
var builtDirectly = 0
let t1 = clock.now
reader.forEachRow { row in
_ = StopTime(
trip_id: row.field(at: iTrip),
arrival_time: row.field(at: iArr),
departure_time: row.field(at: iDep),
stop_id: row.field(at: iStop),
stop_sequence: row.intField(at: iSeq) ?? 0,
stop_headsign: row.isFieldEmpty(at: iHead) ? nil : row.field(at: iHead),
pickup_type: row.intField(at: iPick),
drop_off_type: row.intField(at: iDrop),
shape_dist_traveled: row.doubleField(at: iDist),
timepoint: row.intField(at: iTime)
)
builtDirectly += 1
}
let direct = clock.now - t1
// 2c — the same StopTime, but converting numbers by passing through String:
// it's the obvious way, what you'd write without typed accessors.
var builtViaStrings = 0
let tStr = clock.now
reader.forEachRow { row in
_ = StopTime(
trip_id: row.field(at: iTrip),
arrival_time: row.field(at: iArr),
departure_time: row.field(at: iDep),
stop_id: row.field(at: iStop),
stop_sequence: Int(row.field(at: iSeq)) ?? 0,
stop_headsign: row.field(at: iHead).isEmpty ? nil : row.field(at: iHead),
pickup_type: Int(row.field(at: iPick)),
drop_off_type: Int(row.field(at: iDrop)),
shape_dist_traveled: Double(row.field(at: iDist)),
timepoint: Int(row.field(at: iTime))
)
builtViaStrings += 1
}
let viaStrings = clock.now - tStr
// 3 — the same thing via Codable
var decoded = 0
let t2 = clock.now
try reader.decode(StopTime.self) { _ in decoded += 1 }
let codable = clock.now - t2
#expect(rowsVisited == reader.rowCount)
#expect(builtDirectly == decoded)
#expect(builtDirectly == builtViaStrings)
#expect(builtDirectly == reader.rowCount)
let values = seconds(direct) - seconds(splitOnly)
let valuesViaStrings = seconds(viaStrings) - seconds(splitOnly)
print("""
── stop_times.txt — \(reader.rowCount) rows ──
1. indexing \(indexing) (RSS +\(String(format: "%.0f", rssAfterIndex - rssBefore)) MB)
2a. split fields only \(splitOnly)
2b. + typed accessors \(direct)
2c. + conversion via String \(viaStrings)
3. Codable \(codable)
split into fields \(String(format: "%.3f", seconds(splitOnly)))s
materialize (typed) \(String(format: "%.3f", values))s
materialize (via String) \(String(format: "%.3f", valuesViaStrings))s
typed accessors save \(String(format: "%.1f", valuesViaStrings / values))x
Codable costs \(String(format: "%.1f", seconds(codable) / seconds(direct)))x direct access
""")
}| Phase | Time | Share |
|---|---|---|
| Indexing | 0.062s | 9% |
| Splitting rows into fields | 0.260s | 38% |
| Turning fields into values | 0.427s | 62% |
| Total | 0.687s |
I’d focused on squeezing 315 milliseconds out of indexing only to discover the other 625 milliseconds I’d never looked at. It’s not that I’d optimized badly: the index now really is five times faster, and the numbers are real. It’s that I’d carefully optimized the smallest phase of the program, the one that in real use weighs less than a tenth.
There are two reasons for this:
- the index was the part I could see: the code I’d just written, the thing in my head. The other two phases worked already and were invisible precisely because they worked
- the index was the only one with a number: it was easy to time, and what doesn’t have a number doesn’t compete for our attention
In the beginning, I mentioned Knuth but cut off the crucial word premature. But the full sentence says something more specific than I remembered:
«We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil.»
97% of the time. I found my 9% and dove into it with enthusiasm for three sections.
The difference between me and that advice isn’t that he knew and I didn’t: I knew too, I’d written it in this article’s introduction.
It’s that knowing something and having measured it are two different mental states, and only the second one changes what you do Monday morning.
Splitting the fields
From the tests it was clear that 38% of the time went into splitting rows into their columns. That’s when I realized the answer was already right under my nose, but I’d thrown it away.
If you remember, when opening the file I “sniff” the quotes to decide how to build the index. That result (“this file doesn’t have even one quote”) I used for one line of code and then threw it away. Actually it can be useful later too: if there are no quotes in the entire file, then when I split a row into fields every comma I find is a true separator, with no exceptions to check.
// `start` and `end` delimit the row to split, `ranges` collects the fields.
private func splitFields(start: Int, end: Int, into ranges: inout [Range<Int>]) {
ranges.removeAll(keepingCapacity: true)
// `hasQuotes` is the result of the sniff done when opening the file.
// If it's false, in all 260 MB there's not a single quote: no field
// is quoted, and every comma I find is a true separator.
guard hasQuotes else {
var fieldStart = start // where the field I'm reading starts
var pos = start // where to resume searching
// memchr looks for the next comma between `pos` and the end of the row.
// The third argument, `end - pos`, limits the search to this row:
// it can't spill into the next one.
// Returns nil when it finds none: we're at the last field.
while pos < end,
let hit = memchr(base + pos, Int32(delimiterByte), end - pos) {
// memchr returns a pointer, I need a position:
// I get it by subtracting the start address of the map.
let i = UnsafeRawPointer(hit) - UnsafeRawPointer(base)
ranges.append(fieldStart..<i) // field goes from fieldStart to the comma
fieldStart = i + 1 // next one starts right after
pos = i + 1
}
// The last field has no comma closing it: it ends with the row.
ranges.append(fieldStart..<end)
return
}
// File contains quotes: can't escape the state machine,
// byte by byte, with the four branches from before.
}
A 1.5x.
I expected more.
But why so little, when on the index the same approach gave me 5.7x? After all it was the same function, same file, same idea.
In the end though the reason is much simpler: `memchr` pays off when the byte we're searching for is rare: it loads sixteen bytes at a time, compares them all together, and wins as long as it keeps not finding anything. But each call has a fixed startup cost.
On the index I was searching for newlines: one every forty-six bytes. Time to warm up, and five million calls all told.
Here I'm searching for commas: one every four and a half bytes, and fifty-six million calls total. `memchr` does maybe one vectorized round and comes right back. It's basically the worst case it can work in.
The same optimization, on the same data, is worth five times or one and a half times depending on how dense the thing you're searching for is. There's no such thing as good optimizations: there are optimizations suited to a certain shape of data.
At this point there was one obvious thing left: for each field I was doing append on an array for fifty-six million rows, each one with its capacity check and uniqueness check.
Removing them seemed easy: if the array is already sized to the number of columns in the header, I can write directly ranges[n] instead of appending. The idea was to recover between 170 and 280 milliseconds. I recovered 48.
Also a complete waste.
The reason is I was giving each append three or four nanoseconds, and it cost less than one. With capacity already reserved, in release the compiler lifts almost all the uniqueness check, and the capacity check is a branch the CPU predictor nails every single time.
Plus, by writing `ranges[n]`, I've simply replaced one check with another: the array bounds check.
Anyway by the end, with two moves, I’d gone from 0.484 to 0.275 seconds, gaining 1.76x overall on field splitting.
| Phase | Time | Share |
|---|---|---|
| Indexing | 0.062s | 8% |
| Splitting into fields | 0.275s | 35% |
| Turning fields into values | 0.427s | 57% |
| Total | 0.764s |
There’s only materializing the values left, which is now the biggest part of the program.
And it’s the only phase I haven’t touched yet.
From bytes to values
To read the data, like the sequence number of a stop, the most obvious path is to get the string and cast it.
let n = Int(row.field(at: iSeq)) ?? 0
This innocent operation hides two though: field(at:) grabs the bytes from the mapped file and builds a String from them; then Int(_:) reads that string and pulls out a number.
One instruction later, the string is useful to nobody.
It seemed clear to me that the cost was there: build an object to throw it away immediately, five and a half million times.
So I wrote an accessor that reads the bytes directly, without any string ever getting created.
It’s worth looking at what this means, because it’s not complicated.
In a file there are no numbers: there are bytes. Take 74763, one of the stop IDs from the sample row at the start of the article. On disk it’s not the number seventy-four thousand seven hundred sixty-three, it’s five separate bytes, one per digit, each one with the code of the corresponding character.
To pull out an integer you walk through them one at a time rebuilding the value: start at zero, multiply by ten, add the digit, repeat. Five rounds just for this field alone, and about ten fields per row, on five and a half million rows.
The C library has `strtol`, which converts digits into numbers working straight on raw bytes: no `String` in between, and it's a serious candidate.
I ruled it out for one specific reason: `strtol` wants a sequence terminated by a zero, and my bytes are **in the middle** of a mapped file, where after the field is a comma. In practice it would work anyway, because the function stops at the first non-digit character. But it's not a guarantee written anywhere, and there's a bad case: a number at the last byte of a file whose size is an exact multiple of the page size. There, reading a byte past the field means reading past the map, and that's `SIGBUS` again.
I didn't want the library to work only if my users' files weren't multiples of 16 KB.
The hand-written parser instead stops where I tell it:
var result = 0
var i = range.lowerBound
while i < range.upperBound {
let digit = Int(base[i]) &- 48 // character '0' is worth 48
guard digit >= 0, digit <= 9 else { return nil }
// A field with thirty digits isn't impossible in a file I didn't write.
// If written naively as result * 10 + digit, the process would crash right there.
let (scaled, overflowA) = result.multipliedReportingOverflow(by: 10)
guard !overflowA else { return nil }
let (sum, overflowB) = scaled.addingReportingOverflow(digit)
guard !overflowB else { return nil }
result = sum
i += 1
}
Those two lines about overflow deserve a moment. The obvious version result = result * 10 + digit on a long enough field overflows the capacity of an Int, and in Swift overflowing doesn’t give you a wrong number: it terminates the process.
The variants ...ReportingOverflow cost the same as normal operators, because the compiler generates the check anyway, but they return nil instead of killing everything.
For decimals, the rules change
For Double I thought I’d do the same, and I was right to stop.
Writing a correct floating-point parser is one of those things there are scientific papers about: rounding to the last digit, exponential notation, edge cases of precision.
That’s the kind of code that seems to work for six months.
So here the library wins. With a surprise: Swift’s Double(String) doesn’t go through strtod, it has its own parser, and doesn’t depend on locale. The classic bug where on an Italian machine 3.14 becomes 3 because the decimal separator expected is a comma—in Swift it doesn’t exist.
nd what about the String I had sworn to avoid at all costs? Under sixteen bytes Swift keeps it inside the struct, without touching the heap. 41.902782 is nine characters: that string lives in registers and vanishes.
How much it helped
@Test func stopTimes() throws {
let clock = ContinuousClock()
// Warm-up: one empty run brings the 260 MB into page cache.
// Without it, the first measurement times the disk instead of the code—the same
// read goes from ~0.19s to ~0.44s depending on whether pages are there or not.
do {
let warmup = try CSVReader(fileURL: gtfsURL, config: .init(hasHeaders: true))
#expect(warmup.rowCount > 0)
}
// 1 — indexing (the init: mmap, sniff, scan for offsets)
let rssBefore = residentMemoryMB()
let t0 = clock.now
let reader = try CSVReader(fileURL: gtfsURL, config: .init(hasHeaders: true))
let indexing = clock.now - t0
let rssAfterIndex = residentMemoryMB()
guard let iTrip = reader.columnIndex("trip_id"),
let iArr = reader.columnIndex("arrival_time"),
let iDep = reader.columnIndex("departure_time"),
let iStop = reader.columnIndex("stop_id"),
let iSeq = reader.columnIndex("stop_sequence"),
let iHead = reader.columnIndex("stop_headsign"),
let iPick = reader.columnIndex("pickup_type"),
let iDrop = reader.columnIndex("drop_off_type"),
let iDist = reader.columnIndex("shape_dist_traveled"),
let iTime = reader.columnIndex("timepoint")
else {
Issue.record("header differs from expected: \(reader.columnNames)")
return
}
// 2a — just iteration: splitFields on every row, empty body.
// Isolates the cost of splitting into fields from materializing values.
var rowsVisited = 0
let tSplit = clock.now
reader.forEachRow { _ in rowsVisited += 1 }
let splitOnly = clock.now - tSplit
// 2b — direct access, building the exact same StopTime
var builtDirectly = 0
let t1 = clock.now
reader.forEachRow { row in
_ = StopTime(
trip_id: row.field(at: iTrip),
arrival_time: row.field(at: iArr),
departure_time: row.field(at: iDep),
stop_id: row.field(at: iStop),
stop_sequence: row.intField(at: iSeq) ?? 0,
stop_headsign: row.isFieldEmpty(at: iHead) ? nil : row.field(at: iHead),
pickup_type: row.intField(at: iPick),
drop_off_type: row.intField(at: iDrop),
shape_dist_traveled: row.doubleField(at: iDist),
timepoint: row.intField(at: iTime)
)
builtDirectly += 1
}
let direct = clock.now - t1
// 2c — the same StopTime, but converting numbers by passing through String:
// it's the obvious way, what you'd write without typed accessors.
var builtViaStrings = 0
let tStr = clock.now
reader.forEachRow { row in
_ = StopTime(
trip_id: row.field(at: iTrip),
arrival_time: row.field(at: iArr),
departure_time: row.field(at: iDep),
stop_id: row.field(at: iStop),
stop_sequence: Int(row.field(at: iSeq)) ?? 0,
stop_headsign: row.field(at: iHead).isEmpty ? nil : row.field(at: iHead),
pickup_type: Int(row.field(at: iPick)),
drop_off_type: Int(row.field(at: iDrop)),
shape_dist_traveled: Double(row.field(at: iDist)),
timepoint: Int(row.field(at: iTime))
)
builtViaStrings += 1
}
let viaStrings = clock.now - tStr
// 3 — the same thing via Codable
var decoded = 0
let t2 = clock.now
try reader.decode(StopTime.self) { _ in decoded += 1 }
let codable = clock.now - t2
#expect(rowsVisited == reader.rowCount)
#expect(builtDirectly == decoded)
#expect(builtDirectly == builtViaStrings)
#expect(builtDirectly == reader.rowCount)
let values = seconds(direct) - seconds(splitOnly)
let valuesViaStrings = seconds(viaStrings) - seconds(splitOnly)
print("""
── stop_times.txt — \(reader.rowCount) rows ──
1. indexing \(indexing) (RSS +\(String(format: "%.0f", rssAfterIndex - rssBefore)) MB)
2a. split fields only \(splitOnly)
2b. + typed accessors \(direct)
2c. + conversion via String \(viaStrings)
3. Codable \(codable)
split into fields \(String(format: "%.3f", seconds(splitOnly)))s
materialize (typed) \(String(format: "%.3f", values))s
materialize (via String) \(String(format: "%.3f", valuesViaStrings))s
typed accessors save \(String(format: "%.1f", valuesViaStrings / values))x
Codable costs \(String(format: "%.1f", seconds(codable) / seconds(direct)))x direct access
""")
}| Materialization | |
|---|---|
conversion from String | 0.572s |
| typed accessors | 0.446s |
A 1.3x. on the complete path, 1.18x.
Not quite the gains my reasoning had promised: the truth is the string I was avoiding, for the most part, wasn’t being allocated. The numeric fields of a CSV are short by nature—1, 0, 1167, 07:30:00—so they all fit under the sixteen-byte threshold and stay inline.
It’s the same discovery I’d just made with decimals where I was sure the cost was the allocation. The allocation wasn’t there.
The hand-written parser stays the right thing to have, 1.3x is 1.3x, and once written it costs nothing more, but for a reason different from why I wrote it: it doesn’t avoid allocating, it avoids building and validating a UTF-8 string to throw it away one instruction later.
The golden rule here is: write it by hand when the grammar is simple and the library generics ask for guarantees you don’t have; use the library when correctness is hard.
But above all you need to check what you’re really saving, because in my case it wasn’t what I believed.
Where we arrived
This is the final picture, with the numbers from the last run:
@Test func stopTimes() throws {
let clock = ContinuousClock()
// Warm-up: one empty run brings the 260 MB into page cache.
// Without it, the first measurement times the disk instead of the code—the same
// read goes from ~0.19s to ~0.44s depending on whether pages are there or not.
do {
let warmup = try CSVReader(fileURL: gtfsURL, config: .init(hasHeaders: true))
#expect(warmup.rowCount > 0)
}
// 1 — indexing (the init: mmap, sniff, scan for offsets)
let rssBefore = residentMemoryMB()
let t0 = clock.now
let reader = try CSVReader(fileURL: gtfsURL, config: .init(hasHeaders: true))
let indexing = clock.now - t0
let rssAfterIndex = residentMemoryMB()
guard let iTrip = reader.columnIndex("trip_id"),
let iArr = reader.columnIndex("arrival_time"),
let iDep = reader.columnIndex("departure_time"),
let iStop = reader.columnIndex("stop_id"),
let iSeq = reader.columnIndex("stop_sequence"),
let iHead = reader.columnIndex("stop_headsign"),
let iPick = reader.columnIndex("pickup_type"),
let iDrop = reader.columnIndex("drop_off_type"),
let iDist = reader.columnIndex("shape_dist_traveled"),
let iTime = reader.columnIndex("timepoint")
else {
Issue.record("header differs from expected: \(reader.columnNames)")
return
}
// 2a — just iteration: splitFields on every row, empty body.
// Isolates the cost of splitting into fields from materializing values.
var rowsVisited = 0
let tSplit = clock.now
reader.forEachRow { _ in rowsVisited += 1 }
let splitOnly = clock.now - tSplit
// 2b — direct access, building the exact same StopTime
var builtDirectly = 0
let t1 = clock.now
reader.forEachRow { row in
_ = StopTime(
trip_id: row.field(at: iTrip),
arrival_time: row.field(at: iArr),
departure_time: row.field(at: iDep),
stop_id: row.field(at: iStop),
stop_sequence: row.intField(at: iSeq) ?? 0,
stop_headsign: row.isFieldEmpty(at: iHead) ? nil : row.field(at: iHead),
pickup_type: row.intField(at: iPick),
drop_off_type: row.intField(at: iDrop),
shape_dist_traveled: row.doubleField(at: iDist),
timepoint: row.intField(at: iTime)
)
builtDirectly += 1
}
let direct = clock.now - t1
// 2c — the same StopTime, but converting numbers by passing through String:
// it's the obvious way, what you'd write without typed accessors.
var builtViaStrings = 0
let tStr = clock.now
reader.forEachRow { row in
_ = StopTime(
trip_id: row.field(at: iTrip),
arrival_time: row.field(at: iArr),
departure_time: row.field(at: iDep),
stop_id: row.field(at: iStop),
stop_sequence: Int(row.field(at: iSeq)) ?? 0,
stop_headsign: row.field(at: iHead).isEmpty ? nil : row.field(at: iHead),
pickup_type: Int(row.field(at: iPick)),
drop_off_type: Int(row.field(at: iDrop)),
shape_dist_traveled: Double(row.field(at: iDist)),
timepoint: Int(row.field(at: iTime))
)
builtViaStrings += 1
}
let viaStrings = clock.now - tStr
// 3 — the same thing via Codable
var decoded = 0
let t2 = clock.now
try reader.decode(StopTime.self) { _ in decoded += 1 }
let codable = clock.now - t2
#expect(rowsVisited == reader.rowCount)
#expect(builtDirectly == decoded)
#expect(builtDirectly == builtViaStrings)
#expect(builtDirectly == reader.rowCount)
let values = seconds(direct) - seconds(splitOnly)
let valuesViaStrings = seconds(viaStrings) - seconds(splitOnly)
print("""
── stop_times.txt — \(reader.rowCount) rows ──
1. indexing \(indexing) (RSS +\(String(format: "%.0f", rssAfterIndex - rssBefore)) MB)
2a. split fields only \(splitOnly)
2b. + typed accessors \(direct)
2c. + conversion via String \(viaStrings)
3. Codable \(codable)
split into fields \(String(format: "%.3f", seconds(splitOnly)))s
materialize (typed) \(String(format: "%.3f", values))s
materialize (via String) \(String(format: "%.3f", valuesViaStrings))s
typed accessors save \(String(format: "%.1f", valuesViaStrings / values))x
Codable costs \(String(format: "%.1f", seconds(codable) / seconds(direct)))x direct access
""")
}| Phase | Time | Share |
|---|---|---|
| Indexing | 0.064s | 8% |
| Splitting rows into fields | 0.271s | 35% |
| Turning fields into values | 0.446s | 57% |
| Total | 0.781s |
The phase I worked on the most, three sections, four rewrites, mmap and memchr and the quote sniff, is the one that weighs 8%. The one that weighs 57% I scratched once for 1.3x and stopped there.
Not because it was the right choice. Because it’s where I got to.
And the comparison we opened with
Eleven seconds against 0.34: that 0.34 is indexing plus field splitting, in other words exactly the work the obvious version did, read the file and split each row into its columns. The comparison holds up.
Building typed values on top is extra work the obvious version didn’t do at all, and brings the total to 0.781s. If it had done it too, its eleven seconds would have been quite a bit more.
The 250 MB
The other number from the opening deserves an explanation too, because it’s not what it seems.
Of the 251 MB consumed, about 45 is the index: five and a half million positions at eight bytes each. Real memory, allocated by me, and the only place in the program where the data structure costs more than you’d think. With 32-bit offsets instead of 64 it’d be 22, at the cost of giving up files over 4 GB—a compromise I didn’t want to make, but it’s there.
The other 206 MB are pages of the mapped file, in the count because the scan touches them all. And here the difference from the 782 MB of the obvious version is more than just quantity: those pages are clean, the kernel can discard them for free when it needs space and reread them from disk if needed again. The strings from the obvious version are dirty memory: either in RAM, or spilling to swap.
Two very different ways of taking up a quarter gigabyte.
What we learned
Looking back at the whole story, the part that stays with me isn’t the optimizations.
It’s the times I was sure of something and the timer said something else.
- I believed my parser was slow and it turned out the compiler in debug was fifty-five times slower.
- I believed
mmapwould speed up scanning and it didn’t speed it up a thousandth. - I believed
memchrwould help little with delimiters and it helped 1.5x; then I believed ripping out fifty-six millionappendcalls would help a lot, and it helped 48 milliseconds. - I believed typed accessors avoided an allocation, and that allocation was never there.
Five predictions, and almost all wrong, some by too much, others by too little, which at least rules out that I was cheating in favor of my thesis.
What demolished them each time wasn’t experience or intuition. It was building the thing that could have disproven them: a benchmark, a test designed to tell apart two different failures, or sometimes just a little table on paper.
It’s not that measuring makes you smarter. It’s that it forces you to discover what you’re really doing, which is usually something different from what you thought.
Coming up
The 57% of materialization is still there and I’ve barely touched it. And there’s a bigger jump I haven’t even attempted: rows are independent of each other, thanks to the index, and none depends on the one before or after. On eight cores those 0.78 seconds could become 0.14.
But there (maybe) SIMD comes into play, so the ceiling is set by the phase worth 8%.
That’s the story of the next parts.