JMH으로 자바 프로그램을 benchmark 하기

November 07, 2023


JMH이란

JMH는 Java Micro-benchmarking Harness의 약어로, 자바 언어에서 성능 측정을 위한 벤치마킹 라이브러리입니다.

왜 필요할까?

자바는 코드를 읽고 바이트 코드로 변환하여 JVM에서 실행되는 언어입니다. 그래서 이러한 속도를 개선하기 위해 JVM 버전마다 다양한 최적화 기법이 존재합니다.

하지만 테스트를 위해서는 이러한 최적화 기법과 CPU마다 다른 성능을 배제하고 테스트 환경을 일치시켜야 합니다.

그래서 JMH는 벤치마킹을 위한 일관된 환경을 제공하여 결과의 신뢰성과 여러 실행 결과에 대한 통계를 제공하므로, 이에 대한 신뢰도를 높일 수 있습니다.

warm-up

  • 핫스판 JVM내 JIT 컴파일러는 일정 횟수 이상 실행된 메소드를 컴파일하기 때문에, warm-up 단계를 생략하면 결과가 왜곡될 수 있습니다.

필요없는 코드 제거 방지

  • 벤치마크 작성 시, 성능 측정 대상 코드만 반복문에 넣어 돌리는 경우가 흔한데, 핫스팟 컴파일러는 참조되지 않는 무의미한 코드를 자동으로 삭제하기 때문에 왜곡된 결과를 얻을 수 있습니다.

인터페이스 자동 최적화 방지

  • 인터페이스 구현체가 1개인 경우 실행 시 분기가 필요하지 않아 네이티브 코드가 최적화됩니다. 그러나 동일 인터페이스를 구현하는 클래스가 추가로 로드되면 이전 실행과는 다른 결과가 나올 수 있습니다.

다양한 벤치마크 모드 지원

JMH는 다양한 벤치마크 모드를 지원하여 여러 측정 지표를 쉽게 얻을 수 있습니다.

예를 들어, Throughput 모드는 초당 작업 수를 측정하며, Average Time 모드는 각 작업의 평균 실행 시간을 측정합니다.

평가 요소

평가 요소 설명 지표
Throughput operations per unit of time 1이 기본, 높을 수록 좋은 성능
AverageTime average time per operation 평균 실행 시간, 낮을 수록 좋은 성능
SampleTime samples the time for each operation 정확한 측정을 위해 여러 샘플을 수집하는 것이 중요
SingleShotTime measures the time for a single operation 단일 작업에 대한 실행 시간을 측정하는데 사용되며, 해당 값이 낮을수록 해당 작업의 처리가 빠름

측정 방법

Gradle 종속성

plugins {
    // ...
    id "me.champeau.jmh" version "0.7.2" // JMH 추가
}

dependencies {
    // 만약 main을 따로 설정해서 그 클래스 benchmark만 하고 싶다면 아래 종속성 추가
    jmh 'org.openjdk.jmh:jmh-generator-annprocess:1.36'

    // this is the line that solves the missing /META-INF/BenchmarkList error
    jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.36'
}

jmh { // Default 테스트 수행값 정의
    warmupIterations = 2
    iterations = 5
    fork = 1
}

측정할 코드 위치

JMH으로 측정할 코드들은 main아래에 작성하는 것이 아닌, src 바로 아래의 새로 jmh이라는 패키지를 만들고 아래와 같이 위치해야 합니다.

├── src
│   ├── jmh
│   │   └── java
│   │       └── ben
│   │           └── MyBenchmark.java
│   ├── main

예제 코드

  • MyBenchmark.java
@State(Scope.Benchmark)
@BenchmarkMode({Mode.Throughput, Mode.AverageTime})
@Fork(value = 1)
@Warmup(iterations = 5, timeUnit = TimeUnit.MILLISECONDS, time = 5000)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class MyBenchmark {
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }

    @Benchmark
    public long useDivideAndConquer() {
        Calculator calculator = new CalculatorImplDC();
        return calculator.fibonacci(92);
    }

    @Benchmark
    public long useDynamicProgramming() {
        Calculator calculator = new CalculatorImplDP();
        return calculator.fibonacci(92);
    }
}

실행

./gradlew jmh을 실행하면 @Benchmark 에너테이션이 달린 함수들을 벤치마킹합니다.

결과

결과는 프로젝트 경로/build/results/jmh/results.txt에서 확인할 수 있습니다.

Benchmark                         Mode  Cnt      Score      Error   Units
Benchmark.useDivideAndConquer    thrpt    5   7772.442 ±   96.801  ops/ms
Benchmark.useDynamicProgramming  thrpt    5  19186.381 ±   25.211  ops/ms
Benchmark.useDivideAndConquer     avgt    5     ≈ 10⁻⁴              ms/op
Benchmark.useDynamicProgramming   avgt    5     ≈ 10⁻⁴              ms/op

N이 92밖에 되지 않고 수행횟수도 5번이라서 수행하는데 걸리는 시간이 매우 짧습니다. 그렇지만 Throughput을 본다면 재귀를 사용한 분할 정복보다 반복문을 사용하는 동적 프로그래밍으로 수행했을 때, 더 좋은 성능이 나온다고 말할 수 있습니다.

참조

https://github.com/melix/jmh-gradle-plugin

https://stackoverflow.com/questions/24928922/jmh-what-does-the-score-value-mean


Profile picture

이재원

이해하기 쉬운 코드를 작성하려 고민합니다.


© 2024 Won's blog Built with Gatsby