정렬 된 배열을 정렬되지 않은 배열보다 느리게 처리하는 이유는 무엇입니까?


233

Tuple<long,long,string>간단한 "사이"검색을 수행하는 무작위로 생성 된 500000 개의 개체 목록이 있습니다 .

var data = new List<Tuple<long,long,string>>(500000);
...
var cnt = data.Count(t => t.Item1 <= x && t.Item2 >= x);

무작위 배열을 생성하고 100 개의 무작위로 생성 된 값에 대한 검색을 실행 x하면 약 4 초 안에 검색이 완료됩니다. 그러나 정렬이 검색 하는 데있어 놀라운 점을 알고 있었지만, 100 개의 검색을 실행하기 전에 먼저 데이터를 정렬 Item1한 다음 Item2마지막으로 정렬하기로 결정했습니다 Item3. 분기 예측으로 인해 정렬 된 버전이 약간 더 빨리 수행 될 것으로 예상했습니다. 내 생각은 일단 우리가 Item1 == x더 이상 모든 검사가 t.Item1 <= x지점을 "취득 없음"으로 정확하게 예측하여 검색. 놀랍게도 정렬 된 배열에서 검색하는 데 두 배가 걸렸습니다 !

실험을 실행 한 순서를 바꾸고 난수 생성기에 다른 시드를 사용했지만 그 효과는 동일합니다. 정렬되지 않은 배열의 검색은 같은 배열의 검색보다 거의 두 배 빠르지 만 분류!

누구든지이 이상한 효과에 대해 잘 설명하고 있습니까? 내 테스트의 소스 코드는 다음과 같습니다. .NET 4.0을 사용하고 있습니다.


private const int TotalCount = 500000;
private const int TotalQueries = 100;
private static long NextLong(Random r) {
    var data = new byte[8];
    r.NextBytes(data);
    return BitConverter.ToInt64(data, 0);
}
private class TupleComparer : IComparer<Tuple<long,long,string>> {
    public int Compare(Tuple<long,long,string> x, Tuple<long,long,string> y) {
        var res = x.Item1.CompareTo(y.Item1);
        if (res != 0) return res;
        res = x.Item2.CompareTo(y.Item2);
        return (res != 0) ? res : String.CompareOrdinal(x.Item3, y.Item3);
    }
}
static void Test(bool doSort) {
    var data = new List<Tuple<long,long,string>>(TotalCount);
    var random = new Random(1000000007);
    var sw = new Stopwatch();
    sw.Start();
    for (var i = 0 ; i != TotalCount ; i++) {
        var a = NextLong(random);
        var b = NextLong(random);
        if (a > b) {
            var tmp = a;
            a = b;
            b = tmp;
        }
        var s = string.Format("{0}-{1}", a, b);
        data.Add(Tuple.Create(a, b, s));
    }
    sw.Stop();
    if (doSort) {
        data.Sort(new TupleComparer());
    }
    Console.WriteLine("Populated in {0}", sw.Elapsed);
    sw.Reset();
    var total = 0L;
    sw.Start();
    for (var i = 0 ; i != TotalQueries ; i++) {
        var x = NextLong(random);
        var cnt = data.Count(t => t.Item1 <= x && t.Item2 >= x);
        total += cnt;
    }
    sw.Stop();
    Console.WriteLine("Found {0} matches in {1} ({2})", total, sw.Elapsed, doSort ? "Sorted" : "Unsorted");
}
static void Main() {
    Test(false);
    Test(true);
    Test(false);
    Test(true);
}

Populated in 00:00:01.3176257
Found 15614281 matches in 00:00:04.2463478 (Unsorted)
Populated in 00:00:01.3345087
Found 15614281 matches in 00:00:08.5393730 (Sorted)
Populated in 00:00:01.3665681
Found 15614281 matches in 00:00:04.1796578 (Unsorted)
Populated in 00:00:01.3326378
Found 15614281 matches in 00:00:08.6027886 (Sorted)

15
때문에 분기 예측의 : P
네르 Gönül

8
@ jalf 나는 분기 예측 때문에 정렬 된 버전이 약간 더 빨리 수행 될 것으로 기대했습니다. 내 생각에 일단 Item1 == x모든 추가 검사가 t.Item1 <= x지점을 "취득 없음"으로 정확하게 예측하여 검색의 꼬리 부분을 빠르게 할 수 있다고 생각했습니다 . 분명히, 그 생각의 선은 가혹한 현실에 의해 잘못 입증되었습니다 :)
dasblinkenlight

1
@ChrisSinclair 좋은 관찰! 내 답변에 설명을 추가했습니다.
usr

39
이 질문은 기존 질문과 중복 되지 않습니다 . 하나를 닫으려고 투표하지 마십시오.
ThiefMaster

2
@ Sar009 전혀! 두 질문은 서로 다른 두 가지 시나리오를 고려하여 자연스럽게 다른 결과에 도달합니다.
dasblinkenlight

답변:


269

정렬되지 않은 목록을 사용하는 경우 모든 튜플은 메모리 순서 로 액세스됩니다 . RAM에 연속적으로 할당되었습니다. CPU는 다음 캐시 라인을 추론 적으로 요청할 수 있으므로 필요할 때 항상 존재하기 때문에 메모리에 순차적으로 액세스하는 것을 좋아합니다.

