정렬되지 않은 배열을 처리하는 것이 정렬되지 않은 배열을 처리하는 것보다 빠른 이유는 무엇입니까?


24443

다음은 매우 독특한 동작을 보여주는 C ++ 코드입니다. 이상한 이유로 데이터를 기적적으로 정렬하면 코드가 거의 6 배 빨라집니다.

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster.
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
  • 없이 std::sort(data, data + arraySize); 코드가 11.54 초 안에 실행됩니다.
  • 정렬 된 데이터를 사용하면 코드가 1.93 초 안에 실행됩니다.

처음에는 이것이 언어 또는 컴파일러 이상일 수 있다고 생각했기 때문에 Java를 사용해 보았습니다.

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

비슷하지만 덜 극단적 인 결과.


첫 번째 생각은 정렬이 데이터를 캐시로 가져 오는 것이라고 생각했지만 배열이 방금 생성 되었기 때문에 얼마나 어리석은 지 생각했습니다.

  • 무슨 일이야?
  • 정렬되지 않은 배열을 처리하는 것이 정렬되지 않은 배열을 처리하는 것보다 빠른 이유는 무엇입니까?

코드는 독립적 인 용어를 요약하므로 순서는 중요하지 않습니다.



16
@SachinVerma 내 머리 꼭대기에서 떨어져 : 1) 조건부 이동을 사용하기에 JVM이 마침내 똑똑 할 수 있습니다. 2) 코드가 메모리에 바인딩되어 있습니다. 200M이 너무 커서 CPU 캐시에 맞지 않습니다. 따라서 분기 대신 메모리 대역폭으로 인해 성능 병목 현상이 발생합니다.
Mysticial

12
@Mysticial, 약 2). 예측 테이블이 패턴 (해당 패턴에 대해 확인 된 실제 변수와 상관없이)을 추적하고 기록을 기반으로 예측 출력을 변경한다고 생각했습니다. 왜 초대형 배열이 분기 예측의 이점을 얻지 못하는 이유를 알려주시겠습니까?
Sachin Verma

15
@SachinVerma 배열이 크면 메모리 대역폭과 같은 더 큰 요소가 재생됩니다. 메모리가 평평하지 않습니다 . 메모리 액세스 속도가 매우 느리고 대역폭이 제한되어 있습니다. 지나치게 단순화하기 위해 고정 된 시간 안에 CPU와 메모리간에 전송할 수있는 바이트가 너무 많습니다. 이 질문의 코드와 같은 간단한 코드는 오해로 인해 속도가 느려져도 한계에 도달 할 것입니다. 32768 (128KB) 배열에서는 CPU의 L2 캐시에 맞기 때문에 이런 일이 발생하지 않습니다.
신비주의

13
BranchScope라는 새로운 보안 결함이 있습니다. cs.ucr.edu/~nael/pubs/asplos18.pdf
Veve

답변:


31787

분기 예측 실패 의 희생자입니다 .


분기 예측이란 무엇입니까?

철도 교차점을 고려하십시오.

철도 교차점을 보여주는 이미지 Wikimedia Commons를 통한 Mecanismo의 이미지 . CC-By-SA 3.0 라이센스에 따라 사용됩니다 .

이제 논쟁의 여지가 있기 위해, 이것이 장거리 또는 무선 통신 전에 1800 년대에 다시 시작되었다고 가정하십시오.

당신은 정션의 운영자이며 기차가 오는 소리를 듣습니다. 당신은 그것이 어느 방향으로 가야할지 모른다. 열차를 멈 추면 운전자에게 원하는 방향을 물어볼 수 있습니다. 그런 다음 스위치를 적절하게 설정하십시오.

열차는 무겁고 많은 관성이 있습니다. 그래서 그들은 시작하고 느리게 영원히 걸립니다.

더 좋은 방법이 있습니까? 당신은 기차가 어느 방향으로 갈지 추측합니다!

  • 당신이 올바르게 추측하면 계속됩니다.
  • 당신이 틀렸다고 생각하면, 선장이 멈추고, 백업하고, 스위치를 뒤집으라고 소리 칠 것입니다. 그런 다음 다른 경로를 다시 시작할 수 있습니다.

당신이 매번 올바르게 추측한다면 , 기차는 결코 멈출 필요가 없습니다.
너무 자주 잘못 추측 하면 기차가 멈추고 백업하고 다시 시작하는 데 많은 시간이 걸립니다.


if 문을 고려하십시오 . 프로세서 레벨에서 이는 분기 명령입니다.

if 문이 포함 된 컴파일 된 코드의 스크린 샷

당신은 프로세서이고 지점을 볼 수 있습니다. 당신은 어떤 길로 갈지 모른다. 너 뭐하니? 실행을 중단하고 이전 지침이 완료 될 때까지 기다립니다. 그런 다음 올바른 경로를 계속 진행하십시오.

최신 프로세서는 복잡하고 파이프 라인이 길다. 그래서 그들은 "온난화"하고 "느리게"하는 데 영원히 걸립니다.

더 좋은 방법이 있습니까? 당신은 지점이 어느 방향으로 갈지 추측합니다!

  • 올바르게 추측하면 계속 실행합니다.
  • 잘못 추측 한 경우 파이프 라인을 플러시하고 분기로 롤백해야합니다. 그런 다음 다른 경로를 다시 시작할 수 있습니다.

매번 올바르게 추측 하면 실행을 중지 할 필요가 없습니다.
너무 자주 잘못 추측 하면 실속, 롤백 및 재시작에 많은 시간이 소요됩니다.


이것은 분기 예측입니다. 나는 기차가 깃발로 방향을 알릴 수 있기 때문에 그것이 가장 좋은 비유가 아니라는 것을 인정한다. 그러나 컴퓨터에서 프로세서는 지점이 마지막 순간까지 어느 방향으로 갈지 알 수 없습니다.

그렇다면 열차가 백업하고 다른 경로로 내려가는 횟수를 최소화하기 위해 어떻게 전략적으로 추측 할 것입니까? 당신은 과거의 역사를 봅니다! 기차가 시간의 99 %를 왼쪽으로 가면 왼쪽으로 추측됩니다. 그것이 번갈아 가면 추측을 번갈아 가며 나타냅니다. 세 번마다 한 가지 방법으로 진행된다면 같은 생각입니다.

다시 말해, 패턴을 식별하고 따르려고합니다. 이것은 분기 예측자가 작동하는 방식입니다.

대부분의 응용 프로그램에는 올바르게 동작하는 분기가 있습니다. 따라서 최신 지점 예측자는 일반적으로 90 % 이상의 적중률을 달성합니다. 그러나 인식 할 수있는 패턴이없는 예측할 수없는 분기에 직면 할 때 분기 예측기는 사실상 쓸모가 없습니다.

더 읽을 거리 : Wikipedia의 "지점 예측기"기사 .


위에서 암시 한 바와 같이 범인은이 if 문입니다.

if (data[c] >= 128)
    sum += data[c];

데이터는 0과 255 사이에 균등하게 분배됩니다. 데이터를 정렬 할 때 대략 절반의 반복이 if 문에 들어 가지 않습니다. 그 후, 그들은 모두 if 문에 들어갑니다.

분기가 연속적으로 같은 방향으로 여러 번 이동하기 때문에 분기 예측 변수에 매우 친숙합니다. 간단한 포화 카운터조차도 방향 전환 후 몇 번의 반복을 제외하고 분기를 정확하게 예측합니다.

빠른 시각화 :

T = branch taken
N = branch not taken

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (easy to predict)

그러나 데이터가 완전히 임의 인 경우 분기 예측기는 임의 데이터를 예측할 수 없으므로 쓸모가 없게됩니다. 따라서 약 50 %의 잘못된 예측이있을 것입니다 (임의 추측보다 낫지 않음).

data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   (completely random - hard to predict)

그래서 무엇을 할 수 있습니까?

컴파일러가 분기를 조건부 이동으로 최적화 할 수없는 경우 성능에 대한 가독성을 기꺼이 희생하려는 경우 해킹을 시도 할 수 있습니다.

바꾸다:

if (data[c] >= 128)
    sum += data[c];

와:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

이렇게하면 분기가 제거되고 일부 비트 단위 작업으로 대체됩니다.

(이 해킹은 원래 if 문과 완전히 동일하지는 않지만이 경우 모든 입력 값에 유효합니다 data[].)

벤치 마크 : Core i7 920 @ 3.5 GHz

C ++-Visual Studio 2010-x64 릴리스

//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587

Java-NetBeans 7.1.1 JDK 7-x64

//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823

관찰 :

  • 지사와 함께 : 정렬 된 데이터와 정렬되지 않은 데이터 간에는 큰 차이가 있습니다.
  • 해킹 사용 : 정렬 된 데이터와 정렬되지 않은 데이터 사이에는 차이가 없습니다.
  • C ++의 경우, 데이터가 정렬 될 때 해킹은 실제로 분기보다 느리게 느립니다.

일반적인 경험 법칙은 중요한 루프에서 데이터 종속 분기를 피하는 것입니다 (이 예와 같이).


최신 정보:

  • x64가 -O3있거나있는 GCC 4.6.1 -ftree-vectorize은 조건부 이동을 생성 할 수 있습니다. 따라서 정렬 된 데이터와 정렬되지 않은 데이터 사이에는 차이가 없습니다. 둘 다 빠릅니다.

    (또는 다소 빠름 : 이미 분류 된 경우, cmov특히 GCC 가 2주기 대기 시간이있는 addBroadwell 이전의 Intel에서 대신 GCC가 중요한 경로에 놓으면 특히 느려질 수 있습니다 cmov. gcc 최적화 플래그 -O3은 -O2보다 코드를 느리게합니다. )

  • VC ++ 2010은에서이 분기에 대한 조건부 이동을 생성 할 수 없습니다 /Ox.

  • ICC ( Intel C ++ Compiler ) 11은 기적적인 일을합니다. 그것은 두 개의 루프를 교환하는데 하여 외부 루프에 예측할 수 분기를 게양. 따라서 오해에 대한 내성이있을뿐만 아니라 VC ++ 및 GCC가 생성 할 수있는 것보다 두 배 빠릅니다! 다시 말해 ICC는 벤치 마크를 물리 치기 위해 테스트 루프를 활용했습니다.

  • 인텔 컴파일러에 분기없는 코드를 제공하면 코드를 벡터화하여 분기와 마찬가지로 (루프 인터체인지) 빠릅니다.

이것은 성숙한 현대 컴파일러조차도 코드를 최적화하는 능력이 크게 다를 수 있음을 보여줍니다 ...


256
이 후속 질문을 살펴보십시오. stackoverflow.com/questions/11276291/… 인텔 컴파일러는 외부 루프를 완전히 제거하는 데 거의 근접했습니다.
신비주의

24
@Mysticial 기차 / 컴파일러가 잘못된 경로에 진입했음을 어떻게 알 수 있습니까?
onmyway133

26
@obe : 계층 적 메모리 구조가 주어지면 캐시 미스 비용이 얼마인지 말할 수 없습니다. L1에서 누락되어 느린 L2에서 해결되거나 L3에서 누락되어 시스템 메모리에서 해결 될 수 있습니다. 그러나 기괴한 이유로이 캐시 누락으로 인해 비 상주 페이지의 메모리가 디스크에서로드되지 않으면 좋은 지적이 있습니다 ... 메모리는 약 25-30 년 동안 밀리 초 범위의 액세스 시간을 갖지 못했습니다 ;)
Andon M. Coleman

21
최신 프로세서에서 효율적인 코드 작성을위한 경험 법칙 : 프로그램 실행을보다 규칙적으로 (불균일하지 않게) 만드는 모든 것이 더 효율적입니다. 이 예에서 정렬은 분기 예측으로 인해이 효과가 있습니다. 광범위한 로컬 액세스가 아닌 액세스 로컬 리티가 캐시로 인해이 영향을 미칩니다.
Lutz Prechelt

