LODY/정리

컴퓨터 구조 / 파이프라인과 해저드

파이프라인과 해저드

🟣 부품이 놀지 않게 하려면

앞 장 끝에서 단일 사이클의 낭비를 봤다. 한 명령어가 데이터 메모리를 쓰는 동안 명령어 메모리와 ALU는 이미 놀고 있었다. 낭비를 없애는 발상은 단순하다. 앞 명령어가 뒤 구간으로 넘어가 비운 부품에, 다음 명령어를 곧바로 들여보내면 된다. 여러 명령어가 datapath의 서로 다른 구간을 같은 시각에 나눠 쓰는 것이 파이프라인이다.

명령어 실행을 다섯 단계로 쪼갠다. 명령어 꺼내기(IF), 해독과 레지스터 읽기(ID), ALU 실행(EX), 데이터 메모리 접근(MEM), 되쓰기(WB)다. 한 명령어가 IF를 끝내고 ID로 내려가면, 비워진 IF에 다음 명령어가 들어온다. 다섯 명령어가 겹쳐 흐르면 다섯 단계가 동시에 돌아간다. 단계 사이에는 파이프라인 레지스터를 두어, 각 명령어가 다음 단계로 넘길 중간 상태를 잠깐 보관한다.

여기서 성능 지표 두 개를 구분해야 한다. 한 명령어가 시작해서 끝나는 시간(지연)은 줄지 않는다. 오히려 단계를 나누느라 조금 늘 수도 있다. 그런데 매 사이클 명령어 하나씩 완성되어 나오는 처리량은 이상적으로 다섯 배에 근접한다. 파이프라인이 노리는 것은 지연이 아니라 처리량이다.


🟣 겹쳐 흐르는 그림

다섯 명령어가 파이프라인을 타고 겹쳐 흐르는 모습을 시간축 위에 놓으면 이렇다. 세로가 명령어, 가로가 클럭 사이클이다. 대각선으로 밀려 내려가는 계단 모양이 핵심이다.

사이클     1    2    3    4    5    6    7    8    9
명령어1   IF   ID   EX   MEM  WB
명령어2        IF   ID   EX   MEM  WB
명령어3             IF   ID   EX   MEM  WB
명령어4                  IF   ID   EX   MEM  WB
명령어5                       IF   ID   EX   MEM  WB

5번째 사이클을 세로로 보면 다섯 명령어가 각기 다른 단계에 있다. 명령어1은 WB, 명령어2는 MEM, 명령어3은 EX, 이런 식으로 다섯 부품이 전부 일한다. 처음 네 사이클은 파이프라인을 채우느라 부품이 다 안 차지만, 채워진 뒤로는 매 사이클 명령어 하나씩 완성되어 빠져나온다. 이 정상 상태를 유지하는 것이 파이프라인의 목표이고, 이 흐름을 깨는 상황들이 곧 해저드다.


🟣 해저드: 겹쳐 흐르기 때문에 생기는 문제

명령어들이 독립적이면 파이프라인은 매끄럽게 돈다. 문제는 실제 코드에서 앞뒤 명령어가 서로 얽혀 있다는 점이다. 겹쳐 흐르는 탓에 생기는 충돌을 해저드라 부르고, 세 종류가 있다.

🚀 구조 해저드: 부품이 하나뿐이라서

같은 부품을 두 명령어가 같은 사이클에 쓰려 하면 충돌한다. 앞 장에서 명령어 메모리와 데이터 메모리를 굳이 따로 그린 이유가 여기서 드러난다. 만약 메모리가 하나뿐이라면, 어떤 명령어가 MEM 단계에서 데이터를 읽는 사이클에 다른 명령어는 IF 단계에서 명령어를 읽어야 하는데 둘이 부딪힌다. 명령어용과 데이터용 메모리를 분리하면 이 충돌이 사라진다. 구조 해저드는 이렇게 부품을 나눠 두는 설계로 대부분 미리 막는다.

🚀 데이터 해저드: 앞의 결과를 뒤가 기다린다

가장 자주 마주치는 해저드다. 앞 명령어가 계산한 값을 뒤 명령어가 곧바로 써야 하는 경우다.

add $t0, $t1, $t2   # $t0에 결과를 씀 (WB에서 확정)
sub $t3, $t0, $t4   # 바로 다음 줄에서 $t0을 읽음

add가 결과를 레지스터에 되쓰는 것은 WB 단계인데, sub는 그보다 앞선 ID 단계에서 이미 $t0을 읽으려 한다. 순진하게 두면 sub아직 갱신되지 않은 옛 $t0을 읽어 틀린 계산을 한다.

해결의 실마리는 값의 위치에 있다. add의 결과는 사실 EX 단계가 끝난 순간 ALU 출력에 이미 나와 있다. 레지스터에 정식으로 되쓰이길 기다릴 필요 없이, 그 값을 파이프라인 뒤쪽에서 앞쪽 EX 입력으로 곧장 되돌려 주면 된다. 이 우회 배선이 포워딩(forwarding)이다. 포워딩 덕에 대부분의 데이터 해저드는 파이프라인을 멈추지 않고 넘긴다.

add: EX(ALU 출력에 결과)add: MEMadd: WB(레지스터에 되쓰기)sub: EX(결과가 필요)포워딩(우회 배선)

