Files
fastwc/docs/PERFORMANCE.md
T
huntedbytheirs af61660c42 fix: cap thread count before slice sizing, scale monsters to ncpu
count_sliced computed the slice size from the requested thread count and
only then clamped nt to MAX_THREADS, so the last slice ended before the
file tail: raising the cap (as pick_threads now does) silently dropped
the tail from every count. Cap nt first, then derive per.

Monsters (>=256 MiB) now get one thread per core (capped at 24) instead
of a hard 16, which measures ~1.17x on the 1B-line case and is flat on
warm files. check_sliced now sweeps the cap boundary (16, 24, 25).
2026-08-29 18:56:48 -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) — ~4-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 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.

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