마이크로벤치마킹과 통계

이 장에서는 Java 성능 수치를 직접 측정하는 데 필요한 구체적인 사항을 다룹니다. JVM의 동적 특성으로 인해 성능 수치는 개발자들이 예상하는 것보다 다루기 어려운 경우가 많습니다. 그 결과, 인터넷에는 부정확하거나 오해의 소지가 있는 성능 수치들이 많이 떠돌고 있습니다.

이 장의 주요 목표는 이러한 잠재적인 함정을 인식하고, 여러분과 다른 사람들이 신뢰할 수 있는 성능 수치만을 생성하도록 하는 것입니다. 특히, Java 코드의 작은 조각을 측정하는 것(마이크로벤치마킹)은 매우 미묘하고 올바르게 수행하기 어려워, 이 주제와 성능 엔지니어들이 이를 적절히 사용하는 방법이 이 장의 주요 주제입니다.

첫 번째 원칙은 자신을 속이지 말아야 한다는 것—그리고 당신이 가장 쉽게 속을 수 있는 사람이라는 것이다.

—리처드 파인만

장 두 번째 부분에서는 마이크로벤치마킹 도구의 금본위인 JMH 사용 방법을 설명합니다. 모든 경고와 주의 사항에도 불구하고, 애플리케이션과 사용 사례가 마이크로벤치마킹을 정당화한다고 느낀다면, 가장 신뢰할 수 있고 진보된 도구를 사용하여 수많은 잘 알려진 함정과 "곰의 덫"을 피할 수 있을 것입니다.

마지막으로, 통계의 주제로 넘어갑니다. JVM은 일상적으로 다소 신중한 처리가 필요한 성능 수치를 생성합니다. 마이크로벤치마크에서 생성된 수치는 특히 민감한 경우가 많아, 성능 엔지니어는 관찰된 결과를 통계적으로 세련되게 처리할 책임이 있습니다. 이 장의 마지막 섹션에서는 JVM 성능 데이터를 다루는 몇 가지 기법과 데이터 해석의 문제점을 설명합니다.

Java 성능 측정 소개

"Java 성능 개요"에서 우리는 성능 분석을 다양한 측면의 종합으로 설명했으며, 이는 근본적으로 실험 과학인 분야로 발전했다고 설명했습니다.

즉, 좋은 벤치마크(또는 마이크로벤치마크)를 작성하고자 한다면, 이를 과학 실험으로 간주하는 것이 매우 도움이 될 수 있습니다. 이 접근 방식은 벤치마크를 "블랙 박스"로 보게 합니다—입력과 출력이 있으며, 우리는 결과를 추측하거나 유추할 수 있는 데이터를 수집하고자 합니다. 그러나 주의해야 합니다—단순히 데이터를 수집하는 것만으로는 충분하지 않습니다. 우리는 데이터에 의해 속지 않도록 해야 합니다.

벤치마크 수치는 그 자체로는 중요하지 않습니다. 중요한 것은 그 수치로부터 어떤 모델을 도출하느냐입니다.

벤치마크 수치는 그 자체로는 중요하지 않습니다. 중요한 것은 그 수치로부터 어떤 모델을 도출하느냐입니다.

—알렉세이 시필레프

따라서 우리의 이상적인 목표는 벤치마크를 공정한 테스트로 만드는 것입니다—가능한 한 시스템의 단일 측면만 변경하고, 벤치마크의 다른 외부 요인을 통제하는 것을 의미합니다. 이상적인 세계에서는, 변경 가능한 다른 모든 시스템 측면이 테스트 간에 완전히 불변이어야 하지만, 실제로는 그렇게 운이 좋은 경우가 드뭅니다.

참고 사항

과학적으로 순수한 공정한 테스트의 목표가 실제로 달성 불가능하더라도, 우리의 벤치마크가 최소한 반복 가능해야 합니다. 이는 모든 경험적 결과의 기초가 됩니다.

Java 플랫폼용 벤치마크를 작성할 때의 중심 문제 중 하나는 Java 런타임의 정교함입니다. 이 책의 상당 부분은 JVM이 개발자의 코드를 자동으로 최적화하는 방법을 밝히는 데 할애되었습니다. 벤치마크를 이러한 최적화의 맥락에서 과학적 테스트로 생각할 때, 우리의 선택지는 제한됩니다.

즉, 이러한 최적화의 정확한 영향을 완전히 이해하고 설명하는 것은 사실상 불가능합니다. 애플리케이션 코드의 "실제" 성능에 대한 정확한 모델을 만드는 것은 어렵고, 적용 가능성에 제한이 있는 경향이 있습니다.

다시 말해, 실행 중인 Java 코드를 JIT 컴파일러, 메모리 관리 및 Java 런타임이 제공하는 다른 하위 시스템과 진정으로 분리할 수 없습니다. 또한, 운영 체제, 하드웨어 또는 런타임 조건(예: 부하)의 영향을 무시할 수 없습니다. 이러한 조건은 테스트가 실행될 때 현재 상태를 반영합니다.

No man is an island, Entire of itself

—존 돈

이러한 효과를 완화하는 것은 더 큰 집계(전체 시스템 또는 하위 시스템)를 다루는 것이 더 쉽습니다. 반대로, 소규모 또는 마이크로벤치마킹을 다룰 때, 애플리케이션 코드를 런타임의 배경 동작과 진정으로 분리하는 것은 훨씬 더 어렵습니다. 이것이 마이크로벤치마킹이 매우 어려운 근본적인 이유이며, 우리는 이에 대해 논의할 것입니다.

간단한 예—100,000개의 숫자를 정렬하는 코드를 벤치마크하는 것을 생각해 봅시다. 공정한 테스트를 만들기 위해 다음과 같은 관점에서 이를 검토해 보겠습니다:

public class ClassicSort {
    private static final int N = 100_000;
    private static final int I = 150_000;
    private static final List<Integer> testData = new ArrayList<>();

    public static void main(String[] args) {
        Random randomGenerator = new Random();
        for (int i = 0; i < N; i++) {
            testData.add(randomGenerator.nextInt(Integer.MAX_VALUE));
        }
        System.out.println("Testing Sort Algorithm");
        double startTime = System.nanoTime();
        for (int i = 0; i < I; i++) {
            List<Integer> copy = new ArrayList<Integer>(testData);
            Collections.sort(copy);
        }
        double endTime = System.nanoTime();
        double timePerOperation = ((endTime - startTime) / 1_000_000_000L * I));
        System.out.println("Result: " + (1 / timePerOperation) + " op/s");
    }
}

