Files
fastwc/docs/PERFORMANCE.md
T
huntedbytheirs 6aa461f053 bench: rub it in — monsters, stdin wins, busybox retired
Suites split into benchmarks/files/{lines,words} and
benchmarks/stdin/piping, with the monsters bolted onto the lines suite:
100M lines raced against coreutils (~2x win), 1B lines solo (~6-8s,
11 GB in one pass). Stdin redirects from regular files are now mmap'd
in count_stream, so the stdin suite wins too — up to 12.00x on words.

The ratio column now reports how many times faster fastwc is, not how
much of GNU's time it used. busybox was removed from the suite: it
stopped being a challenge and started being a participation trophy.
GNU wc's lone win — 1M lines by one millisecond on hand-tuned AVX-512
assembly — is now a historical footnote, and the README says so.
2026-08-29 15:52:24 -04:00

4.5 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 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.

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) — ~6s
bytes, 1GB sparse reads all of it st_size, no read

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 6-8 seconds (125-170 Mlines/s), 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.

Why it's fast

  1. 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.
  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 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.
  3. Parallel across cores. Files over 8 MiB are split into 64-byte aligned slices counted by up to 8 threads. 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.
  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.

Correctness is the other half of the contract

The benchmark compares counts, not just clocks. 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 — including U+2007 and U+202F, which glibc's iswspace forgets.
  • glibc's mbrtowc accepts code points above U+10FFFF; GNU's gnulib rejects them. So do we.
  • In a single-byte locale, GNU's -m counts bytes, not characters. So do we now.
  • The -m path 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 -mw can 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 -w in UTF-8 locales. We don't — that's what -m is for, and it keeps the fast path fast. Under LC_ALL=C we match GNU exactly.

Reproducing

make release
./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).