DEV Community

Cover image for What Happens When You Kill the Kotlin Daemon Before R8?
Iñaki Villar
Iñaki Villar

Posted on • Edited on

What Happens When You Kill the Kotlin Daemon Before R8?

Every Android build spins up more JVM processes than most developers realize. Beyond the Gradle wrapper, there’s the main Gradle process orchestrating the build and several child processes created for specific tasks. These include test processes that execute in isolation, optional separate JVMs for tools like Lint or R8 when configured to run out of process, individual Java tasks that may also run in their own JVM, and the Kotlin compiler process, which delegates all Kotlin compilation units to a dedicated Kotlin daemon:

Understanding how these processes interact helps uncover hidden inefficiencies that affect both memory usage and build performance, especially in CI environments.

In general, these child processes terminate once their associated task has completed. However, that’s not the case for the Kotlin process. If we inspect the running JVMs after a build finishes, we can still see both the main Gradle process and the Kotlin daemon alive — even after all compilation work has ended (except when using --no-daemon):

3564 GradleDaemon
15564 KotlinCompileDaemon
Enter fullscreen mode Exit fullscreen mode

This behavior is intentional. The Kotlin daemon stays alive to speed up incremental builds by avoiding the startup overhead of a new compiler process. While that optimization is useful for local development, it provides little benefit in CI environments, where builds are typically clean and short-lived.

In Android builds, which are often highly modularized, the Kotlin compiler is required across all modules that include Kotlin sources. Because of this, the Kotlin process remains active throughout the compilation of all modules. The last compilation unit is typically the main entry point or Android application module, and these modules tend to be heavier, often running additional demanding tasks after Kotlin compilation. This makes it a natural point where the Kotlin process is no longer needed, and releasing its memory can benefit the tasks that follow. This detail can be crucial in environments that are close to their available memory threshold, where freeing the Kotlin process at the right moment can prevent OOM errors and improve overall build stability.

The main goal of this article is to experiment and measure the impact of changing this process behavior, based on the idea that a persisted process is not required once it has completed all the tasks associated with it.

R8 Tasks

In this analysis, we focus specifically on release builds. These builds execute the R8 task, whose main responsibility is to shrink, obfuscate, and optimize the app components that will be packaged into the final binary. This task plays a critical role in Android builds and is often a major contributor to build duration due to its computational cost.

An interesting aspect of R8 is that, by design, it runs at the very end of the build process, once all other compilation and linking phases have been completed:

Because of this sequencing, there’s no overlap between R8 and the Kotlin compiler within the same build variant. Once Kotlin compilation has finished, R8 operates independently, processing bytecode, resources, and dependencies. This means that the Kotlin process, still alive at this point, isn’t performing any work and can safely be terminated:

The goal is to free unnecessary memory before R8 executes, ensuring the system has as much available memory as possible for the final phase of the build. While this doesn’t directly address R8’s primary bottleneck, its CPU-bound processing, it introduces a secondary hypothesis worth testing:
tasks may execute faster in more isolated process environments, where memory and CPU resources face less contention.

How to terminate the process

The implementation is straightforward. We implemented a ValueSource that, through an injected ExecOperations, executes the command used to kill the existing Kotlin processes:

