 |  |
|
난이도 : 고급 Brent Boyer, 프로그래머, Elliptic Group, Inc.
옮긴이: 오국환 dwkorea@kr.ibm.com
2008 년 8 월 19 일 고성능 하드웨어의 시대라 할지라도, 프로그램 성능은 변함없는 관심사입니다. 두 편의 연재 중 두 번째인 본 문서에서는 벤치마킹 통계를 다루고, 사용자 코드 내에 벤치마크 코드를 포함하는(self-contained) 마이크로벤치마크부터 외부에서 전체 애플리케이션을 호출하는 코드에 이르기까지 다양한 자바(Java™) 코드를 벤치마크할 수 있도록 프레임워크를 제공합니다.
두 편의 연재 중 Part 1에서는 자바 코드 벤치마크와 관련된 여러 가지 함정을 소개했다. 이제 본 문서에서는 두 가지 구분된 영역을 다루고자 한다. 첫째, 벤치마크에서 필수적으로 드러나는 측정상의 변동을 극복하기 위한 초보적인 통계를 설명한다. 둘째, 벤치마킹을 위한 소프트웨어 프레임워크를 소개하고, 핵심을 짚어 줄 예제를 몇 가지 들겠다.
통계
실행 시간은 한 번 측정하면 되고 서로 다른 코드의 비교 측정도 한 번으로 그만이라면, 인생이 꽤나 편할지 모르겠다. 자주 사용되는 접근이기는 하지만, 슬프게도 이런 식으로는 그다지 신뢰를 줄 수 없다. 변동 요소가 너무나 많기 때문이다. Part 1에서 필자는 시계의 정밀도, 복잡한 JVM 동작, 잡음을 유발하는 자원의 자동 회수 등을 언급했다. 이러한 것들은 임의로 혹은 조직적으로 벤치마크를 왜곡하는 여러 요소 중 일부일 뿐이다. 그러나 몇 가지 조치를 취하면, 이 중 일부는 거의 무시할 수도 있다. 즉, 이것들을 잘 이해하기만 하면, 얽힌 실타래를 풀 수도 있다(역주: 원문은 ‘perform deconvolution’으로 ‘역합성곱(deconvolution)을 실행하다’로 해석됨. 참고자료 참조). 그러나 이러한 처방은 절대 완벽하지 않으므로, 여러 가지 변동에 절대적으로 대처해야 한다. 이를 위한 유일한 대처법은 여러 번 측정하고 통계를 써서 신뢰할 수 있는 결론을 도출하는 것이다. “산수를 거부하는 사람은 허튼 소리를 하게 마련”이니, 스스로 위험을 감내하겠다면 이 절은 무시해도 좋다(참고자료).
 |
