실행 환경
Gradle 7.5
Java 17
Intellij
어떤 자바 메서드를 개선했다고 가정했을 때, 이를 메서드 단위로 수치화하여 어느 정도의 개선이 이루어졌는지 계산해야한다. 이를 가능하게 하는 방법 중에
첫 째로, System.currentTimeMillis() 이나 System.nanoTime() 메서드를 통해 시작과 끝의 차이를 계산하여 성능을 비교하는 방법이 있다.
둘 째로, JMH (Java Microbenchmark Harness) 는 자바 메서드의 성능을 측정하기 위한 라이브러리로, 어노테이션만으로 성능을 측정할 수 있기 때문에 보일러 플레이트 없이 정확하게 측정할 수 있다.
src 폴더 아래에 jmh 디렉토리에 벤치마크할 코드가 있어야 한다.
설치할 jmh plugin 이 벤치마크에 필요한 설정들을 자동으로 생성해서 src 아래의 jmh 폴더에서 벤치마크할 코드를 찾는다.
jmh 플러그인을 사용하면 별도의 프로젝트를 만들지 않고도 기존 소스를 쉽게 테스트 할 수 있다. 그렇기 때문에 벤치마크 소스 파일을 src/main/java 대신 src/jmh/java 에 넣어야 하는 이유이다.
plugins {
id 'java'
id "me.champeau.jmh" version "0.7.1"
}
dependencies {
jmh 'org.openjdk.jmh:jmh-core:0.9'
jmh 'org.openjdk.jmh:jmh-generator-annprocess:0.9'
}
jmh{
fork = 1
warmupIterations = 1
iterations = 1
}
jmh 플러그인은 자동으로 자기가 사용할 버젼의 org.openjdk.jmh 의 jmh-core 와 jmh-generator-annprocess 를 설치하기 때문에 밑에 dependencies 안의 코드는 작성하지 않아도 된다. 만약 임의로 지정한다면 지정한 JMH 버전이 대신 설치된다.
참고로, 0.9 버전이 호환이 안되는지 빌드할 때 계속 오류가 나서 지우고 실행했다
또한, jmh 0.7.1 버전의 플러그인은 JMH 1.3.6 을 사용한다고 한다.
jmh { } 코드 안에는 벤치마크 할 때 Configuration oprions 을 설정할 수 있다.
더 자세한건 jmh-gradle-plugin 를 참고!
@State(Scope.Benchmark)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@BenchmarkMode(Mode.All)
public class ParallelStreamBenchmark {
private static final long N = 10_000_000L;
// Benchmark method should return something and no parameter requires
@Benchmark
public long iterateSum(){
long sum = 0;
for(long i = 1L; i <= N; i++){
sum += i;
}
return sum;
}
@Benchmark
public long parallelSum(){
return Stream.iterate(1L,i -> i+1)
.limit(N)
.parallel() // make stream parallel
.reduce(0L, Long::sum);
}
// public static void main(String[] args) throws Exception {
// org.openjdk.jmh.Main.main(args); // 벤치마킹 시작 --> 필요 X!
// }
}
코드를 대강 설명하자면, 순차적으로 합을 구한 iterateSum 과 병렬로 합을 구한 parallelSum 의 효율을 계산한 코드이다.
벤치마크할 메서드들의 특징으로, 매개변수가 없어야되고 리턴 값이 void가 아니어야한다!
매개변수를 사용하는 대신 @Setup 을 이용하여 변수 값을 초기화할 수 있다.
리턴값이 void라면 대신 Blackhole 객체를 매개변수에서 생성해서 consume 메서드를 이용해야한다.
참고
@State(Scope.Benchmark)
같은 Benchmark 끼리는 같은 객체를 공유한다.
이걸 이용하면 멀티쓰레드 환경일 때 성능을 측정하기 좋다
@OutputTimeUnit(TimeUnit.MICROSECONDS)
벤치마킹 결과를 보여줄 단위를 설정한다. 위 설정은 ms 로 보여준다
@BenchmarkMode(Mode.All)
JMH 의 어떤 벤치마크를 실행할지 지정해준다
@Benchmark
벤치마킹할 메서드에 붙인다.
더 다양한 설정과 예제 코드는 여기서 java-performance jmn 확인 가능하다.
Benchmark 하기 위해선 gradle 을 이용해 빌드해야 결과를 볼 수 있다.
gradlew 가 있는 루트 디렉토리로 이동해서
./gradlew jmh
를 입력하면 빌드가 시작되면서 벤치마킹 결과를 볼 수 있다
생성된 jar 파일은 {프로젝트}/build/libs 위치에 jar 파일이 있고
java -jar {jar파일명}.jar
로 실행해서 빌드 결과를 볼 수 있다.
위 결과를 해석해보자면
처리율, Throughput (thrp):
iterateSum: ≈ 10^-4 ops/us
parallelSum: ≈ 10^-6 ops/us
이 결과는 iterateSum 메소드가 parallelSum 메소드에 비해 처리량이 더 높다는 것을 의미한다. 즉, iterateSum이 더 많은 연산을 단위 시간 내에 처리하고 있다.
이 이유로는 단순한 계산에 parallelSum의 병렬 처리가 오히려 오버헤드가 되기 때문이다 (❗⚠️병렬은 여러 쓰레드가 각자 할 일을 나눈 것 뿐이지, 정말 같은 시간에 작동하는 것이 아니기 때문에 Context Switching 비용이 든다!!)
Average Time (avgt):
iterateSum: 3275.248 μs/op
parallelSum: 292894.506 μs/op
iterateSum이 연산당 평균 소요 시간이 훨씬 적기 때문에 성능이 더 빠르다.
샘플 결과 (Sample):
iterateSum 메소드의 샘플 분포를 보면,
50번째 백분위(p0.50): 3080.192 μs/op
95번째 백분위(p0.95): 3842.048 μs/op
99번째 백분위(p0.99): 5052.088 μs/op
iterateSum의 처리 시간이 대부분의 경우 일정한 범위 안에 있고, 99번째 백분위수에서도 크게 증가하지 않음을 보여줍니다.
Single Shot (ss):
parallelSum: 645015.100 μs/op
parallelSum의 경우 한 번의 연산에 걸린 시간이 매우 길고, 병렬 처리에 대한 오버헤드가 상당히 크다는 것을 의미한다.
왜 IterateSum이 수행 결과가 더 많지?
그 이유는, 벤치마크는 지정된 시간 동안 메서드를 반복해서 실행하면서 성능을 측정하는데, iterateSum은 한 번 실행되는 데 걸리는 시간이 짧기 때문에 더 많은 실행 횟수를 기록한 것이다.
공감하며 읽었습니다. 좋은 글 감사드립니다.