이번에는 다음과 같은 예시 명령어를 보면서 메모리 리오더링에 대해 자세히 알아봅시다.

 

그림 17.4 메모리 리오더링의

 

01 ~ 04번째 줄은 모두 스토어 명령어입니다. 01 ~ 04번째 명령어가 실행될 , X1 레지스터의 값이 0x10000이라고 가정합시다. 또한 명령어가 실행될 워드의 단위는 8바이트입니다.

 

먼저 01번째 줄을 분석하겠습니다. X1 레지스터가 0x10000이니 0x10000 주소에 X0 레지스터의 (8바이트 크기) 저장합니다. 01번째 줄이 실행되면 바이트는 '1' 업데이트됩니다. 이는 '1' 표기된 박스에 해당합니다. 이어서 02번째 줄을 봅시다. 같은 0x10000 주소에 2바이트 사이즈의 데이터를 저장합니다. 여기서 01번째 줄에서 실행된 0x10000 주소의 데이터가 업데이트 됩니다. 부분은 2 표기된 박스에서 확인할 있습니다. 여기서 STRH 명령어는 2바이트만큼 데이터를 스토어하는 동작이므로 0x10000 0x10001 주소에 데이터(‘2’ 표시된 박스) 저장합니다.

 

이어서 03번째 줄을 봅시다. 0x10002 주소에 4바이트 사이즈의 데이터를 저장합니다. 이는 가운데 3으로 표기된 박스에 해당됩니다. 03번째 줄의 명령어가 STR이고 저장하는 W3 레지스터의 사이즈가 4바이트입니다. 따라서 0x10002 ~ 0x10005 주소에 W3 레지스터의 값이 저장됩니다. 여기서 다음과 같은 질문을 던지면서 예제 명령어에 대해 조금 생각해 봅시다.

 

    "02번째와 03번째 명령어 사이에 의존성이 있을까"?

 

질문에 답하려면 02번째와 03번째 명령어가 연달아 실행되면 어느 주소 공간에 데이터가 저장되는지 확인해야 합니다. 02번째 줄은 0x10000 ~ 0x10001 영역 주소에 2바이트 크기만큼 데이터(2) 저장하고, 03번째 줄은 0x10002 ~ 0x10005 구간 주소에 4바이트 사이즈 데이터(3) 저장합니다.

 

02번째와 03번째 줄을 실행한 결과 업데이트되는 주소 공간이 중복되지 않습니다. 따라서 "02 ~ 03번째 명령어 간에는 의존성이 없다"라고 답할 있습니다. 만약 Arm 프로세서 내부에서 03번째 명령어를 먼저 실행하고 02번째 명령어를 실행하면 결과가 달라지지 않습니다. 그래서 명령어의 순서를 바꿔서 실행할 있습니다. 이를 메모리 리오더링이라고 합니다.

 

여기서 걸음 나갈까요? Arm 프로세서는 내부에서 02 ~ 03번째 명령어를 번에 실행할 수도 있습니다. 이를 Arm 아키텍처에서는 머지 액세스라고 하는데 이번에 예시 명령어를 통해 머지 액세스가 무엇인지 자세히 알아봅시다.

 

02번째 줄은 0x10000 ~ 0x10001 범위의 주소에 접근합니다. 이를 메모리 액세스를 한번 수행했다고 말할 있습니다. 03번째 줄은 0x10002 ~ 0x10005 범위의 주소에 접근하며 역시 메모리 액세스를 처리했다고 있습니다. 02 ~ 03번째 줄을 순서대로 실행하면 2 메모리 액세스를 수행합니다.

 

만약 02번째 줄과 03번째 줄을 번에 실행하면 어떻게 될까요? 02 ~ 03번째 명령어 사이에 의존성이 없으니 번에 0x10000 ~ 0x10005 메모리 영역에 접근해 데이터를 스토어할 있습니다. 이를 머지 액세스라고 합니다.

 

 

[정보] 메모리 리오더링의 예시

 

명령어가 순차적으로 실행해도 프로세서 내부에서 메모리에 접근하는 순서는 순차적이지 않을 수도 있습니다.

 

이해를 돕기 위해 가지 예를 들겠습니다. 은행에 먼저 고객은 1 번호표를 받고 은행 직원과 상담을 시작했습니다. 그런데 다음에 2 번호표를 받은 고객이 창구에 왔는데, 은행 직원이 봤을 1번보다 2 번호표의 고객을 은행 직원이 먼저 처리할 있다고 판단합니다. 1 번호표의 고객의 요청 사항을 처리하려면 은행의 인프라 서버에 10 간격으로 접근할 있는 상황이기 때문입니다. 무엇보다 1 고객의 요청이 2 고객과 의존성이 없는 상황이니 고민할 필요가 없습니다. 이럴 2 번호표의 고객을 은행 직원이 먼저 처리할 있습니다.

 

