마이크로 벤치마킹
거리 측정할 때를 생각해보자. 멀리뛰기에서는 cm단위로 측정할 것이고, 내비게이션은 km단위로 측정한다. 몸무게는 kg단위로, 자동차 무게는 t단위로 측정할 것이다. 이처럼 무언가를 측정할 때는 특정한 '스케일'이 중요하다.
성능 테스트도 마찬가지다. 수 많은 마이크로 서비스로 이루어진 시스템에서 응답 시간을 측정할 때와, Java 메소드 하나를 실행하는 데 걸린 시간을 측정할 때는 우리의 관심사가 다르다. Java 메소드의 실행은 상대적으로 짧은 시간에 일어나기 때문에 JVM의 동작에 많은 영향을 받는다. 예를 들면 GC에 의해 STW가 일어나는 시간, JIT 컴파일러에 의해 최적화가 되면 시간이 우리의 의도와 다르게 측정될 수 있다.
이렇게 비교적 작은 Java 코드에 대한 벤치마크 테스트를 마이크로 벤치마킹이라고 한다. MSA같은 복잡한 시스템에서는 GC같은 JVM의 동작 시간을 무시할 수 있지만, 마이크로 벤치마크 테스트에서는 JVM의 동작을 통제해야 적절한 테스트를 할 수 있다. 이렇게 Java에서는 JVM의 동작을 통제하면서 마이크로 벤치마킹을 할 수 있는 프레임워크를 지원하는데, 그것이 JMH(Java Microbenchmark Harness)이다.
JMH(Java Microbenchmark Harness)
[참고 - JMH github] https://github.com/openjdk/jmh
JMH 예제를 살펴보자. Maven archetype을 사용하면 JMH 프로젝트를 만들 수 있다.
이제 아래와 같은 테스트를 만들어보자.
package org.example;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class DeadCode {
private double x = Math.PI;
private double compute(double d) {
for (int c = 0; c < 10; c++) {
d = d * d / Math.PI;
}
return d;
}
@Warmup(time = 1)
@Measurement(time = 1)
@Benchmark
public void baseline() {
}
@Warmup(time = 1)
@Measurement(time = 1)
@Benchmark
public void measureWrong() {
compute(x);
}
@Warmup(time = 1)
@Measurement(time = 1)
@Benchmark
public double measureRight() {
return compute(x);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(DeadCode.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
}
코드를 살펴보면, main메소드는 JMH 테스트를 실행한다. OptionsBuilder를 통해 다양한 실행 옵션을 선택할 수 있다. command line으로도 옵션을 줄 수 있는데, 중복되는 경우 command line 옵션이 우선이다.
@Benchmark 어노테이션은 테스트하려는 메소드를 나타낸다. 이 코드에서는 baseline, measureWrong, measureRight 메소드를 테스트한다. 테스트는 기본적으로 10초동안 실행해서 평균값을 계산하기 때문에 빠른 실행을 위해 @Warmup과 @Measurement의 time을 1초로 바꿔주었다. 또한 class에 붙어있는 @BenchmarkMode는 측정하고싶은 값을 지정해준다. AverageTime 이외에도 Throughput, SampleTime 등이 있다. @State는 각 테스트를 실행하기 위한 scope를 정한다. 위 코드에서는 DeadCode 인스턴스를 만들어 thread에서 공유한다.
코드를 보면 measureWrong과 measureRight가 비슷하지만, 테스트 결과를 보면 baseline과 measureWrong의 결과가 거의 비슷하게 나왔다. measureWrong의 코드를 보면 compute의 결과가 사용되지 않는데, JIT 컴파일러에서 이것을 알고 DCE(Dead Code Elimination)을 했기 때문이다. 따라서 measureWrong은 의도와 달리 런타임에 아무것도 하지 않는 코드와 같아졌다. JMH는 이러한 문제를 해결하기 위해 Blackholes 클래스를 제공한다.
Blackhole
Blackhole은 사용되지 않는 코드를 임의로 소비하여 JIT 컴파일러가 최적화하지 못하게 한다. Blackhole 클래스는 모든 타입에 대해 소비하는 코드를 구현하고 있다. 예를 들면 int를 소비하는 코드는 아래와 같다.
public final void consume(int i) {
if (COMPILER_BLACKHOLE) {
consumeCompiler(i);
} else {
consumeFull(i);
}
}
private static void consumeCompiler(int v) {}
private void consumeFull(int i) {
int i1 = this.i1; // volatile read
int i2 = this.i2;
if ((i ^ i1) == (i ^ i2)) {
// SHOULD NEVER HAPPEN
nullBait.i1 = i; // implicit null pointer exception
}
}
Blackhole의 활용은 아래와 같이 할 수 있다.
package org.example;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Blackholes {
double x1 = Math.PI;
double x2 = Math.PI * 2;
private double compute(double d) {
for (int c = 0; c < 10; c++) {
d = d * d / Math.PI;
}
return d;
}
@Warmup(time = 1)
@Measurement(time = 1)
@Benchmark
public double baseline() {
return compute(x1);
}
@Warmup(time = 1)
@Measurement(time = 1)
@Benchmark
public double measureWrong() {
compute(x1);
return compute(x2);
}
@Warmup(time = 1)
@Measurement(time = 1)
@Benchmark
public double measureRight_1() {
return compute(x1) + compute(x2);
}
@Warmup(time = 1)
@Measurement(time = 1)
@Benchmark
public void measureRight_2(Blackhole bh) {
bh.consume(compute(x1));
bh.consume(compute(x2));
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Blackholes.class.getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
}
baseline은 compute를 1번만 실행한다. measureWrong은 compute를 2번 실행하지만, compute(x1)은 결과가 사용되지 않기 때문에 JIT 컴파일러의 최적화 대상이 된다. measureRight_1은 compute를 2번하고 합치기 때문에 모두 소비된다. measureRight_2는 Blackhole을 사용해서 임의로 결과를 소비한다.
위 결과를 보면 measureWrong은 최적화되어 compute를 1번만 실행했다는 것을 알 수 있고, measureRight_2는 compute의 결과가 의미있게 소비되지 않지만 모두 소비되어 최적화되지 않았다는 것을 알 수 있다.
마이크로 벤치마킹은 언제 필요한가?
지금까지 마이크로 벤치마킹을 살펴봤지만, 언제 테스트를 수행해야 할까? JVM의 최적화를 통제해서 테스트를 해야한다는 것은 성능에 극도로 민감한 경우이다. 예를 들면, latency에 민감한 코드를 사용하여 GC를 테스트해야 하는 경우, 알고리즘의 효율이 중요한 OpenJDK나 범용 라이브러리를 개발하는 경우 등에는 마이크로 벤치마킹이 유용한 정보를 제공할 수 있다.
그러나 평범한 개발자가 OpenJDK를 개발하는 경우는 많지 않을 것이다. 일반적인 서버를 개발하는 경우에는 JVM 동작의 영향은 무시할 수 있을만큼 작다. 따라서 앞으로는 좀 더 큰 시스템에서 성능 테스트를 진행하고 최적화하는 방법에 대해 알아보도록 하자.
3줄 요약
- 상대적으로 작은 Java 코드의 테스트는 JVM의 영향을 많이 받는다
- JMH를 사용하면 JVM의 동작을 통제할 수 있다
- 라이브러리를 개발하거나 성능에 극도로 민감한 경우가 아니면 마이크로 벤치마킹을 할 일은 거의 없다
'Java > 자바 최적화' 카테고리의 다른 글
[Java] 자바 최적화(7) - 최신 가비지 컬렉터 (0) | 2023.04.02 |
---|---|
[Java] 자바 최적화(6) - 가비지 컬렉션 기초 (0) | 2023.03.26 |
[Java] 자바 최적화(4) - 런타임 데이터 영역 (0) | 2023.03.18 |
[Java] 자바 최적화(3) - 실행 엔진 (0) | 2023.03.17 |
[Java] 자바 최적화(2) - 클래스 로더 (0) | 2023.03.16 |