Optimizing Your Kotlin Build

Kotlin build times are often slow, this is a laundry list of common issues and what you can or can't do about them.

A beluga whale, which is white.
An actual white whale

Kotlin, strictly speaking, is awesome. Kotlin build times, strictly speaking, are not awesome. While many developers have weighed these and concluded the build times are worth it, it doesn't change the fact that they're paying this cost.

There are promising changes coming down the pipeline, but the unfortunate reality is that Kotlin builds today are often slow for avoidable reasons. Some are Gradle issues, some are Kotlin issues. This post is intended to detail a number of common pitfalls to be aware of and what you can do about them.

TL;DR: Modularize, ensure every project is a Kotlin project with at least one Kotlin source file, help test KSP now and Kotlin FIR later.


Disclaimer: Usage of the word "broken" below means different things to different people, so substitute it with whatever word you think suits the described issue best. "unexpected", "unreliable", "actually this a feature request", "source of pain", "literally worse than my ex", etc.

Build Cache

One of Gradle's main tools for improving build times is build caching. Build caching is the act of caching task outputs and reusing them if inputs to the task have not changed. It can be enabled for local builds (where the cache is located in ~/.gradle) via org.gradle.caching=true Gradle property and can be enabled remotely for sharing cached builds across multiple workspaces (CI, developer machines, etc).

When it works, it's great! When it doesn't, it's frustrating at best (cache misses) and a major problem at worst (incorrect/corrupt cache entries).

  • If you use the Wire Gradle plugin, it breaks remote build caching because it uses absolute paths. Avoid putting this on hot build paths. There are likely other Kotlin-generating plugins with similar issues. square/wire#1859.
    Edit 1: this is fixed in its next release now.
    Edit 2: There's another issue! square/wire#2006
  • Kotlin often breaks remote build caching because it uses absolute paths in some places. KT-43686 (just fixed in Kotlin 1.5.20!)
  • Kotlin build cache entries often break incremental compilation. KT-34862
  • Gradle build cache entries can only be used within the same major JDK version. This means that two builds targeting JDK 8 but built with JDK 8 and 11 respectively will produce incompatible cache entries. This applies to JavaCompile tasks as well. Note that you can build Kotlin all the way up to the (at the time of writing) current JDK of 16, but you may need some extra Gradle daemon JVM args to make it work.

It's worth noting that the local cache also breaks in the above cases if you have multiple repo clones in different locations, as the absolute paths fail to match in those places too.

There are a number of people that have deduced that disabling (remote) build cache for Kotlin compile tasks is a net-gain for them because incremental builds are more reliable as a result. See the discussion on KT-34862 for more details.

Incremental Compilation

  • If you have any pure Java projects, they will break incremental compilation in every project that depends on them, directly or transitively. To resolve this, you should add a simple private unused Kotlin file in those projects to ensure kotlinc generates incremental data. This applies to resources-only projects too, as they produce effectively a "Java-only" project with their R.java/R.jar files and possibly generated ViewBinding files. KT-30980 KT-38622
  • If any annotation processors generate non-deterministic outputs, this will break build caching. Most common ones take care not to do this, but it's worth double checking that one random one someone added from jitpack once. A classic example is ordering mismatches or nondeterministic names.
  • Non-incremental annotation processing, while still compatible with "incremental" IC, will obviously slow down your build. Kapt will verbosely warn about these processors in recent versions of Kotlin.
  • Any resource "ABI" change (new resource, removed, etc), is a breaking ABI change to code too and kotlinc will recompile non-incrementally. KT-40772
  • Running clean will wipe incremental compilation data and you should avoid running this when possible. Otherwise the next build will be non-incremental and you won't restore incremental builds until after.

You can find a detailed writeup on future needs for improving incremental compilation, caching, and current issues with both in KT-40184.

Task Configuration

  • Gradle AbstractCompile tasks (i.e. KotlinCompile) are sensitive to classpath jar ordering. Even if you have distinct jars A, B, and C on your classpath for a KotlinCompile task, it will be invalidated if their order on the classpath changes. You can see this in a Gradle build scan. This is the one that burns our repo the most. gradle/gradle#15626
  • If adding freeCompilerArgs in a KotlinCompile task, be sure to add to the existing list and don't replace it. Otherwise you may accidentally replace/clear existing arguments. JetBrains is hoping to improve this in Kotlin 1.6. KT-41985
  • KotlinCompile tasks inherit Gradle's source/target compatibility properties but doesn't use them. They're still used by Gradle as inputs though, so it's useful to set them to something like "unused". KT-32805
  • KaptWithoutKotlincTask eagerly resolves dependencies during construction/configuration. KT-47853
  • Wire's Gradle plugin also eagerly resolves dependencies during configuration time. square/wire#1637

