[게임 프로그래밍 패턴] Chapter17 데이터 지역성

Jangmanbo·2023년 12월 5일
0

CPU 캐시를 최대한 활용할 수 있도록 데이터를 배치해 메모리 접근 속도를 높이자.


CPU의 성능은 시간이 갈수록 증가했지만,
CPU가 필요한 데이터를 RAM으로부터 버스를 통해 가져오는 시간은 그에 비해 빨라지지 못했다.

따라서 캐싱을 통해 이를 어느정도 해결했다.
그러나 여전히 캐시 미스가 발생하면 CPU는 데이터가 도착할 때까지 수백 사이클을 기다리게 된다.

같은 일을 하더라도 캐시 미스가 발생하는 횟수를 줄여보자

데이터 지역성 패턴

데이터를 처리하는 순서대로 연속된 메모리에 둘수록, 즉 데이터 지역성을 높일수록
캐시를 통해서 성능을 향상할 수 있다.

주의사항

추상화를 위한 인터페이스의 경우 포인터나 레퍼런스를 통해 객체에 접근하게 된다.
하지만 포인터는 메모리를 여기저기 찾아가야 하기 때문에 캐시 미스가 발생한다.

따라서 데이터 지역성과 추상화 사이의 절충이 필요하다.

데이터 지역성 패턴 예제

연속 배열

게임루프와 컴포넌트 패턴을 사용한 예제

while (!gameOver) {
    for (int i = 0; i < numEntities; ++i)
        entities[i]->ai()->update();
    
    for (int i = 0; i < numEntities; ++i)
        entities[i]->physics()->update();
        
    for (int i = 0; i < numEntities; ++i)
        entities[i]->render()->update();
}
  • 게임 개체가 배열에 포인터로 저장되어 배열 값에 접근할 때마다 포인터를 따라가기 때문에 캐시 미스
  • 게임 개체의 컴포넌트가 포인터기 때문에 다시 캐시 미스
  • 모든 개체의 모든 컴포넌트에 대해 같은 작업을 반복

-> 결국 컴포넌트 하나를 업데이트할 때마다 캐시미스가 발생할 가능성이 높다.

AIComponent* aiComponents = new AIComponent[MAX_ENTITIES];
PhysicsComponent* physicsComponents = new PhysicsComponent[MAX_ENTITIES];
RenderComponent* renderComponents = new RenderComponent[MAX_ENTITIES];

while (!gameOver) {
    for (int i = 0; i < numEntities; ++i)
        aiComponents[i].update();
    
    for (int i = 0; i < numEntities; ++i)
        physicsComponents[i].update();
        
    for (int i = 0; i < numEntities; ++i)
        renderComponents[i].update();
}
  • 배열에 컴포넌트 객체(포인터X)가 들어갔기 때문에, 바로 다음 객체에 접근할 수 있다.
  • 포인터 추적을 전부 제거하여 연속된 배열 세 개를 쭉 따라갈 수 있다.

-> 앞선 예제에 비해 업데이트 루프가 50배 빨라진다.

정렬된 데이터

void particlsSystem::update() {
	 for (int i = 0; i < numParticles_; ++i)
     	if (particles_[i].isActive()) {
            particles_[i].update();
        }
    }
}
  • 모든 파티클을 업데이트하지 않고, 활성화된 파티클만 업데이트한다.
  • 플래그 값을 캐시에 로딩하면서 나머지 파티클 데이터도 같이 캐시에 올린다.
  • 비활성 파티클이 많이 있을수록 캐시 미스가 많이 발생한다.

-> 활성 여부를 플래그로 검사하지 말고, 활성 파티클만 맨 앞으로 모아두자!

// 파티클 활성화: 맨 앞 비활성 파티클과 바꾸기
void ParticleSystem::activeParticle(int index) {
    assert(index >= numActive_);
    
    Particle temp = particles_[numActive_];
    particles_[numActive_] = particles_[index];
    particles_[index] = temp;
    
    numActive_++;
}

// 파티클 비활성화: 마지막 활성 파티클과 바꾸기
void ParticleSystem::deactiveParticle(int index) {
    assert(index < numActive_);
    
    numActive_--;
    
    article temp = particles_[numActive_];
    particles_[numActive_] = particles_[index];
    particles_[index] = temp;
}
  • 객체 복사가 필요하단 단점이 있으나, 캐시 미스가 발생하지 않을 것이다.
  • 플래그가 필요 없어 객체 크기가 줄어들고, 캐시 라인에 객체를 더 많이 넣을 수 있다.
  • 메모리 복사 비용이 포인터 추적 비용보다 낮을 수 있다. (※프로파일링 필요)

단, Particle 클래스가 자신의 활성 상태를 스스로 제어할 수 없고, 직접 activate과 같은 메서드를 호출할 수가 없다.
반드시 ParticleSystem을 거쳐야만 한다.

빈번한 코드와 한산한 코드 나누기

class AIComponent {
public:
    void udpate() { /* ... */ }
    
private:
    Animation* animation_;
    double energy_;
    Vector goalPos_;
    LootType drop_;
    int minDrops_;
    int maxDrops_;
    double chanceOfDrop_;
};
  • 컴포넌트 크기가 커져서 캐시 라인에 들어갈 컴포넌트 개수가 줄어든다.
  • 읽어야 할 전체 데이터 크기가 커, 캐시 미스가 자주 발생한다.
  • 드랍 아이템 정보는 업데이트에서는 쓰지 않음에도 매 프레임마다 캐시로 읽어와야 한다.

-> 이렇게 자주 사용하지 않는 데이터는 따로 모아두자.

class AIComponent {
public:
    // ...
    
private:
	// 빈번한 데이터
    Animation* animation_;
    double energy_;
    Vector goalPos_;
    // 한산한 데이터
    LootDrop* loot_;
};

class LootDrop {
private:
    friend class AIComponent;
    LootType drop_;
    int minDrops_;
    int maxDrops_;
    double chanceOfDrop_;
};

-> AI 컴포넌트를 매 프레임마다 순회할 때 실제로 사용할 데이터만 캐시에 올라간다.(LootDrop* 포인터 제외)

※어느 데이터가 자주 사용되는지를 나누기는 굉장히 애매하므로, 연습이 필요하다.

0개의 댓글