Reducing Github Actions runtime for a Rust project

banner
Zack ChaseZack Chase
3 MIN READ

I have found that sometimes a slow CI pipeline is not a bad thing. Like when I need to make lunch or go run a quick errand. Unfortunately, the majority of the time slow CI is a major impediment to my daily work.

At Namespace we provide Github Actions Runners and Cache Volumes designed to help reduce the time and cost of CI workflows. In this post we will walkthrough improving the efficiency of a Rust project's workflow using spotify-player as the test case: a terminal Spotify client written in Rust that has a workflow which builds on Linux, macOS, and Windows.

The baseline

The ci.yaml workflow for spotify-player runs on three platforms: Linux, macOS, and Windows. Each job installs system dependencies, checks out source, installs a Rust toolchain, restores a Cargo cache, and runs format checks, tests, and Clippy.

Using Github provided runners, the average duration of the workflow was 9 minutes 31 seconds and it would cost ~$0.44 per run for private repositories. Here is the breakdown:

RunnerAverage DurationCost Per Run
ubuntu-latest4m 24s$0.03
macOS-latest4m 52s$0.31
windows-latest9m 31s$0.10
TOTAL9m 31s$0.44

Step 1: Use Namespace runners

Our first step in improving efficiency is to migrate the workflow to Namespace runners without caching enabled. To use Namespace runners we first need to create a runner profile and then update the matrix in ci.yaml:

ci.yaml
 strategy:   matrix:-    os: [macOS-latest, windows-latest, ubuntu-latest]+    os: [namespace-profile-macos, namespace-profile-windows, namespace-profile-ubuntu]

After migrating to Namespace runners we observed the following improvements to average duration and cost per run:

RunnerAverage DurationCost Per Run
namespace-ubuntu2m 39s↓40%$0.012↓60%
namespace-macOS2m 8s↓56%$0.18↓42%
namespace-windows3m 10s↓66%$0.032↓68%
TOTAL3m 10s↓66%$0.224↓49%
Namespace pricing is based on contracted unit minutes. Overages and pay-as-go are billed at 1.5x.

Wall-clock time of the workflow drops 3x and the cost per run is almost 2x less expensive.

Step 2: Enable Caching

Our second change replaces actions/cache with nscloud-cache-action and swaps the toolchain action to actions-rust-lang/setup-rust-toolchain, with GitHub caching disabled. Namespace runners include a caching layer that's faster than actions/cache because it runs on infrastructure local to the runner, rather than pulling from remote storage. To use it, swap the toolchain action and the cache action:

Windows caching is in early access. Please contact us to request access.

ci.yaml
 - name: Install Rust toolchain   id: install_toolchain-  uses: dtolnay/rust-toolchain@master+  uses: actions-rust-lang/setup-rust-toolchain@v1   with:+    cache: false     toolchain: ${{ matrix.toolchain }}     components: clippy, rustfmt   - name: Cache cargo deps-  uses: actions/cache@v3-  with:-    path: |-      ~/.cargo/registry/index/-      ~/.cargo/registry/cache/-      ~/.cargo/git/db/-    key: ${{ runner.os }}-rustc-${{ steps.install_toolchain.outputs.cachekey }}-cargo-${{ hashFiles('**/Cargo.lock') }}-    restore-keys: ${{ runner.os }}-rustc-${{ steps.install_toolchain.outputs.cachekey }}+  uses: namespacelabs/nscloud-cache-action@v1+  with:+    cache: rust

When running the jobs with caching setup, we observed the following results when we had a cache hit:

RunnerAverage DurationCost Per Run
namespace-ubuntu34s↓87%$0.006↓80%
namespace-macOS27s↓90%$0.06↓80%
namespace-windows44s↓92%$0.008↓92%

Every platform now finishes in under a minute: Ubuntu at 34 seconds, macOS at 27, Windows at 44, each roughly a tenth of its Step 1 duration.

Why the caches are faster

actions/cache stores the Cargo registry and build artifacts as a compressed archive in remote storage. Every run pays for that round trip: download the archive, decompress it, and, if the cache changed, compress and upload it again at the end. That cost scales with the size of the cache and is paid in full on every job, hit or not.

namespacelabs/nscloud-cache-action use our Cache Volumes, which have fully local NVMe storage with high IOPS and no upload/download time. To avoid uploads and downloads, our Cache Volumes persist data across GitHub Actions runs. This gives your runners instant access to cached data through guaranteed cache locality. Cache Volumes also support high concurrency through automatic forking and scale up to hundreds of GB.

Final Results

These numbers reflect cache-hit runs across all three platforms.

RunnerAverage DurationCost Per Run
namespace-ubuntu34s↓87%$0.006↓80%
namespace-macOS27s↓90%$0.06↓80%
namespace-windows44s↓92%$0.008↓92%
TOTAL44s↓92%$0.074↓83%

Starting from a 9m 31s baseline, the migration cuts total wall-clock time to 44 seconds, about 13x faster, and cost per run from $0.44 to $0.074, about 6x cheaper.

Try it on your own repo

The spotify-player workflow is representative of a mid-size Rust project: multiple platforms, system dependencies, a Cargo feature matrix, and a mix of lint and test steps. The main variables are compile time and CI frequency. Projects with longer builds or more frequent runs will see larger absolute gains. Get started today.

Frequently asked questions

Most of the time goes to compiling, not testing. Rust's compiler does more work per line than a scripting language interpreter, and a cross-platform matrix multiplies that cost across Linux, macOS, and Windows runners with different toolchains and dependency caches. Without a cache hit, every job rebuilds the full dependency tree from source before it can run a single test.

GitHub's own hosted runners also allocate modest CPU and memory by default, so a rebuild that would take seconds on a developer laptop can take minutes in CI.

Two changes account for most of the gain: run on faster machines, and cache Cargo's registry and build artifacts between runs. In the spotify-player case study above, switching the runner matrix to Namespace profiles cut total wall-clock time by about 66% before touching caching. Adding nscloud-cache-action on top of that took cache-hit runs down to under a minute across Linux, macOS, and Windows.

Neither change requires restructuring the workflow itself: the matrix, jobs, and steps stay the same, only the runner labels and cache action change.

actions/cache uploads and downloads a compressed archive to and from GitHub's remote cache storage on every run, which adds network time on top of the restore itself. nscloud-cache-action uses a cache volume attached directly to the runner, so restoring the Cargo registry and build artifacts doesn't involve a network round trip.

The two caching systems conflict if left enabled together, so GitHub's built-in cache needs to be turned off explicitly when switching, as shown in the toolchain diff above. See our caching docs for supported languages and cache modes.

Namespace runners give each job dedicated CPU and memory instead of the shared, fixed-size machines GitHub's hosted runners use, and Runner Profiles let you size Linux, macOS, and Windows machines independently for a given workflow. In the spotify-player case study above, that alone cut total wall-clock time by about 66% and cost per run by about 49%, before caching was added.

Runners also include a cache volume attached to the machine, so dependency and build caches restore without the network round trip GitHub's own cache relies on.

Accelerate your developer team

Join hundreds of teams using Namespace to build faster, test more efficiently, and ship with confidence.