ARMv8.0의 원자적 연산은 LL/SC(Load-Linked/Store-Conditional) 방식이다 — ldxr로 값을 읽고 연산한 뒤 stxr로 조건부 저장하는데, 그사이 다른 코어가 같은 주소를 건드리면 저장이 실패해 처음부터 재시도한다. 코어 수가 많아질수록 이 재시도가 잦아져 심하면 여러 코어가 서로의 저장을 계속 실패시키는 livelock에 가깝게 간다. ARMv8.1은 이 문제를 LSE(Large System Extension)로 풀었다 — ldadd, cas, swp 같은 단일 명령어로 원자적 연산을 끝낸다. 이 글에서는 같은 __atomic_fetch_add 코드를 두 가지 방식으로 크로스 컴파일해서, 실제로 어떤 명령어가 나오고 구세대 CPU에서 무슨 일이 생기는지 aarch64 리눅스 머신에서 직접 확인한다.
LL/SC vs LSE
| LL/SC (ARMv8.0) | LSE (ARMv8.1+) | |
|---|---|---|
| 명령어 | ldxr + 연산 + stxr + 재시도 분기 | ldadd/ldaddal 단일 명령어 |
| 컨텐션 시 동작 | stxr 실패 시 루프 재시도 | 재시도 없음 (하드웨어가 원자성 보장) |
| 코어 수 확장성 | 코어가 많을수록 재시도 증가 | 코어 수와 무관하게 일정 |
| 지원 여부 | ARMv8.0부터 항상 가능 | ARMv8.1+ 하드웨어 필요 |
실전: 크로스 컴파일로 두 경로 비교
같은 원자적 증가 코드를 -march=armv8-a(LSE 미보장)와 -march=armv8.1-a(LSE 보장) 두 방식으로 각각 빌드했다.
#include <stdio.h>
#include <stdint.h>
long atomic_add_test(long *counter) {
return __atomic_fetch_add(counter, 1, __ATOMIC_SEQ_CST);
}
int main(void) {
long counter = 0;
for (int i = 0; i < 5; i++)
atomic_add_test(&counter);
printf("counter=%ld\n", counter);
return 0;
}aarch64-linux-gnu-gcc -march=armv8-a -O2 -static -o atomic_add_baseline atomic_add.c
aarch64-linux-gnu-gcc -march=armv8.1-a -O2 -static -o atomic_add_lse atomic_add.c
qemu-aarch64 ./atomic_add_baseline
qemu-aarch64 ./atomic_add_lsecounter=5
counter=5
결과는 같지만 생성된 코드는 다르다. -march=armv8-a 빌드는 인라인 명령어 대신 함수 호출로 컴파일됐다.
0000000000400830 <atomic_add_test>:
400830: a9bf7bfd stp x29, x30, [sp, #-16]!
400838: d2800020 mov x0, #0x1
400840: 94000004 bl 400850 <__aarch64_ldadd8_acq_rel>
400848: d65f03c0 ret
__aarch64_ldadd8_acq_rel은 glibc(libatomic)가 제공하는 헬퍼로, 그 내부를 objdump로 보면 런타임에 CPU 지원 여부를 분기한다.
0000000000400850 <__aarch64_ldadd8_acq_rel>:
400850: d503245f bti c
400854: b0000490 adrp x16, 491000 <_IO_list_all>
400858: 39662210 ldrb w16, [x16, #2440] ; __aarch64_have_lse_atomics
40085c: 34000070 cbz w16, 400868 <__aarch64_ldadd8_acq_rel+0x18>
400860: f8e00020 ldaddal x0, x0, [x1] ; LSE 지원 시
400864: d65f03c0 ret
400868: c85ffc20 ldaxr x0, [x1] ; 미지원 시 LL/SC 루프
40086c: 8b100011 add x17, x0, x16
400870: c80ffc31 stlxr w15, x17, [x1]
400874: 35ffffaf cbnz w15, 40086c <__aarch64_ldadd8_acq_rel+0x1c>
400878: d65f03c0 ret
부팅 시 커널이 감지한 CPU 피처를 glibc가 __aarch64_have_lse_atomics 플래그로 저장해두고, 이 함수가 매 호출마다 그 플래그를 확인해 LSE가 있으면 ldaddal 한 줄로, 없으면 기존 LL/SC 루프로 처리한다. 반면 -march=armv8.1-a 빌드는 LSE가 항상 있다고 가정하므로 이 분기 자체가 없다.
0000000000400830 <atomic_add_test>:
400830: d2800022 mov x2, #0x1
400834: f8e20000 ldaddal x2, x0, [x0]
400838: d65f03c0 ret
실전: LSE 미지원 CPU에서 강제 실행하면
QEMU의 -cpu 옵션으로 ARMv8.0 코어(Cortex-A53, LSE 없음)를 흉내 내 두 바이너리를 각각 실행했다.
qemu-aarch64 -cpu cortex-a53 ./atomic_add_lse
qemu-aarch64 -cpu cortex-a53 ./atomic_add_baselineqemu: uncaught target signal 4 (Illegal instruction) - core dumped
counter=5
armv8.1-a로 고정 컴파일한 바이너리는 ldaddal이 존재하지 않는 코어에서 그대로 실행하려다 SIGILL로 죽었다. armv8-a 빌드는 런타임 분기가 LL/SC 경로를 택해 정상 동작했다.
주의사항
- 대부분의 배포판 패키지는
-march=armv8-a기준으로 빌드되므로 이런 크래시는 실제로는 잘 안 생긴다.-march=armv8.1-a이상이나-mcpu=native로 빌드한 바이너리를 다른(특히 더 오래된) ARM 서버로 옮길 때만 주의하면 된다. - QEMU user-mode 에뮬레이션은 명령어 지원 여부 확인용이지 성능 측정용이 아니다. LL/SC와 LSE의 실제 처리량 차이는 멀티코어 컨텐션이 있는 실물 하드웨어에서만 의미가 있다.
-cpu max(기본으로 자주 쓰는 옵션)는 QEMU가 지원하는 가장 최신 기능을 전부 켠 가상 CPU라 LSE도 포함된다. 특정 세대 CPU를 흉내 내려면-cpu cortex-a53처럼 모델명을 명시해야 한다.- glibc 2.28 이상에서
__aarch64_have_lse_atomics런타임 감지가 도입됐다. 그보다 오래된 libc를 정적 링크하면 이 dispatch 자체가 없을 수 있다.
마무리
ARMv8.1 LSE는 새 명령어를 추가한 것뿐 아니라, glibc가 부팅 시 감지한 CPU 피처를 기준으로 LSE와 LL/SC 두 경로를 런타임에 골라 쓰게 하는 dispatch 메커니즘까지 포함해서 호환성 문제를 해결한다. -march=armv8-a로 빌드하면 이 안전장치가 그대로 살아있고, -march=armv8.1-a 이상으로 명시적으로 고정해야만 그 안전장치가 빠진 채 최신 명령어가 인라인된다는 걸 실제 크래시로 확인했다.