JMH(Java Microbenchmark Harness) 사용 예제

 

JMH(Java Microbenchmark Harness)

JMH는 Java 언어로 작성된 코드의 성능을 측정하는 도구로, 특히 벤치 마이크로벤치마크를 수행하는 데 사용한다. 마이크로벤치마크는 작은 단위의 코드에 대한 경과 시간, 명령어 처리 속도 등을 측정하는 프로그램을 의미한다. 이는 성능 최적화나 코드 변경에 대한 영향을 정량적으로 측정할 때 유용하게 사용할 수 있다.

 

환경 설정

신뢰할 수 있는 결과를 얻기 위해서는 Maven을 사용해서 jar 파일로 빌드하고 이를 실행해서 테스트하길 권장하고 있다. IDE에서 실행하는 테스트는 결과의 신뢰성이 떨어진다는게 공식 문서의 입장이다.

 

샘플 maven project 생성

$ mvn archetype:generate -DinteractiveMode=false 
    -DarchetypeGroupId=org.openjdk.jmh -DarchetypeArtifactId=jmh-java-benchmark-archetype 
    -DgroupId=org.sample -DartifactId=test -Dversion=1.0

 

혹은 maven dependency 추가

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.36</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.36</version>
</dependency>

 

jar 파일로 테스트시 plugin 추가

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>2.0</version>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>shade</goal>
            </goals>
            <configuration>
                <finalName>jmh-ex</finalName>
                <transformers>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                        <mainClass>org.openjdk.jmh.Main</mainClass>
                    </transformer>
                </transformers>
            </configuration>
        </execution>
    </executions>
</plugin>

 

예제 코드

기본적으로 JMH에서 지원하는 어노테이션 기반으로 테스트 코드를 작성하면 된다.
다음은 HashSet, TreeSet, LinkedHash의 순회 속도를 비교하는 간단한 예제이다.

@State(Scope.Benchmark)
@Fork(1)
@Warmup(iterations = 2)
@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@BenchmarkMode(Mode.AverageTime)
public class BenchmarkSet {

    private int size = 1_000_000;
    private Set<String> hashSet;
    private Set<String> treeSet;
    private Set<String> linkedHashSet;

    @Setup(Level.Invocation)
    public void setup() {
        hashSet = new HashSet<>();
        treeSet = new TreeSet<>();
        linkedHashSet = new LinkedHashSet<>();

        for (int i = 0; i < size; i++) {
            String str = UUID.randomUUID().toString();

            hashSet.add(str);
            treeSet.add(str);
            linkedHashSet.add(str);
        }
    }

    @TearDown
    public void tearDown() {
        hashSet = null;
        treeSet = null;
        linkedHashSet = null;
    }

    @Benchmark
    public String iterateHashSet() {
        Iterator<String> iterator = hashSet.iterator();
        String res = null;

        while (iterator.hasNext())
            res = iterator.next();

        return res;
    }

    @Benchmark
    public String iterateTreeSet() {
        Iterator<String> iterator = treeSet.iterator();
        String res = null;

        while (iterator.hasNext())
            res = iterator.next();

        return res;
    }

    @Benchmark
    public String iterateLinkedHashSet() {
        Iterator<String> iterator = linkedHashSet.iterator();
        String res = null;

        while (iterator.hasNext())
            res = iterator.next();

        return res;
    }

}

 

jar 패키징 후 실행하면

# packaging
mvn clean package

# run
java -jar target/benchmark-ex.jar

 

각 테스트에 대한 결과를 얻을 수 있다.

Benchmark                           Mode  Cnt     Score       Error  Units
BenchmarkSet.iterateHashSet        thrpt    3  3286.341 ±  2083.538  ops/s
BenchmarkSet.iterateLinkedHashSet  thrpt    3  6190.420 ± 11425.523  ops/s
BenchmarkSet.iterateTreeSet        thrpt    3  3402.930 ±  8328.370  ops/s

 

 

어노테이션

@State

벤치마크 메서드에서 사용할 변수를 메서드 외부에서 초기화하고 파라미터로 전달받을 수 있는데, 이때의 파라미터를 "state" 변수라고 한다. 클래스에 @State 어노테이션을 기재했을 때 유효하고 어노테이션의 모드별로 파라미터의 상태를 다르게 가져갈 수 있다.

public class MyBenchmark {

    @State(Scope.Thread)
    public static class MyState {
        public int a = 1;
        public int b = 2;
        public int sum ;
    }


    @Benchmark @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.MINUTES)
    public void testMethod(MyState state) {
        state.sum = state.a + state.b;
    }

}
Thread Thread 별로 상태 객체 생성
Group Thread 그룹별로 상태 객체 생성
Benchmark 모든 쓰레드가 동일 상태 객체 공유

 

 

@Setup, @TearDown

상태(@State) 클래스의 메서드에 기재하는 어노테이션으로, @Setup 메서드는 벤치마크가 시작되기 전에 호출되고, @TearDown 메서드는 벤치마크가 종료되고 호출된다.

public class MyBenchmark {

    @State(Scope.Thread)
    public static class MyState {

        @Setup(Level.Trial)
        public void doSetup() {
            sum = 0;
            System.out.println("Do Setup");
        }

