Better Kotlin — 마이크로 최적화
Kotlin의 성능에 작은 영향을 줄 수 있는 요소들을 찾아보고 어떻게 개선할 수 있을지에 대한 이야기들입니다. 원문은 각각 Romain Guy 와 Jake Wharton 입니다.
minOf()
함수 최적화
Kotlin 표준 라이브러리는 훌륭한 API 들을 제공하지만 자세히 보면 몇가지 흥미로운 함정을 숨기고 있습니다. 그중 이번에는 minOf()
함수가 갖는 놀라운 함정에 대해서 알아보겠습니다. minOf()
함수는 Android 나 JVM 에서 각각 min()
함수로 대체되어 사용됩니다.
fun minOf(a: Float, b: Float, c: Float) = min(a, min(b, c))
이제 minOf 함수를 사용하여 각각의 색상에서 RED, GREEN에 대한 최소값을 찾기위한 코드를 작성해 보겠습니다.
var minColor = Float.MAX_VALUE
for (color in colors) {
minColor = minOf(minColor, color.red, color.green)
}
그리고 위 코드의 colors
에 9,999 가지 색상을 넣어 성능을 측정한 결과 입니다.
- minOf(3) 86,815 ns 0 allocs
하지만 위 코드에는 RGB중 R과 G 에 대한 최소값만 구하고 있군요. 이제 누락 되었던 RGB 중 B에 대한 코드를 추가해보겠습니다.
var minColor = Float.MAX_VALUE
for (color in colors) {
minColor = minOf(minColor, color.red, color.green, color.blue)
}
처음엔 minOf()
함수로 3개의 색을 비교하고 있고 다음 코드에서는 4개의 색을 비교하고 있습니다. 따라서 두번째 코드의 경우 첫번째 코드의 성능 측정값보다 1/3 에서 2/3 정도 더 걸릴것으로 기대하는것이 일반적입니다. 그럼 두번째 코드도 첫번째와 동일한 방법으로 성능을 측정해 볼까요?
- minOf(4) 256,048 ns 9999 allocs
놀랍게도 이 결과는 첫번째에 비하여 2배는 더 느립니다. 첫번째에 없었던 메모리 사용양도 주목할만합니다. 왜 이런 결과가 발생한걸까요? 표준라이브러리의 코드를 확인해보겠습니다.
public actual fun minOf(a: Float, vararg other: Float): Float {
var min = a
for (e in other) min = minOf(min, e)
return min
}
앞서 확인했던 3개의 파라메터에 대한 minOf()
함수와 다르게 4개의 파라메터에서부터는 `minOf()` 함수를 반복하여 호출하는 방식으로 구현이 바뀌어 있습니다. 위 코드가 Android 의 DEX 바이트 코드로 컴파일 되면 다음과 같이 표현됩니다.
const/4 v0, #int 3 // #3
new-array v1, v0, [F // type@0037
const/4 v2, #int 0 // #0
aput v4, v1, v2
const/4 v4, #int 1 // #1
aput v5, v1, v4
const/4 v4, #int 2 // #2
aput v6, v1, v4
if-ge v2, v0, 0017 // +000b
aget v4, v1, v2
invoke-static {v3, v4}, Ljava/lang/Math;.min:(FF)F // method@0008
move-result v3
add-int/lit8 v2, v2, #int 1 // #01
goto 000c // -000a
vararg
를 통해 전달된 배열이 생성되어 메모리를 할당받고 있는걸 확인할 수 있습니다. 3개의 값중 최소값을 찾는 코드에 비하여 불필요한 배열이 사용되고 반복문이 추가됨으로써 위와 같은 성능 측정 결과가 나왔다고 볼 수 있습니다. 이런 점을 개선하기 위해서 4개의 매개변수를 사용하는 minOf()
를 재정의하여 사용하는 방법이 있습니다.
inline fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float {
return minOf(a, minOf(b, minOf(c, d)))
}
이 구현에 대한 DEX 바이트 코드는 기대했던 그것과 일치합니다.
invoke-static {v2, v3}, Ljava/lang/Math;.min:(FF)F // method@0008
move-result v2
invoke-static {v1, v2}, Ljava/lang/Math;.min:(FF)F // method@0008
move-result v1
invoke-static {v0, v1}, Ljava/lang/Math;.min:(FF)F // method@0008
move-result v0
불필요한 배열도 반복문도 없습니다. 이 코드를 이전의 코드와 함께 성능측정한 결과는 다음과 같습니다.
- minOf(3) 86,815 ns 0 allocs
- minOf(4) 256,048 ns 9999 allocs
- fastMinOf(4) 114,490 ns 0 allocs
메모리 사용양은 다시 0으로 돌아왔고 벤치도 2/3에 가까워졌습니다.
isBlank() 함수의 속도 향상
isBlank()
함수는 호출된 문자열이 비어있거나 공백으로만 구성되어있을 경우 true
를 반환하는 아주 무해해 보이는 평범한 API 입니다. 하지만 정말 무해할까요? JVM 에서의 구현을 살펴보겠습니다.
public actual fun CharSequence.isBlank(): Boolean =
length == 0 || indices.all { this[it].isWhitespace() }
위 코드는 아주 간단하고 명확해보입니다. 하지만 불행하게도 바이트코드 사정은 좀 다릅니다.
const-string v0, "s" // string@0080
invoke-static {v6, v0}, Lkotlin/text/CharsKt__CharKt;.checkNotNullParameter:(Ljava/lang/Object;Ljava/lang/String;)V // method@002a
invoke-interface {v6}, Ljava/lang/CharSequence;.length:()I // method@0006
move-result v0
const/4 v1, #int 1 // #1
if-eqz v0, 0069 // +005f
new-instance v0, Lkotlin/ranges/IntRange; // type@001c
invoke-interface {v6}, Ljava/lang/CharSequence;.length:()I // method@0006
move-result v2
add-int/lit8 v2, v2, #int -1 // #ff
const/4 v3, #int 0 // #0
invoke-direct {v0, v3, v2}, Lkotlin/ranges/IntRange;.<init>:(II)V // method@0026
instance-of v2, v0, Ljava/util/Collection; // type@0017
if-eqz v2, 0026 // +000c
move-object v2, v0
check-cast v2, Ljava/util/Collection; // type@0017
invoke-interface {v2}, Ljava/util/Collection;.isEmpty:()Z // method@001b
move-result v2
if-eqz v2, 0026 // +0003
goto 0064 // +003f
invoke-virtual {v0}, Lkotlin/ranges/IntProgression;.iterator:()Ljava/util/Iterator; // method@001e
move-result-object v0
move-object v2, v0
check-cast v2, Lkotlin/ranges/IntProgressionIterator; // type@001b
iget-boolean v2, v2, Lkotlin/ranges/IntProgressionIterator;.hasNext:Z // field@0007
if-eqz v2, 0064 // +0035
move-object v2, v0
check-cast v2, Lkotlin/ranges/IntProgressionIterator; // type@001b
iget v4, v2, Lkotlin/ranges/IntProgressionIterator;.next:I // field@0008
iget v5, v2, Lkotlin/ranges/IntProgressionIterator;.finalElement:I // field@0006
if-ne v4, v5, 0047 // +000f
iget-boolean v5, v2, Lkotlin/ranges/IntProgressionIterator;.hasNext:Z // field@0007
if-eqz v5, 0041 // +0005
iput-boolean v3, v2, Lkotlin/ranges/IntProgressionIterator;.hasNext:Z // field@0007
goto 004c // +000c
new-instance v6, Ljava/util/NoSuchElementException; // type@0019
invoke-direct {v6}, Ljava/util/NoSuchElementException;.<init>:()V // method@001c
throw v6
iget v5, v2, Lkotlin/ranges/IntProgressionIterator;.step:I // field@0009
add-int/2addr v5, v4
iput v5, v2, Lkotlin/ranges/IntProgressionIterator;.next:I // field@0008
invoke-interface {v6, v4}, Ljava/lang/CharSequence;.charAt:(I)C // method@0005
move-result v2
invoke-static {v2}, Ljava/lang/Character;.isWhitespace:(C)Z // method@0008
move-result v4
if-nez v4, 005f // +000b
invoke-static {v2}, Ljava/lang/Character;.isSpaceChar:(C)Z // method@0007
move-result v2
if-eqz v2, 005d // +0003
goto 005f // +0003
const/4 v2, #int 0 // #0
goto 0060 // +0002
const/4 v2, #int 1 // #1
if-nez v2, 002a // -0036
const/4 v6, #int 0 // #0
goto 0065 // +0002
const/4 v6, #int 1 // #1
if-eqz v6, 0068 // +0003
goto 0069 // +0002
const/4 v1, #int 0 // #0
return v1
바이트 코드에서 확인 할 수 있듯 위 구현은 불필요한 IntRange
와 Iterator
객체를 생성하는 것을 포함한 여러가지 문제를 갖고 있습니다. Kotlin 의 구현을 다시 살펴보면 각 문자의 isWhiteSpace()
를 호출하기 위해 사용하는 Iterable
의 확장 함수인 all()
함수를 사용하고 있는데 제네릭을 사용하는 이 함수는 목록을 반복하기 위해 많은 추상화를 거치는 터무니없는 DEX 코드를 생성하게 됩니다.
public inline fun <T> Iterable<T>.all(predicate: (T) -> Boolean): Boolean {
if (this is Collection && isEmpty()) return true
for (element in this) if (!predicate(element)) return false
return true
}
따라서 all() 함수 대신 전통적인 for 문을 사용하여 성능을 개선 할 수 있습니다.
private inline fun CharSequence.fastIsBlank(): Boolean {
for (i in 0..<length) {
if (!this[i].isWhitespace()) {
return false
}
}
return true
}
코드는 여전히 이해하기 쉽고 생성된 DEX 바이트 코드도 훨씬 더 효율적입니다.
invoke-interface {v6}, Ljava/lang/CharSequence;.length:()I // method@0007
move-result v0
const/4 v1, #int 1 // #1
sub-int/2addr v0, v1
const/4 v2, #int 0 // #0
const/4 v3, #int 0 // #0
if-ge v3, v0, 0024 // +001c
invoke-interface {v6, v3}, Ljava/lang/CharSequence;.charAt:(I)C // method@0006
move-result v4
invoke-static {v4}, Ljava/lang/Character;.isWhitespace:(C)Z // method@0009
move-result v5
if-nez v5, 0021 // +000f
const/16 v5, #int 160 // #a0
if-eq v4, v5, 0021 // +000b
const/16 v5, #int 8199 // #2007
if-eq v4, v5, 0021 // +0007
const/16 v5, #int 8239 // #202f
if-eq v4, v5, 0021 // +0003
return v2
add-int/lit8 v3, v3, #int 1 // #01
goto 0008 // -001b
return v1
위 두개의 isBlank 를 1,000개의 문자열 목록을 사용하여 성능측정 할 경우 for 문을 사용한 쪽이 60% 더 빠르고 메모리 사용이 없다는걸 확인 할 수 있습니다.
- 37,998 ns 2001 allocs isBlank
- 14,943 ns 0 allocs isBlankForLoop
우리는 이걸 더 개선할수도 있습니다. 바이트코드를 다시 살펴보면 isWhitespace()
는 거의 동일한 역할을 하는 Character.isSpaceChar()
Character.isWhitespace()
를 둘 다 호출하고 있습니다.
public actual fun Char.isWhitespace(): Boolean =
Character.isWhitespace(this) || Character.isSpaceChar(this)
이 중 isSpaceChar()
는 다음과 같은 코드로 대체하여 사용할 수 있습니다.
private inline fun CharSequence.fastIsBlank(): Boolean {
for (i in 0..<length) {
val c = this[i]
if (!Character.isWhitespace(c) &&
c != '\u00a0' && c != '\u2007' && c != '\u202f') {
return false
}
}
return true
}
그리고 동일한 방법으로 성능을 측정해보면
- 37,998 ns 2001 allocs isBlank
- 14,943 ns 0 allocs isBlankForLoop
- 13,519 ns 0 allocs isBlankForLoopOneCall
약 5%의 성능 향상을 확인 할 수 있습니다.
Collection 의 중간 수집 회피하기
이 간단한 코드를 한번 같이 살펴 봅시다.
users.map { it.name }.joinToString()
사용자 목록이 주어지면 그 중 이름만 추출하여 쉼표로 결합된 문자열을 만들어 내는 코드입니다. 역시나 무해해 보이는 이 코드는 IntelliJ IDEA 에서 다음과 같은 경고를 만들어 냅니다.
Call chain on collection type may be simplified
위 코드는 컬렉션에 대한 Kotlin 의 확장 함수를 사용하여 보다 효율적으로 리펙토링 할 수 있습니다.
users.joinToString() { it.name }
이제 컬렉션을 맵핑하는 작업과 문자열로 결합하는 두 개의 작업이 아닌 단일 작업으로 문자열을 생성합니다. 즉 map()
함수를 제거할 수 있죠. 이 코드는 더 짧고 더 빠릅니다.
문자열이 아닌 배열을 생성하고 싶을때도 마찬가지입니다.
users.map { it.name }.toTypedArray()
위 코드 대신 이렇게 사용할 수 있습니다. 이때도 map
은 간단하게 생략될 수 있습니다.
Array(users.size) { users[it].name }
하지만 실제로 생각보다 배열은 잘 사용하지 않습니다. 메모리 또는 성능에 민감한 코드나 Java API 를 호출할때 주로 사용되죠. 고맙게도 이렇게 람다를 사용하여 초기화 하는 방식은 List
에서도 사용 할 수 있습니다.
MutableList(users.size) { users[it].name }
위 코드는 내부적으로 새로운 List
를 생성한 이후 repeat()
함수를 사용하여 람다 블록을 초기화합니다. map
을 사용할때보다 더 적은 연산을 수행하고 중간 컬렉션을 생성하지 않기때문에 메모리도 더 적게 사용합니다.
Benchmark Score Error Units
--------------------------------------------- ---------- -------- -----
NamesJoinToString.map 126.582 ± 38.237 ns/op
NamesJoinToString.map:·gc.alloc.rate.norm 232.000 ± 0.001 B/op
NamesJoinToString.lambda 73.586 ± 1.960 ns/op
NamesJoinToString.lambda:·gc.alloc.rate.norm 168.000 ± 0.001 B/op
NamesToTypedArray.map 78.444 ± 22.427 ns/op
NamesToTypedArray.map:·gc.alloc.rate.norm 120.000 ± 0.001 B/op
NamesToTypedArray.lambda 10.326 ± 0.129 ns/op
NamesToTypedArray.lambda:·gc.alloc.rate.norm 40.000 ± 0.001 B/op
Conclusion
여기까지 세가지 사례를 통해 성능 향상을 위한 작은 최적화 방법을에 대해 알아 봤습니다. 그렇다면 이런 작은 성능향상이 우리 프로그램에 어떤 영향을 끼칠 수 있을까요? 나날이 좋아지는 하드웨어의 성능에 비하면 이런 노력들은 크게 다가오지 않을 수도 있습니다. 하지만 이런 노력은 디버그 워크 플로우를 개선하고 다른 성능 측정에서 노이즈를 줄이는데 충분히 도움이 될 수 있습니다. 또한 메모리 사용량이 줄어들면 GC를 줄일 수 있다는것도 주목할만합니다. 무엇보다 중요한건 단순하게 보이는 한줄의 코드라도 예상치 못한 곳에서 성능에 악영향을 줄 수 있다는걸 깨달게 된것이 아닐까요!