스택큐힙리스트

정렬된 배열을 처리하는 것이 정렬되지 않은 배열을 처리하는 것보다 빠른 이유는 무엇인가요? 본문

카테고리 없음

정렬된 배열을 처리하는 것이 정렬되지 않은 배열을 처리하는 것보다 빠른 이유는 무엇인가요?

스택큐힙리스트 2023. 2. 27. 22:57
반응형

다음은 매우 특이한 동작을 보여주는 C++ 코드입니다.

어떤 이유에서인지, (시간 제한 영역 이전에) 데이터를 정렬하면 신기하게도 기본 루프가 거의 6배 더 빨라집니다:

#include <알고리즘>
#include <ctime>
#include <iostream>

int main()
{
    // 데이터 생성
    const 부호 없는 arraySize = 32768;
    int data[arraySize];

    for (부호 없는 c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! 이렇게 하면 다음 루프가 더 빠르게 실행됩니다.
    std::sort(data, data + arraySize);

    // 테스트
    clock_t start = clock();
    long long sum = 0;
    for (부호 없는 i = 0; i < 100000; ++i)
    {
        for (부호 없는 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 << '\n';
    std::cout << "sum = " << sum << '\n';
}
std::sort(data, data + arraySize); 가 없으면 코드가 11.54초 만에 실행됩니다.
정렬된 데이터를 사용하면 코드가 1.93초 만에 실행됩니다.
(정렬 자체는 배열을 한 번 통과하는 것보다 더 많은 시간이 걸리므로, 알 수 없는 배열에 대해 이 값을 계산해야 하는 경우에는 실제로 이 작업을 수행할 가치가 없습니다.)

처음에는 언어나 컴파일러의 이상일 수 있다고 생각해서 Java를 사용해 보았습니다:

다음과 같이 자바를 사용해 보았습니다;
java.util.Random을 가져옵니다;

공용 클래스 Main
{
    
    {
        // 데이터 생성
        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;

        // !!! 이렇게 하면 다음 루프가 더 빠르게 실행됩니다.
        Arrays.sort(data);

        // 테스트
        long start = System.nanoTime();
        long sum = 0;
        for (int i = 0; i < 100000; ++i)
        {
            for (int c = 0; c < arraySize; ++c)
            { // 1차 루프.
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}
비슷하지만 덜 극단적인 결과입니다.

처음에는 정렬하면 데이터가 캐시에 저장된다고 생각했지만 배열이 방금 생성되었기 때문에 어리석은 생각입니다.

무슨 일이 벌어지고 있는 걸까요?
정렬된 배열을 처리하는 것이 정렬되지 않은 배열을 처리하는 것보다 빠른 이유는 무엇일까요?
코드가 몇 가지 독립적인 용어를 합산하고 있으므로 순서는 중요하지 않습니다.

다른/이후 컴파일러 및 옵션에서 동일한 효과를 내는 관련/추가 Q&A를 참조하세요:

정렬되지 않은 배열을 처리하는 속도가 최신 x86-64 clang에서 정렬된 배열을 처리하는 속도와 동일한 이유는 무엇인가요?
gcc 최적화 플래그 -O3은 -O2보다 코드를 느리게 만듭니다.

Translated with www.DeepL.com/Translator (free version)

 

또 다른 관찰 사항은 배열을 정렬할 필요 없이 128이라는 값으로 분할하기만 하면 된다는 것입니다. 정렬은 n*log(n)이지만 파티셔닝은 선형입니다. 기본적으로 피벗을 128로 선택한 상태에서 빠른 정렬 파티셔닝 단계를 한 번만 실행하면 됩니다. 안타깝게도 C++에는 값이 아닌 위치별로 파티셔닝하는 nth_element 함수만 있습니다. - 

 

여기 파티셔닝으로 충분하다는 것을 보여주는 실험이 있습니다 : 정렬되지 않았지만 파티션 된 배열을 임의의 내용으로 만듭니다. 시간을 측정합니다. 정렬합니다. 시간을 다시 측정합니다. 두 측정값은 기본적으로 구별할 수 없어야 합니다. (실험 2: 무작위 배열을 생성합니다. 시간을 측정합니다. 분할합니다. 시간을 다시 측정합니다. 정렬할 때와 동일한 속도 향상을 볼 수 있을 것입니다. 두 실험을 하나로 통합할 수도 있습니다.) - 

 

참고로 Apple M1에서는 코드가 정렬되지 않은 상태에서 17초, 정렬된 상태에서 7초 만에 실행되므로 분기 예측 페널티는 risc 아키텍처에서 그렇게 나쁘지 않습니다. - 

 

 컴파일러에 따라 다릅니다. 이 특정 테스트를 위해 분기 없는 asm을 만드는 경우(예: 왜 정렬되지 않은 배열을 처리하는 속도가 최신 x86-64 clang으로 정렬된 배열을 처리하는 속도와 동일한가? 에서처럼 SIMD를 사용한 벡터화의 일부로), 또는 스칼라 cmov(gcc 최적화 플래그 -O3은 -O2보다 코드를 느리게 만듭니다)만 사용하는 경우에는 정렬 여부가 중요하지 않습니다. 그러나 예측할 수 없는 분기는 계산만큼 간단하지 않을 때 여전히 매우 현실적인 문제이므로 이 질문을 삭제하는 것은 미친 짓입니다.  

 

하지만 공정하게 말하자면, 파티셔닝을 하려면 동일한 배열[i] > 128 비교를 기반으로 조건부 복사 또는 스왑이 필요하기 때문에 파티셔닝을 먼저 할 가치는 없습니다. (여러 번 계산할 필요가 없고 배열의 대부분을 파티셔닝하여 여전히 빠르면서 일부 추가 또는 수정 후 파티셔닝되지 않은 부분만 잘못 예측하려는 경우가 아니라면). 컴파일러가 이 작업을 수행하도록 할 수 있다면 SIMD로 벡터화하거나, 데이터를 예측할 수 없는 경우 최소한 분기 없는 스칼라를 사용하는 것이 이상적입니다. (링크는 이전 댓글을 참조하세요.) - - 

 

2021 년 후속 조치: 정렬되지 않은 배열을 처리하는 것이 최신 x86-64 clang에서 정렬 된 배열을 처리하는 것과 동일한 속도 인 이유는 무엇입니까? - 

 

네, 질문 하단에 제가 추가한 "관련/후속 Q&A" 섹션과 이전 댓글에 이미 링크되어 있습니다. 예를 들어 최근 클랑 자동 벡터화 관련 질문의 중복 질문이 이 질문에도 링크되어 있는 등 여러 사람이 여전히 놓치고 있기 때문에 더 눈에 잘 띄도록 해야 할 것 같습니다.  

 

아이러니하게도 릴리스 모드에서이 실행을 시도해 보셨습니까? 내 로컬 Core i7 노트북 디버그에서 귀하의 수치와 비슷합니다. 그러나 릴리스 모드에서는 0.7 초와 0.68 초입니다. 컴파일러가 당신을 위해 최적화했습니다. - 

 

 

 

철도 교차로에서:

철도 교차로를 보여주는 이미지 위키미디어 커먼즈를 통해 Mecanismo에 의해 작성되었습니다. CC-By-SA 3.0 라이선스에 따라 사용되었습니다.

이제 논의를 위해 장거리 통신이나 무선 통신이 발달하기 전인 1800년대라고 가정해 보겠습니다.

시각 장애인이 교차로에서 기차가 오는 소리를 들었습니다. 기차가 어느 방향으로 가야 하는지 전혀 알 수 없습니다. 기차를 멈추고 기관사에게 원하는 방향을 물어봅니다. 그런 다음 스위치를 적절하게 설정합니다.

기차는 무겁고 관성이 커서 시동을 걸고 속도를 늦추는 데 시간이 오래 걸립니다.

더 좋은 방법이 있을까요? 기차가 어느 방향으로 갈지 맞추는 거예요!

정답을 맞히면 계속 진행합니다.
잘못 맞히면 기장이 멈추고 후진하며 스위치를 넘기라고 소리칠 거예요. 그러면 다른 경로로 다시 출발할 수 있습니다.
매번 정답을 맞히면 기차가 멈출 필요가 없습니다.
너무 자주 틀리면 기차가 정차, 후진, 재출발하는 데 많은 시간을 소비하게 됩니다.

if 문을 생각해 보세요: 프로세서 수준에서는 분기 명령어입니다:

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

여러분은 프로세서이고 분기가 보입니다. 어느 방향으로 갈지 전혀 알 수 없습니다. 어떻게 해야 할까요? 실행을 중단하고 이전 명령이 완료될 때까지 기다립니다. 그런 다음 올바른 경로로 계속 진행합니다.

최신 프로세서는 복잡하고 긴 파이프라인을 가지고 있습니다. 즉, "예열"과 "속도 저하"에 시간이 오래 걸립니다.

더 좋은 방법이 있을까요? 가지가 어느 방향으로 갈지 맞추는 것입니다!

정답을 맞히면 계속 실행하면 됩니다.
틀렸다면 파이프라인을 플러시하고 해당 브랜치로 롤백해야 합니다. 그런 다음 다른 경로로 다시 시작할 수 있습니다.
매번 맞히면 실행을 멈출 필요가 없습니다.
너무 자주 틀리면 실행을 중단하고 롤백하고 다시 시작하는 데 많은 시간을 소비하게 됩니다.

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

기차가 후진하여 다른 길로 가야 하는 횟수를 최소화하기 위해 어떻게 전략적으로 추측할 수 있을까요? 과거의 역사를 살펴보면 됩니다! 기차가 99%의 시간 동안 왼쪽으로 갔다면 왼쪽을 추측합니다. 번갈아 가면 번갈아 가며 추측합니다. 세 번에 한 번씩 한 방향으로 가면 같은 방향으로 추측합니다.

즉, 패턴을 파악하고 그 패턴을 따라가려고 노력하는 것입니다. 이것이 분기 예측기가 작동하는 방식입니다.

대부분의 애플리케이션에는 잘 동작하는 분기가 있습니다. 따라서 최신 분기 예측기는 일반적으로 90% 이상의 적중률을 달성합니다. 그러나 인식할 수 있는 패턴이 없는 예측할 수 없는 분기에 직면하면 분기 예측기는 사실상 쓸모가 없습니다.

더 읽어보세요: Wikipedia의 "분기 예측기" 문서.

위에서 암시했듯이, 그 원인은 바로 이 if 문입니다:
if (data[c] >= 128)
    합계 += 데이터[c];
데이터가 0에서 255 사이에 고르게 분포되어 있음을 알 수 있습니다. 데이터가 정렬되면 대략 반복의 첫 절반 정도는 if 문에 들어가지 않습니다. 그 이후에는 모두 if 문에 들어갑니다.

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

빠른 시각화:

T = 취한 분기
N = 취하지 않은 분기

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

       = NNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTTT (예측하기 쉬움)
그러나 데이터가 완전히 무작위인 경우, 분기 예측자는 무작위 데이터를 예측할 수 없기 때문에 쓸모없게 됩니다. 따라서 아마도 약 50%의 오 예측이 있을 것입니다(무작위로 추측하는 것보다 나을 것이 없습니다).

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

       = TTNTTTTNTNNTTT ... (완전히 무작위 - 예측 불가능)
어떻게 할 수 있을까요?

컴파일러가 조건부 이동으로 브랜치를 최적화할 수 없는 경우, 성능을 위해 가독성을 희생할 의향이 있다면 몇 가지 해킹을 시도해 볼 수 있습니다.

Replace:

if (data[c] >= 128)
    합계 += 데이터[c];


int t = (data[c] - 128) >> 31;
sum += ~t & data[c];
이렇게 하면 분기가 제거되고 일부 비트 연산으로 대체됩니다.

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

벤치마크: 코어 i7 920 @ 3.5GHz

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

시나리오 시간(초)
분기 - 무작위 데이터 11.777
분기 - 정렬된 데이터 2.352
분기 없음 - 임의 데이터 2.564
분기 없음 - 정렬된 데이터 2.587
Java - NetBeans 7.1.1 JDK 7 - x64

시나리오 시간(초)
분기 - 임의 데이터 10.93293813
분기 - 정렬된 데이터 5.643797077
분기 없음 - 무작위 데이터 3.113581453

 

정렬된 배열을 처리하는 것이 정렬되지 않은 배열을 처리하는 것보다 빠른 이유는 검색 및 정렬 알고리즘의 효율성이 향상되기 때문입니다. 정렬된 배열을 사용하면 시간 복잡도가 O(log n)인 이진 검색 알고리즘을 사용할 수 있으며, 이는 시간 복잡도가 O(n)인 선형 검색 알고리즘보다 빠릅니다. 또한 병합 정렬 및 빠른 정렬과 같은 알고리즘을 사용하면 부분 정렬된 배열을 활용하여 불필요한 비교와 스왑을 피할 수 있으므로 정렬된 배열을 더 빠르게 정렬할 수 있습니다. 대규모 데이터 집합으로 작업할 때 성능이 향상되므로 정렬된 배열 처리는 코드 최적화에 필수적입니다. 따라서 정렬 배열 처리의 이점을 이해하면 코드의 성능을 최적화하려는 개발자에게 도움이 될 수 있습니다.

반응형
Comments