Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff465e981b | ||
|
|
5fc2f3e668 |
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -60,6 +60,8 @@ benchmarks/FAILED-benchmark.txt
|
||||
# ---> fastwc build artifacts
|
||||
bin/
|
||||
fastwc
|
||||
compile_commands.json
|
||||
.cache/
|
||||
|
||||
# ---> autotools generated
|
||||
Makefile
|
||||
|
||||
+4
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Executable
+52
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user