보충 자료
본 문서의 보조 사이트에는 전체 샘플 코드 패키지와 통계 이슈를 상세히 다루는 보충 자료가 있다. 보조 사이트의 링크는 참고자료를 참조하라.
|
|
여기서는 공통적으로 제기되는 성능상 문제를 다루기에 충분한 통계만 살펴 보겠다. 공통적으로 제기되는 성능상 문제는 다음과 같다.
- 작업 A는 작업 B보다 실행 속도가 빠른가?
- 결론은 믿을 만한가? 그저 우연히 얻은 측정 결과일 뿐인가?
여러 번 실행 시간을 측정하게 되면, 흔히 계산하는 첫 통계가 바로 대표 값(typical value)에 해당하는 단일 숫자를 구하는 것이다(본 문서에서 통계적 개념의 정의에 대해서는 참고자료의 위키백과 링크를 참조하라). 가장 일반적으로 측정하는 값은 보통 평균(mean 또는 average)으로 알고 있는 산술 평균(arithmetic mean)이다. 이는 측정 값을 모두 더해 측정 회수로 나눈 값이다.
meanx = Summationi=1,n(xi) / n
보조 사이트(참고자료)에 올려 둔 본 문서의 보충 자료에는 평균 이외의 다른 측정 방법에 대해 논의한 내용도 있다. Alternatives to the mean 절을 보라.
성능을 정량화하기 위한 수단으로 여러 차례 측정한 결과의 평균을 사용하는 것은 단일 측정 결과에 의존하는 것보다 분명 더 정확하다. 그러나 이는 어떤 작업 수행이 더 빠른지를 판단하기에 충분하지 않을 수도 있다. 예를 들면, 작업 A의 평균 실행 시간이 1밀리초이고 작업 B의 평균 실행 시간이 1.1밀리초라고 가정해 보자. 자동적으로 작업 A가 작업 B보다 빠르다고 결론지을 수 있겠는가? 작업 A의 측정 값이 0.9에서 1.5밀리초 이내이고, 작업 B의 측정 값이 1.09에서 1.11밀리초 이내라는 사실을 미리 알고 있다면, 그렇게 결론내릴 수 없을 것이다. 따라서 측정 값의 범위(measurement spread)를 언급할 필요가 있다.
측정 값의 범위를 언급하기 위해 쓰이는 가장 대표적인 통계가 바로 표준 편차(standard deviation)다.
sdx = sqrt{ Sumi=1,n( [xi - meanx]2 ) / n }
표준 편차는 어떻게 측정 값의 범위를 정량화하는가? 그 답은 독자가 측정 결과의 확률 밀도 함수(probability density function, PDF)에 대한 지식에 따라 다르다. 독자가 더 세밀한 가정을 내릴수록, 더 명백한 결론을 얻을 것이다. 본 문서의 보충 자료의 Relating standard deviation to measurement scatter 절을 보면, 이 부분을 더 상세히 다루면서 벤치마킹 관점에서 다음과 같은 결론을 내린다. 경험 법칙에 의하면 측정 값의 최소 95%가 평균 세 개의 표준 편차 내에 있어야 한다는 것이다.
즉, 두 작업 중 어느 쪽이 빠른지를 판단할 때 어떻게 평균과 표준 편차를 사용할 수 있는가? 위에서 언급한 경험 법칙을 적용할 때, 두 작업의 평균이 세 개의 표준 편차 이상 벌어져 있다면 쉽게 판단할 수 있다(둘 중 더 큰 표준 편차를 선택하라). 이런 경우, 그림 1에서 보는 바와 같이 거의 대부분의 경우에 더 작은 평균이 명백히 더 빠르다.
그림 1. 세 개의 표준 편차보다 큰 평균으로 명확하게 성능이 구분되는 경우
그림 2에서 보는 바와 같이 두 작업이 안타깝게도 서로 겹치게 된다면(예를 들어, 평균 값들이 한 개의 표준 편차로 나뉜다면), 결정하기 쉽지 않다.
그림 2. 평균이 세 개보다 적은 수의 표준 편차로 나뉘어 성능이 겹치는 경우
그저 평균만으로 두 작업을 비교할 수도 있지만, 결론의 강도를 병기하는 데 비교 결과의 중첩 정도를 사용할 수도 있다.
신뢰성
언급할 또 다른 질문은 이러한 평균과 표준 편차 통계를 얼마나 신뢰할 수 있느냐의 문제다. 분명 이러한 값들은 측정 결과에서 계산된 것이고, 따라서 새로운 측정 집합에서는 다른 값을 얻게 될 것이다. 일단 측정 절차는 유효하다고 가정해 보자(주의: 제대로 된 표준 편차를 구하기 힘든 경우도 있을 수 있다는 점을 명심하기 바란다. 보충 자료의 Standard deviation measurement issues 절에서 이 문제를 살펴보고 있다). 그렇다면, 측정 절차를 반복할 때마다 평균과 표준 편차의 차이는 어느 정도가 될 것인가? 새로 측정한 값으로 계산해 전혀 다른 결과를 얻게 될 것인가?
이러한 문제에 답할 수 있는 가장 직관적인 방법은 통계에서 말하는 신뢰 구간(confidence intervals)을 정하는 것이다. 통계적 비교를 위해 값 하나만 계산하여 사용하는 것과 달리(point estimate) 신뢰 구간은 평가를 위한 범위에 해당한다. 신뢰도(confidence level)라고 부르는 확률 p는 이 범위와 관련이 있다. 거의 대부분의 경우에 95%를 만족하는 수준에서 p를 정하며, 이 값은 신뢰 구간 비교 중에 일정하게 사용한다. 신뢰 구간은 그 구간의 크기가 곧 신뢰성을 의미한다. 짧은 구간은 통계치가 정확히 알려져 있음을 나타내고, 넓은 구간은 불확실성을 나타낸다. 예를 들어, 작업 A의 평균 실행 시간의 신뢰 구간이 [1, 1.1]밀리초고, 작업 B의 경우 [0.998, 0.999]밀리초라고 할 때, B의 평균이 (신뢰도 측면에서) 명백히 더 짧은 구간이므로 A의 평균보다 신뢰할 수 있다. 더 자세한 내용은 본 문서 보충 자료의 Confidence intervals 절을 보라.
역사적으로, 신뢰 구간은 (Gaussian과 같은) 몇 가지 공통된 PDF와 (평균과 같은) 단순 통계를 통해 쉽게 계산할 수 있었다. 그러나 1970년대 후반 부트스트래핑(bootstrapping)이라는 기술이 개발되었다. 이는 신뢰 구간을 얻기 위한 일반적인 기술 중 최고다. 이는 평균 같은 단순 통계뿐 아니라 어떠한 통계에서도 적용할 수 있다. 나아가, 매개변수를 취하지 않는 부트스트래핑 형식은 하부 PDF에 대해 어떠한 가정도 하지 않는다. 또한 이 기술은 절대 반물리적인 결과를 내지 않는다(예를 들어, 신뢰 구간에서 음의 실행 시간은 하계(lower bound)를 나타낸다). 또한 (Gaussian의 경우처럼) PDF에 대한 잘못된 가정을 내릴 수 있는 것과 비교하면, 이 기술에서는 더 좁고 정확한 신뢰 구간을 얻을 수 있다. 부트스트래핑 기술에 대한 상세 설명은 이 문서의 범위를 넘어선다. 하지만 다음에 다루려는 프레임워크에서는 이러한 계산을 수행하도록 Bootstrap이라는 클래스를 제공한다. 더 상세한 정보는 자바 문서와 소스 코드를 참조하라(참고자료).
이상 내용을 요약하면 앞으로 독자가 할 일은 다음과 같다.
- 벤치마크 측정을 여러 번 수행한다.
- 측정 결과로부터 평균과 표준 편차를 계산한다.
- 두 작업이 성능 비교에서 명확히 구분되는지(평균이 세 개의 표준 편차보다 큰지), 아니면 서로 겹치는지 이 통계를 사용한다.
- 평균과 표준 편차의 신뢰 구간을 계산하여 신뢰 수준을 나타낸다.
- 신뢰 구간을 계산하기 위한 최적의 방법으로 부트스트래핑을 사용한다.
프레임워크 소개
지금까지 자바 코드를 벤치마크하기 위한 일반적인 원리를 소개했다. 이러한 이슈를 모두 적용한, 이제 '당장 쓸 수 있는' 벤치마킹 프레임워크를 소개하겠다.
프로젝트
본 문서의 보조 사이트(참고자료)에서 프로젝트 ZIP 파일을 다운로드한다. ZIP 파일에는 소스와 바이너리 파일, 간단한 빌드 환경이 모두 들어 있다. 특정 디렉터리에 본 압축 파일을 풀라. 더 자세한 정보를 원하면, 최상위 폴더의 readMe.txt 파일을 참조하라.
API
본 프레임워크의 필수적인 클래스가 바로 Benchmark다. 대부분의 사용자는 이 클래스만 알아도 충분하다. 나머지 클래스는 보조적인 것들이다. 대부분의 경우 API 사용 방법은 단순하다. 벤치마크할 코드를 Benchmark 생성자에 넘겨 주기만 하면 된다. 이후의 벤치마크 프로세스는 모두 자동으로 수행된다. 이어서 작업할 유일한 단계는 결과 보고서를 생성하는 것뿐이다.
작업 코드
당연하지만, 실행 시간을 벤치마크할 코드가 있어야 한다. 유일한 제약 사항이라면 그 코드가 Callable 또는 Runnable 내에 있어야 한다. 그렇지 않으면, 사용자 코드 내에 벤치마크 코드가 포함되는(self-contained) 마이크로벤치마크부터 외부에서 전체 애플리케이션을 호출하는 코드에 이르기까지, 대상 코드가 어쨌든 자바 언어로 표현 가능한 다른 형태여야 한다.
작업을 Callable로 작성하는 편이 다소 더 편리하다. Callable.call에서는 명시적(checked) Exceptions을 던질 수 있는 반면, Runnable.run에서는 Exception을 처리하는 루틴을 메서드 내부에서 구현해야 한다. Part 1의 죽은 코드 삭제(Dead-code elimination, DCE) 절에서 소개한 바와 같이, Callable 쪽이 Runnable보다는 DCE를 방지하기가 다소 쉽다. Runnable 형태의 구현이 유리한 점 한 가지는 객체 생성과 쓰레기 수집(garbage collection) 오버헤드를 최소화할 수 있다는 점이다. 더 자세한 내용은 본 문서 보충 자료의 Task code: Callable versus Runnable 절을 참조하라.
결과 보고서
벤치마크 결과 리포트를 생성하는 가장 쉬운 방법은 Benchmark.toString 메서드를 호출하는 것이다. 이 메서드는 가장 중요한 결과와 경고 문구를 한 줄의 요약 보고서 형태로 출력한다. 여러 줄에 걸쳐 모든 결과와 전체 설명을 포함한 상세 보고서를 얻고자 한다면, Benchmark.toStringFull 메서드를 호출하라. 그렇지 않다면, Benchmark의 여러 메서드를 호출하여, 임의의 보고서를 생성할 수도 있다.
경고
Benchmark는 몇 가지 일반적인 문제를 진단하고 이러한 문제가 발견되면 결과 보고서에 경고를 표시한다. 표시되는 경고는 다음과 같다.
-
측정치가 너무 낮음: Part 1의 자원 회수 절에서
Benchmark가 쓰레기 수집과 객체 마무리(object finalization) 비용을 충분히 고려하지 않았을 때 어떻게 경고를 내는지 다루었다.
-
이상점과 계열상관: 실행 시간 측정은 ‘이상점(outlier)’과 ‘계열상관(serial correlation)’에 의해 방해를 받는다. 여기서 ‘이상점’이란 주요 측정 에러가 발생한 것을 말한다. 예를 들어, 측정 도중 그 컴퓨터에서 다른 작업이 시작되면, 이는 심각한 ‘이상점’이 발생한 것이다. 사소한 ‘이상점’이라면 DCE가 일어났다는 단서라고 볼 수도 있다. ‘계열상관’이란 JVM이 아직 안정화 상태의 성능 프로파일(실행 시간의 변동이 작은 경우)에 이르지 않았음을 알려 준다. 특별히, 양의 ‘계열상관’은 (오르든 내리든) 일관된 흐름을 나타내며, 음의 ‘계열상관’은 (실행 시간이 오르내림을 반복하는 식으로) 평균의 반전이 일어났음을 나타낸다. 어느 쪽이든 보기 좋지 않다.
-
표준 편차의 부정확: 더 자세한 내용은 본 문서 보충 자료의 Standard deviation warnings 절을 보라.
샘플 예제
Listing 1의 코드 조각은 지금까지 이 절에서 다룬 요점을 다룬다.
Listing 1. 35번째 피보나치 수 계산 벤치마킹
public static void main(String[] args) throws Exception {
Callable<Integer> task =
new Callable<Integer>() { public Integer call() { return fibonacci(35); } };
System.out.println("fibonacci(35): " + new Benchmark(task));
}
protected static int fibonacci(int n) throws IllegalArgumentException {
if (n < 0) throw new IllegalArgumentException("n = " + n + " < 0");
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
|
Listing 1에서 main은 벤치마크할 코드를 Callable로 정의하여 Benchmark 생성자에 넘긴다. Benchmark 생성자는 작업을 여러 번 수행한다. 처음에는 코드 예열을 하고, 그 다음 실행 통계를 수집한다. 생성자가 리턴하면, 코드가 실행 중인 컨텍스트(여기서는 println 내)에서 새로운 Benchmark 인스턴스의 toString 메서드를 암묵적으로 호출한다. 이 메서드는 벤치마크의 요약 통계를 보고한다. Benchmark 사용법은 대체로 이와 같이 간단하다.
내 컴퓨터 설정에서는 다음 결과를 얻었다.
fibonacci(35): first = 106.857 ms, mean = 102.570 ms (CI deltas: -35.185 us,
+47.076 us), sd = 645.586 us (CI deltas: -155.465 us, +355.098 us)
WARNING: execution times have mild outliers, SD VALUES MAY BE INACCURATE
|
이를 해석하면 다음과 같다.
-
fibonacci(35) 호출의 최초 수행 시간은 106.857밀리초다.
- 실행 시간 평균의 점추정(point estimate)은 102.570밀리초다. 이 점추정 평균에 대한 95% 신뢰 구간은 약 -35/+47밀리초이고, 구간으로 보면 [102.535, 102.617]밀리초다. 이는 상대적으로 좁은 값이므로, 평균값을 신뢰할 수 있다고 볼 수 있다.
- 실행 시간의 표준편차의 점추정은 645.586마이크로초다. 표준편차의 95% 신뢰 구간은 이 점추정 대비 약 -155/+355마이크로초이고, 구간으로 보면 [490.121, 1000.684]마이크로초다. 이는 상대적으로 구간이 넓어 값은 신뢰도가 떨어진다고 볼 수 있다. 사실상, 보고서 끝에 표준 편차가 정확하게 측정되지 않았다고고 경고한다.
- 보고서는 또한 ‘이상점’에 대해 경고한다. 여기서는 이 경고를 무시할 만하지만, 신경이 쓰인다면,
Benchmark의 toStringFull 메서드를 써서 코드를 한 번 더 돌려 볼 수도 있다. 그러면, 모든 방해꾼을 열거해 주므로, 사용자가 그 경중을 판단할 수 있다.
자료 구조 접근 시간
벤치마킹 이슈를 더 잘 확인하고 Benchmark가 이러한 이슈를 어떻게 처리하는지를 보려면, 몇 가지 공통 자료 구조의 접근 시간을 비교해 보면 된다.
자료 구조의 접근 시간만을 측정하는 것이 진정 마이크로벤치마크다. 당연하지만서도, 마이크로벤치마크에는 여러 함정이 있다(예를 들면, 마이크로벤치마크에서는 전체 애플리케이션이 어떻게 구동하는지 아무런 흔적을 남기지 않을 수 있다). 측정에 눈속임이 많아서 정확하게 측정하는 것이 쉽지 않다. (캐시 효과와 같이) 발생할 수 있는 여러 흥미로운 이슈로 인해 마이크로벤치마크는 벤치마크의 좋은 예가 될 수 있다.
Listing 2의 코드를 보자. 여기서는 배열(다른 자료 구조의 경우도 코드는 비슷함)의 접근 시간을 벤치마크한다.
Listing 2. 배열 접근 벤치마크 코드
protected static Integer[] integers; // values are unique (integers[i] <--> i)
protected static class ArrayAccess implements Runnable {
protected int state = 0;
public void run() {
for (int i = 0; i < integers.length; i++) {
state ^= integers[i];
}
}
public String toString() { return String.valueOf(state); }
}
|
이 코드는 integers 배열에 접근하는 것 말고는 아무런 일을 하지 않는다. 그러나 integers의 모든 배열을 루프를 사용하여 편리하게 접근한다. 이 루프는 벤치마크에 있어 일종의 오버헤드이다. 여기서 주의할 점은 구식의 명시적인 int 루프를 사용했다는 점이다. 보다 편리한 for (int i : integers)와 같은 개선된 for 루프를 사용하지 않았다. 이는 구식 루프가 배열의 경우에서조차 약간 더 빠르기 때문이다(참고자료). 여기서 사용된 루프는 사소한 흠이라고 볼 수 있다. 너그러운 JIT(Just-in-time) 컴파일러가 루프 되돌림(loop unrolling)을 수행하여, 그 영향을 약화시키기 때문이다.
그러나 더 심각하게 주의할 부분은 DCE를 막기 위한 코드가 필요하다는 것이다. 첫째, 앞서 언급했듯이 객체 생성과 쓰레기 수집을 최소화하기 위해 작업 클래스로 Runnable을 선택했다. 마이크로벤치마크의 경우에는 이런 오버헤드를 최소화하는 것이 중요하다. 둘째, 작업 클래스가 Runnable이므로, DCE를 막으려면 배열의 접근 결과는 state 필드에 할당해야 한다(그리고 toString 메서드를 오버라이드하여 state를 사용해야 한다). 셋째, 매번 반드시 배열에 접근하도록 state에 담긴 이전 접근 결과를 새로 읽은 값과 비트단위(bitwise) XOR 연산을 수행했다. (그저 state = integers[i]만 수행하면, 똑똑한 컴파일러가 전체 루프를 모두 건너뛰고 단지 state = integers[integers.length - 1]만 수행할 수도 있다.) 이렇게 추가된 작업 네 개(필드 읽기, Integer를 int로 자동언박싱(autounboxing), 비트단위 XOR, 필드 쓰기)는 벤치마크에는 흠을 남기지만, 피할 수 없는 오버헤드다. 결국 단순 배열 접근 시간 이상의 것을 측정하는 것이다. 자료 구조의 접근 시간이 상대적으로 더 긴 것을 감안하면 오버헤드의 상대적인 효과는 무시할 만하므로, 모든 다른 자료 구조의 벤치마크도 이와 비슷하다.
그림 3과 4는 내 데스크톱 설정에서 크기가 서로 다른 integers로 실행한 접근 결과를 나타낸다.
그림 3. 자료 구조 접근 시간(1024개인 경우)
그림 4. 자료 구조 접근 시간(1024 X 1024개인 경우)
결과에 대한 부연 설명은 다음과 같다.
- 평균 실행 시간에 대한 점추정만 표시했다. (평균의 신뢰 구간은 모두 매우 엄격하게 적용했다. 보통 신뢰 구간의 폭은 평균 대비 1000배 이상 좁다. 물론 이러한 사실이 그래프에 나타나지는 않는다.)
- 모든 벤치마크에서는 단일 스레드만 사용했다. 동기화된 클래스 역시 스레드 경쟁이 일어나지는 않았다.
- (동기화하지 않은) 배열 엘리먼트 접근 코드는 Listing 2에서 이미 소개했다.
- (동기화한) 배열 엘리먼트 접근 코드는 루프 몸체가
synchronized (integers) {
state ^= integers[i];
}인 점만 제외하면, 앞의 경우와 같다.
- 그림 3과 4에서
ConcurrentHashMap, case 1과 case 2의 차이는 전적으로 ConcurrentHashMap의 생성자 사용에 있다. case 1은 concurrencyLevel = 1로 주었고, case 2는 기본 값(여기서는 16)을 사용했다. 여기 모든 벤치마크에서 단일 스레드를 사용하였으므로 case 1이 다소 빠르다.
- 모든 벤치마크는 다음의 하나 또는 그 이상의 경고를 받았다.
- 모든 경우에 ‘이상점’이 있었다. 그러나 어느 것도 심각하지는 않았으므로 결과에 큰 영향을 미치지는 않았다.
- 1024 X 1024개의 경우에 모두 결과에서 ‘계열상관’이 있었다. 본 이슈가 어느 정도의 비중인지는 명확하지 않다. 1024개의 경우에는 어느 것에서도 결과에서 ‘계열상관’이 발생하지 않았다.
- (마이크로벤치마크에서는 대체로 그렇듯이) 표준 편차는 항상 측정 불가능했다.
이런 결과는 아마 예상했던 것일 수 있다. 동기화하지 않은 배열 접근이 가장 빠른 자료 구조다. 다음으로 ArrayList가 배열 접근과 거의 동등한 속도를 냈다(아마도 서버 JVM은 탁월하게도 인라인(inline)으로 하부 배열에 직접 접근하기 때문일 것이다). 이는 동기화된 배열보다는 훨씬 빠르고 Vector와는 다소 비슷하다. 그 다음으로 빠른 자료 구조는 HashMap이다. ThreadLocal(스레드마다 독립적으로 사용하는 특별한 해시 테이블)이 그 뒤를 잇는다. 본 테스트에서 HashMap은 Vector만큼이나 빠르다. 얼핏 놀라워 보이지만, Integers가 키로 사용되고 여기에는 아주 바른 hashCode 구현(단순히 int 값을 리턴함)을 쓴다는 점을 감안하면 그럴 법하다. 그 다음에 오는 것이 ConcurrentHashMap이고, 가장 느린 것은 TreeMap이다.
여기에 비록 결과를 나타내지는 않았지만, 똑 같은 벤치마크를 전혀 다른 기계에서도 실행했다. (그 기계는 1.167GHz로 동작하는 SPARC-Enterprise-T5220으로 32GB의 RAM에 SunOS asm03 5.10이 설치되어 있으며, 같은 JVM 버전에 먼저 사용한 것과 동일한 설정을 적용했다. 그러나 Benchmark를 경과 시간이 아닌 CPU 시간을 사용하도록 설정했다. 어차피 테스트는 모두 단일 스레드고 솔라리스는 CPU 시간을 아주 잘 지원한다.) 상대적인 결과는 이상 보여 준 결과와 동일했다.
위에서 제시한 결과에서 한 가지 예외적인 점이라면 동기화된 배열 접근 시간이다. 나는 이 시간이 Vector와 맞먹을 것이라 생각했으나, 이 속도는 일관성 있게 세 배 이상 느렸다. 최대한 이유를 생각해 보자. 몇 가지 락(lock) 관련 최적화(락 생략, lock elision, 또는 락 편향, lock biasing) 적용에 실패했다(참고자료). 이러한 예외는 Azul의 JVM에서는 일어나지 않는다는 사실을 이와 같은 예측의 증거로 제시한다. 참고로, Azul의 JVM에서는 별도의 락 최적화를 사용한다.
사소한 예외가 하나 있다. 1024 X 1024개의 엘리먼트를 사용하는 경우에만 ConcurrentHashMap case 1이 case 2에 비해 약간 빨랐으며, 1024개의 엘리먼트를 사용하는 경우에는 오히려 더 느렸다. 서로 다른 수의 테이블 세그먼트에서 일어나는, 사소한 메모리 위치 선정(memory-placement) 효과 때문에 이렇게 예외적인 상황이 생긴 것으로 보인다. 이러한 효과는 T5220 기계에서는 일어나지 않았다(엘리먼트 수와 상관없이 case 1은 항상 case 2보다 약간 빨랐다).
예외라고 볼 수 없는 점은 HashMap이 ConcurrentHashMap보다 빠르다는 것이다. 양쪽 경우 모두 코드는 state ^= integers[i] 대신 state ^= map.get(integers[i])로 대체된 것을 제외하면 Listing 2와 똑같다. integers의 엘리먼트는 (integers[i].intValue() == i와 같이) 순차적으로 읽고 같은 순서에 따라 키로 사용된다. HashMap 내부의 해시 사전 조율 함수(hash preconditioning function)가 ConcurrentHashMap에 비해 더 나은 ‘순차적인 캐시 지역성(sequential cache locality)’을 갖기 때문이다(이는 ConcurrentHashMap 쪽이 높은 비트에서 분산된 메모리를 더 잘 처리할 필요가 있기 때문이다).
이는 흥미로운 점을 한 가지 이끌어 낸다. 이상 소개한 결과에서는 integers를 순차적으로 접근한다는 점이 결과에 얼마나 큰 영향을 미쳤을까? 어떤 자료 구조가 다른 것에 비해 더 이득을 누릴 수 있는, 이른바 메모리 지역성 효과가 있었을까? 이 문제에 답하려면, 벤치마크를 새로 수행하되 integers의 엘리먼트를 순차적이 아니라 임의로 접근하도록 한다. (의사난수(pseudorandom) 값을 얻기 위해 나는 소프트웨어 선형 피드백 시프트 레지스터(software linear feedback shift register)를 썼다(참고자료). 이로써 각 자료 구조에 접근할 때 약 3나노초의 오버헤드가 추가되었다.) integers의 1024 X 1024개 엘리먼트에 대한 결과는 그림 5에서 보는 바와 같다.
그림 5. 자료 구조 접근 시간(1024 X 1024개인 경우에 임의 접근 방식으로)
그림 4와 비교해 보면, (이미 예상한 바와 같이) HashMap이 이제 ConcurrentHashMap과 거의 동등한 성능을 보인다는 것을 알 수 있다. TreeMap의 경우 지나치게 임의 접근을 사용한다. 배열과 ArrayList 자료 구조는 여전히 가장 빠르다. 그러나 상대적인 성능 우세는 다소 줄었다. (이것들은 임의 접근 성능에서 약 열 배나 느려졌다. ConcurrentHashMap의 경우는 임의 접근 성능에서 약 두 배 정도 느려졌을 뿐이다.)
한 가지 더 짚어 보자. 그림 3과 4, 5는 각각 접근 시간의 그래프를 그린 것이다. 예를 들면, 그림 3에서 TreeMap에서 하나의 엘리먼트에 접근하는 시간이 80나노초를 약간 넘는다는 것을 알 수 있다. 여기서 모든 작업은 Listing 2와 같고, 그저 내부적으로 여러 번 데이터에 접근할 뿐이다(즉, integers의 각 엘리먼트에 루프를 돌기만 한다). 그러면, 여러 독립된 동작으로 구성된 작업에서 각 동작별 통계는 어떻게 구할 것인가?
Listing 2에서 해당 작업의 상위 코드는 언급하지 않았다. 상위 코드에서는 이러한 작업을 어떻게 다루는지를 보여 준다. Listing 3과 같은 코드를 사용하면 된다.
Listing 3. 다수의 동작이 엮인 작업을 위한 코드
public static void main(String[] args) throws Exception {
int m = Integer.parseInt(args[0]);
integers = new Integer[m];
for (int i = 0; i < integers.length; i++) integers[i] = new Integer(i);
System.out.println(
"array element access (unsynchronized): " + new Benchmark(new ArrayAccess(), m));
// plus similar lines for the other data structures...
} |
Listing 3에서 Benchmark 생성자 중 인자를 두 개 갖는 버전을 사용했다. Listing 3에서 볼드체로 표시한 두 번째 인자는 한 작업을 구성하는 독립된 동작의 수를 명시한다. 여기서는 m = integers.length다. 더 자세한 것은 본 문서 참고자료의 Block statistics versus action statistics 절을 참조하라.
최적의 포트폴리오 계산
지금까지 본 문서에서는 마이크로벤치마크만을 고려하였다. 마이크로벤치마크만큼이나 관심을 가져 볼 점은 실제 애플리케이션 성능을 측정하려 할 때 벤치마킹의 진정한 용도가 어디에 있는지를 살펴 보는 것이다.
많은 투자자가 관심을 갖는 예로 Markowitz 평균분산 포트폴리오 최적화(Markowitz mean-variance portfolio optimization)라는 것이 있다. 이는 높은 위험/보상 프로파일을 담은 포트폴리오를 구성하기 위해 재무 설계사들이 사용하는 표준 기법이다(참고자료).
이러한 계산을 목적으로 자바 라이브러리를 제공하는 회사 중 라는 데가 있다(참고자료). Listing 4의 코드는 Portfolio v5.0(J2SE Edition) 라이브러리가 효율적인 시장선(efficient frontier)을 구하는 성능을 벤치마크한 것이다(참고자료).
Listing 4. 포트폴리오 최적화 벤치마킹 코드
protected static void benchmark_efficientFrontier(
double[][] rets, boolean useCons, double Rf, double scale
) throws Exception {
Benchmark.Params params = new Benchmark.Params(false); // false to meas only first
params.setMeasureCpuTime(true);
for (int i = 0; i < 10; i++) {
double[][] returnsAssetsRandomSubset = pickRandomAssets(rets, 30);
Callable<Double. task = new EfTask(returnsAssetsRandomSubset, useCons, Rf, scale);
System.out.println(
"Warmup benchmark; can ignore this: " + new Benchmark(task, params) );
}
System.out.println();
System.out.println("n" + "\t" + "first" + "\t" + "sharpeRatioMax");
for (int n = 2; n <= rets.length; n++) {
for (int j = 0; j < 20; j++) { // do 20 runs so that can plot scatter
double[][] returnsAssetsRandomSubset = pickRandomAssets(rets, n);
Callable<Double> task = new EfTask(returnsAssetsRandomSubset, useCons, Rf, scale);
Benchmark benchmark = new Benchmark(task, params);
System.out.println(
n + "\t" + benchmark.getFirst() + "\t" + benchmark.getCallResult());
}
}
}
|
본 문서는 벤치마킹에 관한 글이지, 포트폴리오 이론에 관한 글이 아니므로 Listing 4에서는 EfTask의 내부 클래스(inner class) 코드를 생략하였다. (설명만 덧붙이면, EfTask는 자산을 통해 얻은 과거 수익을 통해 기대 수익과 분산을 계산하고, 효율적인 시장선을 따라 50가지 점을 구한 후, 최대 Sharpe 비율을 가진 점을 돌려 준다(참고자료). 이 최적의 Sharpe 비율은 특정 자산 집합에서 최고의 포트폴리오가 기대할 수 있는 수익을 가늠하는 기준이 된다. 즉, 위험이 보정된 기초 위에서 최고의 수익을 결정할 수 있다. 더 자세한 것은 본 문서의 코드 다운로드 중 관련 소스 파일을 참조하라.)
본 코드는 실행 시간은 물론 자산의 수를 다루는 함수로서 포트폴리오 품질까지 동시에 평가하는 데 목적이 있다. 포트폴리오 품질은 포트폴리오 최적화를 원하는 사람이라면 잠재적으로 유용한 정보가 될 수 있다. Listing 4의 n 루프는 이 값을 결정한다.
이 벤치마크는 몇 가지 도전적인 요소를 제시한다. 첫째, 특별히 상당한 수의 자산을 고려하게 되면, 계산은 오랜 시간에 걸쳐 수행된다. 따라서 new Benchmark(task)와 같은 단순한 코드는 여기서 피했다. new Benchmark(task)는 (기본적으로) 60회 작업을 수행한다. 대신, 단 번의 측정을 위해 맞춤형 Benchmark.Params 인스턴스를 생성했다. (여기서는 또한 기본적으로 경과 시간을 쓰는 대신 CPU 시간을 측정하도록 했다. 여기서는 단지 이렇게도 할 수 있다는 것을 보여 주기 위해 시간 측정 방법을 변경한 것뿐이다. 이 실행 환경에서 WebCab Components 라이브러리가 스레드를 추가로 생성하지 않기 때문에, 이렇게 하더라도 문제는 없다.) 그러나 단 번의 벤치마크를 수행하기 앞서, JVM이 처음 코드를 모두 최적화하도록 i 루프에서는 측정과 상관없이 벤치마크를 여러 차례 실행한다.
둘째, 일반적인 결과 보고서는 이러한 벤치마크의 필요에 부적합하다. 따라서 탭 문자를 구분자로 하여 숫자만 출력하는 맞춤형 보고서를 구성했다. 이 경우 보고 내용을 쉽게 복사하여 스프레드시트 등에 복사하고 그래프를 작성할 수 있다. 1회 측정했을 뿐이므로, 실행 시간은 Benchmark의 getFirst 메서드를 통하여 얻었다. 주어진 자산 집합에 대한 최대의 Sharpe 비율은 Callable 작업의 리턴 값이다. Benchmark의 getCallResult 메서드가 이 값을 취한다.
이제 결과의 확산 정도를 시각적으로 도식화해 보자. 주어진 수의 자산에서 내부의 j 루프는 각 벤치마크를 20회 수행한다. 이렇게 하여 아래 그래프에서는 각 자산의 수당 20개의 점을 찍었다(몇몇 경우에, 점들이 서로 겹치므로 점이 몇 개 안 되는 것처럼 보인다).
결과로 돌아가 보자. 내가 사용한 자산은 현재의 OEX(S&P 100) 지수상의 주식이다. 지난 3년간(2005년 1월 1일에서 2007년 12월 31일까지)의 모든 주간 자산 소득을 과거의 수익 정보로 사용했다. 배당금은 무시했다(이를 포함한다면, Sharpe 비율이 다소 높아질 것이다).
그림 6은 자산 수 함수 대비 실행 시간의 그래프다.
그림 6. 포트폴리오 최적화 실행 시간
자산 수가 증가하면, 실행 시간은 3승씩(cubically) 증가한다. 이 측정에서 나타난 확산은 실제적인 것으로 벤치마크 오류가 아니다. 포트폴리오 최적화의 실행 시간은 고려하는 자산 유형에 따라 달라진다. 특히, 분산의 특정 유형은 매우 섬세하게 (작은 크기의 단계로) 산술 계산을 해야 하므로 실행 시간에 영향을 준다.
그림 7은 자산 수 함수로서 포트폴리오 품질(최대 Sharpe 비율)의 그래프를 그린 것이다.
그림 7. 포트폴리오 품질
최대 Sharpe 비율은 처음에는 증가하다가 곧 15에서 20 자산 근처에서 더 이상 증가하지 않는다. 따라서 자산 수가 증가함에 따라 최대치를 향해 값의 범위가 줄어드는 효과가 있다. 이러한 효과는 실질적인 것이다. 고려 자산 수가 증가하면, 모든 '핵심' 자산(이것들이 최적의 포트폴리오를 독식한다)은 100% 고지에 모두 몰려 있게 되므로, 이러한 현상이 일어난다. 본 문서의 범위를 넘어서 더 깊이 생각해 보려면, 본 문서 보충자료의 Portfolio optimization 절을 참조하라.
마지막 함정
마이크로벤치마크는 현장감있는 사용 예를 반영한다. 예를 들어, JDK의 컬렉션 API가 다음과 같은 기대를 바탕으로 설계되었기 때문에, 나는 자료 구조의 접근 시간을 측정했다. 전형적인 애플리케이션은 약 85%의 읽기(read)/횡단(traverse)과 14%의 추가(add)/갱신(update), 1%의 삭제(remove)로 구성되어 있다. 그러나 이러한 구성이 바뀔 때마다 상대적인 성능은 임의대로 달라질 수 있음을 명심해야 한다. 또 다른 미묘한 위험은 클래스 계층 구조의 복잡성이다. 마이크로벤치마크는 주로 단순한 클래스 계층 구조를 갖지만, 복잡한 클래스 계층 구조에서 메서드 호출 부하가 클 수 있다(참고자료). 따라서 정확한 마이크로벤치마크는 현실을 반영해야 한다.
벤치마크 결과가 실제적으로 중요한지를 보라. 한 가지 힌트를 들면, StringBuffer와 StringBuilder를 비교 벤치마크하여 웹 서버 성능에 미치는 영향을 판단하기란 쉽지 않다. (마이크로벤치마킹에 의존하기보다는) 고수준의 구조적인 선택이 대체로 훨씬 더 중요하다(그렇더라도, 구식의 Vector/Hashtable/StringBuffer보다는 자동으로 ArrayList/HashMap/StringBuilder를 사용하도록 하라).
마이크로벤치마크에만 의존할 때는 주의하라. 예를 들어, 새로운 알고리즘의 효과를 판단하고자 한다면, 벤치마크 환경에서만 그 알고리즘을 측정하지 말고 실제 애플리케이션 시나리오에서 정말 차이를 보이는지 확인해 보라.
상식 수준의 컴퓨터와 설정에서 테스트해야만 성능에 대한 일반적 결론을 내릴 수 있다. 운 나쁘게도, 개발 기계에서 벤치마크한 후 코드가 실행될 모든 기계에서도 동일한 결론을 얻을 것이라고 가정하는, 전형적인 오류를 범할 수도 있다. 철저하게 검사하려면, 여러 벌의 하드웨어와 서로 다른 JVM을 준비할 필요도 있다(참고자료).
프로파일링(profiling)을 무시하지 말라. 기대한 대로 동작하는 모든 종류의 프로파일링 도구를 써서 벤치마크를 실행해 보라(중요하다고 생각하는 일부 메서드에서 대부분의 시간이 소모되기 때문이다). 이는 또한 DCE 발생 여부를 보고, 최종 결과의 정확성을 보장하는 역할도 한다.
궁극적으로 합리적인 결론을 내리기 위해 저수준에서 어떻게 코드가 동작하는지 제대로 아는 것만큼 좋은 대안은 없다. 예를 들어, 자바 언어의 sine 구현(Math.sin)이 C 구현보다 빠른가를 살펴 보면, x86 기계에서는 자바 sine이 훨씬 늦다. 빠르지만 부정확한 x86 하드웨어 도우미 명령(hardware helper instruction)을 자바에서는 정확히 회피하기 때문이다. 이러한 벤치마킹 결과로 C가 자바보다 매우 빠르다고 얘기하면, 이는 우매한 판단을 내린 것이다. 그러나 사실상 판단하게 된 것은 (부정확하지만) 전용 하드웨어 명령이 정확한 소프트웨어 계산보다 빠르다는 점뿐이다.
결론
종종 여러 가지를 측정하고 결과를 해석하기 위해 벤치마크에서 통계를 사용할 필요가 있다. 이 글에서 제공하는 벤치마킹 프레임워크는 이러한 기능을 제공하며 많은 다른 이슈를 함께 다룬다. (본 연재의 Part 1과 2에서 설명한 가이드라인을 따라) 이 프레임워크를 사용하든 스스로 새로운 프레임워크를 작성하든, 이제 독자는 자바 코드를 효율적으로 동작시킬, 더 많은 준비가 되었다.
참고자료 교육
-
이 연재와 관련된 보조 사이트: 이 연재를 위한 전체 샘플 코드와 다른 보충 자료를 얻을 수 있다.
-
위키백과에 소개된 통계: 역합성곱(deconvolution), 대표 값(typical value), 산술 평균(arithmetic mean), 측정 값 분산(measurement spread), 표준 편차(standard deviation), 신뢰 구간(confidence intervals), 확률 밀도 함수(probability density functions), 부트스트래핑(bootstrapping), 이상점(outliers), 계열 상관(serial correlation) 등에 대해 더 배우라.
-
"He who refuses to do arithmetic is doomed to talk nonsense": 컴퓨터 과학자 John McCarthy 덕분에 명쾌한 여러 문장이 인용된 것을 볼 수 있다.
-
Use Enhanced For Loop Syntax With Caution: 임베디드 개발에서 개선된
for 루프를 사용할 때의 함정
-
"
Java theory and practice: Synchronization optimizations in Mustang"(Brian Goetz, developerWorks, 2005년 10월): (다른 개념을 포함하여) 락 생략을 설명한다.
-
"Biased Locking in HotSpot" (David Dice's Weblog, 2006년 8월): HotSpot에서 편향된 락 걸기 방안에 대해 읽어 보라.
-
선형 피드백 시프트 레지스터(Linear feedback shift register): 엔지니어적 관점에서의 설명
-
현대적인 포트폴리오 이론(Modern portfolio theory): Markowitz 평균분산 포트폴리오 최적화(mean-variance portfolio optimization), 효율적인 시장선(efficient frontier), 기타 포트폴리오 분석 개념을 위키백과에서 설명한다.
-
WebCab Portfolio v5.0 (J2SE Edition) 라이브러리: 이 글의 예제에서 벤치마크해 본 컴포넌트
-
Sharpe ratio: 이 측정에 대한 위키백과의 설명
-
"Polymorphism Performance Mysteries Explained"(Dr. Heinz M. Kabutz, The Java Specialists' Newsletter, 2001년 4월): 복잡한 클래스 계층 구조에서 메서드 호출의 오버헤드가 증가하는 이유를 읽어 보라.
-
Sun, IBM, BEA의 JVM: 철저하게 하려면, 여러 플랫폼에서 자바를 벤치마크해야 한다.
-
이번 주제 또는 여타 기술적인 주제를 다루는 책을 기술 서점에서 찾아 보라.
-
developerWorks 자바 영역: 자바 프로그래밍의 여러 관점에 대한 수많은 자료
토론
필자소개  | |  | Brent Boyer는 9년 이상 전문적인 소프트웨어 개발자로 일했다. 그는 뉴욕에 위치한 소프트웨어 개발 회사인 Elliptic Group의 최고 관리자로 있다. |
기사에 대한 평가
 |
| 이 문서 북마킹 하기
|
|  |