[혼자 공부하는 컴퓨터 구조 + 운영체제] 5강 CPU 성능 향상 기법
빠른 CPU를 위한 설계 기법
클럭
클럭을 학습하기 전에 이전에 배웠던 내용을 다시 떠올려보자.
- 컴퓨터 부품들은 클럭 신호에 맞춰 움직인다.
- CPU는 명령 사이클이라는 정해진 흐름에 맞춰 명령어를 실행한다.
클럭신호가 빠르면 컴퓨터 부품들이 그만큼 빠른 박자에 맞춰 움직이게된다. 실제로 클럭 속도가 높은 CPU는 일반적으로 성능이 좋다.
클럭 속도는 헤르츠(Hz) 단위로 측정하며, 1초에 클럭이 몇 번 반복되는지를 나타낸다. 클럭이 1초에 100번 반복되면 CPU 클럭 속도는 100Hz이다.
클럭 속도는 일정하지 않다. CPU는 기본 클럭 속도와 최대 클럭 속도가 나뉘어져 있다. 이는 고성능을 요하는 순간에 순간적으로 클럭 속도를 높이고, 그렇지 않을 때는 유연하게 클럭 속도를 낮추기도 한다. 최대 클럭 속도를 더 끌어올릴 수도 있는데, 이런 기법을 오버클럭킹(overclocking)이라고 한다.
하지만 클럭을 높인다고 해서 무조건 성능이 오르지는 않는다. 발열 문제나 과도한 전력 소비 문제 등이 발생하기 때문이다. 클럭 속도를 높이는 것은 CPU를 빠르게 만들지만, 클럭 속도만으로 CPU의 성능을 올리기에는 한계가 있다.
코어와 멀티코어
클럭을 올리는 방법 외에 CPU의 성능을 높이는 방법으로는 CPU의 코어와 스레드 수를 늘리는 방법이 있다.
과거에는 CPU를 ‘명령어를 실행하는 부품’이라고 소개했고, 전통적으로 하나만 존재했었다. 하지만 현대의 CPU는 내부에 ‘명령어를 실행하는 부품’을 얼마든지 만들 수 있게 되었다.
따라서 오늘날 ‘명령어를 실행하는 부품’은 코어(Core)라는 용어로 사용된다. 다시말해 오늘날의 CPU는 ‘명령어를 실행하는 부품을 여러 개 포함하는 부품’으로 명칭의 범위가 확장되었다. 이때 코어를 여러 개 포함하고 있는 CPU를 멀티코어(multi-core) CPU 또는 멀티코어 프로세서라고 부른다.
하지만 처리할 수 있는 스레드가 여러개라고 해도 실제 업무가 훨씬 적다면 비효율적이다. 또한 적절하게 자원을 분배하지 못한다면 성능이 오히려 떨어지기도 한다.
스레드와 멀티 스레드
스레드의 사전적 의미는 ‘실행 흐름의 단위’이다. 다만 CPU에서 사용되는 스레드와 프로그래밍에서 사용되는 스레드는 용례가 다르기 때문에 명확하게 이해해야 한다.
CPU에서 사용되는 하드웨어적 스레드가 있고, 프로그램에서 사용되는 소프트웨어적 스레드가 있다.
하드웨어적 스레드
스레드를 하드웨어적으로 정의하면 ‘하나의 코어가 동시에 처리하는 명령어 단위’를 의미한다. CPU에서 사용하는 스레드라는 용어는 보통 CPU 입장에서 정의된 하드웨어적 스레드를 의미한다.
여러 스레드를 지원하는 CPU는 하나의 코어로도 여러 개의 명령어를 동시에 실행할 수 있다. 이처럼 하나의 코어로 여러 명령어를 동시에 처리하는 CPU를 멀티스레드 프로세서 또는 멀티 스레드 CPU라고 한다. 인텔에서는 멀티스레드 기술을 하이퍼스레딩이라고 부른다.
소프트웨어적 스레드
소프트웨어적으로 정의된 스레드는 ‘하나의 프로그램에서 독립적으로 실행되는 단위’를 의미한다. 프로그래밍 언어나 운영체제를 학습할 때 접하는 스레드는 보통 소프트웨어적으로 정의된 스레드이다. 하나의 프로그램은 실행되는 과정에서 한 부분만 실행될 수 있지만, 여러 부분이 동시에 실행될 수도 있다.
스레드의 하드웨어적 정의는 ‘하나의 코어가 동시에 처리하는 명령어 단위’를 의미하고, 소프트웨어적 정의는 ‘하나의 프로그램에서 독립적으로 실행되는 단위’를 의미한다.
멀티스레드 프로세서
멀티스레드 프로세서를 실제로 설계하는 일은 매우 복잡하지만, 가장 큰 핵심은 레지스터이다. 하나의 코어로 여러 명령어를 동시에 처리하도록 만들려면 프로그램 카운터, 스택 포인터, 데이터 버퍼 레지스터, 데이터 주소 레지스터와 같이 하나의 명령어를 처리하기 위해 꼭 필요한 레지스터를 여러 개 가지고 있으면 된다.
하드웨어적 스레드는 병렬적으로 처리할 수 있는 명령어의 개수이므로, 어찌보면 한 번에 하나의 명령어를 처리하는 CPU의 개수와도 같다. 그래서 하드웨어 스레드를 논리 프로세서(logical processor)라고 부르기도 한다.
Q : 코어가 여러개의 레지스터를 가지고 있다고 해도, 명령을 처리하는 부품인 ALU와 제어장치가 하나라면 실질적으로 동시에 처리하는 것은 아니지 않나?
A : 멀티 스레딩은 여러 스레드가 각기 독립적인 코어처럼 작동하는 것은 아니다. 엄밀히 따지면 ‘여러 스레드를 동시에 실행하려고 노력하는 구조’라고 볼 수 있다. 이는 코어를 최대한으로 활용하기 위한 최적화 기법이다.
예를들어 스레드A에서 명령어 처리에 필요한 데이터를 가져오는데 오래걸린다면, 스레드B를 처리하는 방식으로 동작한다. 즉 레지스터에 원하는 데이터를 불러오는속도보다 ALU와 제어장치가 일하는 속도가 월등히 빠르기 때문에 발생하는 문제를 해결하기 위한 방안이다.
- 코어 : 명령어를 실행할 수 있는 하드웨어 부품
- 스레드 : 명령어를 실행하는 단위
- 멀티코어 프로세서 : 명령어를 실행할 수 있는 하드웨어 부품이 두 개 이상 있는 CPU
- 멀티스레드 프로세서 : 하나의 코어로 여러개의 명령어를 동시에 실행할 수 있는 CPU
명령어 병렬처리 기법
명령어 파이프라인
명령어가 처리되는 전체 과정을 비슷한 시간 간격으로 나누어보면 다음과 같다.
- 명령어 인출(Instruction Fetch)
- 명령어 해석(Instruction Decode)
- 명령어 실행(Execute Instruction)
- 결과 저장(Write Back)
이 단계가 정답은 아니다. 전공서에 따라 명령어 인출 → 명령어 실행으로 나누기도 하고, 명령어 인출 → 명령어 해석 → 명령어 실행 → 메모리 접근 → 결과 저장으로 나누기도 한다.
여기에서 핵심은 단계가 겹치지만 않는다면 CPU는 각 단계를 동시에 실행할 수 있다는 것이다. 예를들어 CPU는 한 명령어를 인출하는 동안에 다른 명령어를 실행할 수 있다.
이처럼 명령어들을 명령어 파이프라인에 넣고 동시에 처리하는 기법을 명령어 파이프라이닝이라고 한다.
명령어 파이프라이닝을 사용하지 않고 모든 명령어를 순차적으로 처리한다면 아래와 같이 매우 비효율적으로 동작한다.
명령어 파이프라이닝이 높은 성능을 가져오기는 하지만, 특정 상황에서는 성능 향상에 실패하는 경우도 있다. 이러한 상황을 파이프라인 위험(Pipeline Hazard)라고 부른다. 파이프라인 위험은 크게 세 가지로 나눌 수 있다.
- 데이터 위험
- 명령어 간 ‘데이터 의존성’에 의해 발생한다. 예를들어 ‘A = 10 + 20; B = A + 10’ 이라는 명령어가 있다고 가정하면, A의 값이 구해진 뒤에 B의 값을 구해야 한다. 이때 B는 A의 데이터에 의존한다. 만약 A의 값이 다 구해지지 않았을때 B연산을 하게되면 결괏값에 문제가 생긴다.
- 이처럼 데이터 의존적인 두 명령어를 무작정 동시에 실행하려고 하면 파이프라인이 제대로 작동하지 않는 것을 데이터 위험이라고 한다.
- 제어 위험
- 제어 위험은 주로 분기등으로 인한 ‘프로그램 카운터의 갑작스러운 변화’에 의해 발생한다. 기본적으로 프로그램 카운터는 ‘현재 실행 중인 명령어의 다음 주소’를 가리킨다. 하지만 실행흐름이 바뀌면 프로그램 카운터 값에 있는 주소에 변화가 생기게 된다. 이때 명령어 파이프라인에 미리 가지고 와서 처리 중이었던 명령어들은 쓸모가 없어진다. 이를 제어 위험이라고 한다.
- 이를 위해 사용하는 기술 중 하나가 분기 예측이다. 분기 예측은 프로그램이 어디로 분기할지 미리 예측한 후 그 주소를 인출하는 기술이다.
- 구조적 위험
- 명령어들을 겹쳐 실행하는 과정에서 서로 다른 명령어가 동시에 ALU, 레지스터 등과 같은 CPU 부품을 사용하려고 할 때 발생한다. 구조적 위험은 자원 위험이라고도 부른다.
슈퍼 스칼라
슈퍼 스칼라는 여러 개의 명령어 파이프라인을 포함한 구조를 의미한다.
슈퍼 스칼라 구조로 명령어 처리가 가능한 CPU를 슈퍼스칼라 프로세서 또는 슈퍼 스칼라 CPU라고 한다. 슈퍼스칼라 프로세서는 매 클럭 주기마다 동시에 여러 명령어를 인출하거나 실행할 수 있다.
단일 스레드인 경우에도 슈퍼스칼라를 사용할 수 있다. 다만 명령어를 병렬로 처리하는 과정에서 슈퍼스칼라를 충분히 활용하지 못할수도 있다. 하지만 멀티스레드 프로세스를 사용한다면, 슈퍼스칼라 구조를 효율적으로 활용할 수 있다.
슈퍼스칼라 프로세서는 이론적으로 파이프라인 개수에 비례하여 프로그램 처리속도가 빨라지지만, 파이프라인의 위험 등의 예상치 못한 문제가 있어 실제로는 반드시 파이프라인 개수에 비례하여 빨라지지 않는다. 이 때문에 슈퍼스칼라 방식을 차용한 CPU는 파이프라인 위험을 방지하기 위해 고도로 설계되어야한다.
비순차적 명령어 처리
비순차적 명령어 처리(OoOE; Out-of-order execution) 기법은 명령어를 순차적으로 실행하지 않는 기법이다.
예를들어 다음과 같은 코드가 있다고 가정해보자
int a = 10;
int b = 20;
int c = a + b;
int d = 50;
int e = 40;
1~2번 줄이 처리되어야 3번줄을 처리할 수 있기 때문에, 4번, 5번째 줄은 그동안 명령을 기다려야한다. 하지만 여기에서 의존성이 없는 4~5번째 줄의 명령어는 순서를 바꾸어 실행해도 문제가 될 것이 없다.
이처럼 순서를 바꿔 실행해도 무방한 명령어를 먼저 실행하여 명령어 파이프라인이 멈추는 것을 방지하는 기법을 비순차적 명령어 처리 기법이라고 한다.
비순차적 명령어 처리가 가능한 CPU는 명령어들이 어떤 명령어와 데이터 의존성을 가지고 있는지, 순서를 바꿔 실행할 수 있는 명령어에는 어떤 것들이 있는지를 판단할 수 있어야 한다.
CISC와 RISC
명령어 집합
CPU가 이해할 수 있는 명령어의 모음을 명령어 집합(instruction set) 또는 명령어 집합 구조(ISA; Instruction Set Architecture)라고 한다.
명령어 집합에 ‘구조’라는 단어가 붙은 이유는 CPU가 어떤 명령어를 이해하는지에 따라 컴퓨터 구조 및 설계 방식이 달라지기 때문이다.
세상에는 수많은 CPU 제조사들이 있고, CPU마다 규격과 기능, 만듦새가 다 다르다. 모든 CPU는 명령어의 기본적인 구조와 작동 원리는 비슷하지만, 명령어의 세세한 생김새, 명령어로 할 수 있는 연산, 주소 지정 방식 등은 CPU마다 조금씩 차이가 있다.
인텔의 노트북 CPU는 x86혹은 x86-64 ISA를 이해하고, 애플의 아이폰 속 CPU는 ARM ISA를 이해한다. 인텔 CPU를 사용하는 컴퓨터와 아이폰은 서로 다른 ISA를 사용하기 때문에 서로의 명령어를 이해할 수 없다. 같은 소스코드로 만들어진 프로그램이라도 ISA가 다르면 어셈블리어가 달라진다.
현대 ISA의 양대산맥으로는 CISC와 RISC가 있다.
CISC
CISC는 Complex Instruction Set Computer의 약자이다. 이를 그대로 해석하면 ‘복잡한 명령어 집합을 사용하는 컴퓨터’가 된다. 여기서 컴퓨터를 CPU라고 해석해도 좋다.
CISC는 복잡하고 다양한 명령어를 활용하는 CPU 설계방식이다. x86, x86-64가 대표적인 CISC기반의 ISA이다. CISC는 다양하고 강력한 기능의 명령어 집합을 활용하기 때문에 명령어 형태와 크기가 다양한 가변 길이 명령어를 활용한다.
다양하고 강력한 명령어를 활용한다는 말은 상대적으로 적은 수의 명령어로도 프로그램을 실행할 수 있다는 의미이다. 명령어의 수가 적어지면 실행파일의 크기가 작아지게 되는데, 이런 특징으로 인해 메모리를 최대한 아끼며 개발해야 했던 시절에 인기가 높았다.
하지만 치명적인 단점이 있는데, 활용하는 명령어가 워낙 복잡하고 다양한 기능을 제공하는 탓에 명령어의 크기와 실행되기까지의 시간이 일정하지 않다. 이렇게 되면 명령어 파이프라인을 구현하는 데에 큰 걸림돌이 된다.
CISC가 복잡하고 다양한 명령어를 활용한다고 하지만, 사실 대다수의 복잡한 명령어는 사용 빈도가 낮다. 1975년 IBM 연구소의 존 코크는 CISC 명령어 집합 중 불과 20%의 명령어가 사용된 전체 명령어의 80% 가량을 차지한다는것을 증명했다.
CISC의 특징을 정리하자면 다음과 같다.
- 복잡하고 다양한 기능을 제공하는 명령어로 인해, 적은수의 명령어로 프로그램을 제작할 수 있어서 실행파일의 크기가 작아진다.
- 명령어의 규격화가 어려워 파이프라이닝이 어렵다.
- 대다수의 복잡한 명령어는 사용 빈도가 낮다.
RISC
CISC로 알게된 점은 다음과 같다.
- 빠른 명령어 처리를 위해선 명령어 파이프라인을 잘 활용해야한다. 이를 위해선 명령어의 길이와 수행시간이 잘 규격화 되어야 한다.
- 자주 쓰이는 명령어만 자주 쓰이기 때문에, 복잡한 기능을 지원하는 명령어를 추가하기 보다는 자주 쓰이고 기본적인 명령어를 작고 빠르게 만드는 것이 중요하다.
이런 원칙하에 등장한 것이 RISC이다. RISC는 Reduced Instruction Set Computer의 약자이다. 이름처럼 CISC에 비해서 명령어의 종류가 적다. 뿐만 아니라 CISC와 달리 규격화된 명령어, 되도록 1클럭 내외로 실행되는 명령어를 지향한다. 즉, RISC는 고정 길이 명령어를 사용한다.
명령어가 규격화 되어있기 때문에 명령어 파이프라이닝에 최적화되어있다. RISC는 메모리에 직접 접근하는 명령어를 load, store 두 개로 제한할 만큼 메모리 접근을 단순화하고 최소화를 추구한다. 그렇기 때문에 CISC보다 주소 지정 방식의 종류가 적은 경우가 많다. 이런 점에서 RISC를 load-save 구조라고 부르기도 한다.
RSIC는 메모리 접근을 단순화, 최적화하는 대신 레지스터를 적극적으로 활용한다. 이떄문에 CISC보다 레지스터를 이용하는 연산이 많고, 일반적인 경우보다 범용 레지스터 개수가 더 많다.
RISC의 특징을 정리하자면 다음과 같다.
- 단순하고 작고 속도가 빠른 명령어 위주로 구현되어있다. 그리고 명령어가 규격화되어 있기 때문에 명령어 파이프라이닝을 잘 활용할 수 있다.
- 레지스터를 이용하는 연산이 많다.
- 소스코드를 컴파일 할 때 많은 명령어를 사용하기 때문에 실행파일의 크기가 크다.