Chapter 08

ISA에서 하드웨어로: 파이프라인

ISA는 계약서일 뿐 회로가 아니다. 그런데 계약서를 어떻게 쓰느냐에 따라 그것을 지키는 회로가 쉬워지기도, 어려워지기도 한다. 이 장에서는 RV32I를 직접 단일 사이클 데이터패스와 5단 파이프라인으로 구현해 보면서, 해저드·포워딩·분기 예측·정밀한 예외 같은 구현의 고민을 만난다. 그리고 고정된 레지스터 칸, 부호 비트 위치, 플래그 없음, 지연 슬롯 없음 같은 RISC-V의 결정이 그 고민을 어떻게 덜어 주는지 확인한다.

명령 하나를 한 사이클에: 단일 사이클 데이터패스

가장 단순한 구현부터 시작하자. 클럭이 한 번 똑딱일 때마다 명령 하나를 처음부터 끝까지 처리하는 CPU다. 이런 CPU에서 데이터가 흐르는 길을 데이터패스(datapath)라 하고, 어느 길을 열고 닫을지 정하는 부분을 제어 유닛(control unit)이라 한다. RV32I의 거의 모든 명령은 다음 일곱 부품만 있으면 처리할 수 있다.

  1. PC: 지금 실행할 명령의 주소를 담은 레지스터.
  2. 명령어 메모리: PC가 가리키는 32비트 명령을 꺼낸다.
  3. 레지스터 파일: x0–x31. 읽기 포트 두 개(rs1, rs2)와 쓰기 포트 한 개(rd).
  4. 즉시값 생성기: I·S·B·U·J 형식에 흩어진 즉시값 비트를 모아 32비트로 부호 확장한다(4장).
  5. ALU: 더하기·빼기·논리·시프트·비교.
  6. 데이터 메모리: 적재·저장이 접근하는 메모리.
  7. 선택기(mux): ALU 두 번째 입력(rs2냐 즉시값이냐), 레지스터에 쓸 값(ALU·메모리·PC+4), 다음 PC(PC+4냐 분기 목적지냐)를 고른다.

아래 데이터패스에서 명령 분류를 바꿔 보자. 그 명령이 실제로 쓰는 선만 파란 점선으로 흐르고, 쓰지 않는 부품은 흐려진다. 선 옆의 작은 글자는 제어 유닛이 내보내는 제어 신호(control signal)의 값이다.

SIMULATOR

단일 사이클 RV32I 데이터패스

명령 분류
예시 명령—
기계어—
형식—
지연 (교과서 예)—
적재(lw)를 고르면 명령어 메모리 → 레지스터 파일 → ALU → 데이터 메모리 → WB 선택기 → 레지스터 파일까지 모든 큰 부품을 차례로 지나간다. 이 가장 긴 길이 단일 사이클 CPU의 클럭 주기를 정한다. 저장(sw)과 분기(beq)에서 WB 선택기가 꺼지는 이유도 생각해 보자.

제어 유닛은 opcode(그리고 funct3·funct7)만 보고 아래 표의 신호를 만든다. RISC-V는 형식이 여섯 가지뿐이고 opcode 칸이 언제나 [6:0]에 있어서, 이 표가 작고 규칙적이다.

분류 (예)RegWriteALUSrcImmSelALUOpMemReadMemWriteWBSel다음 PC
R형 add1rs2—funct3·funct700ALUPC+4
I형 산술 addi1immIfunct300ALUPC+4
적재 lw1immI더하기10메모리PC+4
저장 sw0immS더하기01—PC+4
분기 beq0rs2B비교00—성립 시 PC+imm
점프 jal1—J—00PC+4PC+imm
간접 점프 jalr1immI더하기00PC+4(rs1+imm) & ~1
상위 즉시값 lui1immUB 통과00ALUPC+4
명령마다 거치는 부품과 지연 (ps) 클럭 주기 = 800 ps add (R형) 명령 가져오기 읽기 ALU 쓰기 놀고 있는 시간 lw (적재) 명령 가져오기 읽기 ALU (주소) 데이터 메모리 쓰기 800 sw (저장) 명령 가져오기 읽기 ALU (주소) 데이터 메모리 beq (분기) 명령 가져오기 읽기 ALU (비교) 놀고 있는 시간 0 200 400 600 800 메모리 200 · ALU 200 · 레지스터 파일 읽기/쓰기 100 (Patterson·Hennessy 교과서의 예시 값)
그림 8-1. 단일 사이클 CPU는 모든 명령이 같은 클럭 주기를 쓰므로, 가장 오래 걸리는 lw(800 ps)에 클럭을 맞춰야 한다. add는 600 ps, beq는 500 ps면 충분한데도 매번 800 ps를 기다린다. 점선 상자가 그 낭비다. 이 낭비를 줄이려는 것이 파이프라인의 출발점이다.
왜 명령어 메모리와 데이터 메모리를 따로 그리나