벤치마크는 무작위 정수 배열을 생성하고, 이 작업이 완료되면 벤치마크의 시작 시간을 기록합니다. 그런 다음 벤치마크는 템플릿 배열을 복사하고, 데이터를 정렬하는 루프를 반복합니다. 이 작업을 I번 실행한 후, 소요 시간을 초 단위로 변환하고 반복 횟수로 나누어 운영당 소요 시간을 구합니다.

벤치마크의 첫 번째 문제는 JVM을 워밍업할 고려 없이 바로 코드를 테스트에 투입한다는 점입니다. 프로덕션에서 서버 애플리케이션이 몇 시간, 심지어 며칠 동안 실행되고 있을 가능성이 높습니다. 그러나 JVM에는 해석된 바이트코드를 고도로 최적화된 기계어로 변환하는 JIT 컴파일러가 포함되어 있다는 것을 알고 있습니다. 이 컴파일러는 메서드가 일정 횟수 이상 실행된 후에만 작동합니다.

따라서 우리가 수행하는 테스트는 프로덕션에서의 동작을 대표하지 않습니다. JVM은 벤치마크를 측정하는 동안 호출을 최적화하기 위해 시간을 할애할 것입니다. 다음 JVM 플래그를 사용하여 정렬을 실행해보면 이 효과를 확인할 수 있습니다:

java -Xms2048m -Xmx2048m -XX:+PrintCompilation ClassicSort

-Xms-Xmx 옵션은 힙의 크기를 제어하며, 이 경우 힙 크기를 2GB로 고정합니다. PrintCompilation 플래그는 메서드가 컴파일될 때마다 로그 라인을 출력합니다. 다음은 출력의 일부입니다:

Testing Sort Algorithm
73 29 3 java.util.ArrayList::ensureExplicitCapacity (26 bytes)
73 31 3 java.lang.Integer::valueOf (32 bytes)
74 32 3 java.util.concurrent.atomic.AtomicLong::get (5 bytes)
74 33 3 java.util.concurrent.atomic.AtomicLong::compareAndSet (13 bytes)
74 35 3 java.util.Random::next (47 bytes)
74 36 3 java.lang.Integer::compareTo (9 bytes)
74 38 3 java.lang.Integer::compare (20 bytes)
74 38 3 java.lang.Integer::compare (20 bytes)
74 37 3 java.lang.Integer::compareTo (12 bytes)
74 39 4 java.lang.Integer::compareTo (9 bytes)
75 36 3 java.lang.Integer::compareTo (9 bytes) made not entrant
76 40 3 java.util.ComparableTimSort::binarySort (223 bytes)
77 41 3 java.util.ComparableTimSort::mergeLo (656 bytes)
79 42 3 java.util.ComparableTimSort::countRunAndMakeAscending (123 bytes)
79 45 3 java.util.ComparableTimSort::gallopRight (327 bytes)
80 43 3 java.util.ComparableTimSort::pushRun (31 bytes)

JIT 컴파일러는 호출 계층의 일부를 최적화하여 코드 효율성을 높이기 위해 열심히 작업하고 있습니다. 이는 벤치마크의 성능이 측정 시간 동안 변경되며, 우리는 실험에서 변수를 통제하지 못하게 됩니다. 따라서 워밍업 기간이 바람직합니다—이는 JVM이 타이밍을 캡처하기 전에 안정화되도록 합니다. 일반적으로 이는 타이밍 세부 사항을 캡처하지 않고 벤치마크할 코드를 여러 번 실행하는 것을 포함합니다.

또 다른 외부 요인은 가비지 컬렉션입니다. 이상적으로는 타이밍 캡처 중에 GC가 실행되지 않도록 방지하고, 설정 후에도 정규화되어야 합니다. 가비지 컬렉션의 비결정적인 특성으로 인해 이를 통제하는 것은 매우 어렵습니다.

확실히 개선할 수 있는 점은 GC가 실행될 가능성이 있는 동안 타이밍을 캡처하지 않도록 하는 것입니다. 우리는 시스템에 GC 실행을 요청하고 짧은 시간 동안 기다릴 수 있지만, 시스템은 이 호출을 무시할 수 있습니다. 현재 상태에서는 이 벤치마크의 타이밍이 너무 광범위하므로 발생할 수 있는 가비지 컬렉션 이벤트에 대한 세부 정보가 더 필요합니다.

게다가, 타이밍 포인트를 선택하는 것뿐만 아니라 적절한 반복 횟수를 선택하는 것도 문제일 수 있습니다. 이는 시도와 개선을 통해 알아내기가 까다로울 수 있습니다. 가비지 컬렉션의 효과는 다음 VM 플래그를 사용하여 확인할 수 있습니다 (로그 형식의 세부 사항은 7장을 참조하십시오):

java -Xms2048m -Xmx2048m -verbose:gc ClassicSort

이는 다음과 같은 GC 로그 항목을 생성합니다:

Testing Sort Algorithm
[GC (Allocation Failure) 524800K->632K(2010112K), 0.0009038 secs]
[GC (Allocation Failure) 525432K->672K(2010112K), 0.0008671 secs]
Result: 9838.556465303362 op/s

벤치마크에서 또 다른 흔한 실수는 실제로 테스트 중인 코드에서 생성된 결과를 사용하지 않는 것입니다. 벤치마크 복사는 사실상 죽은 코드이며, JIT 컴파일러가 이를 죽은 코드 경로로 식별하고 우리가 실제로 벤치마크하려는 것을 최적화할 가능성이 있습니다.

또 다른 고려 사항은 단일 타이밍 결과를 보는 것이 벤치마크의 전체적인 이야기를 제공하지 않는다는 것입니다. 이상적으로는 오차 범위를 캡처하여 수집된 값의 신뢰성을 이해하고자 합니다. 오차 범위가 높다면 이는 통제되지 않은 변수나 작성한 코드가 성능이 좋지 않음을 나타낼 수 있습니다. 어쨌든, 오차 범위를 캡처하지 않으면 문제가 있는지 식별할 수 없습니다.

매우 간단한 정렬조차도 벤치마크가 크게 왜곡될 수 있는 함정이 있지만, 복잡성이 증가하면 상황은 급격히 더 악화됩니다. 멀티스레드 코드를 평가하려는 벤치마크를 고려해 봅시다. 멀티스레드 코드는 모든 스레드가 완전히 시작될 때까지 유지하고, 벤치마크 시작부터 정확한 결과를 보장하기 위해 매우 신중한 처리가 필요하기 때문에 벤치마킹이 매우 어렵습니다. 그렇지 않으면 오차 범위가 높아집니다.

또한, 동시 코드를 벤치마킹할 때 하드웨어 고려 사항도 있습니다. 이는 단순히 하드웨어 구성뿐만 아니라, 전력 관리가 작동하거나 머신에서 다른 경쟁이 발생하는 경우를 포함합니다.

