performance‎ > ‎

믿을 만한 자바 벤치마킹, Part 1: 다양한 이슈

posted Jun 20, 2010, 9:04 PM by Kuwon Kang   [ updated Jun 20, 2010, 9:05 PM ]

믿을 만한 자바 벤치마킹, Part 1: 다양한 이슈

자바 코드 벤치마킹에 관련된 여러 가지 함정 이해하기

난이도 : 고급

Brent Boyer, 프로그래머, Elliptic Group, Inc.

옮긴이: 오국환 dwkorea@kr.ibm.com
2008 년 7 월 29 일

고성능 하드웨어의 시대에서조차 프로그램 성능을 고려하지 않을 수 없습니다. 두 편의 연재 중 첫 편을 다루는 본 문서에서는 자바(Java™) 코드 벤치마킹과 관련된 여러 함정을 소개합니다. Part 2에서는 벤치마킹 관련 통계를 다루고 자바 벤치마킹을 위한 프레임워크를 제시할 예정입니다. 거의 대부분의 새 언어는 가상 기계 기반이므로, 본 연재가 다루는 일반적인 원칙은 프로그래밍 커뮤니티 전반에도 중요한 시사점을 제공합니다.

멀티메가헤르츠/멀티코어 프로세서와 멀티기가바이트의 시대에서조차 프로그램 성능은 변하지 않는 고려 사항 중 하나다. 신규 도전적인 애플리케이션으로 인해 (또는 프로그래머의 게으름이 늘어나서) 하드웨어의 성능 이점은 많이 누리게 되었지만, 코드를 벤치마킹하고 결과에서 정확한 결론을 얻기에는 여러 잠재적인 문제도 함께 갖게 되었다. 특히 다른 어떤 언어보다도 현대의 섬세한 가상 기계에서 실행하는 자바 언어를 벤치마킹하는 것은 매우 어렵다.

이 두 편의 연재에서는 프로그램 실행 시간만을 다룬다. 즉, 메모리 사용과 같은 다른 주요 실행 관련 특징은 고려하지 않는다. 이같이 제한된 성능 정의에서조차 코드를 정확히 벤치마킹하기에는 여러 함정이 도사리고 있다. 그 수나 복잡성 때문에 대부분 독자가 자체적으로 벤치마킹을 시도하면, 부정확하거나 종종 오해의 소지를 남긴다. 본 연재의 첫 부분은 이러한 이슈를 다루는 데 할애한다. 이로써 독자 고유의 벤치마킹 프레임워크를 작성하기에 필요한 지도를 펼쳐 보인다.

몇 가지 벤치마킹 이슈를 묘사하는 한 가지 성능 수수께끼로 본 논의를 시작하겠다. Listing 1의 코드를 검토해 보자(본 글의 전체 예제 코드는 참고자료의 링크를 참고하라).

protected static int global;

public static void main(String[] args) {
    long t1 = System.nanoTime();

    int value = 0;
    for (int i = 0; i < 100 * 1000 * 1000; i++) {
        value = calculate(value);
    }

    long t2 = System.nanoTime();
    System.out.println("Execution time: " + ((t2 - t1) * 1e-6) + " milliseconds");
}

protected static int calculate(int arg) {
    //L1: assert (arg >= 0) : "should be positive";
    //L2: if (arg < 0) throw new IllegalArgumentException("arg = " + arg + " < 0");

    global = arg * 6;
    global += 3;
    global /= 2;
    return arg + 2;
}

다음 중 어느 버전의 실행 속도가 가장 빠를까?

  1. 코드를 그대로 둔다(calculate 내 arg를 테스트하지 않음).
  2. L1만 코멘트를 해제하지만 조건 확인(assertion)은 비활성화하여 실행(JVM 옵션 중 -disableassertions 사용. 이는 JVM의 기본 동작임)
  3. L1만 코멘트를 해제하지만 조건 확인은 활성화하여 실행(JVM 옵션 중 -enableassertions 사용)
  4. L2만 코멘트를 해제한다.

적어도 테스트를 전혀 하지 않는 A가 가장 빠를 것이라고 짐작할 것이다. 그리고 좋은 동적 최적화 컴파일러가 죽은 코드인 L1은 제거할 것이므로, 조건 확인을 끈 B쪽이 A에 근접한 성능을 보일 것이라 짐작하리라. 그렇지 않은가? 불행히도 이러한 짐작은 틀렸다. Listing 1의 코드는 Cliff Click이 2002 자바원(JavaOne)에서 소개한 것을 수정하였다(참고자료 참조). 그가 발표한 실행 시간은 다음과 같다.

  1. 5초
  2. 0.2초
  3. (이 경우는 결과를 제시하지 않음)
  4. 5초

물론 B의 경우가 놀랍다. 어떻게 B가 A보다 25배 빠를 수 있나?

6년이 지난 후, 다음 설정으로 Listing 1의 코드를 돌려 보았다(다른 언급이 없을 경우, 본 문서에서 다루는 모든 벤치마크 결과에 사용된 설정은 이와 같다).

  • 하드웨어: 2.2GHz 인텔 코어2듀오 E4500, 2GB RAM
  • 운영체제: 윈도(Windows®) XP SP2(2008년 3월 13일 이전 업데이트 모두 적용)
  • JVM: 1.6.0_05, 모든 테스트에서 -server 옵션 적용

이렇게 얻은 결과는 다음과 같다.

  1. 38.601 ms
  2. 56.382 ms
  3. 38.502 ms
  4. 39.318 ms

이제 B가 A와 C, D보다 명백하게 느리다. 그렇지만 여전히 결과는 의아하다. B는 A와 동등해야 했다. 현실은 B가 C보다도 느리다는 점이 놀랍다. 각 설정 별로 네 번 측정하여 1ms 이내의 범위에서 완전히 재현 가능한 결과를 얻었다.

Click은 이상한 결과를 얻은 이유를 설명했다. (이러한 결과는 복잡하기 그지 없는 JVM의 동작 때문으로 판명되었다. 버그도 하나 관련 있었다.) Click은 HotSpot JVM 설계자이고, 이와 같은 합리적인 설명을 할 수 있었다. 그러나 보통의 프로그래머인 독자가 정확한 벤치마킹을 할 수 있을 것이라고 기대할 수 있는가?