한 사이클 안에 명령도 읽고 데이터도 읽어야 하므로 포트가 두 개 필요하다. 실제 칩에서는 명령어 캐시와 데이터 캐시를 따로 두는 방식(하버드식 L1)으로 이 요구를 맞춘다. 규격은 이런 구조를 전혀 요구하지 않는다. 그래서 명령어 쓰기가 명령어 가져오기에 보이게 하려면 fence.i(16장)로 명시적으로 동기화해야 한다.

5단 파이프라인: IF · ID · EX · MEM · WB

빨래를 생각해 보자. 세탁기가 돌아가는 동안 건조기를 놀리지 않으려면, 첫 빨래가 건조기로 넘어가자마자 다음 빨래를 세탁기에 넣으면 된다. CPU도 똑같다. 명령 처리를 다섯 단계로 자르고, 단계 사이에 값을 잠시 붙잡아 두는 파이프라인 레지스터(pipeline register)를 넣으면, 다섯 명령이 동시에 서로 다른 단계에 머물 수 있다. 명령 하나가 걸리는 시간(지연(latency))은 줄지 않지만, 단위 시간에 끝나는 명령 수(처리량(throughput))가 크게 는다.

단일 사이클 — 앞 명령이 끝나야 다음 명령 시작 (클럭 800 ps) lw ① IF ID EX MEM WB lw ② IF ID EX MEM WB lw ③ IF ID EX MEM WB 합계 2400 ps 파이프라인 — 단계마다 한 명령씩 겹쳐 실행 (클럭 200 ps) lw ① IF ID EX MEM WB lw ② IF ID EX MEM WB lw ③ IF ID EX MEM WB 합계 1400 ps 한 명령이 끝나는 간격: 800 → 200 ps 0 800 1600 2400
그림 8-2. 같은 lw 세 개를 실행하는 시간. 단일 사이클은 2400 ps, 5단 파이프라인은 1400 ps가 걸린다. 파이프라인에서는 레지스터 읽기·쓰기(100 ps)처럼 짧은 단계도 가장 긴 단계(200 ps)에 맞춰 한 클럭을 다 쓰므로, 명령 하나의 지연은 오히려 1000 ps로 늘었다. 그래도 명령이 많아지면 처리량은 거의 4배가 된다. 금색 세로선은 흘러가는 시간이다.
단계하는 일RISC-V 인코딩이 돕는 점
IF 가져오기PC로 명령어 메모리를 읽고 PC+4를 계산한다.C 확장이 없으면 모든 명령이 4바이트이고 4바이트 정렬이라 한 번에 한 명령씩 깔끔하게 읽힌다.
ID 해독opcode를 해독하고, 레지스터 파일에서 rs1·rs2를 읽고, 즉시값을 만든다.rs1·rs2 칸 위치가 고정이라 해독과 동시에 레지스터를 읽는다. 부호 비트가 언제나 inst[31]이다.
EX 실행ALU 연산, 적재·저장 주소 계산, 분기 조건 비교와 목적지 계산.비교와 분기가 한 명령이라 조건 코드 레지스터가 필요 없다.
MEM 메모리적재는 데이터 메모리를 읽고, 저장은 쓴다. 다른 명령은 통과한다.메모리에 접근하는 명령은 적재·저장뿐이다(load-store 구조). 주소 지정 방식이 “레지스터+12비트 오프셋” 하나다.
WB 쓰기결과를 레지스터 파일의 rd에 쓴다.명령 하나가 쓰는 정수 레지스터는 많아야 하나(rd)다.
IF명령어 가져오기 ID해독 · 레지스터 읽기 EX실행 · 주소 · 분기 판정 MEM메모리 접근 WB레지스터에 쓰기 IF/ID ID/EX EX/MEM MEM/WB PC 명령어메모리 레지스터파일 즉시값 생성 제어 ALU 분기 데이터메모리 선택 WB → 레지스터 파일 (rd에 쓰기) 5 4 3 2 1
그림 8-3. 5단 파이프라인 데이터패스. 금색 세로 막대가 파이프라인 레지스터다. 클럭마다 각 단계의 결과가 다음 막대에 저장되므로, 단계마다 서로 다른 명령이 동시에 일할 수 있다. 아래 줄의 번호 공들은 차례로 들어온 명령들이 한 단계 간격으로 흘러가는 모습이다. WB에서 레지스터 파일로 되돌아가는 선(파란 점선)만이 “뒤로” 가는 길인데, 여기서 해저드가 생긴다.
파이프라인 레지스터에는 무엇이 실리나

값만이 아니다. ID/EX에는 rs1·rs2 값, 즉시값과 함께 그 명령이 뒤 단계에서 쓸 제어 신호(MemRead, MemWrite, RegWrite, WBSel)와 목적 레지스터 번호 rd도 실린다. 제어 신호가 명령과 함께 “따라 내려가기” 때문에, 각 단계는 자기 앞에 있는 막대만 보고 일할 수 있다. 다음 절의 해저드 검출도 바로 이 rd 번호를 비교한다.

