JIT 준비와 벤치마크
반복 사이 가변 상태 오답을 재현하고 예열·fork·체크섬 소비·다형적 프로필·JIT 로그를 갖춘 측정 전제를 설계합니다.
HotSpot은 메서드 실행 프로필을 모아 인터프리터 실행에서 컴파일된 코드로 전환하고 더 뜨거워지면 재컴파일할 수 있습니다.
첫 실행에는 클래스 로딩·초기화·JIT 컴파일이 섞이고 이후 실행도 CPU 주파수, GC, OS 스케줄링의 영향을 받습니다.
한 번의 nanoTime() 측정은 숫자를 주지만 신뢰할 수 있는 벤치마크를 자동으로 만들지는 않습니다.
측정 반복 사이 남은 누산기 상태
아래 벤치마크 설정은 매 반복에서 같은 입력을 처리한다고 가정하지만 누산기 필드가 이전 값을 유지합니다.
원문을 계산하면 두 합계는 5050과 10100이므로 wrong-same=false가 됩니다.
속도를 비교하기 전에 결과부터 다르므로 이 벤치마크는 무효입니다.
import java.util.stream.IntStream;
public final class BenchmarkStateCarryoverBug {
private static final class Fixture {
private long accumulator;
long run(int[] values) {
for (int value : values) {
accumulator += value;
}
return accumulator;
}
}
public static void main(String[] args) {
int[] values = IntStream.rangeClosed(1, 100).toArray();
Fixture fixture = new Fixture();
long first = fixture.run(values);
long second = fixture.run(values);
System.out.println("first=" + first + ", second=" + second);
System.out.println("wrong-same=" + (first == second));
}
}원칙은 벤치마크 상태 범위와 설정 수준을 명시하는 것입니다.
연산마다 새 상태가 필요한지, 반복마다 초기화할지, 실제 운영처럼 공유 상태를 유지할지 구분합니다.
JMH의 @State, @Setup 같은 생명 주기를 사용하면 의도가 코드에 남습니다.
최소 측정 틀에서도 예열과 측정 분리
다음 예제는 교육용이며 JMH를 대신하지 않습니다.
동일한 입력과 체크섬 비교를 사용하고, 한 번의 배열 전체 처리에 걸린 경과 시간을 나노초로 출력합니다. 원소당 ns/op로 나누는 코드는 없습니다.
import java.util.Arrays;
import java.util.stream.IntStream;
public final class WarmupMeasurementHarness {
private static long transform(int value) {
long result = value;
for (int round = 0; round < 30; round++) {
result = (result * 1_664_525L + 1_013_904_223L) & 0x7fff_ffffL;
}
return result;
}
private static long operation(int[] values) {
return Arrays.stream(values).mapToLong(WarmupMeasurementHarness::transform).sum();
}
public static void main(String[] args) {
int[] values = IntStream.range(0, 20_000).toArray();
for (int warmup = 0; warmup < 5; warmup++) {
operation(values);
}
long expected = operation(values);
for (int iteration = 1; iteration <= 5; iteration++) {
long start = System.nanoTime();
long checksum = operation(values);
long elapsed = System.nanoTime() - start;
if (checksum != expected) {
throw new AssertionError("checksum");
}
System.out.println(
"iteration=" + iteration + ", ns=" + elapsed + ", checksum=" + checksum);
}
}
}WarmupMeasurementHarness의 소스에 쓰인 operation 호출 수와 결과 사용을 비교합니다.
| 구간 | 소스의 호출 수 | 결과 사용 |
|---|---|---|
| 예열 루프 | 5회 | 반환값을 버림 |
| expected 계산 | 1회 | 뒤 반복과 비교할 체크섬 보관 |
| 측정 루프 | 5회 | 경과 ns와 체크섬 출력 |
- 예열 루프
- 소스의 호출 수: 5회결과 사용: 반환값을 버림
- expected 계산
- 소스의 호출 수: 1회결과 사용: 뒤 반복과 비교할 체크섬 보관
- 측정 루프
- 소스의 호출 수: 5회결과 사용: 경과 ns와 체크섬 출력
operation 한 번은 원소 20,000개 전체를 처리합니다. expected도 같은 구현으로 계산하므로 독립 정답 검증이 아니며, 이 표는 JIT 완료나 실제 시간의 관측을 뜻하지 않습니다.
실행 명령과 JIT 로그를 함께 남깁니다.
javac --release 25 WarmupMeasurementHarness.java
java -Xlog:jit+compilation=debug WarmupMeasurementHarness로그를 수집했다면 operation과 transform의 컴파일 관련 기록을 측정 출력과 비교합니다. 고정된 예열 5회가 컴파일 완료를 보장하지 않으며, 버린 반환값을 가진 예열 계산은 최적화 영향을 받을 수 있습니다.
JVM 로그 태그는 java -Xlog:help로 확인합니다.
결과 소비와 죽은 코드 제거
JIT는 관찰 가능한 결과에 영향을 주지 않는 계산을 제거할 수 있습니다.
체크섬을 반환·검증하거나 JMH Blackhole을 사용합니다.
단, 결과 소비 자체가 병목이 되지 않게 합니다.
public final class ConsumedChecksumProbe {
static long calculate(int limit) {
long checksum = 0;
for (int value = 1; value <= limit; value++) {
checksum ^= (long) value * value * 31;
}
return checksum;
}
public static void main(String[] args) {
long combined = 0;
for (int iteration = 0; iteration < 10; iteration++) {
combined ^= calculate(100_000 + iteration);
}
if (combined == Long.MIN_VALUE) {
throw new AssertionError("impossible sentinel");
}
System.out.println("combined=" + combined);
}
}상수 입력과 결과가 컴파일 시점에 접힐 가능성, 할당 탈출, 분기 예측 가능성도 확인합니다.
벤치마크가 운영보다 지나치게 규칙적인 데이터를 쓰면 다른 머신 코드 프로필이 생깁니다.
호출 타입 프로파일에 따른 인라이닝 변화
한 구현만 보이는 단형 호출 지점과 여러 구현이 섞인 다형 호출 지점은 JIT 최적화가 다를 수 있습니다.
다음 예제는 같은 run 호출 지점에 구현 하나와 두 구현을 차례로 전달합니다. 한 라운드의 호출 수부터 1회와 2회로 다르며 시간은 측정하지 않습니다.
public final class PolymorphicCallProfile {
private interface Rule {
int apply(int value);
}
private static final class DoubleRule implements Rule {
public int apply(int value) {
return value * 2;
}
}
private static final class AddRule implements Rule {
public int apply(int value) {
return value + 3;
}
}
private static long run(Rule[] rules, int rounds) {
long checksum = 0;
for (int round = 0; round < rounds; round++) {
for (Rule rule : rules) {
checksum += rule.apply(round);
}
}
return checksum;
}
public static void main(String[] args) {
Rule doubleRule = new DoubleRule();
System.out.println("mono=" + run(new Rule[] {doubleRule}, 20_000));
System.out.println("mixed=" + run(new Rule[] {doubleRule, new AddRule()}, 20_000));
}
}원문의 mono 뒤 mixed 호출은 같은 프로세스의 앞선 타입 프로필 영향을 받을 수 있습니다. 두 체크섬만으로 성능 차이나 인라이닝 여부를 결론 내리지 않습니다.
JMH의 fork를 분리해 이전 프로필의 영향을 막고 컴파일러 로그와 JFR 프로필에서 인라이닝 결과를 확인합니다.
벤치마크 결과의 전제 조건
- JDK 공급자·버전, JVM 플래그, 운영체제·CPU·전원 모드를 기록합니다.
- 예열·측정 반복과 JVM
fork수를 명시합니다. - 입력 크기·분포·시드와 예상 결과를 저장합니다.
- 할당률, GC 일시 중지, 컴파일러 활동을 함께 봅니다.
- 평균뿐 아니라 분산과 백분위수도 확인합니다.
- 같은 프로세스에서 벤치마크 순서를 무작위로 바꾸거나
fork를 분리합니다.
JMH를 선택하는 이유
JMH는 예열, 측정, fork, 상태 범위, 결과 소비를 체계적으로 다룹니다.
그래도 잘못된 작업 부하와 운영과 다른 입력은 고쳐 주지 않습니다.
미세 벤치마크 결과를 종단 간 지연 시간과 함께 확인합니다.
충분히 오래 반복하면 직접 사용한 nanoTime도 정확해지나요?
반복 시간만 늘려도 상태 변화, 죽은 코드 제거, 이전 프로필의 영향, GC, 측정 누락 같은 문제는 남습니다.
최소한 별도 fork와 생명 주기를 제공하는 JMH를 사용하고 작업 부하의 타당성을 검토합니다.
연습 문제
배열 합계를 반복 실행하되 각 반복이 독립적인 지역 누산기를 사용하고 예상 체크섬과 같아야 합니다. 이 문제는 소요 시간 측정 대신 결과의 일관성을 검증합니다.
해설 보기
import java.util.stream.IntStream;
public final class ResetBenchmarkStateSolution {
static long sum(int[] values) {
long total = 0;
for (int value : values) {
total += value;
}
return total;
}
public static void main(String[] args) {
int[] values = IntStream.rangeClosed(1, 1_000).toArray();
long expected = 500_500;
for (int iteration = 1; iteration <= 3; iteration++) {
long result = sum(values);
if (result != expected) {
throw new AssertionError("iteration=" + iteration);
}
System.out.println("iteration=" + iteration + ", result=" + result);
}
}
}종료 기준은 세 반복이 모두 500500이고 공유 누산기 필드가 없는 것입니다.