From 6925b054b5ed3b35b949aa827993a9e0e78681d8 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 28 Sep 2026 00:37:35 -0700 Subject: [PATCH] perf: memoize successful path canonicalization (~35k realpath calls/build) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache-key construction normalizes every path-bearing compiler flag of every translation unit. `path_arg_for_compile_cwd` canonicalizes *both* the path and the compile cwd on each call, so building the ESP32-S3 framework-core cache key performs, per build: 385 include dirs x 46 core sources x 2 (path + cwd) = ~35,000 realpath(2) The cwd is the same value in all 35,420 calls and the include dirs are the same 385 for every source, so ~386 distinct resolutions are repeated 35,000 times. Measured on bench/blink -e esp32s3 (16-core Linux), this is the bulk of the unaccounted time between the `framework-libs cache` and `framework core cache` log lines, where no perf phase is open: it is cache-state independent and identical on warm and cold builds. `canonicalize_lexical` now memoizes its **successful `realpath` results only**. The parent-plus-filename fallback is deliberately not memoized: that is the branch answering for paths that do not exist yet, and caching it would keep reporting a directory as missing for the rest of the process, so a not-yet-created include dir would never resolve. A memo hit is therefore always a path that existed and was resolved once. The map is capped at 8192 entries so a long-lived daemon cannot grow it without bound. Fixing this at `canonicalize_lexical` rather than at the ESP32 call site also covers the other sites that build signatures the same way — `refresh_command_hashes` and `needs_rebuild_with_signature` run the same per-flag normalization. Measured (3 interleaved cold trials each, median, true cache misses): wall 5.96s -> 4.81s unphased 709ms -> 218ms The saving overlaps PR #1543 (fewer include dirs means fewer realpath calls too): on top of that change it is a further 3.97s -> 3.79s. The core-cache key is unchanged by this commit (`41b106e21c2b` before and after), which is the point: memoizing a pure function must not move the cache. Refs FastLED/fbuild#1537 --- crates/fbuild-core/src/path.rs | 69 +++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/fbuild-core/src/path.rs b/crates/fbuild-core/src/path.rs index b1711c263..02141dcca 100644 --- a/crates/fbuild-core/src/path.rs +++ b/crates/fbuild-core/src/path.rs @@ -407,12 +407,52 @@ pub fn normalize_flags_for_compile_cwd(flags: &[String], cwd: &Path) -> Vec &'static std::sync::RwLock> { + static MEMO: std::sync::OnceLock< + std::sync::RwLock>, + > = std::sync::OnceLock::new(); + MEMO.get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new())) +} + /// Canonicalize an existing path (stripping the Windows `\\?\` prefix), /// falling back to canonicalizing the parent + rejoining the file name when /// the full path does not yet exist. Returns `None` if neither resolves. fn canonicalize_lexical(path: &Path) -> Option { + if let Some(hit) = canonicalize_memo() + .read() + .unwrap_or_else(|e| e.into_inner()) + .get(path) + { + return Some(hit.clone()); + } if let Ok(canonical) = path.canonicalize() { - return Some(strip_unc_prefix(&canonical)); + let resolved = strip_unc_prefix(&canonical); + let mut memo = canonicalize_memo() + .write() + .unwrap_or_else(|e| e.into_inner()); + if memo.len() >= CANONICALIZE_MEMO_CAPACITY { + memo.clear(); + } + memo.insert(path.to_path_buf(), resolved.clone()); + return Some(resolved); } let parent = path.parent()?.canonicalize().ok()?; let joined = match path.file_name() { @@ -834,6 +874,33 @@ mod tests { assert_eq!(arg, "src/sketch/main.cpp"); } + #[test] + fn canonicalize_lexical_memoizes_resolved_paths() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = tmp.path().join("include"); + std::fs::create_dir_all(&dir).unwrap(); + let resolved = dir.canonicalize().unwrap(); + assert_eq!(canonicalize_lexical(&dir), Some(resolved.clone())); + // A second call is served from the memo rather than a fresh realpath. + assert_eq!(canonicalize_lexical(&dir), Some(resolved)); + } + + #[test] + fn canonicalize_lexical_does_not_memoize_the_missing_parent_fallback() { + let tmp = tempfile::TempDir::new().unwrap(); + let missing = tmp.path().join("later"); + // First call takes the parent-plus-filename fallback. + let before = canonicalize_lexical(&missing).unwrap(); + assert!(before.ends_with("later")); + std::fs::create_dir_all(&missing).unwrap(); + // Once the directory exists the real path must win, so the fallback + // result cannot have been memoized. + assert_eq!( + canonicalize_lexical(&missing), + Some(missing.canonicalize().unwrap()) + ); + } + #[test] fn path_arg_for_compile_cwd_returns_dot_for_workspace_root() { let tmp = tempfile::TempDir::new().unwrap();