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

길이 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);
    }
}



답변

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과 동일 할 수 있습니다 (오버 헤드 없이는 생성 할 수 없음).


답변