        @TearDown(Level.Trial)
        public void doTearDown() {
            System.out.println("Do TearDown");
        }

```

 

@Setup과 @TearDown이 호출되는 레벨을 지정할 수 있는데 다음과 같은 옵션이 있다.

Trial 벤치마크를 실행할 때마다 호출되며 벤치마크의 실행은 fork를 의미
Iteration 벤치마크 메서드의 iteration이 돌 때마다 호출
Invocation 벤치마크 메서드가 호출될 때마다 호출

 

참고로 Setup이나 TearDown 작업은 벤치마크 성능 측정에 포함되지 않는다.

 

 

@Fork

벤치마크를 수행할 때 몇 번의 프로세스(= fork)를 생성할지 지정하는 데 사용한다. 여기서 fork는 독립적인 JVM에서 벤치마크를 실행하는 것을 의미한다. 예를 들어, @Fork(3)이라고 선언하면 각 벤치마크는 세 번 별도의 프로세스에서 실행되고 이렇게 실행된 결과를 취합해서 최종 성능을 계산한다. 일관성을 높이기 위해 사용되며 기본 값은 5로 설정되어 있다.

 

 

@Warmup

벤치마크를 실행하기 전에 웜업을 몇 번 수행할지 지정하는 데 사용된다. 웜업은 벤치마크 메서드가 실제 측정되기 전에 JVM을 최적화하고, JIT 컴파일러가 코드를 더 효율적으로 변환하도록 하는 작업이다.

 

 

@Measurement

벤치마크를 몇 번 반복해서 측정할지 지정하는 데 사용된다. 측정은 실제 벤치마크 측정이 수행되는 단위를 말한다. 예를 들어, 다음과 같이 기재하면 벤치마크 메서드를 3회 반복하고 측정을 1초 동안 수행한다.

@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)

 

 

@BenchmarkMode

벤치마크 결과를 어떤 형태로 측정할지 결정하는 어노테이션으로, 다음과 같은 옵션을 제공한다.

Throughput  1초당 얼마나 많은 작업을 수행하지는 측정
AverageTime  평균 실행 시간 측정
SampleTime  최대, 최소 시간등을 포함한 시간 측정  
SingleShotTime 메서드 단일 실행 시간 측정 (JVM 워밍업 없이 cold start에 유용하게 사용)
All  모든 모드를 측정한다.

 

 

IDE에서 직접 실행한다면 다음과 같이 main 메서드를 작성하고 실행한다.

@State(Scope.Benchmark)
public class BenchmarkSet {
    ```
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(BenchmarkSet.class.getSimpleName())
                .forks(1)
                .warmupIterations(2)
                .measurementIterations(2)
                .measurementTime(TimeValue.seconds(1))
                .timeUnit(TimeUnit.SECONDS)
                .mode(Mode.AverageTime)
                .build();

        new Runner(opt).run();
    }
}

 

 

Dead Code 제거

Dead Code란 프로그램의 실행 흐름에서 실제 사용되지 않는 코드를 의미한다. 성능 측정 도중에는 이러한 Dead Code가 JIT 컴파일러에 의해 최적화되어 벤치마크 결과가 왜곡될 수 있다. 예를 들어, JVM이 어떤한 계산 결과가 전혀 사용되지 않음을 알면 코드를 제거할 수 있는데 아래 코드가 이에 해당한다.

public class MyBenchmark {

    @Benchmark
    public void testMethod() {
        int a = 1;
        int b = 2;
        int sum = a + b;
    }

}

 

JVM은 sum에 할당된 값이 전혀 사용되지 않음을 감지하고 데드 코드로 간주해서 int sum = a + b; 코드를 제거한다. 결과적으로 ab의 사용처가 사라지면 자연스럽게 이 변수들도 마찬가지로 데드 코드로 간주되어 메서드에는 어떠한 코드도 남아있지 않게 되면서 벤치마크의 결과를 신뢰할 수 없게 된다.

여기엔 두 가지 해결 방안이 있는데

첫째는 계산 결과를 벤치마크 메서드 반환값으로 사용하는 것이고

둘째는 JMH에서 제공하는 Blackhole을 사용하는 것이다.

 

메서드 반환값으로 사용

다음과 같이 계산된 값을 반환하면 호출하는 쪽에서 값을 사용할 수 있으니 JVM이 코드를 삭제하는 불상사는 없어진다.

만약 데드 코드로 제거될 수 있는 여러 값을 만드는 경우 값을 담는 컨테이너 객체를 만들어서 반환해야 한다.

public class MyBenchmark {

    @Benchmark
    public int testMethod() {
        int a = 1;
        int b = 2;
        int sum = a + b;

        return sum;
    }
}

 

 

Blackhole

JMH에서 제공하는 Blackhole은 계산 결과를 "소비(consume)" 하는데 사용되는 클래스로 다음과 같이 사용한다.

public class MyBenchmark {

   @Benchmark
   public void testMethod(Blackhole blackhole) {
        int a = 1;
        int b = 2;
        int sum = a + b;
        blackhole.consume(sum);
    }
}

 

Blackhole을 메서드의 매개변수로 받고 blackhole.consume(result)과 같이 결과를 소비하는 형태로 사용한다. 이렇게 하면 벤치마크에서 계산한 결과를 실제로 사용하지 않더라도 JIT 최적화 등을 방지해서 벤치마크 결과가 왜곡되지 않도록 한다.