Sizing Runners for Android Builds
Size an Android runner by subtracting the emulator first: it holds guest cores and RAM all job. Sizing table, warp- labels, the KVM label, and cost math.
Last verified:
Android assemble and unit test jobs belong on 8 vCPU with 32GB of memory, which is warp-ubuntu-latest-x64-8x at $0.016 per minute, and an instrumented job that boots one emulator holds at that size only until a second device or a wide module graph arrives, at which point it moves to warp-ubuntu-latest-x64-16x at $0.032 per minute. The reason is that an emulator is a virtual machine with a fixed claim on the runner: the cores and RAM you pass to it are gone from Gradle for the whole job, so you size the build first and then add the device on top.
This guide gives the sizing table across the Linux x64 ladder, the memory and core arithmetic an emulator adds, and the workflow YAML that pins the label, the worker count, and the KVM permissions step that emulator jobs require.
Diagnosis
An Android pipeline runs three workloads with different resource shapes, and only some of them convert cores into shorter runs.
- Assemble: Kotlin and Java compilation, AAPT2 resource processing, and D8 or R8 dexing, parallel across modules and bounded by
org.gradle.workers.max. - Unit tests: parallel across forked JVMs, bounded by
maxParallelForkson each test task. - Instrumented tests: one or more emulators running Android system images, with Gradle, adb, and the instrumentation runner working beside them on the host.
Two defaults set the trap. Gradle derives org.gradle.workers.max from the number of processors it sees, documented in the Gradle build environment reference, so a label change silently changes the worker count. The JVM then sets its default maximum heap to one quarter of physical memory when nothing overrides it, which on a 32GB runner is 8GB per process, and the Kotlin compile daemon is a separate JVM with its own heap set through kotlin.daemon.jvmargs in the Kotlin Gradle plugin documentation.
The emulator adds a claim neither default accounts for. emulator -cores 4 -memory 4096 gives the guest 4 vCPU and 4096MB of RAM, both documented on the Android emulator command line reference, and both are committed from device boot until the job ends. Add roughly 2GB for the QEMU process, adb, and the page cache holding the system image, and one device costs 4 vCPU and 6GB before Gradle compiles anything.
Three symptoms follow, and they read as unrelated until the heaps are added up. The job ends with exit code 137, which is the kernel OOM killer. Instrumented tests fail on install rather than on assertion, because the package manager is swapping while the test APK lands. Wall clock rises while CPU utilization stays flat, which is GC pressure or a device waiting on a contended core.
One failure mode no runner size repairs: an emulator running without hardware acceleration. The log signature is ProbeKVM reporting no permission on /dev/kvm, followed by a line disabling Linux hardware acceleration and an emulator command line containing -accel off. Fix acceleration first, as described on Android emulator tests on GitHub Actions, then size the machine. CI observability reports per-job CPU and memory utilization, which is how you separate a starved runner from a misconfigured one.
Fix
Size from a budget. Committed memory equals the Gradle daemon heap, plus the Kotlin daemon heap, plus maxParallelForks multiplied by the fork heap, plus 6GB for each emulator running with -memory 4096. Keep the total at or under two thirds of the machine's RAM, and subtract the emulator -cores value from the vCPU count before choosing a worker count.
This applies the rule across the Linux x64 ladder in the cloud runners documentation, with per-minute rates from the pricing page, checked on 2026-08-13. Every row carries 150GB of SSD storage.
| Runner label | vCPU | RAM | Rate per minute | Workers on a build job | Emulators at -cores 4 -memory 4096 | Workers left with one emulator | Nested virtualization |
|---|---|---|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | 2 | 0 | none | Label accepted, device unusable at this size |
warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | 4 | 1 at -cores 2 -memory 3072 | 2 | Yes, with the label |
warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 | 8 | 1 | 4 | Yes, with the label |
warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 | 12 | 2 | 12 | Yes, with the label |
warp-ubuntu-latest-x64-32x | 32 | 128GB | $0.064 | 16 | 4 | 16 | Yes, with the label |
The 8x row is the working default, and the arithmetic behind it is: a 6g Gradle daemon heap, a 2g Kotlin daemon heap, and two test forks at 3g each is 14GB, and one emulator adds 6GB for a total of 20GB against a 21.3GB two-thirds ceiling on 32GB. The same budget on the 4x row overflows: 4g plus 1g plus one 2g fork is 7GB, and a device at -memory 3072 adds 5GB for 12GB against a 10.7GB ceiling, which is why an emulator job that fits on paper at 4 vCPU spends its time in GC and adb timeouts.
Nested virtualization is where the label differs from every other sizing decision. Emulator jobs run on Linux x86-64 cloud runners with ;nested-virtualization.enabled=true appended to the warp- label, and the nested virtualization reference carries the full support matrix. ARM64, Windows, and macOS labels ignore the suffix silently, so an emulator job placed there runs in software emulation with a workflow file that looks correct. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the ARM64 ladder remains a good home for assemble and unit test jobs that never boot a device. The catalog view of which sizes carry the feature is on runners with nested virtualization.
The rate lever works without any assumption about the build getting quicker. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. Both GitHub list prices come from the per-minute rate reference, checked on 2026-08-13.
Configuration
Set the worker count, every heap, and the device shape explicitly, because all three are derived from the machine by default and a label change moves them together.
org.gradle.parallel=true
org.gradle.workers.max=4
org.gradle.caching=true
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g
kotlin.daemon.jvmargs=-Xmx2gThe worker count is 4 rather than 8 because the emulator holds the other four cores. Build-only jobs that run on the same repository pass --max-workers=8 on the command line to take them back.
The instrumented job pins the label, the KVM suffix, the permissions step, and the device shape:
name: android-instrumented-tests
on:
pull_request:
push:
branches: [main]
jobs:
instrumented-tests:
runs-on: warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- uses: gradle/actions/setup-gradle@v4
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Confirm the device is writable
run: test -w /dev/kvm
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
cores: 4
ram-size: 4096M
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot-save
script: ./gradlew connectedDebugAndroidTest --max-workers=4The permissions step is standard Android emulator setup on all GitHub and GitHub-compatible runners, documented with reactivecircus/android-emulator-runner, and it is required because the default device permissions of crw-rw---- root:kvm stop the runner user from opening /dev/kvm. The test -w /dev/kvm line turns the most common misconfiguration into a failed step at the top of the log rather than a long test run at the bottom.
Assemble and unit test jobs in the same workflow stay on a plain warp-ubuntu-latest-x64-8x label with --max-workers=8. The surrounding pipeline is covered on Android builds on GitHub Actions.
Cost or Time Model
Take a repository running 600 pipeline runs per month with one instrumented job per run. The model assumes that job takes 24 minutes on warp-ubuntu-latest-x64-8x with one device, and that splitting the suite across two devices on warp-ubuntu-latest-x64-16x brings it to 14 minutes, since the shared assemble, install, and boot work does not halve. Substitute your own durations; the rates are fixed.
| Option | Arithmetic | Monthly cost |
|---|---|---|
warp-ubuntu-latest-x64-8x, one emulator | 600 x 24 min x $0.016 | $230.40 |
warp-ubuntu-latest-x64-16x, two emulators | 600 x 14 min x $0.032 | $268.80 |
| GitHub-hosted 8-core, same 24-minute profile | 600 x 24 min x $0.022 | $316.80 |
| GitHub-hosted 16-core, same 14-minute profile | 600 x 14 min x $0.042 | $352.80 |
Read two decisions off that table. Against the same shape, the label change moves the bill by $86.40 per month at 8 vCPU and $84.00 per month at 16 vCPU, with durations held equal on both platforms. Against the size change, the wider machine returns 10 minutes per run for $38.40 per month, which is 6,000 minutes of engineer waiting removed at $0.0064 per minute returned.
The break-even is arithmetic rather than judgment. The 16x rate is exactly double the 8x rate, so the two rows cost the same when the wider machine finishes in 24 x ($0.016 / $0.032) = 12.0 minutes. Below 12 minutes the wider machine wins on both wall clock and invoice, and above it you are buying feedback time. Emulator jobs rarely clear that bar, because AVD boot, APK install, and the serial tail of instrumentation do not shrink with cores, which is the practical argument for holding at 8x until the module graph or a second device forces the move.
Once the pipeline is live, replace these assumptions with measured numbers from CI observability, and use right-sizing GitHub Actions runners from measured usage for the fleet-wide version of this exercise. Snapshot runners, remote Docker builders, an MCP server, and the Action Debugger cover the rest of the toolchain, and the Action Debugger is the fastest way to inspect /dev/kvm and live adb state on the machine actually running the job.
FAQ
What size runner should an Android build use?
Assemble and unit test jobs start at warp-ubuntu-latest-x64-8x, which is 8 vCPU and 32GB at $0.016 per minute. Instrumented jobs that boot an emulator start at the same size and move to warp-ubuntu-latest-x64-16x at $0.032 per minute when a second device or a wide module graph shares the machine.
How much does the emulator take away from Gradle?
Whatever you give it. An emulator launched with -cores 4 -memory 4096 holds 4 vCPU and 4GB of guest RAM for the life of the job, and a further 2GB budget covers the QEMU process, adb, and the system image page cache. Size the machine for the build, then add 4 vCPU and 6GB per device.
Do assemble and unit test jobs need the nested virtualization label?
No. Only jobs that boot an emulator need /dev/kvm, so nested-virtualization.enabled=true belongs on the instrumented test job alone. Adding it to compile-only jobs constrains scheduling for nothing.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.