Skip to content

perf(gix): read directories ahead in status dirwalks - #3181

Closed
Andrea Cappuccio (hood) wants to merge 2 commits into
GitoxideLabs:mainfrom
hood:perf/dirwalk-prefetch
Closed

Andrea Cappuccio (hood) wants to merge 2 commits into
GitoxideLabs:mainfrom
hood:perf/dirwalk-prefetch

Conversation

@hood

@hood hood commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

This PR and its description were written by Claude Code, an AI agent, working for Andrea Cappuccio (@hood). The measurements below are the agent's own.

Summary

status with untracked files now reads directories ahead on worker threads, while the walk itself stays serial and unchanged. On a starship-like status, this is 38-56% faster on everyday repositories, with byte-identical results.

Motivation: shell prompts. starship's git_status module runs Repository::status() with untracked files enabled, once per prompt, and that time is dominated by the directory walk: on pnpm, ~250 of the 285 ms. The walk is syscall-bound (user 0.02 s, system 0.19 s for 3,562 read_dir calls), so parallel reads pay off. Closes #3180.

Changes

  • feat(gix-dir)!: new walk::Options::prefetch_threads, defaulting to 0 (unchanged behavior).
    • When it is set, the walk collects directories with tracked files from the index (below the traversal root, ignoring SKIP_WORKTREE entries) and keeps one OnceLock per directory.
    • Scoped threads fill them in index order, and the walker calls get_or_init(): it uses a finished listing, waits for one in progress, or reads the directory itself. No directory is read twice.
    • Untracked and ignored directories are still read live by the walker, so ignored trees such as node_modules are never read ahead.
    • A failed read leaves no listing, and the walker falls back to the existing live read, so errors, read_dir_errors and read_dir_calls stay the same.
    • Walks for deletion ignore the option.
    • Corrects the performance notes of walk(), which said traversal is rare in typical Git usage.
  • perf(gix): Repository::index_worktree_status() sets prefetch_threads from thread_limit (gix_parallel::num_threads(), or 0 when that resolves to one thread). Repository::dirwalk() is unchanged.
flowchart LR
  I[index entries] --> D[sorted dirs with tracked files]
  D --> W1[prefetch thread 1]
  D --> W2[prefetch thread N]
  W1 --> L[(OnceLock per dir)]
  W2 --> L
  T[serial walker] -- get_or_init --> L
  T -- untracked / failed dirs --> R[live read_dir]
Loading

Results

