3.1 C++ 개념과 올바른 사용법
3.1.1.1 클래스와 객체
클래스와 인스턴스(객체)는 일대다 관계이다.
3.1.1.2 캡슐화
클래스를 사용하는 프로그래머의 입장에서는 정해진 인터페이스만 잘 이해하면 구현을 어떻게 하든 신경 쓰지 않아도 된다.
클래스를 만드는 프로그래머의 입장에서는 클래스 인스턴스가 항상 논리적으로 일관된 상태를 유지하게 보장할 수 있다. (클래스 외부에서는 정해진 인터페이스만 사용하고 내부의 데이터를 건드릴 수 없기 때문에 외부에 의한 간섭이 없다)
3.1.1.3 상속
상속은 두 클래스 사이에 is-a 관계를 만든다.
C++에서는 다중 상속을 잘 사용하지 않는다. 단일 상속만을 기본으로 하는 구조를 유지하되 간단하고 부모를 갖지 않는 클래스만 추가해 다중 상속을 사용하는 것이 정석이다. 이런 클래스를 믹스인 클래스라고 부른다.
3.1.1.4 다형성
서로 다른 타입의 객체들을 하나의 공통 인터페이스로 다룰 수 있는 기능이다.
3.1.1.5 합성과 집합
합성(composition) : 서로 영향을 주고받는 여러 객체들을 이용해 복잡한 일을 해결하는 것
ㄴ 클래스 간에 has-a 관계나 uses-a 관계를 형성한다.
ㄴ 다른 클래스의 인스턴스를 멤버로 포함하게 설계하는 방식이다.
3.1.1.6 디자인 패턴
싱글턴
반복자
추상화 팩토리
3.1.2 코딩 규칙
1. 인터페이스를 중시할 것 : 인터페이스(.h 파일)는 간결하고 단순하며 최소한의 것만 포함해야 한다.
이름을 잘 지을 것
2. 전역 네임스페이스를 깔끔하게 유지할 것 : 다른 라이브러리의 이름과 충돌하는 것을 막아야 한다.
3. 널리 알려진 C++ 사용법을 따를 것 : (Effective C++를 읽기)
3.1.3 C++11
3.1.3.1 auto
auto : 컴파일러가 오른쪽 값으로부터 타입을 추정한다.
auto f = 3.141592f;
3.1.3.2 nullptr
기존에는 NULL은 정수값 0과 동일해서 int 값과 비교가 가능했지만 nullptr은 비교가 불가능하다.
std::nullptr_t 타입의 인스턴스인 nullptr를 NULL 포인터로 사용한다.
3.1.3.3 영역 기반 for 루프
C-스타일 배열과 멤버가 아닌 begin()과 end()함수가 정의되는 다른 자료구조에 대해 반복 작업을 수행할 수 있다.
for (const auto& pair : myMap) { }
3.1.3.4 override와 final
C++11 이전에는 virtual 키워드 하나만 사용할 수 있었음
final : 파생 클래스에 의해 오버라이딩되지 않아야 하는 단말(leaf) 가상 함수를 구현할 때
override : 상속받은 가상 함수를 오버라이딩할 때
3.1.3.5 강한 타입의 enum
enum 클래스를 사용하여 스코프 문제와 enum 값이 int 변수 값과 비교될 수 있는 문제를 없앴다.
3.1.3.6 표준화된 스마트 포인터
std::unique_ptr : 가리키고 있는 객체에 대해 단독 소유권을 유지하기 원할 때 사용
std::shared_ptr : 단일 객체에 대해 다수의 포인터를 사용하고 싶을 때 (참조 횟수로 관리)
std::weak_ptr : shared_ptr처럼 작동하지만 객체를 가리키는 참조 횟수에는 기여하지 않는다.
3.1.3.7 람다
labmda는 익명 함수이다. 펑터(함수처럼 동작하는 클래스), 또는 std::function (함수 포인터로 사용)이 사용되는 모든 곳에 사용할 수 있다.
3.1.3.8 move 의미와 rvalue 레퍼런스
C나 C++에서 lvalue는 컴퓨터의 레지스터나 메모리에 있는 실제 저장 위치를 나타낸다. rvalue는 일시적인 자료 객체로서 논리적으로 존재하는 것이지 반드시 메모리를 차지하고 있을 필요는 없다.
이동 생성 연산자를 정의하면 move()함수를 사용하여 rvalue 복사를 할 수 있다.
lvalue 레퍼런스는 [자료형]& , rvalue 레퍼런스는 [자료형]&& 을 통해 선언된다.
어떤 함수에서 임시 객체를 생성하여 객체를 반환할 때 함수 외부에서 반환 값을 받는 부분의 경우는 깊은 복사가 필요 없는데 깊은 복사(복사 생성자에 의해)가 일어난다. 이동 생성자를 정의하면 객체를 새로 동적 할당 하지 않고 대입할 수 있다.
3.2 데이터, 코드와 메모리
3.2.1 수 표현
3.2.1.4 부동소수점 표현법
부동소수의 정확도는 절대값이 작을수록 높아진다. - 한전된 비트로 표현된 가수를 정수부와 소수부가 나눠 써야하기 때문이다.
0과 부동소수점으로 표현할 수 있는 0이 아닌 가장 작은 수(음수 제외)는 분명한 간격이 있다.
machine epsilon : 23비트 정확도(가수)를 지닌 IEEE-754 부동소수점(부호1, 지수8, 가수 23)에서 머신 엡실론은 2의 -23승이다.
머신 엡실론 보다 작은 수를 연산에 사용하면 잘려 나간다. 머신 엡실론을 1.0에 더한 결과는 1.0이다.
3.2.1.5 기본적인 데이터 타입
기본 타입
ㄴ int, char, short, long, double, bool
컴파일러에 따라 크기가 다른 타입
ㄴ int8, __int16, __int32, __int64
SIMD 타입
ㄴ 요즘의 CPU에는 벡터 프로세서, 혹은 벡터 유닛이라 불리는 산술 연산장치가 있다.
ㄴ 128비트 SIMD 레지스터에 부동소수 4개(32비트 4개) 를 넣어 쓰는 것이 흔하다. 행렬 곱셈이나 내적을 ALU보다 빠르게 처리할 수 있다.
각 마이크로프로세서마다 서로 다른 SIMD 명령어 셋을 구현하고 컴파일러마다 SIMD 변수를 선언하는 방법도 제 각각이다.
이식 가능한 크기 타입
컴파일러마다 서로 데이터 타입의 크기가 다르고 살짝 다른 문법을 사용하기 때문에 대부분의 게임 엔진은 스스로 내장 데이터 타입을 정의해서 이식 가능한 코드를 만든다.
C++11 표준라이브러리는 표준화된 크기의 정수 타입을 제공한다. 이것들은 <cstdint>헤더에 선언되어 있다.
std::int8_t, std::int16_t, std::int32_t, std::int64_t를 사용할 수 있다.
3.2.1.6 멀티바이트 데이터와 엔디언
1바이트보다 큰 값을 멀티바이트 값이라고 한다.
little-endian : 데이터의 하위 바이트가 낮은 메모리 주소에 저장되는 방식
big-endian : 데이터의 상위 바이트가 낮은 메모리 주소에 저장되는 방식
개발할때의 프로세서가 little-endian 방식인데 게임 실행은 big-endian에서 하는 경우 문제가 될 수 있다.
해결 방법
1. 모든 데이터 파일을 텍스트 형태(10진수)로 저장하고 숫자 하나당 바이트 하나씩 사용하여 저장
2. 툴에서 디스크에 저장하기 직전에 엔디언을 바꾸게 하는 방법
정수의 엔디언 바꾸기
ㄴ MSB에서 시작해서 LSB와 값을 1바이트 단위로 바꾼다. 가운데에 도달할때 까지 반복하면 된다.
부동소수의 엔디언 바꾸기
ㄴ 정수인 것처럼 생각하고 바꾸면 된다. 유니온을 사용하면 쉽다.
3.2.2 선언, 정의, 연결성
3.2.2.1 번역 단위 다시 살펴보기
cpp 파일 한 개(번역 단위)를 번역하면 목적 파일 한 개가 생긴다.
목적 파일에는 정의된 함수를 번역한 기계어, 전역 변수와 정적 변수, 다른 .cpp 파일에서 정의된 함수를 가리키는 미확정 참조를 담고 있다.
목적 파일들을 모두 모아서 완성된 실행 파일로 만드는 것은 링커의 몫이다. 이 과정에서 링커는 모든 목적 파일을 읽어 미확정 상태인 외부 참조가 진짜 어떤 것인지 알아내려고 시도한다.
링커의 주된 역할은 외부 참조를 해결하는 일인데, 이와 관련해 링커가 낼 수 있는 에러는 두 가지이다.
1. extern으로 선언된 외부 참조를 찾아낼 수 없는 경우, 미확정 심볼 (unresolved-symbol) 에러
2. extern으로 선언된 외부 참조를 두 개 이상 발견한 경우, 중복 정의된 심볼 (multiply-defined symbol) 에러
3.2.2.2 선언과 정의의 차이 (+ 인라인 함수)
선언
ㄴ 데이터 객체나 함수의 형태를 나타낸다. 이름과 데이터 타입 혹은 함수의 서명(리턴 타입과 인자 타입)을 알려준다.
정의
ㄴ 프로그램 안에 고유한 저장 공간을 나타낸다.
선언으로 함수를 표현하는 경우 다른 번역 단위에서 참조하거나 같은 번역 단위에서 사용할 수 있다. 함수 서명 후에 세미콜론을 붙이면 선언이 되는데 extern 키워드를 붙여도 되고 붙이지 않아도 된다. (안붙이면 붙어있는 것으로 처리됨)
다른 번역 단위에서 정의된 전역 변수를 사용할 때는 현재 번역 단위에서 extern 키워드를 앞에 붙여 선언하면 된다.
헤더 파일에 정의를 두지 않는 이유
ㄴ 여러 .cpp 파일에서 #include 구문으로 정의를 불러들이면 중복 정의된 심볼 에러가 발생하기 때문에
ㄴ 인라인 함수의 정의는 예외이다.
컴파일러가 인라인 함수를 처리하려면 컴파일러가 함수 구현을 볼 수 있어야 한다.
ㄴ 헤더 파일에 inline 키워드를 붙인 함수를 정의한다.
ㄴ 인라인 키워드는 컴파일러에게 주는 힌트일 뿐이다. 인라인 할지 안할지는 컴파일러가 함수 크기와 얻을 수 있는 효율성을 분석해 정한다.
ㄴ 인라인 함수를 호출하는 부분은 함수 자체의 내용 복사본으로 대체되어 함수 호출 오버헤드가 제거된다.
3.2.2.3 연결성
C/C++의 모든 정의마다 연결성(linkage)이라는 속성이 있다.
기본적으로 모든 정의는 외부 연결성(external linkage)이다.
static 키워드는 정의를 내부 연결성으로 바꿀 때 사용한다.
ㄴ static 함수를 예로 들면 다른 번역 단위에 동일한 이름의 함수가 있어도 중복 정의된 심볼 에러를 내지 않고 자신의 번역 단위에 있는 static 함수를 사용한다.
3.2.3 C/C++ 프로그램의 메모리 구조
3.2.3.1 실행 파일 이미지 (= 프로그램 코드, 데이터)
1. 텍스트 세그먼트 = 코드 세그먼트 = 기계어
2. 데이터 세그먼트 = 전역 변수, 정적 변수
3. 읽기 전용 데이터 세그먼트 = const로 선언된 전역 변수, 인스턴스
3.2.3.2 프로그램 스택
실행 파일이 메모리에 로드될 때 운영체제는 프로그램 스택이라는 메모리 공간을 마련한다.
함수가 불릴 때마다 스택 프레임이라는 것을 프로그램 스택에 push하고 리턴할 때마다 pop한다.
스택 프레임에는 세 가지 종류의 데이터가 저장된다.
1. 리턴 주소
ㄴ 함수 리턴 후 PC레지스터가 가리켜야 할 주소(함수를 호출했던 곳)
2. CPU 레지스터
ㄴ 함수가 호출되기 이전의 상태로 돌아가기 위해 이전 레지스터들의 값들을 저장하고 있어야 한다.
3. 지역 변수
ㄴ 호출된 함수의 지역 변수들은 해당 함수 스택 프레임에 존재한다.
3.2.3.3. 동적 할당 힙
전역 변수, 정적 변수, 지역 변수 등은 정적(static)으로 정의되는 저장 공간이어서 컴파일할 때 저장 공간을 알 수 있지만, 프로그램이 메모리를 얼마나 사용할지 컴파일할 때 알 수 없는 경우 프로그램은 추가로 더 필요한 메모리를 동적으로 할당해야 한다.
C++에서는 new delete 연산자를 사용한다.
3.2.4 멤버 변수
클래스나 구조체의 선언만으로는 어떠한 저장 공간도 할당하지 않는다. (멤버 변수들을 위한 저장 공간은 없다)
3.2.4.1 클래스 정적 멤버
클래스 정적 변수는 자동으로 클래스의 네임스페이스에 포함되기 때문에 클래스 밖에서 지칭할 때는 항상 클래스 이름을 사용해야 한다. ([클래스 이름]::[변수 이름])
3.2.5 메모리상의 객체 구조
3.2.5.1 메모리 정렬과 패킹
요즘 나오는 CPU 상당수가 메모리 정렬이 제대로 지켜진 데이터 블록만 읽고 쓸 수 있다.
메모리 정렬 조건 : 객체가 저장된 메모리 주소가 객체의 크기의 배수여야 한다.
구조체와 클래스의 멤버의 정렬 조건 중 가장 큰 정렬 조건을 가지는 멤버(크기가 가장 큰 멤버)의 정렬 조건이 곧 구조체의 정렬 조건이 된다.
구조체 내의 데이터 멤버를 어느 순서로 선언하느냐에 따라 메모리 효율성이 달라질 수 있다.
구조체의 멤버로 패딩을 넣는 것은 구조체가 배열로 사용되었을 경우에도 메모리 정렬을 보장하기 위함이다.
3.2.5.2 C++ 클래스의 메모리 구조
컴파일러는 베이스 클래스나 클래스에 가상함수가 있는 경우 가상함수 테이블을 만들고 테이블을 가르키는 4바이트 포인터(64비트 환경에서는 8바이트) 를 클래스 인스턴스 마다 둔다.
class A {
int x;
int y;
virtual void Draw() {
}
}
class B : public A {
int x;
int y;
virtual void Draw() {
}
}
B 클래스 인스턴스의 메모리 구조
3.2.6 킬로바이트와 키비바이트
킬로바이트 = 1000 바이트
키비바이트 (KiB) = 1024 바이트
3.3 에러 감지와 처리
사용자 에러와 프로그래머 에러로 구분할 수 있다.
3.3.2 에러 처리
1. 사용자가 게임 플레이어인 경우
ㄴ 게임 로직의 에러는 게임의 내용에 맞게 처리되는 것이 자연스럽다. (게임의 규칙을 따르게 한다.)
2. 사용자가 아티스트나 애니메이터, 게임 디자이너 등 게임을 만드는 다른 사람일 경우
ㄴ 잘못된 게임 자원을 사용할 경우 발생한다.
ㄴ 에러가 발생하더라도 다른 자원으로 대체하여 계속 진행할 수 있도록 하는 유연함이 필요하다.
3. 프로그래머 에러 처리
ㄴ 가장 좋은 방법은 에러 감지 코드를 소스코드 곳곳에 넣고 감지가 되면 시스템을 멈춰버리는 assertion 시스템을 활용하는 것
3.3.3 에러 감지와 에러 처리 구현
1. 에러 리턴 코드
ㄴ 함수의 리턴 값으로 불가능한 값을 리턴하여 에러를 처리하는 방법
ㄴ 에러를 감지한 함수가 그 에러를 다룰 수 있는 함수와 연관이 없는 경우 에러 코드를 에러를 다룰 수 있는 상위 함수에 도달할 때까지 전달해야 한다.
2. 예외 처리
ㄴ 에러가 발생했을 때 어떤 함수가 처리할 것인지 신경쓰지 않고 에러를 전달할 수 있다.
ㄴ 프로그래머가 정의한 예외 객체에 연관 정보를 넣고 콜 스택을 펼쳐(펼치는 과정에서 지역 변수들은 소멸된다.) try-catch 블록에 도달할 때까지 반복되며 찾으면 catch 블록 안의 코드가 실행된다.
ㄴ 별로 안좋다고 한다.
3. assertion
ㄴ C++ 에서는 cassert 헤더 파일을 include하고 assert(표현식) 의 형태로 사용한다.
ㄴ assertion은 #define 매크로로 구현되기 때문에 #define 구문만 바꿔주면 assertion을 없어지게 할 수도 있다.
ㄴ assertion이 실패할 경우 항상 프로그램 실행을 멈춰야 한다.
ㄴ 치명적인 에러를 잡는데만 써야 한다.
ㄴ 사용자 에러를 찾는데 써서는 안된다.
3.4 파이프라인, 캐쉬 그리고 최적화
여러가지 성능 최적화 중 여기에서는 소프트웨어가 계산을 최대한 빠르게 수행하도록 만드는 방법에 대해서 논한다.
3.4.1 병렬처리 패러다임의 변화
과거에는 CPU가 상대적으로 느렸기 때문에 프로그래머는 명령에 의해 소비되는 사이클 수를 줄이도록 노력했지만
현대에는 다수 CPU 코어를 이용하도록(병렬성을 이용하도록) 소프트웨어가 작성되어야 한다.
현대의 CPU는 메모리 접근 비용이 CPU 연산 비용보다 상대적으로 비싸졌기 때문에 프로그래머는 CPU에 더 많은 일을 하도록 설계한다.
3.4.2 메모리 캐시
캐시는 메인 RAM보다 훨씬 더 빠르게 CPU에서 읽고 쓸 수 있다.
3.4.2.1 캐시 라인
메인 RAM이 256MiB, 캐시라인이 각각 128바이트고 캐시의 크기가 32 KiB인 경우 (256개의 캐시 라인)
캐시는 캐시 라인 크기의 배수에 정렬되는 메모리 주소만 처리할 수 있다.
메인 RAM의 어떤 주소에 들어있는 데이터가 캐시에 들어 있는 경우 (메인 RAM의 주소 % 캐시 크기(32KiB)) 값을 캐시 주소로 사용하여 캐시에 접근하면 데이터를 얻을 수 있다.
캐시는 캐시 라인 크기의 배수에 정렬되는 메모리 주소만 처리할 수 있다.
TLB를 모두 탐색했을 때 일치하는 블록 인덱스(위의 그림에서 0 ~ 8191)가 있다면 캐시 주소로 캐시에 접근해 데이터를 얻고 없으면 page table을 통해 ram에 접근을 한다.
TLB는 context switching때 초기화된다.
3.4.2.2 명령어 캐시와 자료 캐시
명령어 캐시 (I-캐시) : 수행되기 전에 실행 기계 코드를 미리 적재하는 것
자료 캐시 (D-캐시): 메인 RAM에서 자료를 읽거나 쓰는 작업의 속도를 높이기 위해 자료를 적재하는 것
보편적으로 L1 캐시에서는 두 종류의 캐시를 따로 존재한다.
3.4.2.3 집합 연합과 대체 정책
각각의 메인 메모리 주소가 두 개 이상의 캐시 라인으로 사상될 수 있다면 더 좋은 성능을 낼 수 있다. (N way 집합 연합 캐시를 사용하는 경우)
위 그림(직접 사상 캐시 : 1 way set associateive 캐시)에서는 8192개의 개별 캐시 라인 크기(128byte) 블록이 하나의 캐시 라인으로 사상된다.
캐시 미스가 발생하면 여러 개의 캐시 경로 중 어떤 것을 내쫓을 것인지 정하는 방법을 대체 정책이라고 한다. 보통 가장 오래된 자료를 내쫓는다.
3.4.2.4 쓰기 정책
1. 직접 쓰기 캐시 : 캐시에 쓰면 RAM에 즉시 적용됨
2. 뒤에 쓰기 캐시 : 자료가 먼저 캐시에 기록된다. 캐시 미스로 인해 새로운 캐시 라인을 읽어들이기 위해 내쫓기는 경우, 프로그램이 명시적으로 캐시 비움을 요청하는 경우 RAM에 캐시 라인이 전달된다. (= RAM에 캐시 라인을 복사한다.)
3.4.2.5 다층 캐시
캐시의 크기가 커지면 적중률이 높아져 프로그램이 수행을 더 잘한다. 하지만 캐시의 크기가 커지면 CPU에 가깝게 배치될 수 없어서 더 작은 캐시보다 느려진다.
대부분의 게임 콘솔은 두 개 층의 캐시를 채택한다. 레벨 1 캐시에서 자료 찾기를 시도하고 없으면 지연 시간이 더 긴 레벨 2 캐시에서 시도한다. 자료가 L2 캐시에도 없다면 RAM의 접근 비용을 지불해야 한다. (지연 시간이 매우 크다..)
3.4.2.6 캐시 일관성: MESI와 MOESI
다수의 CPU 코어가 한 개의 메인 메모리 공간을 공유할 때 문제가 생길 수 있다.
각각의 코어가 자신만의 L1 캐시를 갖는 것은 보통이지만, 다수의 코어가 L2 캐시와 메인 RAM은 공유하게 되기 때문이다.시스템을 캐시 일관성을 유지하는 것이 중요하다. 캐시들에 있는 자료가 서로 일치하고 메인 RAM에 있는 자료와도 일치되도록 유지해야 한다.
보통 사용되는 캐시 일관성 프로토콜은 MESI와 MOESI가 있다.
3.4.2.7 캐시 미스 피하기
캐시 미스를 완전히 피하는 것은 불가능하지만 프로그래머는 캐시 미스가 최대한 적게 발생하도록 노력할 수 있다.
D-캐시 미스를 피하는 좋은 방법 : 자료를 가능한 한 제일 작은 연속된 블록으로 조직하고 순차적으로 접근하는 것이다.
예를 들어 객체의 리스트를 순회해야 한다면, 객체는 포인터로 만든 연결리스트가 아닌 배열로 구현(연속된 블록)하고, 객체의 크기는 최대한 줄이고(한정된 캐시의 크기에 최대한 많은 수의 객체를 넣어야 하므로), 배열의 0번 인덱스부터 차례대로 접근(캐시를 알차게 쓸 수 있다) 하면 된다.
I-캐시 미스를 피하는 좋은 방법 : 코드 크기 면에서 성능에 가장 영향을 미치는 반복문을 가능한 작게 만들고, 제일 안쪽의 반복문에서 함수 호출을 피하는 것이다. 이것은 반복문 전체 몸체가 반복문이 수행되는 동안 내내 I-캐시 안에 머물도록 해준다.
반복문이 함수를 호출할 필요가 있을 때는, 불려지는 함수의 코드가 메모리 상에서 반복문의 몸체를 포함하는 코드 가까이에 위치하도록 하는 것이 좋다. 컴파일러와 링커가 메모리상에서 코드 배치를 어떻게 할지 결정하기 때문에 사용자는 I-캐시 미스에 대해서 다룰 권한이 없다고 생각할 수 있지만 그렇지 않다.
대부분의 C/C++ 링커는 다음과 규칙을 따른다.
1. 단일 함수를 위한 기계어 코드는 거의 항상 메모리상에 연속적이다.
2. 함수는 소스 코드에 나타나는 순서대로 메모리에 배치된다.
I-캐시 미스를 피하기 위한 규칙
1. 기계 언어 명령어 수의 관점에서 성능에 영향을 미치는 코드는 가능한 한 작게 만들어라
2. 성능에 민감한 코드 안에서는 함수 호출을 피하라
3. 함수를 호출해야 한다면, 부르는 함수에 최대한 가까이 배치시켜라 (부르는 함수의 앞이나 뒤에 배치시키고, 다른 번역 단위에 놓지 마라)
4. 인라인 함수를 분별 있게 사용하라 : 작은 함수를 인라인으로 바꾸는 것은 성능상의 장점이 될 수도 있지만, 너무 많은 인라인화는 코드 크기를 부풀린다. 불필요하게 커진 코드 부분이 성능에 치명적인 부분 (예를 들어 반복문 내부) 이라면 캐시안에 전부 들어가지 못하게 될 수 있고, 반복문의 반복 한번에 캐시 미스가 두 번 생길 수 있다.
이런 경우에는 코드의 크기를 줄이도록 알고리즘과 구현을 개선하는 방법을 생각해야 한다.
3.4.3 명령문 파이프라인과 슈퍼스칼라 CPU (CPU 자체 안에서의 병렬 처리를 향상시킬 수 있는 두가지 아키텍처 구조)
cpu 명령 파이프라이닝
ㄴ 파이프라인의 지연시간은 하나의 명령어를 완전히 처리하는데 소요되는 시간이다. 파이프라인의 각 단계중 가장 오래걸리는 지연 시간을 기준으로 결정된다.
슈퍼스칼라 프로세서는 파이프라인 단계들의 일부분이나 전체에 대한 다중 회로 복사본을 포함한다. 이는 여러 개의 명령어 스트림을 병렬로 처리할 수 있다.
예를 들어 CPU가 두 개의 정수 산술/논리 단위(ALU)를 가지고 있다면, 두 개의 정수 명령어가 동시에 처리될 수 있다.
3.4.3.1 자료 의존과 칸막이
파이프라인식 CPU는 새로운 명령어를 모든 클락 사이클마다 실행시켜서 가지고 있는 모든 단계(fetch, decode, execute, memory, register write-back)를 활성화 시키려 한다. 어떤 명령어의 결과가 다른 명령어를 실행시키기 위해서 필요하다면
자료 의존 문제가 발생하여 파이프라인에 칸막이(stall)을 만든다.
move 5, r3
mul r0, 10, r1
add r1, 7, r2
와 같은 어셈블리어 명령문을 실행하게 되면 add는 mul의 결과 (r1)을 사용해야 하기 때문에 mul 명령어의 register write-back 단계가 끝날 때까지 기다려야 한다.
최적화 컴파일러에서는 stall을 피하기 위해 add r1, 7, r2 뒤에 있는 의존적이지 않은 명령들을 mul과 add 사이로 옮긴다.
3.4.3.2 분기 예측
if 문을 만나게 되면 stall이 발생한다. 기다리는 대신에 분기를 하나 골라서 계속 실행하고 분기문의 조건 계산이 끝난 후 추측이 맞다면 그대로 진행하고 추측이 틀렸다면 파이프라인은 비워지고 올바른 분기의 첫 번째 명령어가 재시작되어야 한다.
어떤 분기를 선택할지 예측은 하드웨어가 한다. 분기 예측 하드웨어 성능이 좋아야 한다.
'읽은 책 > 게임 엔진 아키텍처' 카테고리의 다른 글
6. 리소스 시스템과 파일 시스템 (0) | 2022.08.08 |
---|---|
5. 엔진 지원 시스템 (0) | 2022.08.06 |
4. 게임에 사용되는 3D 수학 (0) | 2022.08.04 |
2. 도구 (0) | 2022.07.31 |
1. 소개 (0) | 2022.07.30 |