From 3e3d27ee752496d6b30b93bc4d42b34d65eae4c9 Mon Sep 17 00:00:00 2001 From: Reid Kleckner Date: Fri, 28 Aug 2026 05:49:31 +0000 Subject: [PATCH] Don't hand the jobserver to children that can't use it `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 ` 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. --- src/compiler/cicc.rs | 1 + src/compiler/compiler.rs | 12 +++++++ src/compiler/cudafe.rs | 1 + src/compiler/diab.rs | 1 + src/compiler/gcc.rs | 1 + src/compiler/msvc.rs | 1 + src/compiler/rust.rs | 5 +++ src/compiler/tasking_vx.rs | 1 + src/dist/mod.rs | 2 ++ src/mock_command.rs | 64 +++++++++++++++++++++++++++++++++++++- 10 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/compiler/cicc.rs b/src/compiler/cicc.rs index bf4901f648..cdba52a1f8 100644 --- a/src/compiler/cicc.rs +++ b/src/compiler/cicc.rs @@ -319,6 +319,7 @@ pub fn generate_compile_commands( arguments, env_vars: env_vars.to_owned(), cwd: cwd.to_owned(), + share_jobserver: false, }; #[cfg(not(feature = "dist-client"))] diff --git a/src/compiler/compiler.rs b/src/compiler/compiler.rs index 50c4a4ecdd..0939ef08a5 100644 --- a/src/compiler/compiler.rs +++ b/src/compiler/compiler.rs @@ -166,6 +166,14 @@ pub struct SingleCompileCommand { pub arguments: Vec, pub env_vars: Vec<(OsString, OsString)>, pub cwd: PathBuf, + /// Whether this compiler participates in the GNU make jobserver. + /// + /// Deliberately a field rather than a defaulted builder method: the + /// compiler then makes every frontend answer, so a new one cannot + /// silently get this wrong. Getting it wrong in the `true` direction + /// costs a `fork` per compile; in the `false` direction it costs `rustc` + /// its parallelism limit, which is what the jobserver exists to enforce. + pub share_jobserver: bool, } #[async_trait] @@ -196,6 +204,7 @@ impl CompileCommandImpl for SingleCompileCommand { arguments, env_vars, cwd, + share_jobserver, } = self; // Resolve compiler avoiding ccache wrappers to prevent double-caching. let resolved_executable = resolve_compiler_avoiding_wrapper(executable, env_vars); @@ -204,6 +213,9 @@ impl CompileCommandImpl for SingleCompileCommand { .env_clear() .envs(env_vars.clone()) .current_dir(cwd); + if *share_jobserver { + cmd.share_jobserver(); + } run_input_output(cmd, None).await } } diff --git a/src/compiler/cudafe.rs b/src/compiler/cudafe.rs index 9ca1eb6631..d6440ac4c1 100644 --- a/src/compiler/cudafe.rs +++ b/src/compiler/cudafe.rs @@ -154,6 +154,7 @@ pub fn generate_compile_commands( arguments, env_vars: env_vars.to_owned(), cwd: cwd.to_owned(), + share_jobserver: false, }; #[cfg(not(feature = "dist-client"))] diff --git a/src/compiler/diab.rs b/src/compiler/diab.rs index a11e578606..8212930036 100644 --- a/src/compiler/diab.rs +++ b/src/compiler/diab.rs @@ -378,6 +378,7 @@ pub fn generate_compile_commands( arguments, env_vars: env_vars.to_owned(), cwd: cwd.to_owned(), + share_jobserver: false, }; Ok((command, None, Cacheable::Yes)) diff --git a/src/compiler/gcc.rs b/src/compiler/gcc.rs index 8a832b5d68..eb378c1e24 100644 --- a/src/compiler/gcc.rs +++ b/src/compiler/gcc.rs @@ -999,6 +999,7 @@ where arguments, env_vars: env_vars.to_owned(), cwd: cwd.to_owned(), + share_jobserver: false, }; #[cfg(not(feature = "dist-client"))] diff --git a/src/compiler/msvc.rs b/src/compiler/msvc.rs index 1e58a077ba..21edfe3021 100644 --- a/src/compiler/msvc.rs +++ b/src/compiler/msvc.rs @@ -1141,6 +1141,7 @@ fn generate_compile_commands( arguments, env_vars: env_vars.to_owned(), cwd: cwd.to_owned(), + share_jobserver: false, }; #[cfg(not(feature = "dist-client"))] diff --git a/src/compiler/rust.rs b/src/compiler/rust.rs index 779e9dd79e..1a1a24a173 100644 --- a/src/compiler/rust.rs +++ b/src/compiler/rust.rs @@ -1798,6 +1798,11 @@ impl Compilation for RustCompilation { .collect(), env_vars: env_vars.to_owned(), cwd: cwd.to_owned(), + // rustc reads `CARGO_MAKEFLAGS` and runs codegen on a thread pool + // sized by the jobserver. Without one, every concurrent rustc + // spawns as many threads as there are CPUs, which is the + // oversubscription sccache's own jobserver exists to prevent. + share_jobserver: true, }; #[cfg(not(feature = "dist-client"))] diff --git a/src/compiler/tasking_vx.rs b/src/compiler/tasking_vx.rs index b3fff8238a..3e1ed1bb15 100644 --- a/src/compiler/tasking_vx.rs +++ b/src/compiler/tasking_vx.rs @@ -391,6 +391,7 @@ fn generate_compile_commands( arguments, env_vars: env_vars.to_owned(), cwd: cwd.to_owned(), + share_jobserver: false, }; Ok((command, None, Cacheable::Yes)) diff --git a/src/dist/mod.rs b/src/dist/mod.rs index 6bc1024aa8..0fa99d90dd 100644 --- a/src/dist/mod.rs +++ b/src/dist/mod.rs @@ -326,6 +326,8 @@ pub fn try_compile_command_to_dist( arguments, env_vars, cwd, + // The jobserver is a local resource; a remote worker has its own. + share_jobserver: _, } = command; Some(CompileCommand { executable: executable.into_os_string().into_string().ok()?, diff --git a/src/mock_command.rs b/src/mock_command.rs index 3ad5ecc3f4..2f4494e3f3 100644 --- a/src/mock_command.rs +++ b/src/mock_command.rs @@ -111,6 +111,15 @@ pub trait RunCommand: fmt::Debug + Send { fn stdout(&mut self, cfg: Stdio) -> &mut Self; /// Set the process' stderr from `cfg`. fn stderr(&mut self, cfg: Stdio) -> &mut Self; + /// Hand sccache's jobserver down to this child. + /// + /// Only worth doing for a child that implements the protocol -- in + /// practice `rustc`, which reads `CARGO_MAKEFLAGS`. It is not free: see + /// [`AsyncCommand::spawn`] for why sharing costs a `fork` instead of a + /// `posix_spawn`. + fn share_jobserver(&mut self) -> &mut Self { + self + } /// Execute the process and return a process object. async fn spawn(&mut self) -> Result; } @@ -180,6 +189,7 @@ impl CommandChild for Child { pub struct AsyncCommand { inner: Option, jobserver: Client, + share_jobserver: bool, } impl AsyncCommand { @@ -187,6 +197,7 @@ impl AsyncCommand { AsyncCommand { inner: Some(Command::new(program)), jobserver, + share_jobserver: false, } } @@ -246,13 +257,28 @@ impl RunCommand for AsyncCommand { self.inner().stderr(cfg); self } + fn share_jobserver(&mut self) -> &mut AsyncCommand { + self.share_jobserver = true; + self + } async fn spawn(&mut self) -> Result { let mut inner = self.inner.take().unwrap(); inner.env_remove("MAKEFLAGS"); inner.env_remove("MFLAGS"); inner.env_remove("CARGO_MAKEFLAGS"); - self.jobserver.configure(&mut inner); + // `configure` registers a `pre_exec` closure to clear `CLOEXEC` on the + // jobserver's file descriptors, and any `pre_exec` makes `std` fall + // back from `posix_spawn` to `fork`+`exec`. Almost nothing sccache + // spawns can use a jobserver -- preprocessors, version probes and + // C/C++ compilers all ignore it -- so the default is not to share, + // and the callers that spawn `rustc` ask for it. + if self.share_jobserver { + self.jobserver.configure(&mut inner); + } + // The token is acquired either way: it rate-limits how many children + // *we* run, which is separate from whether the child can acquire + // tokens of its own. let token = self.jobserver.acquire().await?; let mut inner = tokio::process::Command::from(inner); let child = inner @@ -667,4 +693,40 @@ mod test { ); assert_eq!(exit_status(0), spawn_on_thread(creator, true)); } + + /// The jobserver reaches a child through `CARGO_MAKEFLAGS`, so reading it + /// back out of the child is what "did the child get the jobserver?" means. + #[cfg(unix)] + fn child_sees_cargo_makeflags(share: bool) -> bool { + let client = Client::new_num(1); + let mut creator = ::new(&client); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let mut cmd = creator.new_command("/bin/sh"); + cmd.args(&["-c", "printf %s \"$CARGO_MAKEFLAGS\""]); + if share { + cmd.share_jobserver(); + } + let output: std::process::Output = runtime + .block_on(async { crate::util::run_input_output(cmd, None).await }) + .unwrap(); + !output.stdout.is_empty() + } + + #[test] + #[cfg(unix)] + fn jobserver_is_withheld_by_default() { + assert!( + !child_sees_cargo_makeflags(false), + "a child should not inherit the jobserver unless it asks" + ); + } + + #[test] + #[cfg(unix)] + fn share_jobserver_hands_it_over() { + assert!( + child_sees_cargo_makeflags(true), + "share_jobserver() should give the child CARGO_MAKEFLAGS" + ); + } }