메뉴
HN
Hacker News 25일 전

C++ 비대칭 펜스(Asymmetric Fences)의 내부 원리

IMP
7/10
핵심 요약

C++ 동시성 제어에서 드물게 실행되는 경로의 동기화 비용을 늘리는 대신, 빈번하게 실행되는 경로의 오버헤드를 줄여 전체 성능을 최적화하는 '비대칭 펜스(Asymmetric Fences)'의 개념과 작동 원리를 설명합니다. 이 기법은 스레드 풀, RCU 등 고성능 동시성 라이브러리에서 시스템 성능을 극대화하기 위해 활발히 사용되고 있습니다.

번역된 본문

비대칭 펜스(Asymmetric Fences)?

동시성(Concurrency) 개념을 공부하기 위해 Folly의 동기화 프리미티브(synchronization primitives)를 살펴보던 중, 코드베이스에서 익숙한 이름 하나가 눈에 띄었습니다. 바로 '비대칭 스레드 펜스(Asymmetric Thread Fence)'였습니다. 저는 std::atomic_thread_fence가 무엇을 위한 것인지 이미 알고 있었지만, 이 구조체는 정확히 무엇을 하는 걸까요? 그리고 대체 무엇이 '비대칭'인 걸까요?

이 질문은 저를 아주 흥미로운 토끼 굴로 이끌었습니다. 비대칭 스레드 펜스의 세부 사항과 내부적으로 실제로 어떤 작업을 수행하는지(적어도 Linux 환경에서) 파헤쳐보게 된 것입니다. 우리는 이 구조체에 대한 C++ 제안서부터 시작해 구현 세부 사항까지 깊게 파고들어 볼 것입니다.

C++ P1202R0 소개

세부 작동 원리는 이후 섹션에서 다루겠지만, 지금은 이 펜스들이 달성하고자 하는 목표를 이해하는 것만으로 충분합니다. 제안서를 참고하여 전체적인 개요를 살펴보겠습니다.

"일부 유형의 동시 알고리즘은 공통 경로(common path)와 비공통 경로(uncommon path)로 나눌 수 있으며, 올바른 실행을 위해 두 경로 모두 펜스(또는 완화되지 않은 메모리 순서를 가진 다른 작업)를 필요로 합니다. 많은 플랫폼에서 비공통 경로에 더 강력한 펜스 유형(memory_order_seq_cst보다 더 강한)을 추가함으로써 공통 경로의 속도를 높일 수 있습니다. 이러한 기법들은 점점 더 많은 동시성 라이브러리에서 사용되고 있습니다. 우리는 이러한 비대칭 펜스를 표준화하고 메모리 모델에 통합할 것을 제안합니다."

본질적으로, 동시성 알고리즘은 양쪽 모두에 펜스가 필요할 수 있습니다. 하지만 한쪽 경로가 다른 쪽보다 훨씬 더 빈번하게 실행된다면, 비공통 경로에 더 무거운 동기화 비용을 전가하고 공통 경로에는 더 가벼운 펜스를 사용하여 공통 경로를 최적화할 수 있습니다. 공통 경로에서 얻는 성능 이점이 비공통 경로의 무거운 펜스로 인한 오버헤드보다 훨씬 크다면 전반적인 성능 향상을 가져올 수 있습니다.

이 패턴은 처음 생각하는 것보다 훨씬 더 흔하게 사용됩니다. Folly의 코드베이스를 살펴보면 이 패턴이 사용된 여러 곳을 찾을 수 있습니다:

folly/synchronization/HazptrDomain.h: 해저드 포인터 (Hazard Pointers) folly/synchronization/detail/ThreadCachedReaders.h: RCU folly/executors/ThreadPoolExecutor.cpp: 스레드 풀 (Thread Pool ExWcutor)

논문에서 발췌한 수정된 예제인 데커(Dekker)의 예제로 시작해 보겠습니다:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 atomic_int x { 0 }, y { 0 }; int r1 , r2 ; // 경로 A: x . store ( 1 , memory_order_relaxed ); atomic_thread_fence ( memory_order_seq_cst ); r1 = y . load ( memory_order_relaxed ); // 경로 B: y . store ( 1 , memory_order_relaxed ); atomic_thread_fence ( memory_order_seq_cst ); r2 = x . load ( memory_order_relaxed ); // 두 경로가 모두 완료된 후 실행됩니다. assert ( ! ( r1 == 0 && r2 == 0 )); // 결코 실패하지 않습니다.

