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 # ---> fastwc build artifacts
bin/ bin/
fastwc fastwc
compile_commands.json
.cache/
# ---> autotools generated # ---> autotools generated
Makefile 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 effort being faster than a forty-year-old dependency museum just so you
could add a `strlen()` in the hot loop. could add a `strlen()` in the hot loop.
The one standing exception: the 1M-line case, where GNU wins by exactly The old standing exception — the 1M-line case, where GNU's hand-tuned
one millisecond because they ship hand-tuned AVX-512 assembly. Closing AVX-512 assembly squeaked out a one-millisecond win — is closed. We
that gap is the project's open goal, not your excuse to be slower beat them there too now (see docs/PERFORMANCE.md). There are no
anywhere else. exceptions left, and there is no excuse to be slower anywhere else.
### 2. C99, or don't bother ### 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 bin_PROGRAMS = fastwc
fastwc_SOURCES = src/main.c fastwc_SOURCES = src/main.c
@@ -12,7 +12,25 @@ release: all
bench: release bench: release
./benchmarks/test-all.sh ./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: clean-local:
rm -rf bin 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 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 museum. One file. One purpose. No translators. No gnulib. No AVX-512
kernels hand-tuned by people whose entire job is compensating for the 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 ## The scoreboard
@@ -24,11 +26,14 @@ and exits non-zero. These are the facts:
|-------|--------| |-------|--------|
| words (6 cases) | **6/6 wins.** Never slower, never wrong. | | words (6 cases) | **6/6 wins.** Never slower, never wrong. |
| lines (up to 100k lines) | **Wins.** GNU never sees us coming. | | 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 (1M lines) | **Win: 1ms vs 2ms.** GNU's AVX-512 assist can't beat a mapped file. |
| lines (10M lines) | Not yet run. The benchmark aborts at the first loss. Coward. | | 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 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. 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 ## 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 - **GNU wc is slow where it should be fast.** Counting bytes is not
supposed to be an architectural achievement. supposed to be an architectural achievement.
- **GNU wc counts like it's 1985** — because it is. We count like it's - **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 now: regular files are mapped and counted in parallel across cores,
hot path. with SIMD kernels (AVX-512, AVX-2, SSE2) dispatched at runtime —
zero function calls in the hot path.
## What it does ## 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: `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. 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 ## License
MIT. Do whatever you want. We're not GNU, we won't sue you — we'll just 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 ## The hot path
`count_stream()`, `count_newlines()`, `count_words()` are the product. `count_stream()`, `count_newlines()`, `count_words()`, and the SIMD
The rest of the file is just the packaging. 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 - **Fixed stride.** No per-token function calls. No `isspace()` in a
loop — that's what the lookup table is for. loop — that's what the lookup table is for.
- **No allocation, no locks, no syscalls in the counting loop.** The - **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; - **Branchless where it costs nothing.** A predictable branch is fine;
a mispredicted one is a lie you told the CPU. a mispredicted one is a lie you told the CPU.
- **The buffer is `static`, 128 KiB, and never grows.** GNU's wc reads - **Regular files are mapped, not streamed.** The kernel hands us the
in chunks too — ours just doesn't make a ceremony of it. 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 ## 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