bench: rub it in — monsters, stdin wins, busybox retired

Suites split into benchmarks/files/{lines,words} and
benchmarks/stdin/piping, with the monsters bolted onto the lines suite:
100M lines raced against coreutils (~2x win), 1B lines solo (~6-8s,
11 GB in one pass). Stdin redirects from regular files are now mmap'd
in count_stream, so the stdin suite wins too — up to 12.00x on words.

The ratio column now reports how many times faster fastwc is, not how
much of GNU's time it used. busybox was removed from the suite: it
stopped being a challenge and started being a participation trophy.
GNU wc's lone win — 1M lines by one millisecond on hand-tuned AVX-512
assembly — is now a historical footnote, and the README says so.
This commit is contained in:
2026-08-29 15:52:24 -04:00
parent ff465e981b
commit 6aa461f053
13 changed files with 362 additions and 177 deletions
+4
View File
@@ -55,7 +55,11 @@ dkms.conf
# ---> fastwc benchmarks # ---> fastwc benchmarks
benchmarks/tools/genfile benchmarks/tools/genfile
benchmarks/.data/ benchmarks/.data/
benchmarks/files/*/.data/
benchmarks/stdin/piping/.data/
benchmarks/FAILED-benchmark.txt benchmarks/FAILED-benchmark.txt
benchmarks/files/*/FAILED-benchmark.txt
benchmarks/stdin/piping/FAILED-benchmark.txt
# ---> fastwc build artifacts # ---> fastwc build artifacts
bin/ bin/
+9 -9
View File
@@ -9,8 +9,9 @@ These aren't guidelines. They're the deal.
### 1. The benchmark is the contract ### 1. The benchmark is the contract
`./benchmarks/bench-coreutils.sh` must pass. Not "mostly pass." Not `./benchmarks/test-all.sh` must pass. Not "mostly pass." Not "pass on
"pass on your machine." Pass. your machine." Pass. Every suite — words, lines (monsters included),
and stdin — races fastwc against GNU wc.
The moment fastwc is slower than GNU wc — or disagrees with it on a The moment fastwc is slower than GNU wc — or disagrees with it on a
single count — your change does not ship. We did not spend this much single count — your change does not ship. We did not spend this much
@@ -40,9 +41,9 @@ feature smaller.
### 4. The hot path is sacred ### 4. The hot path is sacred
`count_stream()`, `count_newlines()`, and `count_words()` in `count_stream()`, `count_newlines()`, `count_words()`, and the SIMD
`src/main.c` are the entire point of this project. You may touch them kernels in `src/main.c` are the entire point of this project. You may
only if `./benchmarks/bench-coreutils.sh` still passes afterward. touch them only if `./benchmarks/test-all.sh` still passes afterward.
Rules for the counting loops: Rules for the counting loops:
- fixed stride, no per-token function calls - fixed stride, no per-token function calls
@@ -85,14 +86,13 @@ GNU's excuses.
Good first issues: Good first issues:
- Closing the 1M-line AVX-512 gap (the only loss on the board)
- Benchmark case coverage for `-m` and multiple files - Benchmark case coverage for `-m` and multiple files
- `bench-busybox.sh` CI on a machine that actually has busybox - Move the 1B-line monster behind a flag so quick CI runs stay quick
Ambitious issues: Ambitious issues:
- A word counter that doesn't just tie GNU — it embarrasses it - A word counter that doesn't just beat GNU — it embarrasses it
- Bigger SWAR chunks, wider strides, less patience - Wider strides, less patience, and a 10B-line monster
### Send a PR ### Send a PR
+27 -12
View File
@@ -17,10 +17,9 @@ correctly, at full speed.
## The scoreboard ## The scoreboard
The benchmark suite in `benchmarks/` races fastwc against GNU `wc` The benchmark suite in `benchmarks/` races fastwc against GNU `wc` —
(and busybox, if you keep such things installed) — fail-fast. The moment fail-fast. The moment we are slower, or disagree on a single count, it
we are slower, or disagree on a single count, it writes a shame report writes a shame report and exits non-zero. These are the facts:
and exits non-zero. These are the facts:
| Suite | Result | | Suite | Result |
|-------|--------| |-------|--------|
@@ -28,19 +27,31 @@ and exits non-zero. These are the facts:
| 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) | **Win: 1ms vs 2ms.** GNU's AVX-512 assist can't beat a mapped file. | | 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. | | 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. | | lines (100M lines) | **Win: ~70ms vs ~140ms.** The monster race. GNU gets lapped. |
| lines (1B lines) | **Solo, ~6-8s.** 11 GB in one pass; the only bottleneck left is the disk. |
| stdin words (1M lines) | **Win: 12x.** GNU still reads stdin like it's 1985. |
| stdin lines (10M lines) | **Win: ~2.5x.** We map stdin redirects; GNU maps nothing. |
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 The how and why of the speed, with receipts, lives in
[docs/PERFORMANCE.md](docs/PERFORMANCE.md). [docs/PERFORMANCE.md](docs/PERFORMANCE.md).
GNU wc's lone win — 1M lines, by a single millisecond, on hand-tuned
AVX-512 assembly — is a historical footnote now. We closed it, then the
10M case, then the 100M monster, and then we added a 1B-line race with
no opponent, because beating GNU wc at this point is just cruelty to
software. Busybox was removed from the suite for the same reason: it
stopped being a challenge and started being a participation trophy.
## Why ## Why
- **GNU wc is a dependency museum.** Its build needs gettext, gnulib, - **GNU wc is a dependency museum.** Its build needs gettext, gnulib,
and a translator for every language on Earth. fastwc needs `cc`. and a translator for every language on Earth. fastwc needs `cc`.
- **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. Counting 11 GB of them
in one pass, solo, while GNU's AVX-512 assembly still needs a buffer
to copy into — that is.
- **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: regular files are mapped and counted in parallel across cores, now: regular files are mapped and counted in parallel across cores,
with SIMD kernels (AVX-512, AVX-2, SSE2) dispatched at runtime — with SIMD kernels (AVX-512, AVX-2, SSE2) dispatched at runtime —
@@ -72,14 +83,18 @@ make release # installs the release binary to bin/release/fastwc
```sh ```sh
make bench # build release + run every suite make bench # build release + run every suite
./benchmarks/bench-coreutils.sh # the real fight ./benchmarks/test-all.sh # all suites: words, lines, stdin
./benchmarks/bench-busybox.sh # if you must
``` ```
The suites interleave runs so both commands see identical cache warmth, The suites live under `benchmarks/files/{lines,words}` (file input) and
keep the minimum, and fail the moment fastwc loses a single case. GNU `benchmarks/stdin/piping` (standard input), and fail fast the moment
`wc` is used as an oracle the same way you'd use a broken clock: fastwc loses a single case. The lines suite also carries the monsters:
occasionally it's right, and it's the only one around. 100M lines raced against coreutils, and 1B lines timed solo — there is
no reference for that one; we are the reference now. The suites
interleave runs so both commands see identical cache warmth, 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 ## Development
+2 -1
View File
@@ -54,7 +54,8 @@ product. The rest of the file is just the packaging.
a mispredicted one is a lie you told the CPU. a mispredicted one is a lie you told the CPU.
- **Regular files are mapped, not streamed.** The kernel hands us the - **Regular files are mapped, not streamed.** The kernel hands us the
pages; we count them, split across cores for anything past 8 MiB. 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 This includes stdin redirects from regular files. Real pipes, ttys,
and the `-m` path fall back to the `static`, 128 KiB buffer that
never grows. never grows.
## Counts ## Counts
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env bash
# bench-busybox.sh — benchmark the busybox wc applet against the release
# build of fastwc. Fails fast: the moment fastwc is slower than (or
# disagrees with) busybox wc, a human readable report is written to
# FAILED-benchmark.txt and this script exits non-zero.
#
# usage: ./bench-busybox.sh
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=std.sh
source "$SCRIPT_DIR/std.sh"
checkfastwc
checkwc busybox
BENCH_NAME="busybox"
run_benchmark_suite
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env bash
# bench-coreutils.sh — benchmark the GNU Coreutils wc implementation against
# the release build of fastwc. Fails fast: the moment fastwc is slower than
# (or disagrees with) coreutils wc, a human readable report is written to
# FAILED-benchmark.txt and this script exits non-zero.
#
# usage: ./bench-coreutils.sh
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=std.sh
source "$SCRIPT_DIR/std.sh"
checkfastwc
checkwc coreutils
BENCH_NAME="coreutils"
run_benchmark_suite
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# bench.sh — race fastwc against GNU coreutils wc on line counts, file input.
# Includes the monster cases: 100M lines raced against coreutils, and 1B
# lines timed solo (no reference to beat — there isn't one).
# Fails fast: the moment fastwc is slower than (or disagrees with) coreutils
# wc, a human readable report is written to FAILED-benchmark.txt and this
# script exits non-zero.
#
# usage: ./bench.sh
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
source "$REPO_DIR/benchmarks/std.sh"
checkfastwc
checkwc coreutils
BENCH_NAME="coreutils"
printf 'benchmarking %s wc vs fastwc: lines, file input (%s interleaved runs each, minimum kept)\n' \
"$BENCH_NAME" "$BENCH_REPS"
printf '%-28s %10s %10s %8s %s\n' 'test' 'wc' 'fastwc' 'ratio' 'status'
printf '%s\n' '--- lines ---'
if ! run_cases lines -l 10000 100000 1000000 10000000; then
exit 1
fi
printf '%s\n' '--- monster: 100M lines vs coreutils ---'
if ! run_cases lines -l 100000000; then
exit 1
fi
printf '%s\n' '--- monster: 1B lines, solo ---'
run_solo_case 1000000000 -l
printf '\nall %s lines benchmarks passed — fastwc was never slower than %s wc\n' \
"$BENCH_NAME" "$BENCH_NAME"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# bench.sh — race fastwc against GNU coreutils wc on word counts, file input.
# Fails fast: the moment fastwc is slower than (or disagrees with) coreutils
# wc, a human readable report is written to FAILED-benchmark.txt and this
# script exits non-zero.
#
# usage: ./bench.sh
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
source "$REPO_DIR/benchmarks/std.sh"
checkfastwc
checkwc coreutils
BENCH_NAME="coreutils"
printf 'benchmarking %s wc vs fastwc: words, file input (%s interleaved runs each, minimum kept)\n' \
"$BENCH_NAME" "$BENCH_REPS"
printf '%-28s %10s %10s %8s %s\n' 'test' 'wc' 'fastwc' 'ratio' 'status'
printf '%s\n' '--- words ---'
if ! run_cases words -w 1 10 100 1000 10000 100000; then
exit 1
fi
printf '\nall %s words benchmarks passed — fastwc was never slower than %s wc\n' \
"$BENCH_NAME" "$BENCH_NAME"
+149 -81
View File
@@ -2,44 +2,47 @@
# #
# std.sh — shared "standard library" for the fastwc benchmark scripts. # std.sh — shared "standard library" for the fastwc benchmark scripts.
# #
# Every benchmark script lives in its own directory (benchmarks/files/words,
# benchmarks/files/lines, benchmarks/stdin/piping) and sets two variables
# before sourcing this file:
# SCRIPT_DIR — the benchmark script's own directory (test data lives here)
# REPO_DIR — the repository root (release binary and tools live here)
#
# Provides the helpers every benchmark script needs: # Provides the helpers every benchmark script needs:
# checkfastwc() verify a release build of fastwc exists # checkfastwc() verify a release build of fastwc exists
# checkwc() locate the coreutils or busybox wc implementation # checkwc() locate the coreutils wc implementation
# createrandstr() print one random 10-character alphanumeric string
# createtxt() create (or reuse) a text file with N such lines # createtxt() create (or reuse) a text file with N such lines
# run_benchmark_suite() run every word/line case for the selected wc # time_ms() run a command once, print elapsed wall time in ms
# capture_count() print the first whitespace-separated field of output
# run_case() race fastwc against the reference on a file argument
# run_stdin_case() same, but feeding the file through standard input
# run_solo_case() time fastwc alone (no reference) and print throughput
# run_cases() run run_case for a list of sizes, fail-fast
# run_stdin_cases() run run_stdin_case for a list of sizes, fail-fast
# write_failed_report() write the human readable failure report
# #
# Source this file from a benchmark script, then: # The suites fail fast: the moment fastwc is slower than (or disagrees
# checkfastwc # with) the reference wc, a human readable report is written to
# checkwc coreutils # or: checkwc busybox # FAILED-benchmark.txt next to the suite and it returns non-zero.
# BENCH_NAME="coreutils"
# run_benchmark_suite
#
# The suite fails fast: the moment fastwc is slower than (or disagrees
# with) the selected wc implementation, it writes a human readable report
# to FAILED-benchmark.txt and returns non-zero.
set -u set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FASTWC="$REPO_DIR/bin/release/fastwc"
FASTWC="$SCRIPT_DIR/../bin/release/fastwc"
DATA_DIR="$SCRIPT_DIR/.data" DATA_DIR="$SCRIPT_DIR/.data"
GENFILE="$SCRIPT_DIR/tools/genfile" # optional C helper, built by test-all.sh GENFILE="$REPO_DIR/benchmarks/tools/genfile" # optional C helper, built by test-all.sh
BENCH_NAME="${BENCH_NAME:-wc}" # set by the caller: coreutils | busybox BENCH_NAME="${BENCH_NAME:-wc}" # set by the caller: coreutils
BENCH_REPS="${BENCH_REPS:-3}" # interleaved runs per case; minimum is kept BENCH_REPS="${BENCH_REPS:-3}" # interleaved runs per case; minimum is kept
RESULT_ROWS="" # accumulated results table RESULT_ROWS="" # accumulated results table
WC_CMD=() # filled by checkwc(), e.g. (wc) or (busybox wc) WC_CMD=() # filled by checkwc()
TEXT_FILE="" # filled by createtxt() TEXT_FILE="" # filled by createtxt()
if [[ -z "$BENCH_REPS" || "$BENCH_REPS" -lt 1 ]]; then if [[ -z "$BENCH_REPS" || "$BENCH_REPS" -lt 1 ]]; then
BENCH_REPS=1 BENCH_REPS=1
fi fi
# checkfastwc — make sure ../bin/release/fastwc exists and is executable. # checkfastwc — make sure the release build exists and is executable.
# Mimics autotools configure: prints "checking for ... yes/no" and bails
# out with a helpful message when the release build is missing.
checkfastwc() { checkfastwc() {
printf 'checking for release build fastwc... ' printf 'checking for release build fastwc... '
if [[ -x "$FASTWC" ]]; then if [[ -x "$FASTWC" ]]; then
@@ -52,44 +55,22 @@ checkfastwc() {
exit 1 exit 1
} }
# checkwc <coreutils|busybox> — locate the requested wc implementation and # checkwc — locate the coreutils wc implementation and store its invocation
# store its invocation in $WC_CMD. Mimics autotools configure output and # in $WC_CMD. Exits on failure.
# exits on failure.
checkwc() { checkwc() {
local impl="$1" printf 'checking for coreutils wc... '
case "$impl" in if command -v wc >/dev/null 2>&1 \
coreutils) && wc --version 2>/dev/null | head -n1 | grep -qi 'GNU coreutils'; then
printf 'checking for coreutils wc... ' printf 'yes\n'
if command -v wc >/dev/null 2>&1 \ WC_CMD=(wc)
&& wc --version 2>/dev/null | head -n1 | grep -qi 'GNU coreutils'; then else
printf 'yes\n' printf 'no\n'
WC_CMD=(wc) printf 'configure: error: GNU Coreutils wc not found in PATH\n' >&2
else exit 1
printf 'no\n' fi
printf 'configure: error: GNU Coreutils wc not found in PATH\n' >&2
exit 1
fi
;;
busybox)
printf 'checking for busybox wc... '
if command -v busybox >/dev/null 2>&1 \
&& busybox --list 2>/dev/null | grep -qx 'wc'; then
printf 'yes\n'
WC_CMD=(busybox wc)
else
printf 'no\n'
printf 'configure: error: busybox (with the wc applet) not found in PATH\n' >&2
exit 1
fi
;;
*)
printf 'checkwc: error: unknown implementation "%s" (expected coreutils or busybox)\n' "$impl" >&2
exit 1
;;
esac
} }
# createrandstr — print one 10-character random alphanumeric combination. # createrandstr — print one random 10-character alphanumeric string.
createrandstr() { createrandstr() {
local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
local out='' i local out='' i
@@ -100,9 +81,10 @@ createrandstr() {
} }
# createtxt <lines> — make sure a text file with <lines> rows of random # createtxt <lines> — make sure a text file with <lines> rows of random
# 10-character alphanumeric strings exists. A copy generated by a previous # 10-character alphanumeric strings exists in this suite's .data directory.
# run is reused (checked by exact byte size: 10 chars + '\n' per line), so # A copy generated by a previous run is reused (checked by exact byte size:
# repeated benchmark runs are cheap. Prints the path and sets $TEXT_FILE. # 10 chars + '\n' per line), so repeated benchmark runs are cheap.
# Prints the path and sets $TEXT_FILE; returns non-zero if generation fails.
createtxt() { createtxt() {
local lines="$1" local lines="$1"
local expect=$((lines * 11)) local expect=$((lines * 11))
@@ -150,7 +132,7 @@ capture_count() {
} }
# write_failed_report <label> <reason> <wc_ms> <fast_ms> — write the # write_failed_report <label> <reason> <wc_ms> <fast_ms> — write the
# human readable failure report to FAILED-benchmark.txt. # human readable failure report to FAILED-benchmark.txt next to the suite.
write_failed_report() { write_failed_report() {
local label="$1" reason="$2" wc_ms="$3" fast_ms="$4" local label="$1" reason="$2" wc_ms="$3" fast_ms="$4"
local report="$SCRIPT_DIR/FAILED-benchmark.txt" local report="$SCRIPT_DIR/FAILED-benchmark.txt"
@@ -172,9 +154,9 @@ write_failed_report() {
printf 'full results written to %s\n' "$report" >&2 printf 'full results written to %s\n' "$report" >&2
} }
# run_case <words|lines> <n-lines> <-w|-l> — create (or reuse) the text file, # run_case <words|lines> <n-lines> <-w|-l> — race the reference wc against
# then race the selected wc against fastwc. Fails the benchmark the moment # fastwc on a file argument. Fails the benchmark the moment fastwc is
# fastwc is slower or reports a different count. # slower or reports a different count.
run_case() { run_case() {
local mode="$1" lines="$2" flag="$3" local mode="$1" lines="$2" flag="$3"
local noun='lines'; [[ "$lines" -eq 1 ]] && noun='line' local noun='lines'; [[ "$lines" -eq 1 ]] && noun='line'
@@ -200,8 +182,13 @@ run_case() {
[[ -z "$fast_ms" || "$d" -lt "$fast_ms" ]] && fast_ms="$d" [[ -z "$fast_ms" || "$d" -lt "$fast_ms" ]] && fast_ms="$d"
done done
# ratio: how many times faster fastwc is than the reference (wc / fastwc)
if [[ -n "$wc_ms" && "$wc_ms" -gt 0 ]]; then if [[ -n "$wc_ms" && "$wc_ms" -gt 0 ]]; then
ratio=$(awk -v f="$fast_ms" -v w="$wc_ms" 'BEGIN { printf "%.2fx", f / w }') if [[ "$fast_ms" -gt 0 ]]; then
ratio=$(awk -v f="$fast_ms" -v w="$wc_ms" 'BEGIN { printf "%.2fx", w / f }')
else
ratio='infx'
fi
else else
ratio='-' ratio='-'
fi fi
@@ -228,30 +215,111 @@ run_case() {
return 0 return 0
} }
# run_benchmark_suite — run every word and line case for the wc selected by # run_stdin_case <words|lines> <n-lines> <-w|-l|...> — same race, but the
# checkwc(). Returns non-zero the first time fastwc loses. # data is fed through standard input with a redirect instead of a file
run_benchmark_suite() { # argument. Counts must also agree with the reference's stdin behavior.
local rc=0 size run_stdin_case() {
local mode="$1" lines="$2" flag="$3"
local noun='lines'; [[ "$lines" -eq 1 ]] && noun='line'
local label="stdin ${mode} (${lines} ${noun})"
local file wc_count fast_count wc_ms fast_ms ratio verdict reason row
local i d
printf 'benchmarking %s wc vs fastwc (%s interleaved runs each, minimum kept)\n' \ file=$(createtxt "$lines") || return 1
"$BENCH_NAME" "$BENCH_REPS"
printf '%-28s %10s %10s %8s %s\n' 'test' 'wc' 'fastwc' 'ratio' 'status'
printf '%s\n' '--- words ---' wc_count=$(capture_count "${WC_CMD[@]}" "$flag" < "$file")
for size in 1 10 100 1000 10000 100000; do fast_count=$(capture_count "$FASTWC" "$flag" < "$file")
run_case words "$size" -w || { rc=1; break; }
wc_ms=''
fast_ms=''
for ((i = 0; i < BENCH_REPS; i++)); do
d=$(time_ms "${WC_CMD[@]}" "$flag" < "$file")
[[ -z "$wc_ms" || "$d" -lt "$wc_ms" ]] && wc_ms="$d"
d=$(time_ms "$FASTWC" "$flag" < "$file")
[[ -z "$fast_ms" || "$d" -lt "$fast_ms" ]] && fast_ms="$d"
done done
if [[ $rc -eq 0 ]]; then # ratio: how many times faster fastwc is than the reference (wc / fastwc)
printf '%s\n' '--- lines ---' if [[ -n "$wc_ms" && "$wc_ms" -gt 0 ]]; then
for size in 10000 100000 1000000 10000000; do if [[ "$fast_ms" -gt 0 ]]; then
run_case lines "$size" -l || { rc=1; break; } ratio=$(awk -v f="$fast_ms" -v w="$wc_ms" 'BEGIN { printf "%.2fx", w / f }')
done else
ratio='infx'
fi
else
ratio='-'
fi fi
if [[ $rc -eq 0 ]]; then verdict='PASS'
printf '\nall %s benchmarks passed — fastwc was never slower than %s wc\n' \ reason=''
"$BENCH_NAME" "$BENCH_NAME" if [[ "$fast_count" != "$wc_count" ]]; then
verdict='FAIL'
reason="output mismatch (fastwc: ${fast_count}, ${BENCH_NAME} wc: ${wc_count})"
elif (( fast_ms > wc_ms )); then
verdict='FAIL'
reason="fastwc was slower (fastwc: ${fast_ms}ms vs ${BENCH_NAME} wc: ${wc_ms}ms)"
fi fi
row=$(printf '%-28s %10s %10s %8s %s\n' \
"$label" "wc: ${wc_ms}ms" "fastwc: ${fast_ms}ms" "$ratio" "$verdict")
RESULT_ROWS+="${row}"$'\n'
printf '%s\n' "$row"
if [[ "$verdict" == 'FAIL' ]]; then
write_failed_report "$label" "$reason" "$wc_ms" "$fast_ms"
return 1
fi
return 0
}
# run_solo_case <n-lines> <-l|...> — time fastwc alone on <n-lines> of data,
# no reference to beat. Prints the best time and throughput. A failure to
# create the data (disk, say) skips the case instead of failing the suite.
run_solo_case() {
local lines="$1" flag="$2"
local noun='lines'; [[ "$lines" -eq 1 ]] && noun='line'
local label="solo ${lines} ${noun}"
local file ms best='' bytes gbps mlps i d
file=$(createtxt "$lines") || {
printf '%-28s %s\n' "$label" 'SKIP (could not create test data)'
return 0
}
for ((i = 0; i < BENCH_REPS; i++)); do
d=$(time_ms "$FASTWC" "$flag" "$file")
[[ -z "$best" || "$d" -lt "$best" ]] && best="$d"
done
bytes=$((lines * 11))
gbps=$(awk -v b="$bytes" -v ms="$best" 'BEGIN { printf "%.2f", b / ms / 1e6 }')
mlps=$(awk -v l="$lines" -v ms="$best" 'BEGIN { printf "%.1f", l / ms / 1e3 }')
printf '%-28s %12s %12s %14s\n' \
"$label" "fastwc: ${best}ms" "${gbps} GB/s" "${mlps} Mlines/s"
}
# run_cases <words|lines> <-w|-l> <size...> — run run_case for every size,
# stopping at the first failure. Returns non-zero if any case failed.
run_cases() {
local mode="$1" flag="$2"
shift 2
local rc=0 size
for size in "$@"; do
run_case "$mode" "$size" "$flag" || { rc=1; break; }
done
return $rc
}
# run_stdin_cases <words|lines> <-w|-l|...> <size...> — run run_stdin_case
# for every size, stopping at the first failure.
run_stdin_cases() {
local mode="$1" flag="$2"
shift 2
local rc=0 size
for size in "$@"; do
run_stdin_case "$mode" "$size" "$flag" || { rc=1; break; }
done
return $rc return $rc
} }
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# bench.sh — race fastwc against GNU coreutils wc on standard input, fed by
# shell redirect (the same path every real pipeline uses). Counts must agree
# with coreutils' stdin behavior, and fastwc must not be slower.
# Fails fast: the moment fastwc is slower than (or disagrees with) coreutils
# wc, a human readable report is written to FAILED-benchmark.txt and this
# script exits non-zero.
#
# usage: ./bench.sh
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
source "$REPO_DIR/benchmarks/std.sh"
checkfastwc
checkwc coreutils
BENCH_NAME="coreutils"
printf 'benchmarking %s wc vs fastwc: stdin (%s interleaved runs each, minimum kept)\n' \
"$BENCH_NAME" "$BENCH_REPS"
printf '%-28s %10s %10s %8s %s\n' 'test' 'wc' 'fastwc' 'ratio' 'status'
printf '%s\n' '--- stdin lines ---'
if ! run_stdin_cases lines -l 10000 100000 1000000 10000000; then
exit 1
fi
printf '%s\n' '--- stdin words ---'
if ! run_stdin_cases words -w 1000 100000 1000000; then
exit 1
fi
printf '%s\n' '--- stdin lines+words+bytes ---'
if ! run_stdin_cases lines -lwc 1000000; then
exit 1
fi
printf '\nall %s stdin benchmarks passed — fastwc was never slower than %s wc\n' \
"$BENCH_NAME" "$BENCH_NAME"
+12 -8
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# test-all.sh — compile the benchmark helper tools first, then run every # test-all.sh — compile the benchmark helper tools first, then run every
# benchmark (coreutils and busybox). Exits non-zero if any of them fails. # benchmark suite (words, lines incl. monsters, stdin). Exits non-zero if
# any of them fails.
# #
# usage: ./test-all.sh # usage: ./test-all.sh
set -u set -u
@@ -16,15 +17,18 @@ if ! cc -O2 -Wall -o "$SCRIPT_DIR/tools/genfile" "$SCRIPT_DIR/tools/genfile.c";
fi fi
printf 'built %s\n' "$SCRIPT_DIR/tools/genfile" printf 'built %s\n' "$SCRIPT_DIR/tools/genfile"
# 2. run each benchmark (both run regardless, so every result is reported) # 2. run each suite (all run regardless, so every result is reported)
"$SCRIPT_DIR/bench-coreutils.sh" "$SCRIPT_DIR/files/words/bench.sh"
rc_coreutils=$? rc_words=$?
"$SCRIPT_DIR/bench-busybox.sh" "$SCRIPT_DIR/files/lines/bench.sh"
rc_busybox=$? rc_lines=$?
"$SCRIPT_DIR/stdin/piping/bench.sh"
rc_stdin=$?
# 3. summarize # 3. summarize
if [[ $rc_coreutils -ne 0 || $rc_busybox -ne 0 ]]; then if [[ $rc_words -ne 0 || $rc_lines -ne 0 || $rc_stdin -ne 0 ]]; then
printf '\ntest-all: FAILED (coreutils=%s, busybox=%s)\n' "$rc_coreutils" "$rc_busybox" >&2 printf '\ntest-all: FAILED (words=%s, lines=%s, stdin=%s)\n' \
"$rc_words" "$rc_lines" "$rc_stdin" >&2
exit 1 exit 1
fi fi
printf '\ntest-all: all benchmarks passed\n' printf '\ntest-all: all benchmarks passed\n'
+24 -12
View File
@@ -10,17 +10,22 @@ 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 page cache warm. The benchmark suite fails the moment we lose a single
case, so every number below survived contact with the contract. case, so every number below survived contact with the contract.
| Case | GNU coreutils | busybox | fastwc | | Case | GNU coreutils | fastwc |
|------|--------------:|--------:|-------:| |------|--------------:|-------:|
| words, 100k lines | 1-2ms | 2ms | ≤1ms | | words, 100k lines | 1-2ms | ≤1ms |
| lines, 100k | 1-2ms | 2ms | ≤1ms | | lines, 100k | 1-2ms | ≤1ms |
| lines, 1M | 2ms | 18ms | **1ms** | | lines, 1M | 2-3ms | **1ms** |
| lines, 10M | 22-24ms | ~165ms | **8-9ms** | | lines, 10M | 21-24ms | **8-9ms** |
| bytes, 1GB sparse | reads all of it | reads all of it | `st_size`, no read | | lines, 100M (monster) | ~140ms | **~70ms** |
| lines, 1B (solo) | — | **~6s** |
| bytes, 1GB sparse | 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 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 a 2x win on the 100M monster. At 1B lines — 11 GB of data — the solo
anyone was worried about. run lands around 6-8 seconds (125-170 Mlines/s), and the bottleneck is
honest to admit: an 11 GB file does not fit in the 15 GB of RAM this
machine has, so the last monster is racing the disk. The 100M case,
which fits, runs at ~17 GB/s, and that number is the counting.
## Why it's fast ## Why it's fast
@@ -32,7 +37,10 @@ anyone was worried about.
word-separator set, in three vector instructions. word-separator set, in three vector instructions.
2. **Regular files are mapped, not streamed.** `mmap` hands us the 2. **Regular files are mapped, not streamed.** `mmap` hands us the
pages; there is no `fread`, no kernel-to-user copy, no 840 syscalls pages; there is no `fread`, no kernel-to-user copy, no 840 syscalls
per 110 MB file. One syscall in, one count out. per 110 MB file. One syscall in, one count out. A stdin redirect
from a regular file (`fastwc -l < file`) gets the same treatment —
the data comes through stdin, but how we read it is our business.
The stdin suite is why this shows up in the scoreboard too.
3. **Parallel across cores.** Files over 8 MiB are split into 64-byte 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 aligned slices counted by up to 8 threads. The kernels are pure, so
the split needs no locks; word boundaries between slices are seeded the split needs no locks; word boundaries between slices are seeded
@@ -81,6 +89,10 @@ combination) passes 100%.
```sh ```sh
make release make release
./benchmarks/bench-coreutils.sh # the real fight ./benchmarks/test-all.sh # words, lines (monsters included), stdin
./benchmarks/bench-busybox.sh # if you must
``` ```
The suites live in `benchmarks/files/{lines,words}` and
`benchmarks/stdin/piping`. The lines suite ends with the monsters:
100M lines raced against coreutils, and 1B lines timed solo (no
reference to beat — the reference is us now).
+29 -18
View File
@@ -336,6 +336,8 @@ static lw_t count_lw_scalar(const unsigned char *s, size_t n, int *prev_ws,
static count_lw_fn count_lw = count_lw_scalar; /* chosen by pick_kernel() */ static count_lw_fn count_lw = count_lw_scalar; /* chosen by pick_kernel() */
static void count_mapped(const unsigned char *p, size_t n, counts_t *c);
#if defined(__x86_64__) || defined(__i386__) #if defined(__x86_64__) || defined(__i386__)
static count_lw_fn pick_kernel(void) static count_lw_fn pick_kernel(void)
{ {
@@ -528,6 +530,28 @@ static void count_stream(FILE *fp, counts_t *c)
return; return;
} }
/* Regular file — named or a stdin redirect — map instead of
* streaming: no copy, and the count can be split across cores. */
struct stat st;
if (fstat(fileno(fp), &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0)
{
if (flags == F_BYTES)
{ /* GNU wc does not read the file either */
c->bytes = (long long)st.st_size;
return;
}
void *m = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE,
fileno(fp), 0);
if (m != MAP_FAILED)
{
count_mapped((const unsigned char *)m, (size_t)st.st_size, c);
munmap(m, (size_t)st.st_size);
if (ferror(fp))
c->ok = 0;
return;
}
}
for (;;) for (;;)
{ {
nread = fread(buf, 1, sizeof buf, fp); /* NOLINT: EOF-state FP */ nread = fread(buf, 1, sizeof buf, fp); /* NOLINT: EOF-state FP */
@@ -675,29 +699,16 @@ static void count_file(const char *path, counts_t *c)
return; return;
} }
/* Regular files: map instead of streaming - no copy, and the count /* count_stream maps regular files itself; -c alone skips reading */
* can be split across cores. Falls back to streaming on any hitch. */ if (flags == F_BYTES)
struct stat st;
if (fstat(fileno(fp), &st) == 0 && S_ISREG(st.st_mode))
{ {
if (flags == F_BYTES) struct stat st;
{ /* GNU wc does not read the file either */ if (fstat(fileno(fp), &st) == 0 && S_ISREG(st.st_mode))
{
c->bytes = (long long)st.st_size; c->bytes = (long long)st.st_size;
fclose(fp); fclose(fp);
return; return;
} }
if (!(flags & F_CHARS) && st.st_size > 0)
{
void *m = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE,
fileno(fp), 0);
if (m != MAP_FAILED)
{
count_mapped((const unsigned char *)m, (size_t)st.st_size, c);
munmap(m, (size_t)st.st_size);
fclose(fp);
return;
}
}
} }
count_stream(fp, c); count_stream(fp, c);