Inline (value) classes implementing interfaces can lead to worse performance

Value classes (I will call them inline classes) are useful for optimization: they can let you avoid object allocations when not really needed.
They have many constraint, however, they support implementation of Interfaces.

The trick that Kotlin uses to allow that can lead to bad performance.
Consider the following example:

interface MyInterface {
    fun doStuff() = 1 * 2 * 3 * 4 * 5
}

inline class InlinedClass(val id: Int) : MyInterface 

fun main() {
    val worker = InlinedClass(42)
    while (true) {
        worker.doStuff()
    }
}

Seems a very innocent code, right? But under the hood this is how it’s compiled to bytecode:

int worker = InlinedClass.constructor-impl(42);
while(true) {
   InlinedClass.doStuff-impl(worker);
}

class InlinedClass {
   public static int doStuff-impl(int $this) {
      return MyInterface.DefaultImpls.doStuff(box-impl($this));
   }
}

As you can see it is not more innocent: doStuff (which is called continuously) now calls box-impl, which is allocating an object.
If we remove the inline class modifier, only one object would be allocated throughout the whole execution, while, by making it inlined, we have a new allocation each round.

If we actually test the whole thing with the following code:

interface MyInterface {
    fun doStuff() = 1 * 2 * 3 * 4 * 5
}

inline class InlinedClass(val id: Int) : MyInterface

class NonInlinedClass(val id: Int) : MyInterface


val rt = Runtime.getRuntime()
inline fun measureMemory(block: () -> Unit) {
    val initial = rt.freeMemory()
    block()
    val final = rt.freeMemory()
    println("Memory used: ${initial - final}")
}

fun main() {
    val counter = 0..1_000_000

    println("Inlined #1")
    measureMemory {
        val worker = InlinedClass(42)
        for (i in counter) {
            worker.doStuff()
        }
    }
    println("NonInlined #1")
    measureMemory {
        val worker = NonInlinedClass(42)
        for (i in counter) {
            worker.doStuff()
        }
    }
    // Repeat the test after warmup
    println("Inlined #2")
    measureMemory {
        val worker = InlinedClass(42)
        for (i in counter) {
            worker.doStuff()
        }
    }
    println("NonInlined #2")
    measureMemory {
        val worker = NonInlinedClass(42)
        for (i in counter) {
            worker.doStuff()
        }
    }

}

The results confirm that using an inlined class is increasing the memory consumption:

Inlined #1
Memory used: 6251584
NonInlined #1
Memory used: 0
Inlined #2
Memory used: 3145728
NonInlined #2
Memory used: 0

So the final point is: is really worth to allow inline classes to implements standard interfaces? What’s the benefit? When the class is used as interface, the object is always boxed and will behave just like a non-inlined class. The only advantage is that, after the method call (in the example doStuff), the boxed element can be garbage collected, but the disadvantage is that you (kinda) lose control on where the boxing is happening and you may end up allocating multiple objects instead of only one.

Here’s another example where one may think that there is an improvement by using inline classes, but where there is actually zero (it’s always boxed):

interface Divisor {
    fun divide(num: Int): Int

    companion object {
        fun fromInt(div: Int): Divisor = DivisorImpl(div)
    }
}

private inline class DivisorImpl(val div: Int) : Divisor {
    override fun divide(num: Int): Int = num / div
}

fun main() {
    val divisor = Divisor.fromInt(10)
    println(divisor.divide(100))
}
1 Like

You seem to assume that only because an inline class implements an interface then it will always be used via that interface. That doesn’t have to be the case and in several cirumstances it’s very useful to have that feature. Like with every feature one needs to know what are its performance implications, the kotlin documentation is quite clear on this about inline classes. Furthermore relating to this example it might well be that in future vesions the compliler will be able to do a better job and create the wrapper once.

About this example:

this is a very bad usage of an inline class, I’d be suprised if someone had really written code like this. As a rule of thumb declaring a class as inline when it has a single field, is not a good practice.

I doubt that this issue will be an issue in the future. It looks like inline classes will not become stable as they are right now. There is a good chance they will be incorporated into value classes which will be compiled with Valhalla in the future (once that is part of the jvm). That should also solve most of the performance issues inline classes have right now.

I don’t assume that’s the only case, but that mixing different cases is not great. We can generalize like this: there are two sides of an inline class that implements an interface:

  1. Inline class side: when used non-boxed (or boxed in generics and nullables), for example when calling a class specific method.
  2. Interface: when used as a class that implements the interface (maybe calling an interface method or being returned by a function that returns the interface type).