우리는 이 assert 문이 실패할 수 없다는 것을 알고 있습니다. C++ 메모리 모델하에서 왜 그런지 살펴보겠습니다:

[thread.thread.constr]/6의 스레드 생성과 스레드 함수 시작 간의 동기화 관계(synchronizes-with)로 인해 제약 조건 $(1) \rightarrow (2)$ 및 $(1) \rightarrow (5)$가 발생합니다. 또한 [intro.races]/15에 설명된 쓰기-쓰기 일관성 보장을 통해 X와 Y의 수정 순서(modification order)가 설정됩니다.

제약 조건 $(4) \rightarrow (5)$ 및 $(7) \rightarrow (2)$는 [atomics.order]/3.3의 요구 사항에서 발생하는 일관성 순서(coherence-orderings)입니다. 예를 들어, $(4)$와 $(5)$는 동일한 원자적 읽기-수정-쓰기 작업이 아니며, $(4)$는 $(1)$이 저장한 값을 읽고, $(1)$은 Y의 수정 순서에서 $(5)$보다 앞섭니다.

[atomics.order]/4.4에 따라, memory_order​::​seq_cst 펜스 $A$는 $(4)$보다 먼저 발생(happens-before)하고, $(5)$는 memory_order​::​seq_cst 펜스 $B$보다 먼저 발생합니다. 따라서 모든 memory_order​::​seq_cst 작업에 대한 단일 전체 순서 $S$에서 $(3)$은 $(6)$보다 먼저 와야 합니다. 대칭적인 추론에 의해, 동일한 전체 순서 $S$에서 $(6)$도 $(3)$보다 먼저 와야 합니다. 이는 불가능하므로 모순이 발생합니다. 따라서 r1 == 0 && r2 == 0이 되는 실행은 금지됩니다.

이제 비대칭 스레드 펜스로 돌아가 보겠습니다. 핵심 아이디어는 일부 동시성 알고리즘에서 흔히 발생하는 경로(common path)를 최적화하는 데 중점을 둔다는 것입니다. (대신 흔하지 않은 경로의 비용을 희생하여)