데이터 해저드와 포워딩

파이프라인은 명령이 서로 독립일 때 가장 잘 돈다. 하지만 실제 프로그램은 앞 명령의 결과를 바로 다음 명령이 쓰는 일이 흔하다. 다음 코드를 보자.

add x1, x2, x3 # x1을 만든다 (WB는 5번째 사이클) sub x4, x1, x5 # x1이 필요하다 (ID는 3번째 사이클!) and x6, x1, x7 or x8, x1, x9

add는 5번째 사이클의 WB에서야 x1을 레지스터 파일에 쓰는데, sub는 3번째 사이클의 ID에서 x1을 읽는다. 쓰기 전에 읽으려는 이 상황을 RAW 해저드(read-after-write hazard)라 한다. 가장 단순한 해결은 sub를 ID에 붙잡아 두는 것(멈춤(stall))이지만, 두 사이클이나 손해다.

그런데 잘 보면 x1의 값은 3번째 사이클 EX가 끝날 때 이미 계산되어 EX/MEM 레지스터에 있다. 레지스터 파일을 거치지 말고 그 값을 다음 명령의 ALU 입력으로 바로 돌려보내면 된다. 이것이 포워딩(forwarding, bypassing)이다.

1234567 사이클 → add x1, x2, x3 IF ID EX MEM WB sub x4, x1, x5 IF ID EX MEM WB and x6, x1, x7 IF ID EX MEM WB or x8, x1, x9 IF ID EX MEM EX/MEM → EX 포워딩 MEM/WB → EX 포워딩 레지스터 파일: 같은 사이클 앞 절반 쓰기 → 뒤 절반 읽기
그림 8-4. 포워딩으로 RAW 해저드를 없앤다. sub는 4번째 사이클 EX에서 EX/MEM 레지스터의 x1을, and는 5번째 사이클 EX에서 MEM/WB 레지스터의 x1을 받는다. or는 5번째 사이클 ID에서 레지스터 파일을 읽는데, 같은 사이클 앞 절반에 WB가 먼저 쓰도록 설계하면 포워딩 없이도 새 값을 본다. 결국 이 네 명령은 한 사이클도 멈추지 않는다.

포워딩 유닛은 EX 단계에 들어온 명령의 rs1·rs2 번호를 앞선 두 명령의 rd 번호와 비교하는 작은 비교기 묶음이다. 조건을 그대로 적으면 다음과 같다(rs2도 똑같다).

우선조건ALU 입력 A로 보낼 값
1EX/MEM.RegWrite 이고 EX/MEM.rd ≠ 0 이고 EX/MEM.rd = ID/EX.rs1EX/MEM의 ALU 결과 (바로 앞 명령)
2MEM/WB.RegWrite 이고 MEM/WB.rd ≠ 0 이고 MEM/WB.rd = ID/EX.rs1MEM/WB의 쓰기 값 (두 칸 앞 명령)
3그 밖ID에서 레지스터 파일로 읽은 값

1번이 2번보다 앞서는 이유는, 두 명령이 모두 같은 레지스터를 썼다면 더 최근 명령의 값이 맞기 때문이다. 그리고 rd가 0인지 따로 검사하는 이유는 x0 때문이다.

x0가 하드웨어를 단순하게 하는 한 가지 방법

x0에 쓰는 값은 버려지고 x0를 읽으면 언제나 0이다(3장). 따라서 addi x0, x0, 0(nop)이나 jal x0, …(j) 같은 명령은 아무리 많아도 진짜 의존성을 만들지 않는다. 포워딩·해저드 검출 비교기에 “rd ≠ 0” 조건 하나만 붙이면 이 모든 경우가 걸러진다. RISC-V는 이렇게 쓰지 않는 결과를 버리는 곳으로 x0를 일관되게 써서, 비교 결과만 내는 별도 명령이나 “결과 없음” 표시 비트를 따로 두지 않았다.

적재-사용 해저드: 피할 수 없는 한 사이클

포워딩으로도 해결할 수 없는 경우가 있다. lw의 값은 데이터 메모리를 읽는 MEM 단계가 끝나야 나온다. 그런데 바로 다음 명령이 그 값을 EX에서 쓰려면, 같은 사이클의 시작에 값이 있어야 한다. 아직 존재하지 않는 값을 시간을 거슬러 보낼 수는 없으므로, 해저드 검출 유닛이 다음 명령을 ID에 한 사이클 붙잡고 EX에 아무 일도 하지 않는 거품(bubble)을 넣는다.

