Make your CI faster on Diffinery
Speed up workflows on Diffinery Actions: runner sizes and auto-sizing, caching, setup and Docker head starts, matrices, and cancelling superseded runs, with measured numbers.
On Diffinery, a hosted job gets 4 vCPUs and 12 GiB of memory by default, and the common setup actions are built into the runner, so many workflows are fast without changes. To go further: let CPU-heavy jobs use all four cores, cache dependencies with keys that the default branch saves, use the Node.js and Python versions the image already has, split long jobs into parallel jobs with needs and matrices, and turn on cancelling of superseded pull request runs, which is off by default. Every job's log says how much CPU, memory and disk it used, so you can see where the time goes.
Steps
- Read what a job used. The last lines of each hosted job's log show its usage, for example
Resources: 0.1 of 4 vCPUs busy on average (0.3 at the 95th percentile), peak memory 0.3 GiB of 12.0 GiB, peak disk 1.6 GB of 19.3 GB, and a line near the top names the runner size it got and why. The jobs API returns the same asusageandrunner_size. - Keep CPU-heavy jobs on the large size. It's the default, and automatic sizing keeps builds and test suites there. Pin it with
runs-on: diffinery-largeif you want to be sure (see How runner sizing works). - Cache dependencies with the
cacheinput of the setup actions or withactions/cache, keyed on your lock file (see Caching). - Use the toolchains the image has, and let Go, Java and Docker start early (see Setup actions and head starts).
- Run jobs in parallel. Split a long job into jobs that
needsonly what they depend on, and spread tests over a matrix (see Matrices and parallel jobs). - Stop runs nobody will look at. Add a
concurrencygroup that cancels a pull request's older runs, and path filters for changes that don't need CI (see Cancel superseded runs). - Re-run only what failed. Re-run failed jobs on the run page (or
diffinery run rerun <run-id> --failed) runs the failed jobs and the jobs that depend on them, and keeps the rest from the earlier attempt without starting a runner. See Re-run a run.
How runner sizing works
Hosted runners come in four sizes. Every size runs the same Ubuntu 24.04 image, so a job behaves the same on each.
| Size | runs-on label | vCPU | Memory | Disk |
|---|---|---|---|---|
| large (default) | diffinery-large | 4 | 12 GiB | 20 GB |
| medium | diffinery-medium | 2 | 8 GiB | 16 GB |
| small | diffinery-small | 1/2 | 4 GiB | 8 GB |
| tiny | diffinery-tiny | 1/4 | 1 GiB | 4 GB |
- With a size label, for example
runs-on: [ubuntu-latest, diffinery-small], the job gets that size (with several, the largest). - Without one, the job gets large, unless its own last runs (at least 3 of up to 5) all fit a smaller size with room to spare, including room for bursts onto several cores and for slower hosts. A job that ran out of memory, failed, timed out or got slower on a smaller size goes back to large for its next 20 runs, and re-runs always run on large. So a smaller size is never the reason a job fails or slows down twice.
- Smaller sizes are about cost, not speed. A job that mostly waits (a deploy, API calls, polling) runs just as fast on a small machine and costs less. A job that computes is fastest on large.
Large is the biggest hosted size; there are no larger runners. See Runner sizes and What hosted jobs cost.
Caching
Diffinery's cache service works with the official actions/cache and with the built-in version, which downloads an archive in parallel ranges and extracts it while it arrives.
The simplest cache is the cache input of a setup action:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
actions/setup-python takes cache: pip or pipenv, and actions/setup-go caches by default. For anything else, use actions/cache with a key that changes when your lock file does:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
Things that make caches hit more often:
- Let the default branch save caches. A run reads entries saved on its own ref, then (for pull requests) the base branch, then the default branch. Entries saved on one feature branch are never visible to another.
- Include a hash of the lock file in the key. Entries are immutable: a save with an existing key is skipped, so a key that never changes never refreshes.
- Use
save-always: trueif failing jobs should still save, since the post step saves only after a successful job by default. - Cache Docker layers with
cache-from: type=ghaandcache-to: type=ghaindocker/build-push-action, or withtype=registryin the container registry.type=ghain a plaindocker buildx buildstep doesn't work, becauserun:steps don't get the cache service credentials.
Each repository keeps up to 10 GB of caches, and entries no job restored for 7 days are deleted. See Caching and artifacts.
Setup actions and head starts
- Node.js 22.22.0 and Python 3.12 are preinstalled. When the version you ask
actions/setup-nodeoractions/setup-pythonfor is satisfied by them, the step uses them without a download. Any other version is downloaded each time. - Go and Java start installing as the job begins. When a job has
actions/setup-go(v4 to v6) oractions/setup-java(v2 to v5,distribution: temurin, a major version such asjava-version: 21), and the version is written in the workflow or comes frommatrix,inputsorvars, the runner downloads that toolchain while checkout runs. Versions read from a file (go-version-file,java-version-file) and jobs that run in acontainer:install the usual way, as the step runs. - Docker starts early for jobs that use it. A job with
container:,services:, adocker://action, or a step whoseuses:orrun:namesdockergets the Docker daemon started in the background as the job begins. Adockercommand as the job's very first step waits for the daemon to finish starting. - Other toolchains (Ruby, Rust, .NET, browsers) aren't in the image and install as their step runs, so cache what they download.
See The Ubuntu 24.04 image and Built-in actions.
Matrices and parallel jobs
Jobs without needs between them run at the same time, each in its own container, and a job starts as soon as the jobs it needs have finished. A matrix turns one job into several:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
- An account runs up to 20 hosted jobs at once by default, across all its repositories; further jobs wait in line, oldest first. If a large matrix often waits, cap it with
strategy.max-parallel. - A matrix may produce at most 256 jobs, and a run at most 1,000.
- With
fail-fast(the default), one failed leg cancels its unfinished siblings, so you hear about a failure sooner.
Cancel superseded runs
As on GitHub, a new push to a pull request doesn't cancel the run for the commit before it. Diffinery doesn't turn this on for you, because cancelling can cut off a deploy halfway or turn an older commit's checks into "cancelled". To opt in for pull requests only, add this to the workflow:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Older runs of the same pull request are cancelled when a new commit arrives, and runs on your default branch finish. A run on a protected branch is only ever cancelled by runs on that same branch. See Concurrency.
To skip runs entirely for changes that don't need them, use path filters on push and pull_request, for example paths-ignore: ["docs/**", "**.md"]. A job that doesn't run reports no check, and a required check that never reports shows as required check '<name>' has not run, so be careful with path filters on workflows whose checks are required. See Path filters and Status checks.
Measured numbers
From Diffinery's measurements on its production hosted runners on October 16, 2026 (see How fast Diffinery Actions runs, measured for the method). Ranges are wide because Cloudflare places containers on hosts whose cores differ about 2x in speed.
| Stage | Time |
|---|---|
| Push to first job running | 6 to 8 s |
| Job queued to first step | About 3 to 5 s |
actions/checkout | 1 to 3 s |
| Restore a 216 MB pnpm cache | 3 to 4 s on fast hosts, about 10 s on the slowest |
pnpm install from that cache | 3 to 7 s |
| Upload a 200 MB artifact | 7 to 9 s |
setup-python, setup-node (preinstalled versions) | 0 s and 2 s |
setup-go, setup-java (Temurin) | 1 to 3 s and 2 to 5 s |
First docker command after checkout | 0.03 to 0.3 s |
First docker command as a job's very first step | 1.4 to 2.3 s |
The same lint job (checkout, npm ci, eslint, prettier and tsc on 40 files) took 9 to 14 s on large, 10 to 13 s on medium, 13 to 14 s on small and 15 to 27 s on tiny. A job that only made ten REST requests took 4 seconds on both tiny and large. Diffinery's own pull requests run 10 required checks, including seven end-to-end suites, in about 3 minutes, and up to about 6 minutes on the slowest hosts.
Frequently asked questions
What hardware do Diffinery's hosted runners have?
By default, 4 vCPUs, 12 GiB of memory and 20 GB of disk, running Ubuntu 24.04 on x64 in a fresh container for each job. Smaller sizes (2, 1/2 and 1/4 vCPU) are picked per job from its own measured runs, or with a diffinery-* label.
Does Diffinery cancel superseded workflow runs automatically?
No. As on GitHub, older runs keep running unless the workflow has a concurrency group with cancel-in-progress. Add one with cancel-in-progress: ${{ github.event_name == 'pull_request' }} to cancel older pull request runs and leave default-branch runs alone.
Can I get a runner bigger than 4 vCPUs?
Not as a hosted runner: large (4 vCPU, 12 GiB) is the biggest size. For bigger machines, GPUs or long-lived caches, connect your own machine as a self-hosted runner.
Why did my job get a smaller runner?
Its last runs all fit a smaller size with room to spare, so Diffinery picked it to save cost. The log's Runner size: line says which runs it was based on. If a smaller size ever makes the job fail or slower, it goes back to large for its next 20 runs. Pin runs-on: diffinery-large to keep it on large.