1. 소개
이 빠른 문서는 JMH(Java Microbenchmark Harness)에 중점을 둡니다. 먼저 API에 익숙해지고 기본 사항을 배웁니다. 그런 다음 마이크로벤치마크를 작성할 때 고려해야 할 몇 가지 모범 사례를 보게 됩니다.
간단히 말해서 JMH는 JVM 워밍업 및 코드 최적화 경로와 같은 작업을 처리하여 벤치마킹을 최대한 간단하게 만듭니다.
2. 시작하기
시작하려면 실제로 Java 8로 작업을 계속하고 간단히 의존성을 정의할 수 있습니다.
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.35</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.35</version>
</dependency>
JMH Core 및 JMH Annotation Processor 의 최신 버전은 Maven Central에서 찾을 수 있습니다.
다음으로, @Benchmark 어노테이션(모든 공용 클래스에서) 을 활용하여 간단한 벤치마크를 만듭니다 .
@Benchmark
public void init() {
// Do nothing
}
그런 다음 벤치마킹 프로세스를 시작하는 기본 클래스를 추가합니다.
public class BenchmarkRunner {
public static void main(String[] args) throws Exception {
org.openjdk.jmh.Main.main(args);
}
}
이제 BenchmarkRunner 를 실행하면 다소 쓸모없는 벤치마크가 실행될 것입니다. 실행이 완료되면 요약 표가 표시됩니다.
# Run complete. Total time: 00:06:45
Benchmark Mode Cnt Score Error Units
BenchMark.init thrpt 200 3099210741.962 ± 17510507.589 ops/s
3. 벤치마크의 종류
JMH는 처리량, AverageTime, SampleTime 및 SingleShotTime 과 같은 몇 가지 가능한 벤치마크를 지원합니다 . @BenchmarkMode 어노테이션 을 통해 구성할 수 있습니다 .
@Benchmark
@BenchmarkMode(Mode.AverageTime)
public void init() {
// Do nothing
}
결과 테이블에는 평균 시간 메트릭(처리량 대신)이 있습니다.
# Run complete. Total time: 00:00:40
Benchmark Mode Cnt Score Error Units
BenchMark.init avgt 20 ≈ 10⁻⁹ s/op
4. 준비 및 실행 구성
@Fork 어노테이션 을 사용하여 벤치마크 실행 방식을 설정할 수 있습니다. value 매개변수는 벤치마크가 실행되는 횟수를 제어하고, warmup 매개변수는 결과가 수집되기 전에 벤치마크가 드라이런할 횟수를 제어합니다. 예를 들어 :
@Benchmark
@Fork(value = 1, warmups = 2)
@BenchmarkMode(Mode.Throughput)
public void init() {
// Do nothing
}
이는 JMH에게 두 개의 워밍업 포크를 실행하고 실시간 벤치마킹으로 이동하기 전에 결과를 폐기하도록 지시합니다.
또한 @Warmup 어노테이션을 사용하여 워밍업 반복 횟수를 제어할 수 있습니다. 예를 들어 @Warmup(iterations = 5) 는 JMH에게 기본 20회가 아닌 5회의 워밍업 반복으로 충분하다고 알려줍니다.
5. 상태
이제 State 를 활용하여 해싱 알고리즘을 벤치마킹하는 덜 사소하고 더 직관적인 작업을 수행하는 방법을 살펴보겠습니다 . 암호를 수백 번 해싱하여 암호 데이터베이스에 대한 사전 공격으로부터 추가 보호를 추가하기로 결정했다고 가정합니다.
State 개체 를 사용하여 성능 영향을 탐색할 수 있습니다 .
@State(Scope.Benchmark)
public class ExecutionPlan {
@Param({ "100", "200", "300", "500", "1000" })
public int iterations;
public Hasher murmur3;
public String password = "4v3rys3kur3p455w0rd";
@Setup(Level.Invocation)
public void setUp() {
murmur3 = Hashing.murmur3_128().newHasher();
}
}
벤치마크 방법은 다음과 같습니다.
@Fork(value = 1, warmups = 1)
@Benchmark
@BenchmarkMode(Mode.Throughput)
public void benchMurmur3_128(ExecutionPlan plan) {
for (int i = plan.iterations; i > 0; i--) {
plan.murmur3.putString(plan.password, Charset.defaultCharset());
}
plan.murmur3.hash();
}
여기서 필드 반복 은 JMH가 벤치마크 메서드에 전달될 때 @Param 어노테이션 의 적절한 값으로 채워집니다 . @Setup 어노테이션 이 달린 메서드는 벤치마크를 호출하기 전에 호출되며 격리를 보장 하는 새 해시 를 생성합니다.
실행이 완료되면 아래와 유사한 결과를 얻게 됩니다.
# Run complete. Total time: 00:06:47
Benchmark (iterations) Mode Cnt Score Error Units
BenchMark.benchMurmur3_128 100 thrpt 20 92463.622 ± 1672.227 ops/s
BenchMark.benchMurmur3_128 200 thrpt 20 39737.532 ± 5294.200 ops/s
BenchMark.benchMurmur3_128 300 thrpt 20 30381.144 ± 614.500 ops/s
BenchMark.benchMurmur3_128 500 thrpt 20 18315.211 ± 222.534 ops/s
BenchMark.benchMurmur3_128 1000 thrpt 20 8960.008 ± 658.524 ops/s
6. 죽은 코드 제거
마이크로벤치마크를 실행할 때 최적화를 인식하는 것이 매우 중요합니다 . 그렇지 않으면 매우 잘못된 방식으로 벤치마크 결과에 영향을 미칠 수 있습니다.
문제를 좀 더 구체적으로 설명하기 위해 예를 들어 보겠습니다.
@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void doNothing() {
}
@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void objectCreation() {
new Object();
}
우리는 아무것도 하지 않는 것보다 객체 할당 비용이 더 많이 들 것으로 예상합니다. 그러나 벤치마크를 실행하면 다음과 같습니다.
Benchmark Mode Cnt Score Error Units
BenchMark.doNothing avgt 40 0.609 ± 0.006 ns/op
BenchMark.objectCreation avgt 40 0.613 ± 0.007 ns/op
분명히 TLAB 에서 장소를 찾고 객체를 생성하고 초기화하는 것은 거의 무료입니다! 이 숫자를 보는 것만으로도 여기에 합산되지 않는 것이 있음을 알 수 있습니다.
여기서 우리는 죽은 코드 제거의 희생자입니다 . 컴파일러는 중복 코드를 최적화하는 데 매우 능숙합니다. 사실 JIT 컴파일러가 여기서 한 일이 바로 그것이다.
이 최적화를 방지하려면 어떻게든 컴파일러를 속여 코드가 다른 구성 요소에서 사용된다고 생각하게 만들어야 합니다. 이를 달성하는 한 가지 방법은 생성된 객체를 반환하는 것입니다.
@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public Object pillarsOfCreation() {
return new Object();
}
또한 블랙홀 이 이를 소비하도록 할 수 있습니다.
@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void blackHole(Blackhole blackhole) {
blackhole.consume(new Object());
}
Blackhole 이 개체를 사용 하도록 하는 것은 JIT 컴파일러가 데드 코드 제거 최적화를 적용하지 않도록 하는 방법 입니다. 어쨌든 이 벤치마크를 다시 실행하면 수치가 더 이해가 될 것입니다.
Benchmark Mode Cnt Score Error Units
BenchMark.blackHole avgt 20 4.126 ± 0.173 ns/op
BenchMark.doNothing avgt 20 0.639 ± 0.012 ns/op
BenchMark.objectCreation avgt 20 0.635 ± 0.011 ns/op
BenchMark.pillarsOfCreation avgt 20 4.061 ± 0.037 ns/op
7. 지속적인 폴딩
또 다른 예를 살펴보겠습니다.
@Benchmark
public double foldedLog() {
int x = 8;
return Math.log(x);
}
상수를 기반으로 하는 계산은 실행 횟수에 관계없이 정확히 동일한 출력을 반환할 수 있습니다. 따라서 JIT 컴파일러가 로그 함수 호출을 해당 결과로 대체할 가능성이 매우 높습니다.
@Benchmark
public double foldedLog() {
return 2.0794415416798357;
}
이러한 형태의 부분 평가를 상수 폴딩 이라고 합니다 . 이 경우 상수 폴딩 은 벤치마크의 핵심인 Math.log 호출을 완전히 피합니다.
상수 폴딩을 방지하기 위해 상태 개체 내부에 상수 상태를 캡슐화할 수 있습니다.
@State(Scope.Benchmark)
public static class Log {
public int x = 8;
}
@Benchmark
public double log(Log input) {
return Math.log(input.x);
}
이러한 벤치마크를 서로에 대해 실행하는 경우:
Benchmark Mode Cnt Score Error Units
BenchMark.foldedLog thrpt 20 449313097.433 ± 11850214.900 ops/s
BenchMark.log thrpt 20 35317997.064 ± 604370.461 ops/s
분명히 로그 벤치마크는 합리적인 foldingLog 에 비해 심각한 작업을 수행하고 있습니다 .
8. 결론
이 사용방법(예제)는 Java의 마이크로 벤치마킹 도구에 중점을 두고 보여주었습니다.
항상 그렇듯이 코드 예제는 GitHub 에서 찾을 수 있습니다 .