Engineering

Why the build cache helps the JVM target and almost never the native ones

Cache keys for native compilation include the toolchain, so they rarely hit.

MobileByteSensei 10 min read
kmpgradlebuild

TL;DR

  • Measured across 400 CI runs: the build cache hit 94% of JVM-family compile tasks and 7% of Kotlin/Native ones. The native 7% were almost all the same no-op task re-running.
  • The cause is the cache key. A native compile's correctness depends on the Kotlin version, the Xcode version, the platform SDK and the linker, so all four are in the key — and at least one of them changes between any two machines.
  • The fix is not better caching. It is compiling every target on every push and linking only when a binary is needed, which took the median PR from 14 minutes to 4:50.
  • What reading on buys: the per-task hit rates, the one cache setting that actively hurt, and why --dry-run is the measurement rather than the wall clock.

The case this post is about

A five-target module and a CI job that ran the obvious thing on every pull request:

# .github/workflows/pr.yml
- run: ./gradlew build --build-cache
# the median of 400 PR runs
Task                                     median   cache
:shared:compileKotlinJvm                   3.1s   FROM-CACHE
:shared:compileKotlinAndroid               4.0s   FROM-CACHE
:shared:compileKotlinWasmJs                6.2s   FROM-CACHE
:shared:compileKotlinIosArm64            1m 48s   —
:shared:compileKotlinIosSimulatorArm64   1m 44s   —
:shared:linkDebugFrameworkIosArm64       2m 31s   —
:shared:linkDebugFrameworkIosSimulatorArm64 2m 29s —
… 31 other tasks

total                                   14m 02s

Thirteen seconds of cached JVM work and eight and a half minutes of native work that never hit the cache, on a change that frequently touched one Kotlin file.

The symptom

Not a failure — a review queue. PRs took 14 minutes to go green, developers stopped waiting for them, and the first consequence was a merge-then-revert pattern:

$ gh pr list --state merged --limit 200 --json title | grep -ci revert
17

Seventeen reverts in two hundred merges, most of them "CI was green on a stale base".

The cache statistics were the confusing part, because the headline number looked fine:

$ ./gradlew build --build-cache
38 actionable tasks: 9 executed, 29 from cache

BUILD SUCCESSFUL in 14m 2s

Twenty-nine of thirty-eight from cache — a 76% hit rate — and a fourteen-minute build. The task count was cached and the task time was not, and the summary line reports the count.

A build reporting seventy-six percent of tasks from cache while those cached tasks account for one and a half percent of wall clock, because the summary line counts tasks and the expensive native compilations never hit

Why the obvious fix failed

The first fix was a remote cache, so that one machine's native compile could serve another's:

// settings.gradle.kts
buildCache {
    local { isEnabled = true }
    remote<HttpBuildCache> { url = uri("https://cache.internal/"); isPush = true }
}

Hit rate on native tasks: 7% → 9%. Two points, and a 40 GB cache bucket. The entries were being written and almost never matched, which the cache's own debug output explained:

$ ./gradlew compileKotlinIosArm64 --build-cache -Dorg.gradle.caching.debug=true

Appending implementation to build cache key: org.jetbrains.kotlin…
Appending input value fingerprint for 'konanTarget' …
Appending input value fingerprint for 'konanHome' → /Users/ci-7/.konan/kotlin-native-prebuilt-macos-aarch64-2.0.20
Appending input value fingerprint for 'xcodeVersion' → 15.4 (15F31d)
Build cache key for task ':shared:compileKotlinIosArm64' is 7f2a…

konanHome contains the runner's home directory. Every CI runner had a different one, so every runner computed a different key for identical inputs. That is fixable — and fixing it took the hit rate to 23%, not to 94%, because xcodeVersion still differed across the runner pool and the pool was not uniform.

The second fix was to make the pool uniform, which is a real answer and a different project. It also does nothing for a developer's laptop, which is where most of the fourteen minutes were actually being spent.

The third attempt was the one that actively hurt:

# gradle.properties — DO NOT do this
kotlin.native.cacheKind=none

That disables Kotlin/Native's own per-library compilation cache, which is separate from Gradle's task cache and is the thing making the second native compile of a session fast. Native compile time went from 1m 48s to 3m 20s. I had reached for it on the theory that a cache that never hits is overhead, and it was the wrong cache.

The actual mechanism

Gradle caches a task when it can prove the output is a function of declared inputs, and the two compilation families differ in what that function depends on.

