# Exercise 05 — The scanner that blamed the SSD

**Failure mode:** unbuffered I/O — one system call per byte
**Language:** C · **Runtime:** about 4 seconds · **Difficulty:** the one where the disk is innocent

---

## The prompt

> A crash-log scanner walks a log file line by line and tallies the lines containing a
> marker. The author needed to split on newlines, could not find a "read a line" system
> call, and wrote one: read a byte, test it, repeat.
>
> The field reports: "scanning a 9 MB log takes three seconds. The disk is idle the whole
> time and we are not even at 100% of one core. Is the SSD broken?"
>
> The answer the program produces is correct. Find the cost and remove it without changing
> the answer.

Answer before you run anything: **when the data is already in the page cache, what does
`read(2)` actually cost?**

---

## What you are given

| Path | What it is |
| --- | --- |
| `broken/logscan.c` | The starting point. `read(fd, &c, 1)` in a loop. |
| `fixed/logscan.c` | One correct repair. |
| `solution.patch` | The diff between the two, applies with `patch -p1`. |
| `check.sh` | Asserts the syscall count and the answer in both builds. |

The fixture builds its own log in `$TMPDIR` and deletes it, so it needs no fixture data and
leaves nothing behind.

---

## Reproduce the measurement

```bash
cd exercises/05-io-buffering
clang -O2 -g -Wall -Wextra broken/logscan.c -o /tmp/logscan_broken
clang -O2 -g -Wall -Wextra fixed/logscan.c  -o /tmp/logscan_fixed
/tmp/logscan_broken
/tmp/logscan_fixed
```

Observed on an Apple M4 Pro, macOS 26.3 (25D125), APFS on internal SSD:

```
                   broken       fixed
fileBytes        9,437,184   9,437,184
lines               98,304      98,304     identical
markers              1,014       1,014     identical
checksum         9,338,880   9,338,880    identical
readSyscalls     9,437,185         145     65,084x fewer
bytesPerSyscall        1.0    65,084.0
elapsedMs          2,945.6         6.7     440x faster
throughputMBps         3.1     1,348.3
```

**Reproduce these; do not quote them.**

---

## The evidence to collect

1. **`bytesPerSyscall = 1.0`.** Not a statistic — a structural fact of the code. Nine and a
   half million user-to-kernel transitions to move nine and a half million bytes.

2. **The disk did the same work in both runs.** Same file, same filesystem, same page cache
   state. The 440x difference is entirely the cost of the transition itself.

3. **The buffer-size curve, measured separately.** Reading the same 64 MiB file with
   different buffer sizes, page cache warm:

   | buffer | `read()` calls | ms | MB/s |
   | --- | --- | --- | --- |
   | 1 B | 67,108,864 | 21,009.6 | 3.0 |
   | 64 B | 1,048,576 | 331.5 | 193.0 |
   | 512 B | 131,072 | 43.0 | 1,488.7 |
   | 4 KiB | 16,384 | 8.2 | 7,840.2 |
   | 16 KiB | 4,096 | 4.6 | 14,013.2 |
   | 64 KiB | 1,024 | 3.4 | 18,712.5 |
   | 256 KiB | 256 | 3.2 | 19,956.2 |
   | 1 MiB | 64 | 3.4 | 18,603.6 |

   The knee is between 4 KiB and 64 KiB, and past 256 KiB there is nothing left to win. A
   megabyte buffer is not better than 64 KiB, and it is worse for cache locality and for
   memory footprint. **"Bigger is better" is false past the knee**, and being able to say
   where the knee is, and that you measured it, is the senior version of this answer.

4. **`F_NOCACHE` changes almost nothing here** — 4 KiB reads measured 8.6 ms with it against
   8.2 ms without. That is a *negative result* and it is worth keeping: it means the win
   above is the system call, not cache residency. Do not claim `F_NOCACHE` proves anything
   about cold-cache performance; it asks the kernel not to *retain* the data, which is not
   the same as purging what is already there or bypassing the device's own caching.

5. **`mmap` is a third shape.** Mapping the same 64 MiB file and touching one byte per page
   took 2.3 ms — faster still, because the data moves by page fault rather than by copy.
   That is not free either; see "Going further".

---

## Success criteria

- [ ] The broken build is still one call per byte — you have not edited it.
- [ ] Your fixed build averages at least **32 KiB per `read(2)`**.
- [ ] Your fixed build's `lines`, `markers` and `checksum` equal the broken build's, exactly.
- [ ] Your fixed build is at least **20x faster** in wall time.
- [ ] You can name the buffer size you chose, and why not ten times larger.

Run `./check.sh` to have all of that checked for you.

---

## Hints

<details>
<summary>Hint 1 — count what the loop does per byte</summary>

Per byte of a 9 MB file, the program performs: one `read` system call. That is a mode
switch into the kernel, a descriptor lookup, a copy of one byte, and a mode switch back.
The newline test is a single comparison. Which of those two do you think dominates?
</details>

<details>
<summary>Hint 2 — the kernel is not the only place you can hold bytes</summary>

The page cache already has the file. The problem is not *where* the data is; it is *how
often you ask for it*. Nothing stops you asking for a lot of it at once and doing the
line-splitting yourself.
</details>

<details>
<summary>Hint 3 — do not stop at the first working number</summary>

Whatever buffer size you pick, measure a few. The curve flattens; find roughly where. An
answer of "64 KiB, because I measured 4 KiB and 1 MiB and they were within 10% of each
other" is worth far more in an interview than "64 KiB because that is what people use".
</details>

---

## Solution

<details>
<summary>Reveal the solution</summary>

### The repair

```c
enum { BUFFER = 64 * 1024 };
char *buf = malloc(BUFFER);
for (;;) {
    ssize_t n = read(fd, buf, BUFFER);
    if (n <= 0) break;
    for (ssize_t i = 0; i < n; i++) { /* same line splitting, in user space */ }
}
```

The line splitting that used to happen between system calls now happens between memory
accesses.

### What was actually being paid

A `read(2)` that hits the page cache performs no I/O at all. Its cost is the **system call**:
the user-to-kernel transition, the descriptor lookup, and the copy. That cost is nearly
independent of how many bytes are moved, which is exactly why the bytes-per-call ratio is
the number that matters. This is not caching, it is not prefetching, and it does not make
the storage device faster.

### Why not use `stdio`

`fgets`/`getline` buffer for you, and for most code they are the right answer. Writing the
loop by hand here makes the mechanism visible and makes the syscall count assertable. In
production, prefer the library that already solved this — and know what it is doing, so you
can explain a profile that shows `read` at the top.

### The three shapes, and when each is right

| Shape | Cost per byte | Best when |
| --- | --- | --- |
| Unbuffered `read` | one syscall | never, for sequential scanning |
| Buffered `read` | one syscall per buffer, one copy | sequential access, streaming, bounded memory |
| `mmap` | one page fault per page, no copy | random access, large files read repeatedly, shared read-only data |

`mmap` is not a universal upgrade. It makes I/O errors arrive as signals rather than as
return values, it ties the mapping's lifetime to the file's, a file truncated under a
mapping gives `SIGBUS`, and the page-fault path has its own per-page cost that a single
sequential pass does not amortise as well as a large `read`.
</details>

---

## Going further

- Add a `writev`/`readv` variant and compare. Vectored I/O moves several buffers in one
  call, which is the same lesson from a different direction.
- Re-run the broken build under `sudo fs_usage -w -f filesys` (needs elevation, so it is
  not part of the check) and watch the call rate directly. If you do not have elevation, the
  `readSyscalls` counter in the fixture is the same evidence.
