본문 바로가기
개발/자바

JMH benchmark

by hamcheeseburger 2023. 3. 12.

동기

대량의 데이터를 쪼개어 처리할 일이 있어, stream 보다는 parallelStream을 사용해보려고 했다.

당연히 parallelStream이 훨씬 빠르겠거니 하고 테스트를 해본 결과 stream이 더 빠른 결과가 나왔다. 대체 어떤 상황인지 이해가 되지 않아 구글링을 해보니, 해당 글을 발견했다.

짧게 수행한 벤치마킹은 JIT 컴파일러가 동작하는 중간에 혹은 동작하기 전에 끝나버리기 때문에, 최대 처리량 측정하지 않게 된다. 즉, 내가 테스트한 결과가 정확하지 않다는 것이다.

JVM에서 성능테스트를 하기란 쉽지 않기 때문에 JMH같이 잘 만들어진 벤치마킹 프레임워크로 테스트하는 것을 추천하여 접하게 됐다.

적용

gradle

plugins {
  id "me.champeau.jmh" version "0.6.8"
}

dependency 없이 plugin만 있어도 된다.

폴더 만들기

공식문서에 따르면 benchmark 코드는 src/jmh 내에 존재해야 한다. 이는 이미 존재하는 프로젝트에 벤치마킹 코드를 쉽게 추가할 수 있게 해준다. 기존에 존재하는 코드를 활용해서 벤치마킹을 진행할 수 있다.

src 폴더에서 jmh라는 폴더를 생성하자. gradle에 플러그인을 추가하면 인텔리제이에서 디렉토리를 생성할 때 이렇게 source set을 추천해준다.

나는 이번에 java로 작성할 것이기 때문에 jmh/java로 source를 생성해보겠다.

벤치마킹 코드 작성하기

package com.example.benchmark;

import static java.lang.Thread.sleep;

import com.example.words.dto.WordCode;
import com.example.words.dto.WordCodes;
import com.google.common.collect.Lists;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;

@State(Scope.Benchmark)
public class BenchMarkTest {

    private static final int N = 16_000;
    private static final int API_LIMIT = 100;
    private List<String> words;

    @Setup
    public void setup() {
        words = new ArrayList<>();
        for (int i = 0; i < N; i++) {
            words.add("abcde");
        }
    }

    @Benchmark
    public void parallelTest() {
        Lists.partition(words, API_LIMIT)
            .parallelStream()
            .map(this::apiCall)
            .map(WordCodes::getData)
            .flatMap(Collection::stream)
            .collect(Collectors.toList());
    }

    @Benchmark
    public void streamTest() {
        Lists.partition(words, API_LIMIT)
            .stream()
            .map(this::apiCall)
            .map(WordCodes::getData)
            .flatMap(Collection::stream)
            .collect(Collectors.toList());
    }

    private WordCodes apiCall(List<String> words) {
        // 생략
        try {
            sleep(3);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return new WordCodes(wordCodeList);
    }

    @TearDown(Level.Invocation)
    public void tearDown() {
        System.gc();
    }
}

@BenchMark

  • 일반적으로 우리가 테스트하고 싶은 메서드에 적용하는 어노테이션
  • JMH는 컴파일하는 동안 해당 메서드에 대해 생성된 벤치마크 코드를 제공하고, 해당 메서드를 벤치마크 목록에 벤치마크로 등록하고 일반적으로 벤치마크가 실행될 환경을 준비한다.
  • 해당 어노테이션이 적용되는 메서드가 가져야하는 특징
    • public 이어야 함
    • arguments가 jmh에서 주입할 수 있는 객체이어야 함
      • 사용자가 정의한 State class
      • jmh infrastructure class (ex. Blackhole, Control)
    • 위의 특성을 깨는 benchmark method를 만들고 싶다면, 외부에서 생성하고 bench mark method에서 호출하는 식으로 변경해야 한다.

@State(value=)

  • 객체의 공유 범위를 표기함
  • value 속성 값
    • Scope.Benchmark
      • 동일 타입의 모든 인스턴스들은 모든 worker thread들 사이에 공유된다. Setup과 TearDown 메서드는 worker thread들 중 하나에 의해 수행된다.
    • Scope.Group
      • 동일 타입의 모든 인스턴스들은 동일 그룹내에 있는 모든 thread들 사이에 공유된다. Setup과 TearDown 메서드는 그룹내의 thread들 중 하나에 의해 수행된다.
    • Scope.Thread
      • 동일한 benchmark 메서드에 다양한 상태의 객체들이 주입되더라도 동일 타입에 대한 모든 인스턴스들은 구분된다. Setup과 TearDown 메서드는 단일 worker thread에 의해서만 수행된다.

@Fork

JVM은 애플리케이션 동작에 profile을 생성하여 최적화를 한다. 이는 BenchMark에 좋지 않다. 다른 테스트에서 profile을 혼합한 다음 모든 테스트에 대해 "균일하게 나쁜" 코드를 렌더링할 수 있기 때문이다. 각 테스트를 분리하여 실행하면 이 문제를 피할 수 있는데, 이 때 @Fork 가 사용된다.

그리고 해당 어노테이션을 적용하지 않아도 jmh는 기본적으로 fork를 수행한다.

속성 값

  • value
    • 해당 값을 지정하지 않으면 기본적으로 default value가 5이다.
    • fork할 JVM의 개수라고 생각하면 된다. 그렇게 격리된 테스트 환경에서 benchMark 메서드가 수행되기 때문에 테스트 set의 개수라고 이해해도 될 것 같다.
  • warmup
    • fork한 JVM에서 warmup을 몇 번 할 것인지 정할 수 있다. 이는 테스트 결과에서 제외된다. 해당 값을 지정하지 않으면 warmup을 하지 않는다.

해당 값을 지정하면 Warmup Fork를 수행한다. @Fork(value=2, warmup=1) 로 지정한 결과이다.

실행

terminal에서 gradle이 존재하는 폴더로 이동하여 아래 명령어를 실행한다.

sudo ./ gradlew jmh

Run progress가 아래와 같이 터미널에 프린트 되면서 실행이 될 것이다.

BenchMarking이 완료되면 build/results/jmh/results.txt 에 결과가 저장된다.

Benchmark                    Mode  Cnt   Score   Error  Units
BenchMarkTest.parallelTest  thrpt   25  14.318 ± 0.128  ops/s
BenchMarkTest.streamTest    thrpt   25   1.651 ± 0.012  ops/s

참고자료

이전 댓글