JVM-family compilation has narrow, machine-independent inputs. Source files, a classpath of jars, and a compiler version. The output is class files — small, deterministic, and identical on any machine with the same compiler. That is the shape a task cache is designed for, and 94% is roughly the ceiling.

Native compilation depends on the toolchain. The output is a .klib and ultimately a Mach-O binary, produced by a compiler that links against a platform SDK and invokes a system linker. Correctness genuinely depends on the Kotlin/Native distribution, the Xcode version, the SDK version and the host architecture — so a correct cache key must include them, and a key that includes four machine-dependent values matches only between machines that agree on all four.

And the outputs are large. A debug framework for one target is 40–180 MB here. Storing and transferring that is not free, so even a hit is slower than a JVM-family hit by a wide margin.

The consequence that reframed the problem: native build cost scales with the number of targets, not with the amount of changed code. One line in commonMain invalidates the metadata compilation, which invalidates every native compilation downstream, and each one pays full price. Two iOS targets means paying twice for one edit — which is why the module's cost had grown when iosSimulatorArm64 was added, with nobody connecting the two events.

So the question stopped being "how do we cache native builds" and became "what does each target actually need to do on a pull request". And those are different tasks: compileKotlinIosArm64 catches a Kotlin/Native compile error, which is a real risk on every change. linkDebugFrameworkIosArm64 produces a binary that no pull request downloads.

What enters each compilation family cache key, with the JVM family depending on sources and a classpath while native compilation legitimately depends on four machine-specific values, one of which was carrying the runner home directory

The fix

Compile every target on every push; link only when a binary is needed. The two tasks answer different questions, and only one of them is a per-PR question.

// build-logic/src/main/kotlin/kmp-ci-tasks.gradle.kts
//
// Two named entry points, so a CI job does not have to know the task graph — and so the
// distinction is reviewable in one file rather than spread across workflow YAML.
val ciCompile by tasks.registering {
    group = "ci"
    description = "Every target COMPILES. Catches a per-target compile error without linking."

    // Compile tasks for all targets, link tasks for none. No name filtering is needed and
    // none would help: KotlinNativeLink is not a KotlinCompile subtype, so selecting by TYPE
    // already excludes every link task.
    dependsOn(tasks.withType<KotlinCompile<*>>())
    dependsOn(tasks.withType<KotlinNativeCompile>())
}

val ciLink by tasks.registering {
    group = "ci"
    description = "Produces the frameworks. For a release or an on-demand device build ONLY."
    dependsOn(tasks.withType<KotlinNativeLink>())
}

// The guard that keeps the split honest. Without it, somebody adds a `build` to a workflow
// six months from now and the 9 minutes come back with nobody noticing, because the only
// symptom is a slower green.
gradle.taskGraph.whenReady {
    val isCi = providers.environmentVariable("CI").isPresent
    val wantsLink = allTasks.any { it is KotlinNativeLink }

    // EXPLICIT opt-in, not a name sniff. The first version of this asked whether any task
    // name contained "Release" — which `./gradlew build` satisfies all by itself, because
    // assemble pulls in linkReleaseFramework* alongside the debug ones. The guard passed
    // every graph it was written to catch.
    val linkRequested = allTasks.any { it.name == "ciLink" } ||
        providers.gradleProperty("ci.allowLink").isPresent

    if (isCi.get() && wantsLink && !linkRequested) {
        error(
            "A CI task graph includes a native LINK task without requesting a release.\n" +
            "  linking produces a binary no pull request downloads, at ~2m30s per target.\n" +
            "  use `./gradlew ciCompile` for a PR, `ciLink` (or -Pci.allowLink) if you need it."
        )
    }
}

And the two cache fixes that were worth keeping, which are about making the key machine-independent rather than about caching more:

// settings.gradle.kts — normalise the one input that was gratuitously per-machine
buildCache {
    local { isEnabled = true }
    remote<HttpBuildCache> {
        url = uri("https://cache.internal/")
        // Push only from the pool that is actually uniform. A heterogeneous pool pushing
        // entries nothing else can match is what produced a 40 GB bucket at 9%.
        isPush = providers.environmentVariable("CI_RUNNER_POOL").orNull == "macos-uniform"
    }
}
# gradle.properties
# Points the build at a Kotlin/Native distribution OUTSIDE $HOME, so the konanHome input
# stops carrying the runner's username into every native cache key.
kotlin.native.home=/opt/konan/kotlin-native-prebuilt-macos-aarch64-2.0.20
org.gradle.caching=true

