공간 지역성과 시간 지역성을 활용해 캐시 알고리즘을 구성할 수 있다고 소개했습니다. 그런데 데이터 구조나 알고리즘을 사용할 때 메모리에 접근하는 패턴을 관찰하면 시간 지역성이나 공간 지역성을 확인하기 어려운 경우가 있습니다. 예를 들어, 링크드 리스트로 데이터를 관리할 때 메모리에 접근하는 패턴을 관찰하면 특정 위치에 있는 데이터에 다시 접근할 확률이 높지 않아 시간 지역성과 같은 특징을 지니지 않습니다. 

 

또한 링크드 리스트는 배열과 달리 인접한 메모리 주소 공간에 접근하지 않아 공간 지역성에도 맞지 않습니다. 그런데 코드를 유심히 분석하면 프로그램은 함수의 호출과 자료구조로 구성돼 있다는 사실을 알 수 있습니다. 자주 사용하는 자료 구조나 알고리즘은 어느 정도 정해져 있습니다. 예를 들어, 링크드 리스트나 스택과 같은 데이터 구조나 알고리즘을 사용해 데이터를 관리하는 패턴을 볼 수 있습니다. 

 

알고리즘의 특징을 활용해 프로그램이 메모리에 접근하는 패턴을 예측할 수 있는데, 이를 알고리즘 지역성이라고 합니다.
이번에는 간단한 예시 코드를 보면서 알고리즘 지역성에 대해 알아봅시다.

struct node {
02 struct node *next; // 다음 노드 주소를 저장
03 int data; // 데이터 저장 필드
04 };
05
06 struct node *curr = head->next; // 링크드 리스트 포인터에 첫 번째 노드의 주소를 저장
07 while (curr != NULL) // 노드의 포인터가 NULL이 아닐 때까지 실행
08 {
09 curr = curr->next; // 포인터에 다음 노드의 주소를 저장
10 process_data(curr); // process_data 함수를 호출해 노드를 처리
11 }

 

위 코드는 링크드 리스트를 구성하는 노드에 접근해 데이터를 처리하는 루틴입니다. 07 ~ 11번째 줄은 노드를 순회하면서 노드의 포인터가 NULL이 아닐 때까지 while 루프를 실행합니다. 그런데 링크드 리스트에 존재하는 자료 구조는 연속적인 메모리 공간에 위치하지 않습니다. 만약 링크드 리스트라는 자료 구조의 패턴을 미리 파악한다면 다음과 같이 처리할 수 있습니다.


06 struct node *curr = head->next; // 링크드 리스트 포인터에 첫 번째 노드의 주소를 저장
07 // 링크드 리스트를 순회하면서 노드 주소를 미리 로딩
08 while (curr != NULL) // 노드의 포인터가 NULL이 아닐 때까지 실행
09 {
10 curr = curr->next; // 포인터에 다음 노드의 주소를 저장
11 process_data(curr); // process_data 함수를 호출해 노드를 처리
12 }


이전 코드와 비교했을 때 달라진 점은 07번째 줄입니다. 07번째 줄은 미리 링크드 리스트를 순회해 노드포인터를 임시로 로딩하는 동작을 표현한 주석입니다. 07번째 줄과 같이 링크드 리스트를 미리 순회해 노드 포인터를 로딩하면 링크드 리스트를 구성하는 노드에 접근하는 08 ~ 12번째 줄의 실행 시간이 더 빨라질 것입니다.


링크드 리스트로 구성된 데이터를 어떤 패턴으로 접근할지는 예측이 가능한데, 이를 위해서는 링크드 리스트라는 자료구조와 알고리즘의 패턴을 이해해야 합니다. 알고리즘의 특징을 활용해 프로그램이 메모리에 접근하는 패턴을 예측하는 과정을 알고리즘 지역성이라고 합니다.


누군가 평소에 자주 들르는 카페에 다시 갈 가능성이 있을까요? 한 번도 안 간 사람보다 다시 갈 확률이 높을 것입니다. 이와 유사하게 프로그램이 특정 명령어나 특정 변수를 반복적으로 사용하는 패턴을 시간 지역성(temporal locality)이라고 합니다. 프로그램이 실행될 때 특정 명령어나 변수에 자주 접근하면 가까운 미래에 사용할 것을 대비해 사용된 명령어나 데이터를 캐시에 미리 로딩합니다.

 

앞에서 예시로 든 예제 코드를 보면서 시간 지역성에 대해 알아봅시다.

01 static int arr[10];
02 int sum = 0;
03 int weight = 10;
04
05 for (i = 0; i < 10; i++) {
06 sum += arr[i] + weight;
07 }


06번째 줄은 arr[] 배열의 &a[0] ~ &a[9] 주소에 있는 배열의 데이터를 읽어서 sum에 더하는 루틴입니다. 데이터에 접근하는 패턴을 자세히 관찰하면 다음과 같은 사실을 확인할 수 있습니다.


weight 지역 변수가 위치한 주소에 반복적으로 접근할 확률이 높다.

 


이런 특징을 활용해 &a[1] ~ &a[9] 주소에 있는 데이터를 로딩할 때 &weight 주소에 있는 데이터를 미리 로딩하면 속도 개선 효과를 얻을 수 있습니다. 이처럼 다시 접근할 것으로 예상되는 데이터를 미리 로딩해 캐시를 설계할 수 있는데, 이를 시간 지역성이라고 합니다.


시간 지역성을 활용한 다양한 알고리즘이 제안됐는데, 그중 프리페칭이 가장 널리 알려진 알고리즘입니다. 프리페칭은 최근에 CPU 코어에서 접근한 주소 접근 이력을 참고해 더 많은 양의 데이터를 미리 페치하는 기법입니다.

 

 

자주 가는 카페가 있다면 카페 옆에 있는 다른 가게에 갈 가능성도 있습니다. 이처럼 프로그램이 어떤 데
이터를 사용하면 그 데이터와 인접한 (주소에 있는) 데이터에 접근할 확률이 높습니다. 이 같은 패턴을 공
간 지역성(spatial locality)이라고 합니다.
【정보】 섹션 정보를 활용한 공간 지역성
프로그램을 컴파일하면 컴파일러는 연관된 객체(자료구조)를 특정 메모리 공간(섹션)에 모아두는 경향이 있습니다.
이번에는 다음 예제 코드를 보면서 공간 지역성에 대해 더 자세히 알아봅시다.
01 static int arr[10];
02 int sum = 0;


03
04 for (i = 0; i < 10; i++) {
05 sum += arr[i];
06 }
05번째 줄은 arr[] 배열의 &a[0] 주소에서 &a[9] 주소에 있는 배열의 데이터를 읽어서 sum에 더하는 루틴입
니다. 데이터의 위치와 데이터에 접근하는 패턴을 자세히 관찰하면 다음과 같은 사실을 확인할 수 있습
니다.
■ &a[0] ~ &a[9] 주소 구간에 위치한 데이터는 연속적인 메모리 공간에 위치한다.
■ &a[0] 주소에 접근하면 &a[1] ~ &a[9] 주소에 접근할 확률이 높다.
이런 특징을 활용해 &a[0] ~ &a[9]가 존재하는 주소에 미리 접근해 데이터를 캐시에 올려 놓으면 for 루프
가 더 빨리 동작할 것입니다. 이처럼 이미 사용된 데이터 주소 근처에 있는 데이터를 미리 로딩하는 방식
을 활용해 캐시를 설계합니다.
이처럼 공간 지역성은 최근 접근한 데이터의 주변 공간에 다시 접근하는 소프트웨어의 패턴을 뜻합니다.
이런 특징을 잘 활용해 연속적인 공간에 존재하는 메모리 데이터를 미리 로딩하면 메모리에 접근하는 횟
수를 줄일 수 있습니다. 이 같은 구조로 설계된 시스템에서는 같은 코드를 실행해도 더 빨리 실행될 수 있
습니다.


【정보】 디맨드 페치란?
공간 지역성을 활용하는 기법 중 하나는 디맨드 페치(demand fetch)입니다. 디맨드 페치란 프로그램이 메인 메모리에
접근해 명령어나 데이터를 캐시에 로딩한 다음에 일정 시간 동안 유지하는 방식을 의미합니다.



이전 절에서는 한 개의 캐시를 기준으로 캐시의 기본 개념을 설명했습니다. Arm 프로세서를 포함한 대부분의 프로세서는 멀티 레벨로 캐시가 구성돼 있습니다. 다음 그림을 보면서 멀티 레벨 캐시에 대해 알아봅시다. 
 


그림 18.2 메모리 계층 구조에서 캐시의 역할 

그림의 왼쪽과 오른쪽에 있는 Core는 말 그대로 CPU 코어를 뜻합니다. Core의 아랫부분을 보면 L1I$와 L1D$ 캐시가 보입니다. 여기서 L1은 레벨 1 캐시(레벨 원으로 발음)라고 하며 L1I$는 L1 명령어(Instruction) 캐시, L1D$는 L1 데이터(Data) 캐시를 뜻합니다.

위와 같이 Arm 프로세서를 비롯한 대부분 프로세서는 캐시가 여러 계층으로 구성돼 있습니다. L1(Level 1: 레벨 1 캐시), L2(Level 2: 레벨 2 캐시)는 대부분 Cortex 프로세서에서 볼 수 있고, L3 캐시까지 있는 프로세서도 확인할 수 있습니다. 그림에서 L3$ 로 명시된 부분은 L3 캐시를 나타냅니다. 

 


[중요] LLC(Last Level Cache) 란?
보통 마지막 레벨의 칩 내에 위치한 캐시를 특별히 LLC(Last Level Cache)라고 부릅니다. LLC 이후는 시간이 매우 오래 걸리는 칩 밖의 메모리 계층으로 이동하므로 특별히 구분합니다.

만약 Arm 코어에서 접근하는 데이터나 명령어가 L1, L2 캐시, 그리고 LLC에 없으면 프로세서 밖에 존재하는 메모리에 접근해 데이터나 명령어를 로딩해야 합니다. 이 과정에서 버스와 메모리 컨트롤러와 같은 하드웨어의 도움을 받아야 하므로 실행 시간이 더 오래 걸립니다. 따라서 LLC에서 가장 중요한 캐시 성능 지표는 캐시 미스로 선정하는 경우가 많습니다.

 



L1 캐시, L2 캐시를 처음 봤을 때 "캐시를 L1으로만 잘 설계하면 되지, 여러 계층의 캐시를 설계하는 이유는 무엇일까?"라는 의문이 생깁니다. 여러 계층의 캐시를 구성하면 프로세서의 성능을 최대한 끌어올릴 수 있기 때문입니다. 캐시의 접근 속도와 용량 사이에 트레이드오프가 있기 마련인데, 멀티 레벨로 캐시를 구성하면 트레이드오프를 최대한 극복할 수 있습니다.

프로세서가 데이터를 요청하면 다음과 같은 순서로 처리합니다. 

1. 먼저 L1 캐시에서 찾는다.
2. 만약 L1 캐시에 데이터가 없으면 L2 캐시에서 찾는다.
3. 그래도 데이터가 없으면 LLC에서 찾는다.
4. 만약 LLC에 도달해도 데이터가 없으면 메인 메모리에 접근해 데이터를 가져 온다.

CPU 코어에 가장 인접한 L1 캐시 내부는 명령어 캐시와 데이터 캐시가 따로 존재합니다. 그다음 레벨인 L2 캐시 이상의 캐시는(L2 캐시, L3 캐시) 하나의 캐시에서 명령어와 데이터를 함께 처리하는 방식으로 설계합니다. 

대부분의 Arm 프로세서는 캐시를 멀티 레벨로 구성하며, 성능을 극대화하는 방향으로 시스템을 설계합니다. 멀티 캐시에 대한 더 자세한 내용은 18.3절 ‘멀티 레벨 캐시’를 참고합시다.

이렇게 해서 캐시를 구성하는 주요 개념을 설명했습니다. 다음 절에서는 캐시와 관련된 알고리즘을 소개합니다.

 

 

 

Arm 아키텍처는 캐시를 제어하는 명령어를 제공하는데, 명령어의 동작 원리를 제대로 파악하려면 Arm 아키텍처에서 정의된 캐시의 동작과 관련된 용어를 알아야 합니다. 먼저 캐시와 관련된 용어를 소개하고 캐시를 제어하는 명령어를 소개합니다.


실전 개발에서 캐시의 동작을 설명할 때 '캐시 플러시(Cache Flush)'란 용어를 많이 씁니다. 일반적으로 캐시의 데이터를 메인 메모리에 내린다는 의미로 사용됩니다. 리눅스 커널이나 RTOS에서 Arm 프로세서의 캐시를 제어하는 함수나 레이블의 이름에 flush가 포함된 경우가 많습니다. 일반적으로 캐시 플러시는 캐시 라인에 있는 데이터를 메인 메모리에 복사해 캐시와 메인 메모리에 있는 데이터의 싱크를 맞추는 동작을 뜻합니다. 

