엔터티 시스템의 캐시 미스 및 유용성


18

최근에 저는 프레임 워크를위한 엔터티 시스템을 연구하고 구현해 왔습니다. 나는 내가 찾을 수있는 대부분의 기사, 레딧 및 질문을 읽은 것으로 생각하며, 지금까지 나는 그 아이디어를 충분히 이해하고 있다고 생각합니다.

그러나 전반적인 C ++ 동작, 엔터티 시스템을 구현하는 언어 및 사용성 문제에 대한 몇 가지 질문을 제기했습니다.

따라서 하나의 접근 방식은 엔티티에 직접 구성 요소 배열을 저장하는 것입니다. 데이터를 반복 할 때 캐시 위치를 망치기 때문에하지 않았습니다. 이로 인해 구성 요소 유형마다 하나의 배열을 사용하기로 결정했기 때문에 동일한 유형의 모든 구성 요소가 메모리에서 연속적이므로 빠른 반복을위한 최적의 솔루션이어야합니다.

그러나 실제 게임 플레이 구현에서 시스템에서 구성 요소 배열을 사용하여 구성 요소 배열을 반복하면 거의 항상 한 번에 두 개 이상의 구성 요소 유형으로 작업하고 있음을 알 수 있습니다. 예를 들어 렌더 시스템은 Transform과 Model 구성 요소를 함께 사용하여 실제로 렌더 호출을합니다. 내 질문은,이 경우 한 번에 하나의 연속 배열을 선형으로 반복하지 않기 때문에이 방법으로 구성 요소를 할당하여 성능 이점을 즉시 희생하고 있습니까? C ++에서 두 개의 서로 다른 연속 배열을 반복하고 각주기마다 두 데이터를 모두 사용할 때 문제가 있습니까?

내가 묻고 싶은 또 다른 것은 구성 요소 또는 엔티티에 대한 참조를 유지하는 방법입니다. 구성 요소가 메모리에 배치되는 방식의 본질 때문에 배열의 위치를 ​​쉽게 전환하거나 배열을 확장하기 위해 다시 할당 할 수 있기 때문에 축소되어 구성 요소 포인터 또는 핸들이 유효하지 않습니다. 프레임마다 변환 및 기타 구성 요소를 조작하고 싶거나 핸들 또는 포인터가 유효하지 않은 경우 프레임마다 조회하는 것이 매우 어려워서 이러한 경우를 처리하는 것이 좋습니다.


4
구성 요소를 연속 메모리에 넣는 것을 귀찮게하지 않고 각 구성 요소에 동적으로 메모리를 할당합니다. 연속 메모리는 구성 요소에 임의의 순서로 액세스 할 가능성이 있기 때문에 캐시 성능이 향상되지 않습니다.
JarkkoL

@Grimshaw 여기에 읽을 수있는 흥미로운 기사입니다 harmful.cat-v.org/software/OO_programming/_pdf/...
Raxvan

@JarkkoL -10 포인트. 친숙한 시스템 캐시를 구축하고 무작위로 액세스하면 성능이 저하됩니다 . 소리만으로 바보입니다. 그것의 포인트는 선형 방식으로 액세스합니다 . ECS의 기술 및 성능 향상은 선형 방식으로 액세스 된 C / S를 작성하는 것입니다.
wondra

@ 그림 쇼 캐시가 하나의 정수보다 큰 것을 잊지 마십시오. 몇 킬로바이트의 L1 캐시를 사용할 수 있고 다른 것들은 MB로 처리 할 수 ​​있습니다. 만약 괴물 같은 일을하지 않으면 한 번에 몇 개의 시스템에 액세스하는 것이 좋습니다.
wondra

