The setup: what turbo run actually does first
Before any task executes, turbo run analyzes the monorepo: its structure, scripts, and dependency graph. That analysis produces the task graph that determines execution order, enables parallelism, and informs caching. The cost of building this graph scales with repo size, and on larger monorepos it becomes a meaningful delay. In our 1,000+ package repository, that graph construction was taking around 10 seconds on an M4 Pro Max — before any actual work started.
Phase one: unattended agents
The first experiment involved launching eight background coding agents overnight, each pointed at a different suspected bottleneck in the Rust codebase. The instruction given was:
“Look for a performance speedup in our Rust code. It has to be something that is well-tested, and on our hot path. Make sure to add benches to check your work. I’m particularly interested in our hashing code.”
Each agent received the same prompt, with only the target code area swapped. By morning, three of the eight agents produced shippable results:
- PR #11872: Reduced allocation pressure by hashing by reference instead of cloning an entire
HashMap, yielding roughly a 25% reduction in wall-clock time. - PR #11874: Swapped the
twox-hashcrate forxxhash-rust, a near drop-in replacement with a faster algorithm — approximately a 6% win. - PR #11878: Addressed an existing
TODOby replacing a Floyd-Warshall implementation with a multi-source depth-first search. Not on the immediate hot path, but a valid improvement regardless.
Reviewing all eight sessions revealed clear limitations of the unattended approach:
- Agents never benchmarked against the Turborepo codebase itself, despite the project dogfooding Turborepo — building a binary and running it on the source would have given end-to-end results.
- Agents hyperfixated on their first idea and forced it to work instead of stepping back and reconsidering.
- Agents chased microbenchmark numbers that looked impressive but translated to negligible real-world gains; one reported a 97% benchmark improvement that amounted to 0.02% in practice.
- No agent wrote a regression test.
- No agent used the
--profileflag in theturboCLI.
Phase two: human-in-the-loop profiling
The next step was a conventional one: run turbo run build --profile on the largest repo and examine the trace in Perfetto. The profiles are Chrome Trace Event Format JSON — technically parseable, but poorly suited for quick analysis. Function identifiers span multiple lines, and metadata is mixed with timing data. Agents, like humans, struggled to grep through the noise.
The fix was to generate profiles in a format both humans and agents could read efficiently. Drawing from a pattern in Bun’s --cpu-prof-md flag, PR #11880 introduced a turborepo-profile-md crate that emits a companion Markdown file with every trace: hot functions sorted by self-time, call trees by total-time, and caller/callee relationships, all greppable on single lines. The same model, same codebase, same harness produced dramatically better optimization suggestions from Markdown input than from the JSON trace.
The iterative loop
With actionable profiles, the workflow settled into a repeatable cycle:
- Put the agent in Plan Mode with instructions to profile and identify hotspots in the Markdown output.
- Review proposed optimizations and select worthwhile ones.
- Have the agent implement the accepted proposals.
- Validate with end-to-end
hyperfinebenchmarks. - Open a PR and repeat.
This loop produced over 20 performance PRs in four days. Improvements clustered into three categories:
- Parallelization: Building the git index, walking the filesystem for glob matches, parsing lockfiles, and loading
package.jsonfiles had been sequential. PRs #11889, #11902, #11927, and #11918 parallelized these hot paths. - Allocation elimination: Removing redundant copies and clones across the pipeline. Notable changes included reference-based hashing in SCM operations (#11916), pre-compiling glob exclusion filters (#11891), and reusing a shared HTTP client (#11929).
- Syscall reduction: Batching per-package git subprocess calls into a single repo-wide index (#11887), moving from git subprocesses to
libgit2calls (#11938), and eventually replacinglibgit2withgix-index(#11950).
Codebase as feedback
An interesting pattern emerged: the source code itself became the strongest corrective signal. Pointing out a performance issue in one location led the agent to find analogous patterns elsewhere. When sloppy code patterns were corrected once, the agent followed the correction in subsequent output. Across separate conversations, newly merged improvements prevented the agent from regressing to old habits.
Over time, the agent began writing tests unsolicited and creating abstractions that matched what a human would have written. The same model and harness produced better results merely because the codebase had improved.
The measurement ceiling
By week’s end, Turborepo was roughly 85% faster on the largest repo — short of the 95% target. The limiting factor was no longer the code, but the measurement environment. Benchmarks run on a MacBook produced increasingly noisy hyperfine reports; as functions got faster, background system activity obscured real signals. Distinguishing a genuine 2% improvement from a quiet run became impossible. The profiles were equally noisy. Further optimization work required a quieter, more controlled environment.
The hunt for clean signal
Progress on low-level optimizations was impossible to measure on my laptop. Background processes, Slack notifications, and other noise made every benchmark run look different. On a machine where Chrome tabs are fighting your binary for CPU, you can't tell whether a 5% improvement is real or just variance.
Vercel Sandboxes solved that problem. These ephemeral Linux containers contain only what you explicitly put in them — no background daemons, no network requests, nothing stealing CPU from your workload. Every resource goes to the process being measured.
I scripted the full benchmark workflow in bash so results were consistent across runs. The script built both versions, ran them in the Sandbox, and downloaded the resulting profiles back to my laptop for inspection. The only caveat: Sandbox instances don't currently guarantee dedicated hardware, so all comparisons had to happen on a single instance where both binaries run under identical conditions.
# Cross-compile Turborepo binaries for Linux on macOS using Zig
zig cc -target x86_64-linux-gnu ...
cargo build --release --target x86_64-unknown-linux-gnu
# Create a Sandbox from a snapshot with test repos pre-loaded
sandbox create --snapshot turbo-bench-snapshot
# Upload both binaries (main and branch) into the Sandbox
sandbox cp ./target/release/turbo-main sandbox:/usr/local/bin/turbo-main
sandbox cp ./target/release/turbo-branch sandbox:/usr/local/bin/turbo-branch
# Run hyperfine comparing both binaries across test repos
sandbox exec -- hyperfine \
--warmup 2 --runs 15 \
'turbo-main run build --dry' \
'turbo-branch run build --dry'
# Generate Markdown profiles for both and download reports
sandbox exec -- turbo-main run build --profile=main-profile
sandbox exec -- turbo-branch run build --profile=branch-profile
sandbox cp sandbox:/reports/ ./local-reports/
Three wins that mattered
With clean measurement signal, three changes stood out in profiles from an 8,000-task monorepo.
Stack-allocated git OIDs (#11984)
Every file tracked in the git index stored its 40-character SHA-1 hash as a heap-allocated String. On the largest repo tested, new_from_gix_index alone created more than 10,000 individual 40-byte heap allocations. The fix was a stack-allocated OidHash type:
/// Fixed-size stack-allocated type for SHA-1 hex strings.
/// Clone is a 40-byte memcpy instead of alloc + memcpy.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct OidHash([u8; 40]);
impl OidHash {
pub fn from_hex_str(s: &str) -> Self {
let mut buf = [0u8; 40];
buf.copy_from_slice(s.as_bytes());
Self(buf)
}
}
impl std::ops::Deref for OidHash {
type Target = str;
fn deref(&self) -> &str {
// SAFETY: OidHash is always constructed from valid ASCII hex bytes.
unsafe { std::str::from_utf8_unchecked(&self.0) }
}
}
OidHash implements Deref<Target=str>, so existing code that consumes these values continues to work unchanged. Because it's Copy, "cloning" is just a 40-byte memcpy on the stack. Profile data showed new_from_gix_index self-time dropped 15% and get_package_file_hashes_from_index dropped 17%.
Repo size | Before | After | Change |
|---|---|---|---|
~1,000 packages | 1.463s ± 0.052s | 1.466s ± 0.027s | Same speed, 48% less variance |
~125 packages | 658.6ms ± 144.6ms | 592.1ms ± 62.9ms | 10% faster, 57% less variance |
6 packages | 96.8ms ± 46.7ms | 75.0ms ± 18.4ms | 22% faster, 61% less variance |
Even more telling was the reduction in run-to-run variance across all three repo sizes tested. Less allocator pressure means more predictable performance, which is exactly what the data showed.
Syscall elimination (#11985)
Every cache fetch was making a bizarre sequence of three system calls: stat(.tar) (which returned ENOENT), then stat(.tar.zst), then open(.tar.zst).
The .tar fallback turned out to be a relic of Turborepo's Golang era from 2021–2022. No modern version writes uncompressed cache entries, and old entries rotate out of the cache automatically. Removing that dead branch eliminated two syscalls per fetch:
// Before: 3 syscalls per cache hit
let cache_path = if uncompressed_cache_path.exists() { // stat(.tar) → ENOENT
uncompressed_cache_path
} else if compressed_cache_path.exists() { // stat(.tar.zst) → OK
compressed_cache_path
};
let mut cache_reader = CacheReader::open(&cache_path)?; // open(.tar.zst)
// After: 1 syscall per cache hit
let mut cache_reader = match CacheReader::open(&cache_path) { // open(.tar.zst)
Ok(reader) => reader,
Err(CacheError::IO(ref e, _))
if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(None); // cache miss
}
Err(e) => return Err(e),
};
Across 962 cache fetches on the largest repo, fetch self-time dropped from 200.5ms to 129.6ms — a 35% reduction.
Move instead of clone (#11986)
The visitor dispatch loop was deep-cloning a (String, HashMap<String, String>) from a precomputed map for each of roughly 1,700 tasks. Since each task ID appears in the dispatch stream exactly once, cloning was pure waste. Switching to HashMap::remove() moves the value out at zero cost.
The outcome
After eight days of work, Time to First Task on the largest repo dropped from 8.1 seconds to 716 milliseconds — roughly a 96% improvement.
Repo size | v2.8.0 | v2.9.0 | Improvement |
|---|---|---|---|
~1,000 packages | 8.1s | 0.716s | 91% faster |
132 packages | 1.9s | 0.361s | 81% faster |
6 packages | 0.676s | 0.132s | 80% faster |
Without agents, I estimate this would have taken at least two months. But it's important to be clear: agents didn't do the work for me. I led throughout — deciding which profiles to examine, which optimizations were worth pursuing, when to switch tools, and when to change strategy entirely. What the combination of existing engineering knowledge, better agent tooling, and a clean benchmarking environment made possible was a pace that simply wasn't achievable six months ago.
These optimizations ship in Turborepo 2.9, now stable and available.



