Skip to content

Commit a0e90cb

Browse files
authored
perf: Reduce CPU overhead in turbo run hot path (#11947)
## Summary Several targeted optimizations to reduce CPU time in `turbo run`'s critical path. These changes reduce allocations, eliminate redundant computation, and improve algorithmic complexity in per-package operations. ### Per-function improvements (absolute self-time) | Function | Repo | Before | After | Change | |---|---|---|---|---| | `calculate_task_hash` | large | 101ms | <1ms | **eliminated from top** (cached) | | `walk_glob` | large | 218ms | 111ms | **-49%** | | `calculate_task_hash` | medium | 26ms | 17ms | **-35%** | | `walk_glob` | medium | 41ms | 29ms | **-30%** | | `to_summary` | small | 7ms | 6ms | -25% | ## Changes - **Binary-search status lookups in `RepoGitIndex`**: Status entries are now sorted at index build time. Per-package filtering uses `partition_point` for O(log N) range queries instead of O(N) linear scans. Matters when both package count and dirty-file count are large. - **Precompute external dependency hashes per-package**: `get_external_deps_hash` sorts transitive dependencies and hashes them. Previously this ran for every task. Now it runs once per package and is cached, since multiple tasks in the same package produce the same hash. `TaskHashable.external_deps_hash` changed from `Option<String>` to `Option<&str>` to avoid cloning from the cache. - **Combined branch+SHA libgit2 lookup**: `to_summary` previously opened the git repo twice (once for branch, once for SHA). Now a single `Repository::open` call retrieves both. - **Reuse buffers in ls-tree and hash-object walks**: The tree walk and hash-object loops now reuse a `String` path buffer and a `[u8; 40]` hex buffer instead of allocating per entry. Uses `hex::encode_to_slice` instead of `git2::Oid::to_string`. ### Wall-clock caveat These CPU improvements don't translate cleanly to wall-clock time in benchmarks because `git status` and `git ls-tree` via libgit2 dominate total runtime (30-80% of profiled duration) and have high I/O variance between runs. The functions we optimized are real work that runs on every `turbo run`, but measuring end-to-end improvement requires many runs to overcome the git I/O noise floor. ## Testing All existing tests pass. Added regression tests for: - Sorted status binary-search correctness (prefix boundary, delete handling, empty prefix) - External deps hash determinism, order-independence, and empty-set behavior - TaskHashTracker pre-sized HashMap behavior
1 parent 037b7ba commit a0e90cb

File tree

9 files changed

+253
-30
lines changed

9 files changed

+253
-30
lines changed

crates/turborepo-hash/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ pub struct TaskHashable<'a> {
6060
pub global_hash: &'a str,
6161
pub task_dependency_hashes: Vec<String>,
6262
pub hash_of_files: &'a str,
63-
pub external_deps_hash: Option<String>,
63+
pub external_deps_hash: Option<&'a str>,
6464

6565
// task
6666
pub package_dir: Option<turbopath::RelativeUnixPathBuf>,
@@ -320,7 +320,7 @@ impl From<TaskHashable<'_>> for Builder<HeapAllocator> {
320320

321321
builder.set_hash_of_files(task_hashable.hash_of_files);
322322
if let Some(external_deps_hash) = task_hashable.external_deps_hash {
323-
builder.set_external_deps_hash(&external_deps_hash);
323+
builder.set_external_deps_hash(external_deps_hash);
324324
}
325325

326326
builder.set_task(task_hashable.task);
@@ -512,7 +512,7 @@ mod test {
512512
task_dependency_hashes: vec!["task_dependency_hash".to_string()],
513513
package_dir: Some(turbopath::RelativeUnixPathBuf::new("package_dir").unwrap()),
514514
hash_of_files: "hash_of_files",
515-
external_deps_hash: Some("external_deps_hash".to_string()),
515+
external_deps_hash: Some("external_deps_hash"),
516516
task: "task",
517517
outputs: TaskOutputs {
518518
inclusions: vec!["inclusions".to_string()],

crates/turborepo-lib/src/task_graph/visitor/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ impl<'a> Visitor<'a> {
138138
is_watch: bool,
139139
micro_frontends_configs: Option<&'a MicrofrontendsConfigs>,
140140
) -> Self {
141-
let task_hasher = TaskHasher::new(
141+
let mut task_hasher = TaskHasher::new(
142142
package_inputs_hashes,
143143
run_opts,
144144
env_at_execution_start,
@@ -147,6 +147,8 @@ impl<'a> Visitor<'a> {
147147
global_env_patterns,
148148
);
149149

150+
task_hasher.precompute_external_deps_hashes(package_graph.packages());
151+
150152
let sink = Self::sink(run_opts);
151153
let color_cache = ColorSelector::default();
152154
// Set up correct size for underlying pty

crates/turborepo-run-summary/src/scm.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ impl SCMState {
4343
}
4444
}
4545

46-
// Fall back to using `git`
46+
// Fall back to using git. Combined call opens the repo once via
47+
// libgit2 instead of spawning two git subprocesses.
4748
if state.branch.is_none() && state.sha.is_none() {
49+
let (branch, sha) = scm.get_current_branch_and_sha(dir);
4850
if state.branch.is_none() {
49-
state.branch = scm.get_current_branch(dir).ok();
51+
state.branch = branch;
5052
}
5153
if state.sha.is_none() {
52-
state.sha = scm.get_current_sha(dir).ok();
54+
state.sha = sha;
5355
}
5456
}
5557

crates/turborepo-scm/src/git.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ impl SCM {
3737
}
3838
}
3939

40+
/// Get both branch and SHA with a single libgit2 Repository::open,
41+
/// avoiding the overhead of opening the repo twice and spawning two
42+
/// git subprocesses. Falls back to subprocess calls if libgit2 fails.
43+
pub fn get_current_branch_and_sha(
44+
&self,
45+
_path: &AbsoluteSystemPath,
46+
) -> (Option<String>, Option<String>) {
47+
match self {
48+
#[cfg(feature = "git2")]
49+
Self::Git(git) => {
50+
if let Some((branch, sha)) = git.get_current_branch_and_sha() {
51+
return (Some(branch), Some(sha));
52+
}
53+
(git.get_current_branch().ok(), git.get_current_sha().ok())
54+
}
55+
#[cfg(not(feature = "git2"))]
56+
Self::Git(git) => (git.get_current_branch().ok(), git.get_current_sha().ok()),
57+
Self::Manual => (None, None),
58+
}
59+
}
60+
4061
/// get the actual changed files between two git refs
4162
pub fn changed_files(
4263
&self,
@@ -214,6 +235,27 @@ impl GitRepo {
214235
Ok(output.trim().to_owned())
215236
}
216237

238+
/// Get branch and SHA in a single libgit2 Repository::open call.
239+
#[cfg(feature = "git2")]
240+
fn get_current_branch_and_sha(&self) -> Option<(String, String)> {
241+
let repo = git2::Repository::open(self.root.as_std_path()).ok()?;
242+
let head = repo.head().ok()?;
243+
244+
let branch = if head.is_branch() {
245+
head.shorthand().unwrap_or("").to_owned()
246+
} else {
247+
String::new()
248+
};
249+
250+
let commit = head.peel_to_commit().ok()?;
251+
let mut hex_buf = [0u8; 40];
252+
hex::encode_to_slice(commit.id().as_bytes(), &mut hex_buf).ok()?;
253+
// SAFETY: hex output is always valid ASCII
254+
let sha = unsafe { std::str::from_utf8_unchecked(&hex_buf) }.to_owned();
255+
256+
Some((branch, sha))
257+
}
258+
217259
/// for GitHub Actions environment variables, see: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
218260
pub fn get_github_base_ref(base_ref_env: CIEnv) -> Option<String> {
219261
// make sure we're running in a CI environment

crates/turborepo-scm/src/hash_object.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ pub(crate) fn hash_objects(
6363
AnchoredSystemPathBuf::relative_path_between(pkg_path, &full_file_path)
6464
.to_unix()
6565
});
66-
Ok(Some((package_relative_path, hash.to_string())))
66+
let mut hex_buf = [0u8; 40];
67+
hex::encode_to_slice(hash.as_bytes(), &mut hex_buf).unwrap();
68+
// SAFETY: hex output is always valid ASCII
69+
let hash_str = unsafe { std::str::from_utf8_unchecked(&hex_buf) }.to_string();
70+
Ok(Some((package_relative_path, hash_str)))
6771
}
6872
Err(e) => {
6973
if e.class() == git2::ErrorClass::Os

crates/turborepo-scm/src/ls_tree.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,22 @@ impl GitRepo {
5757
.map_err(|e| Error::git2_error_context(e, "peeling HEAD to tree".into()))?;
5858

5959
let mut hashes = Vec::new();
60+
let mut path_buf = String::with_capacity(128);
61+
let mut hex_buf = [0u8; 40];
6062
tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
61-
// Only collect blob entries (files), skip trees (directories)
6263
if entry.kind() == Some(git2::ObjectType::Blob) {
6364
let name = match entry.name() {
6465
Some(n) => n,
6566
None => return git2::TreeWalkResult::Ok,
6667
};
67-
let path_str = if dir.is_empty() {
68-
name.to_string()
69-
} else {
70-
format!("{dir}{name}")
71-
};
72-
if let Ok(path) = RelativeUnixPathBuf::new(path_str) {
73-
hashes.push((path, entry.id().to_string()));
68+
path_buf.clear();
69+
path_buf.push_str(dir);
70+
path_buf.push_str(name);
71+
if let Ok(path) = RelativeUnixPathBuf::new(path_buf.clone()) {
72+
hex::encode_to_slice(entry.id().as_bytes(), &mut hex_buf).unwrap();
73+
// SAFETY: hex output is always valid ASCII
74+
let hash = unsafe { std::str::from_utf8_unchecked(&hex_buf) }.to_string();
75+
hashes.push((path, hash));
7476
}
7577
}
7678
git2::TreeWalkResult::Ok

crates/turborepo-scm/src/package_deps.rs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,11 @@ impl GitRepo {
267267
// Include globs can find files not in the git index (e.g. gitignored files
268268
// that a user explicitly wants to track). Walk the filesystem for these
269269
// files but skip re-hashing any already known from the index.
270+
let pkg_prefix = package_path.to_unix();
271+
270272
if !includes.is_empty() {
271273
let full_pkg_path = turbo_root.resolve(package_path);
272-
let package_unix_path_buf = package_path.to_unix();
273-
let package_unix_path = package_unix_path_buf.as_str();
274+
let package_unix_path = pkg_prefix.as_str();
274275

275276
static CONFIG_FILES: &[&str] = &["package.json", "turbo.json", "turbo.jsonc"];
276277
let mut inclusions = Vec::with_capacity(includes.len() + CONFIG_FILES.len());
@@ -304,12 +305,10 @@ impl GitRepo {
304305
let mut to_hash = Vec::new();
305306
for entry in &files {
306307
let git_relative = self.root.anchor(entry)?.to_unix();
307-
let pkg_relative = turbopath::RelativeUnixPath::strip_prefix(
308-
&git_relative,
309-
&package_unix_path_buf,
310-
)
311-
.ok()
312-
.map(|s| s.to_owned());
308+
let pkg_relative =
309+
turbopath::RelativeUnixPath::strip_prefix(&git_relative, &pkg_prefix)
310+
.ok()
311+
.map(|s| s.to_owned());
313312

314313
let already_known = pkg_relative
315314
.as_ref()

crates/turborepo-scm/src/repo_index.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ use crate::{Error, GitHashes, GitRepo, ls_tree::SortedGitHashes, status::RepoSta
99
/// and `git status` so they can be filtered per-package without spawning
1010
/// additional subprocesses.
1111
///
12-
/// Uses a sorted `Vec` for the ls-tree data so that per-package lookups can
13-
/// use `partition_point` (binary search) for range queries. This gives the
14-
/// same O(log n) asymptotic cost as a `BTreeMap` but with better cache
15-
/// locality on the contiguous memory.
12+
/// Both collections are sorted by path so that per-package lookups can use
13+
/// `partition_point` (binary search) for range queries. This gives O(log n)
14+
/// lookup cost with good cache locality on contiguous memory.
1615
pub struct RepoGitIndex {
1716
ls_tree_hashes: SortedGitHashes,
17+
/// Sorted by path so per-package filtering can use binary-search range
18+
/// queries instead of linear scans. With P packages and S status entries
19+
/// the cost drops from O(P*S) to O(P * log S).
1820
status_entries: Vec<RepoStatusEntry>,
1921
}
2022

@@ -259,6 +261,48 @@ mod tests {
259261
assert_eq!(to_hash, vec![path("new.ts")]);
260262
}
261263

264+
#[test]
265+
fn test_sorted_status_binary_search_matches_linear_scan() {
266+
let status = vec![
267+
("apps/docs/new.ts", false),
268+
("apps/web/changed.ts", false),
269+
("apps/web-admin/added.ts", false),
270+
("apps/web/deleted.ts", true),
271+
("packages/ui/modified.ts", false),
272+
("root-new.ts", false),
273+
];
274+
275+
let index = make_index(
276+
vec![
277+
("apps/docs/index.ts", "aaa"),
278+
("apps/web/index.ts", "bbb"),
279+
("apps/web/deleted.ts", "ccc"),
280+
("apps/web-admin/index.ts", "ddd"),
281+
("packages/ui/button.tsx", "eee"),
282+
],
283+
status,
284+
);
285+
286+
// "apps/web" must match apps/web/* but NOT apps/web-admin/*
287+
let (hashes, to_hash) = index.get_package_hashes(&path("apps/web")).unwrap();
288+
assert_eq!(
289+
hashes.len(),
290+
1,
291+
"only index.ts should remain (deleted.ts removed)"
292+
);
293+
assert!(hashes.contains_key(&path("index.ts")));
294+
assert!(!hashes.contains_key(&path("deleted.ts")));
295+
assert_eq!(to_hash, vec![path("apps/web/changed.ts")]);
296+
297+
// "apps/web-admin" should get its own status entries only
298+
let (_, to_hash) = index.get_package_hashes(&path("apps/web-admin")).unwrap();
299+
assert_eq!(to_hash, vec![path("apps/web-admin/added.ts")]);
300+
301+
// empty prefix collects everything
302+
let (_, to_hash) = index.get_package_hashes(&path("")).unwrap();
303+
assert_eq!(to_hash.len(), 5); // all non-delete status entries
304+
}
305+
262306
// Verifies that BTreeMap range queries produce correct results for
263307
// prefix-based package filtering. This captures the exact behavior that
264308
// must be preserved when switching to a sorted Vec with partition_point.

0 commit comments

Comments
 (0)