Fetch: CPU가 메모리에서 명령어를 읽어오는 인출 단계
Fetch는 CPU의 명령어 실행 사이클(Instruction Cycle) 중 첫 단계로, 프로그램 카운터(PC)가 가리키는 메모리 주소에서 명령어를 읽어와 명령어 레지스터(IR)에 적재하는 과정이다. 이 단계의 효율성은 캐시 적중률, 파이프라이닝, 분기 예측과 직결되며 CPU 전체 성능에 큰 영향을 준다. Fetch-Decode-Execute 사이클을 이
1. 개념
Fetch(인출)는 CPU가 메모리에서 다음에 실행할 명령어를 가져오는 단계다. CPU는 명령어를 실행하기 위해 항상 Fetch → Decode(해석) → Execute(실행) 순서의 사이클을 반복하는데, Fetch는 이 사이클의 첫 단계에 해당한다.
핵심 구성 요소는 다음과 같다.
- PC(Program Counter): 다음에 가져올 명령어의 메모리 주소를 저장하는 레지스터
- MAR(Memory Address Register): PC의 값을 받아 메모리 주소 버스에 실어 보내는 레지스터
- MDR(Memory Data Register): 메모리에서 읽어온 데이터(명령어)를 임시로 담는 레지스터
- IR(Instruction Register): 최종적으로 인출된 명령어가 저장되는 레지스터
Fetch가 끝나면 PC는 다음 명령어를 가리키도록 증가(increment)한다.
2. 왜 사용하는가
CPU는 프로그램 코드를 명령어 단위로 순차 실행하는 구조를 가진다. 명령어를 실행하려면 먼저 그 명령어가 무엇인지 메모리에서 읽어와야 하므로, Fetch 없이는 Decode와 Execute 자체가 불가능하다.
또한 Fetch 단계의 설계 방식(파이프라이닝, 캐시 활용, 분기 예측)이 CPU의 처리량(throughput)을 결정하는 핵심 요인이 된다. 명령어를 얼마나 빠르고 끊김 없이 가져오느냐가 곧 CPU 성능이다.
3. 동작 원리
단일 사이클 CPU 기준으로 Fetch 단계는 다음 순서로 진행된다.
- PC의 값을 MAR에 복사한다.
- MAR의 주소를 주소 버스(address bus)에 실어 메모리에 전달한다.
- 메모리는 해당 주소의 데이터(명령어)를 데이터 버스(data bus)로 반환한다.
- 반환된 값을 MDR을 거쳐 IR에 저장한다.
- PC를 다음 명령어 주소로 증가시킨다. (일반적으로 명령어 크기만큼, 예: 4바이트)
현대 CPU는 이 과정을 파이프라이닝(pipelining)으로 처리한다. 즉 한 명령어가 Decode 단계로 넘어가는 동안 다음 명령어의 Fetch가 동시에 진행된다. 이때 분기 명령어(branch)를 만나면 다음 PC 값이 확정되지 않아 파이프라인이 멈출 수 있는데, 이를 완화하기 위해 분기 예측기(branch predictor)가 사용된다.
또한 Fetch는 메인 메모리에 직접 접근하지 않고 L1 명령어 캐시(I-cache)를 먼저 확인한다. 캐시 히트 시 수 클럭 사이클 내에 완료되지만, 캐시 미스가 발생하면 하위 캐시나 메인 메모리까지 접근해야 하므로 지연이 크게 늘어난다.
4. 코드 예제
CPU의 Fetch-Decode-Execute 사이클을 단순화한 시뮬레이터다. 명령어는 리스트 형태의 메모리에 저장되어 있고, PC가 순차적으로 이동하며 Fetch를 수행한다.
class SimpleCPU:
def __init__(self, memory: list[str]):
self.memory = memory # 명령어가 저장된 메모리 (명령어 문자열 리스트)
self.pc = 0 # Program Counter
self.ir = None # Instruction Register
self.registers = {"ACC": 0} # 누산기 레지스터
self.running = True
def fetch(self) -> str:
# PC가 가리키는 주소에서 명령어를 읽어와 IR에 저장
instruction = self.memory[self.pc]
self.ir = instruction
self.pc += 1 # 다음 명령어를 가리키도록 PC 증가
return instruction
def decode_execute(self, instruction: str):
parts = instruction.split()
op = parts[0]
if op == "LOAD":
self.registers["ACC"] = int(parts[1])
elif op == "ADD":
self.registers["ACC"] += int(parts[1])
elif op == "PRINT":
print(f"ACC = {self.registers['ACC']}")
elif op == "HALT":
self.running = False
else:
raise ValueError(f"알 수 없는 명령어: {instruction}")
def run(self):
while self.running and self.pc < len(self.memory):
instruction = self.fetch() # 1. Fetch
self.decode_execute(instruction) # 2~3. Decode + Execute
program = [
"LOAD 10",
"ADD 5",
"PRINT",
"ADD 100",
"PRINT",
"HALT",
]
cpu = SimpleCPU(program)
cpu.run()ACC = 15
ACC = 115실제 하드웨어에서는 Fetch와 Decode/Execute가 파이프라인으로 겹쳐 실행되지만, 위 예제는 개념 이해를 위해 순차적으로 단순화했다.
5. 시간 복잡도 또는 성능 특성
Fetch 자체는 알고리즘적 시간복잡도 개념이 아니라 클럭 사이클(clock cycle) 단위로 측정하는 하드웨어 지연이다.
- 캐시 히트(L1 I-cache hit): 보통 1~4 클럭 사이클 내에 완료
- L1 미스, L2 히트: 수십 클럭 사이클 지연
- 캐시 전체 미스, 메인 메모리 접근: 수백 클럭 사이클 지연 (DRAM 접근 지연은 수십~수백 나노초 수준)
파이프라이닝이 적용된 CPU에서는 이상적으로 매 클럭마다 새로운 Fetch가 시작되어 처리량이 향상되지만, 분기 예측 실패(branch misprediction) 시 파이프라인을 비우고 다시 채워야 하므로 그만큼의 클럭 사이클이 낭비된다. 이 손실 크기는 파이프라인 깊이에 비례한다.
6. 실무 사용 사례
- 분기 예측 최적화: 조건문이 반복적으로 같은 방향으로 분기하는 패턴을 만들면 분기 예측기가 정확도를 높여 Fetch 파이프라인 스톨을 줄인다. 예를 들어 정렬된 배열을 순회하며 조건 검사를 하는 코드가 정렬되지 않은 배열보다 빠른 이유 중 하나다.
- 명령어 캐시 지역성 활용: 함수 호출이 흩어져 있는 코드보다 관련 코드가 메모리상 인접해 있을 때 I-cache 적중률이 높아져 Fetch 지연이 줄어든다. 컴파일러의 함수 인라이닝(inlining), 코드 배치 최적화가 이와 관련된다.
- JIT 컴파일러 및 프로파일 기반 최적화(PGO): 자주 실행되는 코드 경로를 예측해 명령어 배치를 조정함으로써 Fetch 단계의 캐시 효율을 높인다.
7. 주의할 점
- Fetch 단계의 지연은 애플리케이션 코드에서 직접 제어할 수 없지만, 코드의 분기 패턴과 메모리 지역성(locality)을 통해 간접적으로 영향을 줄 수 있다.
- 재귀 호출이 깊거나 함수 포인터/가상 함수 호출이 많은 코드는 분기 예측이 어려워져 Fetch 파이프라인 효율이 떨어질 수 있다.
- 인터럽트(interrupt)나 예외(exception) 발생 시 PC가 강제로 변경되므로 Fetch 파이프라인에 담겨 있던 명령어들이 무효화된다. 이는 인터럽트가 빈번한 시스템에서 성능 저하 요인이 된다.
8. 핵심 정리
Fetch는 PC가 가리키는 메모리 주소에서 명령어를 읽어와 IR에 저장하고 PC를 증가시키는, 명령어 실행 사이클의 첫 단계다. 캐시 적중 여부와 분기 예측 정확도가 Fetch 성능을 좌우하며, 현대 CPU는 파이프라이닝으로 여러 명령어의 Fetch를 동시에 진행해 처리량을 높인다. 코드 작성 시 분기 패턴과 메모리 지역성을 고려하면 Fetch 단계의 효율성에 간접적으로 긍정적인 영향을 줄 수 있다.