Kotlin when(string) optimization

Yair Morgenstern
4 min readNov 20, 2024

TL;DR

  • Kotlin’s when(string) semantics allow flexibility, but default to if/else bytecode implementation when non-constants are involved, causing extraneous string equality checks
  • You can return the optimization by separating constant and non-constant strings to different whens
  • This optimization is a net benefit and thus should be in the compiler

Background

Unciv is pretty hyper-optimized by now, where hashmap lookups and String comparisons — even with cached hashes — feel far too slow.

I recently encountered that compiler optimization in Java 7 generates hashcode-based switching from String switch-cases, and recalled we have a function with a big String-based switch that’s a hotspot.

Given a 30s CPU profiling run, we see this function running ~500ms of a 30s run — in other words, 1.6% runtime — checking string comparisons. This is very noticeable for the first options, where we return a static constant value.

The Bytecode

Let’s take a look at just that first line, "Terrain" -> true , in IDEA’s Kotlin Bytecode tool (double-shift — ‘show Kotlin bytecode`) — annotated for readability.

L0
LINENUMBER 492 L0
ALOAD 1 // store the filter..
ASTORE 3 // in register 3
L1
LINENUMBER 493 L1
ALOAD 3 // load register 3 to the stack
LDC "Terrain" // push the string to compare to the stack, and compare
INVOKESTATIC kotlin/jvm/internal/Intrinsics.areEqual (Ljava/lang/Object;Ljava/lang/Object;)Z
IFEQ L2 // if value is 0 (false) go to next case
ICONST_1 // correct case! Load 1 (true)
GOTO L3 // go to 'return'
L2
LINENUMBER 494 L2
FRAME APPEND [java/lang/String]
ALOAD 3
LDC "All"
INVOKESTATIC kotlin/jvm/internal/Intrinsics.areEqual (Ljava/lang/Object;Ljava/lang/Object;)Z
IFEQ L4
ICONST_1
GOTO L5
L4
FRAME SAME
ALOAD 3
LDC "all"
INVOKESTATIC kotlin/jvm/internal/Intrinsics.areEqual (Ljava/lang/Object;Ljava/lang/Object;)Z
L5
FRAME SAME1 I
IFEQ L6
ICONST_1
GOTO L3
L6
...

We can see we’re sequentially checking each option using String equality — not great.

What if we take the first couple of cases and put them in another switch?

return when (filter) {
"Terrain" -> true
"All", "all" -> true
else -> false
}

Turns into:

LINENUMBER 492 L0
ALOAD 1 //
ASTORE 3 // store filter in register 3
ALOAD 3
INVOKEVIRTUAL java/lang/String.hashCode ()I // get hashcode
LOOKUPSWITCH // switch by hashcode! optimized! :D
65921: L1 // in compile time we generate the hashcode for each option
96673: L2
241216277: L3
default: L4
L1 // "All" hashcode case
FRAME APPEND [java/lang/String]
ALOAD 3 // Push filter to stack
LDC "All" // Push string to compare, and compare (below)
INVOKEVIRTUAL java/lang/String.equals (Ljava/lang/Object;)Z
IFNE L5 // if true, goto 'return true'
GOTO L4 // else goto 'return false'
L2
FRAME SAME
ALOAD 3
LDC "all"
INVOKEVIRTUAL java/lang/String.equals (Ljava/lang/Object;)Z
IFNE L5
GOTO L4
L3
FRAME SAME
ALOAD 3
LDC "Terrain"
INVOKEVIRTUAL java/lang/String.equals (Ljava/lang/Object;)Z
IFEQ L4 // if false goto 'return false'
L6
LINENUMBER 493 L6
ICONST_1
GOTO L7
L5 // 'return true' label
LINENUMBER 494 L5
FRAME SAME
ICONST_1 // push 'true' to stack
GOTO L7 // goto 'return'
L4 // 'return false' label
LINENUMBER 495 L4
FRAME SAME
ICONST_0
L7 // return value in stack
LINENUMBER 492 L7
FRAME SAME1 I
IRETURN
L8

Here we see the optimization in play, we initially find the hash of the string and then use a JVM lookupswitch on the hash vs precomputed hashes of the options to direct us to the only possible correct option — so we only run String.equals() once!

What makes it break is when we introduce a compiler unknown comparison, like

return when (filter) {
"Terrain" -> true
"All", "all" -> true
runtimeVariable -> false
}

The compiler cannot determine the value of all hashcodes at runtime, so it defaults to if/else equality. :(

Attempts to theoretically optimize can also harm you. The naive thought is, “if we have several strings that lead to the same result, instead of adding many cases let’s have a stored set of them and check if the set contains the value” — but instead, we ruin the compiler’s built in optimization for the entire when block!

Solution

We can solve this by splitting the original when into 2: one for constants, and the other for non-constants.

       return when(string){
"a" -> 1
"b" -> 2
"c" -> 3
runtimeVariable -> 4
"d" -> 5
else -> 6
}

becomes

      return when(string){
"a" -> 1
"b" -> 2
"c" -> 3
else -> when(string){
runtimeVariable -> 4
"d" -> 5
else -> 6
}
}

The Result

As a consequence of splitting it up, we went from ~500ms / 30s to ~40ms / 30s — in other words we shaved off 90% of the work, and saved 1.5% of runtime!

This belongs in the compiler

This problem is unique to Kotlin, since in Java it won’t let you use non-constant values in switch statements. The amazing versatility that when gives you works against you in edge cases that don’t fit Java’s notion of what a switch looks like. But since this is a guaranteed performance improvement, this should be part of the compilers job: separate cases into constant and non-constant, turn the constants into a lookup and the non-constants into if/elses.

And so, it became an official compiler suggestion :)

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Yair Morgenstern
Yair Morgenstern

Written by Yair Morgenstern

Creator of Unciv, an open-source multiplatform reimplementation of Civ V https://github.com/yairm210/Unciv

Responses (2)

Write a response

This is really cool Yair.
Great write up 💪

I always love a good low-level post regarding kotlin and performance, thanks for taking the time writing this up!