원문 보기
원문 보기 (영어)
Asymmetric Fences? While browsing through Folly’s synchronization primitives to study up on concurrency concepts, one familiar-looking name in the codebase caught my attention: Asymmetric Thread Fence. I already knew what std::atomic_thread_fence is meant to achieve, but what does this construct do? And what exactly is asymmetric about it? That question led me down an interesting rabbit hole: understanding the details of asymmetric thread fences and what they are actually doing underneath the hood (at least on Linux). We’ll start with the C++ proposal for this construct, then work our way down into the implementation details. C++ P1202R0 Introduction We’ll walk through the mechanics in a later section, but for now it suffices to understand what these fences are trying to achieve. Referring to the proposal , let’s look at the overview: Some types of concurrent algorithms can be split into a common path and an uncommon path , both of which require fences (or other operations with non-relaxed memory orders) for correctness. On many platforms, it’s possible to speed up the common path by adding an even stronger fence type (stronger than memory_order_seq_cst ) down the uncommon path. These facilities are being used in an increasing number of concurrency libraries. We propose standardizing these asymmetric fences, and incorporating them into the memory model. Essentially, a concurrent algorithm may originally require a fence on both sides. However, if one path is significantly more common than the other, we may optimize for the common path by using a lighter fence there, while shifting the heavier synchronization cost onto the uncommon path. This can result in an overall performance improvement if we ensure the performance gain from the common path is much more than the overhead from the heavier fence of the uncommon path. This pattern is more common than it may first appear. If you browse through Folly’s codebase, you can find several uses of it: folly/synchronization/HazptrDomain.h : Hazard Pointers folly/synchronization/detail/ThreadCachedReaders.h : RCU folly/executors/ThreadPoolExecutor.cpp : Thread Pool ExWcutor Let’s begin with a modified example from the paper, known as Dekker’s example: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 atomic_int x { 0 }, y { 0 }; int r1 , r2 ; // Path A: x . store ( 1 , memory_order_relaxed ); atomic_thread_fence ( memory_order_seq_cst ); r1 = y . load ( memory_order_relaxed ); // Path B: y . store ( 1 , memory_order_relaxed ); atomic_thread_fence ( memory_order_seq_cst ); r2 = x . load ( memory_order_relaxed ); // Happens after both paths are completed. assert ( ! ( r1 == 0 && r2 == 0 )); // Never fails. We know the assertion cannot fail. Let’s examine why under the C++ memory model: Constraint $(1) \rightarrow (2)$ and $(1) \rightarrow (5)$ arise from the synchronizes-with relationship between thread construction and the start of the thread function [thread.thread.constr]/6 . This also establishes the modification order of both X and Y via the write-write coherence guarantees described in [intro.races]/15 . Constraint $(4) \rightarrow (5)$ and $(7) \rightarrow (2)$ are coherence-orderings that arise from the requirements in [atomics.order]/3.3 . For example, $(4)$ and $(5)$ are not the same atomic read-modify-write operation, and $(4)$ reads the value stored by $(1)$, and $(1)$ precedes $(5)$ in the modification order of Y . From [atomics.order]/4.4 , memory_order​::​seq_cst fence $A$ happens-before $(4)$, and $(5)$ happens-before memory_order​::​seq_cst fence $B$, therefore $(3)$ must precede $(6)$ in the single total order $S$ on all memory_order​::​seq_cst operations. By symmetric reasoning, $(6)$ must also precede $(3)$ in the same total order $S$. This is impossible, hence a contradiction. Therefore the execution where r1 == 0 && r2 == 0 is forbidden. Now, returning to asymmetric thread fences. The key idea is that in some concurrent algorithms, we care about optimizing a common path at the expense of an uncommon path . If we are willing to let the slow path absorb stronger synchronization costs, we can restructure the code using asymmetric fences. The paper illustrates this with the following transformation: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // Globals: atomic_int x { 0 }, y { 0 }; int r1 , r2 ; // Fast path: x . store ( 1 , memory_order_relaxed ); asymmetric_thread_fence_light (); // Like atomic_signal_fence; // only inhibits compiler reordering r1 = y . load ( memory_order_relaxed ); // Slow path: y . store ( 1 , memory_order_relaxed ); asymmetric_thread_fence_heavy (); // Stronger than atomic_thread_fence(memory_order_seq_cst); r2 = x . load ( memory_order_relaxed ); // Happens after both fast path and slow path are complete. assert ( ! ( r1 == 0 && r2 == 0 )); // Never fails. That’s all we need to know about them for now. Let’s now move on to how these asymmetric fences are implemented. Asymmetric Light Fence - Compiler Barrier Let’s start with the simpler primitive: asymmetric_thread_fence_light . In Folly’s implementation, this is implemented using asm volatile("" : : : "memory") on GCC/Clang or _ReadWriteBarrier() on MSVC, with std::atomic_thread_fence(order) used as a fallback. As a side note, asm declarations are only conditionally supported in C++ according to [dcl.asm]/1 , so we will refer to GCC’s documentation, specifically the extended asm section for the exact semantics here. 1 2 3 4 asm asm-qualifiers ( AssemblerTemplate : OutputOperands [ : InputOperands [ : Clobbers ] ]) Here, we only care about the clobber list, specifically the special "memory" clobber argument: The "memory" clobber tells the compiler that the assembly code performs memory reads or writes to items other than those listed in the input and output operands (for example, accessing the memory pointed to by one of the input parameters). To ensure memory contains correct values, GCC may need to flush specific register values to memory before executing the asm. Further, the compiler does not assume that any values read from memory before an asm remain unchanged after that asm; it reloads them as needed. Using the "memory" clobber effectively forms a read/write memory barrier for the compiler . Note that this clobber does not prevent the processor from doing speculative reads past the asm statement . To prevent that, you need processor-specific fence instructions . This means that the compiler must preserve the ordering of memory reads and writes with respect to asm volatile("" : : : "memory") , effectively making it a compiler barrier . However, this places constraints only on instruction reordering performed by the compiler, not on the processor itself. The CPU may still execute memory operations out-of-order at runtime. As an additional note, we can also examine what the volatile qualifier does here: GCC’s optimizers sometimes discard asm statements if they determine there is no need for the output variables. Also, the optimizers may move code out of loops if they believe that the code will always return the same result (i.e. none of its input values change between calls). Using the volatile qualifier disables these optimizations . asm statements that have no output operands and asm goto statements, are implicitly volatile . Here, volatile simply prevents unwanted compiler optimizations on the asm statement itself. Strictly speaking, it is not necessary in this case since an asm statement without output operands is already implicitly volatile. Asymmetric Heavy Fence - membarrier() The key synchronization mechanism behind asymmetric fences lies in asymmetric_thread_fence_heavy . There are several implementation strategies, but we will focus on the usual Linux path: relying on the membarrier() syscall (Another alternative is to force a TLB shootdown, though I am not familiar enough with that approach to discuss it in detail). As before, std::atomic_thread_fence(order) is used as a fallback. Let’s take a look at the documentation: