New K2 compiler in Kotlin. Part 1

1. Introduction

In this article, a community expert Spring IO Mikhail Polivakha will review the new K2 compiler for Kotlin. He'll first talk about what problems K2 aims to solve, and then other minor improvements that have been made. A guide to updating to the new version will be published in the next part of this article.

2. Reasons for creating a new version of the compiler

To understand the motivation behind the new K2 compiler, it is necessary to start with the history of the issue. Namely, it is important to understand what purpose Kotlin initially pursued.

2.1 Creating Kotlin

Kotlin has been around for quite some time. Development started in 2010, so we can say with confidence that it's been quite a while. The reason for the appearance of Kotlin is that in those days it was generally accepted that Java was developing rather slowly and was stagnating. The speed of language development was quite slow, and new features were a rare occurrence, compared, for example, with C#, in which the evolution of the language was much faster.

And Kotlin was created as a language that was supposed to fill this gap. The language was initially offered only as an improved version of Java, it was not intended to surpass or in any way replace Java since Kotlin runs on top of the JVM, it only offered additional features of the language. And there were quite a lot of these opportunities, for example

and many, many others. And all these features are incredibly in demand among ordinary developers.

2.2 Kotlin issues

However, we must understand that the more features a programming language has, the more complex it will be, and therefore the compiler will have to do more things to compile the project. Currently, Kotlin has more than 30 global (hard) keywords and about 20 contextual (soft) keywords, not counting a dozen other modifiers. For example, the Go language has only 25 keywords. The reason for this is that Go was designed to be as simple as possible in terms of functionality. Therefore, Go programs compile incredibly quickly compared to programs in other programming languages.

Well, what about Kotlin? Due to the many features that Kotlin has, as well as the complexity of the Kotlin type system, compilation time has always been its weak point.. For example, in the general case Kotlin compiles much slower than Java.

These numbers, of course, are quite controversial and highly depend on the configuration of the build process, the complexity of the code we wrote, etc.. The difference in compilation speed was especially noticeable on earlier versions of Kotlin. Experienced developers probably remember how slow code completion was back in the day in IntelliJ IDEA. Of course, the situation has improved over time, but the problem still exists.

3. K2 compiler. Improvements

Starting with Kotlin language version 2.0.0, the K2 compiler is used by default. This means that Maven and Gradle Kotlin plugins will now compile code using the K2 compiler. Let's discuss the improvements related to the compilation process in more detail. Let's start with the main thing – improving performance.

3.1. Compilation speed

The language developers, of course, knew about the problem with compilation speed and an alpha version of the K2 compiler was released in Kotlin 1.7.0. Its main goal was to improve the speed of program compilation and reduce the complexity of the compiler as a whole.. According to JetBrains, compilation speed improvements can reach 200% or more. It is important to understand that these tests only apply to specific JetBrains projects and may differ from ours.

3.2. Smart Casts

Since the Front-End part of the K2 compiler has been rewritten, the functionality of smart type casting has improved. We'll discuss what exactly has changed in the K2 compiler later, but for now we'll focus on the changes that are noticeable to the user. Let's look at an example

fun translateExecution(exception: Throwable?) : Throwable? {
    val isInvocationTargetException = exception is InvocationTargetException
    if (isInvocationTargetException) {
        return (exception as InvocationTargetException).targetException
    }
    return exception
}

This is a fairly simple code, we will not explain it in detail. It compiles with both new and old compilers. The problem is that we need to do an explicit type cast inside the expression if. In fact, this is redundant, since at this stage we should already know that the variable exception has type InvocationTargetException. However, the Kotlin K1 compiler (in this particular case) will not be able to infer the type of the local exception variable. This is due to the fact that K1 cannot bind the value isInvocationTargetException and local variable type exception. In other words, if isInvocationTargetException == truethen the type of the local variable exception exactly InvocationTargetException, but K1 cannot understand this relationship. But in the case of the K2 compiler, this is an explicit type cast inside the expression if would be redundant and the code would look like this:

fun translateExecutionV2(exception: Throwable?) : Throwable? {
    val isInvocationTargetException = exception is InvocationTargetException
    if (isInvocationTargetException) {
        return exception.targetException
    }
    return exception
}

This code will be compiled successfully by the K2 compiler. But it should be noted that the following code will still not work:

fun translateExecutionV2(exception: Throwable?) : Throwable? {
    if (isInvocationTargetException(exception)) {
        return exception.targetException
    }
    return exception
}

private fun isInvocationTargetException(exception: Throwable?) = 
  exception is InvocationTargetException

