Compare commits

..
2 Commits
Author SHA1 Message Date
huntedbytheirs ff465e981b perf: make gnu wc the slowest thing in the benchmark
SIMD kernels (AVX-512/AVX-2/SSE2, runtime-dispatched) counting a
single pass over mmap'd files, split across cores past 8 MiB, with
exact GNU oracle parity (NBSP included, glibc's decoder fixed, the
whole -m path mirrored so counts agree at every boundary).

Result: 1ms vs 2ms at 1M lines, 8-9ms vs 22-24ms at 10M. Forty years
of dependencies, hand-tuned AVX-512 assembly, a translation team per
language — and gnu wc still needs a buffer to copy into before it
can count. We mapped the file and just counted. The benchmark suite
no longer has a losing row; the shame report file is going to rust.
2026-08-29 15:32:08 -04:00
huntedbytheirs 5fc2f3e668 add: dev workflow shenanigans
some shit
2026-08-29 13:57:59 -04:00
13 changed files with 1173 additions and 141 deletions
+18
View File
@@ -0,0 +1,18 @@
---
# fastwc style, codified. 4 spaces, Allman braces, 80 columns.
# The style guide is the law; clang-format is the enforcement.
BasedOnStyle: LLVM
IndentWidth: 4
ContinuationIndentWidth: 4
TabWidth: 4
UseTab: Never
ColumnLimit: 80
BreakBeforeBraces: Allman
PointerAlignment: Right
DerivePointerAlignment: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
SortIncludes: CaseSensitive
IncludeBlocks: Regroup
+14
View File
@@ -0,0 +1,14 @@
---
# fastwc clang-tidy configuration.
# The project compiles with zero warnings; clang-tidy keeps the
# static analysis honest. clang-analyzer-* runs in-editor via clangd.
#
# Disabled noise:
# - DeprecatedOrUnsafeBufferHandling: demands Annex K *_s functions,
# which are not portable POSIX (WG14 deprecated Annex K itself).
# - bugprone-reserved-identifier: flags _POSIX_C_SOURCE, the required
# feature-test macro idiom for POSIX programs.
Checks: '-*,clang-analyzer-*,bugprone-*,-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling,-bugprone-reserved-identifier,-bugprone-easily-swappable-parameters'
WarningsAsErrors: ''
FormatStyle: file
+11
View File
@@ -0,0 +1,11 @@
# clangd configuration for fastwc.
# The style guide is the law; clangd is the enforcement.
CompileFlags:
Compiler: clang
Add:
- -Wall
- -Wextra
Diagnostics:
ClangTidy: true
+21
View File
@@ -0,0 +1,21 @@
# fastwc editor configuration — one file, one opinion, one indentation.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[Makefile.am]
indent_style = tab
[*.sh]
indent_style = space
indent_size = 4
+15
View File
@@ -0,0 +1,15 @@
# fastwc repository hygiene.
* text=auto
*.c text
*.h text
*.md text
*.sh text eol=lf
*.ac text
*.am text
*.json text
*.c diff=cpp
*.h diff=cpp
*.sh diff=bash
+2
View File
@@ -60,6 +60,8 @@ benchmarks/FAILED-benchmark.txt
# ---> fastwc build artifacts
bin/
fastwc
compile_commands.json
.cache/
# ---> autotools generated
Makefile
+4 -4
View File
@@ -17,10 +17,10 @@ single count — your change does not ship. We did not spend this much
effort being faster than a forty-year-old dependency museum just so you
could add a `strlen()` in the hot loop.
The one standing exception: the 1M-line case, where GNU wins by exactly
one millisecond because they ship hand-tuned AVX-512 assembly. Closing
that gap is the project's open goal, not your excuse to be slower
anywhere else.
The old standing exception — the 1M-line case, where GNU's hand-tuned
AVX-512 assembly squeaked out a one-millisecond win — is closed. We
beat them there too now (see docs/PERFORMANCE.md). There are no
exceptions left, and there is no excuse to be slower anywhere else.
### 2. C99, or don't bother
+20 -2
View File
@@ -1,4 +1,4 @@
AM_CFLAGS = -Wall -Wextra -O2
AM_CFLAGS = -Wall -Wextra -O2 -pthread
bin_PROGRAMS = fastwc
fastwc_SOURCES = src/main.c
@@ -12,7 +12,25 @@ release: all
bench: release
./benchmarks/test-all.sh
# --- developer conveniences ---
# compile_commands.json for clangd (bear if present, else Makefile-derived).
compile-commands:
./scripts/gen-compile-commands.sh
# Make the code confess to the style guide.
format:
clang-format -i $(fastwc_SOURCES)
# Verify the code already confesses, without touching it.
format-check:
clang-format --dry-run --Werror $(fastwc_SOURCES)
# Static analysis via clang-tidy (needs compile_commands.json).
# Warnings are errors: the style guide is the law.
lint: compile-commands
clang-tidy -p . --warnings-as-errors='*' $(fastwc_SOURCES)
clean-local:
rm -rf bin
.PHONY: release bench
.PHONY: release bench compile-commands format format-check lint
+31 -5
View File
@@ -11,7 +11,9 @@ up, it doesn't get faster — it gets *more dependencies*.
fastwc is what `wc` looks like when nobody is paying you to maintain the
museum. One file. One purpose. No translators. No gnulib. No AVX-512
kernels hand-tuned by people whose entire job is compensating for the
bloat around them. Just counting, correctly, at full speed.
bloat around them — just our own: AVX-512, AVX-2, and SSE2 intrinsics
with runtime dispatch, and a scalar SWAR fallback. Just counting,
correctly, at full speed.
## The scoreboard
@@ -24,11 +26,14 @@ and exits non-zero. These are the facts:
|-------|--------|
| words (6 cases) | **6/6 wins.** Never slower, never wrong. |
| lines (up to 100k lines) | **Wins.** GNU never sees us coming. |
| lines (1M lines) | **GNU squeaks past by 1ms** — by shipping hand-tuned AVX-512 assembly written by a team of people who get paid for it. We call that cheating. Our SIMD pass is coming, and it will not be subtle. |
| lines (10M lines) | Not yet run. The benchmark aborts at the first loss. Coward. |
| lines (1M lines) | **Win: 1ms vs 2ms.** GNU's AVX-512 assist can't beat a mapped file. |
| lines (10M lines) | **Win: 8-9ms vs 22-24ms (~2.5x).** GNU's lead never survives contact with the buffer. |
| busybox lines (10M) | **Win: 8-9ms vs ~165ms (~18x).** If you must. |
The moment fastwc is slower than GNU `wc`, 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
[docs/PERFORMANCE.md](docs/PERFORMANCE.md).
## Why
@@ -37,8 +42,9 @@ you should say so loudly in an issue. The benchmark is the contract.
- **GNU wc is slow where it should be fast.** Counting bytes is not
supposed to be an architectural achievement.
- **GNU wc counts like it's 1985** — because it is. We count like it's
now: fixed-stride SWAR loops, lookup tables, zero function calls in the
hot path.
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.
## What it does
@@ -75,6 +81,26 @@ keep the minimum, and fail the moment fastwc loses a single case. GNU
`wc` is used as an oracle the same way you'd use a broken clock:
occasionally it's right, and it's the only one around.
## Development
The editor setup is one command:
```sh
make compile-commands # compile_commands.json for clangd
```
clangd reads `.clangd`, `.clang-tidy`, and `.clang-format` — the style
guide, enforced by robots. We use `bear` when it's installed; the
fallback hand-rolls the single entry from the Makefile, because one
source file doesn't need a database.
- `make format` — make the code confess to the style guide
- `make format-check` — verify without touching
- `make lint` — clang-tidy, static analysis included
`.editorconfig` and `.gitattributes` keep every editor honest. Your
editor has opinions. So do we. Ours are in the repo.
## License
MIT. Do whatever you want. We're not GNU, we won't sue you — we'll just
+9 -5
View File
@@ -41,17 +41,21 @@ designed it — it was. We're one file, one purpose, one opinion.
## The hot path
`count_stream()`, `count_newlines()`, `count_words()` are the product.
The rest of the file is just the packaging.
`count_stream()`, `count_newlines()`, `count_words()`, and the SIMD
kernels (`count_lw_avx512`, `count_lw_avx2`, `count_lw_sse2`) are the
product. The rest of the file is just the packaging.
- **Fixed stride.** No per-token function calls. No `isspace()` in a
loop — that's what the lookup table is for.
- **No allocation, no locks, no syscalls in the counting loop.** The
`fread` is the only syscall, and it's not yours to add to.
kernels are pure; parallel slices need no locks. The `fread` (or the
single `mmap`) is the only syscall, and it's not yours to add to.
- **Branchless where it costs nothing.** A predictable branch is fine;
a mispredicted one is a lie you told the CPU.
- **The buffer is `static`, 128 KiB, and never grows.** GNU's wc reads
in chunks too — ours just doesn't make a ceremony of it.
- **Regular files are mapped, not streamed.** The kernel hands us the
pages; we count them, split across cores for anything past 8 MiB.
Stdin and odd files fall back to the `static`, 128 KiB buffer that
never grows.
## Counts
+86
View File
@@ -0,0 +1,86 @@
# 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 | busybox | fastwc |
|------|--------------:|--------:|-------:|
| words, 100k lines | 1-2ms | 2ms | ≤1ms |
| lines, 100k | 1-2ms | 2ms | ≤1ms |
| lines, 1M | 2ms | 18ms | **1ms** |
| lines, 10M | 22-24ms | ~165ms | **8-9ms** |
| bytes, 1GB sparse | reads all of it | 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
about an 18x win over busybox — which, to be fair, was not the fight
anyone was worried about.
## 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.
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
```sh
make release
./benchmarks/bench-coreutils.sh # the real fight
./benchmarks/bench-busybox.sh # if you must
```
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env sh
# Generate compile_commands.json for clangd.
#
# Uses bear when available so every real compile command is captured
# (future-proof as the project grows). Otherwise hand-rolls the single
# entry from the flags in the generated Makefile — one source file, one
# entry, no mysteries.
set -eu
ROOT=$(cd "$(dirname "$0")/.." && pwd)
cd "$ROOT"
if [ ! -f Makefile ]; then
echo "gen-compile-commands: no Makefile found - run ./autogen.sh first" >&2
exit 1
fi
if command -v bear >/dev/null 2>&1; then
bear -- make -B >/dev/null
# Drop the configure probes (conftest.c); clangd only wants real files.
jq 'map(select(.file | endswith("conftest.c") | not))' compile_commands.json \
> compile_commands.json.tmp && mv compile_commands.json.tmp compile_commands.json
echo "compile_commands.json generated via bear"
exit 0
fi
CC=$(sed -n 's/^CC = //p' Makefile | head -n1)
[ -z "$CC" ] && CC=cc
DEFS=$(sed -n 's/^DEFS = //p' Makefile | head -n1)
AM_CFLAGS=$(sed -n 's/^AM_CFLAGS = //p' Makefile | head -n1)
CFLAGS=$(sed -n 's/^CFLAGS = //p' Makefile | head -n1)
# Drop defines carrying embedded quotes (PACKAGE_* metadata): they would
# break JSON and clangd does not need them.
SAFE_DEFS=''
for d in $DEFS; do
case "$d" in
*\"*) ;;
*) SAFE_DEFS="$SAFE_DEFS $d" ;;
esac
done
CMD="$CC$SAFE_DEFS -I. $AM_CFLAGS $CFLAGS -c src/main.c -o src/main.o"
if command -v jq >/dev/null 2>&1; then
jq -n --arg d "$ROOT" --arg c "$CMD" --arg f "$ROOT/src/main.c" \
'[{directory: $d, command: $c, file: $f}]' > compile_commands.json
else
printf '[{"directory":"%s","command":"%s","file":"%s"}]\n' \
"$ROOT" "$CMD" "$ROOT/src/main.c" > compile_commands.json
fi
echo "compile_commands.json generated (fallback)"
+863 -98
View File
File diff suppressed because it is too large Load Diff