22
@Sandeep 네. 프로세서에는 여전히 분기 예측이 있습니다. 변경된 것이 있으면 컴파일러입니다. 요즘에는 ICC와 GCC (-O3 아래)가 여기에서 한 일을 할 가능성이 더 높습니다. 즉, 지점을 제거하십시오. 이 질문이 얼마나 중요한지 감안할 때 컴파일러 가이 질문의 경우를 구체적으로 처리하도록 업데이트되었을 가능성이 큽니다. 확실히 SO에주의하십시오. 그리고이 질문 에서 GCC가 3 주 이내에 업데이트되었습니다. 왜 여기서도 일어나지 않을지 모르겠습니다.
Mysticial

4086

지점 예측.

정렬 된 배열의 경우 조건 data[c] >= 128은 먼저 일련 false의 값에 대한 조건 이며 true이후의 모든 값에 적용됩니다. 예측하기 쉽습니다. 정렬되지 않은 배열을 사용하면 분기 비용을 지불합니다.


105
분기 예측은 정렬 된 배열 대 다른 패턴의 배열에서 더 잘 작동합니까? 예를 들어, 배열-> {10, 5, 20, 10, 40, 20, ...}의 경우 패턴에서 배열의 다음 요소는 80입니다. 이러한 종류의 배열은 패턴을 따르는 경우 다음 요소는 80입니까? 아니면 정렬 된 배열에만 도움이됩니까?
Adam Freeman

133
기본적으로 내가 big-O에 대해 배운 모든 것이 창 밖입니까? 분기 비용보다 정렬 비용이 더 낫습니까?
Agrim Pathak

133
@AgrimPathak 그것은 달려 있습니다. 입력이 너무 크지 않은 경우, 복잡도가 높은 알고리즘의 상수가 작을수록 복잡도가 높은 알고리즘보다 복잡도가 높은 알고리즘이 빠릅니다. 손익 분기점이있는 곳을 예측하기 어려울 수 있습니다. 또한 이것을 비교하면 지역성이 중요합니다. Big-O는 중요하지만 성능의 유일한 기준은 아닙니다.
Daniel Fischer

65
분기 예측은 언제 발생합니까? 언어는 배열이 정렬되었음을 언제 알 수 있습니까? [1,2,3,4,5, ... 998,999,1000, 3, 10001, 10002]와 같은 배열의 상황을 생각하고 있습니다. 이 불분명 한 3이 실행 시간을 늘립니까? 정렬되지 않은 배열만큼 길습니까?
Filip Bartuzi

63
@FilipBartuzi 분기 예측은 프로세서에서 언어 수준 아래로 이루어집니다 (그러나 언어는 컴파일러에게 가능성을 알려주는 방법을 제공하므로 컴파일러는 해당 코드를 생성 할 수 있습니다). 귀하의 예에서, 비 순차 3은 분기 오해 (3이 1000과 다른 결과를 제공하는 적절한 조건의 경우)를 초래하므로 해당 배열을 처리하는 데 2 ​​~ 수백 나노 초가 걸릴 것입니다 정렬 된 배열은 거의 눈에 띄지 않습니다. 시간이 얼마나 걸리는가는 오해 율이 높고 1000 당 한 번의 오해는 그리 많지 않습니다.
Daniel Fischer

3310

데이터를 정렬 할 때 성능이 크게 향상되는 이유는 Mysticial의 답변 에서 아름답게 설명 된대로 분기 예측 패널티가 제거 되었기 때문 입니다.

이제 코드를 보면

if (data[c] >= 128)
    sum += data[c];

이 특정 if... else...분기 의 의미 는 조건이 충족 될 때 무언가를 추가 하는 것임을 알 수 있습니다 . 이 유형의 브랜치 는 시스템 에서 조건부 이동 명령 으로 쉽게 변환 될 수 있으며 조건부 이동 명령으로 컴파일됩니다 . 분기 및 잠재적 분기 예측 패널티가 제거됩니다.cmovlx86

에서는 C, 따라서 C++,에서 조건부 이동 명령으로 (어떤 최적화없이) 직접 컴파일 할 문장은 x86, 삼항 연산자입니다 ... ? ... : .... 따라서 위의 문장을 동등한 문장으로 다시 작성합니다.

sum += data[c] >=128 ? data[c] : 0;

가독성을 유지하면서 속도 향상 요소를 확인할 수 있습니다.

Intel Core i7 -2600K @ 3.4GHz 및 Visual Studio 2010 릴리스 모드에서 벤치 마크는 다음과 같습니다 (Mysticial에서 복사 한 형식).

x86

//  Branch - Random
seconds = 8.885

//  Branch - Sorted
seconds = 1.528

//  Branchless - Random
seconds = 3.716

//  Branchless - Sorted
seconds = 3.71

x64

//  Branch - Random
seconds = 11.302

//  Branch - Sorted
 seconds = 1.830

//  Branchless - Random
seconds = 2.736

//  Branchless - Sorted
seconds = 2.737

결과는 여러 테스트에서 강력합니다. 분기 결과를 예측할 수 없을 때 속도가 크게 향상되지만 예측 가능할 때 약간의 어려움을 겪습니다. 실제로 조건부 이동을 사용하는 경우 데이터 패턴에 관계없이 성능이 동일합니다.

이제 x86생성 한 어셈블리를 조사하여 더 자세히 살펴 보겠습니다 . 단순화하기 위해, 우리는 두 가지 기능을 사용 max1하고max2 합니다.

max1조건부 분기를 사용합니다 if... else ....

int max1(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}

max2삼항 연산자를 사용합니다 ... ? ... : ....

int max2(int a, int b) {
    return a > b ? a : b;
}

x86-64 시스템에서 GCC -S아래 어셈블리를 생성하십시오.

:max1
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    -8(%rbp), %eax
    jle     .L2
    movl    -4(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp     .L4
.L2:
    movl    -8(%rbp), %eax
    movl    %eax, -12(%rbp)
.L4:
    movl    -12(%rbp), %eax
    leave
    ret

:max2
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    %eax, -8(%rbp)
    cmovge  -8(%rbp), %eax
    leave
    ret

max2명령어 사용으로 인해 훨씬 ​​적은 코드를 사용합니다 cmovge. 그러나 실제 이득은 max2지점 점프를 포함하지 않는다는 jmp것인데, 이는 예측 된 결과가 올바르지 않으면 상당한 성능 저하를 초래할 수 있습니다.

그렇다면 조건부 이동이 더 나은 이유는 무엇입니까?

일반적인 x86프로세서에서 명령 실행은 여러 단계로 나뉩니다. 대략, 우리는 다른 단계를 다루는 다른 하드웨어를 가지고 있습니다. 따라서 새로운 명령어를 시작하기 위해 하나의 명령어가 완료 될 때까지 기다릴 필요가 없습니다. 이것을 파이프 라이닝 이라고 합니다.

분기의 경우 다음 명령이 이전 명령에 의해 결정되므로 파이프 라이닝을 수행 할 수 없습니다. 기다리거나 예측해야합니다.

조건부 이동의 경우, 실행 조건부 이동 명령은 여러 단계로 나누어 지지만, 이전 단계 는 이전 명령의 결과 Fetch와 유사 하며 그에 Decode의존하지 않습니다. 후자의 단계 만 결과가 필요합니다. 따라서 하나의 명령 실행 시간의 일부를 기다립니다. 이것이 예측이 쉬운 경우 조건부 이동 버전이 분기보다 느린 이유입니다.

이 책은 컴퓨터 시스템 : 프로그래머의 관점은, 두 번째 버전은 자세하게 설명합니다. 조건부 이동 명령어에 대해서는 3.6.6 절 , 프로세서 아키텍처에 대해서는 4 장 , 분기 예측 및 오해에 대한 특별 처리에 대해서는 5.11.2 절을 확인할 수 있습니다 .

때로는 일부 최신 컴파일러가 더 나은 성능으로 어셈블리에 맞게 코드를 최적화 할 수 있고 때로는 일부 컴파일러에서는 그렇지 않을 수도 있습니다 (문제의 코드는 Visual Studio의 기본 컴파일러를 사용하고 있음). 예측할 수 없을 때 분기 및 조건부 이동 간의 성능 차이를 알면 시나리오가 너무 복잡해 컴파일러가 자동으로 최적화 할 수 없을 때 더 나은 성능으로 코드를 작성할 수 있습니다.


7
@ BlueRaja-DannyPflughoeft 최적화되지 않은 버전입니다. 컴파일러는 삼항 연산자를 최적화하지 않고 단지 번역합니다. 그럼에도 불구하고 GCC는 충분한 최적화 수준이 주어지면 if-then을 최적화 할 수 있지만 조건부 이동의 힘을 보여 주며 수동 최적화는 차이를 만듭니다.
WiSaGaN 2016 년

100
@WiSaGaN 두 코드 조각이 동일한 머신 코드로 컴파일되기 때문에 코드는 아무 것도 보여주지 않습니다. 사람들이 귀하의 예에서 if 문이 귀하의 예에서 3 차와 다르다는 아이디어를 얻지 못하는 것이 매우 중요합니다. 마지막 단락의 유사점을 소유한다는 것은 사실이지만 나머지 예제가 해롭다는 사실을 지우지는 않습니다.
Justin L.

55
@WiSaGaN 잘못된 -O0예제 를 제거하고 두 테스트 사례에서 최적화 된 asm 의 차이를 보여주기 위해 답을 수정하면 내 downvote는 확실히 upvote로 바뀔 것 입니다.
Justin L.

56
@UpAndAdam 테스트 시점에 VS2010은 높은 최적화 수준을 지정하더라도 gcc는 가능하지만 원래 분기를 조건부 이동으로 최적화 할 수 없습니다.
WiSaGaN

9
이 삼항 연산자 트릭은 Java에 아름답게 작동합니다. Mystical의 답변을 읽은 후 Java에는 -O3에 해당하는 것이 없기 때문에 잘못된 분기 예측을 피하기 위해 Java에서 수행 할 수있는 작업이 궁금합니다. 삼항 연산자 : 2.1943s 및 원본 : 6.0303s.
Kin Cheung

2271

이 코드에 대해 더 많은 최적화가 필요한 경우 다음 사항을 고려하십시오.

원래 루프로 시작 :

for (unsigned i = 0; i < 100000; ++i)
{
    for (unsigned j = 0; j < arraySize; ++j)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

루프 교환을 사용하면이 루프를 다음과 같이 안전하게 변경할 수 있습니다.

for (unsigned j = 0; j < arraySize; ++j)
{
    for (unsigned i = 0; i < 100000; ++i)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

그런 다음 루프 if가 실행되는 동안 조건이 일정 하다는 것을 i알 수 있으므로 if아웃을 들어 올릴 수 있습니다 .

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        for (unsigned i = 0; i < 100000; ++i)
        {
            sum += data[j];
        }
    }
}

그런 다음 부동 소수점 모델이 허용한다고 가정하면 내부 루프가 하나의 단일 표현식으로 축소 될 수 있음을 알 수 있습니다 ( /fp:fast예 : 던져 짐)

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        sum += data[j] * 100000;
    }
}

그것은 이전보다 100,000 배 빠릅니다.


276
부정 행위를하려면 루프 외부에서 곱셈을 수행하고 루프 후 sum * = 100000를 수행 할 수 있습니다.
Jyaif

78
@Michael-이 예제는 실제로 LIH ( loop-invariant hoisting ) 최적화 및 NOT 루프 스왑의 예라고 생각합니다 . 이 경우, 전체 내부 루프는 외부 루프와 무관하므로 외부 루프 밖으로 들어 i올릴 수 있으며, 결과는 단순히 하나의 단위 = 1e5 의 합으로 곱 해진다. 최종 결과에는 아무런 차이가 없지만 페이지가 너무 자주 있기 때문에 레코드를 똑바로 설정하고 싶었습니다.
Yair Altman

54
스왑 루프의 단순한 정신은 아니지만 if이 시점 의 내부 는 다음과 같이 변환 sum += (data[j] >= 128) ? data[j] * 100000 : 0;될 수 있습니다 cmovge.
Alex North-Keys

43
외부 루프는 내부 ​​루프에 걸리는 시간을 프로파일 링하기에 충분히 크게 만드는 것입니다. 그렇다면 왜 루프 스왑을 하시겠습니까? 결국 그 루프는 어쨌든 제거됩니다.
saurabheights 2016 년