Kapt

  • Kapt is highly susceptible to classpath changes. This applies even if they are implementation dependencies of upstream dependencies, so it's better to avoid transitive dependencies where possible (i.e. split into api/impl projects, only depend on api). Anything that participates in stub generation can affect its incrementalism.
  • Kapt does not support multiple processing rounds for generated Kotlin code. Only Java code, as it's handed off to javac. KT-41064
  • Kapt runs its tasks on all configurations that it registers, including both main and test source sets. This means kapt may be running on your tests even if it doesn't need to. More here.
    Edit: this was just fixed in 1.5.20 via KT-24533

Future

  • Most of the issues linked above have some plans for eventual fixing. In particular, Google engineers (namely Ivan Gavrilović) have contributed a lot of Kapt fixes.
  • KSP is a new tool from Google in the pipeline that aims to supplant Kapt with promises of improved performance, support for multiple rounds, no stub generation, and is actively developed/maintained. If you use Room or Moshi, please give it a try in your project and help test it with moshi-ksp or Room 2.3.0-beta02 (or later). Dagger is actively working on adding KSP support as well using the same shared infrastructure as Room's implementation.
  • Kotlin needs a performant compilation avoidance mechanism, which can also support better build cache hit rate and incremental compilation after a cache hit. This will also help when Android resources are modified.
  • Kotlin FIR is the eventual new frontend compiler, offering significant compilation speed improvements (currently ~2-4x faster). It will potentially be available for testing later this year, so consider using shadow jobs to help test it when that happens.
  • Namespaced resources (i.e. android.nonTransitiveRClass) can help avoid breaking IC when resources are modified in some cases (namely javac) (KT-40772) but needs help from compilation avoidance to better support Kotlin.
  • JetBrains hopes to improve compilation avoidance and remote caching in Kotlin 1.6 by moving away from history files (which can't be relocated) and moving from producer-side history storing to consumer-side. They are also hoping to make IC indifferent to classpath jar ordering, which would hopefully avoid gradle/gradle#15626.
  • Configuration Caching is an experimental new feature in Gradle to effectively cache configuration and allow for nearly-instant execution (i.e. tasks start executing almost immediately after you invoke the Gradle command). The plugin community is slowly catching up, but Kotlin should hopefully support this in 1.5.30 assuming no more issues (please help test 1.5.30-M1!).

Supplementary tools/patterns

  • Modularization. Incremental compilation will always be slower than not compiling at all. Split up monolithic subprojects and avoid build bottlenecks.
  • Anvil is a compiler plugin from Square that can, on top of its special dagger complementing features, replace Kapt in simple projects only using dagger to generate factories.
  • Anvil, Kapt, and KSP are all built on top of something called AnalysisHandlerExtension, which is something you could build your own plugins on too if you don't want to go all the way down to the IR layer. Anvil is probably the simplest example to look at for this.
  • Some annotation processors offer functionally equivalent reflection implementations, such as Moshi. For these cases, you could (at the cost of build cache) speed up local builds by using reflection in debug builds and kapt only on CI and prod builds.
  • In the event of a bad cache entry, you can rebuild with --rerun-tasks to force task re-runs and populate new cache entries.
  • Nuclear option: make all developers on your team + CI clone the repo into the exact same path to avoid remote build cache issues. Horribly inflexible, but honestly not terrible in practice.

What about Bazel/Buck/Pants/etc?

Your mileage may vary, but it's probably slower. There's no notion of IC for Bazel-like build systems and Kotlin relies heavily on IC to smoothen over its lack of better compilation avoidance. Bazel-like builds may handle simple compilation avoidance better in some cases though.

Conclusions

Some of it is on Kotlin for often allowing critical build tools like Kapt to fall behind, some of it is on Gradle for having legacy APIs that regularly invite you to do the wrong thing (I hope they'll consider a progressive mode or more aggressive deprecation policy).

The easiest way to test for caching issues is to:

  1. Run the same build twice with --scan and see which tasks weren't cached.
  2. After you've resolved #1, have two project clones in different locations and run identical builds with --scan, then compare them to see which tasks weren't cached.

You can find more common caching problems on Gradle's docs here.

The easiest way to test for IC issues is to run a Gradle build with --debug and grep for [IC] to log detailed incremental compilation data about all this from kotlinc. It's noisy so better to pipe it to a file and search in a text editor of your choice.

Edit: @atsvetkv pointed me at the following Gradle properties that will be less noisy for debugging IC issues and getting metrics!

# gradle.properties for debugging
kotlin.build.report.enable=true
kotlin.build.report.verbose=true

# gradle property for metrics
kotlin.build.report.metrics=true
My possibly controversial 2¢: You should take one pass at cleaning up IC issues in your project, then focus on modularizing in the long run rather than rely on IC. Simple* compilation avoidance will win every day of the week and no other investment will yield as significant of results or be as broadly applicable. Incremental compilation is and always will be the white whale of Gradle and Kotlin build performance.

Special thanks to Nelson, Ivan, Eugene, and Tony for reviewing this.

*I define simple compilation avoidance as just avoiding project dependencies. There is also "true", ABI-based compilation avoidance discussed above but not yet available.