프로세서 구조
이번 장은 컴퓨터과학판 레고 블록 조립기다. x86-64를 그대로 만들면 책 한 권으로 안 끝나니까,
저자들은 Y86-64라는 깡통 ISA를 던져준다. 명령어는 12개 남짓, 레지스터는 15개,
조건 코드는 ZF·SF·OF 세 개. 이걸 가지고 SEQ(한 사이클에 한 명령) → PIPE
(5단계 파이프라인)로 진화시킨다. 핵심은 파이프라인 해저드 — 데이터가 아직 안 도착했는데
다음 명령이 그걸 읽으려 할 때, 분기 예측이 틀렸을 때, ret이 어디로 점프할지 모를 때.
포워딩(forwarding), 스톨(stall), 버블(bubble)이라는 세 가지 무기로 이걸 길들인다.
4.1Y86-64 명령어 집합 구조 (ISA)
ISA(Instruction Set Architecture)는 하드웨어와 소프트웨어 사이의 계약서다. 컴파일러는 ISA가 약속한 명령만 토해내고, CPU 설계자는 그 명령을 어떻게든 실행시킬 의무가 있다. x86-64는 너무 거대해서 처음 보는 사람한테는 절벽이다 — 명령어가 수천 개, 인코딩 길이는 1~15바이트, 같은 일을 하는 명령이 다섯 가지씩 있다. 그래서 책은 Y86-64라는 미니어처를 새로 깎는다.
x86-64의 정수 부분만 골라 단순화. 레지스터 이름과 호출 규약은 거의 그대로 가져와서 3장에서 익힌 감각이 안 깨진다. 부동소수점, SIMD, 가변 길이 인코딩 같은 "프로세서 설계자를 울리는 요소"는 통째로 잘라냈다.
4.1.1프로그래머가 보는 상태
"프로그래머가 본다"는 말은 레지스터 이름·메모리 주소·플래그처럼 명령으로 직접 건드릴 수 있는 것을 의미한다. 캐시·파이프라인 레지스터·분기 예측기 같은 내부 부품은 여기 안 들어온다 — 그건 구현의 자유다.
| 구성요소 | 설명 |
|---|---|
| 15개 레지스터 (RF) | %rax, %rcx, %rdx, %rbx, %rsp, %rbp, %rsi, %rdi, %r8~%r14. %r15는 일부러 뺐다 (4비트 인코딩에서 0xF를 "레지스터 없음" 표식으로 쓰려고). |
| 조건 코드 (CC) | ZF (zero), SF (sign), OF (overflow). x86보다 한 개 적다 (CF는 없음). |
| 프로그램 카운터 (PC) | 다음에 실행할 명령의 주소. 8바이트. |
| 메모리 (DMEM) | 바이트 주소 공간. 가상주소 = 물리주소로 단순화. 리틀엔디언. |
| 상태 코드 (Stat) | AOK / HLT / ADR / INS — 4.1.4에서 다룸. |
4.1.2Y86-64 명령들
딱 12종류. 외울 만한 양이다.
| 명령 | 의미 | 비고 |
|---|---|---|
halt | 프로세서 정지 | 상태를 HLT로 |
nop | 아무 일 안 함 | 파이프라인 채울 때 유용 |
rrmovq rA, rB | 레지스터 → 레지스터 복사 | 조건부 변형 cmovXX 6종 포함 |
irmovq V, rB | 즉치값 → 레지스터 | V는 8바이트 상수 |
rmmovq rA, D(rB) | 레지스터 → 메모리 | 주소 = D + R[rB] |
mrmovq D(rB), rA | 메모리 → 레지스터 | |
OPq rA, rB | R[rB] ← R[rB] OP R[rA] | OP = add/sub/and/xor |
jXX Dest | 조건부/무조건 점프 | jmp, jle, jl, je, jne, jge, jg |
call Dest | 스택에 PC+@ 푸시 후 점프 | |
ret | 스택에서 PC 팝 | 다음 PC 예측이 어렵다 — 4.5.4 참조 |
pushq rA | R[%rsp] -= 8; M[R[%rsp]] = R[rA] | |
popq rA | R[rA] = M[R[%rsp]]; R[%rsp] += 8 |
(1) 메모리는 직접 주소 모드 하나만 — D(rB). 인덱싱·스케일은 없다.
(2) 레지스터-레지스터 연산만 ALU 입력. 메모리 오퍼랜드 ALU 없음.
(3) 모든 정수는 8바이트(quad)로 통일.
(4) CMP·TEST 없음 — 조건 코드는 OPq에서만 갱신.
4.1.3명령 인코딩
한 명령은 1바이트 icode:ifun + (선택) 1바이트 레지스터 필드(rA:rB)
+ (선택) 8바이트 상수 V/D/Dest. 즉 가장 짧은 게 1바이트(halt), 가장 긴 게 10바이트(irmovq).
icode ifun 명령
───── ───── ─────────────
0 0 halt
1 0 nop
2 fn cmovXX (rrmovq는 fn=0)
3 0 irmovq
4 0 rmmovq
5 0 mrmovq
6 fn OPq (add=0 sub=1 and=2 xor=3)
7 fn jXX
8 0 call
9 0 ret
A 0 pushq
B 0 popq
예: irmovq $-9, %rbx 는 다음과 같이 인코딩된다.
irmovq $-9, %rbx (10바이트)30 F3 F7 FF FF FF FF FF FF FF
│ │ └─────── 8바이트 상수 V = -9 (리틀엔디언, 2의 보수)
│ └────────── rA=F("없음"), rB=3(%rbx)
└──────────── icode=3, ifun=0 → irmovq
다음 바이트 시퀀스를 디스어셈블해 보라.
30 F2 0A 00 00 00 00 00 00 00
60 23
73 00 1C 00 00 00 00 00 00 00
풀이:
30 F2→ irmovq, rA=F, rB=2(%rdx) + 상수 0x0A →irmovq $10, %rdx60 23→ addq, rA=2(%rdx), rB=3(%rbx) →addq %rdx, %rbx73 00 1C…→ jXX(ifun=3 → jne), Dest=0x1C →jne 0x1C
4.1.4Y86-64 예외
Y86-64는 비현실적으로 단순한 예외 모델을 쓴다. 상태 레지스터 Stat이 다음 4개 중 하나를 가진다.
| 코드 | 이름 | 의미 |
|---|---|---|
| 1 | AOK | 정상 동작 중 |
| 2 | HLT | halt 실행됨 — 정지 |
| 3 | ADR | 잘못된 메모리 주소 접근 |
| 4 | INS | 알 수 없는 명령 코드 (illegal instruction) |
실제 CPU라면 예외 핸들러로 점프해서 OS가 처리하지만, Y86-64는 그냥 멈춰버린다. "왜 죽었는지"만 보고하면 끝. 4.5.6에서 파이프라인일 때 어떻게 정확한 예외를 보장하는지 다룬다.
4.1.5Y86-64 프로그램 예시
배열 합산 함수를 Y86-64 어셈블리로 짜본다. C로는:
long sum(long *start, long count) {
long sum = 0;
while (count) {
sum += *start;
start++;
count--;
}
return sum;
}
Y86-64 어셈블리로 옮기면:
sum:
irmovq $8, %r8 # 상수 8 (포인터 증가용)
irmovq $1, %r9 # 상수 1 (카운터 감소용)
xorq %rax, %rax # sum = 0
andq %rsi, %rsi # count == 0? ZF 갱신
jmp test
loop:
mrmovq (%rdi), %r10 # *start 로드
addq %r10, %rax # sum += *start
addq %r8, %rdi # start++
subq %r9, %rsi # count--
test:
jne loop # count != 0 이면 반복
ret
x86이라면 incq·decq·cmpq로 간단할 일을, Y86-64에서는
"상수를 미리 레지스터에 박아두고 add/sub로 흉내내는" 식으로 푼다. 명령어 수를 줄인 대가.
4.1.6일부 명령의 세부
pushq %rsp가 옛날 값을 푸시할지 새 값을 푸시할지는 ISA가
명시해야 한다. Y86-64는 "옛날 값(decrement 전 값)을 푸시"로 정의한다 — 그래야 SEQ에서
구현이 깔끔해진다. 마찬가지로 popq %rsp는 메모리에서 읽은 값으로 %rsp를 덮어쓴다
(증가된 값이 아니라).
이런 모서리 케이스는 사소해 보여도 구현의 자유도를 좌우한다. ISA가 모호하면 서로 다른 구현체가 다른 결과를 내는 사태가 생긴다.
4.2논리 설계와 HCL
이제 명령어를 정의했으니 실제로 무엇이 무엇과 연결되는지를 그려야 한다. 회로를 그릴 때마다 와이어를 일일이 손으로 그릴 수는 없으니, 책은 HCL(Hardware Control Language)이라는 슈도언어를 만든다. Verilog/VHDL의 미니 버전이라 보면 된다.
4.2.1논리 게이트
AND·OR·NOT — 컴퓨터의 ABC. HCL은 비트 단위에서 &&, ||, !를 쓴다
(C와 동일). 게이트의 출력은 입력이 변하면 즉시 따라간다 (조합 회로의 정의).
4.2.2조합 회로와 HCL 부울 표현식
조합 회로(combinational circuit)는 피드백이 없는 게이트 망이다. 입력이 정해지면 출력도 정해지고, 시간이 지나도 출력이 변하지 않는다 (입력이 같다면). 부울 등식을 그대로 회로로 옮긴 셈.
// 1비트 동등 비교
bool eq = (a && b) || (!a && !b);
// 1비트 멀티플렉서: s가 0이면 a, 1이면 b
bool out = (s && b) || (!s && a);
4.2.3워드 수준 조합 회로 (다중화기, ALU)
1비트짜리 회로를 64개 묶으면 64비트 워드 회로가 된다. HCL에서는 word 타입을 쓴다.
// 64비트 동등 비교
bool eq = (A == B);
// 64비트 멀티플렉서 (case 식)
word Out = [
s == 0 : A;
s == 1 : B;
1 : 0; // default
];
ALU는 4비트 함수 코드 alufun으로 동작을 고른다.
word ALU = [
alufun == ALUADD : aluA + aluB;
alufun == ALUSUB : aluB - aluA; // 순서 주의!
alufun == ALUAND : aluA & aluB;
alufun == ALUXOR : aluA ^ aluB;
];
Y86-64에서 subq rA, rB는 rB ← rB − rA다. ALU 입력 순서를 헷갈리면
부호가 뒤집힌다. 이 작은 실수가 시뮬레이터를 디버깅 지옥으로 보낸다.
4.2.4집합 멤버십 (case)
"icode가 OPq, jXX, call, ret 중 하나면 1"처럼 여러 후보 중 하나인지 검사하는 패턴이 자주 나온다. HCL은 이를 위해 집합 검사 문법을 제공한다.
bool need_regs = icode in { IRRMOVQ, IOPQ, IPUSHQ, IPOPQ, IIRMOVQ, IRMMOVQ, IMRMOVQ };
bool set_cc = icode in { IOPQ }; // 조건 코드는 산술 명령에서만 갱신
4.2.5메모리와 클로킹 (레지스터, 클럭)
조합 회로만으로는 상태를 못 가진다. 무언가 기억하려면 클럭드 레지스터(clocked register, flip-flop의 묶음)가 필요하다. 클럭 신호의 상승 에지(rising edge)에서 입력을 한 번 빨아들여 출력으로 내보낸다.
┌──────────────┐
입력 ──┤ D Q ├── 출력 (다음 사이클)
│ │
clk ──┤▷ │
└──────────────┘
레지스터 파일(RF)·메모리·PC·조건 코드 — 이 네 가지가 SEQ에서 클럭에 따라 갱신되는 상태 요소다. 여기에 PIPE가 되면 파이프라인 레지스터(F, D, E, M, W)가 추가된다.
4.3순차 Y86-64 구현 (SEQ)
드디어 동작하는 프로세서를 짠다. SEQ(SEQuential)는 한 사이클에 한 명령을 통째로 처리하는 가장 단순한 구조다. 빠르진 않지만, 다음 단계인 PIPE의 발판이 된다.
4.3.1단계로 처리 조직화
모든 명령을 6단계로 통일해 처리한다. 어떤 명령은 어떤 단계에서 노는(no-op) 것도 있지만, "단계 자체"는 모든 명령이 거친다.
| 단계 | 약자 | 하는 일 |
|---|---|---|
| Fetch | F | PC가 가리키는 곳에서 명령 바이트 읽기. icode/ifun, rA/rB, valC 분리. valP = PC + 길이. |
| Decode | D | 레지스터 파일에서 valA, valB 읽기 (rA, rB가 가리키는 값). |
| Execute | E | ALU 연산. 분기 조건 평가. 메모리 주소 계산. |
| Memory | M | 메모리 읽기/쓰기 (mrmovq/rmmovq/push/pop/call/ret). |
| Write Back | WB | 결과를 레지스터 파일에 쓰기. |
| PC Update | PC | 다음 PC 결정 — valP, valC, valM 중 선택. |
4.3.2SEQ 하드웨어 구조
위 6단계를 6개의 수직 블록으로 쌓고, 각 블록 사이로 신호 와이어를 줄줄이 그린다. 대략 이런 모양이다.
┌──────────────────┐
│ PC Update │ ◄── newPC 결정
├──────────────────┤
│ Write Back │ ◄── RF에 valE/valM 기록
├──────────────────┤
│ Memory │ ◄── DMEM 읽기/쓰기
├──────────────────┤
│ Execute │ ◄── ALU + Cnd
├──────────────────┤
│ Decode │ ◄── RF 읽기 (srcA, srcB)
├──────────────────┤
│ Fetch │ ◄── IMEM 읽기, 명령 분해
└──────────────────┘
▲
│ PC
4.3.3SEQ 타이밍
한 클럭 사이클 안에 모든 신호가 Fetch부터 PC Update까지 흘러가야 한다. 즉 클럭 주기는 가장 긴 명령의 지연시간보다 커야 한다. 보통 메모리 접근이 가장 느리니 한 사이클의 길이는 메모리 지연 + ALU 지연 + 잡다한 게이트 지연으로 결정된다.
한 사이클에 모든 단계를 다 끝내는 구조라서, 클럭을 빨리 돌리지 못한다. 파이프라인이 풀리면 각 단계가 독립적으로 빨리 돌 수 있어 클럭이 5배쯤 빨라진다.
4.3.4SEQ 단계 구현
각 단계를 HCL로 묘사한다. 예를 들어 Fetch 단계의 핵심.
// 명령이 유효한가
bool instr_valid = icode in
{ INOP, IHALT, IRRMOVQ, IIRMOVQ, IRMMOVQ, IMRMOVQ,
IOPQ, IJXX, ICALL, IRET, IPUSHQ, IPOPQ };
// 레지스터 바이트 필요?
bool need_regids = icode in
{ IRRMOVQ, IOPQ, IPUSHQ, IPOPQ, IIRMOVQ, IRMMOVQ, IMRMOVQ };
// 8바이트 상수 필요?
bool need_valC = icode in
{ IIRMOVQ, IRMMOVQ, IMRMOVQ, IJXX, ICALL };
// 다음 명령 주소 (이 명령 끝난 직후)
word valP = PC + 1 + (need_regids ? 1 : 0) + (need_valC ? 8 : 0);
WB 단계에서 두 개의 쓰기 포트를 쓴다 — dstE(ALU 결과)와 dstM(메모리 결과).
어떤 명령은 둘 다 쓰지 않고(예: jXX), 어떤 명령은 둘 다 쓴다(예: popq — %rsp는
dstE로, 팝된 값은 dstM으로).
4.4파이프라이닝의 일반 원리
SEQ가 답답한 이유는 명령 한 개를 끝까지 처리한 다음에야 다음 명령을 시작한다는 것이다. 세탁기·건조기·다림질을 한 사람씩 차례로 시키는 빨래방을 상상해 보라. 줄이 길게 늘어선다. 그런데 빨래 1번이 건조기에 있는 동안, 빨래 2번이 세탁기를 쓸 수 있다. 같은 일을 동시에 다른 단계에서 처리하면 처리량이 단계 수만큼 늘어난다. 이게 파이프라이닝이다.
4.4.1계산 파이프라인
3단계 파이프라인을 가정하자. 각 단계가 100ps 걸린다. 비파이프라인이라면 한 명령에 300ps, 즉 처리량 ≈ 3.33 GIPS. 파이프라인이라면 클럭 주기는 100ps + 약간의 레지스터 지연(20ps)이라 120ps, 처리량 ≈ 8.33 GIPS. 2.5배 빠르다.
Cycle 1 2 3 4 5
─────────────────────────────────
I1: [A] [B] [C]
I2: [A] [B] [C]
I3: [A] [B] [C]
4.4.2파이프라인 동작 자세히 보기
각 단계의 출력은 클럭 상승 에지에 파이프라인 레지스터로 빨려들어가고, 다음 사이클에는 다음 단계의 입력으로 흘러나온다. 즉 사이클 N의 단계 i 출력 = 사이클 N+1의 단계 i+1 입력.
이 구조의 가장 큰 장점은 각 단계가 다른 명령을 동시에 처리한다는 것. 5단계라면 5개 명령이 공중에 떠 있는 셈.
4.4.3파이프라이닝의 한계
- 단계 균일성: 가장 느린 단계가 클럭을 결정. 5단계 중 하나가 200ps면 다른 단계는 100ps여도 소용없다. 그래서 단계를 잘라낼 때 시간을 균등하게 배분해야 한다.
- 레지스터 오버헤드: 단계마다 클럭드 레지스터를 끼워넣어야 하니, 단계가 많아질수록 세업/홀드 시간 손실이 누적된다. 무한정 잘게 쪼갠다고 빨라지지 않는다.
- 의존성: 명령 사이에 데이터·제어 의존이 있으면 파이프라인이 매끈하게 안 흐른다. 이게 4.5의 메인 이슈.
4.4.4피드백이 있는 시스템
실제 프로세서는 피드백 루프를 갖는다 — 결과가 다음 명령의 입력이 되거나, 분기 결과가 다음에 가져올 PC를 정한다. 피드백이 있으면 단순 파이프라이닝이 깨진다. 다음 절에서 본격적으로 이걸 다룬다.
4.5파이프라인 Y86-64 구현 (PIPE)
이제 SEQ의 6단계를 5단계 파이프라인으로 변신시킨다. PC Update는 Fetch 안에 흡수된다 — "다음 명령을 어디서 가져올지"는 Fetch가 결정해야 하니까.
4.5.1SEQ+: 계산 단계 재배열
먼저 SEQ를 살짝 손본 SEQ+를 만든다. 핵심은 PC 계산을 Fetch 시작에 옮기는 것. 원래 SEQ는 사이클 끝에 PC를 갱신하지만, 파이프라인을 깔려면 사이클 시작에 PC가 정해져 있어야 다음 명령을 미리 가져올 수 있다. 그래서 PC를 별도 레지스터에 두지 않고, 직전 명령의 valP/valC/valM을 파이프라인 레지스터에서 끌어와 다음 PC를 계산한다.
4.5.2파이프라인 레지스터 삽입
각 단계 사이에 다음과 같은 5개 파이프라인 레지스터를 끼워넣는다.
| 레지스터 | 저장하는 것 |
|---|---|
| F | predPC (다음에 가져올 PC 예측값) |
| D | icode, ifun, rA, rB, valC, valP, Stat |
| E | icode, ifun, valC, valA, valB, dstE, dstM, srcA, srcB, Stat |
| M | icode, Cnd, valE, valA, dstE, dstM, Stat |
| W | icode, valE, valM, dstE, dstM, Stat |
4.5.3신호 재배열/재라벨링
각 단계 안에서 쓰는 신호는 D_icode, E_valA처럼 대문자 약자_이름으로
호칭한다. 새로 계산되는 출력은 d_valA처럼 소문자. 이 명명법 덕분에 어느 단계의
어떤 신호인지 한눈에 보인다.
// 예: Decode 단계에서 valA 결정 (포워딩 없는 경우)
word d_valA = [
D_icode in { ICALL, IJXX } : D_valP; // call/jXX은 PC를 valA로
d_srcA == RNONE : 0;
1 : R[d_srcA]; // 일반: RF에서 읽기
];
4.5.4다음 PC 예측
Fetch는 이 사이클 끝까지 다음 PC를 알아야 다음 사이클에 또 다른 명령을 가져올 수 있다. 하지만 분기 결과는 Execute에서 결정되고, ret이 어디로 갈지는 Memory에서 결정된다. 그래서 예측해야 한다.
| 명령 | 예측 전략 | 틀릴 확률 |
|---|---|---|
| 일반 명령 | predPC = valP | 0% (확정적) |
| call, jmp(무조건) | predPC = valC | 0% |
| jXX(조건부) | predPC = valC (taken 가정) | 약 40% (실측치 평균) |
| ret | 예측 불가 — 3사이클 버블 삽입 | 100% |
루프 분기는 거의 매번 taken이라 통계적으로 유리하다. 또 backward 분기는 루프, forward 분기는 if-skip인 경우가 많아 — backward만 taken으로 예측하면 정확도가 더 올라간다(BTFNT).
4.5.5파이프라인 해저드
파이프라인이 직진을 멈추는 모든 사건은 해저드(hazard)다. 종류는 셋.
① 데이터 해저드 (RAW: read-after-write)
예시:
addq %rax, %rbx ; I1: %rbx ← %rbx + %rax
mulq %rbx, %rcx ; I2: %rcx ← %rcx * %rbx ← I1의 결과 필요
I2가 Decode에서 %rbx를 읽으려는 시점에, I1은 아직 Execute 중이라 새 값이 RF에 안 들어갔다.
Cycle 1 2 3 4 5 6
──────────────────────────────────
I1 add: [F] [D] [E] [M] [W]
I2 mul: [F] [D] ⚠ [E] [M] [W]
│
└─ 여기서 %rbx 읽음. 그런데
새 값은 사이클 5의 W에서야 RF로 들어감!
해결책 1: 포워딩 (forwarding / bypassing)
"새 값이 RF에 도달할 때까지 기다리지 말고, ALU 출력을 바로 다음 명령의 ALU 입력으로 꽂아넣자." 파이프라인 레지스터에서 ALU 입력으로 가는 우회 경로를 5개쯤 추가한다.
| 출처 | 저장된 신호 | 도착지 |
|---|---|---|
| e_valE (방금 ALU 결과) | 현재 사이클 | D 단계의 valA/valB 선택 |
| M_valE | 1 사이클 전 ALU 결과 | |
| m_valM (방금 메모리 읽기) | 현재 사이클 | |
| W_valE | 2 사이클 전 ALU 결과 | |
| W_valM | 2 사이클 전 메모리 결과 |
// Decode 단계의 valA 선택 — 5단계 우선순위
word d_valA = [
D_icode in { ICALL, IJXX } : D_valP;
d_srcA == e_dstE : e_valE; // 직전 ALU 결과
d_srcA == M_dstM : m_valM; // Memory 단계의 로드 결과
d_srcA == M_dstE : M_valE; // 1사이클 전 ALU 결과
d_srcA == W_dstM : W_valM;
d_srcA == W_dstE : W_valE;
1 : d_rvalA; // RF에서 읽기
];
해결책 2: load-use 해저드 → 1사이클 stall
딱 한 가지 경우는 포워딩만으로 안 된다 — 로드 직후 그 값을 즉시 쓰는 경우.
mrmovq (%rdi), %rax ; I1: 메모리에서 %rax 로드
addq %rax, %rbx ; I2: %rax 사용
I1의 valM은 사이클 4(Memory)에서야 나온다. I2는 사이클 4에 Decode를 끝내고 Execute로 넘어가야 하는데, 입력이 그 사이클에야 도착하니 한 박자 늦는다. 해결: I2를 Decode에 한 사이클 더 멈추고 (stall), I1과 I2 사이에 버블(bubble = 가짜 nop) 하나 끼워넣는다.
Cycle 1 2 3 4 5 6 7
─────────────────────────────────────
I1 mrmovq: [F] [D] [E] [M] [W]
I2 addq: [F] [D] [D]* [E] [M] [W]
│ │
└────┴── stall: D를 1사이클 더 잡고 있음
↓ 그 자리에 버블 하나
bubble: [E] [M] [W]
② 제어 해저드 (control hazard)
분기 예측이 틀렸을 때. PIPE는 jXX를 taken으로 예측해 다음 두 명령을 이미 가져오고 디코딩 중이다. 사이클 3의 Execute에서 Cnd가 false로 나오면, 사이클 1에 가져온 두 명령은 없던 일로 만들어야 한다. 이를 squash라 한다.
Cycle 1 2 3 4 5 6
──────────────────────────────────
jXX(taken): [F] [D] [E] ← 여기서 not-taken 판명
X1: [F] [D] ← squash: D를 bubble로
X2: [F] ← squash: F를 bubble로
─ 올바른 ─
Y1: [F] [D] [E] ...
분기 미스예측 페널티 = 2 사이클(두 명령 squash).
③ ret 해저드
ret는 다음 PC를 메모리에서 읽어와야 안다(스택에 저장된 반환주소). Fetch는 그 값을 미리 알 길이 없어서, 무조건 3사이클 버블을 끼워야 한다 — Memory에서 valM이 나올 때까지.
Cycle 1 2 3 4 5
─────────────────────────
ret: [F] [D] [E] [M] [W]
│
bubble: [F] │ ← Fetch 멈춤
bubble: [F] │
bubble: [F] │
return: [F] ← 여기서야 valM으로 점프
4.5.6예외 처리
파이프라인에서 예외는 까다롭다. 명령 5개가 공중에 떠 있는데 그중 3번 명령이 ADR 예외를 일으키면, 3번보다 늦게 들어온 4·5번 명령이 부작용을 남기면 안 된다 (다행히 3번보다 먼저 끝난 1·2번은 그대로 두면 됨).
PIPE의 전략은 다음과 같다.
- 각 명령은 자기 Stat을 파이프라인 레지스터에 끌고 다닌다.
- 예외가 발생한 명령이 Write Back 단계에 도달할 때까지 실제 상태(메모리·RF)에 안 쓴다 — 정확히 말하면, 그 뒤에 들어온 명령들의 부작용을 squash한다.
- 예외 명령이 W 단계에 도달하면 프로세서를 멈추고 status를 저장.
이렇게 하면 "예외 시점 = 정확히 그 명령에서 멈춤"이 보장된다 — 이것을 정확한 예외(precise exception)라 한다.
4.5.7PIPE 단계 구현
각 단계의 HCL이 SEQ보다 살짝 복잡해진다. 가장 큰 변화는 Decode — 포워딩 5개 우선순위 멀티플렉서가 들어간다. Execute도 분기 미스예측 시 Cnd 결과로 다음 단계에 신호를 보내야 한다.
4.5.8파이프라인 제어 로직
파이프라인 제어 로직은 매 사이클 4가지 결정을 내린다.
| 상황 | F | D | E | M |
|---|---|---|---|---|
| processing ret | stall | bubble | normal | normal |
| load-use | stall | stall | bubble | normal |
| mispredicted branch | normal | bubble | bubble | normal |
| 예외 발생 | stall | bubble | bubble | normal |
stall = 파이프라인 레지스터를 그대로 유지(시간 멈춤). bubble = nop 명령을 밀어넣음. 두 행위는 다르다.
load-use 해저드와 ret 해저드가 같이 발생할 수 있나? 로직을 따져보면 — 가능하다.
예: mrmovq (%rsp), %rax 직후 ret. 이 경우 stall이 누적되도록 제어 로직을
설계해야 한다 (책의 4.5.8에서 진리표로 정리).
4.5.9성능 분석 (CPI)
이상적 CPI(Cycles Per Instruction)는 1이다. 하지만 해저드가 페널티를 추가한다.
CPI = 1 + lp + mp + rp
lp = load penalty = (load-use 비율) × 1
mp = mispredict penalty = (조건부 분기 비율 × 미스예측률) × 2
rp = ret penalty = (ret 명령 비율) × 3
전형적인 정수 프로그램에서 이 페널티들의 합은 0.2~0.4 정도. 즉 PIPE는 CPI ≈ 1.2~1.4를 낸다 — SEQ보다 클럭이 5배 빠르고, 명령당 사이클은 1.3배라 결과적으로 약 4배 빠른 셈.
어떤 프로그램에서 명령 비율이 다음과 같다. mrmovq 25% (그중 절반이 load-use), 조건부 분기 20% (미스예측률 40%), ret 2%. CPI를 추정하라.
풀이:
- lp = 0.25 × 0.5 × 1 = 0.125
- mp = 0.20 × 0.40 × 2 = 0.16
- rp = 0.02 × 3 = 0.06
- CPI = 1 + 0.125 + 0.16 + 0.06 ≈ 1.345
4.5.10미완의 일들
현실 프로세서는 PIPE보다 한참 더 복잡하다. 책이 맛만 보여준 항목들.
- 다중 사이클 명령: 정수 곱셈, 부동소수점 연산은 한 사이클 ALU에 안 들어간다. 별도 기능 유닛으로 빼고 결과가 나올 때까지 의존 명령을 stall.
- 메모리 인터페이스: 캐시 미스가 나면 메모리 접근이 수십~수백 사이클 걸린다. PIPE는 1사이클 가정이라 캐시를 다루지 않지만, 실 CPU는 미스 처리 로직이 핵심.
- 슈퍼스칼라(superscalar): 한 사이클에 여러 명령을 동시에 issue. 5장의 주제.
- 비순차 실행(out-of-order): 의존성이 없는 뒤 명령을 먼저 실행. 레지스터 리네이밍·재정렬 버퍼(ROB)·예약 스테이션 등 거대한 기계장치가 들어간다.
- 분기 예측기: BTFNT보다 정교한 2-bit saturating counter, gshare, perceptron 등.
현대 CPU의 모든 복잡도는 결국 "파이프라인의 해저드를 어떻게 더 잘 숨길까"의 답이다. 포워딩·stall·bubble의 직관이 잡히면 OoO 슈퍼스칼라의 동작 원리가 자연스럽게 따라온다. 5장의 성능 최적화도 이걸 모르면 추상적인 마법처럼 느껴진다.
4.6요약
4장이 보여주는 큰 그림.
- ISA는 계약이다. 같은 ISA여도 SEQ·PIPE처럼 전혀 다른 구현이 가능하고, 소프트웨어는 차이를 못 느껴야 한다.
- SEQ → PIPE로 가는 동안 클럭 주기는 짧아지고 처리량은 4배쯤 늘었다. 대가는 해저드 처리 로직이라는 복잡도.
- 해저드의 3종 세트: 데이터(포워딩+stall), 제어(예측+squash), 구조적(여기선 거의 없음).
- 정확한 예외는 거저 얻어지지 않는다. 파이프라인 레지스터에 Stat을 끌고 다니고, W 단계에서야 부작용을 확정해야 한다.
- CPI 공식: 1 + lp + mp + rp. 페널티의 출처를 알면 어디를 최적화할지 보인다.
4.6.1Y86-64 시뮬레이터
책 사이트에서 yas(어셈블러), yis(ISA 시뮬레이터), ssim(SEQ 시뮬레이터),
psim(PIPE 시뮬레이터)을 받을 수 있다. 자기 .ys 파일을 짜서 명령별 사이클 수가
공식대로 나오는지 확인하면 4장의 모든 그림이 손에 익는다.
(1) 4.1.5의 sum 함수를 .ys로 작성, yis로 실행 결과 확인. (2) 같은 코드를 ssim·psim에 돌려 사이클 수 비교. (3) 일부러 load-use를 넣어보고 psim의 stall 카운터를 관찰. (4) jne를 안 taken 케이스로 유도해서 미스예측 페널티 확인. 이 4단계만 해보면 4장 시험은 거의 다 풀린다.
다음 장 예고: 5장에서는 컴파일러도 못 해주는 사람의 영역 — 루프 언롤링, 다중 누산기, 분기 예측 친화 코드 — 즉 4장에서 배운 파이프라인 메커니즘을 실제 C 코드에 어떻게 반영하는지를 다룬다.