From 755f7394895045d17a3a55eece66121fc38edd42 Mon Sep 17 00:00:00 2001 From: Francois Girinon Date: Thu, 24 Sep 2026 14:05:01 +0200 Subject: [PATCH 1/5] gcc, clang: don't distribute multi-arch compilations The dist command never passes the -arch flags on to the remote compiler, so a compilation with SCCACHE_CACHE_MULTIARCH and several -arch values came back from the build server as a single-arch object instead of a universal one. Run those compilations locally. --- src/compiler/c.rs | 15 +++++++++++++ src/compiler/gcc.rs | 51 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/compiler/c.rs b/src/compiler/c.rs index 1206225cf..0bc2b30f8 100644 --- a/src/compiler/c.rs +++ b/src/compiler/c.rs @@ -131,6 +131,21 @@ impl ParsedArguments { .map(|s| s.to_string_lossy()) .unwrap_or(Cow::Borrowed("Unknown filename")) } + + #[cfg(feature = "dist-client")] + pub fn is_multiarch(&self) -> bool { + let mut archs = self.arch_values(); + archs + .next() + .is_some_and(|first| archs.any(|arch| arch != first)) + } + + #[cfg(feature = "dist-client")] + fn arch_values(&self) -> impl Iterator { + self.arch_args + .iter() + .filter(|arg| *arg != super::gcc::ARCH_FLAG) + } } /// A generic implementation of the `Compilation` trait for C/C++ compilers. diff --git a/src/compiler/gcc.rs b/src/compiler/gcc.rs index be2ea2453..c9dd3ce27 100644 --- a/src/compiler/gcc.rs +++ b/src/compiler/gcc.rs @@ -183,7 +183,7 @@ ArgData! { pub use self::ArgData::*; -const ARCH_FLAG: &str = "-arch"; +pub(crate) const ARCH_FLAG: &str = "-arch"; // Mostly taken from https://github.com/ccache/ccache/blob/master/src/ccache/compopt.cpp#L52-L183 counted_array!(pub static ARGS: [ArgInfo; _] = [ @@ -1029,7 +1029,12 @@ where // output is parsed by tools like CMake and must reflect the local toolchain // 2. ClangCUDA cannot be dist-compiled because Clang has separate host and // device preprocessor outputs and cannot compile preprocessed CUDA files. - let dist_command = if has_verbose_flag || parsed_args.language == Language::Cuda { + // 3. The dist command does not carry the -arch flags, so a multi-arch + // compilation would come back with a single-arch object. + let dist_command = if has_verbose_flag + || parsed_args.language == Language::Cuda + || parsed_args.is_multiarch() + { None } else { (|| { @@ -2757,6 +2762,48 @@ mod test { assert_eq!(0, creator.lock().unwrap().children.len()); } + #[test] + #[cfg(feature = "dist-client")] + fn test_compile_multiarch_is_not_distributed() { + let f = TestFixture::new(); + let dist_command = |args: Vec| { + let parsed_args = match parse_arguments_(args, false) { + CompilerArguments::Ok(args) => args, + o => panic!("Got unexpected parse result: {:?}", o), + }; + let mut path_transformer = dist::PathTransformer::new(); + generate_compile_commands( + &mut path_transformer, + &f.bins[0], + &parsed_args, + f.tempdir.path(), + &[], + CCompilerKind::Gcc, + false, + language_to_gcc_arg, + ) + .unwrap() + .1 + }; + with_var("SCCACHE_CACHE_MULTIARCH", Some("1"), || { + assert!( + dist_command(stringvec!["-arch", "arm64", "-c", "foo.c", "-o", "foo.o"]).is_some() + ); + assert!( + dist_command(stringvec![ + "-arch", "x86_64", "-arch", "x86_64", "-c", "foo.c", "-o", "foo.o" + ]) + .is_some() + ); + assert!( + dist_command(stringvec![ + "-arch", "x86_64", "-arch", "arm64", "-c", "foo.c", "-o", "foo.o" + ]) + .is_none() + ); + }); + } + #[test] fn test_compile_simple_verbose_long() { let creator = new_creator(); From 1bed6f4c84ebb64b54b5ae39e4fdf3b1e6e6c5ac Mon Sep 17 00:00:00 2001 From: Francois Girinon Date: Thu, 24 Sep 2026 14:06:57 +0200 Subject: [PATCH 2/5] gcc, clang: preprocess multi-arch compilations once per -arch With SCCACHE_CACHE_MULTIARCH, a compilation with several -arch values was preprocessed once, with the -arch flags replaced by -D____=1. Those defines are not what the compiler sees for each target: code guarded by __aarch64__, __ARM_NEON, __SSE2__ and the like did not reach the hash, so changing it could hand back a stale universal object. Like ccache, run the preprocessor once per architecture, with only that -arch flag, and hash every output, each preceded by its architecture and line count. Lines rather than bytes, so that the key doesn't depend on the basedirs stripped from the output afterwards. The passes run concurrently. They would all write the same depfile and serialized diagnostics, so only the last one keeps the arguments that write files, which leaves the same depfile as clang does. The stderr of every pass is kept. Include files are collected from every pass for the preprocessor cache. Since those compilations are never distributed, the passes don't keep the line markers dist needs. --- docs/Caching.md | 11 +- src/compiler/c.rs | 8 +- src/compiler/compiler.rs | 88 +++++++ src/compiler/gcc.rs | 481 +++++++++++++++++++++++++++++++-------- 4 files changed, 480 insertions(+), 108 deletions(-) diff --git a/docs/Caching.md b/docs/Caching.md index 45a8e3255..0f4ca650e 100644 --- a/docs/Caching.md +++ b/docs/Caching.md @@ -24,12 +24,11 @@ See https://github.com/mozilla/sccache/blob/8567bbe2ba493153e76177c1f9a6f98cc7ba ### C/C++ compiler For C/C++, the hash is generated with a blake3 digest of the preprocessed -file (-E with gcc/clang). For compilations that specify multiple `-arch` flags, -these flags are rewritten to their corresponding preprocessor defines to allow -pre-processing the file (e.g `-arch x86_64` is rewritten to `-D__X86_64__=1`), -this can be enabled by setting the environment variable -`SCCACHE_CACHE_MULTIARCH` but is disabled by default as it may not work in all -cases. +file (-E with gcc/clang). Compilations that specify several different `-arch` +flags are only cached when the environment variable `SCCACHE_CACHE_MULTIARCH` +is set. The file is then preprocessed once per architecture, with only that +`-arch` flag, and every output goes into the hash, so that code only one of +the targets sees (e.g. under `__aarch64__`) is taken into account. We also take into account in the hash: * Hash of the compiler binary diff --git a/src/compiler/c.rs b/src/compiler/c.rs index 0bc2b30f8..f36aab17f 100644 --- a/src/compiler/c.rs +++ b/src/compiler/c.rs @@ -132,7 +132,12 @@ impl ParsedArguments { .unwrap_or(Cow::Borrowed("Unknown filename")) } - #[cfg(feature = "dist-client")] + /// The distinct architectures given with `-arch`, in command line order. + pub fn archs(&self) -> Vec<&OsString> { + use itertools::Itertools as _; + self.arch_values().unique().collect() + } + pub fn is_multiarch(&self) -> bool { let mut archs = self.arch_values(); archs @@ -140,7 +145,6 @@ impl ParsedArguments { .is_some_and(|first| archs.any(|arch| arch != first)) } - #[cfg(feature = "dist-client")] fn arch_values(&self) -> impl Iterator { self.arch_args .iter() diff --git a/src/compiler/compiler.rs b/src/compiler/compiler.rs index 56dea23c0..9fb75761c 100644 --- a/src/compiler/compiler.rs +++ b/src/compiler/compiler.rs @@ -2589,6 +2589,94 @@ LLVM version: 6.0", assert_ne!(results[0].key, results[1].key); } + #[test_case(true ; "with preprocessor cache")] + #[test_case(false ; "without preprocessor cache")] + fn test_multiarch_hash_covers_every_arch(preprocessor_cache_mode: bool) { + let f = TestFixture::new(); + let clang = f.mk_bin("clang").unwrap(); + let runtime = single_threaded_runtime(); + let pool = runtime.handle(); + let cwd = f.tempdir.path(); + // Write a dummy input file so the preprocessor cache mode can work + std::fs::write(f.tempdir.path().join("foo.c"), "whatever").unwrap(); + + let key = |arguments: &[OsString], x86_64_output: &str, arm64_output: &str| { + let creator = new_creator(); + next_command( + &creator, + Ok(MockChild::new( + exit_status(0), + "compiler_id=clang\ncompiler_version=\"16.0.0\"", + "", + )), + ); + next_assembler(&creator, "GNU assembler (GNU Binutils) 2.42", ""); + let c = detect_compiler( + creator.clone(), + &clang, + f.tempdir.path(), + &[], + &[], + pool, + None, + ) + .wait() + .unwrap() + .0; + let outputs = [ + ("x86_64", x86_64_output.to_owned()), + ("arm64", arm64_output.to_owned()), + ]; + for _ in 0..outputs.len() { + let outputs = outputs.clone(); + next_command_calls(&creator, move |args| { + let output = outputs + .iter() + .find(|(arch, _)| args.iter().any(|a| a == arch)) + .map(|(_, output)| output.clone()) + .unwrap_or_default(); + Ok(MockChild::new(exit_status(0), output, "")) + }); + } + let mut hasher = match c.parse_arguments(arguments, ".".as_ref(), &[]) { + CompilerArguments::Ok(h) => h, + o => panic!("Bad result from parse_arguments: {:?}", o), + }; + hasher + .generate_hash_key( + &creator, + cwd.to_path_buf(), + vec![], + false, + pool, + false, + Arc::new(MockStorage::new(None, preprocessor_cache_mode)), + CacheControl::Default, + ) + .wait() + .unwrap() + .key + }; + + temp_env::with_var("SCCACHE_CACHE_MULTIARCH", Some("1"), || { + let fat = ovec![ + "-arch", "x86_64", "-arch", "arm64", "-c", "foo.c", "-o", "foo.o" + ]; + assert_eq!( + key(&fat, "x86_64 code\n", "arm64 code\n"), + key(&fat, "x86_64 code\n", "arm64 code\n") + ); + assert_ne!( + key(&fat, "x86_64 code\n", "arm64 code\n"), + key(&fat, "x86_64 code\n", "arm64 code, changed\n") + ); + assert_ne!( + key(&fat, "x86_64 code\n", "arm64 code\n"), + key(&fat, "x86_64 code, changed\n", "arm64 code\n") + ); + }); + } + #[test_case(true ; "with preprocessor cache")] #[test_case(false ; "without preprocessor cache")] fn test_assembler_affects_hash(preprocessor_cache_mode: bool) { diff --git a/src/compiler/gcc.rs b/src/compiler/gcc.rs index c9dd3ce27..463ff8da0 100644 --- a/src/compiler/gcc.rs +++ b/src/compiler/gcc.rs @@ -832,6 +832,7 @@ pub fn language_to_gcc_arg(lang: Language) -> Option<&'static str> { fn preprocess_cmd( cmd: &mut T, parsed_args: &ParsedArguments, + arch_args: &[OsString], cwd: &Path, env_vars: &[(OsString, OsString)], may_dist: bool, @@ -873,35 +874,10 @@ fn preprocess_cmd( } } - // Explicitly rewrite the -arch args to be preprocessor defines of the form - // __arch__ so that they affect the preprocessor output but don't cause - // clang to error. - let rewritten_arch_args = parsed_args - .arch_args - .iter() - .filter(|&arg| arg.ne(ARCH_FLAG)) - .filter_map(|arg| { - arg.to_str() - .map(|arg_string| format!("-D__{}__=1", arg_string).into()) - }) - .collect::>(); - - let mut arch_args_to_use = &rewritten_arch_args; - let mut unique_rewritten = rewritten_arch_args.clone(); - unique_rewritten.sort(); - unique_rewritten.dedup(); - if unique_rewritten.len() <= 1 { - // don't use rewritten arch args if there is only one arch - arch_args_to_use = &parsed_args.arch_args; - } else { - debug!("-arch args before rewrite: {:?}", parsed_args.arch_args); - debug!("-arch args after rewrite: {:?}", arch_args_to_use); - } - cmd.args(&parsed_args.preprocessor_args) .args(&parsed_args.dependency_args) .args(&parsed_args.common_args) - .args(arch_args_to_use); + .args(arch_args); if parsed_args.double_dash_input { cmd.arg("--"); } @@ -929,22 +905,123 @@ where T: CommandCreatorSync, { trace!("preprocess"); - let mut cmd = creator.clone().new_command_sync(executable); - preprocess_cmd( - &mut cmd, - parsed_args, - cwd, - env_vars, - may_dist, - kind, - rewrite_includes_only, - ignorable_whitespace_flags, - language_to_arg, - ); - if log_enabled!(Trace) { - trace!("preprocess: {:?}", cmd); + let run_pass = |parsed_args: &ParsedArguments, arch_args: &[OsString], may_dist: bool| { + let mut cmd = creator.clone().new_command_sync(executable); + preprocess_cmd( + &mut cmd, + parsed_args, + arch_args, + cwd, + env_vars, + may_dist, + kind.clone(), + rewrite_includes_only, + ignorable_whitespace_flags.clone(), + &language_to_arg, + ); + if log_enabled!(Trace) { + trace!("preprocess: {:?}", cmd); + } + run_input_output(cmd, None) + }; + if !parsed_args.is_multiarch() { + return run_pass(parsed_args, &parsed_args.arch_args, may_dist).await; + } + + // Like ccache, one pass per architecture so each output sees the macros that + // target defines. No dist line markers: multi-arch is never distributed. + // The passes run concurrently and would all write the same depfile and + // diagnostics, so only the last one does, which is also what clang leaves + // behind for a multi-arch compilation. + let archs = parsed_args.archs(); + let (last, others) = archs + .split_last() + .expect("a multi-arch compilation has several architectures"); + let writing_no_file = ParsedArguments { + dependency_args: vec![], + common_args: without_output_files(&parsed_args.common_args), + preprocessor_args: without_output_files(&parsed_args.preprocessor_args), + ..parsed_args.clone() + }; + let passes = futures::future::join_all( + others + .iter() + .map(|arch| { + run_pass( + &writing_no_file, + &[ARCH_FLAG.into(), (*arch).clone()], + false, + ) + }) + .chain(std::iter::once(run_pass( + parsed_args, + &[ARCH_FLAG.into(), (*last).clone()], + false, + ))), + ) + .await; + let mut output = process::Output { + status: Default::default(), + stdout: vec![], + stderr: vec![], + }; + let mut failed = None; + for (arch, pass) in archs.iter().zip(passes) { + let mut pass = match pass { + Ok(pass) => pass, + Err(e) => match e.downcast::() { + Ok(ProcessError(mut pass)) => { + output.stderr.append(&mut pass.stderr); + failed.get_or_insert(pass); + continue; + } + Err(e) => return Err(e), + }, + }; + if pass.stdout.last() != Some(&b'\n') { + pass.stdout.push(b'\n'); + } + // Counting lines rather than bytes keeps the boundaries unambiguous + // without depending on the basedirs stripped from the paths later. + let lines = pass.stdout.iter().filter(|&&b| b == b'\n').count(); + output.stdout.extend_from_slice( + format!("#pragma sccache arch {} {lines}\n", arch.to_string_lossy()).as_bytes(), + ); + output.stdout.append(&mut pass.stdout); + output.status = pass.status; + output.stderr.append(&mut pass.stderr); + } + if let Some(mut failed) = failed { + failed.stderr = output.stderr; + return Err(ProcessError(failed).into()); + } + Ok(output) +} + +/// Drops the arguments that make the preprocessor write a file. +fn without_output_files(args: &[OsString]) -> Vec { + let writes_file = |arg: &OsString| arg.to_str().is_some_and(|arg| arg.starts_with("-M")); + let mut kept = vec![]; + let mut args = args.iter().peekable(); + while let Some(arg) = args.next() { + if arg == "--serialize-diagnostics" { + args.next(); + } else if arg == "-Xpreprocessor" && args.peek().is_some_and(|next| writes_file(next)) { + let flag = args.next().expect("peeked"); + if ["-MF", "-MT", "-MQ"].iter().any(|f| flag == f) + && args.next_if(|next| *next == "-Xpreprocessor").is_some() + { + args.next(); + } + } else if !arg + .to_str() + .and_then(|arg| arg.strip_prefix("-Wp,")) + .is_some_and(|wp| wp.split(',').any(|part| part.starts_with("-M"))) + { + kept.push(arg.clone()); + } } - run_input_output(cmd, None).await + kept } #[allow(clippy::too_many_arguments)] @@ -1227,6 +1304,7 @@ mod test { use fs::File; use itertools::assert_equal; use std::io::Write; + use std::sync::{Arc, Mutex}; use super::*; use crate::compiler::*; @@ -2034,76 +2112,272 @@ mod test { assert!(!args.common_args.contains(&"-fdiagnostics-color".into())); } - #[test] - fn test_preprocess_cmd_rewrites_archs() { - with_var("SCCACHE_CACHE_MULTIARCH", Some("1"), || { - let args = stringvec!["-arch", "arm64", "-arch", "i386", "-c", "foo.cc"]; - let parsed_args = match parse_arguments_(args, false) { - CompilerArguments::Ok(args) => args, - o => panic!("Got unexpected parse result: {:?}", o), - }; - let mut cmd = MockCommand { - child: None, - args: vec![], - }; - preprocess_cmd( - &mut cmd, - &parsed_args, - Path::new(""), - &[], - true, - CCompilerKind::Gcc, - true, - vec![], - language_to_gcc_arg, - ); - // make sure the architectures were rewritten to prepocessor defines - let expected_args = ovec![ - "-x", - "c++", - "-E", - "-fdirectives-only", - "-D__arm64__=1", - "-D__i386__=1", - "foo.cc" - ]; - assert_eq!(cmd.args, expected_args); - }); + fn preprocess_archs( + archs: &[&str], + answer: A, + ) -> (Result, Vec>) + where + A: Fn(&[OsString]) -> Result + Clone + Send + 'static, + { + preprocess_archs_with(archs, &[], answer) } - #[test] - fn test_preprocess_cmd_doesnt_rewrite_single_arch() { - let args = stringvec!["-arch", "arm64", "-c", "foo.cc"]; - let parsed_args = match parse_arguments_(args, false) { - CompilerArguments::Ok(args) => args, - o => panic!("Got unexpected parse result: {:?}", o), - }; - let mut cmd = MockCommand { - child: None, - args: vec![], - }; - preprocess_cmd( - &mut cmd, + fn preprocess_archs_with( + archs: &[&str], + extra_args: &[&str], + answer: A, + ) -> (Result, Vec>) + where + A: Fn(&[OsString]) -> Result + Clone + Send + 'static, + { + let mut args = archs + .iter() + .flat_map(|arch| ["-arch".to_owned(), arch.to_string()]) + .collect::>(); + args.extend(stringvec!["-c", "foo.c"]); + args.extend(extra_args.iter().map(|arg| arg.to_string())); + let parsed_args = with_var( + "SCCACHE_CACHE_MULTIARCH", + Some("1"), + || match parse_arguments_(args, false) { + CompilerArguments::Ok(args) => args, + o => panic!("Got unexpected parse result: {:?}", o), + }, + ); + let creator = new_creator(); + let seen = Arc::new(Mutex::new(vec![])); + for _ in 0..parsed_args.archs().len().max(1) { + let seen = seen.clone(); + let answer = answer.clone(); + next_command_calls(&creator, move |args| { + seen.lock().unwrap().push(args.to_vec()); + answer(args) + }); + } + let output = single_threaded_runtime().block_on(preprocess( + &creator, + Path::new("gcc"), &parsed_args, Path::new(""), &[], true, CCompilerKind::Gcc, true, - vec![], + vec!["-P".to_owned()], language_to_gcc_arg, + )); + let seen = seen.lock().unwrap().clone(); + (output, seen) + } + + fn answer_arch(args: &[OsString]) -> Result { + let arch = args + .iter() + .skip_while(|a| *a != "-arch") + .nth(1) + .map(|a| a.to_string_lossy().into_owned()) + .unwrap_or_default(); + Ok(MockChild::new(exit_status(0), format!("out {arch}\n"), "")) + } + + #[test] + fn test_preprocess_passes_per_arch() { + let single = |arch_args: Vec| { + let mut args = ovec!["-x", "c", "-E", "-fdirectives-only"]; + args.extend(arch_args); + args.push("foo.c".into()); + args + }; + let multi = |arch: &str| { + ovec![ + "-x", + "c", + "-E", + "-P", + "-fdirectives-only", + "-arch", + arch, + "foo.c" + ] + }; + let cases: &[(&[&str], Vec>)] = &[ + (&[], vec![single(vec![])]), + (&["arm64"], vec![single(ovec!["-arch", "arm64"])]), + ( + &["arm64", "arm64"], + vec![single(ovec!["-arch", "arm64", "-arch", "arm64"])], + ), + (&["x86_64", "arm64"], vec![multi("x86_64"), multi("arm64")]), + ( + &["x86_64", "arm64", "x86_64"], + vec![multi("x86_64"), multi("arm64")], + ), + ( + &["x86_64", "arm64", "arm64e"], + vec![multi("x86_64"), multi("arm64"), multi("arm64e")], + ), + ]; + for (archs, expected) in cases { + let (output, passes) = preprocess_archs(archs, answer_arch); + output.unwrap(); + assert_eq!(&passes, expected, "-arch {:?}", archs); + } + } + + #[test] + fn test_preprocess_output_per_arch() { + let (output, _) = preprocess_archs(&["arm64"], answer_arch); + assert_eq!(output.unwrap().stdout, b"out arm64\n"); + + let (output, _) = preprocess_archs(&["x86_64", "arm64"], |args: &[OsString]| { + let (arch_output, warning) = if args.iter().any(|a| a == "arm64") { + ("out arm64, without final newline", "arm64 warning\n") + } else { + ("out x86_64\n", "x86_64 warning\n") + }; + Ok(MockChild::new(exit_status(0), arch_output, warning)) + }); + let output = output.unwrap(); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "#pragma sccache arch x86_64 1\nout x86_64\n\ + #pragma sccache arch arm64 1\nout arm64, without final newline\n" ); - // make sure the architectures were rewritten to prepocessor defines - let expected_args = ovec![ - "-x", - "c++", - "-E", - "-fdirectives-only", - "-arch", - "arm64", - "foo.cc" + assert_eq!(output.stderr, b"x86_64 warning\narm64 warning\n"); + } + + #[test] + fn test_preprocess_multiarch_output_independent_of_basedir() { + let stripped = |basedir: &'static str| { + let (output, _) = preprocess_archs(&["x86_64", "arm64"], move |_: &[OsString]| { + let marker = format!("# 1 \"{basedir}src/foo.c\"\n"); + Ok(MockChild::new(exit_status(0), marker, "")) + }); + let output = output.unwrap().stdout; + crate::util::strip_basedirs(&output, &[basedir.as_bytes().to_vec()]).into_owned() + }; + assert_eq!(stripped("/a/"), stripped("/b/longer/checkout/")); + } + + #[test] + fn test_preprocess_multiarch_writes_output_files_once() { + let file_args = [ + "-MD", + "-MF", + "foo.d", + "-MT", + "foo.o", + "--serialize-diagnostics", + "foo.dia", + "-Wp,-MMD,bar.d", + "-Xpreprocessor", + "-MF", + "-Xpreprocessor", + "baz.d", ]; - assert_eq!(cmd.args, expected_args); + let (output, passes) = + preprocess_archs_with(&["x86_64", "arm64", "arm64e"], &file_args, answer_arch); + output.unwrap(); + let (last, others) = passes.split_last().unwrap(); + for pass in others { + for arg in pass { + assert!( + !file_args.iter().any(|file_arg| arg == file_arg), + "{arg:?} in {pass:?}" + ); + } + } + for file_arg in ["-MD", "-MF", "foo.d", "-Wp,-MMD,bar.d", "baz.d"] { + assert!( + last.iter().any(|arg| arg == file_arg), + "{file_arg} in {last:?}" + ); + } + } + + #[test] + fn test_without_output_files() { + assert_eq!( + without_output_files(&ovec![ + "-DFOO", + "--serialize-diagnostics", + "foo.dia", + "-Wp,-MD,foo.d", + "-Wp,-DBAR", + "-Xpreprocessor", + "-MF", + "-Xpreprocessor", + "foo.d", + "-Xpreprocessor", + "-MP", + "-Xpreprocessor", + "-DBAZ", + "-Wall" + ]), + ovec!["-DFOO", "-Wp,-DBAR", "-Xpreprocessor", "-DBAZ", "-Wall"] + ); + } + + #[test] + fn test_preprocess_failing_arch_pass() { + let archs = ["x86_64", "arm64", "arm64e"]; + let (output, passes) = preprocess_archs(&archs, |args: &[OsString]| { + if args.iter().any(|a| a == "arm64") { + Ok(MockChild::new(exit_status(1), "partial", "arm64 error\n")) + } else { + let arch = if args.iter().any(|a| a == "arm64e") { + "arm64e" + } else { + "x86_64" + }; + Ok(MockChild::new( + exit_status(0), + format!("out {arch}\n"), + format!("{arch} warning\n"), + )) + } + }); + assert_eq!(passes.len(), 3); + let ProcessError(output) = output.unwrap_err().downcast::().unwrap(); + assert_eq!(output.status, exit_status(1)); + assert_eq!( + output.stderr, + b"x86_64 warning\narm64 error\narm64e warning\n" + ); + } + + #[test] + fn test_parsed_archs() { + let archs = |archs: &[&str]| { + let mut args = archs + .iter() + .flat_map(|arch| ["-arch".to_owned(), arch.to_string()]) + .collect::>(); + args.extend(stringvec!["-c", "foo.c"]); + let parsed_args = match parse_arguments_(args, false) { + CompilerArguments::Ok(args) => args, + o => panic!("Got unexpected parse result: {:?}", o), + }; + let archs = parsed_args + .archs() + .into_iter() + .map(|arch| arch.to_str().unwrap().to_owned()) + .collect::>(); + (archs, parsed_args.is_multiarch()) + }; + with_var("SCCACHE_CACHE_MULTIARCH", Some("1"), || { + assert_eq!(archs(&[]), (vec![], false)); + assert_eq!(archs(&["arm64"]), (stringvec!["arm64"], false)); + assert_eq!(archs(&["arm64", "arm64"]), (stringvec!["arm64"], false)); + assert_eq!( + archs(&["x86_64", "arm64", "x86_64"]), + (stringvec!["x86_64", "arm64"], true) + ); + assert_eq!( + archs(&["x86_64", "arm64", "arm64e"]), + (stringvec!["x86_64", "arm64", "arm64e"], true) + ); + }); } #[test] @@ -2120,6 +2394,7 @@ mod test { preprocess_cmd( &mut cmd, &parsed_args, + &parsed_args.arch_args, Path::new(""), &[], true, @@ -2146,6 +2421,7 @@ mod test { preprocess_cmd( &mut cmd, &parsed_args, + &parsed_args.arch_args, Path::new(""), &[], true, @@ -2172,6 +2448,7 @@ mod test { preprocess_cmd( &mut cmd, &parsed_args, + &parsed_args.arch_args, Path::new(""), &[], true, @@ -2198,6 +2475,7 @@ mod test { preprocess_cmd( &mut cmd, &parsed_args, + &parsed_args.arch_args, Path::new(""), &[], true, @@ -2946,6 +3224,7 @@ mod test { preprocess_cmd( &mut cmd, &parsed_args, + &parsed_args.arch_args, Path::new(""), &[], true, @@ -2971,6 +3250,7 @@ mod test { preprocess_cmd( &mut cmd, &parsed_args, + &parsed_args.arch_args, Path::new(""), &[], true, @@ -2996,6 +3276,7 @@ mod test { preprocess_cmd( &mut cmd, &parsed_args, + &parsed_args.arch_args, Path::new(""), &[], true, From 53ee3c8189f6ac7ec0bab1c40c2ab1ca462b910a Mon Sep 17 00:00:00 2001 From: Francois Girinon Date: Thu, 24 Sep 2026 14:12:17 +0200 Subject: [PATCH 3/5] preprocessor cache: don't reuse single-pass multi-arch entries Preprocessor cache entries recorded before multi-arch compilations were preprocessed once per -arch point at object keys computed from the old, incomplete preprocessor output, and their include files alone can't tell them apart. Mix a marker into the entry key of multi-arch compilations so those entries are no longer found. Bumping FORMAT_VERSION would have done the same, but would also have thrown away the entries of every single-arch compilation, which are still valid. --- src/compiler/c.rs | 1 + src/compiler/preprocessor_cache.rs | 45 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/compiler/c.rs b/src/compiler/c.rs index f36aab17f..079a8dbef 100644 --- a/src/compiler/c.rs +++ b/src/compiler/c.rs @@ -486,6 +486,7 @@ where &env_vars, &absolute_input_path, self.compiler.plusplus(), + self.parsed_args.is_multiarch(), preprocessor_cache_mode_config, storage.basedirs(), )? diff --git a/src/compiler/preprocessor_cache.rs b/src/compiler/preprocessor_cache.rs index 5642100ef..025791a86 100644 --- a/src/compiler/preprocessor_cache.rs +++ b/src/compiler/preprocessor_cache.rs @@ -386,6 +386,7 @@ pub fn preprocessor_cache_entry_hash_key( env_vars: &[(OsString, OsString)], input_file: &Path, plusplus: bool, + multiarch: bool, config: PreprocessorCacheModeConfig, basedirs: &[Vec], ) -> anyhow::Result> { @@ -397,6 +398,11 @@ pub fn preprocessor_cache_entry_hash_key( m.update(&[plusplus as u8]); m.update(&[FORMAT_VERSION]); m.update(language.as_str().as_bytes()); + // Multi-arch compilations used to be preprocessed in a single pass that + // missed the code only one architecture sees: don't reuse those entries. + if multiarch { + m.update(b"multiarch-per-pass"); + } hash_arguments(&mut m, arguments, basedirs); for hash in extra_hashes { m.update(hash.as_bytes()); @@ -738,6 +744,7 @@ mod test { &[], &file1_path, false, + false, config, &dirs, ) @@ -753,6 +760,7 @@ mod test { &[], &file2_path, false, + false, config, &dirs, ) @@ -774,6 +782,7 @@ mod test { &[], &file1_path, false, + false, config, &dirs[..1], ) @@ -789,6 +798,7 @@ mod test { &[], &file2_path, false, + false, config, &dirs[1..], ) @@ -810,6 +820,7 @@ mod test { &[], &file1_path, false, + false, config, &[], ) @@ -825,6 +836,7 @@ mod test { &[], &file2_path, false, + false, config, &[], ) @@ -836,4 +848,37 @@ mod test { "Hashes should be different without basedirs for files in different directories" ); } + + #[test] + fn test_preprocessor_cache_entry_hash_key_multiarch() { + use tempfile::TempDir; + + let dir = TempDir::new().unwrap(); + let file = dir.path().join("test.c"); + std::fs::write(&file, b"int main() { return 0; }").unwrap(); + let basedir = dir.path().to_string_lossy().into_owned().into_bytes(); + #[cfg(target_os = "windows")] + let basedir = crate::util::normalize_win_path(&basedir); + let key = |arguments: &[&str], multiarch: bool| { + let arguments = arguments.iter().map(OsString::from).collect::>(); + preprocessor_cache_entry_hash_key( + "test_digest", + Language::C, + &arguments, + &[], + None, + &[], + &file, + false, + multiarch, + PreprocessorCacheModeConfig::activated(), + std::slice::from_ref(&basedir), + ) + .unwrap() + .unwrap() + }; + + let fat = ["-arch", "x86_64", "-arch", "arm64"]; + assert_ne!(key(&fat, true), key(&fat, false)); + } } From b033908ac045cb7def3f87d920c458dd12014bd4 Mon Sep 17 00:00:00 2001 From: Francois Girinon Date: Thu, 24 Sep 2026 14:16:05 +0200 Subject: [PATCH 4/5] tests: check multi-arch caching against each slice on macOS Build universal objects with -arch x86_64 -arch arm64 and check that editing code, or a header, that only one architecture sees is a cache miss and lands in the right slice of the object, with and without the preprocessor cache. Also check that the dependency file matches clang's: clang writes it once per architecture, so it lists the include files of the last -arch only, and sccache must hand back the same file on a miss and on a hit. --- tests/system.rs | 208 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) diff --git a/tests/system.rs b/tests/system.rs index e654f0c42..f1d66cd96 100644 --- a/tests/system.rs +++ b/tests/system.rs @@ -2083,6 +2083,214 @@ fn test_assembler_affects_cache(preprocessor_cache_mode: bool) { stop_local_daemon(); } +#[cfg(target_os = "macos")] +fn start_multiarch_server(tempdir: &Path, preprocessor_cache_mode: bool) -> Option { + let compiler = match find_compilers().into_iter().find(|c| c.name == "clang") { + Some(compiler) => compiler, + None => { + warn!("No clang found, skipping test"); + return None; + } + }; + + stop_local_daemon(); + let sccache_cfg = sccache_client_cfg(tempdir, preprocessor_cache_mode); + write_json_cfg(tempdir, "sccache-cfg.json", &sccache_cfg); + // `sccache_command` strips SCCACHE_* variables, so opt the server into + // multi-arch caching explicitly. + assert!( + sccache_command() + .arg("--start-server") + .env("SCCACHE_CONF", tempdir.join("sccache-cfg.json")) + .env("SCCACHE_CACHED_CONF", tempdir.join("sccache-cached-cfg")) + .env("SCCACHE_CACHE_MULTIARCH", "1") + .status() + .unwrap() + .success() + ); + Some(compiler) +} + +/// Writes a source file dated in the past, so that the preprocessor cache +/// doesn't consider it too recent to be trusted. +#[cfg(target_os = "macos")] +fn write_past_source(tempdir: &Path, filename: &str, contents: &str) { + write_source(tempdir, filename, contents); + let past = filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(10)); + filetime::set_file_times(tempdir.join(filename), past, past).unwrap(); +} + +#[cfg(target_os = "macos")] +fn compile_multiarch( + tempdir: &Path, + compiler: &Compiler, + args: &[OsString], + expected_hits: u64, + expected_misses: u64, +) { + fs::remove_file(tempdir.join(OUTPUT)).ok(); + sccache_command() + .args(args) + .current_dir(tempdir) + .envs(compiler.env_vars.clone()) + .assert() + .success(); + get_stats(move |info| { + assert_eq!(expected_hits, info.stats.cache_hits.all()); + assert_eq!(expected_misses, info.stats.cache_misses.all()); + }); +} + +#[cfg(target_os = "macos")] +fn slice_symbols(object: &Path, arch: &str) -> String { + let output = Command::new("nm") + .args(["-arch", arch]) + .arg(object) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() +} + +#[cfg(target_os = "macos")] +const MULTIARCH_INCLUDING_SRC: &str = "#ifdef __SSE2__\n#include \"sse2_impl.h\"\n#endif\n\ + #ifdef __ARM_NEON\n#include \"neon_impl.h\"\n#endif\n\ + int common(void) { return 0; }\n"; + +/// Code guarded by a macro only one target defines (`__SSE2__`, `__ARM_NEON`) +/// must reach the key, or editing it hands back a stale slice. In a header, +/// every pass's include files must be tracked by the preprocessor cache too. +#[test_case(true, false ; "with preprocessor cache, code in the source")] +#[test_case(false, false ; "without preprocessor cache, code in the source")] +#[test_case(true, true ; "with preprocessor cache, code in headers")] +#[test_case(false, true ; "without preprocessor cache, code in headers")] +#[serial] +#[cfg(target_os = "macos")] +fn test_multiarch_slice_specific_code_affects_cache( + preprocessor_cache_mode: bool, + in_headers: bool, +) { + let _ = env_logger::try_init(); + let tempdir = tempfile::Builder::new() + .prefix("sccache_system_test") + .tempdir() + .unwrap(); + let Some(compiler) = start_multiarch_server(tempdir.path(), preprocessor_cache_mode) else { + return; + }; + + const SRC: &str = "multiarch.c"; + let write_slices = |sse2_symbol: &str, neon_symbol: &str| { + let sse2 = format!("int {sse2_symbol}(void) {{ return 1; }}\n"); + let neon = format!("int {neon_symbol}(void) {{ return 2; }}\n"); + if in_headers { + write_past_source(tempdir.path(), SRC, MULTIARCH_INCLUDING_SRC); + write_past_source(tempdir.path(), "sse2_impl.h", &sse2); + write_past_source(tempdir.path(), "neon_impl.h", &neon); + } else { + let source = format!( + "#ifdef __SSE2__\n{sse2}#endif\n#ifdef __ARM_NEON\n{neon}#endif\n\ + int common(void) {{ return 0; }}\n" + ); + write_past_source(tempdir.path(), SRC, &source); + } + }; + let args = compile_cmdline( + compiler.name, + &compiler.exe, + SRC, + OUTPUT, + vec_from!(OsString, "-arch", "x86_64", "-arch", "arm64"), + ); + let compile = |sse2_symbol: &str, neon_symbol: &str, expected_hits, expected_misses| { + write_slices(sse2_symbol, neon_symbol); + compile_multiarch( + tempdir.path(), + &compiler, + &args, + expected_hits, + expected_misses, + ); + }; + let object = tempdir.path().join(OUTPUT); + + zero_stats(); + compile("sse2_v1", "neon_v1", 0, 1); + compile("sse2_v1", "neon_v1", 1, 1); + compile("sse2_v2", "neon_v1", 1, 2); + assert!(slice_symbols(&object, "x86_64").contains("_sse2_v2")); + assert!(slice_symbols(&object, "arm64").contains("_neon_v1")); + compile("sse2_v2", "neon_v2", 1, 3); + assert!(slice_symbols(&object, "x86_64").contains("_sse2_v2")); + assert!(slice_symbols(&object, "arm64").contains("_neon_v2")); + stop_local_daemon(); +} + +/// clang's per-arch jobs all write the same depfile, so it lists the last +/// `-arch`'s includes only; sccache must match it on a miss and on a hit. +#[test_case(true ; "with preprocessor cache")] +#[test_case(false ; "without preprocessor cache")] +#[serial] +#[cfg(target_os = "macos")] +fn test_multiarch_depfile_matches_compiler(preprocessor_cache_mode: bool) { + let _ = env_logger::try_init(); + let tempdir = tempfile::Builder::new() + .prefix("sccache_system_test") + .tempdir() + .unwrap(); + let Some(compiler) = start_multiarch_server(tempdir.path(), preprocessor_cache_mode) else { + return; + }; + + const SRC: &str = "multiarch.c"; + const DEPFILE: &str = "multiarch.d"; + write_past_source(tempdir.path(), SRC, MULTIARCH_INCLUDING_SRC); + write_past_source(tempdir.path(), "sse2_impl.h", "int sse2(void);\n"); + write_past_source(tempdir.path(), "neon_impl.h", "int neon(void);\n"); + let depfile = tempdir.path().join(DEPFILE); + + zero_stats(); + let mut expected_misses = 0; + for archs in [["x86_64", "arm64"], ["arm64", "x86_64"]] { + let args = compile_cmdline( + compiler.name, + &compiler.exe, + SRC, + OUTPUT, + vec_from!( + OsString, "-arch", archs[0], "-arch", archs[1], "-MD", "-MF", DEPFILE + ), + ); + let status = Command::new(&args[0]) + .args(&args[1..]) + .current_dir(tempdir.path()) + .envs(compiler.env_vars.clone()) + .status() + .unwrap(); + assert!(status.success()); + let expected = fs::read_to_string(&depfile).unwrap(); + let (last, other) = match archs[1] { + "arm64" => ("neon_impl.h", "sse2_impl.h"), + _ => ("sse2_impl.h", "neon_impl.h"), + }; + assert!(expected.contains(last) && !expected.contains(other)); + + expected_misses += 1; + for expected_hits in [expected_misses - 1, expected_misses] { + fs::remove_file(&depfile).unwrap(); + compile_multiarch( + tempdir.path(), + &compiler, + &args, + expected_hits, + expected_misses, + ); + assert_eq!(fs::read_to_string(&depfile).unwrap(), expected); + } + } + stop_local_daemon(); +} + #[test] #[serial] fn test_stats_no_server() { From d78784fdbba231fb954bfdba7dab6b31fc9951c3 Mon Sep 17 00:00:00 2001 From: Francois Girinon Date: Thu, 24 Sep 2026 14:17:07 +0200 Subject: [PATCH 5/5] docs: SCCACHE_CACHE_MULTIARCH enables multi-arch caching It was described as disabling it. Setting it to any value, even 0, enables caching of compilations with several different -arch flags. --- docs/Configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index bac3b52fd..34122c772 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -194,7 +194,7 @@ Note that some env variables may need sccache server restart to take effect. * `SCCACHE_NO_DAEMON` set to `1` to disable putting the server to the background * `SCCACHE_CLIENT_SIDE` set to `1` to run the compile in the client process and use the daemon only as a gateway to the cache storage (see [the architecture doc](Architecture.md#client-side-mode-sccache_client_side)). This is the recommended mode and is expected to become the only supported configuration in the future. Ignored when `SCCACHE_ERROR_LOG` or distributed compilation is in use. * `SCCACHE_SKIP_CACHE_CHECK` set to `true`, `on`, or `1` to skip remote cache capability checks. The configured backend `rw_mode` is used without reading or writing `.sccache_check`. The user is responsible for ensuring that the cache is reachable and grants the configured access. -* `SCCACHE_CACHE_MULTIARCH` to disable caching of multi architecture builds. +* `SCCACHE_CACHE_MULTIARCH` set (to any value, even `0`) to enable caching of builds with several different `-arch` flags, which are not cached by default. See [the caching doc](Caching.md#cc-compiler). * `SCCACHE_CACHE_ZSTD_LEVEL` to set zstd compression level of cache. the range is `1-22` and default is `3`. - For example, in `10`, it have about 0.9x size with about 1.6x time than default `3` (tested with compiling sccache code) - This option will only applied to newly compressed cache and don't affect existing cache.