Skip to content

Drastically speed up precommand hook by caching active version to skip redundant activate calls#523

Merged
native-api merged 3 commits intopyenv:masterfrom
jakelodwick:cache-prompt-hook
Mar 25, 2026
Merged

Drastically speed up precommand hook by caching active version to skip redundant activate calls#523
native-api merged 3 commits intopyenv:masterfrom
jakelodwick:cache-prompt-hook

Conversation

@jakelodwick
Copy link
Contributor

@jakelodwick jakelodwick commented Mar 14, 2026

Problem

_pyenv_virtualenv_hook calls pyenv sh-activate --quiet on every prompt, spawning ~10 subprocesses through the pyenv dispatcher regardless of whether anything has changed. On macOS this adds ~200ms of latency per keystroke+Enter (#259, #490, #338).

Approach

Make the hook detect "nothing changed" and short-circuit before forking, rather than skipping the check entirely (the approach that #456 took, which broke pyenv local).

The hook now caches five values using shell builtins (zero forks):

Cache key Detects
$PWD directory changes (cd)
$PYENV_VERSION pyenv shell changes
$PWD/.python-version content pyenv local changes
$PYENV_ROOT/version content pyenv global changes
$VIRTUAL_ENV manual venv activation/deactivation

When all five match their cached values, the hook returns immediately. When any value changes, the full pyenv sh-activate path runs and the cache is refreshed.

Limitation

.python-version files in parent directories are not tracked. pyenv walks up the directory tree to find .python-version; this cache only reads the file in $PWD. If a parent directory's file changes while the user is in a subdirectory, the next cd triggers a full recheck. Walking up directories from the hook would replicate pyenv's version resolution logic.

Measurement

Test plan

  • All 104 existing bats tests pass
  • Three exact-output tests updated (bash, fish, zsh)
  • Cache invalidation verified for all five state transitions

Acknowledgments

This approach builds on prior work:

@jakelodwick jakelodwick marked this pull request as ready for review March 14, 2026 12:31
Copy link
Member

@native-api native-api left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good for a start!

The "limitations" you've outlined are exactly why we haven't done something like this yet! Their complexity has intimidated all the prospective contributors before you! And a stale cache would be a bug -- so we'd have to revert any fundamentally flawed solution in order to fix it.

I think we actually can check for on-disk changes with absolute minimum overhead -- by caching mtimes of the active .pyenv-version file, and of any directories without a .pyenv-version file leading up to it. Then all the disk activity we'll have to do at a prompt is up to a few stat calls.

  • As you can see, this makes it unnecessary to actually read the files each time
  • Moreover, if the current version is set by a higher-priority mechanism (shell -> local -> global) -- we don't have to check for changes in the lower-priority mechanisms at all!
  • stat is not a builtin. It is possible to do with the test -nt builtin -- but then we'd have to create a marker file (which will be per-shell-session) and somehow maintain it. We can create our own builtin if process spawns are really as big of a deal as you make it look...

@native-api
Copy link
Member

P.S. since this is a highly requested change, you are eligible for a payout (non-taxable) from donated money upon completion if you're interested!

@jakelodwick
Copy link
Contributor Author

Thanks for the thorough review. The detail on mtime caching and the priority chain was exactly what I needed to get this right.

Revised commit addresses all three points:

  1. Replaced content reads with test -nt against a per-session marker file. The hook no longer reads version file contents at all.
  2. Full directory walk from $PWD to /, checking .python-version at each level. Directory mtimes catch file creation/deletion.
  3. Priority chain: $PYENV_VERSION set → skip all disk checks. Local .python-version found → skip global.

Cache variables simplified from five to three (_PYENV_VH_PWD, _PYENV_VH_VERSION, _PYENV_VH_VENV) plus a marker file ($PYENV_ROOT/.pyenv-vh-marker-$$, 0 bytes, updated with : > in bash/zsh or command touch in fish).

Shared sourced file in pyenv proper left for a follow-up, per your suggestion.

@native-api
Copy link
Member

I'm currently busy with other RL stuff so I may be replying with a delay. Sorry about that.

@jakelodwick
Copy link
Contributor Author

I'm currently busy with other RL stuff so I may be replying with a delay. Sorry about that.

No problem, also busy with some RL work, should have response within 24h.

Copy link
Member

@native-api native-api left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Indeed, 2ms is not 40us, forks (or rather, spawns, we don't yet have figures on subshell overhead) do show to matter. But that's still way fast enough to be unnoticeable (and worlds apart from hundreds of ms as it is now). We can always change it back to marker files if we have a solution for them, or to custom builtins if such extreme performance here turns out to really be that critical.
  • I also looked through whether we actually need to refactor this into Pyenv, given that this PR seems to be dragging out and someone may lose interest if this happens for too long. Given how much damage this slowdown deals to our product, judging by user commentary, I would not want that.
    • The $PYENV_VERSION logic and pyenv local logic are unlikely to change in the foreseeable future
    • For pyenv global logic, file location or name might change -- but that's also highly unlikely
    • The curveball is pyenv version-name hooks. If they are present, they may make the above copied logic inconsistent with the subcommand.
      • AFAICS, in that case, we have to either abandon caching entirely, or delegate to pyenv version-name proper to see if the active version has changed for parts that we copy from it.
      • Hook entry points are likely to change unpredictably in the future
    • So, refactoring does seem necessary -- but not immediately and critically so.
      • So we can split the refactoring part into a separate PR. But the payout will then be split, too.

Mtime-based caching: stat the directory tree once per prompt (~2ms)
and skip full activation when nothing has changed.

- Compare PYENV_VERSION, VIRTUAL_ENV, PWD; shortcut when PYENV_VERSION matches
- Walk PWD to / for .python-version (stop at first; include broken symlinks)
- Path list built once on miss, stored as array (spaces in paths)
- Global version file skipped when local version active
- version-name hooks checked at init; if present, caching disabled
- Covers bash, zsh, and fish
@jakelodwick
Copy link
Contributor Author

Addressed all feedback, including hooks awareness.

Path list: Built once on cache miss and reused on subsequent hits. Walk stops at first .python-version found. Global file checked only when no local exists.

Env vars: Single check at the top of the function. The PYENV_VERSION-set shortcut and stat check are nested inside it.

stat -L: Format detection updated to -L -c %Y / -L -f %m. Paths stored as an array ("${_PYENV_VH_PATHS[@]}").

Hooks: pyenv hooks version-name checked once at init. If any exist, caching is disabled and the function uses upstream behavior. Plugin changes require shell restart.

Condition Cost
PYENV_VERSION set, env vars unchanged 0 forks
PWD/env vars/mtimes all match 1 fork (~2ms)
Cache miss same as upstream
version-name hooks present same as upstream (caching disabled)

8/8 tests pass. Force-pushed as 8546b11.

Copy link
Member

@native-api native-api left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've addressed the remaining issue. Could you double-check me?

@native-api
Copy link
Member

If you're interested in the payout, please register at https://opencollective.com/, configure the payment method in your profile and provide your handle name -- then we can initiate it. To assess the appropriate amount, I looked at GitPay, heeding the issue's complexity and criticality. I hope it won't disappoint!

@jakelodwick
Copy link
Contributor Author

If you're interested in the payout, please register at https://opencollective.com/, configure the payment method in your profile and provide your handle name -- then we can initiate it. To assess the appropriate amount, I looked at GitPay, heeding the issue's complexity and criticality. I hope it won't disappoint!

My Open Collective handle is JLodw. Payout method is set up. Appreciate it!

@jakelodwick
Copy link
Contributor Author

I've addressed the remaining issue. Could you double-check me?

Checked. Your two commits on the branch look correct: -f matches pyenv version-file, and broken symlinks get tracked without stopping the walk. 104/104 tests pass locally.

@native-api native-api changed the title Cache hook state to skip redundant pyenv sh-activate calls Drastically speed up precommand hook by caching active version to skip redundant activate calls Mar 25, 2026
@native-api native-api merged commit 72cb35b into pyenv:master Mar 25, 2026
5 checks passed
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.

3 participants