컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
arch · 04

파이프라이닝

명령어성능CPU

파이프라이닝(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)로 인해 파이프라인이 멈출 수 있습니다.

성능 향상 — 파이프라이닝 없이 5단계 명령어 4개를 처리하면 20클럭이 필요하지만, 파이프라이닝 적용 시 이상적으로 8클럭으로 단축됩니다.
처리량 vs 지연시간 — 파이프라이닝은 개별 명령어의 실행 시간(지연시간)을 줄이지 않습니다. 단위 시간당 완료되는 명령어 수(처리량)를 높입니다.
파이프라인 스톨(Stall) — 해저드가 생기면 CPU는 버블(Bubble)을 삽입해 대기합니다. 이렇게 파이프라인이 멈추는 현상을 스톨이라 하고, 해저드를 감지해 파이프라인을 멈추게 하는 하드웨어를 인터록(Interlock)이라 합니다.
포워딩(Forwarding) — 데이터 해저드를 줄이기 위해 아직 WB 단계를 거치지 않은 연산 결과를 다음 명령어의 EX 단계에 직접 전달하는 기법입니다.
분기 예측(Branch Prediction) — 제어 해저드를 줄이기 위해 분기 결과를 미리 예측하고 실행합니다. 예측이 틀리면 파이프라인을 플러시(Flush)합니다.

속도 향상은 어떻게 계산되나요

단계가 k개인 파이프라인에서 명령어 N개를 처리하면 첫 명령어가 끝나는 데 k사이클이 걸리고 이후에는 매 사이클 하나씩 끝나므로, 총 N + k − 1 사이클이 듭니다. 파이프라인이 없으면 N × k 사이클입니다. 5단계에서 명령어 100개를 처리하면 104사이클과 500사이클이라 약 4.8배 빨라지고, N이 커질수록 단계 수 5에 가까워집니다. 이 값은 모든 단계의 시간이 같고 해저드가 없을 때의 이상적인 값입니다. 실제 클럭 주기는 가장 느린 단계의 시간에 단계 사이 레지스터의 지연을 더한 값으로 정해집니다.

해저드 세 가지를 조금 더 자세히

구조적 해저드 — 두 명령어가 같은 하드웨어를 같은 사이클에 쓰려 할 때 생깁니다. 메모리가 하나이면 IF 단계의 명령어 읽기와 MEM 단계의 데이터 접근이 겹치므로, 명령어 캐시와 데이터 캐시를 나누는 식으로 해결합니다.
데이터 해저드 — 앞 명령어의 결과가 아직 레지스터에 쓰이지 않았는데 뒤 명령어가 그 값을 읽으려 할 때 생깁니다. 포워딩으로 대부분 해결하지만, LOAD 바로 다음 명령어가 그 값을 쓰는 경우(load-use)는 5단계 파이프라인에서 포워딩을 해도 1사이클을 기다려야 합니다.
제어 해저드 — 분기 결과가 정해지기 전에는 다음에 가져올 명령어를 알 수 없을 때 생깁니다. 기다리거나(스톨), 예측하거나(분기 예측), 분기 뒤의 명령어를 먼저 실행하는 분기 지연 슬롯으로 줄입니다.

자주 하는 오해

단계가 많을수록 무조건 빠르다 — 단계를 잘게 나누면 클럭을 높일 수 있지만, 분기 예측이 틀렸을 때 버려지는 명령어와 단계 사이 레지스터의 오버헤드도 늘어납니다. 인텔 펜티엄 4(NetBurst)는 20단계까지 늘렸지만, 이후 Core 마이크로아키텍처는 단계 수를 줄였습니다.
파이프라이닝은 멀티코어와 같은 병렬 처리다 — 파이프라인은 코어 하나 안에서 서로 다른 명령어가 서로 다른 단계에 있는 것(명령어 수준 병렬성)이고, 멀티코어는 코어 여러 개가 각자 명령어를 실행하는 것입니다. 한 사이클에 명령어를 여러 개 시작하는 슈퍼스칼라는 파이프라인을 폭으로 넓힌 구조입니다.
파이프라인 스톨은 오류다 — 정확한 결과를 위해 하드웨어가 일부러 기다리는 정상 동작입니다. 틀리는 것이 아니라 느려지는 것입니다.
x86 같은 복잡한 명령어 집합은 파이프라인을 쓸 수 없다 — x86 CPU는 명령어를 내부에서 더 단순한 마이크로 연산으로 변환하고, 복잡한 명령어는 여러 개로 나누거나 마이크로코드로 처리해 파이프라인에 태웁니다. 그래서 겉모습은 복잡해도 내부는 파이프라인 구조를 따릅니다.

