멈추지 않고 끈질기게

[컴퓨터 공학] 캐시 메모리(Cache Memory) 본문

컴퓨터공학

[컴퓨터 공학] 캐시 메모리(Cache Memory)

sam0308 2023. 1. 30. 12:13

※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.

 

이번 포스팅에서는 컴퓨터의 저장장치 중 하나인 캐시 메모리에 대해 알아보겠습니다.

 

1. 캐시 메모리의 특징

캐시 메모리는 CPU와 메모리 사이에 위치하는 저장장치로서, 주로  SRAM을 사용하여 CPU의 레지스터보다는 용량이 크고 메모리보다는 속도가 빠릅니다. 특히 속도면에 있어서 CPU와의 물리적인 거리도 메모리보다 가깝기 때문에, 캐시 메모리에서 데이터를 읽어들여 처리한다면 메모리까지 접근하는 것보다 훨씬 빠릅니다. 그래서 CPU는 메모리에서 데이터를 읽어들일 때 앞으로 필요할 것으로 예측되는 데이터를 함께 읽어들여 캐시 메모리에 저장해 둡니다. 이 후 데이터에 접근할 때 캐시 메모리에 존재하는지 확인하여, 존재한다면 캐시 메모리에서 바로 읽어들여 메모리까지 갈 필요 없이 빠르게 처리할 수 있습니다. 

 

캐시 메모리에도 종류가 있어, 코어와 가까운 순으로 L1, L2, L3 로 구분합니다. 

 

그림1. 캐시 메모리의 위치

L1, L2 캐시 메모리는 CPU 내부에 존재하며, 해당 캐시가 소속된 코어의 고유 메모리로서 사용됩니다. L3 캐시는 CPU 외부에 위치하며, 여러 코어들이 공유하여 사용합니다.

 

2.참조 지역성(Locality Of Reference)

위에서 '앞으로 필요할 것으로 예측되는' 이라는 추상적인 표현을 사용했는데, 이 예측에는 기준이 있습니다. 참조 지역성이란 CPU가 메모리에 접근할 때 특정 메모리 공간에 집중적으로 접근하는 경향이 있다는 내용입니다. 이는 다시 시간 지역성(temporal locality)공간 지역성(Spatial Locality)으로 구분됩니다.

 

시간 지역성은 CPU가 최근에 접근한 메모리 공간에 다시 접근하려는 경향을 나타냅니다. 일반적으로 한번 사용한 변수는 여러번 사용될 확률이 높기 때문입니다. 공간 지역성은 CPU가 한번 접근했던 메모리 공간의 근처에 접근하려는 경향을 나타냅니다. 프로세스의 데이터들은 일반적으로 한군데 모여 저장되기 때문입니다. 배열과 같은 자료구조가 예가 될 수 있습니다. 배열을 초기화하고 첫번째 원소에 접근한다면, 나머지 원소들에도 순차적으로 접근할 확률이 높으니까요. 

 

이러한 지역성을 참조하여 캐시 메모리에 데이터를 저장해두고, 다음에 참조해야 하는 데이터가 캐시 메모리에 존재할 경우 캐시 히트(Cache Hit)라고 합니다. 반대로 참조하려는 데이터가 캐시 메모리에 존재하지 않아 다시 메모리까지 접근해야 할 경우 캐시 미스(Cache Miss) 라고 합니다. 캐시 히트 횟수를 (캐시 히트 횟수 + 캐시 미스 횟수)로 나눈 값을 캐시 적중률이라고 하며, 캐시 메모리가 얼마나 활용되고 있는지 확인하는 지표가 됩니다.

 

3. 코드로 살펴보기

캐시 메모리에 대해서 알아야 하는 이유를 하기 C# 코드로 살펴보겠습니다.

public static int count = 0;

static void Main(string[] args)
{
    int[,] arr = new int[50000, 50000];
    int row = arr.GetLength(0), col = arr.GetLength(1);

    long time = CheckTime(() => {
        for (int i = 0; i < row; i++)
            for (int j = 0; j < col; j++)
                count += arr[i, j];
    });
    Console.WriteLine($"[i, j] 순서: {time}ms 소모");

    time = CheckTime(() => {
        for (int i = 0; i < row; i++)
            for (int j = 0; j < col; j++)
                count += arr[j, i];
    });
    Console.WriteLine($"[j, i] 순서: {time}ms 소모");
}

static long CheckTime(Action action)
{
    Stopwatch watch = new Stopwatch();
    watch.Start();
    action();
    watch.Stop();

    return watch.ElapsedMilliseconds;
}

 상기 코드에서는 전역 변수 count에 arr의 모든 원소를 더하는 연산을 두가지 방법으로 시행하였습니다(arr의 모든 원소는 디폴트 값인 0이므로 덧셈에 별 의미는 없습니다). CheckTime() 함수를 만들어 넘겨준 연산의 수행 시간을 반환하게 하였으며, 람다식으로 반복문을 돌며 모든 원소에 접근하는 코드를 넘겼습니다. 첫번째 방식은 [0, 0], [0, 1], [0, 2], ...  순으로 접근하며, 두번째 방식은 [0, 0], [1, 0], [2, 0], ... 순으로 접근합니다. 측정 결과는 다음과 같습니다.

 

그림 2. 배열의 원소 접근_결과

두번째 방식이 약 3.5배 정도 더 많은 시간을 소요한 것을 알 수 있습니다. 이러한 차이가 나는 이유는 위에서 설명한 공간 지역성 때문입니다. 첫번째 방식을 그림으로 표현하면 아래와 같습니다.

 

그림3. 배열의 원소 접근_도식1

첫번째 원소인 [0, 0]에 접근할 때 메모리에서 데이터를 가져오면서, 공간 지역성에 의거하여 인접한 원소들을 캐시메모리에 함께 가져오게 됩니다. 이 후 [0, 1], [0, 2]에 접근할때는 이미 캐시 메모리에 존재하는 원소이기 때문에 메모리까지 갈 필요 없이 빠르게 처리할 수 있습니다(Cache Hit). 캐시 메모리에 저장해 둔 범위를 넘어서면 그제서야 다시 메모리까지 가서 데이터를 가져오고, 이 후로는 다시 캐시 메모리를 통해 빠르게 접근하여 처리할 수 있습니다. 

 

그림 4.배열의 원소 접근_도식2

하지만 두번째 방식은 얘기가 다릅니다. 인덱스를 [1, 0]으로 표현하니 [0, 1]과 별 차이가 없어보이지만 실제로는

[0, 49999] 다음, 즉 5만개의 원소 뒤에 있는 원소입니다. 최초 [0, 0]에 접근할 때 인접한 원소들을 함께 읽어들여 캐시 메모리에 저장했어도 다음에 접근하는 원소가 그 범위를 벗어났을 경우, 다시 메모리까지 가서 데이터를 새로 읽어들여야 합니다(Cache Miss). 두번째 방식은 코드상으로 보면 단지 [i, j]에서 [j, i]로 바뀌었을 뿐 별 차이가 없는 것 같지만, 실제로는 캐시 메모리의 활용도 면에서 큰 차이가 있는 것입니다. 물론 특별한 이유가 없다면 반복문을 두번째 방식으로 사용할 일은 별로 없겠지만, 내가 짠 코드가 하드웨어 레벨에서 성능의 차이를 낼 수 있다는 점은 주의해야겠습니다.