7.7 KiB
Performance
GNU wc has had forty years and a team of people whose entire job is compensating for the bloat around them. Here is what they bought with that time, and what we paid for it.
The scoreboard, with receipts
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 | 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 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), anl_langinfoquote-style probe used only by one error message (~26µs), two 256-entryisspace/isprinttable fills (~39µs), and runtime CPU dispatch — all of it paid even for--helpand-c. - What we did about it. The locale chain (
LC_ALL→LC_CTYPE→LANG) is resolved first andsetlocaleis 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
-won 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
- SIMD kernels, dispatched at runtime. One pass derives both the
newline mask and the whitespace mask from a single load: AVX-512
(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. Dispatch itself is lazy: the CPUID probe runs only when a count will actually use it. - Regular files are mapped, not streamed.
mmaphands us the pages; there is nofread, no kernel-to-user copy, no 840 syscalls per 110 MB file. One syscall in, one count out. A stdin redirect from a regular file (fastwc -l < file) gets the same treatment — the data comes through stdin, but how we read it is our business. The stdin suite is why this shows up in the scoreboard too. - Parallel across cores. Files over 8 MiB are split into 64-byte aligned slices counted by up to one thread per core (capped at 24) past 256 MiB, 12 past 32 MiB, 4 past 8 MiB. The kernels are pure, so the split needs no locks; word boundaries between slices are seeded from the byte before the slice, which makes the split exact. Below 8 MiB the thread spawn would cost more than the counting, so we don't bother.
- No work that isn't asked for.
-con a regular file isst_sizefromfstat— GNU figured that one out too, so we copied the good idea.-lwithout-wskips the whitespace mask entirely;-wwithout-Lnever builds the print table.
Correctness is the other half of the contract
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
-mmode — including U+2007 and U+202F, which glibc'siswspaceforgets. - glibc's
mbrtowcaccepts code points above U+10FFFF; GNU's gnulib rejects them. So do we. - In a single-byte locale, GNU's
-mcounts bytes, not characters. So do we now. - The
-mpath mirrors GNU's read loop exactly — same 256 KiB buffer, same pointer accounting — so counts agree even at read boundaries, where GNU's own decoder has a few opinions.
The selftest (cc -DFASTWC_SELFTEST src/main.c) checks every kernel
against the scalar reference over all sizes, both carry states, and
both counting modes, plus the threaded slice split. A randomized fuzz
against GNU as oracle (both locales, binary and UTF-8 data, every flag
combination) passes 100%.
Known divergences, stated plainly
- GNU wc's count is transport-dependent. The same bytes piped into
wc -mwcan count differently than the same bytes read from a file, because 64 KiB pipe chunks trigger a re-scan of its carried bytes. We reproduced this, then declined to. fastwc counts the data, not the plumbing. - Without
-m, fastwc counts bytes with C-locale semantics. GNU silently switches to multibyte decoding for-win UTF-8 locales. We don't — that's what-mis for, and it keeps the fast path fast. UnderLC_ALL=Cwe match GNU exactly.
Reproducing
make bench # build release + run every suite
./benchmarks/test-all.sh # words, lines (monsters included), stdin
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).