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-hash crate for xxhash-rust, a near drop-in replacement with a faster algorithm — approximately a 6% win.
  • PR #11878: Addressed an existing TODO by 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 --profile flag in the turbo CLI.

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:

  1. Put the agent in Plan Mode with instructions to profile and identify hotspots in the Markdown output.
  2. Review proposed optimizations and select worthwhile ones.
  3. Have the agent implement the accepted proposals.
  4. Validate with end-to-end hyperfine benchmarks.
  5. 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.json files 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 libgit2 calls (#11938), and eventually replacing libgit2 with gix-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.