34
@saurabheights : 잘못된 질문 : 왜 컴파일러가 루프 스왑을하지 않습니까? Microbenchmarks는 어렵다;)
Matthieu M.

1884

의심 할 여지없이 우리 중 일부는 CPU의 분기 예측기에 문제가되는 코드를 식별하는 방법에 관심이있을 것입니다. Valgrind 도구 cachegrind에는 --branch-sim=yes플래그 를 사용하여 활성화되는 분기 예측기 시뮬레이터가 있습니다. 이 질문의 예제를 통해 실행하면 외부 루프 수가 10000으로 줄어들고로 컴파일되어 g++다음과 같은 결과가 나타납니다.

정렬 :

==32551== Branches:        656,645,130  (  656,609,208 cond +    35,922 ind)
==32551== Mispredicts:         169,556  (      169,095 cond +       461 ind)
==32551== Mispred rate:            0.0% (          0.0%     +       1.2%   )

분류되지 않은 :

==32555== Branches:        655,996,082  (  655,960,160 cond +  35,922 ind)
==32555== Mispredicts:     164,073,152  (  164,072,692 cond +     460 ind)
==32555== Mispred rate:           25.0% (         25.0%     +     1.2%   )

cg_annotate우리가 생성 한 라인 별 출력으로 드릴 다운하면 해당 루프가 나타납니다.

정렬 :

          Bc    Bcm Bi Bim
      10,001      4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .      .  .   .      {
           .      .  .   .          // primary loop
 327,690,000 10,016  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .      .  .   .          {
 327,680,000 10,006  0   0              if (data[c] >= 128)
           0      0  0   0                  sum += data[c];
           .      .  .   .          }
           .      .  .   .      }

분류되지 않은 :

          Bc         Bcm Bi Bim
      10,001           4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .           .  .   .      {
           .           .  .   .          // primary loop
 327,690,000      10,038  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .           .  .   .          {
 327,680,000 164,050,007  0   0              if (data[c] >= 128)
           0           0  0   0                  sum += data[c];
           .           .  .   .          }
           .           .  .   .      }

이를 통해 문제가있는 라인을 쉽게 식별 할 수 있습니다. 정렬되지 않은 버전에서는 if (data[c] >= 128)라인이 캐시 그레인 Bcm의 분기 예측기 모델에서 164,050,007 잘못 예측 된 조건부 분기 ( )를 야기하는 반면 정렬 된 버전에서는 10,006 만 발생합니다.


또는 Linux에서 성능 카운터 하위 시스템을 사용하여 동일한 작업을 수행 할 수 있지만 CPU 카운터를 사용하는 기본 성능을 사용할 수 있습니다.

perf stat ./sumtest_sorted

정렬 :

 Performance counter stats for './sumtest_sorted':

  11808.095776 task-clock                #    0.998 CPUs utilized          
         1,062 context-switches          #    0.090 K/sec                  
            14 CPU-migrations            #    0.001 K/sec                  
           337 page-faults               #    0.029 K/sec                  
26,487,882,764 cycles                    #    2.243 GHz                    
41,025,654,322 instructions              #    1.55  insns per cycle        
 6,558,871,379 branches                  #  555.455 M/sec                  
       567,204 branch-misses             #    0.01% of all branches        

  11.827228330 seconds time elapsed

분류되지 않은 :

 Performance counter stats for './sumtest_unsorted':

  28877.954344 task-clock                #    0.998 CPUs utilized          
         2,584 context-switches          #    0.089 K/sec                  
            18 CPU-migrations            #    0.001 K/sec                  
           335 page-faults               #    0.012 K/sec                  
65,076,127,595 cycles                    #    2.253 GHz                    
41,032,528,741 instructions              #    0.63  insns per cycle        
 6,560,579,013 branches                  #  227.183 M/sec                  
 1,646,394,749 branch-misses             #   25.10% of all branches        

  28.935500947 seconds time elapsed

또한 디스 어셈블리로 소스 코드 주석을 수행 할 수도 있습니다.

perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
 Percent |      Source code & Disassembly of sumtest_unsorted
------------------------------------------------
...
         :                      sum += data[c];
    0.00 :        400a1a:       mov    -0x14(%rbp),%eax
   39.97 :        400a1d:       mov    %eax,%eax
    5.31 :        400a1f:       mov    -0x20040(%rbp,%rax,4),%eax
    4.60 :        400a26:       cltq   
    0.00 :        400a28:       add    %rax,-0x30(%rbp)
...

자세한 내용 은 성능 자습서 를 참조하십시오.


74
정렬되지 않은 목록에서 추가가 적중 될 확률은 50 % 여야합니다. 어떻게 든 지점 예측에는 25 %의 미스 비율 만 있습니다. 어떻게 50 % 미스보다 더 잘할 수 있습니까?
TallBrian

128
@ tall.b.lo : 25 %는 모든 브랜치 중 하나입니다. 루프 에는 두 개의 브랜치가 있습니다. 하나는 data[c] >= 128(추천 한대로 50 %의 미스 레이트가 있음) 하나 c < arraySize는 ~ 0 %의 미스 레이트가 있는 루프 조건 입니다. .
caf

1340

나는이 질문과 그 대답을 읽었으며 대답이 빠졌다고 느낍니다.

관리되는 언어에서 특히 잘 작동하는 것으로 알려진 분기 예측을 제거하는 일반적인 방법은 분기를 사용하는 대신 테이블 조회입니다 (이 경우 테스트하지는 않았지만).

이 방법은 다음과 같은 경우에 일반적으로 작동합니다.

  1. 작은 테이블이며 프로세서에 캐시 될 가능성이 높습니다.
  2. 아주 단단한 루프로 작업하고 있거나 프로세서가 데이터를 미리로드 할 수 있습니다.

배경과 이유

프로세서 관점에서 볼 때 메모리가 느립니다. 속도 차이를 보상하기 위해 프로세서에 두 개의 캐시가 내장되어 있습니다 (L1 / L2 캐시). 당신이 멋진 계산을하고 있다고 생각하고 메모리가 필요하다는 것을 알아 내십시오. 프로세서는 '로드'작업을 수행하고 메모리 조각을 캐시에로드 한 다음 캐시를 사용하여 나머지 계산을 수행합니다. 메모리가 상대적으로 느리기 때문에이 '로드'로 인해 프로그램 속도가 느려집니다.

브랜치 예측과 마찬가지로 Pentium 프로세서에서 최적화되었습니다. 프로세서는 데이터 조각을로드해야한다고 예측하고 작업이 실제로 캐시에 도달하기 전에 캐시에로드하려고 시도합니다. 우리가 이미 보았 듯이, 분기 예측은 때때로 끔찍하게 잘못됩니다. 최악의 시나리오에서는 되돌아 가서 실제로 메모리로드를 기다려야합니다. 이는 영원히 걸릴 것입니다 ( 즉, 분기 예측 실패가 잘못되었습니다, 메모리 분기 예측 실패 후로드는 끔찍합니다! ).

다행스럽게도 메모리 액세스 패턴을 예측할 수 있으면 프로세서가 빠른 캐시에로드하고 모든 것이 잘됩니다.

가장 먼저 알아야 할 것은 작은 것 입니까? 일반적으로 크기가 작을수록 좋지만, 일반적으로 <= 4096 바이트 인 조회 테이블을 사용하는 것이 좋습니다. 상한으로 : 룩업 테이블이 64K보다 크면 다시 고려해 볼 가치가 있습니다.

테이블 구성

그래서 우리는 작은 테이블을 만들 수 있다는 것을 알아 냈습니다. 다음으로해야 할 일은 찾아보기 기능을 사용하는 것입니다. 조회 함수는 일반적으로 몇 가지 기본 정수 연산 (및 xor, shift, 더하기, 제거 및 곱하기)을 사용하는 작은 함수입니다. 조회 기능에 의해 입력 내용을 테이블의 일종의 '고유 키'로 변환하여 원하는 모든 작업에 대한 답변을 제공합니다.

이 경우> = 128은 값을 유지할 수 있음을 의미하고 <128은 값을 제거함을 의미합니다. 가장 쉬운 방법은 'AND'를 사용하는 것입니다. 유지하면 7FFFFFFF로 AND합니다. 128을 2의 거듭 제곱으로하여 32768/128 정수의 테이블을 만들어 0과 1로 채울 수 있습니다. 7FFFFFFFF.

관리되는 언어

왜 이것이 관리 언어에서 잘 작동하는지 궁금 할 것입니다. 결국, 관리되는 언어는 분기로 배열의 경계를 확인하여 엉망이되지 않도록합니다 ...

글쎄요, 정확히 ... :-)

관리되는 언어에 대한이 분기를 제거하는 작업이 꽤있었습니다. 예를 들면 다음과 같습니다.

for (int i = 0; i < array.Length; ++i)
{
   // Use array[i]
}

이 경우 경계 조건이 절대로 맞지 않는 것은 컴파일러에게 명백합니다. 적어도 Microsoft JIT 컴파일러 (그러나 Java는 비슷한 작업을 수행한다고 생각합니다)는이를 확인하고 검사를 모두 제거합니다. 와우, 그것은 지점이 없다는 것을 의미합니다. 마찬가지로 다른 명백한 경우도 처리합니다.

관리되는 언어로 조회 할 때 문제가 발생하는 경우 핵심은 & 0x[something]FFF경계 검사를 예측할 수 있도록 조회 기능에 추가하여 빠르게 진행하는 것입니다.

이 사건의 결과

// Generate data
int arraySize = 32768;
int[] data = new int[arraySize];

Random random = new Random(0);
for (int c = 0; c < arraySize; ++c)
{
    data[c] = random.Next(256);
}

/*To keep the spirit of the code intact, I'll make a separate lookup table
(I assume we cannot modify 'data' or the number of loops)*/

int[] lookup = new int[256];

for (int c = 0; c < 256; ++c)
{
    lookup[c] = (c >= 128) ? c : 0;
}

// Test
DateTime startTime = System.DateTime.Now;
long sum = 0;

for (int i = 0; i < 100000; ++i)
{
    // Primary loop
    for (int j = 0; j < arraySize; ++j)
    {
        /* Here you basically want to use simple operations - so no
        random branches, but things like &, |, *, -, +, etc. are fine. */
        sum += lookup[data[j]];
    }
}

DateTime endTime = System.DateTime.Now;
Console.WriteLine(endTime - startTime);
Console.WriteLine("sum = " + sum);
Console.ReadLine();

57
분기 예측자를 우회하고 싶은 이유는 무엇입니까? 최적화입니다.
Dustin Oprea

108
브랜치보다 나은 브랜치가 없기 때문에 :-) 많은 상황에서 이것은 훨씬 더 빠릅니다 ... 최적화하는 경우 시도해 볼 가치가 있습니다. 그들은 또한 f.ex에서 꽤 많이 사용합니다. graphics.stanford.edu/~seander/bithacks.html
atlaste

36
일반적으로 조회 테이블은 빠를 수 있지만이 특정 조건에 대한 테스트를 실행 했습니까? 코드에는 여전히 브랜치 조건이 있지만 이제는 룩업 테이블 생성 부분으로 이동했습니다. 당신은 여전히 당신의 반환 한 부스트 얻을 수 없겠죠
자인 Rizvi

38
@Zain 당신이 정말로 알고 싶다면 ... 예 : 지점과 15 초, 내 버전과 10. 어쨌든, 어느 쪽이든 아는 것이 유용한 기술입니다.
atlaste

42
sum += lookup[data[j]]여기서 lookup256 명 개의 엔트리를 배열하고, 제로를 상기 제 1 인덱스들과 동일한 것 인 마지막?
Kris Vandermotten 2014 년

1200

배열이 정렬 될 때 데이터가 0에서 255 사이로 분산되므로 반복의 절반 if이- if문에 입력되지 않습니다 ( 문은 아래에서 공유 됨).

if (data[c] >= 128)
    sum += data[c];

문제는 정렬 된 데이터의 경우와 같이 특정 경우에 위의 문을 실행하지 못하게하는 이유는 무엇입니까? 여기에 "분기 예측기"가 있습니다. 브랜치 예측기는 브랜치 (예 : if-then-else구조)가 어떤 방식으로 진행 될지 추측하기 위해 시도하는 디지털 회로입니다 . 분기 예측기의 목적은 명령 파이프 라인의 흐름을 개선하는 것입니다. 브랜치 예측자는 높은 효과적인 성능을 달성하는 데 중요한 역할을합니다!

