Skip to content

perf(alias): parallelize prewarm git/config/user-config reads#2573

Merged
max-sixty merged 5 commits intomainfrom
alias-speed-model
May 4, 2026
Merged

perf(alias): parallelize prewarm git/config/user-config reads#2573
max-sixty merged 5 commits intomainfrom
alias-speed-model

Conversation

@max-sixty
Copy link
Copy Markdown
Owner

@max-sixty max-sixty commented May 4, 2026

Three commits that compound. Together they cut the alias-dispatch warm critical path from ~38ms to ~12ms by overlapping the three independent pre-dispatch reads that previously ran in series.

What was slow

For wt <alias>, the pre-dispatch path had three blocking reads in sequence:

  1. git rev-parse --git-common-dir ... (prewarm, ~7ms warm / ~10ms cold)
  2. git config --list -z (all_config, called via project_identifier_warm, ~10ms warm)
  3. UserConfig::load_with_warnings (TOML parse of wt.toml, ~4ms cold)

Standalone measurement showed git config --list -z is dominated by git's process startup (~4ms of ~5ms total — git --version alone is ~4.6ms), and the same applies to the rev-parse fork. Adding a dependency like gix-config would only buy back the subprocess overhead. Running these reads concurrently buys it without a new dep.

How it works

Repository::prewarm_at runs three sibling threads inside a std::thread::scope:

  • prewarm_rev_parse — populates GIT_COMMON_DIR_CACHE etc. (unchanged from before)
  • prewarm_git_config — runs git config --list -z and stashes the parsed IndexMap in a new GIT_CONFIG_PRELOAD static
  • prewarm_user_config — calls UserConfig::load_with_warnings and stashes the result in WORKTRUNK_USER_CONFIG_PRELOAD

Repository::at consumes both preloads into the per-Repository RepoCache (all_config and user_config OnceCells), so the first access from a Repository is a memory hit. On-demand fallbacks remain in place for tests and any caller that bypasses prewarm.

LoadedConfigs::load was a sequential pair of cache reads after this — both fields became thin cache hits. The third commit inlines it at all 8 callsites and folds the surviving invariants (warning ordering, why user/project stay distinct) into Repository::project_config's rustdoc. Net −62 lines.

Naming

The git/worktrunk-config split is now visible at a glance: GIT_* for git-side data (GIT_CONFIG_PRELOAD, GIT_COMMON_DIR_CACHE, GIT_DIRS), WORKTRUNK_* for worktrunk-side (WORKTRUNK_USER_CONFIG_PRELOAD).

Numbers

cargo bench --bench alias:

  • dispatch/warm/1: ~13.8ms → ~11.5ms (-17%)
  • dispatch/warm/100: ~17.4ms → ~12.7ms (-27%)
  • dispatch/cold/1: ~14.5ms → ~11.6ms (-20%)

wt-perf timeline -- stub shows the three sibling spans inside the prewarm scope starting within ~22µs of each other.

Behaviour change worth flagging

User-config deprecation warnings (and the "↳ To see details, run wt config show" hint) now emit on every wt invocation in a deprecated-config repo, not just commands that previously called repo.user_config(). Process-wide dedup (WARNED_DEPRECATED_PATHS for warnings, DEPRECATION_HINT_EMITTED for the hint) keeps it to once per process. Behaviour is now uniform across commands; before, wt config show rendered deprecations into stdout but didn't emit them on stderr, while wt list did.

This exposed and removed a duplication in wt config update: format_update_preview and the --print path were emitting warnings via format_deprecation_warnings directly, which now races prewarm and prints duplicates. Drop the redundant emission — prewarm is the single canonical source. Project-config warnings still emit via check_project_config's separate check_and_migrate call.

Minor wart, not blocking: the hint says "to apply updates, run wt config update" even when the user is running that exact command. Fixable later by reading argv before prewarm or moving the hint into command handlers.

Behaviour nuance

