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
|
# ---> fastwc build artifacts
|
||||||
bin/
|
bin/
|
||||||
fastwc
|
fastwc
|
||||||
|
compile_commands.json
|
||||||
|
.cache/
|
||||||
|
|
||||||
# ---> autotools generated
|
# ---> autotools generated
|
||||||
Makefile
|
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
|
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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
+890
-125
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user