연속되지 않은 어레이는 성능이 있습니까? 미만인 경우 백업 저장소로

C #에서 사용자가 List<byte>바이트를 만들고 바이트를 추가하면 공간이 부족하여 더 많은 공간을 할당해야 할 가능성이 있습니다. 이전 배열의 크기를 두 배 (또는 다른 승수)로 할당하고 바이트를 복사하고 이전 배열에 대한 참조를 버립니다. 각 할당이 비싸기 때문에 목록이 기하 급수적으로 증가한다는 것을 알고 있으며 매번 추가 항목을 O(log n)추가하면 할당 10이 발생하는 O(n)할당으로 제한됩니다.

그러나 큰 배열 크기의 경우 배열의 거의 절반에 해당하는 공간이 많이 낭비 될 수 있습니다. 메모리를 줄이기 위해 목록에 4MB 미만인 경우 백업 저장소로 NonContiguousArrayList사용 하는 비슷한 클래스 를 작성 했으며 크기가 커지면 List<byte>추가 4MB 바이트 배열을 할당 NonContiguousArrayList합니다.

List<byte>이 어레이 와 달리 연속적이지 않으므로 데이터를 복사하지 않고 추가 4M 할당 만하면됩니다. 아이템이 조회 될 때, 인덱스는 4M으로 나누어 아이템을 포함하는 어레이의 인덱스를 얻은 다음, 모듈로 4M은 어레이 내의 인덱스를 얻는다.

이 접근 방식의 문제점을 지적 할 수 있습니까? 내 목록은 다음과 같습니다.

  • 연속되지 않은 어레이에는 캐시 위치가 없으므로 성능이 저하됩니다. 그러나 4M 블록 크기에서는 우수한 캐싱을위한 충분한 로컬 성이있는 것 같습니다.
  • 항목에 액세스하는 것은 그리 간단하지 않습니다. 간접적 인 수준이 있습니다. 이것이 최적화됩니까? 캐시 문제가 발생합니까?
  • 4M 한계에 도달 한 후 선형 증가가 있기 때문에 일반적으로 할당량보다 더 많은 할당량을 가질 수 있습니다 (예 : 1GB 메모리에 최대 250 개의 할당량). 4M 이후에 추가 메모리가 복사되지 않지만 추가 할당이 많은 양의 메모리를 복사하는 것보다 비용이 많이 드는지 확실하지 않습니다.


답변

언급 한 척도에서 우려 사항은 언급 한 것과 완전히 다릅니다.

캐시 지역

  • 두 가지 관련 개념이 있습니다.
    1. 지역, 최근에 방문한 동일한 캐시 라인 (공간 지역)에서 데이터 재사용 (일시 지역)
    2. 자동 캐시 프리 페치 (스트리밍).
  • 언급 한 스케일 (100MB에서 기가 바이트, 4MB 청크)에서 두 가지 요소는 메모리 레이아웃보다 데이터 요소 액세스 패턴과 더 관련이 있습니다.
  • 필자의 예측은 통계적으로 거대한 연속 메모리 할당보다 성능 차이가 크지 않을 수 있다는 것입니다. 이득도없고 손실도 없습니다.

데이터 요소 액세스 패턴

  • 이 문서에서는 메모리 액세스 패턴이 성능에 미치는 영향을 시각적으로 보여줍니다.
  • 간단히 말해, 알고리즘이 메모리 대역폭에 의해 이미 병목 현상이 발생 하는 경우 성능을 향상시킬 수있는 유일한 방법은 이미 캐시에로드 된 데이터로보다 유용한 작업을 수행하는 것입니다.
  • 즉, 경우에도에서 YourList[k]YourList[k+1]연속되는 높은 확률을 (하나가 아닌 존재의 네 백만 기회에), 그 사실은 없습니다 도움 성능 완전히 무작위로, 또는 예를 들어 대형 예측할 수없는 진보의 목록을 액세스하는 경우while { index += random.Next(1024); DoStuff(YourList[index]); }

