메모리 계층 구조
메모리 계층 구조란 응답 시간을 기반으로 메모리의 종류를 구분한 단계를 의미한다. 응답 속도가 빠를 수록 비싸고, 응답 속도가 늦을 수록 저렴하다. 고성능 프로그램을 설계하기 위해서는 메모리 계층의 제한사항, 즉 각 구성 요소의 크기와 기능을 고려해야한다.
캐시 메모리 활용
캐시 메모리와 레지스터의 응답속도 차이로 인한 병목점을 개선하여 전체 성능을 개선하기 위해서 사용된다. 컴퓨터의 동작 흐름은 기본적으로 다음과 같다.
- 처리1. 명령어를 바탕으로 메모리에서 레지스터로 데이터를 읽어온다. (메모리에 접근)
- 처리2. 레지스터에 있는 데이터를 바탕으로 계산한다. (레지스터에 접근)
- 처리3. 계산 결과를 메모리에 쓴다. (메모리에 접근)
레지스터에만 접근하는 처리2의 과정에 비해서 메모리에 접근해야하는 처리1,3은 상당히 느리다. 아무리 처리2 과정을 단축시켜도 전체 성능에는 큰 영향을 줄 수 없다.
병목점이 되는 처리 1,3을 개선한다면 전체 성능이 크게 향상될 수 있다. 이때 캐시 메모리에 접근하는 것이 메모리에 접근하는 것에 비해서 수십배 빠르다는 점을 이용하여 처리1,3을 고속화 할 수 있다.
캐시 메모리를 활용한 읽기
캐시 메모리를 활용할때 명령어를 바탕으로 메모리에서 레지스터로 데이터로 가져오는 과정은 다음과 같다.
- 메모리에서 캐시라인 크기(여기서는 10바이트, 일반적으로는 64바이트를 주로 사용함) 만큼 캐시 메모리로 값을 읽어옴. (메모리에 접근)
- 캐시 메모리에서 레지스터로 값을 읽어옴.(캐시 메모리 접근 - 고속)
만약 다시 메모리 주소 300~310 위치에 다시 접근해야 할 경우 해당 내용이 이미 캐시 메모리에 저장되어 있기 때문에 메모리까지 가지 않고 캐시 메모리 접근(고속)으로 처리할 수 있게 된다.
캐시 메모리를 활용한 쓰기
캐시 메모리를 활용한 쓰기 동작 과정은 다음과 같다.
- 레지스터 값 수정 (레지스터 접근)
- 캐시 메모리에 수정한 데이터 씀(캐시 메모리 접근)
캐시 메모리에서 수정이 발생하면 캐시 메모리에는 더티 플래그가 설정되고, 더티 플래그가 설정된 값은 백그라운드 영역에서 메모리로 옮겨써지기 때문에 오직 캐시 메모리 접근만으로 쓰기 작업을 마칠 수 있게 됨.
따라서 캐시 메모리를 활용할 경우 한번 읽어왔던 데이터에 대해서는 다음과 같이 전체 성능을 크게 개선할 수 있게됨.
- 처리1. 명령어를 바탕으로 메모리에서 레지스터로 데이터를 읽어온다. (캐시 메모리에 접근 - 고속)
- 처리2. 레지스터에 있는 데이터를 바탕으로 계산한다. (레지스터에 접근)
- 처리3. 계산 결과를 메모리에 쓴다. (캐시 메모리에 접근 - 고속)
캐시 메모리가 가득 찬 경우
캐시 메모리가 가득 찬 경우에 캐시 메모리에 존재하지 않는 데이터를 추가로 읽으면 기존의 캐시 메모리 중 1 개를 파기한뒤, 해당 위치에 새로운 데이터를 읽어들인다. 그리고 파기하는 캐시가 더티라면 대응되는 메모리에 덮어쓴 다음 버리는 동기화 작업이 일어난다.
계층형 캐시 메모리
최근 x86_64 아키텍처의 CPU는 캐시 메모리가 계층형 구조로 되어 있다. 각 계층은 사이즈, 레이턴시, 어느 논리 CPU 사이에 공유하는가 등이 다름. 캐시의 종류는 L1, L2, L3 로 이루어져 있으며, 가장 레지스터에 가깝고 용량이 적으며 빠른것이 L1이고, 번호가 늘어날 수록 레지스터로부터 멀어지며 용량이 커지고 속도가 느려진다.
참조 지역성
만약 주 메모리에서 한번 읽어와서 캐시 메모리에 저장했지만, 다시 사용되지 않는다면 캐시 메모리의 효율이 좋지 않을 것이다. 하지만 대부분의 프로그램들은 한번 읽은 메모리 지역에 다시 접근할 가능성이 매우 높다는 것이 알려져있다. 이러한 특징을 다음과 같이 표현한다.
- 시간 지역성: 특정 시점에서 접근하는 데이터는 가까운 미래에 다시 접근할 가능성이 크다. 전형적인 예로는 루프 처리 중인 코드 영역을 들 수 있다.
- 공간 지역성: 특정 시점에 어떤 데이터에 접근하면 그 데이터와 가까운 주소에 있는 데이터를 접근할 확률이 크다. 전형적인 예로는 배열의 전체 탐색을 들 수 있다.
즉 일반적인 프로그램에서는 주 메모리에서 캐시 메모리로 한번 읽어온 경우 가까운 미래에 다시 접근할 가능성이 크기 때문에 캐시 메모리의 효율이 극대화될 수 있는 것이다.
참조 지역성을 활용한 예시
아래의 두 코드를 살펴보면 논리적으로는 동일한 성능을 보여야 한다. 하지만 실제로는 많게는 수십배 이상의 성능 차이가 발생할 수 있다.
// A: arr[i][j]
for(int i = 0; i < MAX_ROW; i++)
{
for(int j = 0; j < MAX_COL; j++)
{
arr[i][j]++;
}
}
// B: arr[j][i]
for(int i = 0; i < MAX_ROW; i++)
{
for(int j = 0; j < MAX_COL; j++)
{
arr[j][i]++;
}
}
첫 번째 A
코드의 동작을 메모리 맵으로 살펴보면 다음과 같다.
처음 arr[0][0]
원소의 위치에 접근하게 되면 OS는 arr[0][0]
원소의 인접 원소들 까지도 캐시라인의 크기만큼 캐시 메모리로 가져온다. (일반적으로는 64바이트 이지만 여기서는 표현하기 쉽게 4개의 원소를 하나의 캐시라인 크기로 표현함) 따라서 arr[0][1]
, arr[0][2]
, arr[0][3]
도 함께 캐시메모리에 올라가게 된다.
그리고 이어서 arr[0][1]
, arr[0][2]
, arr[0][3]
원소에 접근할 때는 주 메모리가 아닌 캐시메모리에 접근하기 때문에 캐시의 효율이 극대화될 수 있다.
두 번째 B
코드의 동작을 메모리 맵으로 살펴보면 다음과 같다.
처음 arr[0][0]
원소의 위치에 접근하게 되면 마찬가지로 OS는 캐시라인 크기만큼 arr[0][1]
, arr[0][2]
, arr[0][3]
원소도 함께 캐시메모리에 올린다.
그러나 프로세스는 다음 코드에서 arr[1][0]
에 접근하게 된다. 캐시 메모리에는 arr[1][0]
이 없기 때문에 "캐시 미스"를 발생시키고 다시 arr[1][0]
, arr[1][1]
, arr[1][2]
, arr[1][3]
를 메모리에 올리게 된다. 따라서 이미 캐시 메모리에 한번 올라온 데이터들을 제대로 활용하지 못하여 성능이 그만큼 떨어지게 된다.
논리적으로 동일한 원소에 접근하지만 첫번째 예시는 캐시 적중률(캐시 활용률)이 높고, 두번째 예시는 캐시 적중률(캐시 활용률)이 낮아 성능에서는 수십 ~ 수백배의 차이를 보이게 된다.
출처:
실습과 그림으로 배우는 리눅스 구조(저자: 다케우치 사토루)
유튜브 채널: 코드없는 프로그래밍(https://www.youtube.com/watch?v=qLZLDzA_dcs)