docs: the locale tax, gone — utf-8 receipts and the startup race won
This commit is contained in:
@@ -48,6 +48,20 @@ count, and the case is reported, but it is excluded from the averages.
|
||||
Raced cases allow a 2% dead-heat margin so a genuine tie can't flake
|
||||
on scheduler jitter. All of it, exactly as `benchmarks/` prescribes.
|
||||
|
||||
And every number above was measured in the C locale — the setting that
|
||||
flatters the opponents most. Under `en_US.UTF-8`, GNU `wc` stops
|
||||
counting bytes and starts decoding them, one `mbrtowc` at a time, even
|
||||
when the file is pure ASCII and decoding changes nothing. We used to
|
||||
make that exact mistake: a UTF-8 locale silently swapped our SIMD
|
||||
kernels for the same decoder, and the 11 MB words race flipped from a
|
||||
4.9x win to a GNU win. The kernels now probe for non-ASCII bytes while
|
||||
they count — a vector move-mask per load, free when unused — so ASCII
|
||||
files never see the decoder. Same 11 MB words file, the locale you
|
||||
actually run: **1.4 ms vs GNU 10.4 ms (7.6x).** GNU still pays that
|
||||
10.4 ms for bytes that were never multibyte; we pay for the decoder
|
||||
only when a file genuinely needs it. Receipts in
|
||||
[docs/PERFORMANCE.md](docs/PERFORMANCE.md).
|
||||
|
||||
The moment fastwc is slower than any of them, this project has failed
|
||||
and you should say so loudly in an issue. The benchmark is the
|
||||
contract. The how and why of the speed, with receipts, lives in
|
||||
@@ -61,21 +75,26 @@ opponent that keeps score, and even its threaded counter only manages
|
||||
a dead heat on mid-size files — never a win, and the moment the file
|
||||
stops fitting in a polite buffer, the dead heat stops being polite.
|
||||
|
||||
A note on startup, in the interest of honesty: on a one-line file the
|
||||
whole race happens in the low hundreds of microseconds — fastwc
|
||||
~0.25 ms, GNU ~0.33 ms, toybox ~0.37 ms, busybox ~0.14 ms. Microseconds
|
||||
either way. Nobody will ever notice a difference that small, and it
|
||||
does not matter in the bigger picture: the tiny cases in the table are
|
||||
here to prove fastwc is never *wrong*, not to brag about a head start
|
||||
that evaporates the moment the page cache warms up. That is why the
|
||||
suite now times both sides at microsecond resolution and files anything
|
||||
the reference finishes in under 5 ms under "startup-bound": correct,
|
||||
reported, and excluded from the averages — because nobody should be
|
||||
racing startup, least of all a word counter. The cases that matter are
|
||||
the ones where counting takes longer than starting — and those are the
|
||||
ones in this table. Every run ends with the average speedup against
|
||||
each oracle — coreutils ~4.5x, busybox ~13-15x, toybox ~15-18x — so
|
||||
the cruelty is quantified.
|
||||
A note on startup, in the interest of honesty — and of gloating: on a
|
||||
one-line file the whole race happens in the low hundreds of
|
||||
microseconds, and fastwc now wins it outright. The default build is
|
||||
static musl, so there is no dynamic loader to pay: min-of-400 on a
|
||||
12-byte file puts fastwc `-l` at 78µs against busybox's 83µs, GNU's
|
||||
253µs, and toybox's 254µs. Busybox's one structural advantage — a
|
||||
loader it never had to start — is no longer an advantage; we don't
|
||||
start one either. None of this matters in the bigger picture, and we
|
||||
will not pretend otherwise: nobody will ever notice a difference that
|
||||
small, and the tiny cases in the table are here to prove fastwc is
|
||||
never *wrong*, not to brag about a head start that evaporates the
|
||||
moment the page cache warms up. That is why the suite now times both
|
||||
sides at microsecond resolution and files anything the reference
|
||||
finishes in under 5 ms under "startup-bound": correct, reported, and
|
||||
excluded from the averages — because nobody should be racing startup,
|
||||
least of all a word counter. The cases that matter are the ones where
|
||||
counting takes longer than starting — and those are the ones in this
|
||||
table. Every run ends with the average speedup against each oracle —
|
||||
coreutils ~4.5x, busybox ~13-15x, toybox ~15-18x — so the cruelty is
|
||||
quantified.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -89,6 +108,12 @@ the cruelty is quantified.
|
||||
now: regular files are mapped and counted in parallel across cores,
|
||||
with SIMD kernels (AVX-512, AVX-2, SSE2) dispatched at runtime —
|
||||
zero function calls in the hot path.
|
||||
- **GNU wc slows down in the locale you actually run.** Under a UTF-8
|
||||
locale it decodes every byte it counts — pure ASCII included, which
|
||||
decoding cannot change — so the 11 MB words file that takes it
|
||||
7.9 ms in the C locale takes 10.4 ms there, against our 1.4 ms.
|
||||
fastwc's kernels probe for non-ASCII bytes as they count and only
|
||||
decode files that need it. The locale that taxes GNU is free for us.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -117,10 +142,19 @@ fastwc [OPTION]... --files0-from=F
|
||||
Requires a C compiler and autotools. That's it. No gettext. No gnulib.
|
||||
No translators.
|
||||
|
||||
The default build links statically against musl (via `musl-gcc`),
|
||||
which is why fastwc now wins the startup cases above outright —
|
||||
there is no dynamic loader to pay, and the ~80µs exec floor is the
|
||||
same one busybox pays. If `musl-gcc` isn't installed the configure
|
||||
script warns and falls back to the system compiler; the glibc build
|
||||
is one flag away:
|
||||
|
||||
```sh
|
||||
./autogen.sh # autoreconf -fi && ./configure
|
||||
./autogen.sh # autoreconf -fi && ./configure (static musl)
|
||||
make
|
||||
make release # installs the release binary to bin/release/fastwc
|
||||
make release # installs the release binary to bin/release/fastwc
|
||||
|
||||
./configure --enable-glibc && make # or: dynamic glibc build
|
||||
```
|
||||
|
||||
## Benchmark
|
||||
|
||||
Reference in New Issue
Block a user