ARMv8.1 LSE 원자적 연산: LL/SC와 실제 컴파일 결과 비교

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_lse
counter=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_baseline
qemu: 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 이상으로 명시적으로 고정해야만 그 안전장치가 빠진 채 최신 명령어가 인라인된다는 걸 실제 크래시로 확인했다.

참고

답글 남기기