Skip to content

Optimize child process spawns by skipping job server configs - #2827

Open
rnk wants to merge 1 commit into
mozilla:mainfrom
rnk:jobserver-posix-spawn
Open

Optimize child process spawns by skipping job server configs#2827
rnk wants to merge 1 commit into
mozilla:mainfrom
rnk:jobserver-posix-spawn

Conversation

@rnk

@rnk rnk commented Aug 28, 2026

Copy link
Copy Markdown

Configuring the job server so that all child processes can access it causes Rust's subprocess library to switch from posix_spawn to fork, which supports running arbitrary code in a pre-exec context. Fork is, unfortunately, very expensive. You really want to use vfork or posix_spawn, which IIRC uses that under the hood, in order to get fast process launching.

This really doesn't matter in the grand scheme of things because sccache direct mode (the defatult) doesn't launch a ton of processes when it gets a cache hit, but if you turn it off, it will launch may pre-processing jobs, and the overhead is observable in a profiler, which is how I (well, Claude) found this:

Measured on a 2456-file LLVM rebuild (X86 only, Release, clang, -j16,
16 cores) where every compile is a cache hit, so the preprocessor spawn is
essentially all the server does. Four interleaved rounds per arm:

| arm    | wall             | server CPU       | of which system |
|--------|------------------|------------------|-----------------|
| before | 40.7 s (+-0.7)   | 28.2 s (+-0.4)   | 22.8 s          |
| after  | 33.5 s (+-0.2)   | 10.6 s (+-0.1)   |  5.8 s          |

It seemed like a reasonably small contribution that might make a good first PR.

The code is AI generated, and I'll take another pass to try to simplify it. I think it has the wrong job server default.

@rnk
rnk force-pushed the jobserver-posix-spawn branch from 2d2b264 to 38a715b Compare August 28, 2026 05:50
@sylvestre

Copy link
Copy Markdown
Collaborator

Nice to see you here :)

@codecov-commenter

codecov-commenter commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.73%. Comparing base (8ab3926) to head (3e3d27e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/mock_command.rs 85.71% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2827      +/-   ##
==========================================
+ Coverage   73.71%   73.73%   +0.01%     
==========================================
  Files          72       72              
  Lines       37932    37976      +44     
==========================================
+ Hits        27963    28002      +39     
- Misses       9969     9974       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`AsyncCommand::spawn` calls `jobserver::Client::configure` on every child it
spawns. On Unix that registers a `pre_exec` closure to clear `CLOEXEC` on the
jobserver's two file descriptors -- and the presence of *any* `pre_exec` makes
`std` abandon its `posix_spawn` fast path and fall back to `fork` + `exec`.

That is a bad trade for a server process. `fork` duplicates the parent's page
tables, and sccache's whole job is to be a long-lived process holding a large
cache; the child then throws the copy away microseconds later in `exec`. The
cost scales with how much memory the server has touched, so it grows over the
life of a build.

An empty closure is enough to trigger it:

    let mut c = Command::new("/bin/true");
    if pre_exec { unsafe { c.pre_exec(|| Ok(())); } }
    c.spawn()

    without:  clone3({flags=CLONE_VM|CLONE_VFORK|CLONE_CLEAR_SIGHAND, ...})
    with:     clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|...|SIGCHLD)

`CLONE_VM|CLONE_VFORK` shares the address space and copies nothing. The second
form is a real fork, and it is what shows up in a profile as `dup_mmap` ->
`copy_page_range` -> `copy_pte_range`.

Almost nothing sccache spawns can use a jobserver. It reaches a child through
`CARGO_MAKEFLAGS`, and only `rustc` reads it: preprocessors, version probes and
C/C++ compilers all ignore it. So stop sharing by default and let the callers
that spawn `rustc` ask, via `RunCommand::share_jobserver`.

For the local compile, which is one code path shared by every frontend, the
opt-in is a field on `SingleCompileCommand` rather than a builder call. That
makes the compiler ask every frontend the question, so a new one cannot get it
wrong by omission -- and getting it wrong in this direction is what matters,
since a `rustc` without a jobserver spawns as many codegen threads as there
are CPUs, per concurrent `rustc`, which is the oversubscription the jobserver
exists to prevent.

Measured on a 2456-file LLVM build (X86 only, Release, clang, `-j16`, 16
cores). Two scenarios, interleaved rounds:

All compiles are cache hits, with `SCCACHE_DIRECT=false` so the preprocessor
still runs -- this isolates the spawn cost, since it is nearly all the server
does (4 rounds):

    | arm    | wall             | server CPU       | of which system |
    |--------|------------------|------------------|-----------------|
    | before | 40.7 s (+-0.7)   | 28.2 s (+-0.4)   | 22.8 s          |
    | after  | 33.5 s (+-0.2)   | 10.6 s (+-0.1)   |  5.8 s          |

All compiles are cache misses, so each one both preprocesses and compiles
(2 rounds):

    | arm    | wall             | server CPU       | of which system |
    |--------|------------------|------------------|-----------------|
    | before | 336.5 s          | 96.3 s           | 45.8 s          |
    | after  | 333.1 s          | 70.1 s           | 21.2 s          |

The saving is almost entirely system time, which is what a page-table copy
costs. Wall clock barely moves on the miss build because it is dominated by the
compiler itself; the win there is 26 s of a core given back, not a faster
build.

`strace` on a 1300-compile miss build confirms the mechanism, and shows why
both spawn sites had to be covered:

    | build            | fork | posix_spawn |
    |------------------|------|-------------|
    | before           | 2617 |           0 |
    | preprocess only  | 1319 |        1300 |
    | this change      |    0 |        2619 |

Under `perf record -p <server>` on the cache-hit build, the address-space
symbols (`copy_pte_range`, `copy_present_ptes`, `zap_pte_range`,
`smp_call_function_many_cond` and friends) go from 4.20% of the server's
samples to 0.05%.

One behaviour change worth naming: the client-side fallback in `commands.rs`,
which runs the compiler itself when the server declines the job, no longer
shares its jobserver either. That jobserver is private to a single short-lived
client process with exactly one child, so it never limited anything across
compiles; the server's jobserver is the one that does real work.
@rnk
rnk force-pushed the jobserver-posix-spawn branch from 38a715b to 3e3d27e Compare August 28, 2026 18:22
@rnk
rnk marked this pull request as ready for review August 28, 2026 18:50
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