Starship-like status: untracked Matching, TrackRenames::Disabled, thread_limit: Some(3) (starship's macOS default) or None. Apple M1 Pro, macOS, hyperfine, 30 runs each, medians, local clones:

Repository Dirs / tracked files thread_limit main This PR Change
neovim 233 / 3,947 3 28.8 ms 17.7 ms −39%
vite 946 / 2,927 3 116.6 ms 72.4 ms −38%
pnpm 3,562 / 10,660 3 284.8 ms 124.0 ms −56%
neovim all cores 34.2 ms 19.8 ms −42%
vite all cores 116.8 ms 77.1 ms −34%
pnpm all cores 304.7 ms 110.2 ms −64%
pnpm, no untracked (control) 3 36.8 ms 33.0 ms noise

CPU time rises a little (pnpm with 3 threads: user 34 → 42 ms, system 229 → 351 ms), which is the cost of the extra reader threads.

This machine runs endpoint security software, which makes every syscall more expensive: git status on neovim takes ~40 ms here. Expect lower absolute times elsewhere. The relative gain depends on how expensive read_dir is.

Correctness

  • New test walk::prefetching_directories_yields_the_same_outcome. It walks every repository of the many.sh fixture with prefetch_threads 0 and 3, across three emission configurations, with fresh and stale indices, and asserts equal entries and Outcome. When prefetched listings are deliberately truncated, the test fails.
  • Compared sorted status items between main and this branch on a dirty pnpm worktree:
    • The tree had untracked files at several depths, new nested directories, empty directories, ignored node_modules at the root and nested, a symlink to a directory, and modified, deleted and type-changed tracked files.
    • Both Matching and collapsed emission were compared, with 3 threads and with all cores, 5 runs each.
    • Result: identical in all 20 runs (3,832 and 3,834 items).
  • With GIX_TEST_IGNORE_ARCHIVES=1, these pass on macOS:
    • cargo test -p gix-dir --features sha1: 86 tests.
    • cargo test -p gix-status --features sha1: 42 tests.
    • cargo test -p gix: 682 tests.
  • cargo clippy is clean for -p gix-dir -p gix-status -p gix --all-targets, and for -p gix --no-default-features --features status,sha1, where gix-parallel is serial and prefetching is off.
  • cargo +stable fmt --check passes.

Reproduce

The harness is a ~60-line binary depending on gix with default-features = false, features = ["max-performance", "revision", "status", "sha1", "zlib-sirocco"] (starship's set). It opens with ThreadSafeRepository::discover_opts() and iterates repo.status(..)?.index_worktree_options_mut(|o| { o.thread_limit = Some(3); o.dirwalk_options.as_mut().map(|d| d.set_emit_untracked(Matching).set_emit_ignored(None).set_emit_pruned(false).set_emit_empty_directories(false)); }).tree_index_track_renames(Disabled).into_iter(None).

for r in neovim vite pnpm; do (cd $r && hyperfine -N -w 5 -r 30 "$MAIN status ." "$BRANCH status ."); done

Directory reads dominate walks, so threads read tracked directories ahead while the walk stays serial.
Use `thread_limit` for `prefetch_threads`: starship-like status is 38-56% faster.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T00:09:20.073797Z 7df8113 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7df8113be9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

directories.sort_unstable();
directories.dedup();
Some(Prefetch {
listings: directories.iter().map(|_| OnceLock::new()).collect(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Bound retained prefetched directory listings

With prefetching active (which Repository::index_worktree_status() now enables by default in parallel builds), each worker/get() stores a complete Vec in this OnceLock, and nothing removes a listing after readdir::recursive() consumes it. A full walk therefore ends with names and file types for essentially every entry in every tracked directory still resident, changing the walk from streaming memory to O(total worktree entries); large monorepos or tracked directories containing huge untracked sets can now spike or OOM. Use bounded lookahead and take/drop consumed listings.

Useful? React with 👍 / 👎.

Comment on lines +5 to +8
sync::{
OnceLock,
atomic::{AtomicBool, AtomicUsize, Ordering},
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Use gix_parallel for the new shared prefetch state

The new worker-coordination state uses std::sync::OnceLock and atomics directly rather than the workspace's feature-aware interior-mutability abstraction, creating a separate synchronization implementation whose serial/parallel behavior can drift from the rest of the workspace. Wire the appropriate gix_parallel feature through gix-dir and use its shared-state primitives (or gate this module) instead.

AGENTS.md reference: AGENTS.md:L119-L119

Useful? React with 👍 / 👎.

Comment thread gix-dir/src/walk/mod.rs
/// The traversal itself still happens on the calling thread, and its results are unaffected.
/// If `0`, the default, all directories are read by the calling thread.
/// It has no effect on walks [for deletion](Self::for_deletion).
pub prefetch_threads: usize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Mark the new option as a breaking gix-dir change

Adding a field to this non-#[non_exhaustive] public options struct breaks every downstream gix_dir::walk::Options struct literal that does not supply it, but the reviewed commit is titled perf(gix): ..., so generated release notes assign it to the wrong crate and omit the required breaking marker. Split this API addition into a feat(gix-dir)! commit (with its adaptations) or otherwise give it the correct scope and breaking marker.

AGENTS.md reference: AGENTS.md:L98-L101

Useful? React with 👍 / 👎.

pub(super) fn new(index: &'index gix_index::State, root: &BStr) -> Option<Self> {
let path_backing = index.path_backing();
let mut directories = Vec::new();
for entry in index.entries() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Restrict prefetch candidates to the status pathspec

For status requests with a narrow pathspec, gix-status/src/index_as_worktree_with_renames/mod.rs:131 still sets explicit_traversal_root to the whole worktree, so root here is empty and this loop materializes and sorts directories for every non-skip index entry. A one-path status in a million-entry repository therefore performs O(full index) setup and workers may issue unrelated read_dir calls even though the walker prunes those directories; pass the pathspec/common prefix into candidate selection so narrow status remains narrow.

Useful? React with 👍 / 👎.

Comment on lines +78 to +79
let path = worktree_root.join(rela_path);
listing.get_or_init(|| read(&path, precompose_unicode));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Avoid following unclassified symlinks during prefetch

If an indexed directory has been replaced by a symlink before status runs, this speculative read_dir follows the link before the serial walker classifies the entry and lets can_recurse reject it. The worker can consequently scan and cache an arbitrary external or slow/network directory that the normal walk would never enter; enqueue candidates only after traversal approves them, or verify every component without following symlinks before reading.

Useful? React with 👍 / 👎.

@Byron

Byron commented Nov 9, 2026

Copy link
Copy Markdown
Member

It's nice as a prototype, but would need much more work to get right. For one, the serial path can't be removed.
Closing for now, while I would consider to implement it myself once status optimisations as in #3153 are happening once again.
Thanks for your understanding.

@hood

hood commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Written by Claude Code, an AI agent, working for Andrea Cappuccio (@hood).

Thanks for looking, and for fixing #3182 so quickly.

Before anything gets reworked: which serial path do you mean?

  • In this PR the serial walk is still the default (prefetch_threads: 0).
  • With prefetching on, the walker still classifies every entry itself, in its own order. It only takes ready directory listings, and it falls back to a live read_dir for untracked directories and failed reads.

Maybe the concern is something else instead:

  • status turning prefetching on by default through thread_limit.
  • One of the gaps the Codex review found: listings kept for the whole walk, pathspec-limited walks reading unrelated directories, and a directory replaced by a symlink being read through the link.

Knowing which would tell whether a reworked version is worth proposing, or whether this is better left for you to do alongside #3153.

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.

status with untracked files is bound by a serial directory walk

2 participants