면접에서 이렇게 물어요

면접 질문

5단계 파이프라인에서 명령어 100개를 실행하면 몇 사이클이 걸리나요?

해저드가 없다고 하면 N + k − 1 = 100 + 5 − 1 = 104사이클입니다. 파이프라인이 없으면 100 × 5 = 500사이클이므로 약 4.8배 빠릅니다.

면접 질문

데이터 해저드는 어떻게 해결하나요?

세 가지입니다. 결과가 준비될 때까지 버블을 넣고 기다리는 스톨, 결과를 레지스터에 쓰기 전에 다음 명령어의 EX 단계로 바로 보내는 포워딩, 컴파일러가 의존성이 있는 명령어를 떨어뜨려 배치하는 명령어 스케줄링입니다.

면접 질문

RISC가 파이프라인에 유리한 이유는 무엇인가요?

명령어 길이가 일정하고 형식이 단순해 해석이 쉽고, 메모리 접근이 LOAD와 STORE로 한정되어 주소 계산(EX)과 메모리 접근(MEM)을 서로 다른 단계로 나눌 수 있습니다. 그래서 모든 명령어를 같은 5단계 순서에 넣기 쉽습니다.

면접 질문

제어 해저드를 줄이는 방법은 무엇인가요?

분기가 확정될 때까지 기다리는 스톨, 결과를 미리 맞혀 보는 분기 예측, 분기 바로 뒤의 명령어를 분기와 상관없이 실행하는 분기 지연 슬롯이 있습니다. 현대 CPU는 보통 분기 예측을 씁니다.

실제 시스템에서는

교재 속 5단계 모델 — IF, ID, EX, MEM, WB 5단계 파이프라인은 MIPS 같은 초기 RISC 프로세서를 바탕으로 한 컴퓨터 구조 교재의 표준 설명 모델입니다. 오늘날의 CPU는 이보다 훨씬 복잡합니다.
리눅스 perf stat — 실행한 명령어 수와 사이클 수, 사이클당 명령어 수(IPC)를 출력합니다. IPC가 평균 1보다 크면 한 사이클에 명령어를 둘 이상 끝낸다는 뜻이라 슈퍼스칼라 구조임을 알 수 있습니다.

함께 보면 좋은 토픽

명령어 사이클 — 파이프라인으로 쪼개기 전의 한 명령어 실행 과정을 봅니다.
분기 예측 — 제어 해저드를 줄이는 예측기의 동작을 봅니다.
비순차 실행 — 의존성이 없는 명령어를 먼저 실행해 스톨을 숨기는 방법을 봅니다.
캐시 메모리 — MEM 단계의 대기를 줄이는 캐시의 동작을 봅니다.
GPU 아키텍처 — 단순한 연산 유닛을 아주 많이 두고 같은 계산을 수많은 데이터에 동시에 적용하는 구조를 봅니다.

참고 자료

교재 — Patterson, Hennessy, "Computer Organization and Design: MIPS Edition" 5판 4장 "The Processor"의 "An Overview of Pipelining"과 "Data Hazards: Forwarding versus Stalling" 절

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

파이프라이닝의 목적은?

공장의 조립 라인처럼 여러 단계를 동시에 돌립니다.
파이프라이닝은 IF/ID/EX/MEM/WB 같은 단계를 겹쳐 실행해, 명령어 하나가 끝나길 기다리지 않고 처리량(throughput)을 높입니다.

Q2

파이프라인 해저드(hazard) 중 이전 명령어 결과를 기다려야 하는 것은?

한 명령어의 결과를 다음 명령어가 바로 써야 할 때 생깁니다.
데이터 해저드는 이전 명령어의 연산 결과가 아직 준비되지 않았는데 다음 명령어가 그 값을 필요로 할 때 발생합니다.

Q3

분기(branch) 명령어로 인해 발생하는 해저드는?

다음에 실행할 명령어 주소가 분기 결과에 따라 바뀝니다.
제어 해저드는 분기문 때문에 다음에 어떤 명령어를 가져와야 할지 불확실해질 때 발생하며, 분기 예측 등으로 완화합니다.