GC 시스템과의 상호 작용

  • 제 생각에는이 부분이 가장 집중해야 할 부분입니다.
  • 최소한 디자인이 어떻게 상호 작용하는지 이해하십시오.
  • 나는이 주제들에 대해 잘 모르기 때문에 다른 사람들이 기여하도록 할 것입니다.

주소 오프셋 계산 오버 헤드

  • 일반적인 C # 코드는 이미 많은 주소 오프셋 계산을 수행하고 있으므로 스키마의 추가 오버 헤드는 단일 배열에서 작동하는 일반적인 C # 코드보다 나쁘지 않습니다.
    • C # 코드는 배열 범위 검사도 수행합니다. 이 사실은 C #이 C ++ 코드를 사용하여 비슷한 배열 처리 성능에 도달하는 것을 방해하지 않습니다.
    • 그 이유는 성능이 대부분 메모리 대역폭으로 인해 병목 현상이 발생하기 때문입니다.
    • 메모리 대역폭에서 유틸리티를 최대화하는 요령은 메모리 읽기 / 쓰기 작업에 SIMD 명령을 사용하는 것입니다. 일반적인 C #이나 일반적인 C ++은이 작업을 수행하지 않습니다. 라이브러리 나 언어 애드온을 사용해야합니다.

이유를 설명하기 위해 :

  • 주소 계산
  • (OP의 경우 청크 기본 주소 (이미 캐시에 있음)를로드 한 다음 추가 주소 계산 수행)
  • 요소 주소에서 읽기 / 쓰기

마지막 단계는 여전히 사자의 시간을 차지합니다.