벤치마크 코드를 올바르게 작성하는 것은 복잡하며, 위에서 강조한 많은 요소를 고려해야 합니다. 개발자로서 우리의 주요 관심사는 우리가 프로파일링하려는 코드이지, 앞서 강조한 모든 문제는 아닙니다. 위에서 언급한 모든 우려 사항이 결합되어 JVM 전문가가 아니면 쉽게 무언가를 놓치고 잘못된 벤치마크 결과를 얻을 수 있는 상황을 만듭니다.

이 문제를 해결하는 두 가지 방법이 있습니다. 첫 번째는 전체 시스템을 벤치마킹하는 것입니다. 이 경우, 저수준 숫자는 단순히 무시되고 수집되지 않습니다. 이 접근 방식은 대부분의 상황과 대부분의 개발자에게 필요한 방법입니다. 여러 개별 효과의 집합이 평균화되어 의미 있는 대규모 결과를 얻을 수 있기 때문입니다.

두 번째 접근 방식은 관련 저수준 결과를 의미 있게 비교할 수 있도록 공통 프레임워크를 사용하여 위에서 언급한 많은 우려 사항을 해결하려는 것입니다. 이상적인 프레임워크는 방금 논의한 주요 압력을 제거해야 합니다. 이러한 도구는 OpenJDK의 주요 개발을 따르며, 새로운 최적화와 기타 외부 제어 변수를 관리해야 합니다.

다행히도, 이러한 도구는 실제로 존재하며, 다음 섹션의 주제입니다. 대부분의 개발자에게는 참조 자료로만 간주되어야 하며, "JVM 성능을 위한 통계"를 선호하는 것이 안전합니다.

JMH 프레임워크 소개

JMH는 우리가 방금 논의한 문제를 해결하는 프레임워크로 설계되었습니다.

JMH는 Java 및 JVM을 대상으로 하는 다른 언어로 작성된 나노/마이크로/밀리/매크로 벤치마크를 구축, 실행 및 분석하기 위한 Java 하니스입니다.

—OpenJDK

과거에는 Google Caliper와 같은 단순한 벤치마킹 라이브러리에 대한 여러 시도가 있었지만, 이러한 모든 프레임워크는 도전 과제를 겪었으며, 코드 성능을 설정하거나 측정하는 합리적인 방법이 미묘한 덫과 함정에 직면할 수 있었습니다. 이는 새로운 최적화가 적용됨에 따라 JVM의 지속적으로 진화하는 특성 때문에 특히 그렇습니다.

JMH는 그 점에서 매우 다르며, JVM을 구축하는 동일한 엔지니어들이 작업해왔습니다. 따라서 JMH 작성자들은 각 JVM 버전 내에서 존재하는 덫과 최적화 함정을 피하는 방법을 알고 있습니다. JMH는 JVM의 각 릴리스와 함께 벤치마킹 하니스로 발전하며, 개발자가 도구 사용과 벤치마크 코드 자체에 집중할 수 있도록 합니다.

JMH는 이미 강조한 몇 가지 문제 외에도 몇 가지 주요 벤치마크 하니스 설계 문제를 고려합니다.

벤치마크 프레임워크는 벤치마크의 내용을 컴파일 타임에 알지 못하므로 동적이어야 합니다. 이를 해결하기 위한 명백한 선택은 리플렉션을 사용하여 사용자가 작성한 벤치마크를 실행하는 것입니다. 그러나 이는 벤치마크 실행 경로에 또 다른 복잡한 JVM 하위 시스템을 포함하게 됩니다. 대신, JMH는 주석 처리 프로세스를 통해 벤치마크에서 추가 Java 소스를 생성하여 작동합니다.

참고 사항

많은 일반적인 주석 기반 Java 프레임워크(예: JUnit)는 목표를 달성하기 위해 리플렉션을 사용하므로, 추가 소스를 생성하는 프로세서를 사용하는 것은 일부 Java 개발자에게 다소 예상치 못한 것일 수 있습니다.

문제 중 하나는 벤치마크 프레임워크가 사용자의 코드를 대량의 반복으로 호출할 경우 루프 최적화가 트리거될 수 있다는 것입니다. 이는 벤치마크 실행 과정에서 문제를 일으킬 수 있습니다.

이를 피하기 위해 JMH는 벤치마크 코드를 생성하여 반복 횟수를 최적화되지 않도록 주의 깊게 설정한 값으로 루프를 감쌉니다.

벤치마크 실행

JMH 실행과 관련된 복잡성은 대부분 사용자에게 숨겨져 있으며, Maven을 사용하여 간단한 벤치마크를 설정하는 것은 간단합니다. 다음 명령을 실행하여 새로운 JMH 프로젝트를 설정할 수 있습니다:

$ mvn archetype:generate \
    -DinteractiveMode=false \
    -DarchetypeGroupId=org.openjdk.jmh \
    -DarchetypeArtifactId=jmh-java-benchmark-archetype \
    -DgroupId=org.sample \
    -DartifactId=test \
    -Dversion=1.0

이는 필요한 아티팩트를 다운로드하고 코드를 수용할 단일 벤치마크 스텁을 생성합니다.

벤치마크는 @Benchmark로 주석 처리되어 있으며, 이는 하니스가 벤치마크를 실행할 메서드를 실행함을 나타냅니다(프레임워크가 다양한 설정 작업을 수행한 후에):

public class MyBenchmark {
    @Benchmark
    public void testMethod() {
        // Stub for code
    }
}

벤치마크 작성자는 벤치마크 실행을 설정하기 위해 매개변수를 구성할 수 있습니다. 매개변수는 명령줄이나 벤치마크의 main() 메서드에서 설정할 수 있습니다:

public class MyBenchmark {
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(SortBenchmark.class.getSimpleName())
            .warmupIterations(100)
            .measurementIterations(5)
            .forks(1)
            .jvmArgs("-server", "-Xms2048m", "-Xmx2048m")
            .build();
        new Runner(opt).run();
    }
}

명령줄의 매개변수는 main() 메서드에 설정된 매개변수를 덮어씁니다.

일반적으로 벤치마크에는 일부 설정이 필요합니다—예를 들어, 데이터 세트를 생성하거나 성능을 비교하기 위해 벤치마크할 조건을 설정하는 것 등이 포함됩니다.

상태(state)와 상태 제어는 JMH 프레임워크에 내장된 또 다른 기능입니다. @State 주석은 상태를 정의하는 데 사용되며, Scope 열거형을 받아 상태가 어디에 표시되는지를 정의합니다: Benchmark, Group 또는 Thread. @State로 주석된 객체는 벤치마크의 수명 동안 접근 가능하며, 일부 설정 작업이 필요할 수 있습니다.

멀티스레드 코드는 벤치마크가 상태를 잘 관리하지 못하게 하여 왜곡되지 않도록 신중하게 처리해야 합니다.

