Rust의 fetch_max 한 줄이 매크로와 컴파일러 인트린식, LLVM IR의 atomicrmw umax, AtomicExpandPass의 CAS 루프 전개, 그리고 최종 x86-64/ARM 어셈블리까지 어떻게 변환되는지를 5단계로 추적합니다. 면접 질문에서 출발한 탐구를 통해 추상화의 아름다움과 아키텍처별 차이를 살펴봅니다.
QuestDB는 거래 현장부터 미션 컨트롤까지, 까다로운 워크로드를 위한 오픈 소스 시계열 데이터베이스입니다. 초저지연, 높은 적재 처리량, 멀티 티어 스토리지 엔진을 제공합니다. Parquet와 SQL을 네이티브로 지원해 데이터를 이식성 있게, AI 준비 완료 상태로 유지하며, 벤더 종속이 없습니다.
저는 가끔 엔지니어링 포지션 지원자들을 면접합니다. 우리는 동시성 프로그래밍을 이해하는 사람이 필요합니다. 우리가 즐겨 묻는 질문 중 하나는 여러 프로듀서 스레드에서 최대값을 추적하는 방법입니다 — 실제 시스템에서 자주 나타나는 고전적인 패턴이죠.
지원자는 원하는 언어를 사용할 수 있습니다. 제가 가장 잘 아는 언어인 Java에서는 CAS 루프를 작성하거나, 함수형 스타일로 가고 싶다면 람다와 함께 updateAndGet()을 사용할 수 있습니다:
AtomicLong highScore = new AtomicLong(100);
[...]
highScore.updateAndGet(current -> Math.max(current, newScore));
하지만 그 람다는 일을 하고 있습니다 — 내부적으로는 여전히 루프를 돌며, 다른 스레드가 간섭하면 재시도합니다. 이 루프는 AtomicLong 소스 코드에서 그대로 볼 수 있습니다.
그러다 한 지원자가 Rust를 선택했습니다.
저는 그가 타이핑하는 것을 따라가며, 명시적인 CAS 루프나 그걸 감싼 함수형 래퍼를 보게 되리라 예상했습니다. 그런데 그는 이렇게만 썼습니다:
high_score.fetch_max(new_score, Ordering::Relaxed);
“Rust에는 fetch_max가 내장돼 있어요.” 그는 아무렇지 않게 설명하곤, 문제의 다음 부분으로 넘어갔습니다.
잠깐만요. 이건 루프 패턴을 감싼 래퍼가 아니라, fetch_add나 fetch_or 바로 옆에 자리한 1급 원자적 연산이었습니다. Java에는 없습니다. C++에도 없습니다. Rust는 어떻게 이걸 그냥… 갖고 있을 수 있죠?
면접이 끝난 뒤, 호기심이 저를 이끌었습니다. 왜 Rust는 fetch_max를 빌트인 인트린식으로 제공할까요? 인트린식은 보통 특정 하드웨어 명령을 활용하기 위해 존재합니다. 하지만 x86-64에는 atomic max 명령이 없습니다. 그렇다면 파이프라인 어딘가에 CAS 루프가 있어야 합니다. 아니면… 어떤 아키텍처에는 이 명령이 정말 있다 고? 그렇다면 동일한 Rust 코드가 둘 다에서 어떻게 동작하죠?
알아내야 했습니다. 루프는 Rust 표준 라이브러리에 있었을까요? LLVM에 있었을까요? x86-64용 코드 생성 시 만들어졌을까요?
그래서 파기 시작했습니다. 제가 발견한 것은 다섯 개의 명확히 구분되는 컴파일러 변환 레이어를 통과하는 흥미로운 여정이었습니다. 각 레이어는 추상화의 한 겹을 걷어내며, 루프가 정확히 어디서 물질화되는지 보여줬습니다. 제가 알아낸 것을 공유하겠습니다.
지원자가 작성한 코드부터 시작해 봅시다 — 여러 스레드에서 안전하게 업데이트할 수 있는 간단한 하이스코어 트래커입니다:
rustuse std::sync::atomic::{AtomicU64, Ordering}; fn main() { let high_score = AtomicU64::new(100); let _old_score = high_score.fetch_max(200, Ordering::Relaxed); }
이 한 줄은 약속한 일을 정확히 수행합니다: 현재 값을 원자적으로 읽고, 새 값과 비교하여 더 크면 갱신하고, 이전 값을 반환합니다. 안전하고 간결하며 실수할 여지가 없습니다. 명시적 루프도, 어디에도 재시도 로직도 보이지 않죠. 하지만 내부적으로는 어떻게 동작할까요?
우리의 fetch_max 호출이 기계어 생성에 가까워지기도 전에 또 다른 추상화 레이어가 작동합니다. fetch_max 메서드는 각 원자적 타입마다 손으로 작성된 게 아니라 atomic_int!라는 Rust 매크로로 생성됩니다.
Rust 표준 라이브러리 소스를 들여다보면, AtomicU64와 그 모든 메서드가 사실 이 매크로로 만들어진다는 것을 알 수 있습니다:
rustatomic_int! { cfg(target_has_atomic = "64"), atomic_umin, atomic_umax, 8, u64 AtomicU64 }
이 매크로 안에서, fetch_max는 어떤 정수형에도 동작하는 템플릿으로 정의됩니다:
rustpub fn fetch_max(&self, val: $int_type, order: Ordering) -> $int_type { unsafe { $max_fn(self.v.get(), val, order) } }
$max_fn 자리표시는 부호 없는 타입에는 atomic_umax, 부호 있는 타입에는 atomic_max로 치환됩니다. 이 단 하나의 매크로 정의가 AtomicI8, AtomicU8, AtomicI16, AtomicU16 등 — AtomicU128까지 — 모든 타입의 fetch_max 메서드를 생성합니다.
즉, 우리의 간단한 fetch_max 호출은 사실 생성된 코드를 호출하는 것입니다. 그렇다면 atomic_umax 함수는 실제로 무엇을 할까요? 답하려면 Rust 컴파일러가 다음에 무엇을 생성하는지 봐야 합니다.
이제 fetch_max가 매크로 생성 코드로 atomic_umax를 호출한다는 것을 알았으니, Rust 컴파일러가 이를 어떻게 처리하는지 살펴봅시다. 컴파일러는 곧장 어셈블리로 가지 않습니다. 먼저 코드를 중간 표현으로 번역합니다. Rust는 LLVM 컴파일러 프로젝트를 사용하므로, **LLVM Intermediate Representation(IR)**을 생성합니다.
우리의 fetch_max 호출에 대한 LLVM IR을 엿보면 대략 이런 것이 보입니다:
bb7:
%0 = atomicrmw umax ptr %self, i64 %val monotonic, align 8
...
이는 LLVM의 언어로 “원자적 read-modify-write 연산이 필요하다. 내가 수행하고 싶은 수정은 부호 없는 최대값이다.”를 의미합니다.
이는 컴파일러 내부에 존재하는 강력한 고수준 명령입니다. 하지만 중요한 질문을 던집니다: CPU에 정말 umax라는 단일 명령이 있을까요? 대부분의 아키텍처에서는 답이 ‘아니오’입니다. 그렇다면 컴파일러는 이 간극을 어떻게 메울까요?
저의 목표는 무슨 일이 일어나는지 설명하는 데 그치지 않고, 여러분이 스스로 볼 수 있는 도구를 드리는 것입니다. 이 변환을 여러분의 머신에서 단계별로 추적할 수 있습니다.
먼저 Rust 컴파일러에게 LLVM IR 생성 이후에 멈추라고 지시합니다:
rustc --emit=llvm-ir main.rs
그러면 main.ll 파일이 생성됩니다. 이 파일에는 우리의 atomicrmw umax 명령을 포함해 Rust 코드의 LLVM IR 표현이 들어 있습니다. 이 파일을 보관해 두세요. 다음 단계에서 사용할 겁니다.
우리는 중요한 무언가를 놓치고 있습니다. Rust 함수 atomic_umax는 어떻게 LLVM 명령 atomicrmw umax가 되는 걸까요? 바로 컴파일러 인트린식이 등장하는 지점입니다.
Rust 소스 코드를 파고들면, atomic_umax가 다음과 같이 정의되어 있음을 알 수 있습니다:
rust#[inline] #[cfg(target_has_atomic)] #[cfg_attr(miri, track_caller)] unsafe fn atomic_umax<T: Copy>(dst: *mut T, val: T, order: Ordering) -> T { unsafe { match order { Relaxed => intrinsics::atomic_umax::<T, { AO::Relaxed }>(dst, val), Acquire => intrinsics::atomic_umax::<T, { AO::Acquire }>(dst, val), Release => intrinsics::atomic_umax::<T, { AO::Release }>(dst, val), AcqRel => intrinsics::atomic_umax::<T, { AO::AcqRel }>(dst, val), SeqCst => intrinsics::atomic_umax::<T, { AO::SeqCst }>(dst, val), } } }
그런데 intrinsics::atomic_umax 함수는 무엇일까요? 정의를 보면 약간 특이한 점이 있습니다:
rust#[rustc_intrinsic] #[rustc_nounwind] pub unsafe fn atomic_umax<T: Copy, const ORD: AtomicOrdering>(dst: *mut T, src: T) -> T;
본문이 없습니다. 이는 정의가 아니라 선언입니다. #[rustc_intrinsic] 속성은 이 함수가 컴파일러 자체가 이해하는 저수준 연산에 직접 매핑됨을 Rust 컴파일러에 알려줍니다. Rust 컴파일러가 intrinsics::atomic_umax 호출을 보면, 이를 대체하여 대응하는 LLVM 인트린식 함수로 바꿉니다.
그래서 우리의 여정은 실제로 이렇게 보입니다:
fetch_max 메서드(사용자에게 보이는 API)atomic_umax 함수 호출로 전개됨atomic_umax는 컴파일러 인트린식atomicrmw umax로 치환 ← 현재 위치LLVM은 코드를 분석하고 변환하는 일련의 “패스”를 실행합니다. 우리가 관심 있는 것은 AtomicExpandPass입니다.
이 패스의 역할은 atomicrmw umax 같은 고수준 원자 연산을 보고 대상 아키텍처에 묻는 것입니다. “이걸 네이티브로 할 수 있니?”
x86-64 백엔드가 “안 돼”라고 말하면, 이 패스는 단일 명령을 CPU가 이해하는 보다 기본적인 명령 시퀀스로 확장합니다. 그 결과는 compare-and-swap(CAS) 루프입니다.
이 변환을 실제로 보려면, LLVM에 해당 패스 전후의 중간 표현을 출력하게 하면 됩니다. AtomicExpandPass 이전의 IR을 보려면 다음을 실행하세요:
llc -print-before=atomic-expand main.ll -o /dev/null
팁:
llc가 설치되어 있지 않다면,rustc에게 직접 패스를 실행하도록 요청할 수도 있습니다.rustc -C llvm-args="-print-before=atomic-expand -print-after=atomic-expand" main.rs
코드는 터미널에 출력됩니다. 우리의 원자적 max를 포함하는 함수는 다음처럼 보입니다:
*** IR Dump Before Expand Atomic instructions (atomic-expand) ***
define internal i64 @_ZN4core4sync6atomic9AtomicU649fetch_max17h6c42d6f2fc1a6124E(ptr align 8 %self, i64 %val, i8 %0) unnamed_addr #1 {
start:
%_0 = alloca [8 x i8], align 8
%order = alloca [1 x i8], align 1
store i8 %0, ptr %order, align 1
%1 = load i8, ptr %order, align 1
%_7 = zext i8 %1 to i64
switch i64 %_7, label %bb2 [
i64 0, label %bb7
i64 1, label %bb5
i64 2, label %bb6
i64 3, label %bb4
i64 4, label %bb3
]
bb2:
unreachable
bb7:
%2 = atomicrmw umax ptr %self, i64 %val monotonic, align 8
store i64 %2, ptr %_0, align 8
br label %bb1
bb5:
%3 = atomicrmw umax ptr %self, i64 %val release, align 8
store i64 %3, ptr %_0, align 8
br label %bb1
bb6:
%4 = atomicrmw umax ptr %self, i64 %val acquire, align 8
store i64 %4, ptr %_0, align 8
br label %bb1
bb4:
%5 = atomicrmw umax ptr %self, i64 %val acq_rel, align 8
store i64 %5, ptr %_0, align 8
br label %bb1
bb3:
%6 = atomicrmw umax ptr %self, i64 %val seq_cst, align 8
store i64 %6, ptr %_0, align 8
br label %bb1
bb1:
%7 = load i64, ptr %_0, align 8
ret i64 %7
}
여러 메모리 순서 지정에 따라 atomicrmw umax 명령이 여러 곳에 있음을 볼 수 있습니다. 이는 컴파일러 백엔드는 이해하지만 CPU는 이해하지 못하는 고수준 원자 연산입니다.
llc -print-after=atomic-expand main.ll -o /dev/null
관련 부분의 출력은 다음과 같습니다:
*** IR Dump After Expand Atomic instructions (atomic-expand) ***
define internal i64 @_ZN4core4sync6atomic9AtomicU649fetch_max17h6c42d6f2fc1a6124E(ptr align 8 %self, i64 %val, i8 %0) unnamed_addr #1 {
start:
%_0 = alloca [8 x i8], align 8
%order = alloca [1 x i8], align 1
store i8 %0, ptr %order, align 1
%1 = load i8, ptr %order, align 1
%_7 = zext i8 %1 to i64
switch i64 %_7, label %bb2 [
i64 0, label %bb7
i64 1, label %bb5
i64 2, label %bb6
i64 3, label %bb4
i64 4, label %bb3
]
bb2:
unreachable
bb7:
%2 = load i64, ptr %self, align 8
br label %atomicrmw.start
atomicrmw.start:
%loaded = phi i64 [ %2, %bb7 ], [ %newloaded, %atomicrmw.start ]
%3 = icmp ugt i64 %loaded, %val
%new = select i1 %3, i64 %loaded, i64 %val
%4 = cmpxchg ptr %self, i64 %loaded, i64 %new monotonic monotonic, align 8
%success = extractvalue { i64, i1 } %4, 1
%newloaded = extractvalue { i64, i1 } %4, 0
br i1 %success, label %atomicrmw.end, label %atomicrmw.start
atomicrmw.end:
store i64 %newloaded, ptr %_0, align 8
br label %bb1
[... MORE OF THE SAME, JUST FOR DIFFERENT ORDERING..]
bb1:
%7 = load i64, ptr %_0, align 8
ret i64 %7
}
패스는 앞부분을 바꾸지 않았습니다 — 여전히 메모리 순서에 따라 분기하는 코드가 있습니다. 하지만 원래 atomicrmw umax LLVM 명령이 있던 bb7 블록에는 이제 완전한 compare-and-swap 루프가 보입니다. 컴파일러 엔지니어의 표현을 빌리면, atomicrmw umax 명령이 하드웨어가 실제로 실행할 수 있는 보다 원시적인 연산 시퀀스로 “낮춰졌다(lowered)”고 할 수 있습니다.
요약된 논리는 다음과 같습니다:
expected).desired = umax(expected, val).observed, success = cmpxchg(ptr, expected, desired, [...]).observed(이전 값)를 반환. 아니면 expected = observed로 갱신하고 루프.이 CAS 루프는 락-프리 프로그래밍의 근본 패턴입니다. 컴파일러가 우리를 위해 자동으로 만들어 준 것이죠.
마지막 단계입니다. 최종 기계어를 보려면 rustc에 어셈블리를 직접 내보내라고 지시할 수 있습니다:
rustc --emit=asm main.rs
그러면 최종 어셈블리 코드가 담긴 main.s 파일이 생성됩니다. 그 안에서 cmpxchg 루프의 결과를 볼 수 있습니다:
.LBB8_2:
movq -32(%rsp), %rax # rax = &self
movq (%rax), %rax # rax = *self (시드 'expected')
movq %rax, -48(%rsp) # expected를 스택에 보관
.LBB8_3: # 루프 헤드
movq -48(%rsp), %rax # rax = expected
movq -32(%rsp), %rcx # rcx = &self
movq -40(%rsp), %rdx # rdx = val
movq %rax, %rsi # rsi = expected (scratch)
subq %rdx, %rsi # 부호 없는 비교를 위한 플래그 설정: expected - val
cmovaq %rax, %rdx # if (expected > val) rdx = expected
lock cmpxchgq %rdx, (%rcx) # CAS: *rcx==rax면 *rcx=rdx
sete %cl # cl = success
movq %rax, -56(%rsp) # observed를 스택에 보관
testb $1, %cl # success 분기
movq %rax, -48(%rsp) # expected = observed (재시도 대비)
jne .LBB8_4 # 성공 -> 종료
jmp .LBB8_3 # 실패 -> 재시도
문법이 낯설 수 있는데, 이는 rustc의 기본인 AT&T 문법이기 때문입니다. Intel 문법을 선호한다면 rustc --emit=asm main.rs -C "llvm-args=-x86-asm-syntax=intel"을 사용하세요.
저는 어셈블리 전문가가 아니지만, 여기서 CAS 루프의 핵심을 볼 수 있습니다:
*self를 한 번 읽어 expected를 초기화sub + cmova 쌍이 desired = max_u(expected, val)을 구현cmpxchg는 RAX를 expected로 사용하고 관측값을 RAX에 반환하며, ZF가 성공을 나타냄ZF가 클리어면 실패이므로 재시도, 아니면 완료참고: 우리는
rustc에 코드 최적화를 요청하지 않았습니다. 최적화하면 컴파일러는 더 효율적인 어셈블리를 생성합니다: 스택으로의 스필 없음, 점프 감소, 메모리 순서 분기 제거 등. 하지만 IR과 최대한 가깝게 유지해 따라가기 쉽게 하려 했습니다.
이제 끝입니다. 우리는 안전하고 명료한 Rust의 한 줄에서 시작해, 어셈블리로 작성된 CAS 루프로 끝났습니다.
Rust fetch_max → 매크로 생성 atomic_umax → LLVM atomicrmw umax → LLVM cmpxchg 루프 → 어셈블리 lock cmpxchg 루프
이 여정은 현대 컴파일러의 힘을 완벽하게 보여줍니다. 우리는 높은 수준의 추상화에서 안전성과 논리에 집중하고, 컴파일러는 하드웨어를 위한 정확하고 효율적인 코드를 생성하는 지저분하고 오류에 취약하며 믿을 수 없을 만큼 복잡한 작업을 처리합니다.
그러니 다음에 원자 연산을 사용할 때는, 여러분의 코드가 곧 떠나게 될 이 놀랍고도 숨겨진 여정을 잠시 감상해 보세요.
PS: 이 여정을 마친 뒤, C++26에도 fetch_max가 추가된다는 것을 알았습니다!
PPS: 저희는 채용 중입니다!
궁금해서 애플 실리콘(AArch64)에서는 어떻게 보이는지도 확인했습니다. 이 아키텍처에는 네이티브 atomic max 명령이 있으므로, AtomicExpandPass가 이를 CAS 루프로 낮출 필요가 없습니다. 패스 전후의 LLVM 코드는 동일하며, 여전히 atomicrmw umax 명령을 포함합니다.
최종 어셈블리에는 LDUMAX 변형이 포함됩니다. 관련 부분은 다음과 같습니다:
ldr x8, [sp, #16] # x8 = 비교할 값
ldr x9, [sp, #8] # x9 = 원자 변수의 포인터
ldumax x8, x8, [x9] # 원자적 부호 없는 max(relaxed), [x9] = max(x8, [x9]), x8 = 이전 값
str x8, [sp, #40] # 이전 값 저장
b LBB8_11
AArch64는 Unified Assembler Language를 사용한다는 점에 유의하세요. 위 스니펫을 읽을 때 목적지 레지스터가 먼저 온다는 사실이 중요합니다.
정말 이게 전부입니다. 더 파고들어 마이크로아키텍처에서 명령이 어떻게 실행되는지, LOCK 프리픽스의 효과가 무엇인지, 메모리 순서의 차이는 어떤지 등을 볼 수도 있겠지만, 그건 다음 기회로 미루겠습니다.
"앨리스: 여기서 어느 쪽으로 가야 할지 말씀해 주시겠어요?"
"고양이: 그건 네가 어디에 도달하고 싶은지에 꽤 많이 달려 있지."
"앨리스: 난 별로 상관없어."
"고양이: 그렇다면 어느 길로 가도 그다지 상관없지."
"앨리스: …어딘가에만 도착할 수 있다면."
"고양이: 오래 걷기만 하면, 그건 분명히 하게 될 거야."
- 루이스 캐럴, 이상한 나라의 앨리스