사이클 → 12345678 lw x1, 0(x2) IF ID EX MEM WB (거품 = nop) 거품 거품 거품 add x3, x1, x4 IF ID ID 멈춤 EX MEM WB sub x5, x6, x7 IF IF 멈춤 ID EX MEM WB ✕ 시간을 거슬러야 함 MEM/WB → EX 해저드 검출 조건: ID/EX.MemRead 이고 ID/EX.rd ≠ 0 이고 (ID/EX.rd = IF/ID.rs1 또는 IF/ID.rs2) → PC와 IF/ID 쓰기를 막고(멈춤), ID/EX의 제어 신호를 0으로 만든다(거품)
그림 8-5. 적재-사용 해저드. lw의 값은 4번째 사이클 MEM이 끝나야 생기므로, 4번째 사이클 EX로 보낼 수 없다(빨간 점선). add를 한 사이클 붙잡아 5번째 사이클 EX에서 MEM/WB 레지스터로부터 받는다(보라 화살표). 뒤따르는 sub도 함께 한 칸 밀린다.

포워딩이 없을 때와 있을 때 멈추는 사이클 수를 의존 거리별로 정리하면 다음과 같다. 의존 거리 1은 “바로 다음 명령이 쓴다”는 뜻이다.

생산자 → 소비자거리 1거리 2거리 3 이상
ALU 명령 → 사용, 포워딩 없음210
ALU 명령 → 사용, 포워딩 있음000
적재 → 사용, 포워딩 없음210
적재 → 사용, 포워딩 있음100

남은 한 사이클은 하드웨어 대신 컴파일러가 숨길 수 있다. 적재와 그 값을 쓰는 명령 사이에 서로 관계없는 명령을 끼워 넣는 명령어 스케줄링(instruction scheduling)이다.

# 전: lw 바로 뒤에 add → 반복마다 1사이클 멈춤 loop: lw t0, 0(a0) add a2, a2, t0 addi a0, a0, 4 addi a1, a1, -1 bnez a1, loop # 후: 독립적인 addi 두 개를 사이에 끼움 → 멈춤 없음 loop: lw t0, 0(a0) addi a0, a0, 4 addi a1, a1, -1 add a2, a2, t0 bnez a1, loop
MIPS의 적재 지연 슬롯

초기 MIPS(R2000)는 이 한 사이클을 아예 ISA에 적어 넣었다. “적재 바로 다음 명령은 아직 새 값을 볼 수 없다”는 적재 지연 슬롯이다. 하드웨어는 해저드 검출 회로가 필요 없어졌지만, 그 규칙은 특정 파이프라인 구조를 계약서에 박제해 버렸고 MIPS II에서 사라졌다. RISC-V는 이런 구현 세부를 ISA에 넣지 않는다. 멈출지 말지는 구현이 알아서 정하고, 소프트웨어는 언제나 순서대로 실행된 것과 같은 결과를 본다.

제어 해저드와 분기 예측

분기 명령은 또 다른 문제를 만든다. 다음에 가져올 명령이 무엇인지 분기 조건을 계산해 봐야 아는데, 그 계산(두 레지스터 비교)은 EX 단계에서 끝난다. 그동안 IF는 놀 수 없으니 무언가를 가져와야 한다. 이 책의 파이프라인은 분기를 EX에서 판정하고, 그동안 일단 다음 주소(PC+4)를 계속 가져온다. 이것이 “불성립 예측”이다. 예측이 틀리면 그사이 가져온 두 명령을 폐기(flush)한다.

사이클 → 12345678 beq x1, x2, L IF ID EX 판정 MEM WB PC+4 의 명령 IF ID ✕ 폐기 PC+8 의 명령 IF ✕ 폐기 L: or x8, x9, x10 IF ID EX MEM WB 성립! 목적지 L로 방향 전환 불성립으로 예측했는데 성립하면 잃는 사이클 = 판정 단계(EX) − 가져오기 단계(IF) = 2 폐기된 두 명령은 아직 레지스터·메모리를 바꾸지 않았으므로(WB·MEM 이전) 흔적 없이 지울 수 있다
그림 8-6. 제어 해저드. beq가 3번째 사이클 EX에서 “성립”으로 판정되면, 그사이 가져온 PC+4·PC+8의 명령을 폐기하고 4번째 사이클부터 목적지 L을 가져온다. 성립하는 분기마다 2사이클을 잃는다. 판정을 ID로 앞당기면 손해가 1사이클로 줄지만, 비교기를 ID에 두면 그쪽으로도 포워딩이 필요해 회로가 복잡해진다.

정적 예측: 뒤로 가면 성립, 앞으로 가면 불성립

루프의 맨 끝 분기는 대부분 성립해서 뒤(낮은 주소)로 간다. 반대로 if 문의 예외 처리 같은 앞쪽 분기는 대개 성립하지 않는다. 그래서 하드웨어 없이도 꽤 잘 맞는 규칙이 BTFN(backward taken, forward not taken)이다. RISC-V의 B형 즉시값은 부호 비트가 언제나 inst[31]에 있으므로, 분기의 방향(앞이냐 뒤냐)을 해독 전에 비트 하나만 보고 알 수 있다. 규격은 소프트웨어에게 이 규칙을 전제로 코드를 배치하라고 권한다.

규격 원문에서
“Software should be optimized such that the sequential code path is the most common path, with less-frequently taken code paths placed out of line. Software should also assume that backward branches will be predicted taken and forward branches as not taken, at least the first time they are encountered.”

Volume I, RV32I · Conditional Branches — 같은 절은 무조건 점프에는 조건이 항상 참인 분기 대신 jal x0(j)을 쓰라고 한다. 그래야 조건 분기 예측 표를 더럽히지 않는다.

동적 예측: 2비트 포화 카운터

정적 규칙은 분기의 실제 행동을 배우지 못한다. 분기 이력 표(BHT, branch history table)는 분기 주소마다 작은 카운터를 두고, 실제 결과를 볼 때마다 갱신한다. 가장 유명한 것이 2비트 포화 카운터다. 한 번 틀렸다고 곧바로 예측을 뒤집지 않기 때문에, 루프 끝에서 한 번 빠져나가는 일 때문에 다음 루프 첫 반복을 또 틀리지 않는다.

00강한 불성립 01약한 불성립 10약한 성립 11강한 성립 T T T T N N N N ← 불성립으로 예측 성립으로 예측 →
그림 8-7. 2비트 포화 카운터. 성립(T)하면 오른쪽, 불성립(N)하면 왼쪽으로 한 칸 움직이고 양 끝에서는 머문다. 왼쪽 두 상태는 불성립, 오른쪽 두 상태는 성립으로 예측한다. 루프 분기는 “11”에 머물다가 루프를 빠져나올 때 한 번 틀려 “10”으로 내려가지만, 다음 루프에서도 여전히 성립으로 예측한다.
EXPLORER

분기 예측기 실험실

분기 결과 패턴
항상 불성립—
항상 성립 (BTFN·뒤로)—
1비트 (직전 결과)—
2비트 카운터—
맨 윗줄이 실제 결과이고, 그 아래 줄들이 각 예측기의 예측이다(초록 = 맞음, 빨강 = 틀림). “4회 루프”에서 1비트 예측기는 루프를 빠져나갈 때와 다시 들어갈 때 두 번 틀리지만, 2비트 카운터는 한 번만 틀린다. “번갈아”처럼 규칙은 있지만 카운터가 배우지 못하는 패턴에서는 어떤가? 실제 고성능 코어는 이런 패턴을 위해 과거 분기 결과(전역 이력)까지 함께 보는 예측기를 쓴다.

5단 파이프라인 시뮬레이터

이제 지금까지의 규칙을 모두 합쳐 보자. 아래 시뮬레이터는 프로그램을 먼저 이 책의 RV32I 에뮬레이터(Riscv.Machine)로 실제 실행해 진짜 실행 순서(어느 분기가 성립했는지)를 얻은 다음, 그 순서를 5단 파이프라인에 흘려보낸다. 따라서 표에 나타나는 분기 결과는 언제나 실제 실행과 같다. 모델의 가정은 이 장의 본문과 같다.

SIMULATOR

RV32I 5단 파이프라인

예제 프로그램
포워딩
분기 처리
IFIDEXMEMWB 멈춤(같은 단계 반복)폐기빨간 기울임 = 잘못된 경로에서 가져온 명령
실행
사이클0
완료한 명령0
CPI (전체 실행)—
데이터 멈춤 (지금까지/전체)0
폐기·제어 손실 (지금까지/전체)0
분기 예측 적중 (전체)—
“의존 사슬”에서 포워딩을 끄고 켜며 멈춤 수를 비교해 보자(8 → 0). “배열 합 루프”에서는 분기 처리를 바꿔 가며 총 사이클을 비교하고, “스케줄링한 루프”로 바꾸면 적재-사용 멈춤 6번이 사라지는 것을 확인하자. CPI에는 처음 파이프라인을 채우는 4사이클이 포함되어 있어서, 명령이 적을수록 1보다 커 보인다.

RISC-V의 설계가 파이프라인을 돕는 방법

지금까지 본 해저드와 해법은 어느 ISA를 구현해도 만나는 것이다. 하지만 ISA가 어떻게 생겼느냐에 따라 같은 일을 하는 회로의 크기와 속도가 달라진다. RISC-V 규격은 비고(commentary)에서 이런 구현상의 이유를 여러 번 밝힌다.

① 레지스터 칸은 언제나 같은 자리

R·I·S·B 형식 모두 rs1은 [19:15], rs2는 [24:20], rd는(있다면) [11:7]이다. 그래서 ID 단계는 opcode가 무엇인지 알기 전에 이 두 칸으로 레지스터 파일을 읽기 시작할 수 있다. 쓰지 않는 칸을 읽었다면 결과를 버리면 그만이다. 해독과 레지스터 읽기가 병렬로 진행되는 것이다.

RISC-V — rs1·rs2 위치 고정, 부호 비트는 언제나 inst[31] opcode 해독 rs1·rs2 레지스터 읽기 즉시값 부호 확장 ID 끝: 피연산자 준비 완료 가상의 ISA — 레지스터 칸 위치가 명령 형식마다 다름 opcode 해독 레지스터 칸 고르기 (mux) 레지스터 읽기 즉시값 부호 확장 ID 끝
그림 8-8. 왜 레지스터 칸을 고정했나. RISC-V에서는 명령이 도착하자마자 opcode 해독, 레지스터 읽기, 부호 확장이 동시에 출발한다. 레지스터 칸 위치가 형식마다 다른 ISA라면 먼저 opcode를 해독해 “이번엔 어느 비트가 레지스터 번호인지”를 골라야 레지스터 파일을 읽을 수 있다. 막대 길이는 개념을 보여 주기 위한 상대 값이다.
규격 원문에서
“Decoding register specifiers is usually on the critical paths in implementations, and so the instruction format was chosen to keep all register specifiers at the same position in all formats at the expense of having to move immediate bits across formats …”

Volume I, RV32I · Base Instruction Formats — 같은 절은 “the sign bit for all immediates is always in bit 31 of the instruction to speed sign-extension circuitry”라고도 적는다. 즉시값 비트가 뒤섞인 이유(4장)가 바로 이 대가다.

② 나머지 결정들

RISC-V의 결정파이프라인에서 얻는 것다른 ISA의 예
조건 코드(플래그) 없음 — 비교와 분기를 한 명령으로산술 명령이 숨은 출력(플래그)을 만들지 않아 해저드 검출·포워딩·이름 바꾸기 대상이 범용 레지스터뿐이다. 분기가 프런트엔드에서 일찍 보인다.x86·Arm·SPARC·PowerPC는 플래그 레지스터를 쓴다.
지연 슬롯 없음분기 판정이 어느 단계에서 끝나든, 몇 개를 동시에 가져오든 ISA가 상관하지 않는다. 깊은 파이프라인과 슈퍼스칼라에 짐이 없다.MIPS·SPARC는 분기 지연 슬롯을 ISA에 넣었다.
부호 비트는 언제나 inst[31]즉시값 부호 확장을 해독과 병렬로 시작하고, 분기 방향(앞/뒤)을 비트 하나로 안다.—
고정 32비트 명령, 4바이트 정렬 (C 확장 없을 때)가져오기와 명령 경계 찾기가 쉽다. C 확장이 있어도 길이는 하위 2비트만 보고 안다(13장).x86은 1–15바이트 가변 길이라 경계를 찾는 일 자체가 어렵다.
메모리 접근은 적재·저장뿐, 주소 방식 하나MEM 단계를 지나는 명령이 적고, 정수 적재·저장 명령 하나는 메모리에 한 번만 접근한다(벡터 확장 등은 예외).x86은 산술 명령도 메모리 피연산자를 가진다.
정수 명령은 소스 ≤ 2, 목적지 ≤ 1레지스터 파일 포트(읽기 2, 쓰기 1)와 이름 바꾸기 회로를 작게 유지한다.Arm의 일부 명령은 쓰는 레지스터가 둘 이상이다(예: 사후 증가 주소 방식).
규격 원문에서
“Control transfer instructions in RV32I do not have architecturally visible delay slots.”

Volume I, RV32I · Control Transfer Instructions — 같은 장의 비고는 비교와 분기를 합친 이유로 “a combined compare-and-branch instruction fits into a regular pipeline, avoids additional condition code state”를 든다.

더 나아가: 슈퍼스칼라와 비순차 실행

5단 순차 파이프라인의 이상적인 CPI는 1이다. 그보다 빨라지려면 한 사이클에 여러 명령을 내보내는 슈퍼스칼라(superscalar), 그리고 준비된 명령부터 먼저 실행하는 비순차 실행(out-of-order execution)이 필요하다. 비순차 코어의 핵심 부품은 레지스터 이름 바꾸기(register renaming)다. 같은 아키텍처 레지스터(예: a0)에 쓰는 명령마다 새 물리 레지스터를 배정해, 진짜 의존성(RAW)만 남기고 가짜 의존성(WAR·WAW)을 없앤다.

