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 CoreJMH 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, SampleTimeSingleShotTime 과 같은 몇 가지 가능한 벤치마크를 지원합니다 . @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 에서 찾을 수 있습니다 .

Generic footer banner