Skip to content

parallel-checkout: default to four workers on Windows - #6469

Open
azchohfi wants to merge 1 commit into
git-for-windows:mainfrom
azchohfi:parallel-checkout-windows-defaults
Open

azchohfi wants to merge 1 commit into
git-for-windows:mainfrom
azchohfi:parallel-checkout-windows-defaults

Conversation

@azchohfi

@azchohfi azchohfi commented Oct 8, 2026 •

Copy link
Copy Markdown

Git checks out files one at a time unless checkout.workers says otherwise. On Windows,
replacing a file in the worktree (unlink, create, write, close) costs about 250 microseconds
even on a Dev Drive (ReFS), and several times that on NTFS with real-time antivirus scanning.
That cost overlaps well across processes, so parallel checkout makes a 2,000-file checkout
28-38% faster (see below). Since 373d334 (entry: flush fscache after creating directories and
writing files, #6250) and d7b8fab (parallel-checkout: limit worker count to what poll() can
wait on, #6395), parallel checkout works with the FSCache and with any worker count.

Starting a worker is not free on Windows either, so the upstream threshold of 100 files is too
low. With four workers and that threshold, switching between branches that differ in 100 files
took 33ms longer than the 138ms of a sequential checkout.

Default to four workers on Windows, capped at the number of logical CPUs, and use them only for checkouts of at least 500 files. Both
settings keep working as before; this only changes what Git does when they are not set.

Switching between branches of a 40,000-file repository that differ in N files, Windows 11 on a
32-thread desktop with an NVMe drive. Medians; time saved is the median paired difference with
its 95% bootstrap interval, over 12-15 interleaved runs:

N files Dev Drive (ReFS): before saved NTFS, Defender real-time: before saved
250 145ms (sequential) 529ms (sequential)
500 187ms 0ms [-36, 7] 671ms 123ms [95, 148]
1000 287ms 40ms [27, 66] 1024ms 282ms [219, 308]
2000 461ms 129ms [61, 180] 1668ms 633ms [603, 645]

git worktree add of all 40,000 files went from 7.07s to 4.51s (2.56s saved [2.00, 3.13]) on
the Dev Drive.

On a real repository, nodejs/node at v26.10.0 (51,849 files), with the same two values passed with
-c to 2.55.0.windows.5 (medians of 5 interleaved passes; saved, with paired 95% intervals):

NTFS, Defender real-time Dev Drive (ReFS)
git worktree add 32.2s → 12.8s (18.6s [14.4, 26.8]) 9.2s → 3.6s (5.6s [5.1, 10.4])
checkout v24.0.0 → v26.0.0 (21,361 files written) 16.9s → 10.4s (6.2s [4.7, 12.9]) 6.05s → 2.86s (3.2s [2.8, 4.9])
local git clone 53.1s → 32.7s (19.6s [18.9, 45.2]) 5.9s saved, noisy [-7.6, 9.7]
checkout v26.9.0 → v26.10.0 (781 files) 0.76s → 0.53s (0.25s [0.07, 0.37]) no measurable change
checkout v25.8.0 → v25.8.1 (195 files, below the threshold) no change no change

Below the threshold the files are still written sequentially, and those checkouts were not
slower (250 files: 10ms faster [4, 16] on the Dev Drive, 24ms faster [5, 63] on NTFS). Eight
workers were no better than four at 2,000 files, and checkout.workers=0 (one per logical CPU,
32 here) was no faster than sequential checkout.

Capping matters on small machines. On two cores, four workers were no faster than two and used up to 36MB more memory; on four cores, four workers were the fastest setting. With four workers, a checkout's peak commit charge, summed over all git processes, was 40MB above a sequential one for nodejs/node and 150MB above for the full git-for-windows/git-sdk-64 tree (details in the comments).

The defaults are compile-time macros, overridable per platform, in the same way as
FSYNC_METHOD_DEFAULT and PROTECT_NTFS_DEFAULT, so other platforms are unchanged. A compiled
default covers every Git for Windows distribution in one place. Shipping it as configuration,
the way core.fscache is shipped, would mean adding it separately to the installer, the MSI,
PortableGit and MinGit, and to any system gitconfig that a tool bundling Git writes for itself.

Add a test that checks the default worker count on both sides of the threshold; on Windows it expects min(4, online_cpus()), using test-tool online-cpus.

Notes for reviewers

@dscho

dscho commented Oct 9, 2026

Copy link
Copy Markdown
Member

To be honest, I'm a little bit concerned about this patch because of the unintended consequences. Yes, it accelerates the checkout, but also it increases the memory requirements.

For context, when I check out the Git for Windows SDK in CI, I don't use four workers, even if the runner agents have two CPU cores available. No. I use 56 workers. Yes. Fifty-six.

In my tests this struck the best balance between performance and robustness. The checkout was even faster when I increased that number to, say, 64 workers. The problem, however, was that frequently these checkouts would run out of memory. And that is the exact problem here.

Unfortunately, due to its attribute handling, it is not possible to make Git's checkout logic multi-threaded. Ask me how I know. Therefore, the only chance we have is to make it multi-process. That, however, multiplies the memory requirements. It's somewhat eased by the fact that at least the packfiles are memory-mapped, so those memory pages can be shared between processes. In theory. I did not actually verify that they are shared, but in theory they could be. However, the packfiles are packfiles, but unpacked objects are unpacked objects, and they occupy a lot of memory. And I mean a lot.

Therefore, if you increase the default number of workers, you immediately also put a potentially enormous strain on the memory requirements of checkouts, in particular of large repositories, the very scenario with which this PR is supposed to help.

@qub1n

qub1n commented Oct 9, 2026 •

Copy link
Copy Markdown

Actually this goal of increasing performance is related with the following pull request, maybe it could solve your problem.

#6467

I was even testing parallel cloning on ReFS with multiple workers and it should not be that memory demanding because those workers mainly wait for system call of cloning to be finished and not holding entire buffer for copying. Buffer is allocated only for last unallocated block of file.

So I will retest how the implementation in this branch works with parallel worktree add in NTFS/RFS old and new implementation.

@azchohfi

Copy link
Copy Markdown
Author

Thanks, that's a fair concern, and I hadn't measured it. So I did, on three repositories including the SDK. Each run is git worktree add --detach from a local repository (objects already present, no network) with 2.55.0.windows.5, inside a Job Object that records the peak commit charge of all git processes at the same time. To see smaller machines, the Job Object also restricts the processes to 2 or 4 physical cores. Three interleaved repetitions per setting; medians below (the memory figures varied by up to ~12% between repetitions, the times more).

  • nodejs/node v26.10.0: 51,849 files, 0.65 GiB, largest 10.8 MiB
  • KhronosGroup/glTF-Sample-Assets: 2,540 files, 1.34 GiB, 283 files of 1 MiB or more, largest 41 MiB
  • git-for-windows/git-sdk-64 main, full tree: 75,916 files, 2.70 GiB, largest 40 MiB

Peak commit of all git processes together, all 32 CPUs (wall time in parentheses):

checkout.workers node glTF assets SDK
1 (today's default) 138 MB (29.2 s) 90 MB (6.4 s) 208 MB (60.4 s)
2 163 MB (19.9 s) 84 MB (3.7 s) 329 MB (32.4 s)
4 (this PR) 178 MB (15.3 s) 103 MB (3.1 s) 358 MB (24.9 s)
8 191 MB (12.9 s) 159 MB (2.4 s) 427 MB (23.2 s)
0 (one per CPU: 32 here) 289 MB (14.2 s) 350 MB (3.2 s) 805 MB (22.8 s)
56 366 MB (14.9 s) 525 MB (1.4 s) 880 MB (27.2 s)

Restricted to fewer cores:

cores workers node glTF assets SDK
4 1 138 MB (31.1 s) 90 MB (6.4 s) 208 MB (51.8 s)
4 2 163 MB (20.5 s) 84 MB (4.6 s) 323 MB (31.2 s)
4 4 178 MB (13.2 s) 99 MB (3.4 s) 348 MB (22.9 s)
2 1 138 MB (30.6 s) 90 MB (6.8 s) 207 MB (50.0 s)
2 2 163 MB (19.5 s) 84 MB (4.8 s) 322 MB (33.5 s)
2 4 172 MB (19.3 s) 99 MB (4.6 s) 358 MB (35.4 s)

What I take from it:

  • With 4 workers the whole checkout peaks 13–150 MB above a sequential one: +40 MB on node, +13 MB on the binary assets, +150 MB on the SDK. With 56 workers it's +230 to +670 MB, about four times today's peak on the SDK, and one-per-CPU is close to that. So your concern holds for large worker counts; at 4 the cost is tens of MB, up to ~150 MB on the full SDK tree.
  • Large files didn't make a worker much bigger here: on the glTF assets (files up to 41 MiB) the largest single process stayed at 85 MB with one worker and 45 MB with more. The total grows with the number of workers more than with file size.
  • On 4 cores, 4 workers were the fastest setting on all three. On 2 cores, 4 workers were no faster than 2 (19.3 vs 19.5 s, 4.6 vs 4.8 s, 35.4 vs 33.5 s) and used up to 36 MB more. So capping the default at online_cpus() costs nothing and saves a little on 2-core machines; I'm happy to add that to the PR. (On Windows online_cpus() counts all logical processors with GetLogicalProcessorInformationEx, which doesn't see a job's affinity, so I compared the capped values directly.)
  • More workers also use more CPU in total (node: 28 s with one worker, 42 s with four), but finish sooner.
  • Times are on NTFS with Defender real-time scanning, so they include its per-file cost.

Not measured: machines where an extra ~150 MB is tight, spinning disks, and the sparse build-installers checkout your CI does (I'd expect the full tree to be the upper bound for it).

@qub1n thanks, #6467 looks complementary: block-cloned files skip the write entirely, and the workers here still help with the files below its 160 KiB threshold, which are most of the files in a typical repository. Happy to rerun these timings and the memory table with both changes once your retest is in.

Git checks out files one at a time unless `checkout.workers` says
otherwise. On Windows, replacing a file in the worktree (unlink, create,
write, close) costs about 250 microseconds even on a Dev Drive (ReFS),
and several times that on NTFS with real-time antivirus scanning. That
cost overlaps well across processes, so parallel checkout makes a
2,000-file checkout 28-38% faster (see below). Since 373d334 (entry:
flush fscache after creating directories and writing files) and
d7b8fab (parallel-checkout: limit worker count to what poll() can
wait on), parallel checkout works with the FSCache and with any worker
count.

Starting a worker is not free on Windows either, so the upstream
threshold of 100 files is too low. With four workers and that
threshold, switching between branches that differ in 100 files took
33ms longer than the 138ms of a sequential checkout.

Default to four workers on Windows, capped at the number of logical
CPUs, and use them only for checkouts of at least 500 files. Both
settings keep working as before; this only changes what Git does when
they are not set.

Switching between branches of a 40,000-file repository that differ in
N files, Windows 11 on a 32-thread desktop with an NVMe drive. Medians;
time saved is the median paired difference with its 95% bootstrap
interval, over 12-15 interleaved runs:

                   Dev Drive (ReFS)          NTFS, Defender real-time
  N files       before   saved               before   saved
    250          145ms   (sequential)         529ms   (sequential)
    500          187ms     0ms [-36, 7]       671ms   123ms [95, 148]
   1000          287ms    40ms [27, 66]      1024ms   282ms [219, 308]
   2000          461ms   129ms [61, 180]     1668ms   633ms [603, 645]

`git worktree add` of all 40,000 files went from 7.07s to 4.51s
(2.56s saved [2.00, 3.13]) on the Dev Drive.

Below the threshold the files are still written sequentially, and
those checkouts were not slower (250 files: 10ms faster [4, 16] on the
Dev Drive, 24ms faster [5, 63] on NTFS). Eight workers were no better
than four at 2,000 files, and `checkout.workers=0` (one per logical
CPU, 32 here) was no faster than sequential checkout at that size.

Capping matters on small machines. On two cores, four workers were no
faster than two and used up to 36MB more memory; on four cores, four
workers were the fastest setting. With four workers, a checkout's
peak commit charge, summed over all git processes, was 40MB above a
sequential one for nodejs/node and 150MB above for the full
git-for-windows/git-sdk-64 tree.

The defaults are compile-time macros, overridable per platform, in the
same way as FSYNC_METHOD_DEFAULT and PROTECT_NTFS_DEFAULT, so other
platforms are unchanged. A compiled default covers every Git for
Windows distribution in one place. Shipping it as configuration, the
way core.fscache is shipped, would mean adding it separately to the
installer, the MSI, PortableGit and MinGit, and to any system gitconfig
that a tool bundling Git writes for itself.

Add a test that checks the default worker count on both sides of the
threshold.

Signed-off-by: Alexandre Zollinger Chohfi <alzollin@microsoft.com>
@azchohfi
azchohfi force-pushed the parallel-checkout-windows-defaults branch from e9be4a0 to 80c28a5 Compare October 12, 2026 23:21
@azchohfi

Copy link
Copy Markdown
Author

Pushed an update: the built-in default is now min(4, online_cpus()). A configured checkout.workers is still used as is, and other platforms are unchanged. The test computes the expected count with test-tool online-cpus, so it holds on CI runners with fewer cores. The docs and the commit message mention the cap and the memory numbers from the comment above.

This branch has not been deployed

No deployments
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