Skip to content

Profile the build before you optimize it

Record where build time goes with the compiler's and build tool's own profilers on a CI runner, then fix the crate, header, type or dependency that costs the most instead of guessing.

Saves time and minuteslow risk

Signals in your baseline

  • The build step is still the long pole after caching, and nobody can name which crate, package, target or header costs the most.
  • No workflow or script in the repository produces a build profile: no `--timings`, `--extendedDiagnostics`, `--generateTrace`, Gradle `--profile`, Bazel `--profile` or `-ftime-trace`.
  • The build step's p50 in the baseline is far above what the size of a typical change would suggest, or it has grown over recent months.

Why it works

Build time is rarely spread evenly. It piles up in a few places: a crate full of macro-generated code, a header that half the codebase includes, a dependency pulled in for one tool, a type the checker struggles with, or one target that every other target waits on. Compilers and build tools can record exactly where their time goes. A profile turns "the build is slow" into a ranked list, so you fix the biggest item, measure again, and stop when the build is no longer the long pole.

Apply it

Profile on the same runner type CI uses. A laptop has different cores, disks and cache state, so its numbers mislead. A manual workflow keeps profiling out of pull requests:

name: build-profile
on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  profile:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v7
      - run: cargo build --release --locked --timings
      - uses: actions/upload-artifact@v7
        with:
          name: cargo-timings
          path: target/cargo-timings/
          retention-days: 7
  • Start it with gh workflow run build-profile.yml --ref <branch>. The file must exist on the default branch before GitHub lets you dispatch it.
  • This profiles a cold build. Restore your build cache first if you want to see what a typical pull request pays.
  • Swap the cargo line for your ecosystem's command below and point the upload at its report.

Rust

cargo build --timings writes cargo-timing.html: one bar per crate, plus a chart of how many crates compile at once. Look for long bars while few others run. Those crates hold everything else back, so split them or trim what they depend on. To see inside one crate, use nightly: cargo +nightly rustc -p <crate> -- -Z time-passes. Codex used it to measure what #[async_trait] cost its tool handlers. cargo tree --duplicates lists dependencies compiled in more than one version.

TypeScript

npx tsc --noEmit --extendedDiagnostics prints file counts, memory and time per phase (parse, bind, check). npx tsc --noEmit --generateTrace trace writes a trace you can open in Perfetto to find the files and types that take longest to check.

Gradle

./gradlew build --profile writes an HTML report under build/reports/profile/ with configuration time and time per task. A Build Scan (--scan) shows more, but it uploads build data to Gradle's servers, so get approval before using it on private code. If the daemon runs short of memory, garbage collection eats build time: size the heap in org.gradle.jvmargs to fit the runner.

Bazel

bazel build //... --profile=profile.json.gz records every action, and bazel analyze-profile profile.json.gz summarizes the critical path. To find out why a heavy dependency is in the build at all, run bazel query 'allpaths(//main:app, @dep//:target)'. Sorbet used that to find that only buildifier needed protoc.

C and C++

Clang's -ftime-trace writes a JSON trace per object file that splits time between header parsing, template instantiation and code generation. Figma tracked the bytes each file sends to the compiler after preprocessing and cut includes from its largest files.

Then pick the play that matches the hot spot: a faster linker when the final link dominates, compiler caches when unchanged code recompiles, or a bigger runner when every core stays busy for the whole build.

Keep it safe

  • Keep it off the gate. Profiling flags slow the build, and nightly-only flags can break on any toolchain update. Run them in a manual or scheduled workflow, never in a required check.
  • Treat profiles as internal. Traces and timing reports contain file paths, target names and dependency lists. Keep retention short, remember that anyone who can read a public repository can download its artifacts, and don't publish Build Scans without approval.
  • Review the fix like any change. Removing a dependency or restructuring code to compile faster changes behavior risk. Run the full suite, and compare profiles against the same base commit, as Codex did.
  • Least privilege. The profiling workflow needs only contents: read.

Verify

  • After each fix, rerun the same profile on the same runner type and compare the specific number you targeted: the crate's bar, the check phase, the task time or the critical path.
  • Then compare the build step's p50 in normal CI runs with the baseline over five or more runs.
  • Confirm the same tests run and pass, and that the build produces the same artifacts.

Sources