prewarm_git_config runs git config --list -z from discovery_path instead of git_common_dir (the rev-parse thread is racing in parallel and git_common_dir isn't known yet). For the default config (extensions.worktreeConfig off — the common case) the output is byte-identical: linked worktrees and the common dir share one config file. With extensions.worktreeConfig enabled, the new code reads per-worktree values that the old code masked; that's arguably the correct behaviour when a user opts into worktree-scoped config, and worktrunk doesn't itself enable the extension.

Testing

Standard pre-merge gate (wt hook pre-merge --yes) passes — 3464 tests including config-show snapshot tests with the new stderr lines. 18 snapshot files updated to reflect the warning-uniformity change; net diff is one or two added stderr lines per test.

max-sixty and others added 4 commits May 3, 2026 17:33
The two big git invocations on the alias-dispatch critical path —
`git rev-parse ...` (prewarm) and `git config --list -z` (all_config) —
ran in series, with prewarm finishing before LoadedConfigs::load even
started. Each pays ~3-4ms of process startup, and standalone measurement
showed the config-merge work itself is only ~0.5ms — i.e. ~4ms of the
~10ms `all_config` cost is purely a second git fork.

Run them concurrently from `Repository::prewarm_at` via `std::thread::scope`,
stash the parsed config map in a new process-wide `GLOBAL_CONFIG_PRELOAD`
keyed by discovery path, and have `Repository::at` consume the preload into
`cache.all_config` so the first `all_config()` call is a memory hit.

`LoadedConfigs::load` no longer warms `project_identifier` — it's a
memory-only OnceCell init now that `all_config` is preloaded — so the
parallel scope simplifies to user-config + project-config loads.

Bench (alias dispatch):
- dispatch/warm/1:    13.84ms → 11.50ms (-16.6%)
- dispatch/warm/100:  17.4ms  → 12.7ms  (-27%)
- dispatch/cold/1:    14.5ms  → 11.6ms  (-20%)
- dispatch/cold/100:  14.85ms → 11.7ms  (-21%)

Behavior nuance: `prewarm_all_config` runs `git config --list -z` from
`discovery_path` instead of `git_common_dir` (the rev-parse thread is
racing in parallel and `git_common_dir` isn't known yet). For the
default config (`extensions.worktreeConfig` off) this is byte-identical
— linked worktrees and the common dir share one config file. With
`extensions.worktreeConfig` enabled, the new code reads per-worktree
values that the old code masked; that's arguably the correct behaviour
when a user opts into worktree-scoped config, and worktrunk doesn't
itself enable the extension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related changes that compound:

1. Rename for git-vs-worktrunk-config disambiguation. Within
   `src/git/repository/mod.rs`, the `GIT_*` prefix denotes git-side data;
   worktrunk-side data deserves a parallel `WORKTRUNK_*` prefix:
   - `GLOBAL_CONFIG_PRELOAD` -> `GIT_CONFIG_PRELOAD`
   - `Repository::prewarm_all_config` -> `Repository::prewarm_git_config`

2. Hoist user-config loading into `Repository::prewarm`. A new sibling
   thread (`prewarm_user_config`) runs `UserConfig::load_with_warnings`
   on `main` alongside the two existing git forks, stashing the result
   in `WORKTRUNK_USER_CONFIG_PRELOAD`. `Repository::at` clones it into
   `cache.user_config` so the first `repo.user_config()` call is a
   memory hit. Pure file I/O on XDG paths -- no git involvement -- so
   it overlaps cleanly with the rev-parse and git-config forks. Removes
   the ~4 ms TOML parse from the alias-dispatch critical path.

3. Collapse `LoadedConfigs::load`'s parallel scope. With `user_config`
   preloaded, `all_config` preloaded, and `project_identifier()` already
   memory-only since the prior commit, the only remaining work is the
   ~30 us `.config/wt.toml` read. The thread::scope is gone; `load`
   becomes two sequential cache reads. Module doc rewritten to current
   shape (no "no longer warmed" prose).

Behaviour change: user-config warnings (parse failures, env-var
rejections, deprecation notices) now emit from the prewarm thread
that runs before alias dispatch, rather than from whatever later
caller first invoked `repo.user_config()`. The result is uniform
across commands -- every `wt` invocation now surfaces config issues
on stderr, not just commands that happen to load user config.

This exposed a pre-existing duplication in `wt config update`:
`format_update_preview` emits warnings via `format_deprecation_warnings`
directly (bypassing `WARNED_DEPRECATED_PATHS` dedup), which now races
prewarm and prints the same warnings twice. Drop the redundant emission
from `format_update_preview` and the `--print` path -- prewarm is the
single canonical source.

Snapshot updates fall into two groups:
- New stderr lines on `wt config show` and friends: warnings now
  appear on stderr where they previously only appeared in stdout's
  rendered USER CONFIG section. Consistent with all other commands.
- Cleaner `wt config update` output: warnings appear once (from
  prewarm), not twice.

Verified: `cargo run -- hook pre-merge --yes` passes (3464 tests);
`wt-perf timeline` shows three sibling spans (prewarm_rev_parse,
prewarm_git_config, prewarm_user_config) starting near ts=0 on
different tids inside the prewarm scope.

Co-Authored-By: Claude <noreply@anthropic.com>
The previous commit moved `UserConfig::load_with_warnings` into
`Repository::prewarm`'s parallel scope. Loading the config eagerly is
the perf win, but it also broke the existing `suppress_warnings()`
contract: handlers for `wt switch` (picker mode), `wt select`, and
`wt list statusline` latch the suppression flag before triggering
config load, because rendering deprecation warnings on stderr above
a TUI picker pushes the picker down (and statusline runs on every
prompt redraw — a recurring stderr line each prompt is unusable).

After the prewarm change, those handler-side latches fire too late:
prewarm has already emitted the warnings before dispatch reaches the
handler. Move the latch up to `main()` based on the parsed `Cli`,
before `Repository::prewarm` runs. The handler-side calls remain as
local documentation of intent and idempotent backups (`OnceLock`).

Cases that suppress:
- `Commands::Select { .. }` — deprecated, always opens the picker
- `Commands::Switch(args)` with `args.branch.is_none()` — picker mode
- `Commands::List(args)` with `Statusline` subcommand

Verified manually: `wt switch main` shows the warning, `wt switch`
(picker) doesn't; `wt list` shows it, `wt list statusline` doesn't.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoadedConfigs::load became a no-op wrapper after prewarm_user_config
landed: both fields were thin cache reads, the `Result` only there
because project_config()? is fallible. Inline at each call site and
move the surviving invariants (warning ordering, why user/project stay
distinct) into Repository::project_config rustdoc.

Net -62 lines.

Co-Authored-By: Claude <noreply@anthropic.com>
@max-sixty max-sixty merged commit 5f56d2a into main May 4, 2026
28 checks passed
@max-sixty max-sixty deleted the alias-speed-model branch May 4, 2026 07:04
max-sixty added a commit that referenced this pull request May 4, 2026
The rustdoc on `project_config` added in #2573 documented a convention
that this PR's parent commit removed. Without any callers warming from
the entry point, the paragraph pointed readers at a pattern the
codebase no longer uses.

Co-Authored-By: Claude <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request May 4, 2026
…entry (#2582)

Follow-up to #2573. The `LoadedConfigs::load` inlining left two call
sites with a bare `repo.project_config()?;` whose only effect was to
warm the cache and emit any deprecation warnings before downstream hook
output started. Reading the resulting code, the discarded `?` looks
load-bearing without being so — the next reader has to trace the call
chain to confirm nothing reads the value.

Both call sites — `CommandEnv::for_action_branchless` and the picker
post-switch path — have downstream consumers (`run_pre_switch_hooks`,
`plan_switch`, the rest of the hook pipeline) that load project config
themselves on first use. Project-config deprecation warnings still
surface; they just emerge from the first real read instead of from a
bare statement at the entry point. User-config warnings continue to come
from prewarm, so the user-visible ordering of warnings vs. command
output is unchanged in the common path.

Drop both. The surrounding comments now name where project config is
loaded, rather than implying the entry point pre-warms it.

---------

Co-authored-by: Claude <noreply@anthropic.com>
@max-sixty max-sixty mentioned this pull request May 5, 2026
3 tasks
max-sixty added a commit that referenced this pull request May 5, 2026
## Summary
Release v0.48.0. See
[CHANGELOG.md](https://github.com/max-sixty/worktrunk/blob/release/CHANGELOG.md)
for the full notes.

Highlights:
- `--format=json` extends to seven step + hook commands (#2560)
- `wt step commit` / `wt step squash` gain `--dry-run` (#2557)
- New `dirname` / `basename` template filters (#2592, #2605)
- New `[remove] delete-branch` config option (#2589)
- `wt-perf timeline` subcommand (#2558)
- Faster `wt list` on dirty worktrees (#2602) and faster alias dispatch
(#2556, #2573)
- Short-SHA display honors `core.abbrev` (#2576)
- Cleaner `wt config show` shell-integration section for new users

## Test plan
- [x] `cargo run -- hook pre-merge --yes` (3497 tests, lints clean)
- [x] `cargo semver-checks check-release -p worktrunk` consulted; minor
bump confirmed
- [ ] CI green
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants