CPU 파이프라인과 분기 예측
명령어를 겹쳐 실행하고 다음 분기를 예상하는 이유
본 블로그 작성 글들은, 강의를 목표로 하고 제작되었습니다. 또한 작성된 글의 정보도 틀렸을 수 있으니, 주의해주세요.
CPU가 명령어 하나를 완전히 끝낸 뒤 다음 명령어를 시작한다면 많은 회로가 기다리게 된다. 현대 CPU는 명령어 처리 단계를 겹치고 앞으로 실행할 흐름을 예상해 처리량을 높인다.
명령어 처리를 단계로 나눈다
단순화하면 명령어 실행은 다음 단계로 생각할 수 있다.
Fetch → Decode → Execute → Memory → Write Back
- Fetch는 메모리나 명령어 캐시에서 명령어를 가져온다.
- Decode는 명령어의 종류와 피연산자를 해석한다.
- Execute는 산술 연산이나 주소 계산을 수행한다.
- Memory는 필요한 데이터에 접근한다.
- Write Back은 결과를 레지스터 등에 반영한다.
파이프라인에서는 첫 명령어가 Execute 단계에 있을 때 두 번째 명령어는 Decode, 세 번째 명령어는 Fetch 단계에 들어갈 수 있다. 한 명령어의 지연 시간이 사라지는 것은 아니지만 여러 명령어가 겹치면서 단위 시간당 완료 수가 늘어난다.
실제 CPU의 파이프라인은 이보다 훨씬 복잡하다. 명령어를 내부 연산으로 나누고, 실행 순서를 바꾸고, 여러 실행 장치를 동시에 사용하는 구조도 있다.
데이터 의존성은 기다림을 만든다
A = B + C
D = A * 2
두 번째 연산은 첫 번째 연산의 결과 A가 필요하다. 결과가 준비되지 않았다면 파이프라인이 기다리거나 다른 독립적인 명령어를 먼저 실행해야 한다.
메모리에서 값을 가져오는 시간이 길어도 같은 문제가 생긴다. 캐시 미스가 발생하면 뒤의 명령어가 필요한 데이터를 기다리면서 파이프라인의 장점이 줄어들 수 있다.
분기에서는 다음 주소를 미리 알기 어렵다
if (health <= 0)
{
state = State::Dead;
}
else
{
state = State::Alive;
}
조건을 계산하기 전에는 어느 경로의 명령어를 가져와야 할지 확정할 수 없다. 결과가 나올 때까지 Fetch를 멈춘다면 파이프라인이 자주 비게 된다.
그래서 CPU는 이전 실행 기록과 분기 구조를 이용해 다음 경로를 예측한다. 예측이 맞으면 준비한 명령어를 계속 사용하고, 틀리면 잘못 진행한 작업을 버린 뒤 올바른 경로에서 다시 시작한다.
이를 분기 예측 실패라고 하며 파이프라인이 깊을수록 버려야 할 작업도 커질 수 있다. 다만 정확한 비용은 CPU 구조와 주변 명령어에 따라 달라진다.
조건문을 없애면 항상 빨라질까?
예측하기 쉬운 분기는 비용이 작을 수 있고, 조건을 없애기 위해 추가한 계산이 오히려 더 비쌀 수 있다. 컴파일러가 조건 이동이나 벡터 명령으로 이미 바꾸는 경우도 있다.
for (int value : values)
{
if (value >= threshold)
{
sum += value;
}
}
데이터가 정렬되어 조건 결과가 일정하게 이어지는 경우와 참·거짓이 무작위로 바뀌는 경우의 성능은 다를 수 있다. 하지만 소스 코드만 보고 원인을 단정하지 말고 최적화된 기계어와 하드웨어 성능 카운터를 함께 확인해야 한다.
파이프라인과 분기 예측은 CPU가 명령어 수준의 병렬성을 얻는 핵심 장치다. 성능을 개선할 때는 분기 하나만 바라보기보다 데이터 의존성, 캐시 미스, 컴파일러 최적화와 실제 측정 결과를 함께 봐야 한다.