더 잘 이해하기 위해 벤치 마킹을 해 봅시다.

-문의 성능은 if해당 조건에 예측 가능한 패턴이 있는지 여부에 따라 다릅니다. 조건이 항상 참 또는 항상 거짓이면 프로세서의 분기 예측 논리가 패턴을 선택합니다. 다른 한편으로, 패턴이 예측할 수 if없으면-문이 훨씬 비쌉니다.

다른 조건에서이 루프의 성능을 측정 해 봅시다 :

for (int i = 0; i < max; i++)
    if (condition)
        sum++;

다른 참-거짓 패턴을 가진 루프의 타이밍은 다음과 같습니다.

Condition                Pattern             Time (ms)
-------------------------------------------------------
(i & 0×80000000) == 0    T repeated          322

(i & 0xffffffff) == 0    F repeated          276

(i & 1) == 0             TF alternating      760

(i & 3) == 0             TFFFTFFF           513

(i & 2) == 0             TTFFTTFF           1675

(i & 4) == 0             TTTTFFFFTTTTFFFF   1275

(i & 8) == 0             8T 8F 8T 8F        752

(i & 16) == 0            16T 16F 16T 16F    490

나쁜 ”참-거짓 패턴은 if좋은 ”패턴 보다 최대 6 배 더 느리게 진술 할 수 있습니다 ! 물론 어떤 패턴이 좋고 나쁜지 컴파일러에 의해 생성 된 정확한 명령과 특정 프로세서에 따라 다릅니다.

따라서 분기 예측이 성능에 미치는 영향에 대해서는 의심의 여지가 없습니다!


23
@MooingDuck '차이를 일으키지 않기 때문에-그 값은 무엇이든 될 수 있지만 여전히 이러한 임계 값의 경계에 있습니다. 그렇다면 이미 한계를 알고있을 때 임의의 값을 표시하는 이유는 무엇입니까? 나는 당신이 완전성을 위해 하나를 보여줄 수 있다는 것에 동의하지만 '그것의 도대체만을 위해서'입니다.
cst1992

24
@ cst1992 : 지금 그의 가장 느린 타이밍은 TTFFTTFFTTFF이며, 이것은 내 인간의 눈에는 꽤 예측 가능한 것 같습니다. 무작위는 본질적으로 예측할 수 없으므로 정지 속도가 느려서 여기에 표시된 한계를 벗어난 것이 전적으로 가능합니다. OTOH, TTFFTTFF가 병리학 적 사례에 완벽하게 부합 할 수 있습니다. 그는 무작위 타이밍을 보여주지 못했기 때문에 말할 수 없습니다.
Mooing Duck

21
@MooingDuck 사람의 눈에는 "TTFFTTFFTTFF"가 예측 가능한 순서이지만 여기서 말하는 것은 CPU에 내장 된 분기 예측기의 동작입니다. 분기 예측기는 AI 레벨 패턴 인식이 아닙니다. 매우 간단합니다. 분기를 번갈아 가면서 제대로 예측하지 못합니다. 대부분의 코드에서 분기는 거의 항상 같은 방식으로 진행됩니다. 수천 번 실행되는 루프를 고려하십시오. 루프 끝의 분기는 999 번 루프의 시작으로 되돌아 간 다음 천 번째 시간은 다른 작업을 수행합니다. 매우 간단한 분기 예측 변수가 일반적으로 효과적입니다.
steveha

18
@ steveha : CPU 브랜치 예측 변수의 작동 방식에 대해 가정하고 있다고 생각합니다. 그 방법에 동의하지 않습니다. 해당 분기 예측 변수가 얼마나 고급인지는 모르지만 귀하보다 훨씬 고급이라고 생각합니다. 당신이 옳을 수도 있지만, 측정은 확실히 좋을 것입니다.
Mooing Duck

5
@ steveha : 2 단계 적응 예측자는 문제없이 TTFFTTFF 패턴에 고정 될 수 있습니다. "이 예측 방법의 변형은 대부분의 최신 마이크로 프로세서에 사용됩니다". 로컬 브랜치 예측 및 글로벌 브랜치 예측은 2 단계 적응 예측자를 기반으로합니다. "글로벌 브랜치 예측은 AMD 프로세서 및 Intel Pentium M, Core, Core 2 및 Silvermont 기반 Atom 프로세서에서 사용됩니다."또한 Agree 예측기, 하이브리드 예측기, 간접 점프 예측을 해당 목록에 추가하십시오. 루프 예측기가 잠기지 않지만 75 %에 도달합니다. 잠글 수없는 2
개만 남았습니다

1126

분기 예측 오류를 피하는 한 가지 방법은 조회 테이블을 작성하고 데이터를 사용하여 색인화하는 것입니다. Stefan de Bruijn은 그의 답변에서 이에 대해 논의했습니다.

그러나이 경우 값이 [0, 255] 범위에 있고 값> = 128에만 관심이 있다는 것을 알고 있습니다. 즉, 값을 원하는지 여부를 알려주는 단일 비트를 쉽게 추출 할 수 있습니다. 오른쪽 7 비트의 데이터는 0 비트 또는 1 비트로 남겨두고 1 비트가있는 경우에만 값을 추가하려고합니다. 이 비트를 "결정 비트"라고합니다.

결정 비트의 0/1 값을 배열의 인덱스로 사용하여 데이터 정렬 여부에 관계없이 동일하게 빠른 코드를 만들 수 있습니다. 코드는 항상 값을 추가하지만 결정 비트가 0이면 관심이없는 곳에 값을 추가합니다. 코드는 다음과 같습니다.

// Test
clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

이 코드는 추가의 절반을 낭비하지만 분기 예측 실패는 없습니다. 실제 if 문이있는 버전보다 임의의 데이터에서 훨씬 빠릅니다.

그러나 테스트에서 명시 룩업 테이블이 이것보다 약간 빠르 았습니다. 아마 룩업 테이블에 대한 인덱싱이 비트 이동보다 약간 빠르기 때문일 수 있습니다. 이것은 코드가 룩업 테이블 ( lut코드에서 "LookUp Table" 이라고 불림) 을 설정하고 사용하는 방법을 보여줍니다 . C ++ 코드는 다음과 같습니다.

// Declare and then fill in the lookup table
int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

이 경우 조회 테이블은 256 바이트에 불과하므로 캐시에 잘 맞고 모두 빠릅니다. 데이터가 24 비트 값이고 절반 만 원할 경우이 기술은 제대로 작동하지 않습니다. 조회 테이블이 너무 커서 실용적이지 않습니다. 반면에, 우리는 위에 표시된 두 가지 기술을 결합 할 수 있습니다 : 먼저 비트를 이동 한 다음 조회 테이블을 인덱싱하십시오. 상위 절반 값만 원하는 24 비트 값의 경우 데이터를 12 비트 오른쪽으로 이동하고 테이블 인덱스에 대해 12 비트 값을 남겨 둘 수 있습니다. 12 비트 테이블 인덱스는 4096 값의 테이블을 의미하며 이는 실용적 일 수 있습니다.

if사용할 포인터를 결정하기 위해 명령문 을 사용하는 대신 배열에 색인을 생성하는 기술 을 사용할 수 있습니다. 이진 트리를 구현하는 라이브러리를 보았고 두 개의 명명 된 포인터 ( pLeftpRight기타)에 길이가 2 인 포인터 배열이 있고 "결정 비트"기술을 사용하여 어느 것을 따라야할지 결정했습니다. 예를 들어,

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;

이 라이브러리는 다음과 같은 작업을 수행합니다.

i = (x < node->value);
node = node->link[i];

이 코드에 대한 링크는 다음과 같습니다. Red Black Trees , Eternally Confuzzled


29
맞습니다, 당신은 또한 비트를 직접적으로 곱할 수 있습니다 ( data[c]>>7-여기에서도 논의됩니다); 나는 의도적 으로이 솔루션을 생략했지만 물론 맞습니다. 간단한 참고 사항 : 조회 테이블의 경험에 따르면 일반적으로 4KB (캐싱 때문에)에 적합하면 테이블을 작게 만드는 것이 좋습니다. 관리되는 언어의 경우 64KB로 푸시하고 C ++ 및 C와 같은 저수준 언어의 경우 아마도 내 경험 일 것입니다. 이후 typeof(int) = 4로 최대 10 비트를 고수하려고합니다.
atlaste

17
0/1 값으로 인덱싱하는 것이 정수 곱하기보다 빠를 것이라고 생각하지만 성능이 실제로 중요하다면 프로파일 링해야합니다. 캐시 조회를 피하기 위해서는 작은 조회 테이블이 필수적이지만, 더 큰 캐시를 사용하는 경우 더 큰 조회 테이블을 사용하여 벗어날 수 있으므로 4KB가 어려운 규칙보다 일반적입니다. 내 생각 엔 sizeof(int) == 4? 32 비트의 경우에도 마찬가지입니다. 2 살짜리 휴대 전화에는 32KB L1 캐시가 있으므로 특히 조회 값이 int 대신 바이트 인 경우 4K 조회 테이블도 작동 할 수 있습니다.
steveha

12
아마도 내가 뭔가를 놓친하지만에있어 j동등한 0 또는 1 방법 할 왜 당신 만 곱하여 값 j을 추가하는 것이 아니라 배열 인덱스를 사용하기 전에 (아마도 곱해야 1-j보다는 j)
리처드 설렘

6
@ steveha 곱셈은 더 빨라야합니다. 인텔 서적에서 찾아 보았지만 찾을 수 없었습니다. 어쨌든 벤치마킹은 나에게 그 결과를 제공합니다.
atlaste

10
@ steveha PS : 또 다른 가능한 대답은 int c = data[j]; sum += c & -(c >> 7);곱셈을 전혀 필요로하지 않을 것 입니다.
atlaste

1021

정렬 된 경우 성공적인 분기 예측 또는 분기없는 비교 트릭에 의존하는 것보다 분기를 완전히 제거하는 것보다 낫습니다.

실제로, 어레이는와 인접 구역에서 분할 data < 128되고 다른 구역은로 분할됩니다 data >= 128. 따라서 이분법 검색으로 파티션 포인트를 찾아야합니다 (Lg(arraySize) = 15 비교법을 찾은 다음 해당 지점에서 바로 누적합니다.

같은 (체크되지 않은)

int i= 0, j, k= arraySize;
while (i < k)
{
  j= (i + k) >> 1;
  if (data[j] >= 128)
    k= j;
  else
    i= j;
}
sum= 0;
for (; i < arraySize; i++)
  sum+= data[i];

또는 약간 더 난독 화

int i, k, j= (i + k) >> 1;
for (i= 0, k= arraySize; i < k; (data[j] >= 128 ? k : i)= j)
  j= (i + k) >> 1;
for (sum= 0; i < arraySize; i++)
  sum+= data[i];

준다하면서도 빠르게 접근 대략 모두 용액 정렬 또는 정렬되지는 : sum= 3137536;(a 진정한 균일 분포, 기대치 191.5 16,384 샘플을 가정) :-)


23
sum= 3137536-영리하다. 그것은 분명히 질문의 요점이 아닙니다. 문제는 놀라운 성능 특성을 설명하는 것에 관한 것입니다. 나는 std::partition대신에 추가하는 std::sort것이 가치가 있다고 말하는 경향 이 있습니다. 실제 질문은 주어진 합성 벤치 마크 이상으로 확장됩니다.
sehe

12
@DeadMG : 이것은 실제로 주어진 키에 대한 표준 이분법 검색이 아니라 분할 인덱스에 대한 검색입니다. 반복마다 단일 비교가 필요합니다. 그러나이 코드에 의존하지 마십시오. 확인하지 않았습니다. 올바른 구현에 관심이 있으시면 알려주십시오.
이브 다우 스트

831

위의 동작은 분기 예측으로 인해 발생합니다.

분기 예측을 이해하려면 먼저 명령 파이프 라인을 이해해야합니다 .

