부등호가 쏘아 올린 성능 최적화 (1) : 쉴 틈 없는 CPU 공장, 파이프라인
프로세스는 쉬지 않는데, 내 코드가 멈춰있다고??
최근 그 내용이 궁금해져 자료를 찾아보았지만 끝내 다시 찾을 수 없었습니다. 결국 궁금증을 직접 해결하는 과정에서 마주한 '분기 예측'의 세계를 정리해 보려 합니다.
그 첫걸음으로, CPU가 명령어를 처리하는 방식인 명령어 파이프라인을 살펴보겠습니다.
⚙️ 명령어의 생애 주기 (Instruction Cycle)
고수준 언어(Java 등)로 작성된 코드 한 줄은 CPU에서 실행되기 위해 여러 단계로 나누어 처리됩니다.
작업 단위는 총 4단계로 구성됩니다.
- Fetch (F): 메모리에서 명령어를 가져옴
- Decode (D): 명령어를 해석
- Execute (E): 실제 연산 수행
- Write-back (W): 결과를 메모리에 기록
하나씩 순서대로 처리 : 순차적 실행
앞선 명령어의 모든 단계가 종료된 후 다음 명령어를 시작하는 방식입니다.
1
2
3
a = 1 + 1; // 명령어 1
b = a + 2; // 명령어 2
c = b + 3; // 명령어 3
| 명령어 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| a = 1 + 1 | F | D | E | W | ||||||||
| b = a + 2 | F | D | E | W | ||||||||
| c = b + 3 | F | D | E | W |
3줄의 코드를 실행할 경우 총 12 사이클이 소요됩니다.
명령어 병렬 처리 : 명령어 파이프라인(Instruction Pipeline)
CPU 성능을 향상하기 위한 기법으로, 하드웨어 자원을 쉼 없이 가동하는 것이 목적입니다.
CPU 내부에는 각 단계를 담당하는 독립된 유닛들이 존재합니다. 모든 단계가 끝날 때까지 기다려야 했던 순차 실행은 유휴(Idle) 상태를 유발하지만, 파이프라인 기법은 이 독립된 각 유닛을 효율적으로 활용하여 서로 다른 명령어를 병렬 처리하게 됩니다.
| 명령어 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| a = 1 + 1 | F | D | E | W | ||
| b = a + 2 | F | D | E | W | ||
| c = b + 3 | F | D | E | W |
그 결과 전체적인 처리량(Throughput)이 극대화되며, 앞선 예시와 비교했을 때 총 12사이클이 소요되던 작업이 6사이클로 단축된 것을 확인할 수 있습니다.
🚨 효율을 방해하는 예외 상황 - Hazard
여러 명령어를 겹쳐 처리하는 파이프라인 과정에서 흐름이 멈추는 예외 상황이 발생할 수 있습니다. 이러한 위험 요소를 Hazard 라고 부릅니다.
Hazard는 3가지로 나뉩니다.
| 구분 | 설명 |
|---|---|
| Structural Hazard | 동일한 하드웨어 자원을 여러 명령어가 동시에 사용하려는 상황 |
| Data Hazard | 이전 연산 결과에 의존하는 다음 연산이, 결과가 준비될 때까지 대기하는 상황 |
| Control Hazard | 분기 결과가 확정되기 전까지 다음 실행할 명령어를 결정하지 못하고 대기하는 상황 |
여러 해저드 중 분기 로직(if, switch)과 연관된 Control Hazard는 파이프라인의 흐름을 방해하는 핵심 요인입니다. 코드 한 줄이 어떻게 파이프라인을 멈춰 세우는지 그 과정을 살펴보겠습니다.
제어 해저드 (Control Hazard)
프로그램에 if와 switch 같은 분기가 포함되면, 조건의 결과가 나오기 전까지 다음에 실행할 명령어의 주소를 확정할 수 없습니다.
1
2
3
4
5
6
7
a = 1 + 1;
// 분기 지점: 조건 결과에 따라 실행 경로가 결정됨
if (a > 10) {
b = a + 2;
}
c = b + 3;
if (a > 10)의 결과가 확정되어야 다음 명령어를 가져올(Fetch) 수 있기 때문에 파이프라인은 일시적으로 멈추게 됩니다. 이처럼 분기 결과에 따라 실행 경로가 결정되지 못해 발생하는 병목 현상이 바로 Control Hazard입니다.
이 과정을 파이프라인 사이클로 나타내면 다음과 같습니다.
| 구분 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
a = a + 1; | F | D | E | W | ||||
if (a > 10) | F | D | E | W | ||||
| (Stall/Bubble) | ☁️ | ☁️ | ||||||
b = a + 2; | F | D | E | W |
분기 결과를 기다리느라 발생하는 지연(Stall)은 파이프라인 흐름 사이에 공백(Bubble)을 만들어냅니다.
💡 지연과 공백 : Stall & Bubble
- Stall: Hazard를 해결하기 위해 파이프라인의 진행을 잠시 멈추는 동작입니다. 필요한 정보(분기 결과)가 준비될 때까지 강제로 대기 시간을 만듭니다.
- Bubble: Stall로 인해 파이프라인 흐름에 끼워 넣은 의미 없는 빈 사이클입니다. 앞선 명령이 처리될 시간을 확보합니다.
결국 CPU는 Control Hazard를 마주하면 Stall을 발생시켜 파이프라인을 Bubble로 채우게 되며, 이는 곧 전체적인 처리량 감소와 성능 저하로 이어집니다.
🚀 다음 예고: 멈출 것인가, 나아갈 것인가?
파이프라인은 병렬 처리를 통해 CPU의 속도를 비약적으로 높여주지만, 분기문을 만나는 순간 Control Hazard로 인해 흐름이 끊기게 됩니다.
결과가 나올 때까지 발생하는 Stall은 성능 지연으로 이어집니다. 그래서 현대 CPU는 결과를 알기도 전에 다음 경로를 미리 예측하여 실행하는 분기 예측(Branch Prediction) 기법을 도입했습니다.
다음 포스팅에서는 파이프라인의 효율을 지키기 위한 전략, 분기예측에 대해서 알아보겠습니다.