GitHub Actions runner size: when 2, 4, 8 and 16 vCPU pay off
Choosing a GitHub Actions runner size is one question, not four: does the slow part of the job use more than one core. We ran the same script on a 2 vCPU and a 4 vCPU Latchkey runner, and the work that threads went from 26.1 seconds to 13.2 while the work that does not moved from 1.72 seconds to 1.74, on machines whose rate doubles between them.

Larger runners are the easiest change on this list to make and the easiest to waste money on, because the rate rises whether or not the job gets faster. GitHub prices a Linux runner at $0.006 a minute for 2 vCPU and $0.012 for 4, so a size you picked by feel is a bill you doubled by feel.
The question has a single, cheap, definitive answer that lives in your own log. One instrumented run tells you whether the slow step is saturating one core, spreading across all of them, or waiting on something neither a bigger machine nor a faster one will help with.
The same work on 2 vCPU and on 4
We ran one script twice on Latchkey runners, once at latchkey-small with 2 vCPU and 7,734 MB of RAM and once at latchkey-medium with 4 vCPU and 15,617 MB, on the same AMD EPYC 7R13 processor and the same Ubuntu 24.04.4 image. Nothing in the script changed between the two runs, and the compressible input file it generates has the same sha256 in both logs, so the pair isolates vCPU count and nothing else.
The threaded rows almost exactly halved. The serial rows did not move at all. That is the whole decision, and it is worth seeing in the same table rather than as two separate claims, because every real pipeline is a mixture of the two and the mixture is what sets your answer.
| Work, control held: same script, input and image | 2 vCPU | 4 vCPU | Change |
|---|---|---|---|
xz -6 -T0, one thread per vCPU | 26.1 s | 13.2 s | 1.99x faster |
| Eight equal CPU loops, one per vCPU | 1.90 s | 0.96 s | 1.97x faster |
| Eight equal CPU loops, run one after another | 3.59 s | 3.58 s | no change |
| One CPU loop, single process | 1.72 s | 1.74 s | no change |
xz -6 -T1, forced to one thread | 40.2 s | 37.7 s | not claimed, see below |
npm ci, 414 packages, cache emptied | 4.10 s | 3.48 s | 1.18x faster |
npm ci, 414 packages, cache restored | 2.32 s | 1.99 s | 1.17x faster |
Read your own log instead of guessing
One step, added once, answers the question permanently. Run your slowest command under /usr/bin/time -v and print nproc beside it. The line to read is "Percent of CPU this job got", and it has exactly three interesting values.
Near 100 percent means one core is saturated and the rest are idle: a bigger machine changes nothing and a faster core would. Near N times 100 percent means the work is genuinely spread and more vCPUs buy you time in proportion. Well under 100 percent means the job is waiting on a network or a disk, and neither more cores nor faster ones will touch it.
- name: What is this job actually doing
run: |
nproc
free -m | head -2
/usr/bin/time -v ./your-slow-command 2>&1 | tail -25Memory is the other half of the size, and it moves with you
On every provider, buying vCPUs buys RAM at the same time, and that is not a bonus you can ignore: the parallelism you just paid for often needs it. The same compression run used a peak resident set of 214,580 KB with two threads and 431,532 KB with four. The memory demand doubled alongside the speed.
So read two numbers out of your log, not one. free -m reports what is available, which was 7,029 MB of 7,734 on the small runner and 14,832 MB of 15,617 on the medium one before the work started. If your peak resident set is a large fraction of that, the size you need is set by memory rather than by cores, and the symptom of getting it wrong is not a slow job, it is exit code 137 and no explanation in the build output.
| Reading in the log | Diagnosis | The right move |
|---|---|---|
| 99 percent CPU on one core | Single-threaded critical path | A faster core, not more of them |
| Close to N times 100 percent | Genuinely parallel | More vCPUs, in proportion |
| Well under 100 percent | Waiting on network or disk | Caching and concurrency, not size |
| Peak resident set near available memory | Memory bound | A bigger size, for the RAM that comes with it |
| Exit code 137 | The kernel killed it | More RAM, urgently, and it is not a code bug |
What the extra vCPUs cost, and the line where they pay
GitHub publishes a per-minute rate for each Linux size, and it roughly doubles with the vCPU count up to 4 before turning slightly sublinear above it. The documentation is blunt about who pays, in two consecutive sentences: "Included minutes cannot be used for larger runners. The larger runners are not free for public repositories." So every minute is billed on a plan with allowance left, and an open source repository that pays nothing at all for standard runners starts paying the moment you size up.
That gives you a break-even you can apply without a spreadsheet. A size that doubles the rate has to at least halve the wall clock to cost you the same. Our threaded rows cleared that bar at 1.99x and were free speed. The npm ci rows came back at 1.18x for a 2x rate, which is a real improvement you are paying about 1.7 times as much for. The single-threaded rows were a pure doubling of the bill.
One detail that bites small jobs: GitHub rounds each job up to the nearest minute. A step that goes from 26 seconds to 13 still bills one minute, at twice the rate. Sizing up is worth doing on jobs long enough for the saving to cross a minute boundary, and worth nothing at all on the short ones.
| Linux runner | Rate per minute | 10,000 minutes a month | Speedup needed to break even against 2 vCPU |
|---|---|---|---|
| 2 vCPU (standard) | $0.006 | $60 | baseline |
| 4 vCPU | $0.012 | $120 | 2.0x |
| 8 vCPU | $0.022 | $220 | 3.7x |
| 16 vCPU | $0.042 | $420 | 7.0x |
| 32 vCPU | $0.082 | $820 | 13.7x |
| 64 vCPU | $0.162 | $1,620 | 27.0x |
| 96 vCPU, x64 only | $0.252 | $2,520 | 42.0x |
Size per job, not per workflow
Runner size is a per-job setting, and the most expensive mistake in this area is putting a whole workflow on a large runner because one job in it needed the memory. Your lint job is single-threaded and finishes in forty seconds; there is no version of the argument where it belongs on 16 vCPU.
Split by what the reading told you. The job whose CPU percentage sat at 99 stays small. The job that reported 397 percent and has more parallelism available goes up one step at a time, measuring each step, because the scaling stops the moment the work runs out of independent pieces and the rate does not.
jobs:
lint:
runs-on: ubuntu-latest # 99% on one core, nothing to gain
test:
runs-on: ubuntu-latest-4-core # sharded, scales with cores
build:
runs-on: ubuntu-latest-8-core # parallel compile and memory hungryWhat we ran, so you can disagree with it
One script, job-j.sh, run twice on 20 September 2026, committed under content/repro/timings/choose-a-github-actions-runner-size/ with both unedited logs and a status file per run carrying the job id, the runner size and the exit code. The only difference between the two invocations is the size flag on the command line.
One pass per cell, so a difference under about half a second is not real, which is why the two serial rows are reported as no change rather than as a hundredth of a second either way. The single-threaded xz row is the one place the noise was large enough to see: repeated inside each job it varied by up to 14 percent, so we print it and decline to draw a conclusion from it rather than quietly leaving it out.
The two sizes also ran on the same processor model, which is the fair test of core count and not a test of core speed. A provider that pairs bigger sizes with faster cores would move the single-threaded rows too, and none of ours moved.
Frequently asked questions
Are larger GitHub Actions runners worth it?
/usr/bin/time -v and read the CPU percentage: 99 percent means a larger size is a pure price increase.