프로그램 코드들은 기계어로 변환되어 메모리에 올라가고 fetch, decode, execute를 반복하며 수행이 된다.
L fetch(명령어 가져오기) decode (명령어 해석하기) execute (명령어 실행기)
이때 메모리 순서라고 하는 것은 결국 CPU가 메모리에 있는 명령어를 읽는 순서를 말하는데
pc -> 0x0000 mov ....
0x0001 add ....
0x0002 mov ....
*pc (프로그램 카운터)
이 순서가 컴파일 시 컴파일러에 의해 혹은 런타임 중에 CPU에 의해 조정될 수 있기에 이에 대해 알아보자.
CPU에 의해 조정되는 메모리 순서
아래 표에도 나와있지만 모든 CPU가 메모리 순서 조정을 지원하진 않는다. 전통적인 아키텍처인 X86과 AMD, 즉 당신의 PC는 메모리 순서 조정을 거의 지원하지 않는다. 왜일까?🤔

weak memory model과 strong memory model의 CPU 아키텍처 장/단점
이는 weak memory model과 strong memory model의 CPU 아키텍처 장/단점을 보면 이해할 수 있다.
weak memory model의 대표적인 CPU 아키텍처로는 ARM이 있다.
하드웨어적으로 구현이 (strong memory model 대비) 비교적 간단하며, 메모리 접근 순서를 조절하므로 유연하기에, 최적화하여 특정상황에서 프로그램 성능을 향상 시킬 수 있다. 불필요한 메모리 접근을 줄임으로써 전력 소비도 감소시킬 수 있다. 반대로 접근 순서가 조절이 되기에 프로그램 일관성을 유지하기 힘들고 프로그래밍 난이도가 증가한다.
strong memory model의 대표적인 CPU 아키텍처로는 x86, AMD64 등이 있는데 x86은 오랜 역사를 가지고 이전부터 사용해 왔고, AMD64 아키텍처는 X86 아키텍처와 호완성을 유지하기 위해 사용하고 있다.
역사적 배경 외에도 strong memry model은 모든 메모리 접근이 순서대로 일어나는 것을 보장하고 (하드웨어가 메모리 접근 순서를 추적하고 제어하기에 하드웨어 설계가 복잡하고 버그 발생 가능성이 높다. 게다가 캐시 일관성 유지를 위해 Coherence 프로토콜 추가가 되는데 이는 더욱 하드웨어 설계를 복잡하게 만든다.) 프로그래머가 메모리 접근 순서를 제어하지 못하게 하기에 유연성은 떨어지나 프로그램의 논리에 집중만 하면 되니 프로그래밍 모델이 단순화되어 있다.
다음은 코드 동작에서의 Strong Memory Model과 Weak Memory Model의 차이 예시이다.
Strong Memory Model:
int x = 0;
void thread1() {
x = 1;
}
void thread2() {
int y = x;
}
Weak Memory Model:
int x = 0;
void thread1() {
x = 1;
}
void thread2() {
int y = x; // y가 0일 수도 있다.
}
Weak Memory Model에서 thread2의 y 변수는 thread1이 x 변수에 값을 쓰기 전에 읽을 수 있기 때문에 0일 수도 있다. 하지만 Strong Memory Model에서는 발생하지 않으며 특징을 정리하면 다음과 같다.
Strong Memory Model:
모든 프로세서가 메모리에 대한 동일한 시점을 가지고 있다.
한 프로세서가 메모리에 쓰면 다른 프로세서는 즉시 변경 사항을 볼 수 있다.
프로그램 순서를 이해하기 쉽고 디버깅하기 쉽다.
하지만 성능 저하를 초래할 수 있다. (모든 프로세서가 동기화되어야 하기 때문)
Weak Memory Model:
프로세서가 메모리에 대한 서로 다른 시점을 가질 수 있다.
한 프로세서가 메모리에 쓰면 다른 프로세서는 즉시 변경 사항을 볼 수 없을 수도 있다.
프로그램 순서를 이해하기 어렵고 디버깅하기 어려울 수 있다.
하지만 성능 향상을 가져올 수 있다.(프로세서가 동기화될 필요가 없기 때문)
Strong Memory Model과 Weak Memory Model 중 어떤 모델을 선택해야 할지는 프로그램의 특성에 따라 다르다. 프로그램 순서가 중요하고 디버깅하기 쉬운 프로그램이라면 Strong Memory Model을 사용하는 것이 좋다. 성능이 중요한 프로그램이라면 Weak Memory Model을 사용하는 것이 좋다.
이렇듯 용도와 목적에 따라 사용 아키텍처가 달라질 수 있으며 자신의 프로그램이 올라가는 CPU가 weak memory model이라면 메모리 순서 옵션을 알아두자.
값 | 설명 |
memory_order_relaxed |
|
memory_order_consume |
|
memory_order_acquire |
|
memory_order_release |
|
memory_order_acq_rel |
|
memory_order_seq_cst |
|
* 위 memory order 옵션의 상세 사용법은 모두의 코드 사이트에 잘 정리되어 있다.
컴파일 타임에 조정되는 메모리 순서
컴파일러는 코드 분석 과정에서 최적화를 하면 코드의 의미를 바꾸지 않는 범위 내에서 변경을 한다. 변경을 하는 대표적인 예를 들을 정리해 보면 다음과 같다.
. 연산의 순서를 바꾸어도 결과가 같을 경우:
1) 덧셈과 곱셈은 교환 법칙이 성립하기 때문에 순서를 바꾸어도 결과가 같다.
//변경 전 코드
int main() {
int a = 1, b = 2;
int c = a + b * 2;
return c;
}
//변경 후 코드
int main() {
int a = 1, b = 2;
int c = b * 2 + a;
return c;
}
2) 비교 연산자는 순서를 바꾸어도 결과가 같다.
//변경 전 코드
int main() {
int a = 1, b = 2;
if (a > b) {
a = b;
}
return a;
}
//변경 후 코드
int main() {
int a = 1, b = 2;
if (b > a) {
a = b;
}
return a;
}
. 상호 의존성이 없는 연산의 경우:
> 서로 영향을 주지 않는 연산은 순서를 바꿔도 결과가 같다.
//변경 전 코드
int main() {
int a = 1, b = 2;
int c = a++, d = b++;
return c + d;
}
//변경 후 코드
int main() {
int a = 1, b = 2;
int tmp = a;
a++;
int c = tmp;
tmp = b;
b++;
int d = tmp;
return c + d;
}
. 레지스터 할당:
레지스터 사용을 효율적으로 하기 위해 연산 순서를 변경할 수 있다.
//변경 전 코드
int main() {
int a = 1, b = 2, c = 3, d = 4;
int e = a + b * c + d;
return e;
}
//변경 후 코드
int main() {
int a = 1, b = 2, c = 3, d = 4;
int tmp = b * c;
int e = a + tmp + d;
return e;
}
변경 전 코드에서는 a, b, c, d 변수를 모두 레지스터에 저장해야 한다. 변경 후 코드에서는 b와 c 변수를 곱한 결과를 임시 변수 tmp에 저장하여 레지스터 사용을 줄일 수 있다.
. 캐시 활용:
캐시 적중률을 높이기 위해 연산 순서를 변경할 수 있다.
//변경 전 코드
int main() {
int a[1000];
for (int i = 0; i < 1000; i++) {
a[i] = i;
}
for (int i = 0; i < 1000; i++) {
int sum += a[i];
}
return 0;
}
변경 전 코드에서는 a[i]와 a[i + 1]에 접근하는 순서가 순차적이지 않아 캐시 적중률이 낮을 수 있다. 컴파일러는 다음과 같이 연산 순서를 변경하여 캐시 적중률을 높일 수 있다.
//변경 후 코드
int main() {
int a[1000];
for (int i = 0; i < 1000; i++) {
a[i] = i;
}
for (int i = 0; i < 1000; i += 2) {
int sum = a[i] + a[i + 1];
}
return 0;
}
변경 후 코드에서는 a[i]와 a[i + 1]에 접근하는 순서를 2씩 증가시켜 캐시 적중률을 높일 수 있다.
//변경 전 코드
int main() {
int a[1000][1000];
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 1000; j++) {
a[i][j] = i * j;
}
}
return 0;
}
변경 전 코드에서는 a[i][j]에 접근하는 순서가 행 우선 순서이다. 컴파일러는 다음과 같이 연산 순서를 변경하여 캐시 적중률을 높일 수 있다.
int main() {
int a[1000][1000];
for (int j = 0; j < 1000; j++) {
for (int i = 0; i < 1000; i++) {
a[i][j] = i * j;
}
}
return 0;
}
변경 후 코드에서는 a[i][j]에 접근하는 순서를 열 우선 순서로 변경하여 캐시 적중률을 높일 수 있다.
현대 CPU는 일반적으로 64바이트 캐시 라인을 사용한다. 즉, 캐시 라인은 64바이트 크기의 연속적인 메모리 주소를 저장하기에 a[0][0]에 접근하면 캐시 라인에 a[0][0]부터 a[3][0]까지 데이터가 로드되며 a[0][1]에 접근하면 캐시 라인에 이미 a[0][1] 데이터가 로드되어 있으므로 추가적인 캐시 접근이 필요하지 않다.
컴파일러가 캐시 적중률을 높이기 위해 연산 순서를 변경하는 경우를 이해하는 것은 코드를 최적화하고 성능을 향상시키는 데 도움이 될 수 있다.
반대로 컴파일러가 연산 순서를 변경하는 것을 원하지 않는 경우에는 다음과 같은 방법을 사용할 수 있다.
괄호를 사용하여 연산 순서를 명시적으로 지정한다.
컴파일러 옵션을 사용하여 연산 순서 변경을 비활성화한다.
컴파일러가 연산 순서 변경을 비활성화하는 옵션은 컴파일러마다 다르지만 일반적으로 다음과 같은 옵션을 사용할 수 있다.
GCC/Clang:
-fno-reorder-blocks: 기본 블록 순서를 변경하지 않는다.
-fno-reassociate: 연산 순서를 변경하지 않는다.
-fno-sched-pressure: 스케줄링 압력을 사용하지 않는다.
Visual Studio:
/O1: 최적화를 최소화한다.
/Ob: 코드 크기를 최적화한다.
/Od: 디버깅을 위해 코드를 최적화한다.
컴파일러 최적화 옵션
일반적인 컴파일러 최적화 옵션은 다음과 같다.
-fno-inline, /Ob1: 함수 인라인 확장을 비활성화한다.
-fno-reorder-blocks, /Ob2: 기본 블록 순서 변경을 비활성화한다.
-fno-reassociate, /Ob3: 연산 순서 변경을 비활성화한다.
-fno-schedule-pressure, /Ob4: 스케줄링 압력을 사용하지 않는다.
-O, /O: 최적화 수준을 설정한다. (일반적으로 수준이 높을수록 최적화 효과가 높지만, 컴파일 시간이 길어지고 디버깅이 어려워질 수 있다.)
-Os, /Os: 코드 크기를 최적화한다.
-O2, /O2: 실행 속도를 최적화한다.
-O3, /O3: 최적화 수준을 최대로 설정한다.
지금까지 메모리 접근 순서를 조정하는 컴파일러와 CPU에 대해 알아보았다. 본 글 작성 초반에는 memory order 옵션 사용법에 대해 정리하려고 했는데 스스로에게 꼬리에 꼬리는 무는 질문을 하다 결국 컴파일러와 CPU에 의해 조정되고 있는 memory order에 대해 이해하는 게 좀 더 필요할 것 같아 정리하였는데 도움이 되었으면 한다.
참고

'L C++ > Concurrency' 카테고리의 다른 글
modern C++ Concurrency 기술 공부를 위하여✔ (0) | 2024.02.06 |
---|---|
[Concurrency] lock-free와 mutex사이 spin lock (2) | 2024.01.23 |
[Concurrency] 동기화 기본 요소(synchronization primitive) 정리 (0) | 2024.01.19 |
[Concurrency] ❇ std::atomic ❇의 이해 (0) | 2024.01.16 |
[Concurrency] C++20 스레드 조정 메커니즘 std::latch와 std::barrier (2) | 2024.01.09 |