2
@wondra 구성 요소에 대한 선형 액세스를 어떻게 보장 하시겠습니까? 렌더링 할 컴포넌트를 수집하고 엔티티가 카메라에서 내림차순으로 처리되도록한다고 가정하겠습니다. 이러한 개체의 렌더링 구성 요소는 메모리에서 선형으로 액세스되지 않습니다. 당신이하는 말은 이론 상으로는 좋은 일이지만 실제로는 효과가 없다고 생각하지만, 나를 잘못 증명하면 기쁩니다 (:
JarkkoL

답변:


13

첫째,이 경우 유스 케이스에 따라 너무 일찍 최적화한다고 말하지 않습니다. 어쨌든, 당신은 흥미로운 질문을했고, 이것에 대해 직접 경험해 보았을 때, 나는 무게를 ll 것입니다. 나는 내가 어떻게 일을했는지와 내가 찾은 것을 설명하려고 노력할 것입니다.

  • 각 엔티티에는 모든 유형을 나타낼 수있는 일반 컴포넌트 핸들의 벡터가 있습니다.
  • 각 구성 요소 핸들을 역 참조하여 원시 T * 포인터를 생성 할 수 있습니다. * 아래를 참조하십시오.
  • 각 구성 요소 유형에는 자체 메모리 풀, 연속 메모리 블록 (필자의 경우 고정 크기)이 있습니다.

아니요, 항상 구성 요소 풀을 통과하여 이상적이고 깨끗한 작업을 수행 할 수는 없습니다. 앞서 언급했듯이 구성 요소간에 피할 수없는 링크가 있습니다. 여기서 한 번에 엔티티를 처리해야합니다.

그러나 실제로 (내가 찾은 것처럼) 실제로 특정 구성 요소 유형에 대한 for 루프를 작성하고 CPU 캐시 라인을 잘 활용할 수있는 경우가 있습니다. 모르거나 더 알고 싶은 사람들은 https://en.wikipedia.org/wiki/Locality_of_reference를보십시오 . 같은 경우, 가능하면 구성 요소 크기를 CPU 캐시 라인 크기 이하로 유지하십시오. 내 줄 크기는 64 바이트이며 일반적이라고 생각합니다.

필자의 경우 시스템 구현 노력은 그만한 가치가있었습니다. 나는 눈에 띄는 성능 향상을 보았습니다 (물론 프로파일 됨). 좋은 아이디어인지 스스로 결정해야합니다. 1000 개 이상의 엔터티에서 본 최고의 성능 향상.

내가 묻고 싶은 또 다른 것은 구성 요소 또는 엔티티에 대한 참조를 유지하는 방법입니다. 구성 요소가 메모리에 배치되는 방식의 본질 때문에 배열의 위치를 ​​쉽게 전환하거나 배열을 확장하기 위해 다시 할당 할 수 있기 때문에 축소되어 구성 요소 포인터 또는 핸들이 유효하지 않습니다. 프레임마다 변환 및 기타 구성 요소를 조작하고 싶거나 핸들 또는 포인터가 유효하지 않은 경우 프레임마다 조회하는 것이 매우 어려워서 이러한 경우를 처리하는 것이 좋습니다.

또한이 문제를 개인적으로 해결했습니다. 나는 다음과 같은 시스템을 갖게되었습니다.

  • 각 구성 요소 핸들에는 풀 색인에 대한 참조가 있습니다.
  • 구성 요소가 풀에서 '삭제'또는 '제거'되면 해당 풀 내의 마지막 구성 요소가 (표준 적으로 std :: move를 사용하여) 현재 사용 가능한 위치로 이동하거나 마지막 구성 요소를 방금 삭제 한 경우에는 아무것도 이동하지 않습니다.
  • '스왑'이 발생하면 리스너에게 알리는 콜백이있어 구체적인 포인터 (예 : T *)를 업데이트 할 수 있습니다.

* 처리하고있는 엔터티 수가 많은 고 사용 코드의 특정 섹션에서 런타임에 구성 요소 핸들을 항상 역 참조하려고 시도하는 것이 성능 문제라는 것을 알았습니다. 이로 인해 이제 프로젝트의 성능 결정적인 부분에서 일부 원시 T 포인터를 유지하지만 가능한 경우 일반적인 구성 요소 핸들을 사용합니다. 콜백 시스템과 함께 위에서 언급 한대로 유효하게 유지합니다. 그렇게 할 필요는 없습니다.

무엇보다도 시도해보십시오. 실제 시나리오를 얻을 때까지 여기에서 말하는 모든 것은 일을 수행하는 한 가지 방법 일뿐입니다.

도움이 되나요? 불분명 한 것을 명확히하려고 노력할 것입니다. 또한 모든 수정 사항에 감사드립니다.


이것은 좋은 답변이었으며, 은색 총알은 아니지만 누군가 비슷한 디자인 아이디어를 가지고있는 것을 보는 것이 좋습니다. ES에서 구현 한 트릭도 일부 있으며 실제처럼 보입니다. 고마워요! 추가 아이디어가 나오면 언제든지 의견을 말하십시오.
그림 쇼

5

이것에 대답하려면 :

내 질문은,이 경우 한 번에 하나의 연속 배열을 선형으로 반복하지 않기 때문에이 방법으로 구성 요소를 할당하여 성능 이점을 즉시 희생하고 있습니까? C ++에서 두 개의 서로 다른 연속 배열을 반복하고 각주기마다 두 데이터를 모두 사용할 때 문제가 있습니까?

아니오 (적어도 반드시 그런 것은 아닙니다). 대부분의 경우 캐시 컨트롤러는 하나 이상의 연속 어레이에서 효율적으로 읽기를 처리 할 수 ​​있어야합니다. 중요한 부분은 가능하면 각 어레이에 선형으로 액세스하는 것입니다.

이를 입증하기 위해 작은 벤치 마크를 작성했습니다 (일반적인 벤치 마크 경고가 적용됨).

간단한 벡터 구조체로 시작 :

struct float3 { float x, y, z; };

두 개의 개별 배열의 각 요소를 합산하고 결과를 세 번째로 저장하는 루프는 소스 데이터가 단일 배열로 인터리브되고 결과가 세 번째로 저장된 버전과 정확히 동일하게 수행됩니다. 그러나 소스와 결과를 인터리브하면 성능이 약 2 배 떨어졌습니다.

데이터에 무작위로 액세스하면 성능이 10에서 20 사이로 떨어졌습니다.

타이밍 (10,000,000 요소)

선형 액세스

  • 별도의 배열 0.21s
  • 인터리브 된 소스 0.21s
  • 인터리브 된 소스 및 결과 0.48 초

랜덤 액세스 (uncomment random_shuffle)

  • 별도의 어레이
  • 인터리브 된 소스 4.43s
  • 인터리브 된 소스 및 결과 4.00

소스 (Visual Studio 2013으로 컴파일) :

#include <Windows.h>
#include <vector>
#include <algorithm>
#include <iostream>

struct float3 { float x, y, z; };

float3 operator+( float3 const &a, float3 const &b )
{
    return float3{ a.x + b.x, a.y + b.y, a.z + b.z };
}

struct Both { float3 a, b; };

struct All { float3 a, b, res; };


// A version without any indirection
void sum( float3 *a, float3 *b, float3 *res, int n )
{
    for( int i = 0; i < n; ++i )
        *res++ = *a++ + *b++;
}

void sum( float3 *a, float3 *b, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = a[*index] + b[*index];
}

void sum( Both *both, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = both[*index].a + both[*index].b;
}

void sum( All *all, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        all[*index].res = all[*index].a + all[*index].b;
}

class PerformanceTimer
{
public:
    PerformanceTimer() { QueryPerformanceCounter( &start ); }
    double time()
    {
        LARGE_INTEGER now, freq;
        QueryPerformanceCounter( &now );
        QueryPerformanceFrequency( &freq );
        return double( now.QuadPart - start.QuadPart ) / double( freq.QuadPart );
    }
private:
    LARGE_INTEGER start;
};

int main( int argc, char* argv[] )
{
    const int count = 10000000;

    std::vector< float3 > a( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > b( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > res( count );

    std::vector< All > all( count, All{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );
    std::vector< Both > both( count, Both{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );

    std::vector< int > index( count );
    int n = 0;
    std::generate( index.begin(), index.end(), [&]{ return n++; } );
    //std::random_shuffle( index.begin(), index.end() );

    PerformanceTimer timer;
    // uncomment version to test
    //sum( &a[0], &b[0], &res[0], &index[0], count );
    //sum( &both[0], &res[0], &index[0], count );
    //sum( &all[0], &index[0], count );
    std::cout << timer.time();
    return 0;
}

1
이것은 캐시 로컬 리티에 대한 의심의 여지가 많이 있습니다. 감사합니다!
그림 쇼

내가 안심하고 찾을 수있는 간단하면서도 흥미로운 답변 :)이 결과가 다른 항목 수 (예 : 10,000,000 대신 1000)에 어떻게 다른지 또는 더 많은 값 배열이있는 경우 (예 : 3의 합산 요소)에 관심이 있습니다. -5 별도의 배열 및 다른 별도의 배열에 값 저장).
Awesomania

2

짧은 답변 : 그런 다음 프로파일을 최적화하십시오.

긴 답변 :

그러나 실제 게임 플레이 구현에서 시스템에서 구성 요소 배열을 사용하여 구성 요소 배열을 반복하면 거의 항상 한 번에 두 개 이상의 구성 요소 유형으로 작업하고 있음을 알 수 있습니다.

C ++에서 두 개의 서로 다른 연속 배열을 반복하고 각주기마다 두 데이터를 모두 사용할 때 문제가 있습니까?

C ++은 모든 프로그래밍 언어에 적용되므로 캐시 누락에 대해 책임을지지 않습니다. 이것은 최신 CPU 아키텍처의 작동 방식과 관련이 있습니다.

귀하의 문제는 조기 최적화 라고 불리는 것에 대한 좋은 예일 수 있습니다 .

제 생각에는 프로그램 메모리 액세스 패턴을 보지 않고 캐시 위치에 너무 일찍 최적화했습니다. 그러나 더 큰 문제는 이런 종류의 참조 (locality of reference)가 정말로 필요 했습니까?

Agner 's Fog는 응용 프로그램을 프로파일 링하거나 병목 현상의 위치를 ​​확인하기 전에 최적화하지 말 것을 제안합니다. (이것은 그의 훌륭한 가이드에 언급되어 있습니다. 아래 링크)

비 순차 액세스로 큰 데이터 구조를 갖는 프로그램을 작성하고 캐시 경합을 방지하려는 경우 캐시가 구성되는 방법을 아는 것이 유용합니다. 보다 휴리스틱 지침에 만족하면이 섹션을 건너 뛸 수 있습니다.

불행히도 실제로 배열 당 하나의 구성 요소 유형을 할당하면 성능이 향상되고 실제로 캐시 누락이 발생하거나 캐시 경합이 발생할 수 있다고 가정했습니다.

그의 뛰어난 C ++ 최적화 가이드를 확실히보아야 합니다.

내가 묻고 싶었던 또 다른 것은 구성 요소가 메모리에 배치되는 방식의 본질이기 때문에 구성 요소 또는 엔티티에 대한 참조를 유지하는 방법입니다.

개인적으로 가장 많이 사용되는 구성 요소를 단일 메모리 블록에 함께 할당하여 "가까운"주소를 갖습니다. 예를 들어 배열은 다음과 같습니다.

[{ID0 Transform Model PhysicsComp }{ID10 Transform Model PhysicsComp }{ID2 Transform Model PhysicsComp }..] 그런 다음 성능이 "충분히"좋지 않은 경우 최적화를 시작하십시오.


제 질문은 아키텍처가 성능에 미칠 수있는 영향에 관한 것이 었습니다. 요점은 최적화하는 것이 아니라 사물을 내부적으로 정리하는 방법을 선택 하는 것이 었습니다. 내부에서 발생하는 방식에 관계없이 나중에 변경하려는 경우 게임 코드가 균질 한 방식으로 상호 작용하기를 원합니다. 데이터 저장 방법에 대한 추가 제안을 제공 할 수 있어도 귀하의 답변은 좋았습니다. 공감.
그림 쇼

내가 본 것으로부터 구성 요소를 저장하는 세 가지 주요 방법이 있습니다. 모두 엔티티 당 단일 배열로 결합되어 있고 개별 배열로 유형별로 결합되어 있으며 올바르게 이해하면 큰 배열에 다른 엔티티를 연속적으로 저장하는 것이 좋습니다. 엔티티별로 모든 구성 요소가 함께 있습니까?
그림 쇼

@ 그림 쇼 답변에서 언급했듯이 아키텍처는 일반적인 할당 패턴보다 더 나은 결과를 보장하지 않습니다. 실제로 애플리케이션의 액세스 패턴을 모르기 때문입니다. 이러한 최적화는 일반적으로 일부 연구 / 증거 후에 수행됩니다. 내 제안과 관련하여 관련 구성 요소를 동일한 메모리에 저장하고 다른 구성 요소를 다른 위치에 저장하십시오. 이것은 전혀 또는 전혀없는 중간 지점입니다. 그러나, 나는 여전히 많은 조건이 작용할 때 아키텍처가 결과에 어떤 영향을 미치는지 예측하기 어렵다고 가정합니다.
concept3d

downvoter 관심을 설명? 내 대답에 문제를 지적하십시오. 더 나은 답변을 제공하십시오.
concept3d

1

내 질문은,이 경우 한 번에 하나의 연속 배열을 선형으로 반복하지 않기 때문에이 방법으로 구성 요소를 할당하여 성능 이점을 즉시 희생하고 있습니까?

말하자면, "가로"가변 크기 블록에서 엔티티에 연결된 구성 요소를 인터리빙하는 것보다 구성 요소 유형별로 별도의 "수직"배열을 사용하면 전체적으로 캐시 미스가 줄어 듭니다.

그 이유는 먼저 "수직"표현이 적은 메모리를 사용하는 경향이 있기 때문입니다. 연속적으로 할당 된 동종 배열의 정렬에 대해 걱정할 필요가 없습니다. 메모리 풀에 동종이 아닌 유형을 할당하면 배열의 첫 번째 요소가 두 번째 요소와 크기 및 정렬 요구 사항이 완전히 다를 수 있으므로 정렬에 대해 걱정할 필요가 있습니다. 결과적으로 간단한 예와 같이 종종 패딩을 추가해야합니다.

// Assuming 8-bit chars and 64-bit doubles.
struct Foo
{
    // 1 byte
    char a;

    // 1 byte
    char b;
};

struct Bar
{
    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;
};

인터리브 Foo하고 Bar메모리에 바로 옆에 저장하고 싶다고 가정 해 봅시다 .

// Assuming 8-bit chars and 64-bit doubles.
struct FooBar
{
    // 1 byte
    char a;

    // 1 byte
    char b;

    // 6 bytes padding for 64-bit alignment of 'opacity'

    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;
};

이제 별도의 메모리 영역에 Foo 및 Bar를 저장하는 데 18 바이트를 사용하는 대신 퓨즈를 연결하는 데 24 바이트가 필요합니다. 주문을 바꾸더라도 중요하지 않습니다.

// Assuming 8-bit chars and 64-bit doubles.
struct BarFoo
{
    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;

    // 1 byte
    char a;

    // 1 byte
    char b;

    // 6 bytes padding for 64-bit alignment of 'opacity'
};

액세스 패턴을 크게 개선하지 않고 순차적 액세스 컨텍스트에서 더 많은 메모리를 사용하는 경우 일반적으로 더 많은 캐시 누락이 발생합니다. 또한 한 엔터티에서 다음 엔터티로, 그리고 크기가 커질 수 있도록하는 보폭은 하나의 엔터티에서 다음 엔터티로 가져 오기 위해 메모리에서 가변 크기로 도약해야합니다. 다시 관심이 있습니다.

따라서 컴포넌트 유형을 저장할 때 "수직"표현을 사용하는 것이 실제로 "수평"대안보다 최적 일 가능성이 높습니다. 즉, 세로 표시에서 캐시 누락 문제가 여기에 표시 될 수 있습니다.

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

화살표는 단순히 엔티티가 컴포넌트를 "소유"한다는 것을 나타냅니다. 우리는 둘 다 가진 엔티티의 모든 모션 및 렌더링 구성 요소에 액세스하려고하면 메모리의 모든 곳에서 뛰어 넘습니다. 이러한 종류의 산발적 액세스 패턴을 사용하면 모션 구성 요소에 액세스하기 위해 캐시 라인에 데이터를로드 한 다음 더 많은 구성 요소에 액세스하고 이전 데이터를 제거하고 다른 모션에 대해 이미 제거 된 동일한 메모리 영역을 다시로드 할 수 있습니다 구성 요소. 따라서 동일한 메모리 영역을 캐시 라인에 두 번 이상로드하여 구성 요소 목록을 반복하고 액세스하는 것은 매우 낭비입니다.

우리가 더 명확하게 볼 수 있도록 그 엉망을 조금 정리해 봅시다.

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

이런 종류의 시나리오가 발생하면 일반적으로 게임이 시작된 후, 많은 구성 요소와 엔터티가 추가 및 제거 된 후 오랜 시간이 걸립니다. 일반적으로 게임이 시작될 때 모든 엔티티와 관련 컴포넌트를 함께 추가 할 수 있으며,이 시점에서 우수한 공간적 위치를 갖는 매우 순차적 인 액세스 패턴이있을 수 있습니다. 그러나 많은 제거 및 삽입 후에는 위와 같은 혼란이 생길 ​​수 있습니다.

이러한 상황을 개선하는 가장 쉬운 방법은 구성 요소를 소유 한 엔티티 ID / 인덱스를 기준으로 구성 요소를 기수 정렬하는 것입니다. 그 시점에서 다음과 같은 것을 얻습니다.

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

이는 훨씬 캐시 친화적 인 액세스 패턴입니다. 시스템이 둘 다 있는 엔티티에만 관심이 있고 일부 엔티티에는 모션 컴포넌트 만 있고 일부는 렌더링 컴포넌트 만 있으므로 일부 렌더링 및 모션 컴포넌트를 건너 뛰어야한다는 것을 알 수 있기 때문에 완벽하지 않습니다. 하지만 적어도 일부 인접 구성 요소를 처리 할 수있게됩니다 (실제로는 일반적으로 모션 구성 요소가있는 시스템의 더 많은 요소가 렌더링 구성 요소를 갖는 것보다 더 관심있는 관련 구성 요소를 연결하기 때문에 일반적으로 아니).

가장 중요한 것은 일단 정렬이 끝나면 메모리 영역을 캐시 라인으로 데이터를로드 한 다음 단일 루프로 다시로드하지 않습니다.

그리고 이것은 매우 복잡한 디자인을 필요로하지 않으며, 때로는 선형 시간 기수 정렬 패스 만 필요합니다. 어쩌면 특정 구성 요소 유형에 대해 많은 구성 요소를 삽입하고 제거한 후에는 다음과 같이 표시 할 수 있습니다 정렬해야합니다. 합리적으로 구현 된 기수 정렬 (여기서 병렬화 할 수도 있음)은 다음과 같이 쿼드 코어 i7에서 약 6ms에 백만 개의 요소를 정렬 할 수 있습니다.

Sorting 1000000 elements 32 times...
mt_sort_int: {0.203000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_sort: {1.248000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_radix_sort: {0.202000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
std::sort: {1.810000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
qsort: {2.777000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]

위는 백만 개의 요소를 32 번 정렬하는 것입니다 ( memcpy정렬 전후의 결과 시간 포함 ). 그리고 대부분의 경우 실제로 백만 개 이상의 구성 요소를 정렬하지 않을 것이라고 가정하고 있으므로 눈에 띄는 프레임 속도 끊김을 유발하지 않으면 서 현재와 거기에서 쉽게 스니핑 할 수 있어야합니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.