개인적인 제안

  • CopyRange함수처럼 Array.Copy작동하지만의 두 인스턴스 NonContiguousByteArray간에 또는 하나의 인스턴스와 다른 normal 사이에서 작동 하는 함수 를 제공 할 수 있습니다 byte[]. 이러한 함수는 SIMD 코드 (C ++ 또는 C #)를 사용하여 메모리 대역폭 활용을 극대화 한 다음 C # 코드는 다중 역 참조 또는 주소 계산의 오버 헤드없이 복사 된 범위에서 작동 할 수 있습니다.

사용성 및 상호 운용성 문제

  • 분명히 바이트 배열 또는 고정 가능한 바이트 배열을 기대NonContiguousByteArray 하는 C #, C ++ 또는 외국어 라이브러리 에서는 이것을 사용할 수 없습니다 .
  • 그러나 고유 한 C ++ 가속 라이브러리 (P / Invoke 또는 C ++ / CLI 사용)를 작성하는 경우 몇 개의 4MB 블록의 기본 주소 목록을 기본 코드로 전달할 수 있습니다.
    • 예를 들어에서 시작 (3 * 1024 * 1024)하고 끝나는 요소에 대한 액세스 권한을 부여해야하는 경우 (5 * 1024 * 1024 - 1)액세스가 chunk[0]및에 걸쳐 있음을 의미합니다 chunk[1]. 그런 다음 바이트 배열 (크기 4M)의 배열 (크기 2)을 구성하고 이러한 청크 주소를 고정한 다음 기본 코드로 전달할 수 있습니다.
  • 또 다른 유용성 관심사는 당신이 구현할 수없는 것입니다 IList<byte>: 효율적 인터페이스를 Insert하고 Remove그들이 필요로하기 때문에 바로 처리하는 데 시간이 너무 오래 걸릴 것입니다 O(N)시간.
    • 실제로, 당신은 이외의 것을 구현할 수없는 것처럼 보입니다 IEnumerable<byte>. 즉, 순차적으로 스캔 할 수 있습니다.

답변

C ++은 이미 Standard에 의해 동등한 구조를 가지고 있음을 주목할 가치가 std::deque있습니다. 현재 랜덤 액세스 시퀀스가 ​​필요한 경우 기본적으로 선택하는 것이 좋습니다.

실제로 데이터가 특정 크기를 초과하면 연속 메모리가 거의 필요하지 않습니다. 캐시 라인은 64 바이트에 불과하고 페이지 크기는 4-8KB에 불과합니다 (현재는 일반적인 값). 몇 MB에 대해 이야기하기 시작하면 실제로 창 밖으로 나옵니다. 할당 비용도 마찬가지입니다. 모든 데이터를 처리하는 비용은 단지 데이터를 읽는 것조차도 어쨌든 할당 가격을 떨어 뜨립니다.

걱정해야 할 유일한 이유는 C API와의 인터페이스 때문입니다. 그러나 어쨌든 List의 버퍼에 대한 포인터를 얻을 수 없으므로 걱정할 필요가 없습니다.


답변

데이터 청크 내의 하위 배열에서와 같이 메모리 청크가 다른 시점에 할당되면 메모리 청크가 서로 떨어져있을 수 있습니다. 이것이 문제인지 아닌지는 CPU에 달려 있으며 더 이상 예측하기가 매우 어렵습니다. 당신은 그것을 테스트해야합니다.

이것은 훌륭한 아이디어이며 과거에 사용한 적이 있습니다. 물론 하위 배열 크기에 2의 거듭 제곱과 나눗셈에 비트 이동 (최적화의 일부로 발생할 수 있음) 만 사용해야합니다. 컴파일러가 단일 배열 간접 지시를보다 쉽게 ​​최적화 할 수 있다는 점 에서이 유형의 구조가 약간 느리다는 것을 알았습니다. 이러한 유형의 최적화는 항상 변경되므로 테스트해야합니다.

주요 이점은 이러한 유형의 구조를 일관되게 사용하는 한 시스템의 메모리 상한에 더 근접 할 수 있다는 것입니다. 데이터 구조를 더 크게 만들고 가비지를 생성하지 않는 한 일반 List에 대해 발생할 수있는 추가 가비지 수집을 피하십시오. 거대한 목록의 경우 계속 실행과 메모리 부족 간의 차이가 크게 달라질 수 있습니다.

각 배열 할당에 메모리 오버 헤드가 있기 때문에 하위 배열 청크가 작은 경우에만 추가 할당이 문제가됩니다.

사전 (해시 테이블)에 대한 유사한 구조를 만들었습니다. .net 프레임 워크에서 제공하는 사전에는 List와 동일한 문제가 있습니다. 리 해싱도 피해야한다는 점에서 사전이 더 어렵습니다.


답변

4M 블록 크기에서는 단일 블록조차도 실제 메모리에서 연속적이라고 보장되지 않습니다. 일반적인 VM 페이지 크기보다 큽니다. 그 규모에서 의미가없는 지역.

힙 조각화에 대해 걱정해야 할 것입니다. 블록이 힙에서 거의 연속적이지 않은 할당이 발생하면 GC에서 회수 할 때 너무 조각화되어 힙에 맞지 않을 수 있습니다. 후속 할당. 관련이없는 곳에서 오류가 발생하여 응용 프로그램을 강제로 다시 시작할 수 있기 때문에 일반적으로 더 나쁜 상황입니다.


답변

설명 된 데이터 구조 유형을 중심으로 내 코드베이스 (ECS 엔진)의 가장 중심 부분 중 일부를 회전하지만 작은 연속 블록 (4 메가 바이트 대신 4 킬로바이트와 같은)을 사용합니다.

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

더블 프리리스트를 사용하여 삽입 준비가 된 프리 블록 (가득이없는 블록)에 대한 하나의 프리리스트와 해당 블록의 인덱스에 대한 블록 내의 서브 프리리스트로 일정한 시간 삽입 및 제거를 수행합니다. 삽입시 회수 할 수 있습니다.

이 구조의 장단점을 다룰 것입니다. 몇 가지 단점이 있기 때문에 몇 가지 단점부터 시작하겠습니다.

단점

  1. 이 구조 std::vector에 순전히 인접한 구조 보다 2 억 개의 요소를 삽입하는 데 약 4 배 더 오래 걸립니다 . 그리고 나는 마이크로 최적화에 꽤 익숙하지만 일반적으로 블록 프리 목록의 맨 위에있는 프리 블록을 검사 한 다음 블록에 액세스하여 블록의 무료 인덱스를 팝해야하기 때문에 개념적으로 더 많은 작업이 있습니다. 빈 목록, 빈 위치에 요소를 쓴 다음 블록이 가득 찼는 지 확인하고 블록 빈 목록에서 블록을 팝하십시오. 여전히 일정 시간 작업이지만로 돌아가는 것보다 훨씬 큰 상수를 사용 std::vector합니다.
  2. 인덱싱을위한 추가 산술 및 추가 간접 레이어가 제공되는 임의 액세스 패턴을 사용하여 요소에 액세스하는 경우 약 두 배가 걸립니다.
  3. 반복자는 증가 할 때마다 추가 분기를 수행해야하므로 순차 액세스는 반복자 설계에 효율적으로 맵핑되지 않습니다.
  4. 일반적으로 요소 당 약 1 비트의 약간의 메모리 오버 헤드가 있습니다. 요소 당 1 비트는별로 소리가 나지 않지만 백만 개의 16 비트 정수를 저장하기 위해 이것을 사용하는 경우 이는 완벽하게 컴팩트 한 배열보다 6.25 % 더 많은 메모리 사용입니다. 그러나 실제로 이것은 예약 된 초과 용량을 제거하기 위해 std::vector압축하지 않는 한 메모리보다 적은 메모리를 사용하는 경향이 있습니다 vector. 또한 나는 일반적으로 그런 조그마한 요소를 저장하는 데 사용하지 않습니다.

찬성

  1. for_each블록 내에서 요소의 콜백 처리 범위를 취하는 함수를 사용한 순차 액세스 std::vector는 10 % 차이와 같은 순차 액세스 속도와 거의 비슷 하므로 성능이 가장 중요한 사용 사례에서 효율성이 훨씬 낮습니다. ECS 엔진에 소비되는 대부분의 시간은 순차적 액세스에 있습니다.
  2. 블록이 완전히 비워지면 블록을 할당 해제하는 구조로 중간에서 일정한 시간을 제거 할 수 있습니다. 결과적으로 데이터 구조가 필요한 것보다 훨씬 더 많은 메모리를 사용하지 않는 것이 일반적으로 꽤 괜찮습니다.
  3. 컨테이너에서 직접 제거되지 않은 요소에 대한 인덱스는 무효화되지 않습니다. 이후 삽입시 자유 목록 접근 방식을 사용하여 구멍을 남기기 때문에 구멍을 남기기 때문입니다.
  4. 이 구조가 많은 수의 요소를 보유하고 있어도 메모리 부족에 대해 걱정할 필요가 없습니다. 소형 연속 블록 만 요청하므로 OS가 수많은 연속 사용되지 않는 것을 찾는 데 어려움을 겪지 않기 때문입니다. 페이지.
  5. 작업은 일반적으로 개별 블록에 국한되므로 전체 구조를 잠그지 않고 동시성 및 스레드 안전성에 적합합니다.

이제 나에게 가장 큰 장점 중 하나는 다음과 같이이 데이터 구조의 변경 불가능한 버전을 만드는 것이 쉽지 않다는 것입니다.

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

그 이후로 부작용이없는 더 많은 함수를 작성하기 위해 모든 종류의 문을 열었으므로 예외 안전성, 스레드 안전성 등을 훨씬 쉽게 달성 할 수 있습니다. 불변성은 내가 쉽게 달성 할 수있는 것으로 나타났습니다. 이 데이터 구조는 후시와 우연히 발생하지만 코드베이스를 훨씬 쉽게 유지 관리 할 수있게되면서 얻을 수있는 가장 큰 이점 중 하나 일 것입니다.

연속되지 않은 어레이에는 캐시 위치가 없으므로 성능이 저하됩니다. 그러나 4M 블록 크기에서는 우수한 캐싱을위한 충분한 로컬 성이있는 것 같습니다.

참조의 지역 성은 4 킬로바이트 블록은 물론 그 크기의 블록에서도 관심을 가질만한 것이 아닙니다. 캐시 라인은 일반적으로 64 바이트입니다. 캐시 누락을 줄이려면 해당 블록을 올바르게 맞추는 데 집중하고 가능한 경우보다 순차적 인 액세스 패턴을 선호하십시오.

랜덤 액세스 메모리 패턴을 순차적으로 변환하는 매우 빠른 방법은 비트 세트를 사용하는 것입니다. 당신이 인덱스의 보트로드를 가지고 있고 그것들이 무작위 순서라고 가정 해 봅시다. 그것들을 쟁기질하고 비트 세트에 비트를 표시 할 수 있습니다. 그런 다음 비트 세트를 반복하고 0이 아닌 바이트를 확인하여 한 번에 64 비트를 확인하십시오. 하나 이상의 비트가 설정된 64 비트 세트가 발견되면 FFS 명령어를 사용하여 설정된 비트를 신속하게 결정할 수 있습니다 . 비트는 인덱스를 순차적으로 정렬하는 것을 제외하고는 어떤 인덱스에 액세스해야하는지 알려줍니다.

이것은 약간의 오버 헤드가 있지만 경우에 따라 가치있는 교환이 될 수 있습니다. 특히이 지수를 여러 번 반복하려는 경우 특히 그렇습니다.

항목에 액세스하는 것은 그리 간단하지 않습니다. 간접적 인 수준이 있습니다. 이것이 최적화됩니까? 캐시 문제가 발생합니까?

아니요, 최적화 할 수 없습니다. 랜덤 액세스는 적어도이 구조에서 더 많은 비용이 듭니다. 특히 일반적인 실행 경로가 순차적 액세스 패턴을 사용하는 경우 블록에 대한 포인터 배열로 높은 시간적 지역성을 얻는 경향이 있기 때문에 캐시 누락을 크게 늘리지 않는 경우가 많습니다.

4M 한계에 도달 한 후 선형 증가가 있기 때문에 일반적으로 할당량보다 더 많은 할당량을 가질 수 있습니다 (예 : 1GB 메모리에 최대 250 개의 할당량). 4M 이후에 추가 메모리가 복사되지 않지만 추가 할당이 많은 양의 메모리를 복사하는 것보다 비용이 많이 드는지 확실하지 않습니다.

실제로는 복사 log(N)/log(2)횟수 가 드물기 때문에 종종 더 빠릅니다. 총 횟수 와 같은 일이 발생 하는 동시에 더러워 지기 쉬운 일반적인 경우를 단순화하는 동시에 배열이 가득 차기 전에 여러 번 요소를 쓸 수 있고 다시 할당 해야하는 경우가 많습니다. 따라서 일반적으로 이러한 유형의 구조를 사용하면 더 빠른 삽입을 얻지 못합니다. 거대 배열을 재 할당하는 값 비싼 희귀 사례를 처리 할 필요가없는 경우에도 일반적인 사례 작업이 더 비싸기 때문입니다.

모든 단점에도 불구 하고이 구조의 주요 매력은 메모리 사용이 줄어들고 OOM에 대해 걱정할 필요가 없으며 무효화되지 않은 동시성 및 불변성이 아닌 인덱스 및 포인터를 저장할 수 있다는 것입니다. 일정한 시간에 물건을 삽입하고 제거하면서 정리하고 포인터와 색인을 구조에 무효화하지 않는 데이터 구조를 갖는 것이 좋습니다.


답변