On side 2, inline is completely useless. All the constraints that inline classes have (eg no backing field) are not even required, since the object is always boxed.
On side 1, you have zero benefits from the fact that is implementing an interface, as soon as you use that, you get it boxed and go into side 2. Eg, even this example is boxing the class:

interface MyInterface {
    fun doStuff(): Int
}
inline class InlinedClass(val id: Int): MyInterface {
    override fun doStuff() = 42
}
fun main() {
    val inlinedInstance: MyInterface = InlinedClass(42)
    inlinedInstance.doStuff()
}

Morover, there is the additional price of going from side 1 to side 2, given by the fact that every time you use it as an interface, you get an object allocation. And unless you are very careful with it, you don’t know where this allocation is going to happen, may even happen inside a tight loop (like in my first example).
I’m trying to think of a use case where all this struggle with inline classes constraints and caring where the allocation is going to happen, is worth it, but it’s not easy to think about that.
A solution without this feature would be a little bit more verbose but clearer: we could achieve the same by creating the inline class without implementing the interface, then a wrapping class that is not inlined but implements the interface and keeps a reference to the inline class.
This is the solution with inline classes implementing interfaces:

interface MInterface {
    fun doStuff()
    fun doMoreStuff() {
        for (i in 0..10) doStuff()
    }
}

inline class InlineClass(private val id: Int) : MInterface {
    override fun doStuff() = println(id)
    fun doClassThingy() = println("This is a class method")
}

fun main() {
    val instance = InlineClass(42)
    while (true) {
        // Ok, this is not boxed
        instance.doClassThingy()
        // Not ok, this is boxed
        instance.doMoreStuff()
    }
}

and with the more verbose but specific solution:

interface MInterface {
    fun doStuff()
    fun doMoreStuff() {
        for (i in 0..10) doStuff()
    }
}

inline class InlineClass(private val id: Int) {
    fun doClassThingy() = println("This is a class method")

    fun wrapped() = Wrapper(this)
    class Wrapper(private val ref: InlineClass) : MInterface {
        override fun doStuff() = println(ref.id)
    }
}

fun main() {
    val instance = InlineClass(42)
    val wrapped = instance.wrapped()
    while (true) {
        // Ok, this is not boxed
        instance.doClassThingy()
        // Ok, this is already wrapped
        wrapped.doMoreStuff()
    }
}

So from the inline class you cannot access Interface stuff (methods or even just inheritance) unless you clearly specify that you want to get it wrapped by calling the appropriate method.
Notice also that because our boxing class is a normal class, we don’t have the constraints of the inline class, for example we could do this:

inline class InlineClass(private val id: Int) {
    fun doClassThingy() = println("This is a class method")

    fun wrapped(additionalField: Int) = Wrapper(this, additionalField)
    class Wrapper(private val ref: InlineClass, val otherField: Int) : MInterface {
        override fun doStuff() = println(ref.id+otherField)
    }

which is not feasbile with the inline class+interface paradigm.
The option of implementing an interface with an inline class is just masking this wrapping thing, and merging it with boxing (used for generics and nullables), but you lose control over it because it’s the compiler doing it where it feels it’s needed and you can actually do bad pretty easily.

I agree it’s a bad usage but only if you know how inline classes are implemented: the actual class is not useless, without interface it makes sense, even if with just one field. For example, passing a Divisor to a function is not the same as passing an Int from an architectural pov. This is ok for me:

private inline class Divisor(private val div: Int) {
    fun divide(num: Int): Int = num / div
}

fun main() {
    val divisor = Divisor(10)
    println(divisor.divide(100))
}

I think the main issue here is that the compiler is missing a few optimisations w.r.t. calling interface methods on inline classes. For example, the doStuff method could possibly be inlined by copying the DefaultImpls method, make it a static fun on InlinedClass, make it accept the underlying type (int in this case), and then simply recursively resolve the interface methods that this method calls so that, in the end, there’s no need to box this method unless absolutely needed. Of course there’ll then have to be some extra compiler magic for breakpoints to work, but that’s already happening with inline methods and so that should be entirely fine. I’ve actually ran into some of those problems recently while developing a compiler plugin that depends on inline classes, and so I’ve come up with a few solutions that I’m including there to optimise inline classes more.