모든 명령은 일련의 단계로 나뉘어 서로 다른 단계를 동시에 실행할 수 있습니다. 이 기술을 명령 파이프 라인이라고하며 최신 프로세서의 처리량을 높이는 데 사용됩니다. 이것을 더 잘 이해하려면 Wikipedia 에서이 예제 를 참조하십시오 .

일반적으로 최신 프로세서에는 파이프 라인이 상당히 길지만 쉽게 4 단계 만 고려해 봅시다.

  1. IF-메모리에서 명령어를 가져옵니다
  2. ID-명령어 해독
  3. EX-명령 실행
  4. WB-CPU 레지스터에 다시 쓰기

2 가지 명령에 대한 일반적인 4 단계 파이프 라인. 일반적으로 4 단계 파이프 라인

위의 질문으로 돌아가서 다음 지침을 고려하십시오.

                        A) if (data[c] >= 128)
                                /\
                               /  \
                              /    \
                        true /      \ false
                            /        \
                           /          \
                          /            \
                         /              \
              B) sum += data[c];          C) for loop or print().

분기 예측이 없으면 다음이 발생합니다.

명령어 B 또는 명령어 C를 실행하려면 명령어 B 또는 명령어 C로가는 결정이 명령어 A의 결과에 따라 달라지기 때문에 프로세서는 명령어 A가 파이프 라인에서 EX 스테이지까지 도달하지 않을 때까지 기다려야합니다. 이렇게 보일 것입니다.

조건이 true를 반환하는 경우 : 여기에 이미지 설명을 입력하십시오

조건이 false를 반환하는 경우 : 여기에 이미지 설명을 입력하십시오

명령어 A의 결과를 기다린 결과, 위의 경우에 사용 된 총 CPU주기 (분기 예측없이, 참과 거짓 모두)는 7입니다.

그렇다면 분기 예측이란 무엇입니까?

브랜치 예측기는 브랜치 (if-then-else 구조)가 어떤 방식으로 진행 될지 추측하려고 시도합니다. 명령 A가 파이프 라인의 EX 단계에 도달 할 때까지 기다리지는 않지만 결정을 추측하고 해당 명령 (이 예에서는 B 또는 C)으로 이동합니다.

정확한 추측의 경우 파이프 라인은 다음과 같습니다. 여기에 이미지 설명을 입력하십시오

나중에 추측이 잘못되었다는 것이 감지되면 부분적으로 실행 된 명령이 삭제되고 파이프 라인이 올바른 분기로 시작하여 지연이 발생합니다. 분기 잘못 예측 된 경우 낭비되는 시간은 파이프 라인의 페치 단계에서 실행 단계까지의 단계 수와 같습니다. 최신 마이크로 프로세서는 파이프 라인이 매우 길기 때문에 오 탐지 지연이 10 ~ 20 클럭주기입니다. 파이프 라인이 길수록 좋은 분기 예측 변수 가 필요합니다 .

OP의 코드에서 조건부, 분기 예측기는 처음으로 예측을 기반으로 할 정보가 없으므로 처음으로 다음 명령을 임의로 선택합니다. 나중에 for 루프에서 히스토리를 기반으로 예측할 수 있습니다. 오름차순으로 정렬 된 배열의 경우 세 가지 가능성이 있습니다.

  1. 모든 요소는 128보다 작습니다
  2. 모든 요소가 128보다 큽니다.
  3. 일부 새로운 시작 요소는 128보다 작으며 나중에 128보다 큽니다.

예측자가 항상 첫 번째 실행에서 실제 분기를 가정한다고 가정합시다.

따라서 첫 번째 경우 역사적으로 모든 예측이 정확하기 때문에 항상 진정한 분기를 취합니다. 두 번째 경우 처음에는 잘못 예측하지만 몇 번의 반복 후에 올바르게 예측합니다. 세 번째 경우에는 처음에 요소가 128보다 작을 때까지 올바르게 예측합니다. 그 후 일정 시간 동안 실패하고 히스토리에서 분기 예측 실패를 볼 때 자체가 올바른 것입니다.

이러한 모든 경우에 실패 횟수가 너무 적으므로 결과적으로 부분적으로 실행 된 명령어를 버리고 올바른 분기로 다시 시작하면 CPU주기가 줄어 듭니다.

그러나 임의의 정렬되지 않은 배열의 경우 예측은 부분적으로 실행 된 명령어를 버리고 대부분의 올바른 분기로 다시 시작하여 정렬 된 배열에 비해 더 많은 CPU주기를 가져와야합니다.


1
두 명령어는 어떻게 함께 실행됩니까? 이것은 별도의 CPU 코어로 수행됩니까, 아니면 파이프 라인 명령이 단일 CPU 코어에 통합되어 있습니까?
M.kazem Akhgary 14

1
@ M.kazemAkhgary 모든 것이 하나의 논리 코어 안에 있습니다. 당신이 관심이 있다면, 이것은 예를 들어 인텔 소프트웨어 개발자 매뉴얼
Sergey.quixoticaxis.Ivanov에서

727

공식 답변은

  1. 인텔-지점 오해 방지
  2. 인텔-잘못된 예측을 방지하기위한 분기 및 루프 재구성
  3. 과학 논문-지점 예측 컴퓨터 아키텍처
  4. 책 : JL Hennessy, DA Patterson : 컴퓨터 아키텍처 : 정량적 접근
  5. 과학 간행물에 실린 기사 : TY Yeh, YN Patt는 분기 예측에 대해 많은 것들을 만들었습니다.

이 멋진 다이어그램 에서 분기 예측 변수가 왜 혼란스러워하는지 알 수 있습니다 .

2 비트 상태 다이어그램

원래 코드의 각 요소는 임의의 값입니다

data[c] = std::rand() % 256;

예측 변수가 std::rand() 타격 .

반면에 일단 정렬되면 예측자는 먼저 강력하게 취하지 않은 상태로 이동하고 값이 높은 값으로 변경되면 예측자는 세 번의 실행으로 강하게 취하지 않은 것에서 강하게 취한 것으로 변경됩니다.



696

같은 줄에서 (이것은 대답으로 강조되지 않았다고 생각합니다) 때로는 (특히 Linux 커널과 같이 성능이 중요한 소프트웨어에서) 다음과 같은 if 문을 찾을 수 있다고 언급하는 것이 좋습니다.

if (likely( everything_is_ok ))
{
    /* Do something */
}

또는 유사하게 :

if (unlikely(very_improbable_condition))
{
    /* Do something */    
}

양자 모두 likely()unlikely()사실 매크로에 GCC의 같은 것을 사용하여 정의되는 __builtin_expect계정에 조건 복용을 사용자가 제공 한 정보를 선호하는 예측 코드 삽입 컴파일러를 도움. GCC는 실행중인 프로그램의 동작을 변경하거나 캐시 지우기 등과 같은 저수준 명령어를 생성 할 수있는 다른 내장 기능을 지원합니다. 이 설명서를 참조하십시오. . 사용 가능한 GCC 내장 기능을 를 .

일반적으로 이러한 종류의 최적화는 주로 실행 시간이 중요하고 중요한 실시간 응용 프로그램 또는 임베디드 시스템에서 발견됩니다. 예를 들어 1/10000000 번만 발생하는 오류 조건을 확인하는 경우 컴파일러에 이에 대해 알리지 않는 이유는 무엇입니까? 이런 식으로 기본적으로 분기 예측은 조건이 거짓이라고 가정합니다.


678

C ++에서 자주 사용되는 부울 연산은 컴파일 된 프로그램에서 많은 분기를 생성합니다. 이러한 분기가 루프 내부에 있고 예측하기 어려운 경우 실행 속도가 크게 느려질 수 있습니다. 부울 변수는 0for false1for 값 을 가진 8 비트 정수로 저장됩니다.true .

부울 변수는 입력으로 부울 변수가있는 모든 연산자가 입력에 0또는 이외의 값이 있는지 확인 1하지만 부울이 출력 인 연산자는 0또는 이외의 값을 생성 할 수 없다는 점에서 과도하게 결정됩니다 1. 따라서 부울 변수를 입력으로 사용하여 필요한 것보다 효율성이 떨어집니다. 예를 들어 보자.

bool a, b, c, d;
c = a && b;
d = a || b;

이것은 일반적으로 다음과 같은 방식으로 컴파일러에 의해 구현됩니다.

bool a, b, c, d;
if (a != 0) {
    if (b != 0) {
        c = 1;
    }
    else {
        goto CFALSE;
    }
}
else {
    CFALSE:
    c = 0;
}
if (a == 0) {
    if (b == 0) {
        d = 0;
    }
    else {
        goto DTRUE;
    }
}
else {
    DTRUE:
    d = 1;
}

이 코드는 최적이 아닙니다. 잘못 예측할 경우 지점에 시간이 오래 걸릴 수 있습니다. 피연산자가 0및 이외의 다른 값을 가지고 있지 않다고 확신하면 부울 연산을 훨씬 효율적으로 만들 수 있습니다 1. 컴파일러가 이러한 가정을하지 않는 이유는 변수가 초기화되지 않았거나 알 수없는 소스에서 온 경우 다른 값을 가질 수 있기 때문입니다. 상기 코드는 최적화 될 수있는 경우 ab그들이 부울 연산자 출력을 온 유효 값으로 초기화되거나되었다. 최적화 된 코드는 다음과 같습니다.

char a = 0, b = 1, c, d;
c = a & b;
d = a | b;

char부울 연산자 ( 및 ) 대신 bool비트 연산자 ( &|) 를 사용할 수 있도록하기 위해 대신 을 사용합니다 . 비트 연산자는 하나의 클럭 주기만 수행하는 단일 명령어입니다. 는 OR 연산자 ( ) 경우에도 작동 및 이외의 값을 가지고 나 . 피연산자가 및 이외의 다른 값을 갖는 경우 AND 연산자 ( ) 및 EXCLUSIVE OR 연산자 ( )는 일치하지 않는 결과를 제공 할 수 있습니다&&|||ab01&^01 .

~NOT에 사용할 수 없습니다. 대신, 다음 과 같이 알려진 변수 0또는 1XOR 변수로 불리언 NOT을 만들 수 있습니다 1.

bool a, b;
b = !a;

다음에 최적화 될 수 있습니다.

char a = 0, b;
b = a ^ 1;

a && b로 대체 할 수없는 a & b경우 b경우 평가하지 말아야 표현이다 a입니다 false( &&평가하지 않을 것이다 b, &것이다). 마찬가지로 a || b대체 될 수없는 a | b경우 b경우 평가 안되는 표현 a이다 true.

피연산자가 비교 인 경우보다 피연산자가 변수 인 경우 비트 연산자를 사용하는 것이 더 유리합니다.

bool a; double x, y, z;
a = x > y && z < 5.0;

대부분의 경우에 최적입니다 ( &&표현식에서 여러 가지 분기 예측 오류가 발생할 것으로 예상하지 않는 한 ).


341

그건 확실합니다!...

지점 예측 은 코드에서 발생하는 전환으로 인해 로직 실행을 느리게 만듭니다! 그것은 당신이 직선 거리 또는 많은 터닝과 거리를 가고있는 것처럼, 직선이 빨리 끝나는 것을 확신합니다! ...

배열이 정렬되면 첫 번째 단계에서 조건이 false입니다. data[c] >= 128 인 경우 거리 끝까지 전체 값이 참값이됩니다. 그것이 당신이 논리를 더 빨리 끝내는 방법입니다. 반면, 정렬되지 않은 배열을 사용하려면 코드를 느리게 실행하기 위해 많은 회전 및 처리가 필요합니다 ...

내가 당신을 위해 만든 이미지를 아래에서보십시오. 어느 거리가 더 빨리 끝날 것입니까?

지점 예측

프로그래밍 방식으로 분기 예측 인해 프로세스가 느려집니다 ...

또한 마지막에는 각각 코드에 다르게 영향을 줄 두 가지 유형의 분기 예측이 있다는 것을 아는 것이 좋습니다.

1. 정적

2. 동적

지점 예측

정적 분기 예측은 조건부 분기가 처음 발생할 때 마이크로 프로세서에 의해 사용되며 동적 분기 예측은 조건부 분기 코드의 후속 실행에 사용됩니다.