The K2 compiler does not track checks within functions. To solve this problem we have Contracts API.

There are many other improvements made to smart casting, but they all boil down to one simple thing: The K2 compiler is now better able to infer types, and explicit type casts are less common.

4. K2 compiler. Major changes

Let's now discuss what changes have led to the claimed productivity gains. To understand this, you need to first find out exactly what part of the compiler has been changed – mainly the Front-End part of the Kotlin compiler.

The front-end compiler is responsible for building the PSI (Program Structure Interface) – a specific syntax tree (Concrete Syntax Tree, CST) – the initial data structure that is assembled by the Front-End Kotlin compiler. Later, the Front-End Kotlin compiler builds a semantic tree FIR (Front-End Intermediate Representation), which is an abstract syntax tree (Abstract Syntax Tree, AST).

4.1 PSI vs FIR

The first problem with the old compiler was that it relied too much on the PSI structure, which is much larger and more complex by design than the FIR data structure. This is because PSI contains all the information present in the source code, while FIR is a sparser version. Therefore, working with FIR is generally faster.

Another problem with the old compiler was BindingContext. This is a huge collection of hash tables that stores semantic information about the program. So, for example, if we want to find a variable referenced in string interpolation, the old compiler needs to perform 2 lookups in the hash tables inside the BindingContext. The new K2 compiler does not do this, but instead relies on the tree structure of the data in the FIR. Accessing the value of a tree node is faster than 2 lookups inside a huge hash table.

4.2 Reducing the number of jumps between classes

Finally, the K2 compiler has significantly reduced the number of jumps required to determine, for example, the return value of a function. To understand what “jump” means, let's look at the following example:

// in ChildClass.kt
class ChildClass : ParentClass() {
    fun greeting(s: String) = hello(s)
}

// in ParentClass.kt
open class ParentClass {
    internal fun hello(s: String) = println("Hello, $s!")
}

So here the return type of the greeting() function matches the return type of the hello() function. Therefore, we need to first determine the return type of the hello() function in order to determine the return type of the greetings() function. However, we don't actually know which hello() function the user was referring to, so we need to find it first. It could be a local function (in the same class, for example), it could be a function of a parent class, which is most likely in a different .kt file, so we need to jump to the parent .kt file. The function you are looking for may not be in the parent file. Additionally, it may be a function included via an asterisk import, so we need to look for the top level hello() function in those files, etc. All this will jump into other source files.

Jump is relatively expensive in the link resolution process. The old Kotlin compiler performed a lot of jumps at almost every step of the compilation process. In contrast, the new Kotlin K2 compiler has only 2 stages, which include jump – inference of parent types (as in the example above) and implicit type declarations.

5. Multiplatform module

Two changes have also been made to the Kotlin Multiplatform module.

5.1 Module separation

In the past, the Kotlin compiler required both common and platform code to be present during compilation. In some scenarios, this could lead to situations where Kotlin common code called platform code. It is now possible to compile platform code separately from the common module. This approach is less error-prone and predictable in the behavior of code on different platforms.

5.2 Expansion of visibility

Now we can also change the visibility modifier for elecop actual so that it differs from expect. It is important to note that we can either expand the visibility level of the actual declaration compared to the expected one, or leave it the same, but not narrow it. Previously, the compiler required that the visibility modifier be the same for both expect element, and for actual. Let's look at an example:

// Common module
expect fun getPlatform(): Platform

// Android Platform
actual fun getPlatform(): Platform = AndroidPlatform()

In the example above, both functions implicitly have a modifier public. Now you can do this:

// Common module
internal expect fun getPlatform(): Platform

// Android Platform
actual fun getPlatform(): Platform = AndroidPlatform()

Here expect the function has the internal visibility modifier. In the same time actual the Android implementation, for example, has an implicit public modifier.

6. Conclusions

Kotlin has been around for quite a long time. During this time, it has accumulated many possibilities. Since Kotlin is ultimately compiled into Java bytecode (ignoring KMM and Kotlin Native), all of its features are implemented by the compiler. For this reason, the compilation process was quite complex. This resulted in slow compilation of Kotlin projects. The K2 compiler solves exactly this problem. It aims to simplify the compilation process, making it significantly faster. At the same time, it improves smart casting and allows the separation of common and platform code in KMM.


Join the Russian-speaking community of Spring Boot developers in telegram – Spring IOto stay up to date with the latest news from the world of Spring Boot development and everything related to it.

Waiting for everybody, join us!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *