[혼공컴운] Chapter 06. 메모리와 캐시 메모리

NCOOKIE·2023년 11월 8일
0

RAM의 특징과 종류

RAM의 특징

  • 휘발성 저장 장치(volatile memory) : 전원을 끄면 저장된 내용이 사라지는 저장 장치
  • 비휘발성 저장 장치(non-volatile memory) : 전원이 꺼져도 저장된 내용이 유지되는 저장 장치

일반적으로 보조기억장치인 비휘발성 저장 장치에는 ‘보관할 대상’을 저장하고, 휘발성 저장장치인 RAM에는 ‘실행할 대상’을 저장한다.

RAM의 용량과 성능

RAM 용량이 적다면 보조기억장치에서 실행할 프로그램을 가져오는 일이 잦아 실행 시간이 길어진다.

RAM 용량이 충분히 크다면 보조기억장치에서 많은 데이터를 가져와 미리 RAM에 저장할 수 있다. 즉, RAM 용량이 크면 많은 프로그램들을 동시에 빠르게 실행하는 데 유리하다.

그렇다고 용량이 필요 이상으로 커졌을 때 속도가 그에 비례하여 증가하지는 않는다.

RAM의 종류

DRAM

  • DRAM(Dynamic RAM)
  • 저장된 데이터가 동적으로 변하는(사라지는) RAM을 의미
  • 시간이 지나면 저장된 데이터가 점차 사라진다.
  • 데이터의 소멸을 막기 위해 일정 주기로 데이터를 재활성화(다시 저장)해야 한다.
  • 소비 전력이 비교적 낮고, 저렴하고, 집적도가 높기 때문에 대용량으로 설계하기가 용이하다. → 일반적으로 메모리로써 사용됨

SRAM

  • SRAM(Static RAM)
  • 저장된 데이터가 변하지 않는 RAM
  • 주기적인 데이터 재활성화가 필요없기 때문에 일반적으로 DRAM보다 속도가 더 빠르다.
  • 집적도가 낮고, 소비 전력도 크며, 가격도 더 비싸다. → 메모리가 아닌 ‘대용량으로 만들어질 필요는 없지만 속도가 빨라야 하는 저장 장치’, 가령 캐시 메모리에서 사용된다.

SDRAM

  • SDRAM(Synchronous Dynamic RAM)
  • 클럭 신호와 동기화된, 발전된 형태의 DRAM
  • 클럭에 맞춰 동작하며 클럭마다 CPU와 정보를 주고받을 수 있다.

DDR SDRAM

  • DDR SDRAM(Double Data Rate SDRAM)
  • 최근 가장 흔히 사용되는 RAM
  • 대역폭을 넓혀 속도를 빠르게 만든 SDRAM
    • 대역폭(data rate) : 데이터를 주고받는 길의 너비
  • 한 클럭에 한 번씩 CPU와 데이터를 주고받을 수 있는 SDRAM에 비해 DDR SDRAM은 두 배의 대역폭으로 한 클럭 당 두 번씩 CPU와 데이터를 주고받을 수 있다. → 전송 속도가 두 배가량 빠르다.
    • 한 클럭당 하나씩 데이터를 주고받을 수 있는 SDRAM을 SDR SDRAM(Single Data Rate SDRAM)이라 부르기도 한다.
    • 최근에 흔히 사용되는 메모리는 DDR4 SDRAM으로, SDR SDRAM보다 16배 넓은 대역폭을 가진다.

메모리의 주소 공간

물리 주소와 논리 주소

  • 물리 주소(physical address)
    • 메모리가 사용
    • 정보가 실제로 저장된 하드웨어상의 주소
  • 논리 주소(logical address)
    • CPU와 실행 중인 프로그램이 사용
    • 실행 중인 프로그램 각각에게 부여된 0번지부터 시작되는 주소

메모리가 사용하는 주소는 하드웨어상의 실제 주소인 물리 주소이고, CPU와 실행 중인 프로그램이 사용하는 주소는 각각의 프로그램에 부여된 논리 주소이다.

CPU가 메모리와 상호작용하려면 논리 주소와 물리 주소 간의 변환이 이루어져야 한다. 논리 주소와 물리 주소 간의 변환은 CPU와 주소 버스 사이에 위치한 메모리 관리 장치(MMU; Memory Management Unit)라는 하드웨어에 의해 수행된다. MMU는 CPU가 발생시킨 논리 주소에 베이스 레지스터 값을 더하여 논리 주소를 물리 주소로 변환한다.

베이스 레지스터는 프로그램의 가장 작은 물리 주소, 즉 프로그램의 첫 물리 주소를 저장하는 셈이고, 논리 주소는 프로그램의 시작점으로부터 떨어진 거리인 셈이다.

메모리 보호 기법

다른 프로그램의 영역을 침범할 수 있는 명령어는 위험하기 때문에 논리 주소 범위를 벗어나는 명령어 실행을 방지하고 실행 중인 프로그램이 다른 프로그램에 영향을 받지 않도록 보호할 방법이 필요하다. 이는 한계 레지스터(limit register)라는 레지스터가 담당한다.

한계 레지스터는 논리 주소의 최대 크기를 저장한다. 즉, 프로그램의 물리 주소 범위는 베이스 레지스터 값 이상, 베이스 레지스터 + 한계 레지스터 값 미만이 된다.

CPU가 접근하려는 논리 주소는 한계 레지스터가 저장한 값보다 커서는 안된다. CPU는 메모리에 접근하기 전에 접근하고자 하는 논리 주소가 한계 레지스터보다 작은지를 항상 검사한다. 만약 CPU가 한계 레지스터보다 높은 논리 주소에 접근하려고 하면 인터럽트(트랩)를 발생시켜 실행을 중단한다.

이러한 방식으로 실행 중인 프로그램의 독립적인 실행 공간을 확보하고 하나의 프로그램이 다른 프로그램을 침범하지 못하게 보호할 수 있다.

캐시 메모리

저장 장치 계층 구조

저장 장치는 일반적으로 아래와 같은 명제를 따른다.

  1. CPU와 가까운 저장 장치는 빠르고, 멀리 있는 저장 장치는 느리다.
  2. 속도가 빠른 저장 장치는 저장 용량이 작고, 가격이 비싸다.

컴퓨터가 사용하는 저장 장치들은 ‘CPU에 얼마나 가까운가’를 기준으로 계층적으로 나타낼 수 있다. 이를 저장 장치 계층 구조(memory hierarchy)라고 한다.

저장 장치 계층 구조를 영문으로 나타내면 memory hierarchy, 메모리 계층 구조를 의미한다. 여기서 ‘메모리’라는 용어는 RAM이 아닌 일반적인 저장 장치를 의미한다. 때문에 여기서는 용어의 혼동을 방지하기 위해 ‘저장 장치 계층 구조’라는 표현을 사용한다.

캐시 메모리

캐시 메모리(cache memory)는 CPU와 메모리 사이에 위치하고, 레지스터보다 용량이 크고 메모리보다 빠른 SRAM 기반의 저장 장치이다.

캐시 메모리는 CPU의 연산 속도와 메모리 접근 속도의 차이를 조금이나마 줄이기 위해 탄생했다. 이를 위해 메모리에서 CPU가 사용할 일부 데이터를 미리 캐시 메모리로 가지고 와서 활용한다.

컴퓨터 내부에는 여러 개의 캐시 메모리가 있는데, 이들은 CPU(코어)와 가까운 순서대로 계층을 구성한다. 코어와 가장 가까운 캐시 메모리를 L1(level 1) 캐시, 그 다음 가까운 캐시 메모리를 L2(level 2) 캐시, 그 다음 가까운 캐시 메모리를 L3(level 3) 캐시라고 부른다.

CPU가 메모리 내에 데이터가 필요하다고 판단하면 우선 L1 캐시에 해당 데이터가 있는지를 알아보고, 없다면 L2, L3 캐시 순으로 데이터를 검색한다. L1 캐시와 L2 캐시는 코어마다 고유한 캐시 메모리로 할당되고, L3 캐시는 여러 코어가 공유하는 형태로 사용된다.

코어와 가장 가까운 L1 캐시는 조금이라도 접근 속도를 빠르게 만들기 위해 명령어만을 저장하는 L1 캐시인 L1I 캐시와 데이터만을 저장하는 L1 캐시인 L1D 캐시로 분리하는 경우도 있다. 이를 분리형 캐시(split cache)라고 한다.

참조 지역성 원리

캐시 메모리는 CPU가 사용할 법한 대상을 예측하여 저장한다. 이 때 자주 사용될 것으로 예측한 데이터가 실제로 들어맞아 캐시 메모리 내 데이터가 CPU에 활용될 경우캐시 히트(cache hit)라고 한다.

반대로 자주 사용될 것으로 예측하여 캐시 메모리에 저장했지만, 예측이 틀려 메모리에서 필요한 데이터를 직접 가져와야 하는 경우캐시 미스(cache miss)라고 한다. 캐시 미스가 발생하면 CPU가 필요한 데이터를 메모리에서 직접 가져와야 하기 때문에 캐시 메모리의 이점을 활용할 수 없다. 당연히 캐시 미스가 자주 발생하면 성능이 떨어지게 된다.

캐시가 히트되는 비율을 캐시 적중률(cache hit ratio)이라 하고 다음과 같이 계산한다.

캐시 히트 횟수 / (캐시 히트 횟수 + 캐시 미스 횟수)

캐시 메모리는 참조 지역성의 원리(locality of reference, principle of locality)라는 원칙에 따라 메모리로부터 가져올 데이터를 결정한다. 참조 지역성의 원리란 CPU가 메모리에 접근할 때의 주된 경향을 바탕으로 만들어진 원리이다.

  1. 최근에 접근했던 메모리 공간에 다시 접근하려는 경향

변수에 값을 저장하고 나면 언제든 변수에 다시 접근하여 변수에 저장된 값을 사용할 수 있다. 이는 ‘CPU는 변수가 저장된 메모리 공간을 언제든 다시 참조할 수 있다’는 것을 의미한다. 이러한 경향을 시간 지역성(temporal locality)라고 한다.

  1. 접근한 메모리 공간 근처를 접근하려는 경향

CPU가 실행하려는 프로그램은 보통 관련 데이터들끼리 한데 모여 있다. 하나의 프로그램 내에서도 관련 있는 데이터들은 모여서 저장된다. CPU는 프로그램을 실행할 때 그 프로그램이 모여 있는 공간 근처와 사용하는 기능들이 모여 있는 공간 근처를 집중적으로 접근할 것이다. 이러한 경향을 공간 지역성(spatial locality)라고 한다.

Q&A

캐시 메모리의 최적화를 위한 코드 개선 (Ch06-3)

행렬 곱셈


for i in 0..n
  for j in 0..m
    for k in 0..p
      C[i][j] = C[i][j] + A[i][k] * B[k][j];

위 코드에서 캐시 메모리에는 공간적 지역성에 의해 A[i][k] 배열이 있다. k가 마지막 차원에서 연속적으로 증가하기 때문이다.

하지만 B[k][j]는 첫 번째 차원에서 k가 증가하므로 캐시 미스가 발생한다. 루프 인덱스 k가 증가할수록 B[k][j]를 참조하기 위해 m * sizeof(int)만큼 점프해야 하기 때문이다.

for i in 0..n
  for k in 0..p
    for j in 0..m
      C[i][j] = C[i][j] + A[i][k] * B[k][j];

i와 k의 루프 순서를 바꾸는 것만으로 드라마틱한 속도 향상을 볼 수 있다. A[i][k]는 내부 루프에서 고정되어 있고, B[k][j]는 마지막 차원에서 j가 증가하므로 공간적 지역성을 충분히 활용하여 캐시 적중율을 높일 수 있다. 물론 순서만 다를뿐 결과는 수학적으로 동일하다.

이러한 성능 향상은 p의 값이 100,000이 넘거나 L1, L2 캐시 메모리가 감당하지 못할 정도로 크면 더욱 눈에 띈다.

코드 파티셔닝(Code Partitioning)

int foo() {
    if (condition) {
        // Large code block 1
    } else {
        // Large code block 2
    }

    // more code
    return value;
}

CPU는 두 개의 코드 블록 중 하나인 code block 1 또는 2를 실행한다. 공간적 지역성에 따라 캐시는 두 블럭의 코드를 모두 저장할 수 있다. 여기서 Large code block 1이 매우 높은 확률로 실행되고 Large code block 2가 매우 드물게 실행되면 동적 메모리의 크기가 불필요하게 커져 캐시 활용도가 낮아진다.

int foo() {
    if (condition) {
        // Large code block 1
    } else {
        Large_code_2();
    }

    // more code
    return value;
}

드물게 실행되는 Large code block 2를 별도의 함수로 분리했다. 이 함수의 코드는 메모리의 다른 곳에 위치하기 때문에 공간적 지역성은 더 이상 두 블록의 코드에서 강제되지 않는다. 이렇게 하면 동적 메모리 설치 공간이 줄어들어 캐시 사용률이 높아진다. 동일한 방법을 케이스문이 많은 스위치(switch)문에도 적용할 수 있다.

정리하자면, 많은 케이스가 존재하는 큰 함수를 가지고 있다면, 어떤 케이스가 자주 실행될 것인지 따져보고, 자주 실행되지 않는 케이스에서 다른 함수를 호출한다.

인라이닝(Inlining)

#include <stdio.h>

#define SIZE 1000

int add(int a, int b) {
    return a + b;
}

int main() {
    int array[SIZE];
    int sum = 0;

    // 배열 초기화
    for (int i = 0; i < SIZE; ++i) {
        array[i] = i + 1;
    }

    // 배열 합 구하기
    for (int i = 0; i < SIZE; ++i) {
        sum = add(sum, array[i]);
    }

    printf("Sum: %d\n", sum);

    return 0;
}

부모 함수가 자식 함수를 호출하는 경우, 다음 두 가지 이유로 인한 결함이 존재한다.

  1. 자식 함수의 코드가 캐시에 존재하지 않는 경우 캐시 미스가 발생하여 프로세서가 중지된다.
  2. 흐름 변경(COF, Change of Flow)이 발생하면, 파이프라인을 Flush 해야하므로 사이클에 상당한 결함이 발생할 수 있다.

이와 같은 경우를 함수기반의 조각화(Function-Based Fragmentation)이라고 한다.

COF에는 예외 처리, 비동기 프로그래밍, 조건문, 반복문, 함수 호출과 같은 상황에서 발생할 수 있다.

#include <stdio.h>

#define SIZE 1000

// 인라인 함수 정의
inline int add(int a, int b) {
    return a + b;
}

int main() {
    int array[SIZE];
    int sum = 0;

    // 배열 초기화
    for (int i = 0; i < SIZE; ++i) {
        array[i] = i + 1;
    }

    // 배열 합 구하기 (인라인 함수 사용)
    for (int i = 0; i < SIZE; ++i) {
        sum = add(sum, array[i]);
    }

    printf("Sum: %d\n", sum);

    return 0;
}

인라인 함수(inline function)는 컴파일러가 함수를 호출하는 대신, 그에 대응하는 함수 코드로 대체하여 호출되는 모든 장소에 삽입할 것을 요청할 수 있다.

인라인 함수는 함수 기반의 조각화를 줄이는데 유용하다. 한 함수를 다른 함수에 포함시킴으로써 흐름 변화(COF)를 줄이고 코드를 더 선형적으로 만들 수 있다.

인라인 함수의 장단점

장점은 무엇보다도 프로그램의 실행속도가 빨라진다는 점입니다. 함수는 호출될 때마다 처리해야 하는 작업이 상당히 많습니다. 함수 종료 후 반환할 위치를 알려줘야 하니 실행 중인 현재 명령어의 주소를 저장도 해야 하고 함수도 호출해야 합니다. 함수를 호출하려면 프로그램 실행 중 함수를 실행해야 하면 해당 함수의 주소로 점프시켰다가, 함수의 처리가 종결되면 다시 원래의 자리로 돌아오게 해야 하는데 이러한 과정에서 시간이 소요됩니다. 반면에 인라인 함수는 컴파일된 함수 코드가 프로그램의 코드 안에 직접 삽입되므로 이러한 과정이 필요가 없어 프로그램 속도 측면에서는 유리하다고 볼 수 있습니다.

반대로 메모리 사용 측면에서는 인라인 함수가 일반 함수보다 불리합니다. 인라인 함수는 호출한 부분이 몇번이 되던 컴파일러는 인라인 함수의 사본을 호출한 부분에 계속해서 삽입해야 하기 때문에 그만큼 메모리의 낭비가 있을 수밖에 없습니다. 그러므로 인라인 함수를 사용하기에 적절한곳은 호출이 많이 일어나지 않는 함수이거나 함수 내부 코드의 양이 매우 실행 속도가 짧은데 비해 함수의 호출시간이 부담스럽다고 생각할 때만 인라인 함수로 만들어 활용하는 것이 적당합니다.

인라이닝은 코드가 커진다는 단점이 있기 때문에 때때로 역효과를 가지고 올 수 있다. 따라서 인라이닝을 사용할 때는 다음과 같은 체크리스트를 확인해야 한다.

  • 함수의 크기는 어떻게 되는가?
  • 그 함수를 호출하는 장소가 몇 개나 되는가?
  • 함수가 자주 호출되는가? 아니면 코너 케이스인가?

참고

profile
일단 해보자

0개의 댓글