그런데 Arm 아키텍처 문서를 보면 Flush라는 용어 대신 다음과 같은 용어를 사용해 캐시의 동작을 설명합니다.  

 * 캐시 클린
 * 캐시 Invalidate
 * 캐시 Clean & Invalidate

위에서 명시된 용어에 대해 더 자세히 알아봅시다.  

캐시 클린

캐시 클린은 현재 레벨의 캐시 라인을 다음 레벨의 캐시나 메모리에 기록하는 동작입니다. 예를 들어 데이터 캐시를 클린한다고 하면 캐시 라인 중에 Dirty(더티)로 명시된 캐시 라인을 다음 레벨의 캐시나 메모리에 써주는 동작입니다. 예를 들어 L1 캐시를 클린하면 L1 캐시에 더티로 마킹된 캐시 라인을 L2 캐시에 써줍니다.

캐시 Invalidate

Invalidate는 캐시 라인의 데이터를 비우는 동작입니다. 처음 부팅할 때 캐시 라인의 상태로 돌아간다고 볼 수 있습니다. 그렇다면 캐시 라인의 데이터를 어떻게 비울까요? 캐시 라인의 Valid 비트를 클리어하면서 Invalidate를 수행합니다. 

캐시를 Invalidate하는 동작은 언제 수행할까요? 한 가지 예시를 들어 보겠습니다. 트러스트존 아키텍처에서는 논시큐어 상태에서 시큐어 상태로 자주 바뀌는데, 시큐어 상태에서 실행됐던 캐시 라인의 데이터는 Invalidate한 다음에 논시큐어 상태로 이동합니다. 시큐어 상태에서 실행된 데이터가 캐시 라인에 남아 있으면 보안 상 취약점으로 남게 됩니다.

캐시 Clean & Invalidate

캐시 Invalidate를 수행하는데 더티(변경) 상태로 마킹돼 있는 캐시 라인이 있으면 데이터의 불일치가 발생합니다. 그래서 Invalidate를 할 때 더티(변경) 상태로 마킹돼 있는 캐시 라인이 있으면 해당 캐시를 클린한 다음에 Invalidate를 수행합니다. 이 동작을 'Clean & Invalidate'으로 명시합니다.

 

< '시스템 소프트웨어 개발을 위한 Arm 아키텍처의 구조와 원리' 저자>

 
 

컴퓨터가 발명된 후 초장기 시점에 컴퓨터는 어떤 모습일까요? 컴퓨터의 크기는 방 하나 정도였습니다. 그 당시에 CPU와 메인 메모리만 존재했습니다. CPU가 어떤 명령어를 실행하거나 데이터를 가져오려면 메인 메모리에 접근했습니다. 

컴퓨터 기술이 발전하면서 컴퓨터의 성능을 키우려는 시도를 합니다. 처음에 CPU의 처리 속도(클럭)를 키우려는 방향으로 다양한 연구를 진행했는데 CPU의 성능을 최대로 키우면 이에 비례해 시스템의 성능도 함께 좋아질 것이라 예상했습니다. 그래서 CPU의 성능을 측정했는데 다음과 같은 흥미로운 사실을 알게 됐습니다.

    "CPU의 대부분 시간은 메인 메모리에 접근하는데 허비한다."

CPU의 실행 속도는 매우 빠른데 메모리에 접근하는 시간은 CPU의 실행 시간에 비해 상당히 느리다는 사실을 알게 됐습니다. 차가 속도를 냈다가 신호등에서 기다렸다를 반복하듯이 CPU가 실행하다가 메모리에 접근해 데이터를 가져오기 위해 기다리면서 많은 사이클을 낭비했습니다. 이를 병목 현상이라고도 부릅니다.

성능을 측정한 다음에 병목 현상을 최소화하는 방법을 고민을 한 끝에 다음과 같은 아이디어가 떠올랐습니다.

    "CPU 사이에 속도가 빠른 임시 메모리가 있으면 속도가 빨라질 것이다."

CPU 근처에 속도가 성능이 매우 좋은 메모리를 위치시켰더니 성능이 상당히 개선된 것을 확인했습니다. 이처럼 CPU와 메인 메모리 사이에 있는 고속 메모리를 CPU 캐시라고 합니다.

다음 그림을 보면서 캐시의 기본 개념을 배워 봅시다. 



그림 17.1 프로세서 내부에서 캐시의 위치

그림의 가장 왼쪽 부분에 CPU, 가운데는 고속으로 동작하는 캐시 그리고 가장 오른쪽에 용량이 큰 메모리가 있습니다. 