다만 포워딩으로도 못 막는 경우가 하나 있다. lw는 값이 MEM 단계가 끝나야 나온다. 바로 다음 명령어가 그 값을 EX에서 쓰려 하면, 아직 값이 없어 한 사이클을 비워 기다려야 한다(스톨). 이 load-use 해저드는 컴파일러가 명령어 순서를 바꿔 그 한 칸을 다른 일로 메우려 애쓴다.

🚀 제어 해저드: 어디로 갈지 아직 모른다

beq 같은 분기는 조건을 계산해 봐야 다음에 어디로 갈지 정해진다. 그런데 파이프라인은 매 사이클 다음 명령어를 미리 꺼내야 한다. 분기 결과가 나오기 전에 이미 그다음 명령어들을 파이프라인에 들여보낸 상태가 된다. 분기가 실제로 뛰는 것으로 판명되면, 잘못 들여보낸 명령어들을 버려야 한다.

무작정 분기 결과를 기다리면 매 분기마다 사이클을 버린다. 그래서 프로세서는 분기 방향을 미리 추측(분기 예측)해서 그쪽 명령어를 계속 흘려보낸다. 맞으면 손해가 없고, 틀리면 그때만 잘못 넣은 것을 버린다. 반복문은 대개 같은 방향으로 여러 번 도니 예측이 잘 맞는다. 초기 MIPS는 분기 바로 뒤 한 칸(지연 슬롯)에 어차피 실행할 명령어를 채워 넣는 단순한 방식을 쓰기도 했다.


🟣 명령어 수준 병렬성(ILP)

파이프라인의 본질은 여러 명령어를 동시에 진행시키는 것이고, 이것이 명령어 수준 병렬성(ILP)의 가장 기본 형태다. 프로그램은 겉보기엔 한 줄씩 순서대로 실행되지만, 서로 의존하지 않는 명령어들은 사실 겹쳐 실행해도 결과가 같다. 파이프라인은 이 숨은 병렬성을 다섯 단계만큼 끌어낸다.

여기서 더 밀어붙인 것이 슈퍼스칼라와 비순차 실행이다. 매 사이클 명령어를 여러 개 동시에 발행하거나(슈퍼스칼라), 앞 명령어가 메모리를 기다리는 동안 뒤의 독립적인 명령어를 먼저 실행하는(비순차 실행) 식이다. 원리는 같다. 의존하지 않는 명령어를 찾아 최대한 겹쳐 부품을 놀리지 않는 것이다. 데이터 해저드에서 본 의존 관계 분석이, 이 고급 기법들에서 훨씬 정교해진 형태로 다시 등장한다.


🟣 라즈베리파이의 파이프라인

교재의 5단계는 개념 모델이고, 실제 칩은 더 깊다. 라즈베리파이의 ARM 코어를 찾아보니 파이프라인 단계가 교재보다 여러 배 깊었다. 단계를 더 잘게 쪼갤수록 각 단계가 짧아져 클럭을 높일 수 있지만, 분기를 잘못 예측했을 때 버려야 할 명령어가 그만큼 많아진다. 파이프라인을 깊게 판 칩일수록 분기 예측기에 공을 들이는 이유가 여기 있었다.

교재에서 "분기 예측이 틀리면 손해"라고 배운 문장이, 실제 칩에서는 예측기 설계에 얼마나 큰 자원이 들어가는지로 확인됐다. 깊은 파이프라인과 정확한 예측이 한 쌍이라는 것을 실물 사양에서 다시 본 셈이다.


🟣 정리

  • 파이프라인은 명령어 실행을 다섯 단계로 쪼개 여러 명령어가 부품을 나눠 쓰게 한다. 지연은 그대로지만 처리량이 크게 오른다.
  • 구조 해저드는 부품이 겹쳐 생기고, 명령어와 데이터 메모리를 분리하는 설계로 대부분 미리 막는다.
  • 데이터 해저드는 앞 결과를 뒤가 기다려 생긴다. 포워딩으로 대부분 해결하지만, load-use는 한 사이클 스톨이 불가피하다.
  • 제어 해저드는 분기 결과 전에 다음 명령어를 꺼내 생긴다. 분기 예측으로 대개 넘기고, 틀렸을 때만 버린다.
  • 파이프라인은 ILP의 기본형이다. 슈퍼스칼라와 비순차 실행이 같은 원리를 더 밀어붙인다.

면접에서 파이프라인을 물으면 5단계 이름 나열에서 멈추지 말고, 해저드 세 종류와 각각의 대응(부품 분리·포워딩·분기 예측)까지 내려가야 깊이가 산다. 틀린 값을 읽을 뻔한 상황을 포워딩이 어떻게 막는지를 값의 위치로 설명할 수 있으면, 지식 나열이 아니라 이해로 읽힌다.

파이프라인으로 프로세서는 매 사이클 명령어를 뽑아낼 만큼 빨라졌다. 그런데 그 명령어들이 데이터를 메모리에서 가져와야 할 때, 메모리는 프로세서만큼 빠르지 않다. 다음 장에서는 이 속도 격차와, 그것을 메우는 메모리 계층과 캐시를 본다.