정렬 키가 무작위로 생성되므로 목록을 정렬 할 때 임의 순서 로 나열합니다 . 이것은 튜플 멤버에 대한 메모리 액세스가 예측할 수 없음을 의미합니다. CPU는 메모리를 프리 페치 할 수 없으며 튜플에 대한 거의 모든 액세스는 캐시 누락입니다.

이것은 GC 메모리 관리 의 특정 이점에 대한 좋은 예입니다. 함께 할당되고 함께 사용되는 데이터 구조는 매우 훌륭하게 수행됩니다. 그들은 큰 참조 지역을 가지고 있습니다 .

이 경우 캐시 미스의 페널티 가 저장된 브랜치 예측 페널티보다 큽니다 .

struct-tuple로 전환 해보 십시오. 튜플 멤버에 액세스하기 위해 런타임에 포인터 역 참조가 필요하지 않기 때문에 성능이 복원됩니다.

크리스 싱클레어 (Chris Sinclair)는 논평에서 "약 10,000 이하의 TotalCount의 경우 정렬 된 버전이 더 빠르게 수행된다 "고 언급했다. 작은 목록 이 CPU 캐시에 완전히 맞기 때문 입니다. 메모리 액세스는 예측할 수 없지만 대상은 항상 캐시에 있습니다. 캐시에서로드조차도 약간의주기가 걸리기 때문에 여전히 작은 페널티가 있다고 생각합니다. 그러나 CPU가 여러 개의 미해결로드를 저글링 하여 처리량을 증가시킬 있기 때문에 문제가되지 않는 것 같습니다 . CPU가 메모리 대기에 도달 할 때마다 명령 스트림에서 속도를 높여서 가능한 한 많은 메모리 작업을 대기시킵니다. 이 기술은 대기 시간을 숨기는 데 사용됩니다.

이러한 종류의 동작은 최신 CPU의 성능을 예측하기가 얼마나 어려운지를 보여줍니다. 순차 메모리에서 랜덤 메모리 액세스로 갈 때 우리가 단지 2 배 더 느리다 는 사실은 메모리 대기 시간을 숨기기 위해 커버 아래에서 진행되는 양을 알려줍니다. 메모리 액세스는 50-200주기 동안 CPU를 정지시킬 수 있습니다. 이 숫자를 고려하면 임의의 메모리 액세스를 도입 할 때 프로그램이 10 배 이상 느려질 것으로 예상 할 수 있습니다.


5
C / C ++에서 배우는 모든 것이 C #과 같은 언어에 그대로 적용되지 않는 좋은 이유!
user541686

37
new List<Tuple<long,long,string>>(500000)새 목록을 테스트하기 전에 정렬 된 데이터를 하나씩 수동으로 복사하여이 동작을 확인할 수 있습니다 . 이 시나리오에서 정렬 된 테스트는 정렬되지 않은 테스트만큼 빠르며이 답변의 추론과 일치합니다.
Bobson

3
훌륭합니다, 대단히 감사합니다! 나는 동등한 Tuple구조체를 만들었고 프로그램은 내가 예상 한대로 행동하기 시작했다. 정렬 버전은 조금 더 빠르다. 또한 분류되지 않은 버전은 두 배 빨라졌습니다! 따라서 숫자 struct는 정렬되지 않은 2 대 정렬되지 않은 1.9입니다.
dasblinkenlight

2
그렇다면 캐시 미스가 브랜치 오해보다 더 많은 것을 아프다고 추론 할 수 있습니까? 나는 그렇게 생각하고 항상 그렇게 생각했습니다. C ++에서는 std::vector거의 항상보다 성능이 우수합니다 std::list.
Nawaz

3
@Mehrdad : 아니요. C ++에서도 마찬가지입니다. C ++에서도 컴팩트 한 데이터 구조가 빠릅니다. C ++에서는 다른 언어와 마찬가지로 캐시 누락을 피하는 것이 중요합니다. std::vectorvs std::list는 좋은 예입니다.
Nawaz

4

LINQ는 목록이 정렬되어 있는지 여부를 모릅니다.

술어 매개 변수가있는 Count는 모든 IEnumerables의 확장 방법이므로 효율적인 임의 액세스로 컬렉션에서 실행되는지조차 알 수 없다고 생각합니다. 따라서 단순히 모든 요소를 ​​확인하고 Usr 은 왜 성능이 저하되었는지 설명했습니다.

정렬 된 배열 (예 : 이진 검색)의 성능 이점을 활용하려면 코딩을 조금 더해야합니다.


5
나는 당신이 그 질문을 잘못 이해했다고 생각한다. 물론 나는 그것을 희망하지 않았 Count거나 Where내 데이터가 정렬된다는 생각을 "어떻게"선택하고 평범한 "모든 것을 확인"검색하는 대신 이진 검색을 실행하려고했다. 내가 기대했던 것은 더 나은 분기 예측 (내 질문 안에있는 링크 참조)으로 인해 약간의 개선이 있었지만, 참조의 위치는 분기 예측보다 시간이 오래 걸립니다.
dasblinkenlight
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.