프로세서
명령어가 칩 안에서 살아 움직이는 5단계 컨베이어 벨트
중요한 일에는, 사소한 디테일이 없다. — 프랑스 속담
이 장의 목차
4.1 도입 — 성능 방정식 다시 보기
1장에서 우리는 컴퓨터의 속도를 결정하는 세 친구를 만났습니다. 다시 한 번 인사 나눠보죠.
- 명령어 개수 (Instruction Count, IC) — 컴파일러와 ISA 설계자가 책임지는 영역.
- CPI (Cycles Per Instruction) — 프로세서 구현이 책임지는 영역.
- 클럭 주기 시간 (Clock Cycle Time) — 회로 설계자와 프로세서 구현이 같이 책임지는 영역.
이 장의 주인공은 두 번째와 세 번째입니다. 같은 명령어를 어떻게 만들면 더 빨리 돌릴 수 있을까? 이게 바로 「프로세서 구현」이라는 학문의 한 줄 요약이에요.
RISC-V 부분 집합으로 단순화
실제 RISC-V는 명령어가 수백 개지만, 4장에서는 아주 작은 집합만 다룹니다. 핵심 패턴만 잡으면 나머지는 거의 자동이거든요.
- 메모리 접근:
ld,sd - R-type 산술/논리:
add,sub,and,or - 조건 분기:
beq
딱 7개입니다. 이 7개로 데이터패스를 짜면, 거기에 다른 명령어를 끼워 넣는 건 거의 「복붙 후 살짝 수정」 수준이에요. 교과서가 친절한 게 아니라, 진짜 그렇습니다.
모든 명령어가 공유하는 패턴
어떤 명령어든, 프로세서 입장에서는 같은 의식을 치러야 합니다.
- Fetch — PC가 가리키는 주소에서 명령어를 꺼낸다.
- Decode — 어떤 명령어인지, 레지스터는 뭘 쓰는지 파악한다.
- Execute — 실제 일을 한다 (덧셈, 메모리 접근, 분기 결정 등).
이걸 5단계로 잘게 쪼갠 게 나중에 보게 될 IF/ID/EX/MEM/WB 파이프라인입니다. 일단 지금은 “세 단계를 한 사이클에 다 끝내자”는 무모한 계획으로 시작합니다.
4.2 논리 설계 규칙
데이터패스를 만들기 전에, 디지털 회로의 「공중도덕」 두 가지를 짚고 갑니다. 이걸 안 지키면 회로가 발작합니다 (메타스테이블이라고 합니다, 진짜로).
- 조합 회로 (Combinational Logic)
- 입력만 보고 즉시 출력을 결정하는 회로. 기억력이 없어요. ALU, 멀티플렉서, 디코더, 가산기 같은 것들. 입력이 흔들리면 출력도 같이 흔들립니다.
- 상태 소자 (State Element)
- 값을 「붙잡아두는」 회로. 레지스터, 메모리, 플립플롭이 여기에 해당합니다. 클럭이 올 때만 값이 갈아엎어집니다. 말하자면 디지털 회로의 「냉장고」 — 클럭이 와야만 문을 엽니다.
클러킹 방법론 — Edge-Triggered
이 책이 채택한 디자인은 edge-triggered clocking입니다. 풀어쓰면 이렇게 됩니다.
- 클럭은 0과 1을 왕복하는데, 우리는 그중 한쪽 가장자리(보통 상승 엣지)에만 관심을 둡니다.
- 그 순간, 모든 상태 소자가 동시에 값을 갱신합니다. 입력은 「엣지 직전의 값」으로 굳어지고, 출력은 「엣지 직후의 새 값」이 됩니다.
- 한 클럭 사이클 동안 조합 회로는 충분한 시간을 갖고 새 입력을 처리해 다음 엣지 전까지 안정화돼야 합니다.
덕분에 「읽기와 쓰기를 같은 사이클에 안전하게」 할 수 있어요. 사이클 시작에 읽고 → 일하고 → 사이클 끝(다음 엣지)에 씁니다. 같은 레지스터를 한 사이클에 읽고 쓰는 일이 자주 일어나는데, 이게 가능한 이유가 바로 이 클러킹 규약 덕분입니다.
Asserted vs Deasserted
제어 신호 1비트는 둘 중 하나입니다.
- asserted = 활성, 보통 1. “하라”는 뜻.
- deasserted = 비활성, 보통 0. “하지 마라” 또는 “관심 없음”.
가끔 active-low(0이 활성) 신호도 있는데, 그땐 RegWrite#처럼 #이나 막대를 붙여서 표시합니다.
헷갈리니까 일단 active-high만 쓰겠습니다.
제어 신호 vs 데이터 신호
회로 안의 선들은 두 종류로 나뉩니다.
- 데이터 신호 — 실제 값(레지스터 값, 메모리 값, ALU 결과). 보통 폭이 넓습니다 (32비트, 64비트).
- 제어 신호 — “이 멀티플렉서는 위쪽 입력을 골라라”, “레지스터에 써라” 같은 명령. 보통 1~몇 비트짜리 가는 선.
답
너무 짧으면 조합 회로가 다 안정되기 전에 다음 엣지가 와서 틀린 값이 레지스터에 저장됩니다. 너무 길면 단순히 성능 손해 — 회로는 진작 끝났는데 다음 엣지를 멍하니 기다립니다.4.3 데이터패스 만들기
이제 진짜로 회로를 그립니다. 한 번에 다 그리면 어지러우니까, 명령어 종류를 하나씩 늘려가며 부품을 추가해봅시다.
3.1 기본 부품들
- PC (Program Counter)
- 다음에 실행할 명령어 주소를 들고 있는 레지스터. 매 사이클 4를 더해서 갱신됩니다 (RISC-V는 명령어가 4바이트).
- Instruction Memory
- PC를 입력받아 그 주소의 32비트 명령어를 뱉는 메모리. 4장에선 “읽기 전용”으로 가정해서 단순화합니다.
- Register File
- 32개의 64비트 레지스터를 들고 있는 작은 메모리. 읽기 포트 2개 + 쓰기 포트 1개가 기본 사양입니다. 대부분의 명령어가 두 개 읽고 한 개 쓰니까요.
- ALU
- 덧셈, 뺄셈, AND, OR, 그리고 비교용 「Zero」 신호까지 뱉는 만능 계산기. 4비트 ALU 제어 입력으로 무슨 연산을 할지 정합니다.
- Sign-Extend 유닛
- 12비트나 13비트짜리 즉시값을 64비트로 「부호 확장」해주는 회로. 음수도 음수답게 늘려줘야죠.
- Branch Target Adder
beq처럼 분기 주소를 계산할 때 PC와 즉시값을 더해주는 또 하나의 가산기. ALU 본체가 그동안 다른 일을 해야 해서 별도 가산기를 둡니다.
3.2 R-type만 있는 작은 세상
가장 단순한 시나리오부터. add x5, x6, x7 같은 R-type 명령어만 있다고 칩시다.
필요한 흐름은 이렇습니다.
- PC → Instruction Memory → 32비트 명령어를 가져온다.
- 명령어에서
rs1,rs2,rd필드를 뽑아 레지스터 파일에 입력으로 준다. - 레지스터 파일이
rs1,rs2의 값을 두 포트로 동시에 읽어 ALU에 넣는다. - ALU가 계산하고, 결과를 다시 레지스터 파일의
rd에 쓴다 (RegWrite=1). - PC ← PC + 4.
3.3 ld / sd 추가하기
이제 메모리 접근이 끼어듭니다. ld x5, 8(x6)은 “x6에 8을 더한 주소에서 8바이트 읽어 x5에 넣어라”라는 뜻이죠.
뭐가 새로 필요할까요?
- Sign-extend된 즉시값이 ALU의 두 번째 입력으로 들어가야 합니다. R-type일 땐
rs2가 들어가야 하고요. 그래서 멀티플렉서가 필요합니다 (제어 신호 이름:ALUSrc). - ALU가 계산한 주소로 Data Memory를 읽거나(MemRead) 써야(MemWrite) 합니다.
ld의 경우 메모리에서 읽은 값을rd에 써야 하고, R-type의 경우 ALU 결과를 써야 합니다. 또 멀티플렉서가 필요합니다 (MemtoReg).
멀티플렉서는 「자원 공유」의 핵심 부품이에요. 두 종류의 데이터가 같은 목적지로 가야 할 때, 적절한 쪽을 골라주는 ‘교통경찰’입니다.
3.4 beq 추가하기
beq x5, x6, label은 “두 레지스터가 같으면 PC를 label로, 아니면 PC+4로”라는 뜻입니다.
필요한 추가 부품은 다음과 같습니다.
- ALU가 두 레지스터를 빼고, 결과가 0이면
Zero신호를 뱉어 「같다」를 알린다. - 분기 타깃 주소 = PC + (sign-extend(immediate) « 1). 별도 가산기로 계산.
- PC의 다음 값은
(PC+4)또는분기 타깃중 하나 — 이건Branch && Zero신호로 멀티플렉서가 결정.
4.4 단순 구현 (Single-Cycle)
부품들을 다 모아서 「한 사이클에 명령어 하나」 끝내는 가장 단순한 프로세서를 만듭니다. 이 디자인의 매력은 정말 단순하다는 것 — 한 명령어 = 한 사이클 = CPI 1.0. 끝.
ALU 제어 — 4비트, 두 단계
ALU는 4비트짜리 제어 입력으로 어떤 연산을 할지 결정합니다. R-type은 funct 필드를 보고, ld/sd는 무조건 덧셈, beq는 무조건 뺄셈이죠. 그런데 메인 제어가 이걸 다 알 필요는 없습니다. 두 단계로 나누면 깔끔해져요.
- 메인 제어는 ALUOp(2비트)만 내보냄. “R-type처럼 굴어”, “덧셈만 해”, “뺄셈만 해” 같은 큰 분류.
- 그러면 ALU 제어 회로가 ALUOp + funct 필드를 보고 진짜 4비트 ALU 제어 신호를 만든다.
ALUOp 인코딩 예시:
| 명령어 | ALUOp | 해석 | 최종 4비트 |
|---|---|---|---|
| ld / sd | 00 | 무조건 덧셈 | 0010 |
| beq | 01 | 무조건 뺄셈 | 0110 |
| add | 10 | funct 보고 결정 | 0010 |
| sub | 10 | funct 보고 결정 | 0110 |
| and | 10 | funct 보고 결정 | 0000 |
| or | 10 | funct 보고 결정 | 0001 |
메인 제어 유닛 — 7개의 신호
명령어의 opcode를 보고, 데이터패스의 모든 멀티플렉서·메모리·레지스터 파일에게 “이번 사이클은 이렇게 굴러가”라고 지시하는 회로입니다. 제어 신호 한 줄씩 살펴봅시다.
| 신호 | 0일 때 | 1일 때 |
|---|---|---|
| RegWrite | 레지스터 파일에 쓰지 않음 | WB 단계에서 rd에 씀 |
| ALUSrc | ALU 두 번째 입력 = rs2 값 | ALU 두 번째 입력 = sign-extended immediate |
| MemRead | 데이터 메모리 읽지 않음 | ALU 결과 주소에서 읽음 |
| MemWrite | 데이터 메모리 쓰지 않음 | rs2 값을 ALU 결과 주소에 씀 |
| MemtoReg | 레지스터에 쓸 값 = ALU 결과 | 레지스터에 쓸 값 = 메모리에서 읽은 값 |
| Branch | 분기 명령 아님 | 분기 명령 (Zero와 AND해서 PC를 결정) |
| ALUOp (2비트) | 위 표 참고 | |
그러면 명령어별 제어 신호는 다음처럼 정리됩니다.
| 명령 | RegWrite | ALUSrc | MemRead | MemWrite | MemtoReg | Branch | ALUOp |
|---|---|---|---|---|---|---|---|
| R-type | 1 | 0 | 0 | 0 | 0 | 0 | 10 |
| ld | 1 | 1 | 1 | 0 | 1 | 0 | 00 |
| sd | 0 | 1 | 0 | 1 | X | 0 | 00 |
| beq | 0 | 0 | 0 | 0 | X | 1 | 01 |
X는 “돈 케어(don’t care)” — 어차피 그 신호의 결과가 안 쓰이는 상황이라 0/1 아무거나 좋다는 뜻입니다. 하드웨어 설계에선 이 X들을 잘 활용하면 회로가 더 작아지기도 해요.
왜 single-cycle은 멸종했을까?
한 사이클에 모든 명령어를 끝내려면, 클럭 주기를 가장 느린 명령어에 맞춰야 합니다.
여기서 가장 느린 친구는 보통 ld입니다 — IF → 레지스터 읽기 → ALU(주소 계산) → 데이터 메모리 읽기 → WB까지 다 한 사이클에 해야 하니까요.
그런데 add는 데이터 메모리 단계가 필요 없잖아요? 그걸 ld의 사이클 시간만큼 기다려주는 건 너무 손해입니다.
한 줄로 요약: “가장 느린 친구가 모든 친구의 속도를 결정한다.”
그래서 진짜 프로세서는 single-cycle을 쓰지 않습니다. 대신 파이프라이닝이라는 더 영리한 트릭을 씁니다. 드디어 4장의 진짜 주제로 넘어가요.
4.5 파이프라이닝 — 김밥 공장의 깨달음
- 밥 깔기 — 1분
- 속재료 올리기 — 1분
- 김으로 말기 — 1분
- 썰기 — 1분
- 포장하기 — 1분
RISC-V의 5단계
이 책의 모든 RISC-V 파이프라인은 5단계입니다. 외워두면 평생 우려먹습니다.
- IF (Instruction Fetch) — 명령어를 메모리에서 가져온다.
- ID (Instruction Decode) — 명령어를 해독하고 레지스터를 읽는다.
- EX (Execute) — ALU가 일한다 (산술 연산, 주소 계산, 분기 비교 등).
- MEM (Memory Access) — 데이터 메모리를 읽거나 쓴다.
- WB (Write Back) — 결과를 레지스터에 다시 쓴다.
처리량(throughput) vs 지연시간(latency)
파이프라이닝이 빨라지는 이유를 정확히 이해하려면 두 단어를 구분해야 합니다.
- 지연시간 (latency) — 한 명령어가 IF에 들어가서 WB까지 끝나기까지 걸린 시간. 5단계니까 5사이클.
- 처리량 (throughput) — 단위 시간당 끝나는 명령어 수. 이상적으로 매 사이클 1개씩 — 즉, IPC = 1, CPI = 1.
파이프라이닝은 지연시간을 줄여주지 않습니다. 한 명령어 자체의 처리 시간은 비슷하거나 오히려 늘어요(파이프라인 레지스터 오버헤드). 대신 여러 명령어를 동시에 처리해서 처리량을 끌어올립니다. CPU 광고에 “3GHz, IPC 3”이라고 적힌 거, 다 이거 얘기예요.
RISC-V는 파이프라이닝을 위해 태어났다
예전 CISC 명령어 집합(8086, VAX 같은 친구들)으로 파이프라인을 만들면 머리가 폭발합니다. 명령어 길이가 다 다르고, 한 명령어가 메모리를 두세 번 만지고, 부작용이 따로 있고… 반면 RISC-V는 처음부터 파이프라이닝을 염두에 두고 설계됐어요.
- 모든 명령어가 같은 길이 (32비트). IF 단계가 단순해집니다 — 무조건 4바이트 가져오면 끝.
- 포맷 종류가 적다. ID 단계에서
rs1,rs2가 항상 같은 위치라 디코딩이 빠릅니다. - 메모리 접근은 ld/sd만. 다른 명령어는 메모리를 안 만지니까, MEM 단계가 한가할 때가 많고 자료 의존성도 단순합니다.
- 피연산자는 항상 정렬. 메모리 접근 단계에서 두 워드를 걸치는 경우가 없어서 회로가 단순해집니다.
4.5+ 파이프라인 해저드
김밥 공장이 항상 매끄럽게 굴러가면 좋겠지만, 현실은 그렇지 않습니다. 알바들 사이에 「충돌」이 생기죠. 이걸 파이프라인 해저드(hazard)라고 부릅니다. 세 종류가 있어요.
1) 구조적 해저드 (Structural Hazard)
같은 자원을 두 단계가 동시에 쓰려고 할 때 발생합니다. 예컨대 명령어 메모리와 데이터 메모리가 하나로 통합돼 있으면, IF가 명령어를 가져오는 동안 MEM이 데이터를 못 가져오죠.
RISC-V 5단계 파이프라인은 처음부터 명령어 메모리와 데이터 메모리를 분리해서(또는 캐시를 둘로 나눠서) 이 문제를 피합니다. 레지스터 파일도 「한 사이클에 두 번 읽고 한 번 쓰기」가 가능하게 설계해서, ID(읽기)와 WB(쓰기)가 같은 사이클에 일어나도 충돌 안 납니다.
2) 데이터 해저드 (Data Hazard)
앞 명령어의 결과를 뒤 명령어가 필요로 할 때, 그 결과가 아직 레지스터 파일에 안 들어갔으면 문제가 됩니다.
add x1, x2, x3 // x1 = x2 + x3 (결과는 5사이클 후 WB에 기록)
sub x4, x1, x5 // 그런데 sub은 ID에서 x1을 읽으려 함
add의 결과는 4사이클 뒤에야 레지스터 파일에 들어갑니다. 그런데 sub은 바로 다음 사이클에 ID 단계에 들어가서
x1을 읽으려 하죠. 망했습니다.
포워딩(Forwarding) — 일명 「먼저 알려주기」
멋진 트릭입니다. add의 결과는 사실 EX 단계 끝나면 이미 ALU 출력에 떠 있어요. 굳이 WB까지 기다릴 필요가 없죠.
그래서 EX/MEM 파이프라인 레지스터에서 직접 ALU 입력으로 보내주기로 합니다. 이게 포워딩(forwarding) 또는 바이패싱(bypassing)이에요.
포워딩 덕분에 대부분의 「뒤따르는 명령어가 앞 결과를 쓰는」 상황은 스톨 없이 해결됩니다. 더 자세한 규칙은 4.7에서 봅시다.
로드-사용 해저드 (Load-Use Hazard) — 포워딩으로도 안 되는 경우
그런데 ld의 결과는 EX가 아니라 MEM 단계가 끝나야 나옵니다. 그래서 바로 다음 명령어가 그 값을 쓰려고 하면
포워딩만으로는 부족해요. 한 사이클은 버블(bubble)을 끼워서 기다려야 합니다.
ld x1, 0(x2) // MEM 끝에야 x1이 준비됨
add x3, x1, x4 // 1사이클 stall 필요
이 경우 컴파일러가 ld와 add 사이에 무관한 명령어를 끼워 넣으면 stall이 사라집니다. 코드 재정렬이라고 해요.
똑똑한 컴파일러는 이걸 자동으로 합니다.
3) 제어 해저드 (Control Hazard)
분기 명령(beq 같은 친구)의 결과는 EX(또는 더 늦은) 단계에서 나옵니다. 그런데 IF는 매 사이클 다음 명령어를 가져와야 하잖아요?
분기가 어디로 갈지 모르는 동안 어떻게 할까요?
- 예측 안 함 (stall) — 그냥 멈춤. 가장 안전하지만 느림.
- Predict Not-Taken — “안 분기할 거야”라고 가정하고 PC+4를 계속 가져옴. 틀리면 그동안 가져온 명령어 버리기(flush).
- Predict Taken — “분기할 거야”라고 가정. 단, 분기 타깃 주소를 일찍 알아야 의미 있음.
- Dynamic Prediction — 과거 기록을 보고 학습. 1비트 / 2비트 예측기, BTB, 상관 예측기 등 — 4.8에서 자세히.
- Delayed Branch — 옛날 MIPS가 쓰던 방식. 분기 명령 바로 뒤의 「분기 지연 슬롯」 명령어는 분기 결과와 무관하게 항상 실행. 컴파일러가 그 자리에 안전한 명령어를 끼움. RISC-V는 안 씁니다.
4.6 파이프라인 데이터패스와 제어
이제 single-cycle 데이터패스를 잘게 썰어서 5단계 파이프라인으로 변신시킬 차례입니다. 핵심 아이디어는 파이프라인 레지스터예요.
파이프라인 레지스터 — 단계 사이의 「임시 보관함」
각 단계 사이마다 큰 레지스터를 끼워 넣습니다. 한 단계가 만들어낸 모든 정보(데이터 + 제어 신호)를 다음 단계가 쓸 수 있게 다음 사이클까지 들고 있어주는 사물함이에요.
- IF/ID — 가져온 명령어와 PC+4 값을 보관.
- ID/EX — 디코딩한 레지스터 값들, 즉시값(부호확장 후), 그리고 EX/MEM/WB에서 쓸 제어 신호.
- EX/MEM — ALU 결과, Zero 비트, 메모리에 쓸 데이터, 그리고 MEM/WB 제어 신호.
- MEM/WB — 메모리에서 읽은 값, ALU 결과(R-type일 때), WB 제어 신호.
제어 신호의 「행진」
재미있는 건 제어 신호도 같이 흘러간다는 점입니다. ID 단계에서 메인 제어가 신호 한 묶음을 만들어내면, 그중 EX에서 쓸 건 EX 단계로, MEM에서 쓸 건 MEM 단계로, WB에서 쓸 건 WB 단계로 — 마치 회사 인수인계 문서가 단계별로 넘어가듯 파이프라인 레지스터에 실려 「행진」합니다.
한 사이클에 여러 명령어가 동시에 진행 중이라, 각 명령어마다 자기만의 제어 신호 묶음이 따라다녀야 하거든요. 그래서 이 행진이 필수입니다.
| 단계 | 이 단계에서 쓰는 신호 |
|---|---|
| EX | ALUOp, ALUSrc |
| MEM | Branch, MemRead, MemWrite |
| WB | RegWrite, MemtoReg |
파이프라인 다이어그램 — 시간 vs 단계
파이프라인을 시각화할 때는 보통 가로축이 시간(사이클), 세로축이 명령어인 표를 그립니다.
| 명령어 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| I1 | IF | ID | EX | MEM | WB | |||
| I2 | IF | ID | EX | MEM | WB | |||
| I3 | IF | ID | EX | MEM | WB | |||
| I4 | IF | ID | EX | MEM | WB |
한 사이클에 다섯 단계가 동시에 진행 중인 게 보이시죠? 5단계 파이프라인의 진가는 이 「대각선의 미학」에 있습니다.
4.7 데이터 해저드: 포워딩 vs 스톨
이제 가장 유명한 해저드를 정밀하게 들여다봅시다. 「누가 누구한테 값을 보내야 하는가」 — 이 질문 하나가 4.7의 전부예요.
포워딩 유닛의 두 가지 케이스
EX 단계에 있는 명령어가 ALU 입력으로 두 레지스터를 받는데, 그 값이 정말 레지스터 파일에서 읽은 「오래된 값」인지, 아니면 앞 명령어가 만든 「최신 값」인지 따져봐야 합니다. 가능한 경우는 두 가지뿐이에요.
- EX 해저드 — 바로 직전 명령어(현재 EX/MEM 사이에 있는)가 ALU 결과를 만들었고, 그 결과 레지스터가 지금 EX 단계의 rs1 또는 rs2와 같다. → EX/MEM 파이프라인 레지스터의 ALU 결과를 ALU 입력으로 직접 포워딩.
- MEM 해저드 — 두 사이클 전 명령어(현재 MEM/WB 사이에 있는)의 결과가 지금 EX 단계의 rs1 또는 rs2와 같다. → MEM/WB 파이프라인 레지스터의 값(ALU 결과 또는 메모리에서 읽은 값)을 포워딩.
포워딩 유닛은 4비트 멀티플렉서 제어 신호 두 개(ForwardA, ForwardB)를 만들어,
ALU 입력 멀티플렉서가 「레지스터 파일 / EX/MEM / MEM/WB」 셋 중 어디서 값을 가져올지 결정하게 합니다.
EX 해저드 조건
if (EX/MEM.RegWrite
and (EX/MEM.RegisterRd ≠ 0)
and (EX/MEM.RegisterRd == ID/EX.RegisterRs1))
ForwardA = 10 // EX/MEM에서 가져와
MEM 해저드 조건 — 그리고 우선순위
if (MEM/WB.RegWrite
and (MEM/WB.RegisterRd ≠ 0)
and not (EX/MEM.RegWrite and EX/MEM.RegisterRd == ID/EX.RegisterRs1)
and (MEM/WB.RegisterRd == ID/EX.RegisterRs1))
ForwardA = 01 // MEM/WB에서 가져와
여기서 중요한 게 최신 값 우선 규칙입니다. 만약 가까운 앞 명령어(EX/MEM)와 더 앞 명령어(MEM/WB)가 둘 다 같은 레지스터에 쓰는 경우, 당연히 더 최근의 값(EX/MEM)을 써야죠. 그래서 MEM 해저드 조건에는 “EX 해저드가 아닐 때만”이라는 조건이 붙습니다.
또 x0(RISC-V의 zero 레지스터)는 영원히 0이라, 거기에 쓰는 척하는 명령어가 있어도 포워딩하면 안 됩니다.
그래서 RegisterRd ≠ 0 조건이 들어가요.
해저드 검출 유닛 — 로드-사용은 stall이 답이다
포워딩으로도 안 되는 경우가 하나 남았죠. ld의 결과는 MEM 끝에야 나오므로, 바로 다음 명령어가 그 값을 EX에서 쓰려 하면
아직 값이 없는 상태예요. 미래에서 가져올 수는 없으니, 한 사이클 기다리는 수밖에 없습니다.
해저드 검출 유닛은 ID 단계에서 다음 조건을 검사합니다.
if (ID/EX.MemRead
and ((ID/EX.RegisterRd == IF/ID.RegisterRs1)
or (ID/EX.RegisterRd == IF/ID.RegisterRs2)))
stall_pipeline()
이 조건이 참이면, 한 사이클 동안 다음 일이 벌어집니다.
- PC와 IF/ID 레지스터를 그대로 유지(=같은 명령어를 한 번 더 가져옴, 또는 그냥 멈춤).
- ID/EX 레지스터의 모든 제어 신호를 0으로 만든다 — “아무 일도 하지 마”의 NOP 버블을 끼우는 셈.
한 사이클 뒤에는 ld가 MEM/WB로 넘어가, 평소처럼 MEM 해저드 포워딩으로 처리할 수 있게 됩니다.
코드 재정렬로 stall 줄이기
컴파일러는 영리해서 load 결과가 즉시 필요하지 않게 코드를 살짝 비틀어 놓습니다.
// 원본 — load-use stall 1번 발생
ld x1, 0(x10)
add x2, x1, x3
ld x4, 8(x10)
add x5, x4, x6
// 재정렬 — load 둘을 앞당겨 stall 0번
ld x1, 0(x10)
ld x4, 8(x10)
add x2, x1, x3
add x5, x4, x6
똑같은 결과인데 stall이 사라졌어요. 이런 「실행 순서 바꾸기」를 instruction scheduling이라고 합니다. 한국어로는 그냥 「명령어 스케줄링」이라고 부르는 편이에요.
4.8 제어 해저드와 분기 예측
분기 명령은 파이프라인의 골치 거리입니다. 다음에 어디로 가야 할지를 결정하기 전에는 IF 단계가 무엇을 가져와야 하는지 알 수 없거든요. 가장 단순한 단계까지로 미루면 분기는 EX 단계 끝에야 결정되고, 그동안 3사이클이 그냥 날아갑니다. 너무 비싸요.
분기 결정을 ID 단계로 옮기기
가장 직접적인 해결책은 분기를 더 일찍 결정하는 겁니다. 두 레지스터의 동등성 비교는 사실 빼기 안 해도 되거든요 — XOR 게이트로 충분합니다. 그래서 ID 단계 안에 작은 비교기와 분기 타깃 가산기를 추가합니다.
그러면 분기는 ID 끝에 결정되고, 잘못 페치된 명령어는 IF 한 개뿐입니다. flush 비용이 1사이클로 줄어요. 물론 ID 단계 자체는 약간 무거워지지만, 보통 이 트레이드오프는 이득입니다.
예측이 틀렸을 때 — Flush
분기 예측이 틀리면, 잘못 가져온 명령어들을 파이프라인에서 「취소」해야 합니다. 이걸 flush라고 합니다. 구체적으로는 IF/ID, ID/EX 같은 파이프라인 레지스터의 제어 신호를 0으로 만들어서, 그 명령어들이 아무 일도 안 하게 만듭니다 — 「버블화」시키는 거예요.
1비트 분기 예측기 — 가장 단순한 학습
“이 분기 명령은 지난번에 분기했지? 그럼 이번에도 분기할 거야”라는 단순한 신뢰의 알고리즘입니다. 각 분기 명령어 주소마다 1비트 「최근 결과 기록」을 들고 있어요.
- 비트가 1이면 분기 예측 = taken (PC ← target)
- 비트가 0이면 분기 예측 = not taken (PC ← PC+4)
- 실제 결과가 나오면 비트를 그 결과로 갱신
문제는, 루프 끝에서 빠져나갈 때처럼 분기 패턴이 “TTTTTN”인 경우 마지막 N에서 틀리고, 다음 루프 첫 번째 T에서도 틀립니다. 한 번의 예외가 두 번의 오예측을 낳는 셈이죠. 그래서 1비트 예측기는 정확도가 90% 안팎에서 한계입니다.
2비트 분기 예측기 — “두 번 틀려야 마음 바꾼다”
아이디어는 단순합니다. 「관성」을 추가해요. 한 번의 예외로는 예측을 안 바꾸고, 두 번 연속 틀려야 비로소 마음을 바꿉니다. 이걸 4상태 FSM(유한 상태 기계)으로 표현해요.
| 상태 | 예측 | actual = T일 때 | actual = N일 때 |
|---|---|---|---|
| Strongly Taken (11) | Taken | Strongly Taken (11) | Weakly Taken (10) |
| Weakly Taken (10) | Taken | Strongly Taken (11) | Weakly Not Taken (01) |
| Weakly Not Taken (01) | Not Taken | Weakly Taken (10) | Strongly Not Taken (00) |
| Strongly Not Taken (00) | Not Taken | Weakly Not Taken (01) | Strongly Not Taken (00) |
루프 종료 같은 경우, 종료 시 한 번 틀리지만 상태는 “Weakly Taken”에서만 살짝 흔들리고 다음 루프엔 곧장 Taken을 예측해 맞힙니다. 오예측이 1번에서 끝나니 1비트 예측기보다 훨씬 좋아요.
분기 타깃 버퍼 (BTB)
예측이 “taken”이라면 다음 PC를 분기 타깃으로 바꿔야 하는데, 타깃 주소를 ID에서 계산하면 또 1사이클이 늦어요. 그래서 BTB(Branch Target Buffer)라는 작은 캐시를 두고, 분기 명령어 주소를 키로 「예측된 타깃 주소」를 IF 단계에서 바로 꺼냅니다. 예측이 맞으면 분기에 패널티가 거의 0이에요.
상관 예측기 (Correlating Predictor)
개별 분기의 과거뿐 아니라, 최근 분기들의 패턴까지 같이 보는 친구입니다. 예를 들어, “직전 두 분기가 TT였으면 이 분기는 N”처럼 글로벌 히스토리와 결합해서 예측 정확도를 끌어올려요. 이름하여 (m, n) 예측기 — 최근 m개의 분기 결과를 기억하고, 각 패턴마다 n비트 예측기를 둡니다.
토너먼트 예측기 (Tournament Predictor)
한 가지 예측기로는 모든 분기를 잘 맞히기 힘듭니다. 어떤 분기는 지역적 패턴을, 어떤 분기는 전역 패턴을 따르거든요. 그래서 두 가지 예측기를 동시에 돌리고, 각 분기마다 “요즘 어느 예측기가 더 잘 맞히지?”를 또 다른 예측기가 학습해 골라줍니다. 이게 토너먼트 예측기 — 사람으로 치면 “회의에서 의견 두 명 듣고 그중 평소 잘 맞히는 사람 손 들어주기”입니다.
지연 분기 (Delayed Branch) — 옛날 얘기
MIPS 같은 옛 ISA는 분기 명령 바로 뒤의 「지연 슬롯」 명령어를 무조건 실행하기로 약속해뒀습니다. 컴파일러가 그 자리에 분기 결과와 무관한 안전한 명령어를 끼워 넣으면 분기 패널티를 0으로 만들 수 있죠. 그런데 깊은 파이프라인이나 슈퍼스칼라에서는 슬롯 하나로는 부족하고, ISA에 못 박혀 있으니 후세의 짐이 됩니다. 그래서 RISC-V는 지연 분기를 안 씁니다. 그냥 분기 예측에 베팅하는 게 더 깔끔하다는 결론이에요.
답
0.20 × 0.10 × 3 = 0.06사이클. CPI가 1.0에서 1.06으로 늘어요. 같은 조건에서 1비트 → 2비트로 정확도를 95%로 올리면 0.03사이클로 줄고, 그게 누적되면 큰 차이가 됩니다.4.9 예외 처리
파이프라인이 매끄럽게 굴러가다가도, 가끔 예상 밖의 사건이 터집니다. 예외(exception)는 프로그램 내부에서, 인터럽트(interrupt)는 외부에서 발생하는 「프로그램 흐름의 강제 일시정지」예요. 실은 둘 다 비슷한 메커니즘으로 처리합니다.
예외의 종류
- 정의되지 않은 명령어 — 디코더가 “이게 뭐야”라며 거부.
- 산술 오버플로우 — ALU 결과가 표현 범위를 넘었을 때 (RISC-V는 기본적으로 signal 안 함, 옵션).
- 페이지 폴트 — 메모리 접근하려는데 가상 메모리에 매핑이 없을 때.
- I/O 인터럽트 — 키보드, 디스크, 네트워크 카드가 “저 좀 봐주세요” 신호를 보냄.
- 시스템 콜 — 사용자 프로그램이 OS 서비스 요청.
핸들러 호출 — 예외 처리 절차
예외가 발생하면, 프로세서는 사실상 「특이한 분기」를 합니다. 다음 단계를 거쳐요.
- 예외를 일으킨 명령어와 그 뒤 명령어들을 모두 flush한다.
- 예외 원인을
SCAUSE같은 시스템 레지스터에 기록한다 (RISC-V CSR). - 예외 발생 시점의 PC를
SEPC에 저장한다. - PC를 핸들러 주소로 점프시킨다.
- 핸들러가 처리 후
sret으로 원래 위치로 복귀.
벡터드 vs 단일 진입
- 단일 진입(single entry) — 예외 종류 무관하게 한 주소로 점프, 핸들러가 SCAUSE 보고 분기.
- 벡터드(vectored) — 예외 종류별로 미리 정해진 주소로 바로 점프. 더 빠르지만 메모리 차지.
Precise vs Imprecise Exception
파이프라인에서는 한 사이클에 여러 명령어가 다른 단계에 떠 있어요. 그래서 “정확히 누가 예외를 일으켰나”를 분명히 하지 않으면 OS가 어떻게 복구해야 할지 헷갈립니다.
- Precise exception — 예외를 일으킨 명령어 직전까지의 모든 변경은 완료, 그 이후 명령어의 변경은 없음. 직관적이고 디버깅 쉬움.
- Imprecise — 일부 「뒷 명령어」가 이미 부분 실행됐을 수 있음. 빠르지만 OS 입장에선 악몽. 현대 ISA는 거의 다 precise를 보장합니다.
4.10 명령어 수준 병렬성 (ILP)
5단계 파이프라인은 매 사이클 명령어 하나씩 처리해서 CPI를 1로 만들었습니다. 야망 있는 설계자는 여기서 멈추지 않아요. 한 사이클에 여러 명령어를 동시에 시작하면 어떨까? 이게 다중 발행(multiple issue)의 출발점입니다.
정적(Static) vs 동적(Dynamic) 다중 발행
- 정적 — 컴파일러가 컴파일 시점에 어떤 명령어들을 같이 발행할지 결정. 하드웨어는 컴파일러를 믿고 단순.
- 동적 — 하드웨어가 런타임에 발행 여부를 결정. 회로는 복잡하지만 컴파일러가 모르는 정보(캐시 미스, 분기 결과)를 활용 가능.
VLIW — “묶음 명령어”
Very Long Instruction Word. 한 명령어 안에 여러 연산을 슬롯별로 박아 넣고, 컴파일러가 그 슬롯을 채워주는 방식이에요. 하드웨어는 그냥 슬롯대로 병렬 실행하면 끝입니다. Itanium이 이 길로 갔는데, 현실의 코드 패턴이 컴파일러 예측을 자주 빗나가서 결국 시장에서 졌습니다.
루프 언롤링 — 컴파일러의 무기
// 원래
for (i = 0; i < n; i++) a[i] = a[i] + s;
// 4번 언롤링
for (i = 0; i < n; i += 4) {
a[i ] = a[i ] + s;
a[i+1] = a[i+1] + s;
a[i+2] = a[i+2] + s;
a[i+3] = a[i+3] + s;
}
네 번을 한 덩어리로 펼치면 분기 오버헤드가 1/4로 줄고, 안의 네 연산은 서로 독립적이라 병렬 실행하기도 좋습니다. 하지만 같은 레지스터를 네 번 재사용하면 가짜 의존성이 생기는데, 그건 다음 친구가 해결합니다.
레지스터 리네이밍 — 가짜 의존성을 지우는 마법
소스 코드에서 같은 변수 x가 여러 번 나오면, 어셈블리에선 같은 레지스터에 반복해서 쓰여요.
그런데 「이름이 같다」는 이유만으로 의존성이 생기는 건 가짜 의존성(이름 의존성)이지, 진짜 데이터 흐름은 아닙니다.
하드웨어가 「논리 레지스터」를 「물리 레지스터」로 매핑해 매번 새 이름을 주면 이 가짜 의존성이 사라집니다. 이게 register renaming이에요.
슈퍼스칼라 (Superscalar)
한 사이클에 두 개 이상의 명령어를 발행할 수 있는 동적 다중 발행 프로세서. 발행 폭(issue width)이 4면 이론상 IPC 4까지 가능합니다. 실제로는 의존성 때문에 그 절반도 못 가는 경우가 많아요.
Out-of-Order 실행
프로그래머가 적은 순서대로 실행할 필요가 없습니다. 의존성만 안 어기면 어떤 순서로도 OK죠. 그래서 현대 고성능 CPU는 다음 흐름을 따릅니다.
- Fetch & Decode — 명령어를 in-order로 읽고 해독한다.
- Issue / Dispatch — 적당한 실행 유닛 옆 「예약 스테이션(reservation station)」에 명령어를 보낸다.
- Execute — 예약 스테이션은 자기 명령어의 피연산자가 다 도착한 순간 실행 유닛으로 보낸다. 의존성이 풀린 순서대로!
- Write Result — 결과를 공통 데이터 버스(CDB)로 뿌려, 같은 값을 기다리는 다른 예약 스테이션에 즉시 도착시킨다.
- Commit — Reorder Buffer(ROB)가 명령어를 원래 프로그램 순서로 「커밋」해, 사용자에게 보이는 상태(레지스터, 메모리)는 in-order로 갱신한다.
예약 스테이션과 ROB의 협주
- Reservation Station — 각 실행 유닛 옆 대기실. 「피연산자가 다 도착한 명령어」가 발사 대기.
- Reorder Buffer — 발행된 모든 명령어가 결과를 저장하는 큐. 가장 오래된 친구부터 차례로 commit.
- Commit Unit — ROB 머리에서 한 명씩 끌어내려 진짜 레지스터/메모리를 갱신. 분기 오예측이나 예외가 생기면 이 시점에서 ROB 뒷부분을 통째로 버린다.
4.11 실제 사례 — Cortex-A53 vs Core i7
교과서식 5단계는 학습용이고, 진짜 칩들은 어떨까요?
ARM Cortex-A53 — 「전기 적게 먹는 모범생」
- 2-issue, in-order, 8단계 파이프라인.
- 모바일·임베디드 시장의 단골. ARMv8-A 64비트.
- 분기 예측은 갖췄지만 OoO는 없음. 컴파일러가 어느 정도 일을 떠맡아야 함.
- 면적과 전력 효율이 1순위. 성능은 그다음.
Intel Core i7 — 「뭐든 다 욱여넣는 슈퍼카」
- 4~6-issue 슈퍼스칼라, out-of-order, 14~19단계 파이프라인.
- 대형 ROB(수백 개), 다중 분기 예측기, 다단계 캐시, SMT(하이퍼스레딩).
- x86 ISA를 받아 마이크로옵(uop)으로 변환 후 그걸 OoO 파이프라인에 흘려보냄.
- 전력은 폭발적이지만 성능 한계까지 끌어올림.
4.12 가속 — DGEMM with ILP
DGEMM은 행렬 곱(double-precision GEMM) 루틴인데, 과학 계산의 「밥」이라 부를 만큼 자주 등장합니다. 이걸 빨리 돌리는 게 성능 최적화의 시금석이에요.
- ILP 활용 — 작은 블록 안의 여러 곱-누산을 동시에 발행해 슈퍼스칼라 자원을 채운다.
- SIMD/벡터 — 한 명령어로 여러 double을 동시에 처리(AVX, NEON, RVV).
- 레지스터 블로킹 — 작은 행렬 블록을 레지스터에 잡아두고 외부 메모리 접근을 줄인다.
- 루프 언롤링 — 분기 오버헤드 줄이기, 의존성 체인 끊기.
이런 기법들을 다 적용하면 같은 하드웨어에서 처음 「naive」 코드 대비 10배 이상 빨라지는 일이 흔해요. 그래서 BLAS 라이브러리(OpenBLAS, MKL 등)는 사람이 손으로 어셈블리를 튜닝하는 경우가 아직도 있습니다.
4.14 오류와 함정
함정 1 — “더 깊은 파이프라인은 무조건 더 빠르다”
단계가 깊어질수록 각 단계의 일 양은 줄어 클럭은 빨라지지만, 분기 오예측 패널티는 커지고 의존성 stall도 늘어납니다. Pentium 4의 31단계는 결국 Core 2의 14단계에 졌어요. 깊이는 약이자 독.
함정 2 — “파이프라이닝이 latency를 줄인다”
아닙니다. 파이프라이닝은 처리량(throughput)을 늘리는 기법이지, 한 명령어의 처리 시간(latency)은 오히려 살짝 늘어요. 파이프라인 레지스터 통과 비용 + 단계 사이 균형 맞추기 손실 때문입니다.
함정 3 — “슈퍼스칼라면 IPC가 발행 폭만큼”
이론과 현실은 다릅니다. 진짜 코드는 의존성, 분기 오예측, 캐시 미스로 자원을 다 못 채워요. 4-issue 프로세서가 평균 IPC 1.5만 찍어도 잘 한 거라는 게 업계 통설입니다.
오류 — “단순 구현이 충분히 빠르다”
단순 single-cycle 구현은 「가장 느린 명령어」에 클럭이 묶여 매우 느립니다. 교육용으로는 좋지만, 실제 칩에서는 거의 무가치해요. 파이프라이닝과 캐시는 선택이 아니라 필수입니다.
4.15 결론
이 장은 한 가지 큰 이야기였어요. “명령어 하나를 어떻게 더 빨리 돌리지?”에서 시작해 파이프라이닝, 해저드, 분기 예측, 다중 발행, 동적 스케줄링까지 옷장 속 옷을 다 꺼내 입혀봤습니다.
핵심을 다시 한 줄로 정리하면 이렇습니다.
- Single-cycle은 단순하지만 가장 느린 명령어에 묶인다.
- Pipelining은 매 사이클 명령어 하나씩 「졸업」시켜 throughput을 N배로 끌어올린다.
- 그 대가로 해저드가 생기고, 포워딩·stall·분기 예측으로 막아낸다.
- 여전히 욕심나면 슈퍼스칼라·OoO·ROB로 IPC를 1 위로 올린다.
- 모든 단계에서 ISA 설계, 컴파일러, 마이크로아키텍처는 한 팀으로 움직인다.
다음 5장에서는 이 모든 명령어들이 「어디서 데이터를 가져오는가」를 깊게 들여다봅니다. 레지스터 32개로는 부족하니까요. 캐시, TLB, 가상 메모리의 세계가 기다리고 있어요.