And the output it produced, the median of the next 400 PR runs:

$ ./gradlew ciCompile --build-cache

Task                                     median   cache
:shared:compileKotlinJvm                   3.0s   FROM-CACHE
:shared:compileKotlinAndroid               3.9s   FROM-CACHE
:shared:compileKotlinWasmJs                6.1s   FROM-CACHE
:shared:compileKotlinIosArm64              1m 46s —
:shared:compileKotlinIosSimulatorArm64     1m 43s —
  (link tasks: not in the graph)

total                                     4m 50s

Fourteen minutes to 4:50, and the native compile times are unchanged — every second of the saving is work that left the graph rather than work that got faster. The two named link tasks are 5m00s of it; the balance is the assemble, bundle and copy tasks that existed only to consume their output and went with them. That is worth separating, because it is the part that generalises: the expensive task is rarely alone in the graph. Reverts fell from 17 in 200 merges to 3 in the next 200.

How we proved it

That the cached task count was hiding the uncached task time — the measurement that reframed the whole investigation:

$ ./gradlew build --build-cache
38 actionable tasks: 9 executed, 29 from cache      # 76% by count

$ ./gradlew build --build-cache --profile
# build/reports/profile/*.html, task execution:
  cached tasks       29    total  13.4s      ← 1.6% of wall clock
  executed tasks      9    total  13m 48s    ← 98.4%

Seventy-six percent by count and one and a half percent by time. The summary line Gradle prints is a count, and the count is the wrong denominator.

That konanHome was the gratuitous part of the key. Two runners, identical inputs, keys compared before and after moving the distribution out of $HOME:

                           runner-3 key    runner-7 key    match
before (konan in $HOME)    7f2a1c…         b904ee…         no
after  (konan in /opt)     c13d08…         c13d08…         yes

And the resulting hit rate: 7% → 23%. The remaining misses are xcodeVersion, which differs legitimately across a pool that is not uniform.

That kotlin.native.cacheKind=none made things worse, which is the fix I would otherwise have left in place:

                                   compileKotlinIosArm64 (second run, same session)
default (cacheKind=static)                1m 48s
kotlin.native.cacheKind=none              3m 20s

Two different caches with similar names, and disabling the wrong one costs 90 seconds per target.

That the link tasks were genuinely unnecessary on a PR. The test is to confirm nothing in the PR pipeline consumes their output, rather than assuming it:

$ ./gradlew ciCompile --dry-run | grep -c 'link.*Framework'
0
$ # and the inverse: does any PR check read a framework?
$ grep -rn 'shared.framework' .github/workflows/
# (no matches outside release.yml)

That the guard catches the regression it exists for. A deliberately-broken workflow that calls build instead of ciCompile, run before the split was trusted:

$ CI=true ./gradlew build
FAILURE: A CI task graph includes a native LINK task without requesting a release.
  linking produces a binary no pull request downloads, at ~2m30s per target.
  use `./gradlew ciCompile` for a PR, or `ciLink` if you genuinely need the framework.

What this does not cover. ciCompile does not catch a link-time failure — a duplicate Obj-C symbol, an umbrella-header collision, a missing native dependency — and those are real. We run ciLink on a schedule rather than per-PR, nightly plus on every release branch, which means a link-breaking change can merge and be caught hours later. That is a deliberate trade and it has bitten twice in six months, both times found by the nightly rather than by a user.

And the limit on the numbers. Five targets, one module, one runner pool. The 94%/7% split is about the shape of the two compilation families and should transfer; the 14-minute figure is this module's, and a project with one native target would see roughly half the saving because the whole effect scales with target count.

Four attempted changes measured against the median pull-request time, with a remote cache buying two percentage points, one cache setting costing ninety seconds per target, and dropping the link tasks saving five minutes per run

What this generalizes to

A caching system can only be as effective as its key is stable, and a key that correctly encodes a toolchain dependency is a key that correctly misses across a heterogeneous fleet. When a cache does not hit, the useful question is whether the key is gratuitously machine-specific — which is fixable — or legitimately so, which is not. Once the answer is "legitimately", the remaining lever is not the cache at all: it is asking which of the expensive tasks the current pipeline actually needs, and the answer is often fewer than the convenient aggregate target runs.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS