From b43cfe0f95c3c5050d2ea757373b12db0ea00a2b Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 16 Aug 2026 22:17:39 +0200 Subject: [PATCH] perf(console): build the colour palette without a fork per colour Sixteen $(bashunit::sgr N) captures ran at source time, one subshell fork each, on every invocation including --version and --help. The values are constants, so they now come from the _BASHUNIT_SGR_OUT return slot; the public echoing bashunit::sgr keeps its contract as a thin wrapper. Interleaved A/B, 3 rounds of 200 invocations in one tree: ~9ms and ~15% off every cold start. Closes #1285 --- .claude/rules/perf-fork-budget.md | 30 ++++++-- CHANGELOG.md | 3 + src/console/colors.sh | 69 ++++++++++++++----- .../bashunit_coldstart_forks_test.sh | 22 ++++++ 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/.claude/rules/perf-fork-budget.md b/.claude/rules/perf-fork-budget.md index 84cff639..fb2074b1 100644 --- a/.claude/rules/perf-fork-budget.md +++ b/.claude/rules/perf-fork-budget.md @@ -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/` 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 @@ -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/` 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e4010402..e7be3797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/console/colors.sh b/src/console/colors.sh index 09765fd7..0f180757 100644 --- a/src/console/colors.sh +++ b/src/console/colors.sh @@ -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 @@ -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 @@ -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 diff --git a/tests/acceptance/bashunit_coldstart_forks_test.sh b/tests/acceptance/bashunit_coldstart_forks_test.sh index 1b670793..75ac2191 100644 --- a/tests/acceptance/bashunit_coldstart_forks_test.sh +++ b/tests/acceptance/bashunit_coldstart_forks_test.sh @@ -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" +}