Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions .claude/rules/perf-fork-budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ Caveats: binaries pinned at startup via `command -v` (`$GREP`, `$MKTEMP`,
`$CAT`) bypass PATH shims β€” trace those instead; and shims are unreliable on
Git Bash (skip such tests on Windows).

**A shim census only sees forks that exec a binary.** `$(some_shell_function)`
forks a subshell and execs nothing, so it is invisible to every shim and to the
`^\++ +/path/to/binary` trace patterns above β€” yet it costs the same ~0.6ms.
That blind spot hid sixteen of them in the colour palette (`$(bashunit::sgr N)`
per colour, ~9ms, the largest single cost in a cold start) straight through the
#801-#851 campaign that pinned everything else on this page. To count them,
match the *function name* in the trace (`^\++ +bashunit::fn( |$)`), or profile
by injecting `$EPOCHREALTIME` echoes at the `# src/<path>` markers the build
emits β€” that attributes startup cost per source file and is how this one
surfaced.

**`bash -x` trace census (cheap but inflated).** `PS4='+ ' bash -x ./bashunit …`
also counts trace lines **re-echoed inside captured test output**, so it can
overcount 10-20x (one real `grep` appeared 24 times). Use it to *locate* fork
Expand Down Expand Up @@ -193,11 +204,20 @@ into the runner β€” plus the duplicate check), `perl` Γ—2 clock reads (start/end
no `EPOCHREALTIME` before Bash 5), 1 `base64` capability probe, 1 `mkdir`,
1 `tput`. Per-test cost is fork-free.

**Cold start: 3 forks** β€” `uname` (OS detect), `tput` (snapshot width), `perl`
(clock before Bash 5). It was 5 until #1124: `check_os::init` ran twice, once
at source time and again from the entrypoint, and `BASHUNIT_ROOT_DIR` came from
`$(dirname …)`. Sourcing `src/` is the rest of it and is irreducible without
lazy-loading, rejected in #798.
**Cold start: 3 binary forks** β€” `uname` (OS detect), `tput` (snapshot width),
`perl` (clock before Bash 5). It was 5 until #1124: `check_os::init` ran twice,
once at source time and again from the entrypoint, and `BASHUNIT_ROOT_DIR` came
from `$(dirname …)`. Plus a handful of *subshell* forks no shim census sees
(see the blind-spot note above); the sixteen in the palette are gone.

Sourcing `src/` is the rest of it, and it is **not** all irreducible β€” that was
assumed here until a per-source-file profile disproved it. Measured on macOS
with the `# src/<path>` marker technique: 41.6ms of executed top-level code,
against 10ms to parse the whole 600KB artifact and define all 915 functions.
Parsing is cheap; what runs at source time is not. The current shape is
`config/env.sh` ~12.6ms (config files, `tput`, `mkdir`), `state/payload.sh`
~2.9ms (the `base64 --help` probe) and `system/check_os.sh` ~2.4ms (`uname`).
Lazy-loading whole modules was still rejected in #798.

Measuring a change this small needs ~200 invocations per sample: the two forks
are ~4ms against a ~65ms startup, and single runs vary by Β±10ms. The acceptance
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Changed
- Performance: cold start is about 9ms faster, roughly 15%. Building the colour palette cost one subshell fork per colour, sixteen of them β€” the largest single cost in a startup. Every invocation paid it, including `--version` and `--help`

### Removed
- `bashunit learn`, the interactive tutorial. Nobody used it, and it was broken for most of the nine months it shipped without anyone reporting it. Learning bashunit belongs in the docs at https://bashunit.com, not in a subsystem inside the runner β€” which is also 6% of the distributable. Calling it now says it was removed and points there (#1256, #1258)

Expand Down
69 changes: 51 additions & 18 deletions src/console/colors.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters
# Credit:
# https://superuser.com/a/1119396
bashunit::sgr() {
_BASHUNIT_SGR_OUT=""

##
# Writes the escape sequence for the given SGR codes into _BASHUNIT_SGR_OUT.
# Arguments: $@ - SGR codes (default: 0)
##
function bashunit::sgr_to_slot() {
local codes=${1:-0}
shift

Expand All @@ -15,7 +21,12 @@ bashunit::sgr() {
codes="$codes;$c"
done

echo $'\e'"[${codes}m"
_BASHUNIT_SGR_OUT=$'\e'"[${codes}m"
}

bashunit::sgr() {
bashunit::sgr_to_slot "$@"
echo "$_BASHUNIT_SGR_OUT"
}

if bashunit::env::is_no_color_enabled; then
Expand All @@ -36,22 +47,44 @@ if bashunit::env::is_no_color_enabled; then
_BASHUNIT_COLOR_RETURN_RISKY=""
_BASHUNIT_COLOR_DEFAULT=""
else
_BASHUNIT_COLOR_BOLD="$(bashunit::sgr 1)"
bashunit::sgr_to_slot 1
_BASHUNIT_COLOR_BOLD=$_BASHUNIT_SGR_OUT
# Use SGR 90 (bright black / gray) instead of SGR 2 (faint), since
# GitHub Actions' log renderer does not render the faint attribute.
_BASHUNIT_COLOR_FAINT="$(bashunit::sgr 90)"
_BASHUNIT_COLOR_BLACK="$(bashunit::sgr 30)"
_BASHUNIT_COLOR_FAILED="$(bashunit::sgr 31)"
_BASHUNIT_COLOR_PASSED="$(bashunit::sgr 32)"
_BASHUNIT_COLOR_SKIPPED="$(bashunit::sgr 33)"
_BASHUNIT_COLOR_INCOMPLETE="$(bashunit::sgr 36)"
_BASHUNIT_COLOR_SNAPSHOT="$(bashunit::sgr 34)"
_BASHUNIT_COLOR_RISKY="$(bashunit::sgr 35)"
_BASHUNIT_COLOR_RETURN_ERROR="$(bashunit::sgr 41)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_SUCCESS="$(bashunit::sgr 42)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_SKIPPED="$(bashunit::sgr 43)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_INCOMPLETE="$(bashunit::sgr 46)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_SNAPSHOT="$(bashunit::sgr 44)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_RETURN_RISKY="$(bashunit::sgr 45)$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
_BASHUNIT_COLOR_DEFAULT="$(bashunit::sgr 0)"
bashunit::sgr_to_slot 90
_BASHUNIT_COLOR_FAINT=$_BASHUNIT_SGR_OUT
bashunit::sgr_to_slot 30
_BASHUNIT_COLOR_BLACK=$_BASHUNIT_SGR_OUT
bashunit::sgr_to_slot 31
_BASHUNIT_COLOR_FAILED=$_BASHUNIT_SGR_OUT
bashunit::sgr_to_slot 32
_BASHUNIT_COLOR_PASSED=$_BASHUNIT_SGR_OUT
bashunit::sgr_to_slot 33
_BASHUNIT_COLOR_SKIPPED=$_BASHUNIT_SGR_OUT
bashunit::sgr_to_slot 36
_BASHUNIT_COLOR_INCOMPLETE=$_BASHUNIT_SGR_OUT
bashunit::sgr_to_slot 34
_BASHUNIT_COLOR_SNAPSHOT=$_BASHUNIT_SGR_OUT
bashunit::sgr_to_slot 35
_BASHUNIT_COLOR_RISKY=$_BASHUNIT_SGR_OUT

# The banner colours all end in black + bold, so they are the background code
# concatenated with two entries set just above.
_bashunit_banner_suffix="$_BASHUNIT_COLOR_BLACK$_BASHUNIT_COLOR_BOLD"
bashunit::sgr_to_slot 41
_BASHUNIT_COLOR_RETURN_ERROR="$_BASHUNIT_SGR_OUT$_bashunit_banner_suffix"
bashunit::sgr_to_slot 42
_BASHUNIT_COLOR_RETURN_SUCCESS="$_BASHUNIT_SGR_OUT$_bashunit_banner_suffix"
bashunit::sgr_to_slot 43
_BASHUNIT_COLOR_RETURN_SKIPPED="$_BASHUNIT_SGR_OUT$_bashunit_banner_suffix"
bashunit::sgr_to_slot 46
_BASHUNIT_COLOR_RETURN_INCOMPLETE="$_BASHUNIT_SGR_OUT$_bashunit_banner_suffix"
bashunit::sgr_to_slot 44
_BASHUNIT_COLOR_RETURN_SNAPSHOT="$_BASHUNIT_SGR_OUT$_bashunit_banner_suffix"
bashunit::sgr_to_slot 45
_BASHUNIT_COLOR_RETURN_RISKY="$_BASHUNIT_SGR_OUT$_bashunit_banner_suffix"
unset _bashunit_banner_suffix

bashunit::sgr_to_slot 0
_BASHUNIT_COLOR_DEFAULT=$_BASHUNIT_SGR_OUT
fi
22 changes: 22 additions & 0 deletions tests/acceptance/bashunit_coldstart_forks_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,25 @@ function test_coldstart_creates_scratch_dirs_with_one_mkdir() {

assert_less_or_equal_than 1 "$mkdir_forks"
}

# Regression guard: the colour palette used to be built with one
# `$(bashunit::sgr N)` per colour, which is a subshell fork each -- sixteen of
# them, the single largest cost in a cold start. A PATH-shim census cannot see
# these: the subshell execs no binary, which is why they outlived the fork
# campaign that pinned everything else here. The palette entries are constants,
# so they must come from the return slot instead.
function test_coldstart_does_not_fork_a_subshell_per_color() {
if bashunit::check_os::is_windows; then
bashunit::skip "process tracing is unreliable under Git Bash" && return
fi

local trace
trace="$(PS4='+ ' bash -x ./bashunit --version 2>&1 >/dev/null)"

# The capturing spelling is the only caller of the echoing `bashunit::sgr`;
# the slot writer is a different name, so it is not counted here.
local sgr_forks
sgr_forks="$(printf '%s\n' "$trace" | grep -cE '^\++ +bashunit::sgr( |$)' || true)"

assert_equals 0 "$sgr_forks"
}
Loading