파이프라이닝
파이프라이닝(Pipelining)은 명령어 실행을 여러 단계로 나눠 각 단계를 동시에 겹쳐서 처리하는 기법입니다. 공장 조립 라인처럼, 한 명령어가 다음 단계로 넘어가는 동시에 다음 명령어가 그 자리를 채웁니다.
1단계
IF — Instruction Fetch
PC가 가리키는 메모리 주소에서 명령어를 읽어옵니다. 읽은 후 PC는 자동으로 다음 주소로 증가합니다.
2단계
ID — Instruction Decode
IF/ID 레지스터에 담긴 명령어를 해석합니다. 필요한 레지스터를 읽고 제어 신호를 생성합니다.
3단계
EX — Execute
ALU가 실제 연산을 수행합니다. 산술·논리 연산 또는 메모리 접근을 위한 주소를 계산합니다.
4단계
MEM — Memory Access
필요한 경우 메모리에서 데이터를 읽거나 씁니다. LOAD/STORE 명령어에서 실제 메모리 접근이 일어납니다.
5단계
WB — Write Back
결과를 써야 하는 명령어는 목적지 레지스터에 저장합니다. 파이프라인의 마지막 단계로 명령어 실행이 완료됩니다.
핵심 개념
파이프라인 해저드
데이터 의존성(Data Hazard), 제어 해저드(Control Hazard), 구조적 해저드(Structural Hazard)로 인해 파이프라인이 멈출 수 있습니다.
속도 향상은 어떻게 계산되나요
단계가 k개인 파이프라인에서 명령어 N개를 처리하면 첫 명령어가 끝나는 데 k사이클이 걸리고 이후에는 매 사이클 하나씩 끝나므로, 총 N + k − 1 사이클이 듭니다. 파이프라인이 없으면 N × k 사이클입니다. 5단계에서 명령어 100개를 처리하면 104사이클과 500사이클이라 약 4.8배 빨라지고, N이 커질수록 단계 수 5에 가까워집니다. 이 값은 모든 단계의 시간이 같고 해저드가 없을 때의 이상적인 값입니다. 실제 클럭 주기는 가장 느린 단계의 시간에 단계 사이 레지스터의 지연을 더한 값으로 정해집니다.
해저드 세 가지를 조금 더 자세히
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
5단계 파이프라인에서 명령어 100개를 실행하면 몇 사이클이 걸리나요?
해저드가 없다고 하면 N + k − 1 = 100 + 5 − 1 = 104사이클입니다. 파이프라인이 없으면 100 × 5 = 500사이클이므로 약 4.8배 빠릅니다.
면접 질문
데이터 해저드는 어떻게 해결하나요?
세 가지입니다. 결과가 준비될 때까지 버블을 넣고 기다리는 스톨, 결과를 레지스터에 쓰기 전에 다음 명령어의 EX 단계로 바로 보내는 포워딩, 컴파일러가 의존성이 있는 명령어를 떨어뜨려 배치하는 명령어 스케줄링입니다.
면접 질문
RISC가 파이프라인에 유리한 이유는 무엇인가요?
명령어 길이가 일정하고 형식이 단순해 해석이 쉽고, 메모리 접근이 LOAD와 STORE로 한정되어 주소 계산(EX)과 메모리 접근(MEM)을 서로 다른 단계로 나눌 수 있습니다. 그래서 모든 명령어를 같은 5단계 순서에 넣기 쉽습니다.
면접 질문
제어 해저드를 줄이는 방법은 무엇인가요?
분기가 확정될 때까지 기다리는 스톨, 결과를 미리 맞혀 보는 분기 예측, 분기 바로 뒤의 명령어를 분기와 상관없이 실행하는 분기 지연 슬롯이 있습니다. 현대 CPU는 보통 분기 예측을 씁니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
C — 5단계 파이프라인 레지스터 시뮬레이션
// 파이프라인 단계 간 데이터를 보관하는 파이프라인 레지스터 typedef struct { unsigned int pc, instruction; } IF_ID; typedef struct { unsigned int pc, opcode, rs, rt, rd, imm; } ID_EX; typedef struct { unsigned int alu_result, rt_val, rd, opcode; } EX_MEM; // opcode: MEM 단계 판별용 제어 신호 typedef struct { unsigned int result, rd; } MEM_WB; // 파이프라인 레지스터 전역 선언 IF_ID if_id = {0}; ID_EX id_ex = {0}; EX_MEM ex_mem = {0}; MEM_WB mem_wb = {0}; // 각 단계 함수 — 한 클럭에 동시 실행됨 void stage_IF(unsigned int *pc, unsigned int *mem) { if_id.instruction = mem[*pc]; // 명령어 인출 if_id.pc = (*pc)++; // PC 증가 } void stage_ID() { id_ex.opcode = (if_id.instruction >> 26) & 0x3F; id_ex.rs = (if_id.instruction >> 21) & 0x1F; id_ex.rt = (if_id.instruction >> 16) & 0x1F; id_ex.rd = (if_id.instruction >> 11) & 0x1F; id_ex.imm = if_id.instruction & 0xFFFF; id_ex.pc = if_id.pc; } void stage_EX(unsigned int *regs) { ex_mem.rd = id_ex.rd; ex_mem.rt_val = regs[id_ex.rt]; ex_mem.opcode = id_ex.opcode; // 제어 신호를 EX/MEM 레지스터로 전파 (MEM 단계가 사용) switch (id_ex.opcode) { case 0x00: ex_mem.alu_result = regs[id_ex.rs] + regs[id_ex.rt]; break; // ADD case 0x23: ex_mem.alu_result = regs[id_ex.rs] + id_ex.imm; break; // LOAD case 0x2B: ex_mem.alu_result = regs[id_ex.rs] + id_ex.imm; break; // STORE } } void stage_MEM(unsigned int *data_mem) { mem_wb.rd = ex_mem.rd; if (ex_mem.opcode == 0x23) // LOAD — EX/MEM 레지스터의 opcode 사용 (id_ex는 EX 단계 명령어) mem_wb.result = data_mem[ex_mem.alu_result]; else if (ex_mem.opcode == 0x2B) // STORE — 동일 data_mem[ex_mem.alu_result] = ex_mem.rt_val; else mem_wb.result = ex_mem.alu_result; } void stage_WB(unsigned int *regs) { regs[mem_wb.rd] = mem_wb.result; // 결과를 레지스터에 기록 } // 한 클럭 사이클 실행 — 5단계가 동시에 작동 void clock_cycle(unsigned int *pc, unsigned int *instr_mem, unsigned int *data_mem, unsigned int *regs) { stage_WB(regs); // WB 먼저 — 포워딩 충돌 방지 stage_MEM(data_mem); stage_EX(regs); stage_ID(); stage_IF(pc, instr_mem); }
파이프라인 레지스터(IF/ID, ID/EX, EX/MEM, MEM/WB)가 각 단계 사이에서 데이터를 래치(Latch)합니다. 매 클럭에 5단계가 동시에 실행되며, WB를 먼저 수행해 같은 클럭 내 포워딩 충돌을 방지합니다.
Java — 파이프라인 해저드 시뮬레이션
// 데이터 해저드 감지 및 스톨 삽입 시뮬레이터 public class PipelineSimulator { private int[] registers = new int[32]; private int stallCount = 0; // 파이프라인 레지스터 (단계 간 데이터 보관) private int ifId_instr, idEx_rd, exMem_rd, memWb_rd; private boolean idEx_regWrite, exMem_regWrite; /** * 데이터 해저드(RAW) 감지 * — ID 단계 명령어의 소스 레지스터가 * 앞선 명령어의 목적지 레지스터와 겹치는지 확인 */ private boolean detectDataHazard(int rs, int rt) { // EX/MEM 해저드: 2클럭 앞 명령어가 MEM 단계에 있고 아직 WB 미완료 // (Patterson & Hennessy 조건 1: EX/MEM.RegisterRd == 현재 명령어 소스) if (exMem_regWrite && exMem_rd != 0 && (exMem_rd == rs || exMem_rd == rt)) { return true; } // ID/EX 해저드: 1클럭 앞 명령어가 EX 단계에 있고 아직 WB 미완료 // (Patterson & Hennessy 조건 2: ID/EX.RegisterRd == 현재 명령어 소스) // idEx_rd = ID/EX 파이프라인 레지스터 → EX 단계의 명령어 (MEM/WB가 아님) if (idEx_regWrite && idEx_rd != 0 && (idEx_rd == rs || idEx_rd == rt)) { return true; } return false; } /** * 스톨(Bubble) 삽입 * — 해저드 감지 시 NOP을 끼워 파이프라인을 1클럭 지연 */ private void insertStall() { // ID/EX 레지스터를 NOP(버블)으로 초기화 idEx_rd = 0; idEx_regWrite = false; stallCount++; System.out.printf("[STALL] 버블 삽입 — 총 스톨 횟수: %d%n", stallCount); } /** * 포워딩(Forwarding) — 스톨 없이 해저드 해소 * 표준 포워딩 경로: * EX/MEM → EX: exMem 레지스터 (2클럭 앞 명령어, MEM 단계) * ID/EX → EX: idEx 레지스터 (1클럭 앞 명령어, EX 단계) * ※ 이 시뮬레이터는 단순화를 위해 registers[]에서 값을 읽습니다. * 실제 하드웨어에서는 파이프라인 레지스터의 결과 필드를 직접 바이패스합니다. */ private int forwardValue(int regNum) { if (exMem_regWrite && exMem_rd == regNum) { System.out.println("[FORWARD] EX/MEM → EX (MEM 단계 명령어에서 EX 단계로)"); return registers[exMem_rd]; // EX/MEM 포워딩 } if (idEx_regWrite && idEx_rd == regNum) { System.out.println("[FORWARD] ID/EX → EX (EX 단계 명령어에서 EX 단계로)"); return registers[idEx_rd]; // ID/EX 포워딩 (idEx = EX 단계의 명령어, MEM/WB 아님) } return registers[regNum]; } }
RAW(Read After Write) 해저드는 앞선 명령어의 결과가 아직 레지스터에 기록되기 전에 뒤 명령어가 같은 레지스터를 읽을 때 발생합니다. 스톨(버블 삽입)은 성능을 낮추지만 정확성을 보장하고, 포워딩은 파이프라인 레지스터에서 직접 값을 전달해 스톨을 최소화합니다.
Q1
파이프라이닝의 목적은?
Q2
파이프라인 해저드(hazard) 중 이전 명령어 결과를 기다려야 하는 것은?
Q3
분기(branch) 명령어로 인해 발생하는 해저드는?