Skip to content

perf: startup and per-tool-call latency of the daily surface on darwin #123

Description

@Castrozan

Same exercise as #114, run on darwin. rin, M-series, macOS 26.1, WezTerm, herdr.

The headline is that the terminal stack needed nothing. #114 fixed it in home/base, so darwin inherited every one of those wins and now measures faster than the numbers that issue closed on. What was left is the agent hook layer, which #114 never looked at because it is not startup at all: it is a cost charged per tool call, all day, on every agent in the fleet.

Measured (median, fresh spawn)

target darwin #114 closing number status
herdr client (herdr list) 7ms 19ms fine
nvim startup 30ms 268ms fine
shell login-interactive, clean env 38ms 95ms fine
claude CLI spawn 87ms 234ms fine
herdr pane writable 140ms 373ms fine
wezterm new window 195ms not measured fine
codex CLI spawn 301ms 126ms runtime boot, no config lever
codex MCP initialize 312ms not measured connects at session start
chrome-devtools MCP initialize 335ms ~300ms matches #114's re-measurement
opencode CLI spawn 603ms (337-977) ~950ms Bun SEA boot, no config lever
claude mcp list 2507ms not measured health-checks both servers serially
PreToolUse hook, per tool call 47ms not measured fixed, 1f8cb8a
PostToolUse hook, per Edit or Write 52-81ms not measured fixed, 1f8cb8a
Stop hook, per turn 56ms not measured mostly interpreter floor
SessionStart hook 120ms not measured mostly interpreter floor

The shell number needs env -i to be honest. /etc/profile and hm-session-vars.sh short-circuit on __ETC_PROFILE_SOURCED and __HM_SESS_VARS_SOURCED, so measuring from an inherited environment reads 13ms and hides the login tax that #114 item 2 is about.

What was fixed

PreToolUse is registered with a .* matcher, so every Read, Grep, Glob and Bash an agent issues starts a fresh interpreter. That interpreter was bootstrapping sys.path through pathlib, which pulls in urllib.parse, ipaddress and fnmatch for work os.path does with modules the interpreter has already loaded, and then importing all twelve handlers when the tool's matcher could select at most two.

The handler table now names its module rather than binding an imported function, and run_handlers imports one only once the surface and tool matchers select it. The bootstraps moved to os.path, including in the two matcher-less guards that genuinely do load on every call. Measured against a bare interpreter on the same machine, dispatch overhead fell from 1.11 interpreter floors to 0.45. Through the real invocation path a Read went from 47ms to 36ms, and 58 modules now load on that path.

PostToolUse was already narrowed to Skill|Edit|Write in event-to-dispatcher-map.nix, so a Read never spawns it at all. That is the largest structural saving available in this layer and it was already taken.

Two guards hold the result, both in agents/hooks/__tests__/unit/. test_dispatcher_import_budget.py reads the module set each invocation actually imports and fails on an expensive stdlib module, an unmatched handler, a third-party dependency, or a per-invocation count over budget. test_dispatcher_startup_latency.py measures wall time against the interpreter floor measured on the same machine, so its bound travels to CI hardware of any speed.

Measured and rejected

-S skips site and saves 3.7ms on a bare interpreter, which looked like the next win. Interleaved A/B on the real dispatcher put it at -0.04ms: once json and re are loaded the saving is already spent. It was measured back out rather than kept as unpaid risk, since skipping site would also have tied the hooks to staying free of site-packages forever. run-hook.sh kept only exec, which stops a bash process idling for the hook's lifetime.

Dropping re from hook_dispatch in favour of literal alternation matching was rejected for the same reason: json imports re regardless, so the matcher rewrite would have bought nothing.

Remaining work

  1. The interpreter floor is now the hook budget. A Read spends 36ms end to end: roughly 10ms in the shell Claude Code spawns the command through, 5ms in the run-hook.sh wrapper, 15ms of interpreter boot, and 6ms of our own imports and work. Going below that means not starting a Python process at all: a resident daemon holding the handlers preloaded, with a small client on the hook command. That trades the cost for a supervision surface and a staleness problem, and these are security guards, so the fail-open versus fail-closed call has to be made deliberately rather than inherited.
  2. claude mcp list at 2.5s suggests the two MCP servers are health-checked serially. Worth confirming whether session start pays the same shape, since perf: track startup latency of core terminal and AI apps #114 item 1 measured the servers themselves at ~0.3s each.
  3. The darwin shell login tax is unmeasured beyond the 38ms total. perf: track startup latency of core terminal and AI apps #114 item 2 is a NixOS finding, and the darwin split between /etc/profile and hm-session-vars has not been decomposed.
  4. There is still no darwin desktop benchmark. benchmark.nix gates benchmark_desktop.py behind pkgs.stdenv.hostPlatform.isLinux because it drives Hyprland, so window and app latency on darwin has no reproducible harness. perf: track startup latency of core terminal and AI apps #114 asked for a reusable benchmark script as a follow-up, and this is the platform where the gap is total.

How to re-measure

Wall-clock medians of env -i bash -l -i -c exit for the shell, --startuptime for nvim, spawn-to-version for the CLIs, a JSON-RPC initialize round trip for the MCP servers, and for the hooks a payload piped into run-hook.sh exactly as settings.json invokes it. The two dispatcher tests above are the parts that now run without being asked.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions