컴파일러와 POoE
- CPU는 프로그래머가 코드를 작성한 순서대로 명령어를 실행하지 않음 (성능을 높이기 위함)
- 비순차적 실행 단계
- 기계 명령어를 생성하는 단계: 컴파일 중 명령어 정렬
- CPU가 명령어를 실행하는 단계: 실행 중 명령어가 비순차적으로 실행
컴파일러가 명령어 순서를 변경하는 과정
코드 예시
int a = 1;
int b = 2;
int c = a + 5; // a에 의존
int d = b + 3; // b에 의존
최적화된 어셈블리어
; 최적화된 어셈블리 코드
mov eax, 1 ; a = 1
mov ebx, 2 ; b = 2
add ebx, 3 ; d = b + 3 (먼저 계산)
add eax, 5 ; c = a + 5 (나중에 계산)
- 아래와 같은 명령어로 정렬을 못하게 지시 가능.
- asm volatile("" ::: "memory");
- 하지만 CPU 또한 명령어를 실행할 때 비순차적으로 실행
순차적 CPU 명령어 실행 과정
- 기계 명령어를 가져오기
- 명령어의 피연산자가 레지스터에 저장
- 피연산자가 아직 메모리에 저장되지 않았다면 저장될 때 까지 대기
- 데이터가 준비 되었다면 실행단계 돌입
- 실행 결과를 기록
비순차적인 CPU 명령어 실행 과정
- 기계 명령어 가져오기
- 명령어를 대기열에 넣고 명령어에 필요한 피연산자 읽기
- 명령어는 대기열에서 피연산자가 준비 완료될 때 까지 대기, 준비 완료된 명령어부터 실행
- 기계 명령어를 실행하면 실행 결과를 이전과 다른 재배치 버퍼에 적재
- 이전 명령어 실행 결과가 기록될 때까지 대기, 현재 명령어의 실행 결과를 기록.
- 이 과정은 명령어의 원래 실행 순서에 맞춰 결과를 응답하기 위함
이와같은 비순차적인 명령어 실행을 Out of Order Excution(OoOE), 즉 비순차적 명령어 실행이라고 합니다. CPU와 메모리의 실행속도는 엄청난 차이가 나기 때문에 대기하는 동안 빈공간(Slot)이 생기게 되고 다른 명령어로 이 공간을 메울 수 있다면 CPU를 효율적으로 사용가능해짐. (하지만 모든 CPU가 해당 기능을 보유하고 있지 않음)
캐시 고려
- 캐시 시스템에서는 캐시 갱신과 캐시 일관성 유지가 매우 중요
- 캐시로 저장하는 과정을 최적화하기 위해 코어와 L1 캐시 사이에 버퍼를 통해 전달
- 코어는 버퍼를 통해 기록을 전달한 뒤 다음 명령어 실행
코드 예시
a = 1;
b = y;
print(a);
- 비동기로 메모리가 갱신된다면 a를 출력할 때 초기값이 출력되어야 함.
- CPU의 내부 설계에서 결과를 원래 순서대로 커밋(commit)하는 ROB(Reorder Buffer)를 사용하여 프로그램 순서를 보장
- 이런 동작은 여러 개의 코어를 활용하여 명령어를 실행할 때 발생하는 현상
- 단일 스레드 환경에서는 이런 문제를 신경 쓸 필요가 없음
저수준의 CPU
- 저장 버퍼는 CPU 유형에 따라 다르며 각자의 최적화 방법들이 존재
- 설명한 과정이 어떤 명령어에는 적용이 되지만 어떤 명령어에는 적용이 안될 수 있음, CPU에 따라서도 지원이 안될 수 있음
- 대부분 프로그래밍 언어에 이런 문제를 내부적으로 해결해주지만 잠금 없는 프로그래밍(lock-free programming)에서는 고려해야함
- CAS(Compare and Store) 알고리즘을 통해 원자성 작업에는 문제가 없지만 중간 상태가 필요하다면 제어가 필요
4 가지 메모리 장벽 유형
첫번째 장벽, Load 장벽
- CPU의 성능을 위해 기다리지 않고 바로 다음 명령어를 실행
- LoadLoad 유형의 메모리 장벽은 다음에 오는 Load 명령어가 먼저 실행되는 것을 방지
- 장벽 이전의 모든 load 연산이 장벽 이후의 load 연산보다 먼저 완료되도록 보장
- 읽기 연산들의 순서를 유지
두번째 장벽, Store 장벽
- Store 장벽은 CPU가 Store 명령어를 실행될 때 다음 쓰기 명령어를 방지
- 장벽 이전의 모든 store 연산이 장벽 이후의 store 연산보다 먼저 완료되도록 보장
- 쓰기 연산들의 순서를 유지
- 비동기로 처리되더라도 장벽으로 인해 변수 갱신 순서와 코드 순서를 보장
세번째 장벽, LoadStore 장벽
- is_enemy_comming Load: 메모리에서 값을 읽어옴
- is_enemy_comming Load 대기: 캐시에서 값이 완전히 로드될 때까지 대기
- important=10 실행: is_enenmy_comming이 완료된 후에만 important 수행
4번째 장벽, StoreLoad:
- Store A 실행: 값을 메모리에 쓰기 시작
- Store A 완료 대기: 모든 캐시 레벨까지 쓰기가 완료될 때까지 대기
- Load B 실행: Store A가 완전히 완료된 후에만 Load B 수행
- StoreStore은 코드의 순서만 보장할 뿐 최신 값을 보장하지 않음
StoreLoad가 가장 비용이 큰 이유:
- Store 연산은 여러 캐시 레벨을 거쳐야 하므로 완료 시간이 오래 걸림
- CPU는 Store 완료를 기다리며 파이프라인이 정체됨
- 따라서 StoreLoad 순서 보장이 성능에 가장 큰 영향을 미침
획득 해제 의미론
다중 스레드 프로그래밍에 고려해야되는 점
- 공유 데이터에 대한 상호 베타적인 접근
- 스레드 간 데이터 동기화
획득 의미론과 해제 의미론이란?
- 획득 의미론: Load 뒤에 있는 모든 메모리 작업은 Load 작업 이전에 실행 불가
- 해제 의미론: Store 앞에 있는 모든 메모리 작업은 Store 작업 이전에 실행 불가
C++에서 제공하는 명령어 순서 제어 인터페이스
1. std::memory_order (C++11)
메모리 순서를 제어하는 열거형으로, atomic 연산과 함께 사용됩니다.
#include <atomic>
std::atomic<int> data{0};
std::atomic<bool> ready{false};
// Acquire 의미론
void consumer() {
while (!ready.load(std::memory_order_acquire)) {
// ready가 true가 될 때까지 대기
}
// 이 시점에서 data 읽기는 안전함
int value = data.load(std::memory_order_relaxed);
}
// Release 의미론
void producer() {
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_release);
}
2. std::atomic_thread_fence
명시적인 메모리 펜스를 제공합니다.
#include <atomic>
std::atomic<int> x{0}, y{0};
void thread1() {
x.store(1, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_release);
y.store(1, std::memory_order_relaxed);
}
void thread2() {
while (y.load(std::memory_order_relaxed) != 1) {}
std::atomic_thread_fence(std::memory_order_acquire);
assert(x.load(std::memory_order_relaxed) == 1); // 항상 성공
}
3. std::atomic_signal_fence
단일 스레드 내에서 컴파일러 최적화만을 제어합니다.
volatile int signal_var = 0;
void signal_handler() {
std::atomic_signal_fence(std::memory_order_acquire);
// 시그널 핸들러에서 안전한 메모리 접근
signal_var = 1;
std::atomic_signal_fence(std::memory_order_release);
}
4. memory_order 종류
enum class memory_order {
relaxed, // 순서 보장 없음
consume, // 의존성 기반 순서 (deprecated)
acquire, // 획득 의미론
release, // 해제 의미론
acq_rel, // 획득-해제 의미론
seq_cst // 순차 일관성 (기본값)
};
잠금 프로그래밍과 잠금 없는 프로그래밍
잠금없는 프로그래밍
- 개별 스레드가 실패해도 시스템 전체는 진행된다
- 경합이 심할 때도 최소 하나의 스레드는 성공한다
- 데드락이나 라이브락 없이 시스템이 동작한다
- 스레드가 중단되어도 다른 스레드들은 계속 작업할 수 있다
- 스레드가 대기 없이 항상 일을 하도록 하는 것에 큰 가치를 둠
잠금 프로그래밍
- 상호 배제: 한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 보장. 잠긴 뒤 다른 스레드는 대기 상태로 진입. 이는 락을 해제할 때까지 계속 됨
- 스핀락: 잠금을 획득할 때까지 CPU를 소모하며 계속 확인. 컨텍스트 스위칭 오버헤드가 없음
- 잠금을 사용하고 있을 때 다른 스레드는 대기 상태로 잠금이 풀릴 때까지 기달려야함. 모든 스레드가 앞으로 나아갈 수 없음
명령어 재정렬은 하드웨어인가 소프트웨어인가?
- 하드웨어 엔지니어 입장에서 명령어 재정렬은 CPU 성능 향상의 도움을 줌
- 명령어 재정렬은 어려우며 리눅스 토발즈 또한 버그를 일으키는 주요 원인 중 하나라고 지적
- 소프트웨어 입장에서는 하드웨어 내부에서 문제를 해결하는 것이 편하다
- 성능을 위해, CPU는 반드시 프로그래머가 작성한 순서대로 명령어를 실행할 필요가 없다
- 단일 스레드인 경우에는 프로그래머가 비순차적인 실행을 볼 수 없기 때문에 명령어 재정렬에 신경 쓸 필요가 없다
- 메모리 장벽은 특정 코어에서 명령어를 실행하는 순서와 다른 코어에서 보이는 순서가 코드 순서와 일치하게 만들기 위함
- 잠금 기반 프로그래밍을 사용한다면, 개발자가 직접 명령어 재정렬(instruction reordering)을 고려할 필요가 없다
요약
- 폰노이만 구조에서는 캐시가 없지만 메모리와 CPU의 속도가 극명하게 차이나게 되면서 캐시라는 개념이 생김
- CPU 대부분에 캐시를 내장하고 있고 다중 코어, 다중 스레드, 캐시까지 추가되며 소프트웨어 설계의 복잡함은 높아짐
- 캐시 용량은 제한되어 있기 때문에 프로그램에 필요한 데이터에 집중해서 쓰는 것을 지향
- 여러 스레드 사이에 캐시의 일관성이 중요하다면 다중 스레드 프로그래밍에 캐시 튕김 현상을 경계
- 스레드 사이에 데이터를 최대한 공유하지 않도록 설계하는 것이 중요
- 빈번하게 들어가는 데이터가 같은 캐시 라인에 들어가는지 유의, 거짓 공유는 캐시 튕김을 일으킴
- 최적화는 병목이 발생되는 부분이 판단이 섰을 때 적용, 섣부른 최적화는 만악의 근원
- 명령어 재정렬 문제는 메모리 장벽으로 해결할 수 있지만 다중 스레드 기반의 잠금 없는 프로그래밍이 필요하지 않다면 고려할 필요 없음.
거짓 공유: 서로 다른 변수들이 같은 캐시 라인에 위치하여, 실제로는 공유하지 않는 데이터임에도 불구하고 캐시 일관성 프로토콜에 의해 성능 저하가 발생하는 현상
'CS' 카테고리의 다른 글
[컴퓨터 밑바닥의 비밀] CPU 진화론 (3) | 2025.06.29 |
---|---|
[컴퓨터 밑바닥의 비밀] 프로그래밍 개념 파헤치기 (3) | 2025.06.01 |
[컴퓨터 밑바닥의 비밀 스터디] 링커에 대하여 (0) | 2025.05.27 |
[컴퓨터 밑바닥의 비밀] 소스 코드의 역사 (0) | 2025.05.11 |
비개발자를 위한 마이크로 서비스 아키텍처(MSA) 안내서 (2) | 2024.01.21 |