길이 2의 배열에서 다음 두 코드 조각을 고려하십시오.
boolean isOK(int i) {
for (int j = 0; j < filters.length; ++j) {
if (!filters[j].isOK(i)) {
return false;
}
}
return true;
}
과
boolean isOK(int i) {
return filters[0].isOK(i) && filters[1].isOK(i);
}
충분한 예열 후에이 두 조각의 성능이 비슷해야한다고 가정합니다. 여기 및 여기에
설명 된대로 JMH 마이크로 벤치마킹 프레임 워크를 사용하여이를 확인 했으며 두 번째 스 니펫이 10 % 이상 빠릅니다.
질문 : Java가 기본 루프 언 롤링 기술을 사용하여 첫 번째 스 니펫을 최적화하지 않은 이유는 무엇입니까?
특히 다음을 이해하고 싶습니다.
- 2 필터의 경우에 최적 인 코드를 쉽게 생성 할 수 있으며 다른 필터 수의 경우에도 작동 할 수 있습니다 (간단한 빌더를 상상해보십시오)
return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters)
. JITC가 동일한 작업을 수행 할 수 있습니까? - JITC가 ‘ filters.length == 2 ‘가 가장 빈번한 경우 임을 감지하고 예열 후이 경우에 가장 적합한 코드를 생성 할 수 있습니까? 수동으로 롤링 한 버전과 거의 동일해야합니다.
- JITC가 특정 인스턴스 가 매우 자주 사용되는 것을 감지 한 다음 이 특정 인스턴스에 대한 코드 를 생성 할 수 있습니까 (필터 수는 항상 2임을 알고 있습니까)?
업데이트 : JITC가 수업 수준에서만 작동한다는 답변을 받았습니다. 알았어
이상적으로는 JITC의 작동 방식에 대해 깊이 이해 한 사람으로부터 답변을 받고 싶습니다.
벤치 마크 실행 세부 사항 :
- 최신 버전의 Java 8 OpenJDK 및 Oracle HotSpot에서 시도한 결과는 비슷합니다.
- 사용 된 Java 플래그 : -Xmx4g -Xms4g -server -Xbatch -XX : CICompilerCount = 2 (팬시 플래그없이 유사한 결과를 얻음)
- 그건 그렇고, JMH가 아닌 루프에서 단순히 수십 억 번 실행하면 비슷한 런타임 비율을 얻습니다. 즉 두 번째 스 니펫이 항상 명확하게 빠릅니다.
일반적인 벤치 마크 출력 :
벤치 마크 (filterIndex) 모드 Cnt 점수 오류 단위
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44.202 ± 0.224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38.347 ± 0.063 ns / op
첫 번째 줄은 첫 번째 스 니펫에 해당하고 두 번째 줄은 두 번째 줄에 해당합니다.
완전한 벤치 마크 코드 :
public class LoopUnrollingBenchmark {
@State(Scope.Benchmark)
public static class BenchmarkData {
public Filter[] filters;
@Param({"0", "1"})
public int filterIndex;
public int num;
@Setup(Level.Invocation) //similar ratio with Level.TRIAL
public void setUp() {
filters = new Filter[]{new FilterChain1(), new FilterChain2()};
num = new Random().nextInt();
}
}
@Benchmark
@Fork(warmups = 5, value = 20)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public int runBenchmark(BenchmarkData data) {
Filter filter = data.filters[data.filterIndex];
int sum = 0;
int num = data.num;
if (filter.isOK(num)) {
++sum;
}
if (filter.isOK(num + 1)) {
++sum;
}
if (filter.isOK(num - 1)) {
++sum;
}
if (filter.isOK(num * 2)) {
++sum;
}
if (filter.isOK(num * 3)) {
++sum;
}
if (filter.isOK(num * 5)) {
++sum;
}
return sum;
}
interface Filter {
boolean isOK(int i);
}
static class Filter1 implements Filter {
@Override
public boolean isOK(int i) {
return i % 3 == 1;
}
}
static class Filter2 implements Filter {
@Override
public boolean isOK(int i) {
return i % 7 == 3;
}
}
static class FilterChain1 implements Filter {
final Filter[] filters = createLeafFilters();
@Override
public boolean isOK(int i) {
for (int j = 0; j < filters.length; ++j) {
if (!filters[j].isOK(i)) {
return false;
}
}
return true;
}
}
static class FilterChain2 implements Filter {
final Filter[] filters = createLeafFilters();
@Override
public boolean isOK(int i) {
return filters[0].isOK(i) && filters[1].isOK(i);
}
}
private static Filter[] createLeafFilters() {
Filter[] filters = new Filter[2];
filters[0] = new Filter1();
filters[1] = new Filter2();
return filters;
}
public static void main(String[] args) throws Exception {
org.openjdk.jmh.Main.main(args);
}
}
답변
TL; DR 여기서 성능 차이의 주요 원인은 루프 언 롤링과 관련이 없습니다. 오히려 형식 추측 과 인라인 캐시 입니다.
언 롤링 전략
로 사실, 핫스팟 용어로, 이러한 루프는 취급 계산 하고, 어떤 경우 JVM은 수 를 풀다. 그러나 귀하의 경우에는 아닙니다.
핫스팟에는 두 가지 루프 풀기 전략이 있습니다. 1) 최대로 풀기, 즉 루프를 완전히 제거합니다. 또는 2) 여러 연속 반복을 함께 붙입니다.
정확한 반복 횟수를 알고있는 경우에만 최대 언 롤링을 수행 할 수 있습니다 .
if (!cl->has_exact_trip_count()) {
// Trip count is not exact.
return false;
}
그러나 귀하의 경우 첫 번째 반복 후 함수가 일찍 반환 될 수 있습니다.
부분 언 롤링이 적용될 수 있지만 다음과 같은 조건에서는 언 롤링이 중단됩니다.
// Don't unroll if the next round of unrolling would push us
// over the expected trip count of the loop. One is subtracted
// from the expected trip count because the pre-loop normally
// executes 1 iteration.
if (UnrollLimitForProfileCheck > 0 &&
cl->profile_trip_cnt() != COUNT_UNKNOWN &&
future_unroll_ct > UnrollLimitForProfileCheck &&
(float)future_unroll_ct > cl->profile_trip_cnt() - 1.0) {
return false;
}
귀하의 경우 예상 여행 횟수가 2보다 작으므로 HotSpot은 두 번의 반복을 풀기에는 적합하지 않다고 가정합니다. 어쨌든 첫 번째 반복은 사전 루프로 추출 되므로 ( 루프 필링 최적화 ) 실제로 롤링은 그리 중요하지 않습니다.
타입 추론
롤링되지 않은 버전에는 두 가지 다른 invokeinterface
바이트 코드가 있습니다. 이 사이트에는 두 가지 유형의 프로파일이 있습니다. 첫 번째 수신기는 항상 Filter1
있고 두 번째 수신기는 항상 Filter2
있습니다. 따라서 기본적으로 두 개의 단일 통화 사이트가 있으며 HotSpot은 두 통화를 완벽하게 인라인 할 수 있습니다.이 경우 “인라인 캐시”라고하며이 경우 적중률이 100 %입니다.
루프를 사용하면 하나의 invokeinterface
바이트 코드 만 있으며 하나의 유형 프로파일 만 수집됩니다. HotSpot JVM filters[j].isOK()
은 Filter1
수신자의 경우 86 %, 수신자의 경우 14 % 라고 Filter2
합니다. 이것은 바이 모픽 호출이 될 것입니다. 다행히도 HotSpot은 투기 적으로 바이 모픽 호출을 인라인 할 수 있습니다. 조건부 분기를 사용하여 두 대상을 모두 인라인합니다. 그러나이 경우 적중률은 최대 86 %이며 성능은 아키텍처 수준에서 해당하는 잘못 예측 된 분기로 인해 어려움을 겪습니다.
3 개 이상의 다른 필터가 있으면 상황이 더욱 악화됩니다. 이 경우 isOK()
HotSpot이 전혀 인라인 할 수없는 대형 호출이됩니다. 따라서 컴파일 된 코드에는 성능에 더 큰 영향을 미치는 실제 인터페이스 호출이 포함됩니다.
(Java) Method Dispatch의 Black Magic 기사에서 추측 인라인에 대해 자세히 알아보십시오 .
결론
가상 / 인터페이스 호출을 인라인하기 위해 HotSpot JVM은 호출 바이트 코드 당 유형 프로파일을 수집합니다. 루프에 가상 호출이있는 경우 루프가 풀 렸는지 여부에 관계없이 호출에 대해 하나의 유형 프로파일 만 있습니다.
가상 통화 최적화를 최대한 활용하려면 주로 유형 프로파일을 분할 할 목적으로 루프를 수동으로 분할해야합니다. 핫스팟은 지금까지 이것을 자동으로 수행 할 수 없습니다.
답변
제시된 루프는 “카운트되지 않은”루프 범주에 속할 가능성이 있으며, 이는 루프 횟수가 컴파일 타임이나 런타임에 결정될 수없는 루프입니다. 배열 크기에 대한 @Andreas 인수뿐만 아니라 무작위 조건 break
(이 게시물을 쓸 때 벤치 마크에 있었음) 때문입니다.
계산되지 않은 루프를 언 롤링하면 루프의 종료 조건이 복제되기 때문에 최신 컴파일러는 적극적으로 최적화하지 않습니다. 따라서 후속 컴파일러 최적화가 언 롤링 된 코드를 최적화 할 수있는 경우 런타임 성능 만 향상시킵니다. 그들이 그러한 물건을 풀리는 방법을 제안하는 위치에 대한 자세한 내용 은이 2017 논문 을 참조하십시오 .
이것으로부터, 루프의 “수동 풀림”을 한 것으로 가정하지 않습니다. 조건부 중단이있는 배열의 반복을 &&
체인 부울 식 으로 변환하는 기본 루프 언 롤링 기술을 고려하고 있습니다. 나는 이것이 다소 특별한 경우라고 생각하고 핫 스팟 옵티마이 저가 복잡한 리팩토링을 즉석에서 수행한다는 사실에 놀랐습니다. 여기서 그들은 실제로 무엇을 할 수 있는지에 대해 논의하고 있습니다. 아마도이 참고 문헌 은 흥미로울 것입니다.
이것은 현대 언 롤링의 역학에 더 가깝게 반영 될 것이며 아마도 언 롤링 된 머신 코드의 모습과는 거리가 멀다.
if (! filters[0].isOK(i))
{
return false;
}
if(! filters[1].isOK(i))
{
return false;
}
return true;
한 코드는 다른 코드보다 빠르게 실행되므로 루프가 풀리지 않습니다. 그래도 다른 구현을 비교하고 있기 때문에 런타임 차이를 여전히 볼 수 있습니다.
더 확실하게 얻으려면 머신 코드 (github) (프레젠테이션 슬라이드)를 포함한 실제 Jit 작업 의 jitwatch 분석기 / 시각화 기가 있습니다 . 결국 볼만한 것이 있다면 JIT가 일반적으로 할 수 있거나하지 않을 수있는 것에 대한 의견보다 내 눈을 신뢰합니다. 여기서 그들은 JIT에 관한 한 특정 사례에 대한 일반적인 진술에 도달하기가 어려워지고 흥미로운 링크를 제공합니다.
목표는 최소 런타임이므로 a && b && c ...
양식은 루프 언 롤링에 대한 희망에 의존하고 싶지 않으면 적어도 제시된 것보다 더 효율적일 것입니다. 그러나 당신은 그것을 일반적인 방식으로 가질 수 없습니다. java.util.Function의 기능 구성으로 다시 큰 오버 헤드가 있습니다 (각 함수는 클래스이고 각 호출은 디스패치가 필요한 가상 메소드 임). 아마도 이러한 시나리오에서 언어 수준을 전복시키고 런타임에 사용자 지정 바이트 코드 를 생성하는 것이 좋습니다. 반면에 &&
논리 는 바이트 코드 레벨의 분기도 필요 하며 if / return과 동일 할 수 있습니다 (오버 헤드 없이는 생성 할 수 없음).