1 고객과 2 고객의 요청을 모아 은행의 인프라 서버에서 처리하면 업무 시간을 줄일 수도 있습니다. 은행 직원도 메모리 리오더링과 유사한 방식을 적용해 업무를 최적화합니다.

 

 

Arm 프로세서 내부에서 성능을 최적화하기 위해 종종 어셈블리 명령어의 순서를 바꿔 실행한다고 설명했습니다. 이런 동작을 수행하기 전에 먼저 각 명령어 간에 의존성이 있는지 체크합니다. 명령어 간 의존성이 있다면 명령어의 순서를 지켜 실행합니다.

다음 어셈블리 명령어를 보면서 어드레스 의존성에 대해 배워 봅시다.
 

그림 17.2 의존성이 있는 로드(LDR) 명령어의 예시

위 코드는 2개의 로드 명령어로 구성돼 있습니다. 

먼저 01번째 줄을 봅시다. X1 레지스터가 0x30000을 담고 있고 0x30000 주소에 0x10000이 있다고 가정합시다. 01번째 줄은 X1 레지스터가 담고 있는 주소에 있는 데이터를 X0 레지스터에 로딩하는 동작입니다. 이어서 02번째 줄은 X0 레지스터의 값에서 8을 더한 결과(주소)에 있는 값을 X2 레지스터에 로딩합니다. 

01번째 줄 명령어를 실행한 결과, X0이 0x10000이면 02번째 줄은 0x10008 주소에 있는 데이터를 X2 레지스터에 로딩합니다. 02번째 줄은 다음과 같이 실행된다고 볼 수 있습니다.

X2 = *(0x10000 + 0x8);

이번에는 다른 예를 들까요? 01번째 줄 명령어의 실행 결과 X0이 0x20000이면 02 번째 줄은 어떻게 실행될까요? 02번째 줄은 0x20008 주소에 있는 데이터를 X2 레지스터에 로딩합니다. 이를 다음과 같은 수식으로 나타낼 수 있습니다. 

X2 = *(0x20000 + 0x8);

앞의 두 가지 예시에서 든 명령어의 공통점은 다음과 같은 의사 코드로 표현할 수 있습니다. 

X2 = *(0x0 + 0x8);

“X2 레지스터의 값은 X0 레지스터에 따라 결정된다”라고 해석할 수 있습니다. 

앞에서 소개한 두 가지 패턴의 명령어에서 한 가지 기억할 점은 "01번째 줄 명령어의 실행 결과가 02번째 줄에 영향을 미친다."라는 것입니다. 여기서 X0 레지스터의 값에 따라 02번째 줄의 실행 결과에 영향을 미치니 “01번째와 02번째 줄 명령어 사이에 의존성이 있다”라고 볼 수 있습니다. 이를 어드레스 의존성(address dependence) 혹은 레지스터 의존성(register dependence)이라고 하며, 다음 그림과 같이 표현할 수 있습니다.

 

그림 17.3 어드레스 의존성

만약 의존성이 있는 01번째 줄과 02번째 줄 명령어의 실행 순서를 바꾸면 어떻게 동작할까요? 명령어가 실행되면 예측할 수 없는 이상한 값이 레지스터에 저장될 가능성이 높아 오동작을 유발할 것입니다. 따라서 명령어 사이에 의존성이 있으면 Arm 프로세서 내부에서 메모리에 액세스하는 순서를 바꾸지 않습니다.

대부분 시스템 소프트웨어 개발자는 C 언어로 프로그래밍합니다. C 언어로 작성된 코드는 컴파일러에 의해 기계어로 변환되는데, 과정에서 컴파일러는 성능을 최적화하기 위해 코드를 나름대로 재해석합니다. 컴파일러에 의해 최적화된 코드를 보면 " 컴파일러는 똑똑하구나, 어떻게 코드를 알아서 생성했을까!" 생각이 듭니다.

 

C 코드를 컴파일할 컴파일러 최적화 옵션을 적용하는데, 최적화 레벨이 높을수록 컴파일러는 높은 수준의 최적화를 수행합니다. 하지만 컴파일러는 프로그래머가 작성한 코드를 최적화하는 과정에서 종종 사고를 치기도 합니다. 코드를 최적화하다 보니 선을 넘어 프로그래머가 의도하지 않은 코드를 생성하기 때문입니다. 예를 들어, 함수를 인라인으로 처리하면서 심벌이 제거되기도 합니다.

 