일반적으로, 메서드 내에서 실행되는 코드가 부작용이 없고 결과가 사용되지 않는다면, 해당 메서드는 JVM에 의해 제거될 수 있는 후보입니다. JMH는 이것이 발생하지 않도록 해야 하며, 사실 벤치마크 작성자가 벤치마크 메서드에서 반환 값을 단일 결과로 반환하도록 하고, 프레임워크는 값을 블랙홀(blackhole)에 암시적으로 할당하도록 합니다. 블랙홀은 프레임워크 작성자가 개발한 메커니즘으로, 성능 오버헤드가 거의 없습니다.

벤치마크 메서드가 여러 계산을 수행하는 경우, 결과를 결합하여 메서드에서 반환하는 것이 비용이 많이 들 수 있습니다. 이 경우, 벤치마크 작성자가 명시적 블랙홀을 사용하도록 벤치마크를 작성해야 할 수 있습니다. 이는 벤치마크에 Blackhole을 매개변수로 사용하는 벤치마크를 생성하여 프레임워크가 이를 주입하도록 합니다.

블랙홀은 런타임 최적화에 영향을 줄 수 있는 최적화로부터 벤치마크를 보호하는 네 가지 보호 기능을 제공합니다. 일부 보호는 벤치마크가 제한된 범위로 인해 과도하게 최적화되는 것을 방지하는 것과 관련이 있으며, 다른 보호는 일반적인 시스템 실행에서 발생하지 않는 예측 가능한 런타임 데이터 패턴을 피하는 것과 관련이 있습니다. 보호 기능은 다음과 같습니다:

  1. 런타임 최적화로 인해 죽은 코드가 제거되는 것을 방지합니다.

  2. 반복 계산이 상수로 접혀지는 것을 방지합니다.

  3. 현재 캐시 라인이 영향을 받을 수 있는 값의 읽기 또는 쓰기를 방지합니다(가짜 공유 방지).

  4. "쓰기 벽(write walls)"을 방지합니다.

쓰기 벽(write wall) 이란 성능에서 일반적으로 리소스가 포화 상태에 도달하여 애플리케이션에 병목 현상이 발생하는 지점을 의미합니다. 벤치마크 내에서 이를 충족시키면 캐시와 버퍼에 큰 영향을 미칠 수 있습니다. 이는 벤치마크에 큰 영향을 미칠 수 있습니다.

블랙홀 JavaDoc에 문서화된 바와 같이, 이러한 보호 기능을 제공하기 위해서는 JIT 컴파일러에 대한 밀접한 지식이 필요하며, 최적화를 피할 수 있는 벤치마크를 작성할 수 있어야 합니다. 다음은 블랙홀에서 사용되는 두 가지 consume() 메서드의 예입니다:

public volatile int i1 = 1, i2 = 2;

/**
 * Consume object. This call provides a side effect preventing JIT to eliminate
 * dependent computations.
 *
 * @param i int to consume.
 */
public final void consume(int i) {
    if (i == i1 & i == i2) {
        // SHOULD NEVER HAPPEN
        nullBait.i1 = i; // implicit null pointer exception
    }
}
public int tlr = (int) System.nanoTime();

/**
 * Consume object. This call provides a side effect preventing JIT to eliminate
 * dependent computations.
 *
 * @param obj object to consume.
 */
public final void consume(Object obj) {
    int tlr = (this.tlr = (this.tlr * 1664525 + 1013904223));
    if ((tlr & tlrMask) == 0) {
        // SHOULD ALMOST NEVER HAPPEN IN MEASUREMENT
        this.obj1 = obj;
        this.tlrMask = (this.tlrMask << 1) + 1;
    }
}

객체의 경우, 처음에는 동일한 논리가 적용될 것처럼 보이지만, 컴파일러가 탈출 분석을 통해 객체가 다른 객체와 결코 같지 않다고 단언할 경우, 비교 자체가 거짓을 반환하도록 최적화될 수 있습니다. 대신, 객체는 희귀한 시나리오에서만 실행되는 조건 하에서 소비됩니다. tlr 값은 계산되고 tlrMask와 비트 단위로 비교되어 0 값의 가능성을 줄이지만 완전히 제거하지는 않습니다. 이는 객체가 대부분 할당을 요구하지 않고도 소비되도록 보장합니다.

마이크로벤치마킹 도구인 JMH는 정확한 마이크로벤치마킹 도구를 작성하는 것 외에도, 클래스에 대한 인상적인 문서를 작성하는 데 성공했습니다. 프레임워크 뒤에서 일어나는 마법을 이해하고 싶다면, 주석이 이를 잘 설명하고 있습니다.

간단한 벤치마크를 설정하는 것은 앞서 설명한 정보를 통해 어렵지 않지만, JMH는 상당히 고급 기능도 가지고 있습니다. 공식 문서에는 각 기능의 예제가 있으며, 모두 검토할 가치가 있습니다.

JMH의 힘과 JVM과의 상대적인 밀접함을 보여주는 흥미로운 기능은 다음과 같습니다:

  • 컴파일러 제어 가능

  • 벤치마크 중 CPU 사용 수준 시뮬레이션

또 다른 멋진 기능은 블랙홀을 사용하여 실제로 CPU 사이클을 소비하고, 다양한 CPU 부하 하에서 벤치마크를 시뮬레이션할 수 있다는 것입니다.

@CompilerControl 주석은 컴파일러가 메서드를 인라인하지 않도록 요청하거나, 명시적으로 인라인하도록 요청하거나, 메서드를 컴파일에서 제외하도록 요청하는 데 사용될 수 있습니다. 이는 인라인이나 컴파일로 인해 JVM이 특정 문제를 일으키고 있다고 의심되는 경우 매우 유용합니다:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(1)
public class SortBenchmark {
    private static final int N = 100_000;
    private static final List<Integer> testData = new ArrayList<>();

    @Setup
    public static final void setup() {
        Random randomGenerator = new Random();
        for (int i = 0; i < N; i++) {
            testData.add(randomGenerator.nextInt(Integer.MAX_VALUE));
        }
        System.out.println("Setup Complete");
    }

    @Benchmark
    public List<Integer> classicSort() {
        List<Integer> copy = new ArrayList<Integer>(testData);
        Collections.sort(copy);
        return copy;
    }

    @Benchmark
    public List<Integer> standardSort() {
        return testData.stream().sorted().collect(Collectors.toList());
    }

    @Benchmark
    public List<Integer> parallelSort() {
        return testData.parallelStream().sorted().collect(Collectors.toList());
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(SortBenchmark.class.getSimpleName())
            .warmupIterations(100)
            .measurementIterations(5)
            .forks(1)
            .jvmArgs("-server", "-Xms2048m", "-Xmx2048m")
            .addProfiler(GCProfiler.class)
            .addProfiler(StackProfiler.class)
            .build();
        new Runner(opt).run();
    }
}

