Java : 수동 풀린 루프가 여전히 원래 루프보다 빠릅니다. 왜?


13

길이 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가 기본 루프 언 롤링 기술을 사용하여 첫 번째 스 니펫을 최적화하지 않은 이유는 무엇입니까?
특히 다음을 이해하고 싶습니다.

  1. 2 필터의 경우에 최적 인 코드를 쉽게 생성 할 수 있으며 다른 필터 수의 경우에도 작동 할 수 있습니다 (간단한 빌더를 상상해보십시오)
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters). JITC가 동일한 작업을 수행 할 수 있습니까?
  2. JITC가 ' filters.length == 2 '가 가장 빈번한 경우 임을 감지하고 예열 후이 경우에 가장 적합한 코드를 생성 할 수 있습니까? 수동으로 롤링 한 버전과 거의 동일해야합니다.
  3. 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);
    }
}

1
컴파일러는 배열의 길이가 2임을 보장 할 수 없습니다. 그래도 배열을 풀 수 있는지 확실하지 않습니다.
marstran

1
@Setup(Level.Invocation): 확실하지 않습니다 (javadoc 참조).
GPI

3
배열의 길이가 항상 2라는 보장이 없기 때문에 두 방법은 같은 일을하지 않습니다. 그러면 JIT가 어떻게 첫 번째를 두 번째로 바꿀 수 있습니까?
Andreas

@Andreas 나는 당신이 그 질문에 대답 할 것을 제안하지만, JIT가 가능한 다른 유사한 경우와 비교하여이 경우에 JIT가 풀릴 수없는 이유를 자세히 설명합니다.
Alexander

1
@Alexander JIT 필드가이므로 생성 후 배열 길이를 변경할 수 없다는 것을 알 수 final있지만 JIT는 클래스의 모든 인스턴스 가 길이 2의 배열을 얻는다는 것을 알지 못합니다 . createLeafFilters()배열이 항상 2 길다는 것을 알기에 충분히 깊이 코드를 분석하고 분석하십시오. JIT 옵티마이 저가 코드에 깊이 들어가는 이유는 무엇입니까?
Andreas

답변:


10

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은 호출 바이트 코드 당 유형 프로파일을 수집합니다. 루프에 가상 호출이있는 경우 루프가 풀 렸는지 여부에 관계없이 호출에 대해 하나의 유형 프로파일 만 있습니다.

가상 통화 최적화를 최대한 활용하려면 주로 유형 프로파일을 분할 할 목적으로 루프를 수동으로 분할해야합니다. 핫스팟은 지금까지 이것을 자동으로 수행 할 수 없습니다.


큰 답변 주셔서 감사합니다. 완전성을 위해 : 특정 인스턴스에 대한 코드를 생성 할 수있는 JITC 기술에 대해 알고 있습니까?
Alexander

@Alexander HotSpot은 특정 인스턴스에 대한 코드를 최적화하지 않습니다. 바이트 별 카운터, 유형 프로파일, 분기 대상 확률 등을 포함하는 런타임 통계를 사용합니다. 특정 사례에 맞게 코드를 최적화하려면 수동 또는 동적 바이트 코드 생성을 위해 별도의 클래스를 작성하십시오.
apangin

13

제시된 루프는 "카운트되지 않은"루프 범주에 속할 가능성이 있으며, 이는 루프 횟수가 컴파일 타임이나 런타임에 결정될 수없는 루프입니다. 배열 크기에 대한 @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과 동일 할 수 있습니다 (오버 헤드 없이는 생성 할 수 없음).


단지 작은 adendum : JVM 세계에서 계산 루프 이상 "실행"어떤 루프 int i = ....; i < ...; ++i어떤 다른 루프가 없습니다.
Eugene
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.