JMH(Java Microbenchmark Harness)
개발을 하다보면 이 로직보다 다른 로직이 더 빠를 것 같은데? 이 라이브러리 사용이 더 좋을 것 같은데? 하는 궁금증이 있다. 하지만 어플리케이션 단위로 성능을 측정하기엔 부수적인 로직들과 라이브러리가 들어가기 때문에, 함수 단위로 성능 측정을 하고 싶은 생각이 든다. 그렇다고 time함수를 사용해서 end time과 start time을 측정해서 성능 테스트 결과는 이러하다 라고 하기엔 warm up, 쓰레드 갯수 등 고려해야 할 사항들이 많다. 이러한 부분을 해결해주기 위한 성능 측정 프레임워크가 벤치마크 프레임워크이다.
특징
oracle의 jit compiler 개발자가 만들었기 때문에 타 benchmark framework보다 신뢰할 수 있다.
기존 코드에 테스트를 붙이는 것이 아닌 jmh 용 코드를 따로 생성해야 한다. (아래 사진과 같이 jmh 폴더에 코드를 작성해야 한다.)
설치
plugins {
id 'me.champeau.jmh' version '0.6.6'
}
필자는 gradle을 사용하여 설치해주었는데, 다음과 같이 plugin을 추가해주면 된다.
gradle 버전에 따라 라이브러리 이름과 버전이 상이하니 공식 깃허브 페이지를 참고하여 설치해야 한다.
사용
@State(Scope.Benchmark)
@Warmup(iterations = 10)
@Fork(1)
@Measurement(iterations = 10)
public class StreamBenchMark {
public static final int N = 10000;
static List<Integer> sourceList = new ArrayList<>();
@Setup
public void setUp() {
for (int i = 0; i < N; i++) {
sourceList.add(i);
}
}
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void useForEach(Blackhole blackhole) {
List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
for (Integer i : sourceList) {
if (i % 2 == 0) {
result.add(Math.sqrt(i));
}
}
blackhole.consume(result);
}
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void useStream(Blackhole blackhole) {
List<Double> result = sourceList.stream()
.filter(i -> i % 2 == 0)
.map(Math::sqrt)
.collect(Collectors.toCollection(
() -> new ArrayList<>(sourceList.size() / 2 + 1)));
blackhole.consume(result);
}
}
@Benchmark 라는 어노테이션을 사용하면 Benchmark는 해당 함수를 테스트할 함수로 인식한다.
./gradlew jmh를 통해 테스트를 실행할 수 있다.
# Blackhole mode: full + dont-inline hint
# Warmup: 10 iterations, 10 s each
# Measurement: 10 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Run progress: 50.00% complete, ETA 00:03:20
# Fork: 1 of 1
# Warmup Iteration 1: 42048.279 ns/op
# Warmup Iteration 2: 40075.217 ns/op
# Warmup Iteration 3: 40969.973 ns/op
# Warmup Iteration 4: 39656.846 ns/op
# Warmup Iteration 5: 40679.756 ns/op
# Warmup Iteration 6: 39297.216 ns/op
# Warmup Iteration 7: 40594.538 ns/op
# Warmup Iteration 8: 40423.582 ns/op
# Warmup Iteration 9: 40044.450 ns/op
# Warmup Iteration 10: 41058.458 ns/op
Iteration 1: 39502.578 ns/op[5m 8s]
Iteration 2: 41584.472 ns/op[5m 18s]
Iteration 3: 39578.528 ns/op[5m 28s]
Iteration 4: 40801.548 ns/op[5m 38s]
Iteration 5: 39699.534 ns/op[5m 48s]
Iteration 6: 40758.056 ns/op[5m 58s]
Iteration 7: 39899.669 ns/op[6m 8s]
Iteration 8: 39844.784 ns/op[6m 18s]
Iteration 9: 40448.119 ns/op[6m 28s]
Iteration 10: 39262.071 ns/op[6m 38s]
Result "com.yaini.study.StreamBenchMark.useStream":
40137.936 ±(99.9%) 1107.810 ns/op [Average]
(min, avg, max) = (39262.071, 40137.936, 41584.472), stdev = 732.748
CI (99.9%): [39030.126, 41245.746] (assumes normal distribution)
Benchmark Mode Cnt Score Error Units
ObjectMapperBenchMark.useForEach avgt 10 44247.319 ± 494.833 ns/op
ObjectMapperBenchMark.useStream avgt 10 40137.936 ± 1107.810 ns/op
설정 값만큼 iteration, warmup이 실행되고 /build/results/jmh에 테스트 결과가 저장된다.
Setting
- @Fork, fork: fork 실행 횟수 지정
- @Warmup, warmupIterations: warm up 수행 횟수
- warm up: jvm은 자주 실행되는 코드는 인터프리터로 실행하지 않고 JIT 컴파일러를 사용해 캐싱하여 코드를 실행한다. 인터프리터로 번역하여 실행할 때와 캐싱하여 코드를 실행할 때의 성능이 다르므로 테스트의 사용되는 함수를 code cache 영역에 캐싱하여 일관성 있게 실행되도록 해준다.
- @Measurement, iterations: 측정횟수를 지정한다.
좀 더 자세한 차이점은 여기 나와있다.
해당 값을 설정하는 방법은 크게 3가지가 있다.
jmh {
fork = 1
warmupIterations = 10
iterations = 10
}
build.gradle에 benchmark 테스트 디폴트 값 전체 설정
public static void main(String[] args) throws IOException, RunnerException {
Options opt = new OptionsBuilder()
.include(StreamBenchMark.class.getSimpleName())
.warmupIterations(10)
.measurementIterations(10)
.forks(1)
.build();
new Runner(opt).run();
}
main method에서 설정 후 runner를 통해 실행
@Warmup(iterations = 10)
@Fork(1)
@Measurement(iterations = 100)
annotation을 통해 설정
후자의 두 방법이 각 테스트 클래스 마다 설정 값을 따로 지정할 수 있고, build.gradle 파일엔 의존성 라이브러리 관리 책임만 가져갈 수 있기 때문에 더 좋은 방식인 것 같다.
options
@Timeout
테스트 함수의 timeout 지정
@Threads
사용할 쓰레드의 갯수 지정
@BenchmarkMode
- throughput: 디폴트 값. 초당 작업 수 측정
- average time: 작업이 수행되는 평균 시간 측정
- sample time: 최대, 최소 시간 등 작업이 수행되는 시간 측정
- single shot time: 단일 작업 수행 소요 시간 측정
- all: 모든 옵션 측정
@OutputTimeUnit
측정 시간 단위, 디폴트는 ns
@State
argument의 상태 지정
state annotation을 지정한 클래스는 pulic이어야 하며, no arg 생성자를 가지고 있어야 한다.
- Scope.Thread: 쓰레드 별로 인스턴스 생성
- Scope.Benchmark: 동일한 테스트 내의 모든 쓰레드에서 동일한 인스턴스 공유, 멀티 쓰레딩 성능 테스트에 사용
- Scope.Group: 쓰레드 그룹마다 인스턴스 생성
@Setup / @TearDown
junit의 @Before과 @After와 같은 역할을 한다.
- setup은 벤치마크가 시작되기 전 Object를 설정하기 위해 사용
- teardown은 벤치마크가 종료된 후 Object를 정리하기 위해 사용
Dead Code
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void useForEach() {
List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
for (Integer i :sourceList) {
if (i % 2 == 0) {
result.add(Math.sqrt(i));
}
}
}
해당 메소드를 보면 result를 사용한 로직이 있지만 결과 값으로 사용되고 있지 않기 때문에, jvm은 이를 인식하여 컴파일에서 제외한다. 사용하지 않는 변수에 대한 컴파일을 하지 않는 것이다. 이를 dead code라고 한다.
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void useForEach(Blackhole blackhole) {
List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
for (Integer i :sourceList) {
if (i % 2 == 0) {
result.add(Math.sqrt(i));
}
}
blackhole.consume(result);
}
dead code로 인해 작성한 테스트 로직이 실행되지 않을 수 있기 때문에, benchmark에선 black hole이라는 class를 제공한다. 실제 로직에서 사용되지 않아도 blackhole.consume() 메소드를 통해 사용한 것 처럼 인식하도록 해준다.
성능 테스트 설정을 좀 더 간편하게 해주지만, 정확한 성능 테스트를 위해선 thread, dead code 등 고려해야 할 사항이 많기 때문에 설정 값을 유의미하게 정할 필요가 있어 보인다. 🤔
코드는 깃허브에 작성해두었다.
그 외의 다른 샘플은 공식 홈페이지에서 참고할 수 있다.
참고
https://stackoverflow.com/questions/22658322/java-8-performance-of-streams-vs-collections
https://medium.com/@devAsterisk/jmh-java-benchmark-tool-4f7a27eb3fa3
https://ysjee141.github.io/blog/quality/java-benchmark/
https://javabom.tistory.com/75
https://jerry92k.tistory.com/65?category=981716
'Java' 카테고리의 다른 글
[JAVA] M1 jdk, jenv 환경 세팅 (0) | 2022.02.19 |
---|---|
[JAVA] 리플렉션과 제네릭은 어떻게 형변환을 자유롭게 해줄까? (0) | 2022.02.15 |