그 답은 물론 '그렇다'이다. 본 연재의 Part 2에서 필자는 한 벤치마킹 프레임워크를 제시할 예정이다. 그 프레임워크는 벤치마킹 관련 여러 함정을 처리한다. 그러니 다운로드해 안심하고 쓰시라. 이는 또한 대부분의 벤치마킹 요구에도 쉽게 적용할 수 있다. 목적 코드를 작업 오브젝트의 Callable 또는 Runnable 타입으로 패키징하고 Benchmark 클래스의 메서드 하나를 호출하기만 하면 된다. 그 외 다른 것, 성능 측정, 통계 계산, 결과 보고서 생성 등은 프레임워크가 자동으로 수행한다.

Listing 1의 main을 Listing 2의 코드로 교체하여 그 프레임워크를 적용하고, 벤치마크를 다시 수행하였다.

public static void main(String[] args) throws Exception {
    Runnable task = new Runnable() { public void run() {
        int value = 0;
        for (int i = 0; i < 100 * 1000 * 1000; i++) {
            value = calculate(value);
        }
    } };
    System.out.println("Cliff Click microbenchmark: " + new Benchmark(task));
}

필자의 설정으로 이 코드를 수행하여, 다음의 결과를 얻었다.

  1. 평균 = 20.241 ms ...
  2. 평균 = 20.246 ms ...
  3. 평균 = 26.928 ms ...
  4. 평균 = 26.863 ms ...

마침내, A와 B는 본질적으로 거의 같은 수행 시간을 보인다. 인자 확인이 동일한 C와 D 역시 거의 동일한 수행 시간을 보인다.

Benchmark를 사용하면 기대한 결과를 얻는다. 아마도 이는 내부적으로 task를 여러 번 반복 수행하기 때문일 것이다. 더구나 이는 실행 프로파일이 안정화 상태에 돌입하기까지의 예열 결과는 무시하고, 오직 정확한 결과만을 수집한다. 반면 Listing 1의 코드에서는 즉시 실행 결과를 측정하는데, 이렇게 얻는 결과는 의도한 코드 자체보다 JVM 동작과 더 관련이 많을 수 있다. 상기 실험에서 보듯이, Benchmark에서는 이러한 JVM 동작의 영향을 배제하여, 신뢰할 수 있는 결과의 통계를 계산한다.

그렇지만, 이 프레임워크를 바로 사용하면 안 된다. 본 글이 다루는 일정 수준의 지식, 특별히 동적 최적화에 관련된 숨은 이슈들과 Part 2에서 다룰 몇 가지 해석 상 문제와도 친숙해져야 한다. 절대 맹목적으로 수치를 신뢰하면 안 된다. 수치를 얻은 방법도 알아야 한다.

위로

원칙적으로 코드 실행 시간을 측정하는 것은 단순하다.

  1. 시작 시각을 기록한다.
  2. 코드를 실행한다.
  3. 종료 시각을 기록한다.
  4. 시각 차를 계산한다.

대부분의 자바 프로그래머는 아마도 본능적으로 Listing 3과 같은 코드를 작성할 것이다.

long t1 = System.currentTimeMillis();
task.run();    // task is a Runnable which encapsulates the unit of work
long t2 = System.currentTimeMillis();
System.out.println("My task took " + (t2 - t1) + " milliseconds to execute.");

장시간 수행하는 작업에서는 Listing 3의 접근법이 대체로 무난하다. 예를 들어, task 실행에 1분이 걸린다면, 필자가 아래에서 다루려는 정밀도는 그다지 이슈가 되지 않는다. 그러나 task 실행 시간이 감소할수록, 벤치마킹 결과는 부정확해진다. 벤치마킹 프레임워크는 어떤 task든 자동으로 처리해야 하기 때문에, Listing 3의 실행 결과를 task의 내용과 관계없이 보장해야 한다.

한 가지 문제는 정밀도다. System.currentTimeMillis의 이름이 의미하듯이, 이 메서드는 명목상 밀리초 수준의 정밀도를 보장한다(참고자료 참조). 만일 결과가 ±1밀리초의 임의 오류를 포함한다고 가정하고 실행 시간 측정에서 1퍼센트 이내의 오류를 허용한다면, System.currentTimeMillis는 200ms 이내의 작업 실행 시간 측정에는 부적합하다(시간차 측정에는 2ms까지 더해진 두 번의 오류를 포함하기 때문이다).

현실적으로 System.currentTimeMillis는 ~10-100배까지 부정확한 정밀도를 제공할 수도 있다. 본 메서드의 Javadocs을 보면 다음과 같다.

반환값의 시간 단위는 밀리초지만, 값의 정밀도는 기반 운영체제에 따라 달라서, 단위가 이보다 커지는 경우도 있습니다. 예를 들어, 많은 운영체제에서는 시간을 10밀리초 단위로 계측합니다.

알려진 정밀도는 표 1과 같다.

정밀도플랫폼소스(참고자료 참조)
55 ms윈도 95/98Java Glossary
10 ms윈도 NT, 2000, XP 단일 프로세서Java Glossary
15.625 ms윈도 XP 다중 프로세서Java Glossary
~15 ms윈도(아마도 XP)Simon Brown
10 ms리눅스 2.4 커널Markus Kobler
1 ms리눅스 2.6 커널Markus Kobler

따라서 Listing 3의 코드는 대략 10초 이내에 수행되는 작업에는 부적합할 수 있다.

System.currentTimeMillis는 장시간 실행되는 작업에도 영향을 미칠 수 있다. 이 때 유념해야 할 이슈는 System.currentTimeMillis가 벽시계 시각을 반영하도록 되어 있다는 점이다. 이는 표준시에서 섬머타임으로의 변화 또는 NTP(Network Time Protocol) 동기화 등으로 시각값이 앞뒤로 갑자기 튈 수 있다는 뜻이다. 드물기는 하더라도, 이러한 시각값 보정으로 인해 오류투성이의 벤치마크 결과를 얻을 수도 있다.

JDK 1.5에서는 훨씬 높은 정밀도의 API인 System.nanoTime (참고자료 참조)을 제공한다. 이 메서드는 임의의 오프셋 이후의 나노초를 돌려 준다. 이와 관련된 몇 가지 유념할 핵심은 다음과 같다.

  • 이는 시간차 측정에만 유용하다.
  • 정확도와 정밀도(참고자료 참조)는 최소한 System.currentTimeMillis보다는 낫다(수준이 거의 같을 수도 있다).
  • 현대적인 하드웨어와 운영체제에서는 마이크로초 범위 내에서 정확도와 정밀도를 제공할 수 있다.

결론: 벤치마킹에서는 System.nanoTime이 더 나은 결과를 보장하므로, 반드시 이 메서드를 사용하라. 그러나 벤치마킹 코드에서는 여전히 이 메서드가 System.currentTimeMillis보다 그다지 나을 것이 없을 가능성까지 고려해야 한다.

JDK 1.5는 또한 ThreadMXBean 인터페이스(참고자료 참조)를 제공한다. 이 인터페이스에는 몇 가지 기능이 있지만, 특별히 벤치마킹과 관련된 getCurrentThreadCpuTime 메서드가 있다(참고자료 참조). 비록 가능성일 뿐이지만, 이 메서드로는 단순히 (벽시계) 경과 시간이 아닌 현재 스레드의 실제 CPU 사용 시간을 측정할 수 있다. 이렇게 측정한 사용 시간은 벽시계 경과 시간 대비 작거나 같다.

불행히도 getCurrentThreadCpuTime은 다음 몇 가지 문제점을 안고 있다.

  • 이 메서드는 독자들이 사용하는 플랫폼에서 지원되지 않을지도 모른다.
  • 지원하는 플랫폼마다 이 메서드의 의미가 다를 수도 있다(예를 들어, I/O를 사용하는 스레드에 I/O 처리 관련 CPU 시간이 포함될 수도 있고, I/O 처리 관련 CPU 시간은 OS 스레드에 대신 포함될 수도 있다).
  • ThreadMXBean Javadocs는 불길한 경고를 포함한다. "일부 자바 가상 기계 구현에 따라 스레드 CPU 측정을 활성화하면, 값비싼 비용을 치를 수도 있다." (이는 OS에 특화된 이슈다. 일부 OS에서는 스레드 CPU 측정에 필요한 마이크로어카운팅(microaccounting)을 항상 켜 두는 경우도 있어서, getCurrentThreadCpuTime이 추가적인 성능 저하를 유발하지는 않는다. 다른 대부분의 경우 기본으로 이것이 꺼져 있는데, 이를 활성화하면 그 프로세스의 모든 스레드 또는 시스템 내의 모든 스레드에서 성능 저하가 일어날 수도 있다.)
  • 정밀도가 불명확하다. (명목상 나노초의 정밀도로 결과를 돌려 주므로 이는 자연스럽게 System.nanoTime과 동일한 정확도와 정밀도를 갖는다고 생각할 수 있다. 그러나 이렇게 언급하는 문서는 어디에도 찾을 수 없고, 이 메서드가 훨씬 더 부정확하다는 보고서가 하나 있을 뿐이다(참고자료 참조). getCurrentThreadCpuTime을 사용한 필자의 경험으로는 nanoTime 대비 이 메서드가 더 작은 평균 실행 시간을 산출하였다. 필자의 데스크톱 설정에서 실행 시간은 0.5에서 1퍼센트 가량 적었다. 불행히도, 측정 결과의 일관성이 떨어지기는 한다. 예를 들어, 표준편차가 세 배씩 벌어지기도 했다. N2 솔라리스 10 기계에서 실행 시간은 5에서 10퍼센트 적었다. 이 차이가 증가하는 경우는 없었고, 때로는 아주 큰 감소를 보이기도 했다.)
  • 최악의 경우로 현재 스레드가 사용하는 CPU 시간이 실제 작업 수행 시간과 서로 연관성이 없을 수도 있다. 호출 쪽 스레드(CPU 시간을 측정하는 현재 스레드)를 가진 작업이 스레드 풀(thread pool)만 생성하여 한 움큼의 부분 작업을 풀로 보낸 후 풀이 종료할 때까지 대기하기만 한다고 생각해 보자. 스레드를 호출하기 위해 사용하는 CPU 시간은 미미하다. 반면 작업을 마치기 위해 걸린 시간은 훨씬 길다. 따라서 전적으로 오해를 살만한 실행 시간을 보고할 수도 있다.

이러한 이슈들 덕분에 getCurrentThreadCpuTime을 기본 적용하여 일반적인 목적의 벤치마킹 프레임워크를 작성하는 것은 너무나도 위험하다. Part 2에서 소개하는 Benchmark 클래스에서는 특별한 설정을 통해 선택적으로 이 메서드를 사용하도록 한다.