조건 코드가 있는 ISA (x86 식) add eax, ebx sub ecx, 1 mov edx, [esi] jnz loop FLAGS → p70 FLAGS → p71 산술 명령마다 숨은 출력 FLAGS가 하나 더 있다. FLAGS도 이름을 바꾸고 의존성을 추적해야 하며, 일부 비트만 쓰는 명령은 이전 값과 합쳐야 한다. RISC-V add a0, a0, a1 addi a2, a2, -1 lw a3, 0(a4) bnez a2, loop a0 → p41 a2 → p42 a3 → p43 a2만 읽는다 (p42) 읽고 쓰는 레지스터가 모두 명령어 칸에 드러난다. 이름 바꾸기 대상은 정수 레지스터 32개뿐이고, x0는 이름을 바꿀 필요조차 없다.
그림 8-9. 비순차 코어에서 플래그가 만드는 일. 왼쪽은 jnz가 두 줄 위 sub가 남긴 FLAGS를 읽는 숨은 의존성을 추적해야 하고, add·sub가 FLAGS를 쓸 때마다 새 물리 레지스터(p70, p71)를 배정해야 한다. 오른쪽 RISC-V는 bnez가 a2를 읽는다는 사실이 명령어에 적혀 있다.

그 밖에도 RISC-V 구현자들이 즐겨 쓰는 기법이 있다.

실제 RISC-V 코어들

버클리의 교육용·연구용 코어만 봐도 범위가 넓다. 5단 순차 파이프라인 Rocket, 비순차 슈퍼스칼라 BOOM이 같은 ISA를 구현한다. 그 위로 중국과학원의 XiangShan 같은 고성능 비순차 코어, 아래로는 2–3단짜리 마이크로컨트롤러 코어가 있다. 이 장의 5단 모델은 그 중간쯤, 교과서의 표준 모델이다.

파이프라인 속의 예외: 정밀한 트랩

파이프라인에는 여러 명령이 동시에 들어 있다. 그중 하나가 예외를 일으키면 어떻게 될까? 예를 들어 MEM 단계의 lw가 접근할 수 없는 주소를 읽으려 한다. 이때 그보다 먼저 들어온 명령은 끝까지 마쳐야 하고, 예외를 일으킨 lw와 그 뒤에 들어온 명령은 아무 흔적도 남기면 안 된다. 그래야 트랩 처리기가 보기에 “lw 직전까지 순서대로 실행된” 깨끗한 상태가 된다. 이것을 정밀한 예외(precise exception)라 한다.

사이클 → 12345678 add x5, x6, x7 IF ID EX MEM WB ✓ lw x1, 0(x2) IF ID EX MEM ⚠ 접근 오류 → 쓰기 안 함 sub x3, x1, x4 IF ID EX ✕ or x8, x9, x10 IF ID ✕ and x11, x12, x13 IF ✕ mtvec: 트랩 처리기 IF ID EX 트랩 시점: mepc ← lw의 주소, mcause ← 5 (load access fault), mtval ← 실패한 주소(구현이 기록할 때)
그림 8-10. 5번째 사이클 MEM에서 lw가 접근 오류를 냈다. 앞선 add는 같은 사이클에 WB를 마치고, lw 자신과 뒤의 세 명령은 아무것도 쓰지 않은 채 사라진다. 6번째 사이클부터 mtvec이 가리키는 트랩 처리기를 가져온다. 빨간 점선이 “여기까지는 실행됐고, 여기부터는 실행되지 않았다”는 정밀한 경계다.

5단 파이프라인에서 정밀성을 지키는 요령은 간단하다. 예외는 발견된 단계에서 바로 처리하지 않고 표시만 해서 파이프라인 레지스터에 실어 내려보낸다. 그리고 한 지점(보통 MEM 끝 또는 WB 직전, 커밋 지점(commit point))에서 프로그램 순서대로 확인한다. 그러면 ID에서 발견된 잘못된 명령 예외와 MEM에서 발견된 접근 오류가 동시에 생겨도, 더 앞선 명령의 예외가 먼저 처리된다. 레지스터와 메모리를 바꾸는 일이 모두 커밋 지점 뒤(MEM의 저장, WB의 쓰기)에 있다는 점이 이 방식을 가능하게 한다.

발견되는 단계 (전형적)예외mcause 예외 코드
IF명령어 접근 오류 · 명령어 페이지 폴트1 · 12
ID잘못된 명령(illegal instruction) · ebreak · ecall(U/S/M)2 · 3 · 8/9/11
EX분기·점프 목적지 정렬 오류(instruction address misaligned) · 적재/저장 주소 정렬 오류0 · 4/6
MEM적재 접근 오류 · 저장 접근 오류 · 적재/저장 페이지 폴트5 · 7 · 13/15

표에서 눈여겨볼 점이 둘 있다. 첫째, 분기 목적지의 정렬 오류는 목적지 명령이 아니라 분기 명령에서 보고된다. 규격이 그렇게 정한 이유는 “IALIGN=32인 구현에서 정렬이 어긋날 수 있는 곳이 분기·점프뿐이라 하드웨어 설계가 단순해지고 디버깅도 쉽기 때문”이다. EX에서 목적지를 계산하는 순간 바로 알 수 있다. 둘째, 정렬되지 않은 적재·저장을 어떻게 처리할지는 실행 환경(EEI)이 정한다. 하드웨어가 두 번 나눠 접근해 처리할 수도, 보이지 않는 트랩으로 소프트웨어가 흉내 낼 수도, 예외를 낼 수도 있다. 자연 정렬된 접근만은 어떤 경우에도 정렬 예외를 내지 않는다.