override fun obtain(): String {
    ...
    return try {
        execOperations.exec {
            try {
                commandLine("sh", "-c", parameters.commands.get())
            } catch (ignored: Exception) {
            }
        }
        ...
}
Enter fullscreen mode Exit fullscreen mode

This allowed us to create a new provider with the value source and the termination command:

val provider = project.providers.of(KillKotlinCompileDaemonValueSource::class.java) {
    parameters.commands.set(DEFAULT_COMMAND)
}

// Terminate Kotlin processes:
const val DEFAULT_COMMAND =
    "jps | grep -E \"KotlinCompileDaemon\" | awk '{print \$1}' | xargs -r kill -9"

Enter fullscreen mode Exit fullscreen mode

Then, we have created a task that receives the provider as input and executes the command during the task action:

abstract class KillKotlinCompileDaemonTask : DefaultTask() {

    @get:Input
    abstract val kotlinDaemonKillInfo: Property<String>

    @TaskAction
    fun killDaemons() {
        kotlinDaemonKillInfo.get()
        logger.lifecycle("Kill Kotlin compile daemon command executed")
    }
}

Enter fullscreen mode Exit fullscreen mode

Finally, we wire up when we want to execute the termination process. We register our task and make it run after the Kotlin compile task in the main application module.

Simple demonstration

How does this look in practice? If we analyze only the memory allocation of build child processes, we see that after our task runs and kills the Kotlin process, the impact is clear:

There is also a bigger benefit. We are not just releasing the heap allocation of the Kotlin process, we are freeing the entire memory footprint of that process and giving it back to the operating system:

The same applies in environments where Kotlin versions are not aligned and multiple Kotlin processes exist:

In summary, we are successfully returning the Kotlin process memory back to the OS and creating a lighter environment before R8 execution. Next, we analyze the results of the experiments.

Experiment environment

Because we want to measure the impact in CI builds, we set the entire experiment in GitHub Actions.
We selected three different projects:

Each project has two variants: the default main configuration, and a variant applying the R8Booster plugin in the Android application module. This plugin provides the task that terminates the Kotlin process right after the Kotlin compilation phase.
For the iterations, we ran one warm-up build to download dependencies and initialize the Gradle User Home, followed by 100 iterations of the assembleRelease task on fresh agents, reusing only dependencies and transforms from the cache.

The tasks under experiment were:

  • nowinandroid: :app:assembleProdRelease

  • synthetic project: assembleRelease

  • Signal Android: :Signal-Android:assemblePlayProdRelease

Results Experiment

After preparing the environment and running 100 iterations per variant, we aggregated the results to compare build time, R8 task duration, and peak memory usage.

The following table shows the results of the experiments for the main metrics across all projects, based on the mean values:

Project Build Time Improvement R8 Task Improvement Max Memory Reduction Notes
Now in Android 0.0% 1.5% 14.7% Minimal time change, strong memory reduction
Synthetic Project (120 modules) 1.7% 5.6% 13.3% Moderate, consistent gains across all metrics
Signal Android App 3.1% 7.0% 14.5% Best overall results, closest to a real project scenario

Terminating the Kotlin process after compilation had no negative impact on build performance and noticeably reduced memory usage across all projects.
The Signal Android app, representing a real-world scenario, achieved the best overall gains, with build times 3% faster, R8 7% faster, and memory reduced by about 15%.

nowinandroid

Build Scans:

Build time

Unit: seconds

Metric main Terminate Kotlin Process Diff Diff %
mean 283 283 0 0.0
median 283 283 0 0.0
p90 294 292 2 0.7
R8 Task

Unit: seconds

Metric main Terminate Kotlin Process Diff Diff %
mean 135 133 2 1.5
median 135 133 2 1.5
p90 140 137 3 2.2
Peak Build Memory

Unit: GiB

Metric main Terminate Kotlin Process Diff Diff %
mean 8.69 7.41 1.28 14.7
median 8.69 7.38 1.31 15.1
p90 8.81 7.72 1.09 12.4

Synthectic project 120 modules

Build Scans:

Build time

Unit: seconds

Metric main Terminate Kotlin Process Diff Diff %
mean 654 643 11 1.7
median 652 643 9 1.4
p90 676 660 16 2.4
R8 Task

Unit: seconds

Metric main Terminate Kotlin Process Diff Diff %
mean 90 85 5 5.6
median 90 85 5 5.6
p90 95 89 6 6.3
Peak Build Memory

Unit: GiB

Metric main Terminate Kotlin Process Diff Diff %
mean 11.77 10.20 1.57 13.3
median 11.72 10.19 1.53 13.1
p90 12.07 10.54 1.53 12.7

Signal Android

Build Scans:

Build time

Unit: seconds

Metric main Terminate Kotlin Process Diff Diff %
mean 604 585 19 3.1
median 601 583 18 3.0
p90 626 600 26 4.2
R8 Task

Unit: seconds

Metric main Terminate Kotlin Process Diff Diff %
mean 215 200 15 7.0
median 215 200 15 7.0
p90 225 207 18 8.0
Peak Build Memory

Unit: GiB

Metric main Terminate Kotlin Process Diff Diff %
mean 15.38 13.15 2.23 14.5
median 15.39 13.13 2.26 14.7
p90 15.45 14.13 1.32 8.5

Final Words

The results show a positive impact. In the project closest to a real application, we saw the best gains in memory reduction and measurable decreases in R8 and overall build times. This confirms the expectation that releasing the Kotlin compiler process returns OS memory in full and can help builds that have heavier Kotlin phases. In practice, this translates into a lower peak memory usage, which, when close to system thresholds, can be the difference between a successful build and an out-of-memory error in CI environments.

While we focused entirely on the R8 tasks, you may want to experiment with this approach in other scenarios where the Kotlin compiler is no longer required, but a heavier post-compilation task follows, such as Java compilation.

Remember that this approach was tested only for single-variant release builds. If you want to apply the same idea elsewhere, you will need to adjust the orchestration for when the Kotlin process should be terminated based on your project’s requirements.

Happy building!

Top comments (0)