if-else 또는 switch 문을 작성할 때 이러한 규칙을 활용하기 위해 코드를 효과적으로 작성 하려면 가장 일반적인 경우를 먼저 확인하고 가장 일반적인 경우까지 점진적으로 작업하십시오. 루프 반복자의 조건 만 일반적으로 사용되므로 루프는 정적 분기 예측을 위해 특별한 코드 순서를 필요로하지는 않습니다.


304

이 질문은 이미 여러 차례 훌륭하게 답변되었습니다. 여전히 나는 또 다른 흥미로운 분석에 그룹의 관심을 끌고 싶습니다.

최근이 예제 (매우 약간 수정 됨)도 Windows에서 프로그램 자체 내에서 코드 조각을 프로파일 링하는 방법을 보여주는 방법으로 사용되었습니다. 그 과정에서 저자는 결과를 사용하여 코드가 정렬 및 정렬되지 않은 경우에 가장 많은 시간을 소비하는 위치를 결정하는 방법도 보여줍니다. 마지막으로이 기사에서는 HAL (Hardware Abstraction Layer)의 일부 알려진 기능을 사용하여 분류되지 않은 사례에서 발생하는 분기 오해의 정도를 결정하는 방법을 보여줍니다.

링크는 다음과 같습니다 : http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm


3
그것은 매우 흥미로운 기사입니다 (실제로 방금 읽었습니다). 그러나 질문에 어떻게 대답합니까?
피터 Mortensen

2
@PeterMortensen 귀하의 질문에 약간 혼란스러워합니다. 예를 들어, 여기 해당 부분과 관련된 한 줄이 있습니다. When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping. 작성자는 여기에 게시 된 코드의 맥락과 분류 된 사례가 훨씬 더 빠른 이유를 설명하는 프로세스에서 프로파일 링에 대해 논의하려고합니다.
ForeverLearning

260

다른 사람들이 이미 언급했듯이 미스터리의 배후에는 Branch Predictor가 있습니다.

나는 무언가를 추가하지 않고 다른 방법으로 개념을 설명하려고합니다. 위키에는 텍스트와 다이어그램이 포함 된 간결한 소개가 있습니다. 아래의 설명을 통해 다이어그램을 사용하여 지점 예측자를 직관적으로 정교하게 설명합니다.

컴퓨터 아키텍처에서 브랜치 예측기는 브랜치 (예를 들어 if-then-else 구조)가 어떤 방식으로 진행 될지 추측하려고 시도하는 디지털 회로입니다. 분기 예측기의 목적은 명령 파이프 라인의 흐름을 개선하는 것입니다. 지점 예측자는 x86과 같은 많은 최신 파이프 라인 마이크로 프로세서 아키텍처에서 높은 효과적인 성능을 달성하는 데 중요한 역할을합니다.

양방향 분기는 일반적으로 조건부 점프 명령으로 구현됩니다. 조건부 점프는 "취득되지 않음"일 수 있고 조건부 점프 직후에 오는 첫 번째 코드 분기로 실행을 계속하거나 "취득"하여 두 번째 코드 분기가있는 프로그램 메모리의 다른 위치로 점프 할 수 있습니다. 저장되었습니다. 조건이 계산되고 조건 파이프가 명령 파이프 라인의 실행 단계를 통과 할 때까지 조건부 점프가 수행되는지 여부는 확실하지 않습니다 (그림 1 참조).

그림 1

설명 된 시나리오를 기반으로 다양한 상황에서 파이프 라인에서 명령이 실행되는 방법을 보여주는 애니메이션 데모를 작성했습니다.

  1. 지점 예측기가 없습니다.

분기 예측이 없으면 프로세서는 조건부 점프 명령이 실행 단계를 통과 할 때까지 기다려야 다음 명령이 파이프 라인의 페치 단계에 들어갈 수 있습니다.

이 예제에는 세 개의 명령어가 포함되어 있으며 첫 번째 명령어는 조건부 점프 명령어입니다. 후자의 두 명령어는 조건부 점프 명령어가 실행될 때까지 파이프 라인으로 들어갈 수 있습니다.

분기 예측기가없는

3 개의 명령이 완료 되려면 9 클럭주기가 걸립니다.

  1. Branch Predictor를 사용하고 조건부로 점프하지 마십시오. 예측이 조건부 점프를 수행 하지 않는다고 가정합시다 .

여기에 이미지 설명을 입력하십시오

3 개의 명령이 완료 되려면 7 클럭주기가 걸립니다.

  1. Branch Predictor를 사용하고 조건부로 점프하십시오. 예측이 조건부 점프를 수행 하지 않는다고 가정합시다 .

여기에 이미지 설명을 입력하십시오

3 개의 명령이 완료 되려면 9 클럭주기가 걸립니다.

분기 잘못 예측 된 경우 낭비되는 시간은 파이프 라인의 페치 단계에서 실행 단계까지의 단계 수와 같습니다. 최신 마이크로 프로세서는 파이프 라인이 매우 길기 때문에 오 탐지 지연이 10 ~ 20 클럭주기입니다. 결과적으로 파이프 라인을 더 길게 만들면 고급 분기 예측기의 필요성이 높아집니다.

보시다시피 분기 예측기를 사용하지 않는 이유는 없습니다.

Branch Predictor의 매우 기본적인 부분을 설명하는 매우 간단한 데모입니다. 해당 GIF가 성가신 경우 답변에서 자유롭게 제거하고 방문자는 BranchPredictorDemo 에서 라이브 데모 소스 코드를 얻을 수도 있습니다.


1
인텔 마케팅 애니메이션과 거의 비슷하며 지점 예측뿐만 아니라 순서가 잘못된 실행에 집착했습니다. 두 전략 모두 "추측 적"입니다. 메모리 및 스토리지에서 미리 읽기 (순차 프리 페치-버퍼)도 추론 적입니다. 모두 합산됩니다.
mckenzm

@mckenzm : 비 순차적 추측 실행은 분기 예측을 더욱 가치있게 만듭니다. 페치 / 디코딩 버블을 숨길뿐만 아니라 분기 예측 + 추측 실행은 중요한 경로 대기 시간에서 제어 종속성을 제거합니다. 분기 조건을 알기 전에if() 블록 내부 또는 이후의 코드 를 실행할 수 있습니다 . 또는 같은 검색 루프 나 , interations가 겹칠 수 있습니다. 다음 반복을 실행하기 전에 일치 여부를 알기를 기다려야하는 경우 처리량 대신 캐시로드 + ALU 대기 시간에 병목 현상이 발생합니다. strlenmemchr
Peter Cordes

209

분기 예측 이득!

분기 오해가 프로그램 속도를 늦추지 않는다는 것을 이해하는 것이 중요합니다. 누락 된 예측 비용은 분기 예측이 존재하지 않는 것과 같으며 실행할 코드를 결정하기 위해 표현식 평가를 기다렸습니다 (다음 단락에서 자세히 설명).

if (expression)
{
    // Run 1
} else {
    // Run 2
}

if-else\ switch문 이있을 때마다 어떤 블록을 실행해야하는지 결정하기 위해 식을 평가해야합니다. 컴파일러에서 생성 한 어셈블리 코드에는 조건부 분기 명령어가 삽입됩니다.

분기 명령어는 컴퓨터가 다른 명령어 시퀀스의 실행을 시작하게하여 if어떤 조건에 따라 순서대로 명령어를 실행하는 기본 동작 (예 :식이 거짓이면 프로그램이 블록 의 코드를 건너 뜁니다)에서 벗어날 수 있습니다. 우리의 경우 표현 평가.

즉, 컴파일러는 실제로 평가되기 전에 결과를 예측하려고합니다. if블록 에서 명령어를 가져 와서 표현식이 참이면 훌륭합니다! 우리는 그것을 평가하고 코드를 발전시키는 데 걸리는 시간을 얻었습니다. 그렇지 않으면 잘못된 코드를 실행하고 파이프 라인이 플러시되고 올바른 블록이 실행됩니다.

심상:

경로 1 또는 경로 2를 선택해야한다고합시다. 파트너가지도를 확인하기를 기다리거나 ##에서 멈추고 기다렸거나 route1을 선택하고 운이 좋으면 (1 번 경로가 올바른 경로), 파트너가지도를 확인할 때까지 기다릴 필요가 없습니다 (지도를 확인하는 데 걸리는 시간을 절약했습니다). 그렇지 않으면 다시 돌아옵니다.

플러싱 파이프 라인은 매우 빠르지 만 오늘날에는이 도박을하는 것이 가치가 있습니다. 정렬 된 데이터 또는 느리게 변경되는 데이터를 예측하는 것이 빠른 변경을 예측하는 것보다 항상 쉽고 낫습니다.

 O      Route 1  /-------------------------------
/|\             /
 |  ---------##/
/ \            \
                \
        Route 2  \--------------------------------

파이프 라인 플러싱은 빠르지 만 실제로 아닙니다. DRAM에 이르기까지 캐시 미스와 비교하면 빠르지 만 최신 고성능 x86 (Intel Sandybridge 제품군과 같은)에서는 약 12주기입니다. 빠른 복구를 통해 복구를 시작하기 전에 이전의 모든 독립 명령이 폐기 될 때까지 기다리지 않아도되지만 여전히 잘못된 프런트 엔드주기가 많이 손실됩니다. 스카이 레이크 CPU가 지점을 잘못 예측하면 정확히 어떻게됩니까? . (각주기는 약 4 개의 작업 지침이 될 수 있습니다.) 처리량이 많은 코드에는 좋지 않습니다.
Peter Cordes

153

ARM에서는 분기마다 필요하지 않습니다. 모든 명령어에는 프로세서 상태 레지스터에서 발생할 수있는 16 가지의 다른 조건 중 하나를 테스트하는 4 비트 조건 필드 가 있으며 명령어의 조건이 false이면 명령을 건너 뜁니다. 따라서 짧은 분기가 필요하지 않으며이 알고리즘에 대한 분기 예측 히트가 없습니다. 따라서 정렬의 추가 오버 헤드로 인해이 알고리즘의 정렬 된 버전이 ARM의 정렬되지 않은 버전보다 느리게 실행됩니다.

이 알고리즘의 내부 루프는 ARM 어셈블리 언어에서 다음과 유사합니다.

MOV R0, #0     // R0 = sum = 0
MOV R1, #0     // R1 = c = 0
ADR R2, data   // R2 = addr of data array (put this instruction outside outer loop)
.inner_loop    // Inner loop branch label
    LDRB R3, [R2, R1]     // R3 = data[c]
    CMP R3, #128          // compare R3 to 128
    ADDGE R0, R0, R3      // if R3 >= 128, then sum += data[c] -- no branch needed!
    ADD R1, R1, #1        // c++
    CMP R1, #arraySize    // compare c to arraySize
    BLT inner_loop        // Branch to inner_loop if c < arraySize

그러나 이것은 실제로 더 큰 그림의 일부입니다.

CMPopcode는 PSR (Processor Status Register)의 상태 비트를 항상 업데이트하지만, 그 목적이 있기 때문에 대부분의 다른 명령어는 명령에 선택적 S접미사를 추가하지 않는 한 PSR을 건드리지 않습니다 . 지시의 결과. 그냥 4 비트 조건 접미사처럼 PSR에 영향을주지 않고 명령을 실행할 수있는 것은 ARM에 지점에 대한 필요성을 감소시키는 메커니즘이다, 또한 하드웨어 수준에서 주문 파견 밖으로 용이 하기 때문에 일부 조작 X 업데이트가를 수행 한 후, 상태 비트는 이후에 (또는 병렬로) 상태 비트에 명시 적으로 영향을 미치지 않아야하는 여러 가지 다른 작업을 수행 할 수 있습니다. 그런 다음 X로 미리 설정된 상태 비트의 상태를 테스트 할 수 있습니다.

