Post

Java 벤치마킹의 함정

이 포스팅은 특히 마이크로 벤치마킹에 대해 다룹니다.

100번 루프와 10만번 루프 중 어느것이 더 빠를까요? 바보가 아니라면 쉽게 알 수 있습니다.

테스트 해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RepeatedTest(1000)
void test1() {
    long start = System.nanoTime();
    // loop 100 times
    for (int i = 0; i < 100; i++) {
        Object obj = new Object();
    }
    long end = System.nanoTime();

    // collect execution time
    testResult1.add((end - start));
}

@RepeatedTest(1000)
void test2() {
    long start = System.nanoTime();
    // loop 100,000 times
    for (int i = 0; i < 100000; i++) {
        Object obj = new Object();
    }
    long end = System.nanoTime();

    // collect execution time
    testResult2.add((end - start));
}

1000회 반복 테스트 결과는 다음과 같습니다.

테스트 결과

데이터를 보면 초기에는 느리지만 전체적으로는 테스트2가 더 빠르네요. 이를 통해 루프를 10만번 돌리는 것이 100번 돌리는 것 보다 훨씬 빠르다는 것, 그리고 제가 바보라는 것을 알게 되었습니다.

물론 절대로 그렇지 않습니다. 실행시간 측정에는 많은 함정들이 숨어있습니다. 지금과 같은 작은단위의 벤치마킹에서 훨씬 위험하며 잘못된 지식을 가지게 될 수 있습니다.

이런 방식의 성능테스트는 과연 유의미할까요? 해당 주제에 대한 몇가지 정보와, 저의 생각을 정리해보았습니다.

⚠️ 1. JVM의 함정

1-1. 최적화로 인한 오류


Java 코드는 다음 과정을 거쳐서 실행됩니다.

  1. 컴파일 : 소스코드(~.java) -> 바이트코드(~.class) 변환합니다.
  2. 실행 : 변환된 바이트코드는 JVM에서 로딩 -> 검증 -> 기계어 번역을 통해 최종적으로 타깃 OS에서 실행됩니다.

문제는 기계어 번역과정에서 발생합니다.

기계어 번역은 JIT(Just-In-Time) Compiler가 담당하는데, 이때 상당한 최적화가 발생합니다. 예를들면

  • 함수가 인라인 삽입되어 호출스택이 줄어듭니다.
  • 인라이닝을 통해 for 루프를 벗기거나 루프 반복횟수를 줄입니다.
  • 사용되지 않는 변수를 제거합니다.
  • 자주사용되는 코드는 네이티브 메서드로 컴파일되어 JVM 코드영역에 캐싱됩니다.

이런 정보들을 통해 테스트결과의 원인을 추론해볼 수 있습니다.

  • for loop와 obj는 최적화됩니다. 해당 코드에서 obj는 실제로 10만개가 생성되지 않습니다. 아래 GC 섹션에서 설명합니다.
  • 최적화된 코드는 점차적으로 네이티브 메서드로 컴파일되어 캐싱됩니다.

실제로 확인하고 싶다면 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly VM 옵션을 통해 실행된 어셈블리를 확인할 수 있습니다. 결과를 분석하지는 않겠습니다.

test - 초반부 실행 test - 후반부 실행

확인해보면, 초기실행에 비해 후반부 실행의 어샘블리코드 양이 눈에 띄게 줄어있는 것을 확인할 수 있습니다.

1-2. GC를 통한 추가적인 판단


또한 JVM의 GC가 예측하기 힘들게 동작하는 점도 고려해야 합니다. 하지만 요즘의 GC는 성능이 매우 좋은 편이라 결과에 영향은 미미합니다. 따라서 GC의 로그를 보며 추가적인 정보를 얻는 용도로 사용해보겠습니다.

-XX:+PrintGC VM 옵션을 통해 GC의 실행과 소요시간을 확인할 수 있습니다.

GC detect

GC의 실행은 JVM의 정지(STW: Stop The World)를 발생시킵니다. 맨 우측 시간이 시행시간인데, 2ms 대로 굉장히 작습니다. 지금처럼 작은 실행단위를 측정할 때는 유의미한 차이이긴 하지만, 발생빈도 또한 작습니다.

오히려 Object 생성을 고려해야합니다. 위의 테스트에서 단 2줄을 추가해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static Object temp = null; // 1. Test 클래스의 static 필드를 추가했습니다.

@RepeatedTest(1000)
void test2() {
    long start = System.nanoTime();
    // loop 100,000 times
    for (int i = 0; i < 100000; i++) {
        Object obj = new Object();
        temp = obj; // 2. static 필드에 obj의 참조를 담습니다.
    }
    long end = System.nanoTime();

    // collect execution time
    testResult2.add((end - start));
}

GC and static field

결과는 매우 흥미롭게도, GC 실행이 굉장히 증가했습니다. 결과는 역전되어, 1000번 중 한번도 test2가 빠르지 않습니다. 더 재밌는 사실은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RepeatedTest(1000)
void test2() {
    long start = System.nanoTime();
    Object temp = null; // 1. test2 함수 스코프 내부로 temp 변수를 옮겼습니다.
    // loop 100,000 times
    for (int i = 0; i < 100000; i++) {
        Object obj = new Object();
        temp = obj; // 2. static 필드에 obj의 참조를 담습니다.
    }
    long end = System.nanoTime();

    // collect execution time
    testResult2.add((end - start));
}

obj를 담는 변수를 함수 내부로 옮기자, 결과는 처음과 같아졌습니다. new Object()에 대한 최적화가 수행되면, 다시말해 JIT 컴파일러가 해당 객체가 실제로 사용되지 않음을 알 수 있다면, 실제로 Object를 생성하지 않는다는 강력한 증거입니다.