컴파일러가 코드를 생성하는 과정에서 최적화를 수행해 문제를 일으키면 차라리 낫습니다. C 코드와 컴파일러가 생성한 어셈블리 명령어를 비교하면 컴파일러가 명령어를 어떻게 생성했는지 확인할 있기 때문입니다. 하지만 Arm 프로세서 내부에서 명령어의 순서를 바꿔서 실행하면 소프트웨어 개발자가 이를 눈으로 확인할 없습니다. Arm 프로세서는 명령어 의존성이 없다면 성능을 최적화하기 위해 '메모리 액세스' 순서를 변경할 있기 때문입니다.

 

이러한 노멀 메모리의 특징을 Weakly-Ordered 모델이라고 명시합니다. 'weakly Ordered 모델 가장 특징은 메모리 액세스 순서가 프로그램 순서와 같을 필요가 없다라는 점인데, 여기서 말하는 프로그래밍 순서의 기준은 로드와 스토어입니다.

지금까지 Arm 아키텍처에서 정의된 노멀 메모리 타입과 디바이스 메모리 타입을 알아봤습니다. 이어서 메모리 맵을 보면서 노멀 메모리 타입과 디바이스 메모리 타입의 특징을 자세히 알아봅시다.

[정보] 메모리 맵이란?

실전 프로젝트를 진행하면 메모리 맵이란 용어를 자주 듣습니다. 메모리 맵은 무엇일까요? 프로그램에 의해 실행되는 프로세스 입장에서 바라 본 메모리 레이아웃입니다. 지도를 보면 일정한 규칙(시/도)에 따라 구획이 나눠져 있듯이 메모리 영역을 속성별로 분류한 일종의 메모리 지도가 메모리 맵입니다. 


메모리 맵의 구조도 알아보기

메모리 맵은 데이터 영역, 코드와 같은 속성으로 마킹된 다양한 영역으로 구성돼 있습니다. 다음 그림을 보면서 메모리 맵에 대해 더 자세히 알아봅시다.

 
출처: Learn the architecture - ARMv8-A memory systems https://developer.arm.com/documentation/100941/latest/

그림 17.1 메모리 맵의 구조도 

그림 17.1을 보면 가장 왼쪽 열에는 메모리 구간별 주소를, 가운데에는 영역 이름, 그리고 가장 오른쪽에는 메모리 속성을 확인할 수 있습니다.

메모리 맵의 가장 윗부분에 있는 Peripheral부터 봅시다. Peripheral로 분류되는 메모리 영역은 디바이스 메모리 타입으로 분류됩니다. 이 영역에 있는 데이터는 오로지 읽고/쓰기만 가능하며 직접 실행할 수는 없습니다. Peripheral은 프로그래머들이 작성한 코드가 로딩될 수 없는 영역이기 때문입니다. 

그다음으로 가운데에 있는 Kernel Code를 봅시다. Kernel Code에 있는 데이터는 실행만 가능하며 읽고/쓰기는 불가능합니다. 운영체제 커널 코드가 로딩된 영역이므로 코드를 읽거나 쓰면 코드 내용이 바뀌므로 읽고/쓰기는 불가능하도록 설정합니다. 

이어서 바로 위에 있는 Kernel Data를 봅시다. Kernel Data에 있는 데이터는 오로지 읽고/쓰기만 가능하며 직접 실행할 수는 없습니다.  

여기서 주의깊게 봐야 할 점이 있습니다. Kernel Data와 Kernel Code의 공통적인 속성은 Cacheable, Privileged입니다. 둘 다 캐시에 의해 처리되며 Privileged 권한으로 접근됩니다. 또는 Privileged는 Armv8 기준으로 EL1 이상의 권한으로 실행되는 동작 권한을 뜻합니다.

마지막으로 가장 아랫부분에 있는 App Data와 App Code의 영역을 알아봅시다. Kernel Data와 Kernel Code 영역이 지닌 속성과 대부분 같지만 Unprivileged라는 속성이 다릅니다. Unprivileged(비특권)는 가장 낮은 실행 권한을 뜻하며, Unprivileged 권한으로 실행되는 코드는 Privileged 권한이 부여된 코드나 데이터에 직접 접근할 수 없습니다.

메모리 맵을 구성하는 주요 영역

앞에서 든 예시와 같이 메모리 맵은 메모리 주소별 영역의 이름과 각 속성 정보로 구성돼 있습니다. 그림에서 설명한 내용 이외에 메모리 맵은 다음과 같은 속성 정보로 구성돼 있습니다.

 메모리 페리페럴
 메모리 영역의 코드와 데이터
 운영체제와 유저 애플리케이션에 포함되는 리소스

그림 17.1을 보면 다양한 메모리 영역이 존재하나 Arm 아키텍처 관점에서 보면 메모리 타입은 크게 두 가지로 분류됩니다.

 디바이스 메모리: Peripheral  
 노멀 메모리: Kernel Code, Kernel Data, App Code, App Data 
 
Kernel Code, Kernel Data, App Code, App Data 주소 영역은 노멀 메모리로 관리하며 페리페럴은 디바이스 메모리(메모리 맵드 I/O)로 처리됩니다.

메모리 맵에 존재하는 노멀 메모리 타입과 디바이스 메모리 타입을 구분하는 가장 큰 차이점은 무엇일까요? 바로 명령어나 메모리를 리오더링하는 동작입니다. 노멀 메모리 타입으로 분류된 명령어나 데이터를 Arm 코어가 실행하면 내부에서 다양한 방식으로 최적화 작업을 수행합니다. 대표적으로 메모리 접근 순서(메모리 리오더링)나 명령어의 실행 순서를 바꿉니다. 소프트웨어 개발자가 작성한 대부분의 코드는 노멀 메모리 타입으로 분류되며, 명령어와 데이터는 이 방식으로 처리됩니다. 

디바이스 메모리를 처리하는 방식

Arm 프로세서가 디바이스 메모리를 처리하는 방식은 노멀 메모리를 처리하는 방식과 다릅니다. 디바이스 메모리로 분류된 영역의 데이터를 Arm 코어가 보면 메모리 리오더링와 같은 기법을 적용해 실행하지 않습니다. 디바이스 메모리 타입은 메모리 맵드 I/O 방식이며, 대부분 페리페럴은 디바이스 메모리 타입으로 관리됩니다. 메모리 맵드 I/O로 정의된 주소에 접근하는 한 가지 예를 들까요?

 0xD000_0000 주소 접근: 페리페럴에 인터럽트 활성화
 0xD000_0004 주소 접근: 페리페럴에 인터럽트를 잘 받았다는 ACK(Acknowledge)를 전달
 0xD000_0008 주소 접근: 페리페럴의 인터럽트를 비활성화

위 목록은 메모리 맵드 I/O 기반 물리 주소와 특징을 나타냅니다. 각각 드라이버에서 0xD000_0000, 0xD000_0004, 0xD000_0008 주소에 접근해 페리페럴을 제어할 수 있습니다.

0xD000_0000 주소와 0xD000_0008 주소에 순차적으로 접근해 페리페럴을 제어하려는 루틴이 있는데, 이를 Arm 코어가 노멀 메모리 타입으로 처리하면 주소에 접근하는 순서를 바꿔서 실행할 수 있습니다. 예를 들어 페리페럴에서 인터럽트를 받으면 인터럽트에 대한 ACK를 보내고 페리페럴의 인터럽트를 비활성화하는 명령어를 작성했는데, 명령어의 순서가 바뀌면 오동작할 수 있습니다. 즉, 디바이스 메모리 타입으로 분류되는 영역이 노멀 메모리 타입처럼 처리되면 이처럼 예상치 못한 사이트 이펙트가 유발될 수도 있습니다.

이어서 디바이스 메모리를 알아봅시다. 메모리 맵을 보면 다양한 페리페럴 디바이스의 세부 정보와 함께 레지스터를 있습니다. 이를 Memory-Mapped I/O 혹은 디바이스 메모리라고 합니다.

 

디바이스 메모리 타입은 노멀 메모리 타입과 어떤 차이점이 있을까요? Arm 프로세서 입장에서 노멀 메모리로 마킹된 데이터는 메모리에 접근하는 명령어의 순서를 바꾸는 메모리 리오더링 기법이 적용됩니다. 하지만 디바이스 메모리인 경우는 다릅니다. 디바이스 메모리로 마킹된 데이터에 액세스할 메모리 리오더링이 적용되면 프로그래머가 예상하지 못한 오동작이 발생할 있습니다. 그래서 디바이스 메모리로 마킹된 데이터는 명령어를 순차적으로 실행해야 합니다.

 

디바이스 메모리는 페리페럴을 제어하는 용도로 사용되므로 명령어는 디바이스 메모리 타입으로 마킹된 메모리 영역에 로딩되면 됩니다. 그래서 디바이스 메모리로 지정된 영역은 실행 불가(not executable) 지정돼야 합니다. 디바이스 메모리 영역으로 지정된 공간을 '실행 불가' 설정하지 않으면 프로세서는 명령어를 메모리에서 미리 예측해 로딩할 수도 있어 결과 시스템이 예상치 못한 오동작을 있습니다.

명령어를 실행하거나 명령어 실행 과정에서 처리되는 데이터나 코드는 노멀 메모리 타입으로 처리됩니다. 대부분의 소프트웨어 개발자가 입력한 코드는 노멀로 마킹된 메모리 영역에 로딩된다고 있습니다.

 

Arm 프로세서는 노멀 메모리로 마킹된 영역의 데이터를 처리하기 위해 캐싱 기법을 적용합니다.

CPU 노멀 메모리로 마킹된 메모리 영역에 대해 다음과 같은 기법을 적용해 최적화를 수행합니다.

 

l  머지 액세스

l  스페큘레이션(speculation)

l  리오더링 액세스

 

노멀 메모리를 처리하는 과정에서 적용되는 다양한 기법을 소개합니다.

 

머지 액세스

 

메모리 공간에 여러 접근하거나 연속된 메모리 공간에 접근하는 2 이상의 명령어를 번에 처리하는 동작을 머지 액세스(merge access)라고 합니다.

 

CPU 입장에서는 레지스터보다 메모리에 접근할 많은 사이클과 리소스를 사용합니다. 이유는 무엇일까요? 메모리에 접근하려면 메모리 버스에 먼저 접근해야 하며, DRAM 메모리 콘트롤러와 같은 메모리 하드웨어에서 부가적인 동작을 수행하기 때문입니다. 메모리에 액세스하는 횟수가 늘어날수록 이에 비례해 많은 사이클을 소모합니다. 따라서 메모리에 접근하는 횟수를 줄이면 시스템 성능을 높일 있습니다.

 

Arm 프로세서 내부에도 메모리에 액세스하는 횟수를 최소화해 성능을 높이는 메커니즘이 적용돼 있습니다.  예를 들어, 같은 주소 공간에 있는 메모리에 액세스하는 명령어가 있으면 이를 합쳐서 번만 메모리에 접근할 있습니다.

 

스페큘레이션 액세스

 

성능 최적화를 위한 다른 CPU 설계 기법은 스페큘레이션 액세스입니다. 소프트웨어에서 접근할 것으로 예상하는 데이터를 패턴 인식과 같은 알고리즘을 활용해 미리 예측해 미리 로딩하는 동작을 스페큘레이션 액세스라고 하며 이를 통해 성능을 높일 있습니다.  

 

밖에도 Arm 프로세서는 내부에서 분기 예측, 실행 예측을 통해 성능을 극대화하는  메커니즘이 적용되어 있습니다.

 

리오더링 액세스

 

Arm 프로세서는 성능을 향상하기 위해 프로세서 내부에서 명령어 실행 순서를 바꿔서 실행할 있습니다. 이를 위해 Arm 프로세서는 명령어 간에 의존성이 있는지 체크하며, 의존성이 없는 경우 명령어의 순서를 바꿔 처리합니다.

 

Arm 프로세서는 내부적으로 성능을 극대화하기 위해 다양한 기법을 적용해 왔습니다. 가운데 대표적인 기법은 "데이터 처리량을 높이기 위해 메모리 읽기 혹은 쓰기 작업을 재정렬(reorder)하는 동작"으로, 이를 메모리 리오더링이라고 합니다. 메모리 리오더링을 통해 프로세서와 외부 메모리 간의 데이터 처리량을 늘릴 있고, 결과 프로세서의 성능을 높일 있습니다.

 

앞에서 언급한 가지 동작 특성은 노멀 메모리 타입으로 마킹된 메모리 영역에 적용됩니다. 일반적으로 대부분 소프트웨어 개발자가 입력한 코드는 앞에서 언급한 가지 특징이 적용된 상태에서 실행됩니다.

 

 

[정보] 분기 예측과 실행 예측

 

최신 Arm 코어는 성능을 높이기 위해 분기 예측이나 실행 예측을 수행하기도 합니다. 이러한 CPU 코어의 특징을 악용해 해커들은 악의적인 코드나 데이터를 캐시에 남기려는 시도를 합니다. 이를 스펙터(spectre) 혹은 멜트다운(meltdown)이라고 합니다.

 

 

 

앞에서 설명한 특징을 가리켜 노멀 메모리 타입은 "Weakly ordered"라는 특징이 있다고 말합니다. 여기서 말하는 "Weakly ordered" 무엇일까요? 프로그램을 실행하면 메모리에 액세스하는데, 메모리 액세스 순서가 프로그램에 명시된 프로시저(실행 흐름) 같지 않다는 의미입니다.

+ Recent posts