CPU가 자주 사용하는 데이터를 캐시가 갖고 있으면 CPU는 캐시에 있는 데이터를 로딩하므로 속도가 빠를 것입니다. 대신 CPU에서 메모리에 직접 접근하면 시간이 오래 걸립니다. 그래서 CPU는 메인 메모리에 접근하기 전에 먼저 캐시에 데이터가 있는지 체크합니다. 만약 CPU가 사용할 데이터가 캐시에 100% 있다면 CPU는 기분이 좋을 겁니다. 용량이 크면서 속도가 빠르다는 느낌을 받을 겁니다. 그런데 이게 정말 가능할까요? 만약 CPU가 처리할 데이터나 명령어가 캐시에 항상 존재한다면 가능합니다.

그렇다면 CPU가 사용할 데이터가 캐시에 항상 존재할까요? 그렇지는 않습니다. CPU가 사용하는 모든 데이터가 100% 캐시에 존재하지 않습니다. 만약 캐시에 찾으려는 데이터가 없으면 메인 메모리로 가서 데이터를 가져와야 합니다.

[정보] 캐시와 성능

사실 캐시가 CPU 옆에 달려있는 작지만 동작 속도가 빠른 메모리라고 여기는 분도 있습니다. 하지만 캐시를 어떻게 설정하느냐에 따라 시스템 성능에 지대한 영향을 끼칩니다. 그래서 시스템 소프트웨어 개발자는 Arm 프로세서에서 캐시가 어떤 방식으로 구성돼 있는지 잘 알 필요가 있습니다.

  
Cortex-A53이나 Cortex-A57과 같은 프로세서의 세부 스팩 문서를 보면 프로세서의 세부 구현 방식을 확인할 수 있습니다. 이 중에 캐시의 타입과 캐시의 사이즈를 확인할 수 있습니다.

다음 표는 Arm 프로세서 별로 사용되는 캐시의 타입과 사이즈입니다.



표 17.1 Arm 프로세서 별 캐시 사이즈
---
[정보] 캐시 표현 방식

많은 CPU 아키텍처 문서에서 캐시를 $로 표기합니다. I$은 명령어(Instruction) 캐시, D$는 데이터(Data) 캐시를 뜻합니다.

표 17.1과 같이 Arm 프로세서마다 캐시를 다른 방식으로 구현합니다. 한 걸음 나아가 SoC 칩셋 업체들이 캐시의 사이즈는 적절히 변경해 자신의 칩에 맞게 디자인할 수 있습니다. 사실 CPU 캐시 이외에도 Arm 프로세서에서 사용되는 캐시의 종류는 생각보다 많습니다. Write 버퍼, TLB 캐시, predict 캐시를 예로 들 수 있습니다.
---

CPU가 메모리 주소에 존재하는 데이터를 로딩하는 과정에서 어떤 단계를 거칠까요? CPU는 먼저 캐시에 접근해 찾으려는 데이터가 있는지 체크합니다. 만약 캐시에 데이터가 있으면 캐시에 존재하는 데이터를 가져옵니다. 이 과정에서 메인 메모리에 접근하지 않습니다. 메인 메모리에 접근하면 수십에서 수 백 사이클을 허비하기 때문입니다. 이는 80km로 달리다가 신호등을 만나 기다리는 상황과 비슷합니다.

되도록 CPU가 캐시에 존재하는 데이터만 사용하고 메인 메모리에 접근하지 않으면 성능은 좋아질 겁니다.
---
[정보] 캐시는 어떻게 구성돼 있을까?

캐시는 어떻게 구성돼 있을까요? 캐시 메모리와 캐시 콘트롤러로 구성돼 있습니다. 

캐시 콘트롤러는 메인 메모리에 존재하는 데이터를 알아서 캐시 메모리에 로딩하는 역할을 합니다. 캐시 메모리는 말 그대로 캐시 데이터를 저장하는 공간입니다. 캐시 콘트롤러 CPU가 사용할 것 같은 데이터를 예측해 캐시 메모리에 로딩합니다. 
---.

여기까지 캐시의 기본 동작 원리를 알아봤습니다. 이어서 Arm 프로세서에 적용되는 멀티 레벨 캐시 구조를 알아봅시다.

 

< '시스템 소프트웨어 개발을 위한 Arm 아키텍처의 구조와 원리' 저자>

 

 
 

+ Recent posts