docs: honest scoreboard — startup note, us methodology, current hardware
This commit is contained in:
+79
-29
@@ -6,27 +6,72 @@ that time, and what we paid for it.
|
||||
|
||||
## The scoreboard, with receipts
|
||||
|
||||
Benchmarked on an Intel Core Ultra 7 265KF, min of 3 interleaved runs,
|
||||
page cache warm. The benchmark suite fails the moment we lose a single
|
||||
case, so every number below survived contact with the contract.
|
||||
Benchmarked on an AMD Ryzen AI 7 PRO 350 — 8 cores / 16 threads, boost
|
||||
up to 5.09 GHz, 384 KiB L1d / 256 KiB L1i / 8 MiB L2 / 16 MiB L3 —
|
||||
with 64 GiB of DDR5-4800 dual-channel. Opponents: GNU coreutils 9.11,
|
||||
busybox 1.36.1, toybox 0.8.13. Page-cache-warm files, min of 3
|
||||
interleaved runs, both sides timed at microsecond resolution by a C
|
||||
timer. Only *raced* cases appear below — anything the reference
|
||||
finishes in under 5 ms is startup, not throughput, and gets reported
|
||||
but excluded (see "On startup" below). Every number survived contact
|
||||
with the contract.
|
||||
|
||||
| Case | GNU coreutils | fastwc |
|
||||
|------|--------------:|-------:|
|
||||
| words, 100k lines | 1-2ms | ≤1ms |
|
||||
| lines, 100k | 1-2ms | ≤1ms |
|
||||
| lines, 1M | 2-3ms | **1ms** |
|
||||
| lines, 10M | 21-24ms | **8-9ms** |
|
||||
| lines, 100M (monster) | ~140ms | **~70ms** |
|
||||
| lines, 1B (solo) | — | **~4-6s** |
|
||||
| bytes, 1GB sparse | reads all of it | `st_size`, no read |
|
||||
| Case | GNU coreutils | busybox | toybox | fastwc |
|
||||
|------|--------------:|--------:|-------:|-------:|
|
||||
| words, 1M lines (11 MB) | 7.9ms | 16.2ms | 15.9ms | **1.6ms** |
|
||||
| words, 10M lines (110 MB) | 66ms | 160ms | 157ms | **5.1ms** |
|
||||
| lines, 1M (11 MB) | startup | 16.9ms | 17.6ms | **1.6ms** |
|
||||
| lines, 10M (110 MB) | 5.8ms | 160ms | 154ms | **4.6ms** |
|
||||
| lines, 100M (monster) | 47.5ms | — | — | **29.2ms** |
|
||||
| lines, 1B (solo) | — | — | — | **266ms** |
|
||||
| stdin lines, 10M | 5.9ms | 161ms | 154ms | **4.6ms** |
|
||||
| stdin words, 1M | 7.1ms | 16.0ms | 16.5ms | **1.5ms** |
|
||||
|
||||
That is a ~2.5x win over GNU on 10M lines, a 2x win on 1M lines, and
|
||||
a 2x win on the 100M monster. At 1B lines — 11 GB of data — the solo
|
||||
run lands around 4-6 seconds (200-270 Mlines/s, warm cache), and the
|
||||
bottleneck is
|
||||
honest to admit: an 11 GB file does not fit in the 15 GB of RAM this
|
||||
machine has, so the last monster is racing the disk. The 100M case,
|
||||
which fits, runs at ~17 GB/s, and that number is the counting.
|
||||
That is a 13x win over GNU on 10M lines of words, 4.9x on 1M, and 1.6x
|
||||
on the 100M monster. GNU's threaded counter manages a genuine dead
|
||||
heat at 110 MB of plain lines (5.8ms vs our 4.6ms — it never wins;
|
||||
we allow it a 2% tie margin and it still can't take it). The moment
|
||||
the file stops fitting in a polite buffer, the dead heat stops being
|
||||
polite. At 1B lines — 11 GB, which this machine's 64 GiB actually
|
||||
fits — the solo run lands at 266ms: 41.4 GB/s, 3.76 billion lines per
|
||||
second. The reference is us now. Busybox and toybox, meanwhile, are
|
||||
here for the cruelty: 10-31x slower depending on the case, and their
|
||||
word counting has *bugs*.
|
||||
|
||||
## On startup
|
||||
|
||||
A word counter that loses one-line races to a slower counter is not
|
||||
slower — it's slower to *start*, and the benchmark used to punish that
|
||||
with integer-millisecond `date`-fork rounding. We fixed the honest
|
||||
half and then the honest reporting:
|
||||
|
||||
- **What startup used to cost.** Staged-exit probes with a min-of-N
|
||||
exec timer showed ~105µs of avoidable work above the glibc exec
|
||||
floor: `setlocale` (~44µs), a `nl_langinfo` quote-style probe used
|
||||
only by one error message (~26µs), two 256-entry `isspace`/`isprint`
|
||||
table fills (~39µs), and runtime CPU dispatch — all of it paid even
|
||||
for `--help` and `-c`.
|
||||
- **What we did about it.** The locale chain (`LC_ALL` → `LC_CTYPE` →
|
||||
`LANG`) is resolved first and `setlocale` is skipped entirely when it
|
||||
resolves to C/POSIX or is unset — glibc's initial locale *is* C, so
|
||||
the semantics are identical. The quote-style probe went lazy (only
|
||||
the `--total=` error diagnostics ever print curly quotes). The
|
||||
whitespace/print tables are constant-filled under the C locale and
|
||||
built only when the flags need them (`-w`, `-L`). Option parsing
|
||||
moved ahead of everything, so `--help`, `--version`, and usage
|
||||
errors exit with zero startup cost.
|
||||
- **The receipts.** Min-of-400 interleaved on an 11-byte file: fastwc
|
||||
~0.25ms, GNU ~0.33ms, toybox ~0.37ms, busybox ~0.14ms. Before the
|
||||
work, fastwc `-w` on a tiny file measured ~562µs; after, ~425µs.
|
||||
Nobody will ever notice a difference that small.
|
||||
- **The honest half.** Because those microseconds don't matter, the
|
||||
benchmark no longer pretends they do. Any case the reference
|
||||
finishes in under 5ms is filed under `startup-bound`: fastwc must
|
||||
still match the count, but the case is excluded from the averages
|
||||
and the throughput scoreboard. Busybox's genuinely faster startup
|
||||
(242µs vs our 534µs on one line) is reported exactly that way. The
|
||||
cases in the table above are the ones where counting takes longer
|
||||
than starting.
|
||||
|
||||
## Why it's fast
|
||||
|
||||
@@ -35,7 +80,8 @@ which fits, runs at ~17 GB/s, and that number is the counting.
|
||||
(64 bytes per step) when the CPU has it, else AVX-2 (32), else SSE2
|
||||
(16), else scalar SWAR. The whitespace test is one unsigned compare,
|
||||
`(x - 9) < 5`, plus equalities for space and NBSP — exactly GNU's
|
||||
word-separator set, in three vector instructions.
|
||||
word-separator set, in three vector instructions. Dispatch itself is
|
||||
lazy: the CPUID probe runs only when a count will actually use it.
|
||||
2. **Regular files are mapped, not streamed.** `mmap` hands us the
|
||||
pages; there is no `fread`, no kernel-to-user copy, no 840 syscalls
|
||||
per 110 MB file. One syscall in, one count out. A stdin redirect
|
||||
@@ -51,12 +97,13 @@ which fits, runs at ~17 GB/s, and that number is the counting.
|
||||
bother.
|
||||
4. **No work that isn't asked for.** `-c` on a regular file is
|
||||
`st_size` from `fstat` — GNU figured that one out too, so we copied
|
||||
the good idea. `-l` without `-w` skips the whitespace mask entirely.
|
||||
the good idea. `-l` without `-w` skips the whitespace mask entirely;
|
||||
`-w` without `-L` never builds the print table.
|
||||
|
||||
## Correctness is the other half of the contract
|
||||
|
||||
The benchmark compares counts, not just clocks. Matching GNU byte for
|
||||
byte took some archaeology:
|
||||
The benchmark compares counts, not just clocks — against all three
|
||||
oracles. Matching GNU byte for byte took some archaeology:
|
||||
|
||||
- GNU counts U+00A0 (non-breaking space) as a word separator even in
|
||||
the C locale, and the full Unicode white space set in `-m` mode —
|
||||
@@ -90,11 +137,14 @@ combination) passes 100%.
|
||||
## Reproducing
|
||||
|
||||
```sh
|
||||
make release
|
||||
./benchmarks/test-all.sh # words, lines (monsters included), stdin
|
||||
make bench # build release + run every suite
|
||||
./benchmarks/test-all.sh # words, lines (monsters included), stdin
|
||||
```
|
||||
|
||||
The suites live in `benchmarks/files/{lines,words}` and
|
||||
`benchmarks/stdin/piping`. The lines suite ends with the monsters:
|
||||
100M lines raced against coreutils, and 1B lines timed solo (no
|
||||
reference to beat — the reference is us now).
|
||||
`test-all.sh` compiles its two helper tools on the spot
|
||||
(`tools/genfile` for the test data, `tools/timeit` for microsecond
|
||||
timing) — no separate setup. The suites live in
|
||||
`benchmarks/files/{lines,words}` and `benchmarks/stdin/piping`, and
|
||||
race all three oracles in one run. The lines suite ends with the
|
||||
monsters: 100M lines raced against coreutils, and 1B lines timed solo
|
||||
(no reference to beat — the reference is us now).
|
||||
|
||||
Reference in New Issue
Block a user