이상 소개한 시간 측정 API에 대해 한 가지 추가 주의 사항을 덧붙이면 다음과 같다. 이 API들에는 실행 오버헤드가 따른다. 이 오버헤드는 측정치 왜곡을 막으면서 API가 호출 가능한 빈도에 영향을 준다. 이러한 영향의 정도는 플랫폼에 따라 다르다. 예를 들어, 최근 윈도 시스템에서는 System.nanoTime이 마이크로초 이내에 실행되는 OS 함수 호출을 포함한다. 즉, 1퍼센트 이내의 측정 정확성을 유지하려면, 매 100마이크로초 이내에 한 번 이상 이 API가 호출되면 곤란하다. (반대로, System.currentTimeMillis는 글로벌 변수를 읽기만 하면 되므로, 나노초 이내에 아주 빨리 실행된다. 측정 정확성을 위해서라면 이 API를 좀 더 자주 호출하는 편이 낫다. 물론, 글로벌 변수가 매우 자주 — 표 1에 따르면 약 10에서 15밀리초마다 — there's no point in calling it more frequently.) 갱신되는 것은 아니므로, 이 API도 마냥 자주 호출할 수는 없다.) 반면에, 대부분의 솔라리스와 몇몇 리눅스(Linux®) 기계에서는 System.nanoTime이 System.currentTimeMillis보다 대체로 더 빨리 실행된다.

위로

성능 관련 수수께끼에서, Benchmark가 정상적인 결과를 도출한 것은 초기 성능에 대비해 task의 성능 안정화 단계의 실행 프로파일을 측정한 덕분이다. 대부분의 자바 구현은 아주 복잡한 성능 수명 주기를 갖는다. 보통 초기 실행 성능은 상대적으로 느리며, 안정화 상태에 돌입하기까지 한 동안 성능이 대폭 (대개 불연속적으로) 향상된다. 독자가 안정화 상태에서 성능 측정이 필요하다면, 안정화 상태로 이끄는 모든 요소를 이해할 필요가 있다.

JVM은 전형적으로 클래스를 처음 사용할 때만 로딩한다. 따라서 task의 첫 실행 시간에는 사용하는 모든 클래스를 로딩하는 (미리 클래스를 로딩하지 않았다면) 시간도 포함한다. 대체로 클래스 로딩은 디스크 I/O나 파싱, 검증(verification)과 연관이 있어, task의 첫 실행 시간을 대폭 부풀린다. 보통 task를 여러 번 반복 수행하여 이러한 효과를 어느 정도 중화시킬 수는 있다. (필자는 여기서 항상 대신 대개라는 표현을 사용했다. task에 복잡한 분기 동작이 있어 모든 클래스를 처음 실행에서 다 사용하지 않을 수도 있기 때문이다. 충분한 시간을 두고 코드를 실행하면, 희망사항이긴 하나 가능한 모든 분기를 다 수행하여 관련 클래스도 모두 로딩할 것이다.)

별도로 작성한 클래스로더를 쓰게 되면, 쓰레기(garbage)가 된 클래스를 JVM이 언로딩(unloading)할 수 있는 문제가 생긴다. 이것이 성능 상에 중대한 장애물이 되는 것은 아니지만, 벤치마크 중에 이런 일이 생기면 이상적인 결과를 얻기는 어렵다.

벤치마크 전후에 ClassLoadingMXBean의 getTotalLoadedClassCount와 getUnloadedClassCount 메서드(참고자료 참조)를 호출하면, 벤치마크 도중에 클래스 로딩/언로딩이 일어나는지 확인할 수 있다. 클래스 수에 변화가 있다면, 안정화 상태에 돌입하지 않은 것이다.

현대적인 JVM은 JIT(Just-in-time) 컴파일 수행 전에 프로파일링 정보를 얻고자 (대개 순수 인터프리트 방식으로) 얼마간 코드를 수행한다(참고자료 참조). 이것이 벤치마킹에서 의미하는 바는 안정화 상태의 실행 프로파일에 도달하기 전에 작업을 여러 번 반복 수행해야 할 수도 있다는 점이다. 한 예로, 썬의 클라이언트/서버 HotSpot JVM의 현재 기본 동작은 코드 영역을 포함하는 메서드를 JIT 컴파일하기에 앞서, 1500회(클라이언트) 또는 1만 회 (서버) 정도 해당 코드 영역을 실행해야 한다.필자는 코드 영역(code block)이라는 표현을 사용했다. 코드 영역은 전체 메서드뿐 아니라 한 메서드 내의 코드 일부분을 가리킬 수도 있다. 한 예로, 많은 JVM은 섬세하게도 반복 수행할 코드 영역을 포함하는 메서드를 단 한 번만 호출하더라도 그 코드 영역에 "요주의" 코드를 포함하는지 판별할 수 있다. 이 점에 대해서는 본 문서의 스택 내 치환 절에서 상세히 다루겠다.

따라서 안정화 상태의 성능 벤치마킹에는 다음과 같은 것이 필요하다.

  1. 모든 클래스를 로딩하기 위해 task를 한 번 수행한다.Execute task once to load all classes.
  2. 안정화 상태의 프로파일에 도달한 것을 보장받기 위해 충분히 여러 번 task를 반복 수행한다.
  3. 실행 시간 예상치를 얻기 위해 몇 번 더 task를 반복 수행한다.
  4. 단계 3의 결과로 충분히 큰 누적 실행 시간을 얻기 위해 필요한 task의 반복 횟수 n을 계산한다.
  5. task를 n번 더 실행하여 전체 실행 시간 t를 측정한다.
  6. 실행 시간을 t/n으로 계산한다.

task의 n번(n >= 1) 수행을 측정하는 숨은 목적은 누적 실행 시간이 매우 커서 앞서 언급한 시간 측정 오류를 무시할 수 있도록 하기 위함이다.

여기서 단계 2는 다소 모호하다. JVM이 작업 최적화를 완료한 시점을 어떻게 알 수 있다는 말인가?

실행 시간을 측정하여, 그 측정 결과가 어느 일관된 값으로 수렴하는지를 보고 판단할 수도 있다. 그럴 듯한 말이다. 하지만 JVM이 여전히 프로파일링 중이며 단계 5에 돌입하자 갑자기 그 프로파일링을 JIT에 적용한다고 가정해 보자. 이 방법은 실패다. 다음 단계에서 문제를 일으킬 수 있다.

더군다나 수렴 여부는 어떻게 정량화할 수 있단 말인가?


요즘 썬의 HotSpot JVM은 그저 프로파일링 단계를 한 번 거친 후 가능할 경우 컴파일을 한다. 
역최적화는 무시하더라도, 핫스팟에서 프로파일링 코드의 오버헤드가 지나치게 심각해 연속 컴파일(continuous compiling)은 현재 되지 않는다(참고자료 참조).

이 프로파일링 오버헤드 문제의 해법이 있다. 예를 들어 JVM은 두 가지 버전의 메서드, 즉 프로파일링 코드를 담지 않은 빠른 것과 프로파일링 코드가 들어 있는 느린 것을 유지할 수 있다(참고자료 참조). JVM은 주로 빠른 메서드를 사용하지만 종종 성능에 심각하게 영향을 미치지 않고 프로파일링 정보를 유지하는 느린 메서드로 교체하기도 한다. 또는 놀고 있는 코어를 사용할 수 있을 때마다 JVM이 느린 메서드를 동시에(concurrently) 실행할 수도 있다. 이 같은 기법 덕분에 연속 컴파일이 앞으로 표준이 될 수도 있다.

(Benchmark 클래스가 사용하는) 다른 방법 중 하나는 단순하게 미리 정해진 합리적으로 긴 시간 동안 작업을 지속적으로 수행하는 것이다. 10초의 예열 시간이면 충분하다(Click의 보고서 33쪽을 보라). 물론 이 방법보다 측정 결과가 일정한 값에 수렴할 때까지 실행 시간을 측정하는 편이 더 믿을 만할 수도 있다. 그러나 적어도 이 방법은 더 단순하게 구현할 수 있다. 또한 이 방법은 더 쉽게 매개변수로 표현할 수 있다. 사용자가 직관적으로 개념을 이해하고 더 긴 시간의 예열로 (더 긴 시간의 벤치마킹 시간 비용으로) 더 신뢰성 있는 결과를 얻을 수 있다는 점을 쉽게 납득할 수 있다.

JIT 컴파일이 일어나는 시점을 판단할 수 있다면, 안정화 상태의 성능에 도달했다는 더 큰 확신을 얻을 수도 있다. 특별히 안정화 상태에 돌입했다고 생각하여 벤치마킹을 시작했지만 벤치마킹 중에 컴파일이 일어난 것을 알았다면 벤치마킹을 중단하고 다시 시도할 수도 있다.

필자의 지식으로는 JIT 컴파일 수행 여부를 확인할 수 있는 완벽한 방법은 없다. 가장 좋은 방법이라면 벤치마킹 전후로CompilationMXBean.getTotalCompilationTime 을 호출하는 것이다. 다만, CompilationMXBean 구현에 오류가 많아, 이 방법도 문제가 있을 수 있다. 또 다른 기술은 -XX:+PrintCompilation 옵션을 사용하여 stdout 출력 결과물을 파싱하는 (또는 그냥 눈으로 확인하거나) 방법이다(참고자료 참조).

위로

예열 이슈 이외에도 JVM의 동적 컴파일로 인해 벤치마킹 관련 다른 여러 유의 사항이 발생한다. 이러한 유의 사항은 미묘하다. 더 나쁜 것은 이러한 유의 사항을 다루는 책임은 전적으로 벤치마킹 프로그래머에게 달려 있다는 점이다. 벤치마킹 프레임워크에서 이러한 이슈를 다루기 위해 할 수 있는 일은 별로 없다(이 글의 캐싱과 준비 절에서 벤치마크 프로그래머가 책임져야 할, 대부분 보통의 상식에 해당하는 몇 가지 이슈를 언급한다).

한 가지 유의 사항이 바로 역최적화(deoptimization)다(참고자료 참조). JVM은 컴파일한 메서드 사용을 중단하고 동일 메서드를 다시 컴파일하기 앞서 일정 기간 동안 인터프리트(interpret) 상태로 돌아간다. 이러한 현상은 먼저 최적화한 동적 컴파일러가 더 이상 적절하지 않다고 판단할 때 일어난다. 한 예가 동일 호출 변형(monomorphic call transformation)을 무효화하는 클래스 로딩이다. 또 다른 예가 드물게 일어나는 트랩(uncommon traps)이다. 코드 영역을 컴파일할 때 보통은 가장 가능성이 높은 코드 수행 경로를 따라 컴파일하고, (예외 경로와 같이) 수행 가능성이 떨어지는 코드 분기는 남겨 둔다. 그러다, 드물게 수행되어야 할 트랩이 자주 수행되는 코드로 판명이 되면, 그제서야 남겨둔 코드가 핫스팟(hotsopt) 경로가 되어 다시 컴파일한다.

따라서 앞 절의 충고를 따라 안정화 상태의 성능에 도달한 것으로 보이더라도, 성능이 갑작스럽게 변할 수 있다는 점을 명심해야 한다. 이것이 바로 벤치마크 내에서 JIT 컴파일을 탐지해야 하는 주요 이유 중 하나다.

또 다른 유의 사항은 특정 코드 구조 최적화를 돕는 고급 JVM 기능 중 하나인 스택 내 치환이다(참고자료 참조). Listing 4의 코드를 살펴 보자.

private static final int[] array = new int[10 * 1000];
static {
    for (int i = 0; i < array.length; i++) {
        array[i] = i;
    }
}

public static void main(String[] args) {
    long t1 = System.nanoTime();

    int result = 0;
    for (int i = 0; i < 1000 * 1000; i++) {    // outer loop
        for (int j = 0; j < array.length; j++) {    // inner loop 1
            result += array[j];
        }
        for (int j = 0; j < array.length; j++) {    // inner loop 2
            result ^= array[j];
        }
    }

    long t2 = System.nanoTime();
    System.out.println("Execution time: " + ((t2 - t1) * 1e-9) +
        " seconds to compute result = " + result);
}

JVM이 메서드 호출을 센다면 main은 단지 한 번만 수행되기 때문에 컴파일 버전의 main을 절대 사용할 수 없다. 이 문제를 해결하기 위해, JVM은 메서드 내부의 코드 영역 실행도 셀 수 있다. 특별히, Listing 4의 코드라면, JVM은 각 루프가 얼마나 많이 반복 수행되는지 추적할 수 있다(루프의 닫는 괄호는 "역방향 분기"를 유발한다). 기본적으로 어떤 루프든 1만 번 반복하면 전체 메서드를 컴파일한다. 단순한 JVM이라면 main이 다시 호출되지 않는다고 하여 이 컴파일된 코드를 절대로 사용하지 않을 것이다. 그러나 OSR을 사용하는 JVM은 똑똑해서 메서드 호출 도중에도 현재 코드를 새로 컴파일한 코드로 치환할 수 있다..

첫 눈에도 OSR이 멋지지 않은가? 이는 마치 JVM이 어떠한 코드 구조도 다룰 수 있으며 최적의 성능까지 제공하는 것으로 보인다. 그러나 안타깝게도 OSR에는 널리 알려지지 않은 단점이 있다. OSR을 사용한 코드 품질이 차선이라는 것이다. 예를 들면, OSR은 간혹 루프 호이스팅(loop-hoisting), 배열 경계 검사 생략(array-bounds check elimination), 또는 루프 되돌림(loop unrolling) 등을 하지 않는다(참고자료 참조). OSR이 사용되면 최고의 성능을 벤치마킹할 수 없다는 뜻이다.

최적의 성능을 원한다면, OSR이 발생할 수 있는 위치를 파악하여 OSR을 가급적 피할 수 있도록 코드 구조를 재구성해야 한다. 전형적인 방법은 주요 내부 루프를 독립된 메서드로 옮기는 것이다. 예를 들면,Listing 4의 코드를 Listing 5와 같이 다시 쓰는 것이다.

public static void main(String[] args) {
    long t1 = System.nanoTime();

    int result = 0;
    for (int i = 0; i < 1000 * 1000; i++) {    // sole loop
        result = add(result);
        result = xor(result);
    }

    long t2 = System.nanoTime();
    System.out.println("Execution time: " + ((t2 - t1) * 1e-9) +
        " seconds to compute result = " + result);
}

private static int add(int result) {    // method extraction of inner loop 1
    for (int j = 0; j < array.length; j++) {
        result += array[j];
    }
    return result;
}

private static int xor(int result) {    // method extraction of inner loop 2
    for (int j = 0; j < array.length; j++) {
        result ^= array[j];
    }
    return result;
}

Listing 5에서 add와 xor 메서드는 각각 100만 회 수행되므로, 최적의 형태로 JIT 컴파일해야 한다. 이 특정 코드의 경우 필자의 설정으로 처음 세 번의 실행에서 실행 시간 측정치가 각각 10.81, 10.79, 10.80초였다. 반면, Listing 4의 코드에서는(모든 루프가 main 안에 있고 OSR을 유발함) 두 배의 실행 시간이 걸렸다(첫 세 번의 실행에서 21.61, 21.61, 21.6초 소요).

OSR과 관련된 마지막 조언은 다음과 같다. 이는 프로그래머가 게을러서 모든 것을 main과 같이 단일 메서드에 넣을 때나 주의해야 할 벤치마킹에서의 성능 문제라는 것이다. 실제 애플리케이션에서는 프로그래머가 자연스럽게 메서드를 잘게 나눈다. 나아가, 성능이 문제가 되는 코드는 장시간 수행되고 중요한 메서드를 여러 번 호출한다. 따라서 현실 세계 코드는 OSR 성능 문제와는 대개 관련이 없다. 독자의 애플리케이션에서도 대체로 그 문제를 염려할 필요가 없을 것이다. 그렇지 않다면(그것이 문제가 되지 않을 것이라고 증명할 수 없다면) 그저 고상한 코드로 변형하도록 해라. 기본적으로 Benchmark는 통계치를 얻으려고 여러 번 작업을 반복 수행하므로 성능 이슈로서의 OSR을 제거하는 부수적인 효과도 얻는다.

또 다른 미묘한 주의 사항은 죽은 코드 삭제다(참고자료 참조). 몇 가지 환경에서 컴파일러는 일부 코드가 결과에 절대 영향을 미치지 못할 것이라고 판단할 수 있으므로, 컴파일러가 그 코드를 삭제할 수 있다. Listing 6은 정적으로 (즉 javac가 컴파일 시점에) 이런 일을 수행하는 대표적인 예를 보여 준다.

private static final boolean debug = false;

private void someMethod() {
    if (debug) {
        // do something...
    }
}

javac는 Listing 6에서 if (debug) 영역 내의 코드가 절대 실행되지 않을 것이라는 사실을 알고, 그 코드를 제거한다. 동적 컴파일러는 특히 메서드 인라이닝(method inlining)이 일어날 때, 여러 방법으로 죽은 코드를 판별한다. 벤치마킹 중 DCE 관련 문제는 실행되는 코드가 전체 코드 대비 단지 작은 일부분만 실행될 수도 있다는 것이다. 즉 전체적인 계산은 아예 일어나지도 않을 수 있다는 말이다. 이는 결국 짧은 실행 시간을 유도하지만 궁극적으로 잘못된 결과값을 얻을 뿐이다.

컴파일러가 죽은 코드라고 판명하는 모든 영역에 대해 잘 설명해 놓은 것을 찾을 수 없었다(참고자료 참조). 단순히 도달할 수 없는 코드는 물론 죽은 코드다. 그러나 JVM은 종종 적극적으로 여러 DCE 정책을 적용한다..

예를 들면, Listing 4의 코드를 다시 살펴보자. main은 result 값을 계산할 뿐 아니라 출력하기 위해 result를 사용한다. 만일 약간 코드를 변경하여 println에서 result를 제거한다고 가정해 보자. 이 경우라면, 적극적인 컴파일러는 result를 계산할 필요가 없다고 판단할지 모른다.

이것은 더 이상 이론적인 주의 사항이 아니다. Listing 7의 코드를 보자.

public static void main(String[] args) {
    long t1 = System.nanoTime();

    int result = 0;
    for (int i = 0; i < 1000 * 1000; i++) {    // sole loop
        result += sum();
    }

    long t2 = System.nanoTime();
    System.out.println("Execution time: " + ((t2 - t1) * 1e-9) +
        " seconds to compute result = " + result);
}

private static int sum() {
    int sum = 0;
    for (int j = 0; j < 10 * 1000; j++) {
        sum += j;
    }
    return sum;
}

필자의 설정으로는 일관되게 Listing 7의 코드를 4.91초 내에 수행했다. 이 코드를 수정하여 println 구문에서 result 참조를 삭제하고 System.out.println("Execution time: " + ((t2 - t1) * 1e-9) + " seconds to compute result"); 라고 바꿔 보자. 이제 이 코드는 일관되게 0.08초 이내에 수행된다. 명백하게 DCE가 모든 계산을 삭제해 버렸다(DCE의 또 다른 예는 참고자료 참조).

벤치마킹을 원하는 계산을 DCE가 삭제하지 않도록 하려면, 그 계산에서 어떤 결과를 구하여 어떤 식으로든 (예를 들어, Listing 7의 println처럼) 그 결과를 사용해야 한다. Benchmark 클래스는 이것을 지원한다. 독자의 작업이 Callable이면 필요한 계산이 반드시 수행되도록 call() 메서드가 돌려 주는 계산 결과를 반드시 사용하도록 하라. 독자의 작업이 Runnable이면 필요한 계산이 반드시 수행되도록 작업의toString 메서드에서 계산된 내부 상태를 사용하도록 하라(Object의 toString 메서드를 오버라이드해야 한다). 이 원칙만 잘 지킨다면, Benchmark 클래스에서 DCE를 완전히 예방할 수 있다.

OSR처럼 (정해진 시간 동안 코드를 실행만 하고 결과는 사용하지 않는 경우가 아니라면) DCE 역시 현실 세계의 애플리케이션에서 대체로 이슈가 되지 않는다. 그러나 OSR과 달리 DCE는 대충 작성된 벤치마크에서 중대한 이슈가 될 수 있다. OSR에서는 적당히 부정확한 결과를 얻을 뿐이지만 DCE라면 총체적으로 잘못된 결과를 얻게 된다.

위로

전형적인 JVM은 쓰레기 수집(garbage collection, GC)과 객체 마무리(object finalization, OF)의 두 가지 형태로 자원을 회수한다. 프로그래머의 관점에서 GC/OF는 대체로 비결정적이다. 이는 전적으로 프로그래머의 제어 영역 밖에 있을 뿐 아니라 JVM이 필요하다고 생각하는 어느 시점에든 발생할 수 있다.

작업과 연관된 GC/OF 시간은 벤치마크 결과에 포함되어야 한다. 예를 들어, 초기 실행 시간은 짧지만 점차 엄청난 GC 시간을 유발한다면, 그 작업을 빠르다고 주장할 수 없다. (어떤 작업은 객체를 생성할 필요가 없는 경우도 있다. 이 경우 대신 이미 생성된 객체를 참조할 뿐이다. 배열 엘리먼트에 접근하되 소요 시간만 측정하는 벤치마크라면, 배열을 생성할 필요는 없으며 대신 다른 어딘가에서 생성된 배열 객체를 그 작업에서는 참조만 하게 된다.)

그러나 동일한 JVM 세션에서 목표 작업의 GC/OF를 다른 코드에 의한 GC/OF와 구별할 필요가 있다. 단 한 가지 방법은 벤치마킹 시작 전에 JVM의 메모리를 정리하는 것이다. 또한 측정이 끝나기 전에 그 작업에 의한 GC/OF도 말끔한 종료를 보장하도록 최대한 노력해야 한다.

System 클래스는 gc와 runFinalization 메서드를 제공한다. 이는 JVM의 정리에 사용할 수 있다. 이 메서드들에 대해 javadoc에서 다음과 같이 말하는 것을 유의하라. "제어가 이 메서드 호출에서 돌아올 때면, 자바 가상 머신은 GC/OF를 수행하기 위해 최선을 다해야 한다."

Part 2에서 제공할 Benchmark 클래스는 GC/OF를 다음과 같은 방법으로 다룬다.

  1. 어떤 측정이든 시작 전에 cleanJvm이라고 이름 붙인 메서드를 호출한다. 이 메서드는 메모리 사용량이 안정화되고 마무리할 객체가 남지 않을 때까지 적극적으로 System.gcSystem.runFinalization을 필요한 만큼 많이 반복 호출한다.
  2. 기본적으로 60회 실행을 측정한다. 각 측정은 최소 1초 이상 지속된다(필요하면 각 측정에서 한 작업을 여러 번 실행하여, 이 시간 이상 소요되도록 한다). 따라서 총 실행 시간은 적어도 1분 이상은 되도록 한다. 이는 GC/OF의 실행 주기가 60회 측정 중에 골고루 분산되어 전체 동작을 정확히 측정할 수 있는 시간이다.
  3. 모든 측정이 끝나면, 마지막으로 cleanJvm을 호출한다. 그러나 이번에는 소요 시간도 함께 측정한다. 최종 정리 작업이 작업 전체 수행 시간의 1퍼센트 이상이 되면, 벤치마크 결과에서 GC/OF 비용이 이번 측정에서 정확히 고려되지 않았을 가능성을 경고한다.
  4. 각 측정에서 GC/OF는 마치 잡음같이 동작하므로, 신뢰성 있는 결론 도출에는 통계를 사용한다.

한 가지 유의점은 다음과 같다. 필자가 Benchmark를 작성했을 때, Listing 8에서 보는 코드처럼 각 측정 내부에 GC/OF 비용을 고려하도록 노력했다.

protected long measure(long n) {
    cleanJvm();    // call here to cleanup before measurement starts

    long t1 = System.nanoTime();
    for (long i = 0; i < n; i++) {
        task.run();
    }
    cleanJvm();    // call here to ensure that task's GC/OF is fully included
    long t2 = System.nanoTime();
    return t2 - t1;
    }

측정 루프 내에서 System.gc와 System.runFinalization을 호출하면 왜곡된 GC/OF 비용을 측정할 수 있는 문제가 있다. 특별히 System.gc는 stop-the-world 수집기(역자 주: 모든 자바 스레드를 중지하고 쓰레기를 수집)를 써서 모든 세대(generation)에 걸쳐 전체적으로 쓰레기를 수집한다(참고자료 참조). (이것이 기본 JVM 동작이긴 하나, JVM 옵션 중에 -XX:+ExplicitGCInvokesConcurrent와 -XX:+DisableExplicitGC.도 있긴 하다.) 이와 대조적으로 애플리케이션에서 주로 사용하는 쓰레기 수집기가 서로 완전히 다르게 동작할 수도 있다. 예를 들면, 설정상 쓰레기 수집기가 동시 동작할 수 있고, 적은 노력으로 (특히 젊은 세대 중에) 부분적으로만 이리 저리 수집할 수도 있다. 이와 비슷하게, 마무리 처리기(finalizer)는 보통 백그라운드 작업으로 처리되어, 그 비용이 시스템의 유휴 시간에 대체로 흡수되어 버린다.

위로

하드웨어/운영체제의 캐시로 인해 종종 벤치마킹이 복잡할 수도 있다. 한 가지 단순한 예가 파일 시스템 캐싱이다. 이 캐싱은 하드웨어나 OS에서 일어난다. 파일에서 바이트를 읽는 소요 시간을 벤치마킹한다면, 벤치마크 코드는 동일한 파일을 여러 번 반복하여 읽게 된다(같은 벤치마크를 여러 번 수행하는 것도 마찬가지다). 그러면, 첫 읽기 이후 I/O 시간이 급격히 감소한다. 랜덤 파일 읽기 속도를 벤치마킹하려면, 가급적 캐싱을 방지하기 위해 다른 파일을 읽도록 해야 한다.

CPU의 메인 메모리 캐싱은 매우 중요하여 특별한 주의를 요한다(참고자료 참조). 지금까지 약 20년 간 CPU 속도는 기하급수적으로 빨라졌다. 반면 메인 메모리 속도는 완만하게 빨라졌다. 이들 간의 속도 불일치를 개선하기 위해 현대적인 CPU는 광범위하게 캐싱을 사용한다(CPU 상의 대부분의 트랜지스터가 캐싱 용도로 할당되어 있다). CPU 캐싱을 적절히 활용하는 프로그램은 그렇지 않은 프로그램 대비 인상적으로 나은 성능을 보인다(대부분의 현실 세계 작업량이란 CPU의 이론적인 성능의 일부분만을 사용할 뿐이다).

프로그램이 CPU 캐싱을 적절히 활용하기에는 여러 요소가 작용한다. 예를 들면, 현재의 JVM은 메모리 접근을 최적화하기 위해 많은 노력을 들였다. 힙(heap) 크기를 재조정하고, 힙의 값을 CPU 레지스터로 올리고, 스택을 할당하고, 객체 폭발(object explosion, 참고자료 참조)을 수행한다. 그러나 주요한 요소 중 하나는 단지 데이터 집합 크기다. 작업의 데이터 크기가 n이라고(예를 들어, 길이 n의 배열을 사용한다고) 하자. 단순히 단일 변수 n에 따른 벤치마킹으로 내린 결론은 상당한 오류를 내포할 수도 있다. 따라서 여러 n 값에 걸쳐 벤치마킹을 해야 한다. J. P. Lewis와 Ulrich Neumann의 문서에 훌륭한 예가 있다(참고자료 참조). 함수 인자 n에 따라(여기서는 배열 크기) C 언어 대비 자바의 FFT 성능 그래프를 도출하여, n 값에 따라 C 언어 대비 두 번 빠르고 두 번 느린 식으로 자바 성능이 요동치는 점을 발견하였다.

위로

벤치마킹 함정은 독자가 개발하는 벤치마킹으로 시작하고 끝나지 않는다. 벤치마킹 프로그램 수행 전에 시스템의 여러 부분을 함께 다루어야 한다.

저사양 하드웨어, 주로 랩톱에서 전원 관리(예를 들면, 고급 전원 관리(Advanced Power Management, APM) 또는 고급 설정 및 전원 인터페이스(Advanced Configuration and Power Interface, ACPI)가 벤치마킹 도중에 상태를 바꾸지 않도록 해야 한다. 컴퓨터가 동면 모드로 진입하듯이 급격히 전력 상태가 변하면, 벤치마킹 결과를 얻지 못하거나 잘못된 결과를 얻을 수도 있다. 그러나 좀 더 주의가 필요한 또 다른 전력 상태 변화가 있다. 주로 CPU 위주의 벤치마킹을 생각해 보자. 이 때 OS는 하드 드라이브의 전원을 끈다. 벤치마킹 종료 시점에 하드 드라이브를 쓰려고 하면, I/O 비중이 더 커질 수 있다. 또 다른 예로 인텔 스피드스텝(SpeedStep) 또는 이와 비슷하게 CPU 전력 사용량을 동적으로 조절하는 기술을 사용하는 시스템을 들 수 있는데, 이러한 효과를 중지하도록 OS 설정을 조정해야 한다.

벤치마킹 중에는 (CPU 부하를 모니터링할 목적이 아니라면) 다른 프로그램을 수행하지 말아야 한다. 불필요한 백그라운드 프로세스는 중단하는 편이 좋고, 벤치마킹 도중에 실행될 수도 있는 스케쥴된 프로세스(스크린 세이버 또는 바이러스 스캐너 등)는 막아야 한다.

윈도는 ProcessIdleTask API를 제공한다. 이는 벤치마킹 전에 대기 중인 노는 프로세스(idle process)를 실행하도록 한다. 이 API는 다음과 같이 커맨드라인에서 실행할 수 있다.

Rundll32.exe advapi32.dll,ProcessIdleTasks

한 동안 이 명령을 실행한 적이 없다면 이 명령을 실행할 때 몇 분까지도 걸릴 수 있다(연속적으로 실행하면 주로 몇 초 이내에 종료한다).

십여 가지 JVM 옵션이 벤치마킹에 영향을 끼친다. 몇 가지 관련 있는 옵션은 다음과 같다.

  • JVM 타입: 서버(-server) 대 클라이언트(-client)
  • 사용 가능한 충분한 메모리 보장(-Xmx)
  • 쓰레기 수집기 유형(고급 JVM은 다양한 튜닝 옵션을 제공한다. 그러나 주의해야 한다.)
  • 클래스 쓰레기 수집을 허용할 지 여부(-Xnoclassgc). 기본적으로 클래스 GC는 일어난다. -Xnoclassgc 사용이 좋지 않다는 의견도 있다.
  • 이스케이프 분석(escape analysis) 수행 여부(-XX:+DoEscapeAnalysis)
  • 대용량 페이지 힙 지원 여부(-XX:+UseLargePages)
  • 스레드 스택 크기 변경(예를 들어, -Xss128k)
  • JIT 컴파일러 항상 사용(-Xcomp), 항상 사용하지 않음(-Xint), 또는 핫스팟에서만 사용(-Xmixed, 이것이 기본 옵션이며 최고 성능 옵션이다.)
  • JIT 컴파일 전 누적할 프로파일 양(-XX:CompileThreshold), 백그라운드 JIT 컴파일(-Xbatch), 단계별 JIT 컴파일(-XX:+TieredCompilation)
  • 편향된 잠금(biased locking) 수행 여부(-XX:+UseBiasedLocking). JDK 1.6 이상에서는 자동으로 수행한다.
  • 실험적인 최신 성능 기법 활성화 여부(-XX:+AggressiveOpts)
  • 조건 검사(assertion) 활성화 여부(-enableassertions와 -enablesystemassertions)
  • 엄격한 네이티브 호출 검사 활성화 여부(-Xcheck:jni)
  • NUMA 멀티 CPU 시스템에서 메모리 위치 최적화 활성화(-XX:+UseNUMA)
위로

벤치마킹은 대단히 힘들다. 명백하든 미묘하든 많은 요소가 결과에 영향을 줄 수 있다. 정밀한 결과를 얻으려면, 이러한 주의 사항을 다루는 벤치마킹 프레임워크를 사용하면서 가이드라인을 철저히 준수할 필요가 있다. 이제 Part 2에서는 믿을만한 자바 벤치마킹 프레임워크에 대해 배워 보자.

Comments