조건 테스트 필드와 선택적 "상태 비트 설정"필드는 다음과 같이 결합 될 수 있습니다.

  • ADD R1, R2, R3R1 = R2 + R3상태 비트를 업데이트하지 않고 수행합니다 .
  • ADDGE R1, R2, R3 상태 비트에 영향을 미치는 이전 명령이보다 크거나 같음 조건 인 경우에만 동일한 작업을 수행합니다.
  • ADDS R1, R2, R3다음 수행 추가를하고는 업데이트 N, Z, CV결과 (서명되지 않은 추가 용)를 추진 제로, 음의 여부에 따라 프로세서 상태 레지스터에 플래그를, 또는 오버 플로우 (서명 추가)입니다.
  • ADDSGE R1, R2, R3GE테스트가 참인 경우에만 더하기를 수행 한 다음 추가 결과에 따라 상태 비트를 업데이트합니다.

대부분의 프로세서 아키텍처에는 주어진 작업에 대해 상태 비트를 업데이트해야하는지 여부를 지정할 수있는 기능이 없으므로 상태 비트를 저장 한 후 나중에 복원하기 위해 추가 코드를 작성해야하거나 추가 분기가 필요하거나 프로세서가 제한 될 수 있습니다. 대부분의 명령 이후 상태 비트를 강제로 업데이트하는 대부분의 CPU 명령 세트 아키텍처의 부작용 중 하나는 명령을 서로 간섭하지 않고 병렬로 실행할 수 있다는 점을 구분하기가 훨씬 어렵다는 것입니다. 상태 비트를 업데이트하면 부작용이 있으므로 코드에 선형화 효과가 있습니다.ARM은 모든 명령어에서 분기없는 조건 테스트를 조합하고 조합하여 명령어가 완료된 후 상태 비트를 업데이트하거나 업데이트하지 않는 옵션을 사용하여 어셈블리 언어 프로그래머와 컴파일러 모두에게 매우 강력하며 매우 효율적인 코드를 생성 할 수 있습니다.

ARM이 왜 대단한 성공을 거둔 지 궁금한 적이 있다면이 두 메커니즘의 뛰어난 효과와 상호 작용은 ARM 아키텍처 효율성의 가장 큰 원인 중 하나이기 때문에 이야기의 큰 부분입니다. 1983 년 당시 ARM ISA의 최초 디자이너 인 Steve Furber와 Roger (현재 Sophie) Wilson의 광채는 과장 될 수 없습니다.


1
ARM의 또 다른 혁신은 S 명령어 접미사를 추가하는 것인데, 거의 모든 명령에서 선택 사항이며 선택 사항이 없을 경우 명령이 상태 비트를 변경하지 못하게합니다 (CMP 명령을 제외하고 작업은 상태 비트를 설정 함). 따라서 S 접미사가 필요하지 않습니다). 이를 통해 비교가 0 이상인 경우 (예 : SUBS R0, R0, # 1은 R0이 0에 도달 할 때 Z (Zero) 비트를 설정 함) 많은 경우 CMP 명령을 피할 수 있습니다. 조건부와 S 접미사는 오버 헤드가 없습니다. 꽤 아름다운 ISA입니다.
Luke Hutchison

2
S 접미사를 추가하지 않으면 그 중 하나가 상태 비트를 변경하여 나머지 조건부 명령어를 건너 뛰는 부작용이있을 수 있다는 걱정없이 여러 개의 조건부 명령어를 연속으로 가질 수 있습니다.
Luke Hutchison

OP에는 측정에서 정렬하는 시간이 포함되어 있지 않습니다 . 분류되지 않은 경우 루프가 훨씬 느리게 실행되지만 분기 x86 루프를 실행하기 전에 먼저 정렬하는 것이 전체 손실 일 수 있습니다. 그러나 큰 배열을 정렬하려면 많은 작업이 필요합니다 .
Peter Cordes

BTW, 배열의 끝을 기준으로 인덱싱하여 명령을 루프에 저장할 수 있습니다. 루프 전에을 설정 한 R2 = data + arraySize다음로 시작하십시오 R1 = -arraySize. 루프의 맨 아래는 adds r1, r1, #1/가 bnz inner_loop됩니다. 컴파일러는 어떤 이유로 든이 최적화를 사용하지 않습니다 : / 그러나 어쨌든, add의 예정된 실행은 x86과 같은 다른 ISA에서 분기없는 코드로 할 수있는 것과 근본적으로 다르지 않습니다 cmov. : 그것은 좋은으로하지 비록 -O2보다 느리게 GCC 최적화 플래그 -O3 차종 코드
피터 코르

1
(ARM 술어 실행은 실제로 명령어를 NOP하기 때문에 cmov메모리 소스 피연산자가있는 x86 과 달리 고장이 발생하는로드 또는 스토어에서도 명령을 사용할 수 있습니다 . AArch64를 포함한 대부분의 ISA에는 ALU 선택 조작 만 있으므로 ARM 술어는 강력 할 수 있습니다. 대부분의 ISA에서 분기없는 코드보다 효율적으로 사용할 수 있습니다.)
Peter Cordes

146

분기 예측에 관한 것입니다. 무엇입니까?

  • 브랜치 예측기는 여전히 현대 아키텍처와 관련성을 찾는 고대의 성능 개선 기술 중 하나입니다. 간단한 예측 기술은 빠른 조회 및 전력 효율을 제공하지만 높은 오해 율로 인해 어려움을 겪습니다.

  • 반면에 신경 기반 또는 2 단계 분기 예측의 변형 인 복잡한 분기 예측은 더 나은 예측 정확도를 제공하지만 더 많은 전력을 소비하고 기하 급수적으로 기하 급수적으로 증가합니다.

  • 이 외에도 복잡한 예측 기법에서 분기를 예측하는 데 걸리는 시간은 2 ~ 5 사이클 범위로 매우 높아 실제 분기의 실행 시간과 비슷합니다.

  • 브랜치 예측은 기본적으로 가능한 최소 미스 레이트, 낮은 전력 소비 및 최소의 리소스로 낮은 복잡성을 달성하기 위해 강조되는 최적화 (최소화) 문제입니다.

실제로 세 가지 종류의 가지가 있습니다.

순방향 조건부 분기 -런타임 조건에 따라 PC (프로그램 카운터)가 명령 스트림에서 주소 순방향을 가리 키도록 변경됩니다.

뒤로 조건부 분기 -PC가 명령 스트림에서 뒤로 향하도록 변경되었습니다. 분기는 루프의 끝에서 테스트 할 때 루프를 다시 실행해야 할 때 프로그램 루프의 시작으로 뒤로 분기하는 것과 같은 일부 조건을 기반으로합니다.

무조건 분기 -여기에는 특정 조건이없는 점프, 프로 시저 호출 및 리턴이 포함됩니다. 예를 들어, 무조건 점프 명령은 어셈블리 언어에서 단순히 "jmp"로 코딩 될 수 있으며 명령 스트림은 점프 명령이 가리키는 대상 위치로 즉시 지정되어야하지만 "jmpne"으로 코딩 될 수있는 조건부 점프는 이전 "비교"명령어에서 두 값을 비교 한 결과 값이 같지 않은 경우에만 명령어 스트림을 리디렉션합니다. (x86 아키텍처에서 사용하는 세그먼트 화 된 주소 지정 체계는 점프가 "세그먼트 내에서"또는 "멀리"(세그먼트 외부에서)가 될 수 있기 때문에 복잡성을 추가합니다. 각 유형은 분기 예측 알고리즘에 서로 다른 영향을 미칩니다.

정적 / 동적 분기 예측 : 정적 분기 예측은 조건부 분기가 처음 발생할 때 마이크로 프로세서에 의해 사용되며 동적 분기 예측은 조건부 분기 코드의 후속 실행에 사용됩니다.

참고 문헌 :


145

분기 예측이 속도를 늦출 수 있다는 사실 외에도 정렬 된 배열에는 또 다른 장점이 있습니다.

값을 확인하는 대신 중지 조건을 가질 수 있습니다. 이렇게하면 관련 데이터 만 반복하고 나머지는 무시합니다.
분기 예측은 한 번만 누락됩니다.

 // sort backwards (higher values first), may be in some other part of the code
 std::sort(data, data + arraySize, std::greater<int>());

 for (unsigned c = 0; c < arraySize; ++c) {
       if (data[c] < 128) {
              break;
       }
       sum += data[c];               
 }

1
맞습니다. 그러나 배열을 정렬하는 설정 비용은 O (N log N)이므로 배열을 정렬하는 유일한 이유가 배열을 조기에 분리 할 수있는 경우에만 조기 분리가 도움이되지 않습니다. 그러나 배열을 사전 정렬해야하는 다른 이유가있는 경우에는 그렇습니다.
Luke Hutchison

데이터를 반복하는 횟수와 비교하여 데이터를 정렬하는 횟수에 따라 다릅니다. 이 예제의 정렬은 예제 일뿐입니다. 루프 바로 앞에있을 필요는 없습니다.
Yochai Timmer

2
그렇습니다. 정확히 내가 첫 번째 의견에서 한 요점입니다. 그러나 정렬 알고리즘 내부의 O (N log N) 분기 예측 누락을 계산하지 않습니다. 실제로 정렬되지 않은 경우 O (N) 분기 예측 누락보다 큽니다. 따라서 정렬 된 데이터에 따라 O (log N) 시간 전체를 사용하여 정렬 알고리즘에 따라 (아마도 실제로는 O (10 log N)에 더 가깝습니다) 캐시 캐시 누락으로 인한 빠른 정렬-병합 정렬 더 캐시 일관성 당신이 가까이 O에 필요하므로, (2 로그 N)는도 휴식 용도).
누가 복음 허치슨

그러나 피벗보다 작 거나 같은 모든 항목 이 피벗 후에 정렬 되었다고 가정 할 경우 대상 피벗 값이 127보다 작은 항목 만 정렬하는 "빠른 정렬의 절반"만 수행하는 것이 한 가지 중요한 최적화 입니다. 피벗에 도달하면 피벗 전에 요소를 합칩니다. 이것은 O (N log N)가 아닌 O (N) 시작 시간에 실행되지만, 여전히 많은 수의 분기 예측 누락이있을 수 있습니다. 퀵소트 반입니다.
Luke Hutchison

132

정렬 된 배열은 분기 예측이라는 현상으로 인해 정렬되지 않은 배열보다 빠르게 처리됩니다.

브랜치 예측기는 브랜치가 어떤 방식으로 진행될지를 예측하여 명령 파이프 라인의 흐름을 개선하려는 디지털 회로 (컴퓨터 아키텍처)입니다. 회로 / 컴퓨터는 다음 단계를 예측하고 실행합니다.

잘못된 예측은 이전 단계로 돌아가 다른 예측으로 실행됩니다. 예측이 정확하다고 가정하면 코드는 다음 단계로 계속 진행됩니다. 잘못된 예측은 올바른 예측이 발생할 때까지 동일한 단계를 반복합니다.

귀하의 질문에 대한 답변은 매우 간단합니다.

정렬되지 않은 배열에서 컴퓨터는 여러 번 예측하여 오류 발생 가능성을 높입니다. 반면, 정렬 된 배열에서는 컴퓨터가 예측을 적게하여 오류 가능성을 줄입니다. 더 많은 예측을하려면 더 많은 시간이 필요합니다.

정렬 된 배열 : 직선 도로 ____________________________________________________________________________________--------------------------------------------------------------------------TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT

정렬되지 않은 배열 : 곡선 도로

______   ________
|     |__|

지점 예측 : 어떤 도로가 직선인지 점검하고 예측하지 않고 따라 가기

___________________________________________ Straight road
 |_________________________________________|Longer road

두 도로가 동일한 목적지에 도달하지만 직선 도로는 더 짧고 다른 도로는 더 깁니다. 실수로 다른 사람을 선택하면 돌아갈 필요가 없으므로 더 긴 도로를 선택하면 시간이 더 소요됩니다. 이것은 컴퓨터에서 발생하는 것과 유사하며 이것이 더 잘 이해하는 데 도움이 되었기를 바랍니다.


또한 의견에서 @Simon_Weaver 를 인용하고 싶습니다 .

예측을 적게하지 않습니다. 틀린 예측을 줄입니다. 여전히 루프를 통해 매번 예측해야합니다 ...


123

다음 MATLAB 코드에 대해 MacBook Pro (Intel i7, 64 비트, 2.4GHz)에서 MATLAB 2011b와 동일한 코드를 시도했습니다.

% Processing time with Sorted data vs unsorted data
%==========================================================================
% Generate data
arraySize = 32768
sum = 0;
% Generate random integer data from range 0 to 255
data = randi(256, arraySize, 1);


%Sort the data
data1= sort(data); % data1= data  when no sorting done


%Start a stopwatch timer to measure the execution time
tic;

for i=1:100000

    for j=1:arraySize

        if data1(j)>=128
            sum=sum + data1(j);
        end
    end
end

toc;

ExeTimeWithSorting = toc - tic;

위의 MATLAB 코드의 결과는 다음과 같습니다.

  a: Elapsed time (without sorting) = 3479.880861 seconds.
  b: Elapsed time (with sorting ) = 2377.873098 seconds.

@GManNickG와 같은 C 코드의 결과는 다음과 같습니다.

  a: Elapsed time (without sorting) = 19.8761 sec.
  b: Elapsed time (with sorting ) = 7.37778 sec.

이를 바탕으로 MATLAB은 정렬하지 않고 C 구현보다 거의 175 배 느리고 정렬하면 350 배 느립니다. 즉, (브랜치 예측) 효과는 1.46x MATLAB 구현과 대 2.7 C 구현 대.


7
완벽을 기하기 위해 Matlab에서 구현 한 방식이 아닐 수도 있습니다. 문제를 벡터화 한 후에 완료하면 훨씬 빠를 것입니다.
ysap

1
Matlab은 많은 상황에서 자동 병렬화 / 벡터화를 수행하지만 여기서 문제는 분기 예측의 효과를 확인하는 것입니다. Matlab은 어쨌든 면역성이 없습니다!
Shan

1
(? 그래서 자리의 무한한 또는) MATLAB의 사용 네이티브 번호 또는 매트 랩 특정 구현합니까
Thorbjørn Ravn 안데르센

54

다른 답변에 따르면 데이터를 정렬해야한다고 가정하는 것이 올바르지 않습니다.

다음 코드는 전체 배열을 정렬하지 않고 200 요소 세그먼트 만 정렬하므로 가장 빠르게 실행됩니다.

k- 요소 섹션 만 정렬하면 전체 배열을 정렬하는 데 필요한 시간이 O(n)아닌 선형 시간으로 사전 처리가 완료 O(n.log(n))됩니다.

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    int data[32768]; const int l = sizeof data / sizeof data[0];

    for (unsigned c = 0; c < l; ++c)
        data[c] = std::rand() % 256;

    // sort 200-element segments, not the whole array
    for (unsigned c = 0; c + 200 <= l; c += 200)
        std::sort(&data[c], &data[c + 200]);

    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i) {
        for (unsigned c = 0; c < sizeof data / sizeof(int); ++c) {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

이것은 또한 정렬 순서와 같은 알고리즘 문제와 관련이 없으며 실제로 분기 예측이라는 것을 "증명"합니다.


4
이것이 어떻게 증명되는지 실제로 보지 못합니까? 당신이 보여준 유일한 것은 "전체 배열을 정렬하는 모든 작업을 수행하지 않으면 전체 배열을 정렬하는 것보다 시간이 덜 걸린다는 것"입니다. 이 "또한 가장 빠르게 실행된다"는 귀하의 주장은 아키텍처에 따라 다릅니다. 이것이 ARM에서 어떻게 작동하는지에 대한 내 대답을 참조하십시오. 추신 : 당신은 합산을 200 요소 블록 루프 안에 넣고 역순으로 정렬 한 다음 범위를 벗어난 값을 얻은 후에 Yochai Timmer의 제안을 사용하여 ARM이 아닌 아키텍처에서 코드를 더 빠르게 만들 수 있습니다. 이렇게하면 각 200 요소 블록 요약을 조기에 종료 할 수 있습니다.
Luke Hutchison

정렬되지 않은 데이터에 대해 알고리즘을 효율적으로 구현하려면 분기없이 (및 SIMD, 예를 들어 x86 pcmpgtb을 사용하여 높은 비트 세트를 가진 요소를 찾은 다음 AND를 작은 요소 0으로) 수행하십시오. 실제로 청크를 정렬하는 데 시간이 걸리면 느려집니다. 브랜치리스 버전은 데이터 독립적 인 성능을 가지게되며, 비용이 브랜치 잘못 예측 된 것임을 입증합니다. 또는 성능 카운터를 사용하여 Skylake와 같이 직접 관찰 int_misc.clear_resteer_cycles하거나 int_misc.recovery_cycles잘못된 예측에서 프런트 엔드 유휴주기를 계산하십시오.
Peter Cordes

위의 두 의견은 특수한 기계 명령으로 특수 하드웨어를 옹호하기 위해 일반적인 알고리즘 문제와 복잡성을 무시하는 것으로 보입니다. 나는 전문 기계 지시에 대한 맹목적인 호의 로이 답변에서 중요한 일반적인 통찰을 무시하고 있다는 점에서 특히 사소한 것을 발견했습니다.
user2297550

36

이 질문에 대한 Bjarne Stroustrup의 답변 :

인터뷰 질문처럼 들립니다. 사실인가요? 당신은 어떻게 알겠습니까? 먼저 일부 측정을 수행하지 않고 효율성에 대한 질문에 대답하는 것은 좋지 않으므로 측정 방법을 아는 것이 중요합니다.

그래서 나는 백만 개의 정수로 구성된 벡터로 시도하고 얻었습니다.

Already sorted    32995 milliseconds
Shuffled          125944 milliseconds

Already sorted    18610 milliseconds
Shuffled          133304 milliseconds

Already sorted    17942 milliseconds
Shuffled          107858 milliseconds

확실하게 몇 번 실행했습니다. 예, 현상은 실제입니다. 내 키 코드는 다음과 같습니다

void run(vector<int>& v, const string& label)
{
    auto t0 = system_clock::now();
    sort(v.begin(), v.end());
    auto t1 = system_clock::now();
    cout << label 
         << duration_cast<microseconds>(t1  t0).count() 
         << " milliseconds\n";
}

void tst()
{
    vector<int> v(1'000'000);
    iota(v.begin(), v.end(), 0);
    run(v, "already sorted ");
    std::shuffle(v.begin(), v.end(), std::mt19937{ std::random_device{}() });
    run(v, "shuffled    ");
}

적어도이 현상은이 컴파일러, 표준 라이브러리 및 옵티 마이저 설정에서 실제입니다. 다른 구현은 다른 답변을 줄 수 있습니다. 실제로 누군가가 좀 더 체계적인 연구를 수행하고 (빠른 웹 검색으로 찾아 낼 수 있음) 대부분의 구현에서 그 효과가 나타납니다.

한 가지 이유는 분기 예측입니다. 정렬 알고리즘의 주요 작업은 “if(v[i] < pivot]) …”동일합니다. 정렬 된 시퀀스의 테스트는 항상 참인 반면, 랜덤 시퀀스의 경우 선택한 분기는 임의로 변경됩니다.

또 다른 이유는 벡터가 이미 정렬되었을 때 요소를 올바른 위치로 옮길 필요가 없기 때문입니다. 이 작은 세부 사항의 효과는 우리가 본 5 또는 6의 요소입니다.

Quicksort (및 일반적으로 정렬)는 컴퓨터 과학의 가장 큰 마음을 끄는 복잡한 연구입니다. 좋은 정렬 기능은 좋은 알고리즘을 선택하고 구현시 하드웨어 성능에주의를 기울인 결과입니다.

효율적인 코드를 작성하려면 머신 아키텍처에 대해 약간 알아야합니다.


28

이 질문은 CPU의 분기 예측 모델을 기반으로합니다. 이 논문을 읽는 것이 좋습니다.

다중 브랜치 예측 및 브랜치 주소 캐시를 통한 명령 페치 비율 증가

요소를 정렬하면 IR이 모든 CPU 명령을 반복해서 페치하도록 귀찮게 할 수 없었으며, 캐시에서 요소를 페치합니다.


잘못된 예측과 상관없이 명령어는 CPU의 L1 명령어 캐시에서 뜨겁게 유지됩니다. 문제는 바로 이전 명령이 디코딩되고 실행이 완료되기 전에 파이프 라인 에 올바른 순서 로 가져 오는 것 입니다.
Peter Cordes

15

분기 예측 오류를 피하는 한 가지 방법은 조회 테이블을 작성하고 데이터를 사용하여 색인화하는 것입니다. Stefan de Bruijn은 그의 답변에서 이에 대해 논의했습니다.

그러나이 경우 값이 [0, 255] 범위에 있고 값> = 128에만 관심이 있다는 것을 알고 있습니다. 즉, 값을 원하는지 여부를 알려주는 단일 비트를 쉽게 추출 할 수 있습니다. 오른쪽 7 비트의 데이터는 0 비트 또는 1 비트로 남겨두고 1 비트가있을 때만 값을 추가하려고합니다. 이 비트를 "결정 비트"라고합니다.

결정 비트의 0/1 값을 배열의 인덱스로 사용하여 데이터 정렬 여부에 상관없이 동일하게 빠른 코드를 만들 수 있습니다. 코드는 항상 값을 추가하지만 결정 비트가 0이면 관심이없는 곳에 값을 추가합니다. 코드는 다음과 같습니다.

// 테스트

clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

이 코드는 추가의 절반을 낭비하지만 분기 예측 실패는 없습니다. 실제 if 문이있는 버전보다 임의의 데이터에서 훨씬 빠릅니다.

그러나 테스트에서 명시 룩업 테이블이 이것보다 약간 빠르 았습니다. 아마 룩업 테이블에 대한 인덱싱이 비트 이동보다 약간 빠르기 때문일 수 있습니다. 이것은 내 코드가 룩업 테이블 (코드에서 "LookUp Table"에 대해 상상할 수없는 lut)을 설정하고 사용하는 방법을 보여줍니다. C ++ 코드는 다음과 같습니다.

// 룩업 테이블을 선언하고 채운다

int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

이 경우 조회 테이블은 256 바이트에 불과하므로 캐시에 잘 맞고 모두 빠릅니다. 데이터가 24 비트 값이고 절반 만 원할 경우이 기술은 제대로 작동하지 않습니다. 조회 테이블이 너무 커서 실용적이지 않습니다. 다른 한편으로, 우리는 위에 표시된 두 가지 기술을 결합 할 수 있습니다. 먼저 비트를 이동 한 다음 조회 테이블을 인덱싱합니다. 상위 절반 값만 원하는 24 비트 값의 경우 데이터를 12 비트 오른쪽으로 이동하고 테이블 인덱스에 대해 12 비트 값을 남겨 둘 수 있습니다. 12 비트 테이블 인덱스는 4096 값의 테이블을 의미하며 이는 실용적 일 수 있습니다.

if 문을 사용하는 대신 배열로 인덱싱하는 기술을 사용하여 사용할 포인터를 결정할 수 있습니다. 바이너리 트리를 구현하는 라이브러리를 보았고 두 개의 명명 된 포인터 (pLeft 및 pRight 또는 기타) 대신 길이가 2 인 포인터 배열이 있고 "결정 비트"기술을 사용하여 어느 것을 따라야할지 결정했습니다. 예를 들어,

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;
this library would do something like:

i = (x < node->value);
node = node->link[i];

좋은 해결책 일 것입니다.


어떤 C ++ 컴파일러 / 하드웨어에서 어떤 컴파일러 옵션으로 테스트 했습니까? 원래 버전이 멋진 분기없는 SIMD 코드로 자동 벡터화되지 않은 것에 놀랐습니다. 완전 최적화를 활성화 했습니까?
Peter Cordes

4096 개의 엔트리 룩업 테이블이 미친 듯이 들립니다. 당신이 밖으로 이동하면 모든 비트를, 당신은 할 수 필요 원래 번호를 추가 할 경우 LUT 결과를 사용합니다. 이것들은 모두 분기없는 기술을 사용하지 않고 컴파일러에서 작동하는 바보 같은 트릭처럼 들립니다. 더 간단한 방법은 다음 mask = tmp < 128 : 0 : -1UL;과 같습니다.total += tmp & mask;
Peter Cordes
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.