벤치마크를 실행하면 다음과 같은 출력이 생성됩니다:

Benchmark Mode Cnt Score Error Units
optjava.SortBenchmark.classicSort thrpt 200 14373.039 ± 111.586 ops/s
optjava.SortBenchmark.parallelSort thrpt 200 7917.702 ± 87.757 ops/s
optjava.SortBenchmark.standardSort thrpt 200 12656.107 ± 84.849 ops/s

이 벤치마크를 보면, 단순한 정렬 방법이 스트림을 사용하는 것보다 더 효과적이라는 빠른 결론에 도달할 수 있습니다. 두 코드 모두 하나의 배열 복사와 하나의 정렬을 사용하므로 괜찮아 보입니다. 개발자들은 낮은 오차율과 높은 처리량을 보고 벤치마크가 올바르다고 결론을 내릴 수 있습니다.

하지만 벤치마크가 성능을 정확하게 반영하고 있는지 확인하기 위해 몇 가지 이유를 고려해 봅시다—기본적으로, "이것이 통제된 테스트인가?"라는 질문에 답하려고 합니다. 우선, classicSort 테스트에 가비지 컬렉션의 영향을 살펴보겠습니다:

Iteration 1:
[GC (Allocation Failure) 65496K->1480K(239104K), 0.0012473 secs]
[GC (Allocation Failure) 63944K->1496K(237056K), 0.0013170 secs]
10830.105 ops/s

Iteration 2:
[GC (Allocation Failure) 62936K->1680K(236032K), 0.0004776 secs]
10951.704 ops/s

이 스냅샷에서는 반복 당 하나의 GC 사이클이 실행되고 있는 것이 분명합니다(대략적으로). 이를 parallel sort와 비교해 보면 흥미롭습니다:

Iteration 1:
[GC (Allocation Failure) 52952K->1848K(225792K), 0.0005354 secs]
[GC (Allocation Failure) 52024K->1848K(226816K), 0.0005341 secs]
[GC (Allocation Failure) 51000K->1784K(223744K), 0.0005509 secs]
[GC (Allocation Failure) 49912K->1784K(225280K), 0.0003952 secs]
9526.212 ops/s

Iteration 2:
[GC (Allocation Failure) 49400K->1912K(222720K), 0.0005589 secs]
[GC (Allocation Failure) 49016K->1832K(223744K), 0.0004594 secs]
[GC (Allocation Failure) 48424K->1864K(221696K), 0.0005370 secs]
[GC (Allocation Failure) 47944K->1832K(222720K), 0.0004966 secs]
[GC (Allocation Failure) 47400K->1864K(220672K), 0.0005004 secs]

이를 보면, 벤치마크에 무언가 다른 요소가 노이즈를 일으키고 있다는 것을 알 수 있습니다—이 경우, 가비지 컬렉션입니다.

결론은, 벤치마크가 통제된 환경을 나타낸다고 가정하기 쉽지만, 실제로는 훨씬 더 미끄럽다는 것입니다. 종종 통제되지 않은 변수가 발견하기 어렵기 때문에, JMH와 같은 하니스라도 주의가 필요합니다. 또한, 확인 편향을 수정하고 시스템의 동작을 진정으로 반영하는 관찰 가능 항목을 측정하고 있는지 확인해야 합니다.

9장에서 우리는 JIT 컴파일러가 바이트코드를 어떻게 처리하는지에 대한 또 다른 시각을 제공할 JITWatch를 만나게 될 것입니다. 이는 특정 메서드의 바이트코드가 벤치마크 성능에 예상대로 영향을 미치지 않는 이유를 이해하는 데 도움이 될 수 있습니다.

JVM 성능을 위한 통계

성능 분석이 진정한 실험 과학이라면, 우리는 필연적으로 결과 데이터의 분포를 다루게 될 것입니다. 통계학자와 과학자들은 실제 세계에서 나온 결과는 거의 항상 깨끗하고 뚜렷한 신호로 표현되지 않는다는 것을 알고 있습니다. 우리는 세상이 우리가 원하는 이상화된 상태가 아니라는 것을 받아들이고, 그것을 다루어야 합니다.

In God we trust. Everyone else, bring data.

—마이클 블룸버그

모든 측정에는 일정량의 오류가 포함되어 있습니다. 다음 섹션에서는 Java 개발자가 성능 분석을 수행할 때 마주칠 수 있는 두 가지 주요 오류 유형을 설명합니다.

오류의 유형

엔지니어가 마주칠 수 있는 두 가지 주요 오류 소스는 다음과 같습니다:

  1. 랜덤 오류 (Random Error)

    • 측정 오류 또는 무관한 요소가 비상관적으로 결과에 영향을 미치는 경우.

  2. 체계적 오류 (Systematic Error)

    • 통제되지 않은 요소가 관찰 가능한 항목의 측정을 일관되게 영향을 미치는 경우.

각 오류 유형에는 특정 용어가 관련되어 있습니다. 예를 들어, **정확도(accuracy)**는 체계적 오류의 수준을 설명하는 데 사용됩니다; 높은 정확도는 낮은 체계적 오류에 해당합니다. 유사하게, **정밀도(precision)**는 랜덤 오류에 해당하는 용어입니다; 높은 정밀도는 낮은 랜덤 오류를 의미합니다.

Figure 5-1의 그래픽은 이러한 두 가지 오류 유형이 측정에 미치는 영향을 보여줍니다. 극단적인 왼쪽 이미지는 측정이 실제 결과(타겟의 중심) 주변에 클러스터링된 모습을 보여줍니다. 이러한 측정은 높은 정밀도와 높은 정확도를 가지고 있습니다. 두 번째 이미지는 체계적인 효과(예: 잘못 보정된 조준선)를 보여주며, 모든 샷이 타겟에서 벗어나게 합니다. 이는 높은 정밀도지만 낮은 정확도를 나타냅니다. 세 번째 이미지는 샷이 타겟에 거의 맞으나 중심 주변에 느슨하게 클러스터링되어 있어 낮은 정밀도와 높은 정확도를 나타냅니다. 마지막 이미지는 낮은 정밀도와 낮은 정확도로 인해 명확한 신호가 없는 모습을 보여줍니다.

체계적 오류 (Systematic Error)

예를 들어, JSON을 주고받는 백엔드 Java 웹 서비스 그룹을 대상으로 실행되는 성능 테스트를 생각해 봅시다. 이 유형의 테스트는 애플리케이션 프론트엔드를 직접 사용하여 부하 테스트를 수행하는 것이 문제가 될 때 매우 일반적입니다.

Figure 5-2는 Apache JMeter 부하 생성 도구용 JP GC 확장 팩에서 생성된 것입니다. 여기에는 두 가지 체계적인 효과가 작용하고 있습니다. 첫 번째는 상단 라인(이상치 서비스)에서 관찰되는 선형 패턴으로, 제한된 서버 리소스의 느린 소진을 나타냅니다. 이 유형의 패턴은 메모리 누수나 스레드가 요청 처리 중에 사용된 리소스를 해제하지 않는 것과 관련이 있으며, 이는 조사 대상 후보로 나타납니다—진정한 문제일 수 있습니다.

Figure 5-2. Systematic error

참고 사항

영향을 받은 리소스 유형을 확인하려면 추가 분석이 필요합니다; 단순히 메모리 누수라고 결론짓지 마십시오.

두 번째 효과는 대부분의 다른 서비스가 약 180ms 수준에서 일관되게 나타나는 것입니다. 이는 서비스들이 요청에 대해 매우 다른 양의 작업을 수행함에도 불구하고, 결과가 일관되게 나타나는 것이 의심스럽습니다.

그 이유는 무엇일까요? 답은 테스트가 런던에 위치한 서비스들을 대상으로 한 반면, 이 부하 테스트는 인도 뭄바이에서 수행되었다는 것입니다. 관찰된 응답 시간에는 뭄바이에서 런던으로의 불가역적인 왕복 네트워크 지연 시간이 포함됩니다. 이는 120–150ms 범위에 있으며, 따라서 대부분의 서비스의 관찰된 시간에 큰 영향을 미칩니다. 이는 실제로 서비스의 응답 시간이 120ms 이하로 예상되는 것과는 대조적입니다. 이 대규모 체계적 효과는 실제 애플리케이션의 동작과는 관련이 없으며, 테스트 설정의 문제에서 비롯된 오류입니다. 이 아티팩트는 테스트를 런던에서 다시 실행할 때 완전히 사라졌습니다.

랜덤 오류 (Random Error)

랜덤 오류는 여기에서 언급할 가치가 있으며, 이는 매우 잘 알려진 경로입니다.

참고 사항

논의는 기본적인 통계 처리(평균, 최빈값, 표준 편차 등)에 익숙한 독자를 전제로 합니다. 익숙하지 않은 독자는 기본 교과서(예: Handbook of Biological Statistics)를 참조해야 합니다.

랜덤 오류는 환경의 알려지지 않았거나 예측할 수 없는 변화로 인해 발생합니다. 일반적인 과학적 용어 사용에서는 이러한 변화가 측정 기기나 환경에서 발생할 수 있지만, 소프트웨어의 경우 측정 하니스가 신뢰할 수 있다고 가정하므로 랜덤 오류의 원인은 운영 환경에만 있을 수 있습니다.

랜덤 오류는 일반적으로 **가우시안(정규) 분포(Gaussian or Normal Distribution)**를 따르는 것으로 간주됩니다. 다음 두 가지 전형적인 예를 Figure 5-3에서 볼 수 있습니다. 이 분포는 오류가 관찰 가능 항목에 양의 기여나 음의 기여를 동일하게 할 가능성이 있을 때 좋은 모델입니다. 그러나 JVM의 경우 이는 적합하지 않습니다.

스푸리어스 상관관계 (Spurious Correlation)

통계에 관한 가장 유명한 격언 중 하나는 "상관관계는 인과관계를 의미하지 않는다"는 것입니다. 즉, 두 변수가 유사하게 동작한다고 해서 그들 사이에 근본적인 연결 고리가 있다는 것을 의미하지는 않습니다.

가장 극단적인 예에서는 실무자가 아무리 노력해도 전혀 관련 없는 측정 간의 상관관계를 찾을 수 있습니다. 예를 들어, Figure 5-4에서는 미국의 닭 소비량과 원유 총 수입 간의 상관관계를 볼 수 있습니다.

이 수치는 명백히 인과 관계가 없습니다; 원유 수입과 닭 소비량을 모두 움직이는 요인은 없습니다. 그러나 이는 실무자가 무의미한 상관관계에 속지 않도록 주의해야 하는 이유 중 하나입니다.

Figure 5-5에서는 비디오 아케이드 수익과 컴퓨터 과학 박사 학위 수여 건수 간의 상관관계를 볼 수 있습니다. Antoine de Saint-Exupéry의 어린 왕자에서 나온 "boa constrictor에 의해 먹힌 모자" 문제처럼, 더 현실적인 상관관계 예제를 통해 우리가 배워야 할 점을 보여줍니다.

JVM과 성능 분석의 영역에서는, 상관관계에만 근거하여 인과 관계를 귀속시키지 않도록 특별히 주의해야 합니다. 이는 파인만의 "자신을 속이지 말라"는 최대경언의 한 측면으로 볼 수 있습니다.

우리는 오류의 몇 가지 예를 만나고 스푸리어스 상관관계의 유명한 덫을 언급했으므로, 이제 특별한 주의와 세부 사항에 신경 써야 하는 JVM 성능 측정의 한 측면으로 넘어갑니다.

비정규 통계 (Non-Normal Statistics)

정규 분포에 기반한 통계는 많은 수학적 정교함을 필요로 하지 않습니다. 이러한 이유로, 일반적으로 대학 이전 또는 학부 수준에서 가르치는 통계는 주로 정규 분포 데이터를 분석하는 데 중점을 둡니다.

학생들은 평균과 표준 편차(또는 분산)를 계산하고, 때로는 왜도(skew)와 첨도(kurtosis)와 같은 높은 모멘트를 계산하도록 배웁니다. 그러나 이러한 기법들은 분포에 약간의 먼 점이 있는 경우 쉽게 왜곡될 수 있다는 심각한 단점이 있습니다.

참고 사항

Java 성능에서, 이상치는 느린 트랜잭션과 불만족한 고객을 나타냅니다. 이러한 점들에 특별히 주의를 기울이고, 이상치의 중요성을 희석시키는 기법을 피해야 합니다.

다른 관점에서 보면, 많은 고객이 이미 불평하지 않는 한, 평균 응답 시간을 개선하는 것이 목표가 아닐 가능성이 큽니다. 물론, 그렇게 하면 모든 사람의 경험이 개선되지만, 몇몇 불만족한 고객이 지연 튜닝 작업의 원인이 되는 경우가 훨씬 더 흔합니다. 이는 이상치 이벤트가 대부분의 만족스러운 서비스를 받는 대다수의 경험보다 더 관심을 끌 가능성이 높다는 것을 의미합니다.

Figure 5-6에서는 트랜잭션(또는 메서드) 시간의 분포에 대한 보다 현실적인 곡선을 볼 수 있습니다. 이는 분명히 정규 분포가 아닙니다.

이 분포의 형태는 JVM에 대해 우리가 직관적으로 알고 있는 것을 보여줍니다: "핫 패스"가 존재하며, 관련 코드가 이미 JIT 컴파일된 상태이고, GC 사이클이 없으며, 기타 최적화가 적용된 경우입니다. 이는 최상의 시나리오(비록 일반적인 것은 아니지만)로 나타나며, 단순히 무작위로 약간 더 느린 호출이 없다는 것을 의미합니다.

이는 가우시안 통계의 기본 가정을 위반하며, 비정규 분포를 고려하게 만듭니다.

참고 사항

비정규 분포의 경우, 많은 "기본 규칙"—평균/표준 편차 및 기타 높은 모멘트—이 기본적으로 쓸모없게 됩니다.

고동적 범위(High Dynamic Range) 분포라고도 불리는 장기 꼬리 분포(Long-Tail Distribution)를 다루기 위해 매우 유용한 기법 중 하나는 수정된 백분위(percentiles) 체계를 사용하는 것입니다. 분포는 전체 그래프, 즉 데이터의 형태로 존재하며, 단일 숫자로 표현되지 않습니다.

평균을 계산하는 대신, 우리는 분포를 일정 간격으로 샘플링할 수 있습니다. 정규 분포 데이터를 사용할 때는 보통 일정 간격으로 샘플링을 하지만, JVM 통계를 위해 약간의 수정을 가해야 합니다.

수정 사항은 평균에서 시작하여 90번째 백분위수로 이동한 다음, 로그 방식으로 이동하는 샘플링을 사용하는 것입니다. 이는 데이터의 형태에 더 잘 맞는 패턴으로 샘플링을 수행한다는 것을 의미합니다:

50.0% level was 23 ns
90.0% level was 30 ns
99.0% level was 43 ns
99.9% level was 164 ns
99.99% level was 248 ns
99.999% level was 3,458 ns
99.9999% level was 17,463 ns

이 샘플은 평균 시간이 23ns인 getter 메서드가, 1,000번 중 1번은 1자리수 이상의 시간이 소요되고, 100,000번 중 1번은 평균보다 두 자릿수 이상 더 오래 걸렸다는 것을 보여줍니다.

장기 꼬리 분포는 고동적 범위(High Dynamic Range) 분포라고도 합니다. 관찰 가능 항목의 동적 범위는 보통 기록된 최대값을 최소값으로 나눈 값으로 정의됩니다.

로그 백분위수는 장기 꼬리를 이해하는 데 유용한 간단한 도구입니다. 그러나 더 정교한 분석을 위해, 높은 동적 범위를 가진 데이터 세트를 처리하는 공개 도메인 라이브러리를 사용할 수 있습니다. 이 라이브러리는 HdrHistogram이라고 하며, GitHub에서 사용할 수 있습니다. 원래는 Gil Tene(Azul Systems)에 의해 만들어졌으며, Mike Barker, Darach Ennis, Coda Hale이 추가 작업을 했습니다.

참고 사항

히스토그램은 범위(버킷)의 유한 집합을 사용하여 데이터를 요약하고, 각 버킷에 데이터가 얼마나 많이 들어오는지를 표시하는 방법입니다.

HdrHistogram은 Maven Central에서도 사용할 수 있습니다. 작성 시점의 현재 버전은 2.1.9이며, 다음 종속성 스탠자를 pom.xml에 추가하여 프로젝트에 추가할 수 있습니다:

<dependency>
    <groupId>org.hdrhistogram</groupId>
    <artifactId>HdrHistogram</artifactId>
    <version>2.1.9</version>
</dependency>

HdrHistogram을 사용한 간단한 예를 살펴보겠습니다. 이 예제는 파일의 숫자를 입력받아 연속적인 결과 간의 차이에 대한 HdrHistogram을 계산합니다:

public class BenchmarkWithHdrHistogram {
    private static final long NORMALIZER = 1_000_000;
    private static final Histogram HISTOGRAM = new Histogram(TimeUnit.MINUTES.toMicros(1), 2);

    public static void main(String[] args) throws Exception {
        final List<String> values = Files.readAllLines(Paths.get(args[0]));
        double last = 0;
        for (final String tVal : values) {
            double parsed = Double.parseDouble(tVal);
            double gcInterval = parsed - last;
            last = parsed;
            HISTOGRAM.recordValue((long)(gcInterval * NORMALIZER));
        }
        HISTOGRAM.outputPercentileDistribution(System.out, 1000.0);
    }
}

출력은 연속적인 가비지 컬렉션 간의 시간을 보여줍니다. 다음은 샘플 GC 로그에 대한 히스토그램 플로터의 출력 예입니다:

Value Percentile TotalCount 1/(1-Percentile)
14.02 0.000000000000 1 1.00
1245.18 0.100000000000 37 1.11
1949.70 0.200000000000 82 1.25
1966.08 0.300000000000 126 1.43
1982.46 0.400000000000 157 1.67
...
28180.48 0.996484375000 368 284.44
28180.48 0.996875000000 368 320.00
28180.48 0.997265625000 368 365.71
36438.02 0.997656250000 369 426.67
36438.02 1.000000000000 369
#[Mean = 2715.12, StdDeviation = 2875.87]
#[Max = 36438.02, Total count = 369]
#[Buckets = 19, SubBuckets = 256]

포매터의 원시 출력은 분석하기 다소 어렵지만, 다행히도 HdrHistogram 프로젝트에는 원시 출력을 사용하여 시각적 히스토그램을 생성할 수 있는 온라인 포매터가 포함되어 있습니다. 이를 사용하면 Figure 5-7과 같은 출력이 생성됩니다.

많은 JVM 성능 튜닝에 필요한 관찰 가능 항목의 통계는 종종 매우 비정규적이며, HdrHistogram은 데이터를 이해하고 시각화하는 데 매우 유용한 도구가 될 수 있습니다.

통계의 해석 (Interpretation of Statistics)

실험적 데이터와 관찰된 결과는 진공 상태로 존재하지 않으며, 관찰 가능 항목 데이터를 해석하는 데 있어 가장 어려운 작업 중 하나가 될 수 있습니다.

No matter what they tell you, it’s always a people problem.

—제랄드 와인버그

Figure 5-8에서는 실제 Java 애플리케이션의 메모리 할당 속도 예시를 보여줍니다. 이 예시는 상당히 잘 작동하는 애플리케이션을 위한 것입니다. 스크린샷은 Chapter 8에서 만날 Censum 가비지 컬렉션 분석기에서 가져온 것입니다.

할당 데이터의 해석은 비교적 간단하며, 명확한 신호가 존재합니다. 커버된 시간 동안(거의 하루 동안) 할당 속도는 기본적으로 초당 350MB에서 700MB 사이에서 안정적으로 유지되었습니다. JVM이 시작된 지 약 5시간 후부터 할당 속도에 하향 추세가 시작되었으며, 9~10시간 사이에 명확한 최저점이 있었고, 그 이후로 할당 속도가 다시 상승하기 시작했습니다.

이러한 유형의 추세는 매우 일반적입니다. 할당 속도는 애플리케이션이 실제로 수행하는 작업의 양을 반영하는 경향이 있으며, 이는 시간대에 따라 크게 달라질 수 있습니다. 그러나 실제 관찰 가능 항목을 해석할 때는 그림이 더 복잡해질 수 있습니다. 이는 때때로 Antoine de Saint-Exupéry의 어린 왕자에 나온 "모자/코끼리" 문제로 불리는 것을 초래할 수 있습니다.

문제는 Figure 5-9에서 설명됩니다. 처음에는 HTTP 요청-응답 시간의 복잡한 히스토그램만 보입니다. 그러나 조금 더 상상하거나 분석하면, 복잡한 그림이 실제로는 몇 가지 꽤 단순한 부분으로 구성되어 있다는 것을 알 수 있습니다.

응답 히스토그램을 해독하는 핵심은 "웹 애플리케이션 응답"이 매우 일반적인 범주라는 것을 깨닫는 것입니다. 여기에는 성공적인 요청(일명 2xx 응답), 클라이언트 오류(4xx, 보편적인 404 오류 포함), 서버 오류(5xx, 특히 500 Internal Server Error)가 포함됩니다.

각 응답 유형은 응답 시간에 대해 다른 특성 분포를 가집니다. 클라이언트가 매핑되지 않은 URL에 대한 요청을 하면(404), 웹 서버는 즉시 응답을 보낼 수 있습니다. 이는 클라이언트 오류 응답에 대한 히스토그램이 Figure 5-10과 유사하게 보인다는 것을 의미합니다.

반면에, 서버 오류는 종종 많은 처리 시간이 소요된 후에 발생합니다(예: 백엔드 리소스가 스트레스 상태이거나 타임아웃). 따라서 서버 오류 응답에 대한 히스토그램은 Figure 5-11과 유사할 수 있습니다.

성공적인 요청은 긴 꼬리 분포를 가질 것이지만, 실제로는 분포가 "다중 모드(multimodal)"이고 여러 개의 국부 최대값(local maxima)을 가질 수 있다는 것을 예상할 수 있습니다. 예를 들어, Figure 5-12는 애플리케이션을 통과하는 두 가지 일반적인 실행 경로가 꽤 다른 응답 시간을 가질 수 있다는 가능성을 나타냅니다.

이러한 서로 다른 응답 유형을 하나의 그래프로 결합하면 Figure 5-13에 표시된 구조가 됩니다. 우리는 원래의 "모자" 모양을 개별 히스토그램에서 재유도했습니다.

일반적인 관찰 가능 항목을 더 의미 있는 하위 집합으로 분해하는 개념은 매우 유용하며, 데이터를 이해하고 도메인을 잘 이해하고 있는지 확인한 후에 결과에서 결론을 도출해야 함을 보여줍니다. 우리는 데이터를 더 작은 세트로 더 분해하고자 할 수 있습니다; 예를 들어, 성공적인 요청은 주로 읽기(read)인 요청과 업데이트(update) 또는 업로드(upload)인 요청에 대해 매우 다른 분포를 가질 수 있습니다.

PayPal의 엔지니어링 팀은 통계 및 분석 사용에 대해 광범위하게 글을 썼습니다; 그들은 훌륭한 자료를 포함한 블로그를 가지고 있습니다. 특히, Mahmoud Hashemi의 "Statistics for Software"는 그들의 방법론에 대한 훌륭한 소개이며, 앞서 논의한 Hat/Elephant 문제의 버전을 포함하고 있습니다.

요약

마이크로벤치마킹은 Java 성능이 "어둠의 예술(Dark Art)"에 가장 가까운 것입니다. 이러한 표현은 감각적이지만, 완전히 정당화되지는 않습니다. 여전히 실제로 작업하는 개발자가 수행하는 엔지니어링 분야입니다. 그러나 마이크로벤치마킹은 주의해서 사용해야 합니다:

  • 마이크로벤치마킹은 그것이 적절한 사용 사례인지 확실하지 않다면 피하십시오.

  • 마이크로벤치마킹이 필요하다면, JMH를 사용하십시오.

  • 결과를 가능한 한 공개적으로 논의하고, 동료들과 함께 논의하십시오.

  • 종종 틀릴 준비를 하고, 반복적으로 사고가 도전받는 상황에 대비하십시오.

마이크로벤치마킹 작업의 긍정적인 측면 중 하나는 저수준 하위 시스템이 생성하는 매우 동적이고 비정규적인 분포를 노출한다는 것입니다. 이는 JVM의 복잡성을 더 잘 이해하고, 성능 작업의 어려움을 이해하는 데 도움이 됩니다.

다음 장에서는 방법론에서 벗어나 JVM 내부 및 주요 하위 시스템에 대한 기술적 심층 분석으로 넘어가며, 가비지 컬렉션에 대한 검토를 시작할 것입니다.

참고 자료

  1. John H. McDonald, Handbook of Biological Statistics, 3rd ed. (Baltimore, MD: Sparky House Publishing, 2014).

  2. 이 섹션에서 언급된 스푸리어스 상관관계는 Tyler Vigen의 사이트에서 가져왔으며, 크리에이티브 커먼즈 라이선스 하에 허가를 받아 재사용되었습니다. 재미있게 보셨다면, Vigen은 더 많은 재미있는 예제를 포함한 책을 링크에서 제공하고 있습니다.

5-1. 다른 유형의 오류 (Figure 5-1)

5-2. 체계적 오류 (Figure 5-2)

5-3. 가우시안 분포 (Figure 5-3)

5-4. 스푸리어스 상관관계 예시 (Figure 5-4)

5-5. 덜 스푸리어스한 상관관계 예시 (Figure 5-5)

5-6. 트랜잭션 시간의 현실적인 분포 (Figure 5-6)

5-7. HdrHistogram 시각화 예시 (Figure 5-7)

5-8. 메모리 할당 속도 예시 (Figure 5-8)

5-9. 모자 또는 보아에게 먹힌 코끼리? (Figure 5-9)


이 장에서는 마이크로벤치마킹의 복잡성과 통계적 처리의 중요성에 대해 논의했습니다. JMH와 HdrHistogram과 같은 도구를 사용하면 마이크로벤치마킹의 많은 함정을 피할 수 있지만, 여전히 주의와 세심한 분석이 필요합니다. 다음 장에서는 JVM의 내부와 주요 하위 시스템에 대해 자세히 살펴보겠습니다.

Last updated

Was this helpful?