규격 원문에서
“The EEI defines for each trap whether it is handled precisely, though the recommendation is to maintain preciseness where possible.”

Volume I, Introduction · Exceptions, Traps, and Interrupts — 트랩이 생겼을 때 mepc·mcause·mtval과 mstatus가 어떻게 바뀌는지는 18장에서 자세히 다룬다.

인터럽트도 같은 경계를 쓴다

타이머 인터럽트처럼 바깥에서 오는 인터럽트는 특정 명령 때문이 아니므로, 구현은 파이프라인 안의 아무 명령 경계나 골라 그 앞까지 완료하고 나머지를 폐기하면 된다. 이때 mepc에는 “다시 실행할 첫 명령”의 주소가 기록된다. 파이프라인이 깊고 넓을수록 경계를 맞추는 일이 어려워지는데, 비순차 코어는 재정렬 버퍼(ROB)에서 명령을 순서대로 은퇴시키며 이 경계를 만든다.

핵심 정리

  1. 단일 사이클 데이터패스는 PC·명령어 메모리·레지스터 파일·즉시값 생성기·ALU·데이터 메모리·선택기로 RV32I를 구현한다. 가장 긴 lw의 경로가 클럭 주기를 정한다.
  2. 5단 파이프라인(IF·ID·EX·MEM·WB)은 파이프라인 레지스터로 단계를 나눠, 명령 하나의 지연은 그대로 두고 처리량을 높인다.
  3. RAW 해저드는 EX/MEM·MEM/WB → EX 포워딩으로 대부분 없앤다. 비교에서 rd = x0인 경우는 제외한다.
  4. 적재-사용 해저드는 포워딩이 있어도 1사이클 멈춤이 필요하다. 컴파일러 스케줄링으로 숨길 수 있다.
  5. 분기를 EX에서 판정하면 예측이 틀릴 때 2사이클을 잃는다. BTFN 정적 예측과 2비트 BHT가 이 손해를 줄인다.
  6. 고정된 rs1·rs2 위치, inst[31]의 부호 비트, 플래그 없음, 지연 슬롯 없음, 적재·저장 구조가 해독·포워딩·이름 바꾸기를 단순하게 만든다.
  7. 정밀한 예외는 예외를 표시만 해서 내려보내고 커밋 지점에서 순서대로 처리하는 방식으로 지킨다.

확인 퀴즈

단일 사이클 RV32I CPU의 클럭 주기를 결정하는 명령은 보통 무엇인가?

lw는 명령어 메모리, 레지스터 읽기, ALU(주소 계산), 데이터 메모리, 레지스터 쓰기를 모두 거쳐 가장 길다.

포워딩이 있는 5단 파이프라인에서 lw x1, 0(x2) 바로 다음에 add x3, x1, x4가 오면 몇 사이클 멈추는가?

적재 값은 MEM 끝에야 나오므로 한 사이클 멈춘 뒤 MEM/WB → EX로 포워딩한다.

포워딩이 없고, 레지스터 파일이 같은 사이클에 앞 절반 쓰기·뒤 절반 읽기를 한다. add x1, … 바로 다음 sub x4, x1, x5는 몇 사이클 멈추는가?

add의 WB(5번째 사이클)와 sub의 ID가 같은 사이클이 될 때까지 sub의 ID(원래 3번째 사이클)를 두 번 붙잡아야 한다.

분기를 EX에서 판정하고 “불성립”으로 예측하는 파이프라인에서, 실제로 성립한 분기 하나 때문에 폐기되는 명령은 몇 개인가?

분기가 EX에 있을 때 IF와 ID에 있던 두 명령(PC+4, PC+8)이 폐기된다.

포워딩·해저드 검출 조건에 “rd ≠ 0”을 넣는 이유는?

nop(addi x0, x0, 0)이나 j(jal x0, …) 같은 명령이 가짜 멈춤이나 엉뚱한 포워딩을 만들지 않게 한다.

RISC-V에서 ID 단계가 opcode 해독이 끝나기 전에 레지스터 파일을 읽기 시작할 수 있는 이유는?

규격 비고가 직접 밝힌 이유다. 그 대가로 즉시값 비트가 형식마다 흩어졌다.

조건 코드(플래그)가 없다는 점이 비순차 코어에 주는 이점으로 가장 알맞은 것은?

플래그가 있으면 FLAGS도 이름을 바꾸고 추적해야 한다. RISC-V 분기는 비교할 레지스터를 명시적으로 읽는다.

파이프라인에서 “정밀한 예외”가 뜻하는 것은?

그래서 mepc가 가리키는 명령부터 다시 실행하면 아무 일도 없었던 것처럼 이어 갈 수 있다.

더 읽을거리