Skip to content

On Windows a git status refresh takes 3x as long as on Linux on the same machine: 39 git processes at 29 to 41 ms each, plus slower working-tree scans #36673

Closed

Description

@juliusmarminge

Problem

On Windows the server's git work costs about three times what it costs on Linux on the same hardware, and there is a lot of it: one status refresh starts 39 git processes, a turn 33, a thread switch 14. Thread switching and typing do not wait for it, so the UI does not hang, but the branch, Changes totals and PR state arrive seconds late, and an idle app with one thread open keeps a core busy with git.

Numbers

Windows Server 2025, 4 vCPU (Xeon Platinum 8370C), 16 GB, NTFS, Git for Windows 2.56.1, Node 26.13.1, Defender on with default settings and no exclusions, commit ca6e09c. The Linux baseline is WSL2 Ubuntu 24.04 (ext4, git 2.43) on the same machine.

The real dev server (Replay provider) with one thread open in headless Chromium, the same script on Windows and inside WSL. Span durations come from each run's server.trace.ndjson; process counts come from a preload that wraps ChildProcess.prototype.spawn in the server.

Depth-1 clone of llvm-project (191,083 files), median / p95 / n in ms:

Span Windows WSL
ws.rpc.vcs.refreshStatus 7,586 / 8,080 / 5 2,277 / 7,204 / 5
VcsStatusBroadcaster.refreshLocalStatus 3,684 / 3,872 / 12 1,327 / 1,833 / 16
RunFinalizationService.finalize (end of every turn) 5,623 / 5,807 / 12 2,976 / 4,595 / 16
ws.rpc.review.getDiffPreview 1,299 / 2,320 / 9 509 / 624 / 9
ws.rpc.vcs.listRefs 352 / 464 / 16 32 / 56 / 16
ws.rpc.vcs.refreshBranch 57 / 59 / 5 13 / 14 / 5
runGitCommand (every git process) 45 / 1,257 / 876 8 / 477 / 870

Turning Defender real-time scanning off changed none of these by more than noise (refreshStatus 7,806 ms, runGitCommand 39 ms).

Per action on the same repository, git processes and their summed run time:

Action Windows WSL
One replayed turn 33 processes, 8.5 s 33 processes, 3.1 s
One thread switch 14 processes, 2.6 s 14 processes, 0.6 s
One window focus 38 processes, 8.3 s 55 processes, 6.1 s

On this repository (32,168 files) Windows ran 33 git processes per turn, 50 per window focus (5.0 s of git time), and 62 per minute while idle with one thread open (9.4 s of git time per minute). ws.rpc.vcs.refreshStatus took a median 3,314 ms.

For comparison, what the user waits on directly was the same on both: a sidebar thread switch took 482 ms on Windows and 498 ms in WSL, and file search 853 ms and 789 ms.

Cause

Two things multiply.

1. Each git process costs 15 to 20 times more to start. Median of 40 runs of git rev-parse HEAD:

How git is started ms
WSL 1.8
Windows, ucrt64\bin\git.exe, detached (the allow-list in runsGitWithoutConsole, packages/shared/src/shell.ts) 29.0
Windows, ucrt64\bin\git.exe, with a console 41.2
Windows, cmd\git.exe launcher (already avoided by preferGitForWindowsBinary) 61.4

The spawn() call itself held the thread about 2.2 ms (max 4 ms) here, so this machine did not show the multi-second stalls of #25245. The cost is the process, not the call.

2. One refresh starts 39 of them and scans the working tree at least six times. Git processes inside the median ws.rpc.vcs.refreshStatus on Windows, from the trace:

Operation Count Total ms
GitVcsDriver.review.listUntracked (ls-files --others) 2 2,605
GitVcsDriver.statusDetails.status 2 2,113
GitVcsDriver.statusDetails.numstat (diff-index) 2 1,713
GitVcsDriver.readBranchChangeTotals 2 1,020
GitVcsDriver.fetchRemoteForStatus 1 510
GitVcsDriver.readConfigValue (config --get, one key each) 12 369
18 more ref and path lookups 18 about 700

The local read runs twice per refresh (VcsStatusBroadcaster.refreshStatus, apps/server/src/vcs/VcsStatusBroadcaster.ts), and the working-tree scans are 2 to 4 times slower on NTFS: status 601 vs 416 ms, diff --numstat 456 vs 141 ms, ls-files --others --exclude-standard 1,269 vs 324 ms.

What already helps, measured on Windows

Both open PRs applied to ca6e09c, one run each, same script:

base #29155 #30838 both
refreshStatus median, llvm-project 11,158 ms 4,290 ms 7,458 ms 4,153 ms
refreshStatus median, this repo 3,314 ms 3,241 ms 2,851 ms 2,732 ms
git processes in the run, this repo 726 687 505 485

What is left after both is the scan cost. Git's file system monitor is the one setting that moved it: with core.fsmonitor=true and core.untrackedCache=true, git status --porcelain=2 --branch went from 617 to 128 ms on llvm-project and from 227 to 100 ms on this repo (median of 9, GIT_OPTIONAL_LOCKS=0 as the server sets it). The untracked cache alone gave 505 and 213 ms. ls-files --others and diff-index did not change with either, so the status read would have to stop calling them separately to benefit. The server currently passes core.fsmonitor=false on some commands; I did not test turning the monitor on from the server, only the raw git commands.

Reproduce on Windows

Per-process cost, no T3 Code needed:

// node spawn-cost.mjs <repo>     run it on Windows and inside WSL
import { spawn } from "node:child_process";
const git = process.platform === "win32" ? String.raw`C:\Program Files\Git\ucrt64\bin\git.exe` : "git";
const once = (detached) => new Promise((done) => {
  const t = performance.now();
  spawn(git, ["rev-parse", "HEAD"], { cwd: process.argv[2], stdio: "ignore", windowsHide: true, detached })
    .on("close", () => done(performance.now() - t));
});
for (const detached of [false, true]) {
  const ms = [];
  for (let i = 0; i < 40; i++) ms.push(await once(detached));
  console.log({ detached, median: ms.sort((a, b) => a - b)[20].toFixed(1) });
}

Count and refresh time in the app:

  1. vp run dev, add a large repository as a project (git clone --depth 1 https://github.com/llvm/llvm-project), open a thread in it.
  2. Click away from the window and back five times, a few seconds apart.
  3. In <T3 home>/userdata/logs/server.trace.ndjson, take the median durationMs of the ws.rpc.vcs.refreshStatus spans, and count the runGitCommand spans under one of them by parentSpanId.

Related: #25245 (the spawn call blocking the thread), #32260, #29155, #30838, #32351, #7076.

Measured and written by Claude Fable 5.2 in Claude Code (T3 Code).

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions