ARM64에서 std::memory_order의 실제 비용은 얼마인가? — Jetson Orin 실측 벤치마크
"ARM에서 memory_order_seq_cst는 매우 비싸므로 가능하면 release/acquire를 사용하라"
C++ 커뮤니티에서 널리 퍼진 이 조언이 AArch64(64-bit ARM)에도 유효한지 직접 측정해봤습니다.
결론 먼저
| 항목 | 결론 |
|---|---|
| ARMv7(32-bit)에서 seq_cst가 비싼가? | 맞다. DMB 배리어 2개가 삽입되어 수십~수백 사이클 소모 |
| ARMv8 AArch64(64-bit)에서도 비싼가? | 아니다. release/acquire와 seq_cst 모두 동일한 STLR/LDAR 명령어로 컴파일 |
| release/acquire가 relaxed보다 비싼가? | 같은 스레드에서 store+load 쌍일 때 ~6ns의 파이프라인 스톨 발생. 단독 store/load는 차이 없음 |
| 1kHz RT 루프에 영향이 있는가? | 사실상 없다. 25개 atomic 연산 기준 총 89ns = 1ms 주기의 0.0089% |
| relaxed로 바꿔도 되는가? | 안 된다. 정확성(correctness) 보장 불가. 77ns 절약은 무의미한 반면 위험은 치명적 |
배경: 왜 이 분석을 했는가
우리 팀은 NVIDIA Jetson Orin(Cortex-A78AE) 위에서 1kHz 실시간 로봇 제어 시스템을 운영합니다. EtherCAT 통신, CiA 402 상태 머신, PID 토크 제어가 모두 1ms 주기 안에 완료되어야 하는 hard real-time 환경입니다.
코드베이스에서는 RT 스레드와 non-RT 스레드 간 통신에 std::atomic을 광범위하게 사용합니다. Seqlock 패턴의 sequence counter, shutdown flag, 상태 머신 전이 등이 모두 memory_order_release/acquire로 보호됩니다.
이 atomic 사용이 1kHz RT 루프의 성능 병목이 될 수 있는지 직접 측정해보기로 했습니다.
ARM 아키텍처 세대별 명령어 매핑
핵심은 ARMv7(32-bit)과 ARMv8 AArch64(64-bit)가 완전히 다른 명령어 세트를 사용한다는 점입니다.
ARMv7 (32-bit) — 배리어 기반
ARMv7에는 atomic store/load 전용 명령어가 없습니다. 컴파일러는 일반 STR/LDR에 DMB(Data Memory Barrier) 명령어를 삽입하여 순서를 보장합니다.
Store release: DMB ISH → STR (배리어 1개)
Store seq_cst: DMB ISH → STR → DMB ISH (배리어 2개!)
Load acquire: LDR → DMB ISH (배리어 1개)
Load seq_cst: LDR → DMB ISH (배리어 1개)
DMB 한 번에 수십~수백 사이클이 소모됩니다. seq_cst store에 DMB가 2개 들어가므로, release 대비 비용이 2배에 달할 수 있습니다.
ARMv8 AArch64 (64-bit) — 전용 명령어
AArch64는 acquire/release 시맨틱이 명령어 자체에 내장되었습니다.
Store release: STLR (Store-Release, 단일 명령어)
Store seq_cst: STLR (동일한 명령어!)
Load acquire: LDAR (Load-Acquire, 단일 명령어)
Load seq_cst: LDAR (동일한 명령어!)
DMB가 완전히 제거되었습니다. 그리고 결정적으로, release와 seq_cst가 같은 명령어로 컴파일됩니다.
ARMv8.3+ FEAT_LRCPC — 미세한 차이의 등장
ARMv8.2에서 optional로 도입되고 ARMv8.3에서 필수가 된 FEAT_LRCPC는 LDAPR 명령어를 추가했습니다.
Load acquire: LDAPR (이전 STLR 완료를 기다리지 않음)
Load seq_cst: LDAR (이전 STLR drain 대기)
| 연산 | C++ memory_order | ARMv7 | AArch64 | AArch64 + LRCPC |
|---|---|---|---|---|
| Store | relaxed | STR | STR | STR |
| Store | release | DMB + STR | STLR | STLR |
| Store | seq_cst | DMB + STR + DMB | STLR | STLR |
| Load | relaxed | LDR | LDR | LDR |
| Load | acquire | LDR + DMB | LDAR | LDAPR |
| Load | seq_cst | LDR + DMB | LDAR | LDAR |
FEAT_LRCPC가 있는 프로세서에서만 acquire와 seq_cst 사이에 측정 가능한 차이가 생깁니다. Jetson Orin의 Cortex-A78AE는 ARMv8.2이지만 FEAT_LRCPC를 지원합니다.
Jetson Orin 실측 벤치마크
벤치마크 환경
| 항목 | 값 |
|---|---|
| SoC | NVIDIA Jetson Orin (Cortex-A78AE) |
| ISA | ARMv8.2-A + FEAT_LRCPC |
| Counter frequency | 31.2 MHz |
| Compiler | g++ -O2 -std=c++17 -march=native |
| Iterations | 10,000,000 (1천만 회, warmup 1백만 회) |
벤치마크 코드 (핵심부)
#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>
static std::atomic<uint64_t> g_counter{0};
constexpr int ITERATIONS = 10'000'000;
constexpr int WARMUP = 1'000'000;
// 1. Store-only 벤치마크
template <std::memory_order Order>
double bench_store_only() {
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < ITERATIONS; ++i) {
g_counter.store(i, Order);
}
auto end = std::chrono::steady_clock::now();
double ns = std::chrono::duration<double, std::nano>(end - start).count();
return ns / ITERATIONS;
}
// 2. Load-only 벤치마크
template <std::memory_order Order>
double bench_load_only() {
volatile uint64_t sink = 0;
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < ITERATIONS; ++i) {
sink = g_counter.load(Order);
}
auto end = std::chrono::steady_clock::now();
double ns = std::chrono::duration<double, std::nano>(end - start).count();
return ns / ITERATIONS;
}
// 3. Store+Load 쌍 벤치마크 (같은 스레드)
template <std::memory_order StoreOrder, std::memory_order LoadOrder>
double bench_store_load_pair() {
volatile uint64_t sink = 0;
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < ITERATIONS; ++i) {
g_counter.store(i, StoreOrder);
sink = g_counter.load(LoadOrder);
}
auto end = std::chrono::steady_clock::now();
double ns = std::chrono::duration<double, std::nano>(end - start).count();
return ns / ITERATIONS;
}
// 4. Cross-thread 벤치마크
template <std::memory_order StoreOrder, std::memory_order LoadOrder>
double bench_cross_thread() {
std::atomic<bool> stop{false};
std::atomic<uint64_t> read_count{0};
// Reader thread
std::thread reader([&] {
uint64_t count = 0;
volatile uint64_t sink = 0;
while (!stop.load(std::memory_order_relaxed)) {
sink = g_counter.load(LoadOrder);
++count;
}
read_count.store(count, std::memory_order_relaxed);
});
// Writer (this thread)
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < ITERATIONS; ++i) {
g_counter.store(i, StoreOrder);
}
auto end = std::chrono::steady_clock::now();
stop.store(true, std::memory_order_relaxed);
reader.join();
double ns = std::chrono::duration<double, std::nano>(end - start).count();
return ns / ITERATIONS;
}
측정 결과
Store Only
| memory_order | 명령어 | 소요 시간 | relaxed 대비 |
|---|---|---|---|
relaxed | STR | 0.46 ns/op | baseline |
release | STLR | 0.46 ns/op | +0.00 ns (+0.2%) |
seq_cst | STLR | 0.46 ns/op | +0.00 ns |
Load Only
| memory_order | 명령어 | 소요 시간 | relaxed 대비 |
|---|---|---|---|
relaxed | LDR | 0.46 ns/op | baseline |
acquire | LDAPR | 0.46 ns/op | -0.00 ns (-0.3%) |
seq_cst | LDAR | 0.46 ns/op | +0.00 ns |
단독 store 또는 load에서는 memory ordering에 따른 비용 차이가 측정 불가능할 정도로 작습니다.
Store+Load Pair (같은 스레드)
| memory_order | 소요 시간 | relaxed 대비 |
|---|---|---|
relaxed/relaxed | 0.93 ns/pair | baseline |
release/acquire | 7.09 ns/pair | +6.16 ns (+661%) |
seq_cst/seq_cst | 7.09 ns/pair | +6.16 ns (+661%) |
비용의 진짜 원천이 여기에 있습니다. STLR 직후 LDAR/LDAPR을 실행하면 파이프라인 스톨이 발생합니다. 그러나 release/acquire와 seq_cst의 비용은 정확히 동일합니다.
fetch_add (Read-Modify-Write)
| memory_order | 소요 시간 |
|---|---|
relaxed | 6.01 ns/op |
acq_rel | 6.00 ns/op |
seq_cst | 5.99 ns/op |
RMW 연산은 ordering과 무관하게 비용이 동일합니다. 내부적으로 LDXR/STXR 또는 CAS 루프를 사용하기 때문입니다.
Cross-Thread Store/Load
| memory_order | Writer 소요 시간 | release/acquire 대비 |
|---|---|---|
relaxed/relaxed | 0.49 ns/write | -2.31 ns |
release/acquire | 2.80 ns/write | baseline |
seq_cst/seq_cst | 3.14 ns/write | +0.34 ns (+12%) |
크로스 스레드 환경에서 seq_cst는 release/acquire보다 0.34ns 더 비쌉니다. LDAPR(acquire)과 LDAR(seq_cst)의 차이가 여기서 드러납니다.
결과 분석: 비용은 어디서 발생하는가
STLR → LDAR 파이프라인 스톨
같은 스레드에서 store+load 쌍의 비용이 0.93ns에서 7.09ns로 뛰는 이유는 STLR 명령어의 특성 때문입니다. STLR은 "이 store가 모든 이전 메모리 연산 이후에 관찰되어야 한다"는 보장을 제공하기 위해, store buffer를 drain하는 동안 후속 LDAR을 지연시킵니다.
시간 →
relaxed: STR ─── LDR ────────── (0.93ns, 파이프라인 통과)
release: STLR ─── ⏳ drain ─── LDAPR ── (7.09ns, store buffer drain 대기)
seq_cst: STLR ─── ⏳ drain ─── LDAR ─── (7.09ns, 동일한 스톨)
핵심: 이 비용은 memory_order_release에서도 동일하게 발생합니다. seq_cst를 release/acquire로 "다운그레이드"해도 같은 스레드 내 store+load 패턴의 비용은 줄지 않습니다.
LDAPR vs LDAR (크로스 스레드)
FEAT_LRCPC의 LDAPR은 "이전 STLR의 완전한 drain을 기다리지 않아도 된다"는 완화된 시맨틱을 제공합니다. 이 차이가 크로스 스레드 벤치마크에서 0.34ns 차이로 나타납니다.
하지만 이 차이는 절대값 기준으로 무시해도 좋은 수준입니다.
실제 RT 루프 영향도 계산
1kHz 제어 루프의 atomic 연산 프로파일:
| 연산 종류 | 대략적 횟수/cycle | 비용 (relaxed) | 비용 (release/acquire) |
|---|---|---|---|
| Seqlock sequence store | 2 | 0.92 ns | 0.92 ns |
| Seqlock sequence load | 4 | 1.84 ns | 1.84 ns |
| 상태 flag load | ~10 | 4.60 ns | 4.60 ns |
| Store+Load pair | ~5 | 4.65 ns | 35.45 ns |
| Cross-thread load | ~4 | 1.96 ns | 11.20 ns |
| 합계 | ~25 | ~14 ns | ~54 ns |
54ns / 1,000,000ns(1ms) = 0.0054% 의 주기 예산을 사용합니다.
극단적으로 여유를 두고 계산해도 100ns 미만이며, 1ms 예산의 0.01%에도 미치지 않습니다. PID 계산(~10us), dynamics 연산(~30us), EtherCAT PDO 통신(~50us)과 비교하면 완전히 노이즈 수준입니다.
relaxed로 바꾸면 안 되는 이유
"어차피 비용이 미미하면 그냥 relaxed 써도 되지 않나?"라는 유혹이 생길 수 있습니다. 하지만 ARM64는 weakly-ordered 아키텍처입니다.
relaxed의 가시성 보장 없음
// Thread 1 (RT controller)
data_.store(new_value, std::memory_order_relaxed);
running_.store(false, std::memory_order_relaxed);
// Thread 2 (shutdown handler)
while (running_.load(std::memory_order_relaxed)) {
// ARM64에서 이 루프가 무한정 지연될 수 있다!
// relaxed는 가시성 시점을 보장하지 않는다.
}
memory_order_relaxed는 다른 코어에 대한 가시성 시점을 보장하지 않습니다. C++ 표준은 "합리적인 시간 내에 보여야 한다(should)"고 권고하지만, 이는 요구사항(shall)이 아닙니다. x86에서는 TSO(Total Store Order) 모델 덕분에 store가 비교적 빠르게 전파되지만, ARM64에서는 store buffer에 값이 예측 불가능한 시간 동안 체류할 수 있습니다.
release/acquire가 제공하는 보장
// Thread 1
data_.store(new_value, std::memory_order_relaxed);
running_.store(false, std::memory_order_release); // data_ 쓰기가 먼저 완료됨을 보장
// Thread 2
while (running_.load(std::memory_order_acquire)) { // release와 쌍을 이루는 acquire
// happens-before 관계 성립
// running_ == false를 관찰하면, data_의 new_value도 보장됨
}
// 여기서 data_.load(relaxed)는 반드시 new_value를 반환
release/acquire는 happens-before 관계를 수립합니다. 단순히 "빨리 보인다"가 아니라 "반드시 올바른 순서로 보인다"는 정형적 보장입니다.
비용 대비 위험
| 선택지 | 절약 비용 | 위험 |
|---|---|---|
| relaxed 사용 | ~40ns/cycle (0.004%) | 가시성 지연 → shutdown 실패, 데이터 비일관성, 미정의 동작 |
| release/acquire 사용 | 0 | 0 (정확성 보장) |
40ns를 절약하기 위해 로봇 팔이 shutdown 신호를 무한정 무시할 수 있는 코드를 작성할 이유는 없습니다.
실무 가이드라인
언제 어떤 memory_order를 사용할 것인가
정확성이 필요한가?
│
┌────┴────┐
│ Yes │ No (카운터, 통계 등)
│ │
┌─────┴─────┐ └── relaxed
│ │
단일 변수의 여러 변수의
가시성만 필요 순서 보장 필요
│ │
└─────┬─────┘
│
release/acquire
│
┌─────┴─────┐
│ │
AArch64 x86-64
(비용: ~3ns) (비용: ~0ns, TSO)
│ │
└─────┬─────┘
│
seq_cst가 필요한 경우:
- 여러 atomic 변수 간 전역 순서 필요
- Peterson's lock 같은 알고리즘
- 확실하지 않을 때의 기본값
AArch64에서의 실용적 규칙
-
relaxed는 순수 통계/카운터 전용. 로직에 영향을 주는 모든 flag, 상태 변수에는 사용하지 않습니다. -
release/acquire가 기본 선택지. AArch64에서 seq_cst와 비용이 거의 동일하면서 의도를 더 명확하게 표현합니다. -
seq_cst를 피할 필요가 없습니다. AArch64에서 seq_cst 대신 release/acquire를 선택해도 절약되는 비용은 크로스 스레드 기준 0.34ns에 불과합니다.
-
같은 스레드에서 store 직후 load는 피합니다. 7ns 스톨의 원천입니다. 가능하면 store와 load 사이에 다른 연산을 끼워 넣거나, 설계를 재고합니다.
-
RMW(fetch_add 등)는 ordering 무관. 어떤 memory_order를 쓰든 비용이 동일하므로, 안전한 쪽(acq_rel 또는 seq_cst)을 선택합니다.
아키텍처 세대에 따른 유효성
"ARM에서 seq_cst는 비싸다"는 조언은 ARMv7(32-bit)에는 사실이지만 AArch64(64-bit)에는 해당하지 않습니다. ARM 관련 memory ordering 글을 참고할 때 대상 아키텍처를 반드시 확인하세요.
| 아키텍처 | seq_cst 추가 비용 | seq_cst 회피 효과 |
|---|---|---|
| ARMv7 (32-bit) | DMB 1개 추가 (수십~수백 ns) | 의미 있음 |
| AArch64 (64-bit) | 0ns (동일 명령어) | 없음 |
| AArch64 + LRCPC | load에서 ~0.3ns | 무시 가능 |
핵심 정리
-
ARMv7과 AArch64는 다릅니다. "ARM에서 seq_cst는 비싸다"는 조언은 32-bit ARMv7에만 해당합니다. AArch64에서는 release/acquire와 seq_cst가 같은 명령어(STLR/LDAR)로 컴파일됩니다.
-
비용의 진짜 원천은 store+load 쌍의 파이프라인 스톨(~7ns)입니다. 이 비용은 release/acquire에서도 동일하게 발생하며, seq_cst를 회피해도 줄지 않습니다.
-
1kHz RT 루프에서 atomic은 병목이 아닙니다. 25개 atomic 연산의 총 비용은 ~54ns로, 1ms 예산의 0.005%입니다. dynamics 연산(~30us)이나 EtherCAT 통신(~50us)과 비교하면 노이즈 수준입니다.
-
relaxed로 바꾸면 안 됩니다. ARM64는 weakly-ordered 아키텍처입니다. ~40ns 절약을 위해 가시성 보장을 포기하면 shutdown 실패, 데이터 비일관성 등 치명적 위험이 발생합니다.
-
정확한 코드가 빠른 코드보다 항상 먼저입니다. 그리고 AArch64에서는, 정확한 코드가 빠른 코드이기도 합니다.