Skip to content

Commit fd1b6e8

Browse files
authored
perf: Improve transitive dependency resolution cache sharing across workspaces (#11903)
## Summary The `ResolveCache` used during pnpm lockfile transitive closure computation keyed entries by `(workspace_path, package_name, specifier)`. This meant two workspaces resolving the same transitive sub-dependency (e.g. `lodash@4.17.21`) would each miss the cache and resolve independently — the workspace path made the keys differ. For a monorepo with N workspaces sharing a common dependency tree, this caused O(N*D) resolution work instead of O(N+D). The fix splits cache key strategy by resolution phase: - **Direct workspace deps** (root level): include `workspace_path` in the key, since `resolve_specifier` needs the workspace's importer entry. - **Transitive sub-deps** (recursive): omit `workspace_path`, since `resolve_package` resolves these by direct lockfile key lookup, independent of which workspace started the traversal. Also removes `#[tracing::instrument]` from `resolve_package` (~332k calls) and `all_dependencies` (~329k calls). At this call volume, span creation/entry/exit overhead is significant — `all_dependencies` is a single HashMap lookup where the tracing cost exceeded the function body. ## Benchmarks All benchmarks are `turbo run <task> --skip-infer --no-daemon --dry` with `--warmup 5`. **Repo A — ~1000 packages (pnpm)** | | Before | After | Speedup | |---|---|---|---| | Wall clock | 4.210s ± 0.087s | 2.165s ± 0.034s | **1.94x** | | User time | 29.624s | 3.705s | **8.0x** (CPU work) | **Repo B — ~100 packages (pnpm)** | | Before | After | Speedup | |---|---|---|---| | Wall clock | 1.376s ± 0.116s | 1.323s ± 0.092s | 1.04x | | User time | 3.522s | 0.801s | **4.4x** (CPU work) | **Repo C — ~6 packages (pnpm)** | | Before | After | Speedup | |---|---|---|---| | Wall clock | 694.6ms ± 85.4ms | 669.5ms ± 37.0ms | ~1.0x (within noise) | The improvement scales with the number of workspaces sharing transitive dependencies, which is why the 1000-package repo sees the largest gain. Repo B shows a dramatic CPU reduction (4.4x) that translates to a modest wall-clock improvement because the old code hid redundant work behind rayon parallelism, and other costs (globwalk, git operations) now dominate.
1 parent 57cf69c commit fd1b6e8

File tree

2 files changed

+90
-81
lines changed

2 files changed

+90
-81
lines changed

crates/turborepo-lockfiles/src/lib.rs

Lines changed: 90 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -172,107 +172,118 @@ fn transitive_closure_cached<L: Lockfile + ?Sized>(
172172
ignore_missing_packages: bool,
173173
resolve_cache: &ResolveCache,
174174
) -> Result<HashSet<Package>, Error> {
175-
let mut transitive_deps = HashSet::new();
176-
let mut key_buf = String::new();
177-
transitive_closure_helper(
175+
let mut ctx = ClosureContext {
178176
lockfile,
179177
workspace_path,
178+
resolve_cache,
179+
key_buf: String::new(),
180+
};
181+
let mut transitive_deps = HashSet::new();
182+
ctx.walk(
180183
&unresolved_deps,
181184
&mut transitive_deps,
182185
ignore_missing_packages,
183-
resolve_cache,
184-
&mut key_buf,
186+
true,
185187
)?;
186-
187188
Ok(transitive_deps)
188189
}
189190

190-
fn make_cache_key(buf: &mut String, workspace_path: &str, name: &str, specifier: &str) {
191-
buf.clear();
192-
buf.reserve(workspace_path.len() + name.len() + specifier.len() + 2);
193-
buf.push_str(workspace_path);
194-
buf.push('\0');
195-
buf.push_str(name);
196-
buf.push('\0');
197-
buf.push_str(specifier);
191+
struct ClosureContext<'a, L: Lockfile + ?Sized> {
192+
lockfile: &'a L,
193+
workspace_path: &'a str,
194+
resolve_cache: &'a ResolveCache,
195+
key_buf: String,
198196
}
199197

200-
fn resolve_deps<L: Lockfile + ?Sized>(
201-
lockfile: &L,
202-
workspace_path: &str,
203-
unresolved_deps: &HashMap<String, String>,
204-
ignore_missing_packages: bool,
205-
resolve_cache: &ResolveCache,
206-
key_buf: &mut String,
207-
) -> Result<Vec<Package>, Error> {
208-
let mut newly_resolved = Vec::new();
198+
impl<L: Lockfile + ?Sized> ClosureContext<'_, L> {
199+
fn make_cache_key(&mut self, workspace_path: Option<&str>, name: &str, specifier: &str) {
200+
self.key_buf.clear();
201+
if let Some(wp) = workspace_path {
202+
self.key_buf
203+
.reserve(wp.len() + name.len() + specifier.len() + 2);
204+
self.key_buf.push_str(wp);
205+
self.key_buf.push('\0');
206+
} else {
207+
self.key_buf.reserve(name.len() + specifier.len() + 1);
208+
}
209+
self.key_buf.push_str(name);
210+
self.key_buf.push('\0');
211+
self.key_buf.push_str(specifier);
212+
}
209213

210-
for (name, specifier) in unresolved_deps {
211-
make_cache_key(key_buf, workspace_path, name, specifier);
214+
fn resolve_deps(
215+
&mut self,
216+
unresolved_deps: &HashMap<String, String>,
217+
ignore_missing_packages: bool,
218+
is_workspace_root_deps: bool,
219+
) -> Result<Vec<Package>, Error> {
220+
let mut newly_resolved = Vec::new();
212221

213-
let pkg = match resolve_cache.get(key_buf.as_str()) {
214-
Some(cached) => cached.clone(),
215-
None => {
216-
let result = match lockfile.resolve_package(workspace_path, name, specifier) {
217-
Ok(pkg) => pkg,
218-
Err(Error::MissingWorkspace(_)) if ignore_missing_packages => {
219-
resolve_cache.insert(key_buf.clone(), None);
220-
continue;
221-
}
222-
Err(e) => return Err(e),
223-
};
224-
resolve_cache.insert(key_buf.clone(), result.clone());
225-
result
226-
}
227-
};
222+
for (name, specifier) in unresolved_deps {
223+
// For direct workspace dependencies, include workspace_path in the cache key
224+
// since resolution depends on the workspace's importer entry.
225+
// For transitive sub-dependencies, the resolution is workspace-independent
226+
// (the version is already a resolved lockfile key), so we omit workspace_path
227+
// to enable cross-workspace cache sharing.
228+
let wp = is_workspace_root_deps.then_some(self.workspace_path);
229+
self.make_cache_key(wp, name, specifier);
228230

229-
if let Some(pkg) = pkg {
230-
newly_resolved.push(pkg);
231+
let pkg = match self.resolve_cache.get(self.key_buf.as_str()) {
232+
Some(cached) => cached.clone(),
233+
None => {
234+
let result =
235+
match self
236+
.lockfile
237+
.resolve_package(self.workspace_path, name, specifier)
238+
{
239+
Ok(pkg) => pkg,
240+
Err(Error::MissingWorkspace(_)) if ignore_missing_packages => {
241+
self.resolve_cache.insert(self.key_buf.clone(), None);
242+
continue;
243+
}
244+
Err(e) => return Err(e),
245+
};
246+
self.resolve_cache
247+
.insert(self.key_buf.clone(), result.clone());
248+
result
249+
}
250+
};
251+
252+
if let Some(pkg) = pkg {
253+
newly_resolved.push(pkg);
254+
}
231255
}
256+
257+
Ok(newly_resolved)
232258
}
233259

234-
Ok(newly_resolved)
235-
}
260+
fn walk(
261+
&mut self,
262+
unresolved_deps: &HashMap<String, String>,
263+
resolved_deps: &mut HashSet<Package>,
264+
ignore_missing_packages: bool,
265+
is_workspace_root_deps: bool,
266+
) -> Result<(), Error> {
267+
let newly_resolved = self.resolve_deps(
268+
unresolved_deps,
269+
ignore_missing_packages,
270+
is_workspace_root_deps,
271+
)?;
236272

237-
fn transitive_closure_helper<L: Lockfile + ?Sized>(
238-
lockfile: &L,
239-
workspace_path: &str,
240-
unresolved_deps: &HashMap<String, String>,
241-
resolved_deps: &mut HashSet<Package>,
242-
ignore_missing_packages: bool,
243-
resolve_cache: &ResolveCache,
244-
key_buf: &mut String,
245-
) -> Result<(), Error> {
246-
let newly_resolved = resolve_deps(
247-
lockfile,
248-
workspace_path,
249-
unresolved_deps,
250-
ignore_missing_packages,
251-
resolve_cache,
252-
key_buf,
253-
)?;
273+
for pkg in newly_resolved {
274+
if resolved_deps.contains(&pkg) {
275+
continue;
276+
}
254277

255-
for pkg in newly_resolved {
256-
if resolved_deps.contains(&pkg) {
257-
continue;
278+
let all_deps = self.lockfile.all_dependencies(&pkg.key)?;
279+
resolved_deps.insert(pkg);
280+
if let Some(deps) = all_deps {
281+
self.walk(&deps, resolved_deps, false, false)?;
282+
}
258283
}
259284

260-
let all_deps = lockfile.all_dependencies(&pkg.key)?;
261-
resolved_deps.insert(pkg);
262-
if let Some(deps) = all_deps {
263-
transitive_closure_helper(
264-
lockfile,
265-
workspace_path,
266-
&deps,
267-
resolved_deps,
268-
false,
269-
resolve_cache,
270-
key_buf,
271-
)?;
272-
}
285+
Ok(())
273286
}
274-
275-
Ok(())
276287
}
277288

278289
impl Package {

crates/turborepo-lockfiles/src/pnpm/data.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,6 @@ struct GlobalFields<'a> {
468468
}
469469

470470
impl crate::Lockfile for PnpmLockfile {
471-
#[tracing::instrument(skip(self))]
472471
fn resolve_package(
473472
&self,
474473
workspace_path: &str,
@@ -520,7 +519,6 @@ impl crate::Lockfile for PnpmLockfile {
520519
}
521520
}
522521

523-
#[tracing::instrument(skip(self))]
524522
fn all_dependencies(
525523
&self,
526524
key: &str,

0 commit comments

Comments
 (0)