다시 한번 결과를 정리해보겠습니다.

  • 첫 테스트에서 발생했던 GC는 모두 test1 영역에서 발생했습니다. test2에서는 GC가 아예 발생하지 않았습니다.
  • 2라인을 추가한 후, JIT 컴파일러가 함부로 new Object를 무시할 수 없게되자 test2영역에서 GC가 크게 증가했고, 결과는 역전되었습니다.

이제 test1이 더 느렸던 이유를 조금 그럴듯하게 설명할 수 있겠네요. test1은 최적화를 위한 임계치에 도달하지 못했기에, new Object()가 매번 실행되었기 때문에 test2보다 느렸습니다.

🤔 2. 그 외 고려사항들


JVM 문제가 아니더라도 벤치마킹은 잘못 작성하기가 쉽습니다.

  • 논리적인 실수를 할 수 있습니다. 예를들어 동기화 함수 성능을 싱글스레드 환경에서 테스트 할 수도 있습니다. 이 경우는 특히 동기화에 대한 JVM 최적화까지 발생할 수 있어서, 오히려 동기화 로직이 더 빠르다고 착각하게 될 수도 있습니다.
  • Java를 잘못사용할 수 있습니다. 무분별한 암시적 박싱/언박싱부터, 잘못된 자료구조 선택, 람다식의 생성비용을 놓치는 등 다양한 실수를 할 수 있습니다.
  • 실제 코드가 돌아갈 환경을 생각하지 않습니다. 병렬 스트림 등을 테스트하는데 실제 환경의 CPU 코어수를 고려하지 않는 실수를 할 수 있습니다. RAM 용량도 마찬가지입니다.

🎯 3. 결론

3-1. 프로파일링


앞서 소개한 함정들을 모두 고려하여 벤치마킹을 작성할 수 있습니다. 하지만 그 결과가 정말로 실제 사용환경에서도 똑같이 적용될 지는 확신할 수 없습니다.

  • 예를들어, 웹서버를 구현한다면 실제로 운영해보기 전에는 R/W 비율을 알기 힘듭니다.
  • 함수가 VM에 의해 네이티브 캐싱될 지 알 수 없습니다. VM 캐시에는 한계가 있으며, 캐싱조건에도 임계치가 있습니다. 고로 실제 운영환경에서 캐싱되지 않을 함수를 캐싱조건에서 테스트하면 의미가 없어집니다.

최적화에 대해 M. A. Jackson이 제시하는 지침이 있습니다.

  1. 하지 마세요
  2. (전문가용) 아직 하지마세요

실제 코드가 돌아가는 환경을 알지못하는 상태에서 최적화는 대부분 의미가 없습니다. JVM은 본질적으로 적응형 머신입니다. 만약 꼭 벤치마킹을 하고 싶다면 실제 코드와 최대한 유사한 환경을 만들어놓고 수행해야 합니다.

따라서 구현단계에서 너무 고민하지 말고 일반적인 지침을 따른 뒤, 프로그램이 완성된 후 프로파일링을 권장합니다. 프로파일링 데이터를 보면 실제로 어떤 함수의 어떤 부분이 시간과 메모리를 많이 잡아먹는지 한눈에 알 수 있습니다. 많은 지분을 차지하지 않는 함수는 아무리 바보같이 구현했더라도 당장은 최적화할 필요가 없습니다.

또한 많은 지분을 차지하는 함수더라도, 구현 자체에는 큰 문제가 없을 수 있습니다. 이런 경우 JVM의 힙사이즈, 캐시사이즈, GC전략 등을 튜닝하는 것이 답일 수도 있음을 분명히 인지하고 있어야 합니다.

3-2. JMH


위에서 알아본 것 처럼, 벤치마크 결과에서 많은 것을 기대하지 않는 것이 좋습니다. 하지만 모든 벤치마크가 의미없다는 것은 아닙니다.

다양한 함정들을 고려하여 작성할 수 있다면, 간단한 예제를 통해 원하는 통찰력을 얻을 수 있습니다. 다음 사항들을 고려하세요.

  1. 테스트 이전 해당 함수를 미리 여러번 실행하여 워밍업하세요.
  2. Dead Code의 작성을 피하세요. 앞서의 new Object();와 같이 사용되지 않는 코드를 의미합니다.
  3. 이외의 JVM 최적화들을 항상 염두에 두세요. 필요하다면 생성된 어셈블리를 체크합니다.
  4. 여러번 테스트 한 후 전체적인 결과를 보고 판단하세요.

이를 위해 JMH라는 Test Harness 를 사용할 수 있습니다. Java JIT Compiler의 작성자가 직접 제작했기 때문에 믿을 수 있습니다. 해당 라이브러리를 사용하면 마이크로 벤치마크에서 발생할 수 있는 실수를 상당 수 방지할 수 있도록 다양한 도구를 제공합니다.

Reference

  • https://web.archive.org/web/20200606234821/https://www.ibm.com/developerworks/java/library/j-jtp02225/
  • https://stackoverflow.com/questions/504103/how-do-i-write-a-correct-micro-benchmark-in-java
  • https://www.oracle.com/technical-resources/articles/java/architect-benchmarking.html
  • https://www.ibm.com/docs/en/sdk-java-technology/8?topic=compiler-how-jit-optimizes-code
  • https://www.geeksforgeeks.org/compilation-execution-java-program/
This post is licensed under CC BY 4.0 by the author.

Trending Tags