development

메모리 주문 "획득"과 "소비"는 어떻게 다르며 언제 "소비"가 선호됩니까?

big-blog 2020. 12. 9. 21:07
반응형

메모리 주문 "획득"과 "소비"는 어떻게 다르며 언제 "소비"가 선호됩니까?


C ++ 11 표준은 대략 "순차적으로 일관된", "획득", "소비", "릴리스"및 "완화"인 메모리 순서 를 포함하는 메모리 모델 (1.7, 1.10)을 정의합니다 . 마찬가지로 대략적으로 프로그램은 인종이없는 경우에만 옳습니다. 모든 동작이 한 동작이 다른 동작 보다 먼저 발생 하는 순서로 배치 될 수있는 경우 발생합니다 . 작업 X가 작업 Y 이전에 발생 하는 방식 XY 이전에 (하나의 스레드 내에서 ) 시퀀스 되거나 XY 이전에 스레드 간 발생하는 것 입니다. 후자의 조건은 무엇보다도 다음과 같은 경우에 주어집니다.

  • X는 과 동기화 Y , 또는
  • XY 이전에 종속성 순서가 있습니다.

Synchronizing-withX 가 일부 원자 변수에 대해 "release"순서가있는 원자 저장소이고 Y 가 동일한 변수에 대해 "acquire"순서가있는 원자로드 일 때 발생 합니다. 종속성-전에 주문이 유사한 상황이 발생 Y는 부하가 "소비"함께 주문 (적당한 메모리 액세스). 동기화와 동기화 의 개념은 스레드 내에서 순서 가 지정되는 작업간에 이적으로 발생-전 관계 확장 하지만, 이전종속성이 지정 되는 것은 호출 전 시퀀스 의 엄격한 하위 집합을 통해서만 전 이적으로 확장됩니다.carry-dependency 는 거대한 규칙 세트를 따르며 특히 std::kill_dependency.

그렇다면 "종속성 순서"개념의 목적은 무엇입니까? 더 단순한 순서 전 / 동기화와 비교하여 어떤 이점을 제공 합니까? 규칙이 더 엄격하기 때문에 더 효율적으로 구현할 수 있다고 가정합니다.

릴리스 / 획득에서 릴리스 / 소비로 전환하는 것이 정확하고 사소하지 않은 이점을 제공하는 프로그램의 예를 제공 할 수 있습니까? 그리고 언제 std::kill_dependency개선을 제공할까요? 높은 수준의 주장은 좋지만 하드웨어 별 차이점에 대한 보너스 포인트입니다.


N2492 는 다음과 같은 근거 로 데이터 종속성 순서를 도입했습니다 .

현재 작업 초안 (N2461)이 일부 기존 하드웨어에서 가능한 확장 성을 지원하지 않는 두 가지 중요한 사용 사례가 있습니다.

  • 거의 작성되지 않은 동시 데이터 구조에 대한 읽기 액세스

거의 작성되지 않은 동시 데이터 구조는 운영 체제 커널과 서버 스타일 애플리케이션 모두에서 매우 일반적입니다. 예를 들어 외부 상태 (예 : 라우팅 테이블), 소프트웨어 구성 (현재로드 된 모듈), 하드웨어 구성 (현재 사용중인 스토리지 장치) 및 보안 정책 (액세스 제어 권한, 방화벽 규칙)을 나타내는 데이터 구조가 있습니다. 10 억 대 1을 훨씬 초과하는 읽기-쓰기 비율은 매우 일반적입니다.

  • 포인터 매개 게시를위한 게시-구독 의미 체계

스레드 간의 대부분의 통신은 포인터를 통해 이루어지며, 여기서 생산자는 소비자가 정보에 액세스 할 수있는 포인터를 게시합니다. 해당 데이터에 대한 액세스는 완전한 획득 의미없이 가능합니다.

이러한 경우 스레드 간 데이터 종속성 순서 지정을 사용하면 스레드 간 데이터 종속성 순서 지정 을 지원하는 시스템에서 크기 순서 속도가 빨라지고 확장 성이 비슷하게 향상되었습니다. 이러한 기계는 값 비싼 잠금 획득, 원자 적 명령 또는 달리 필요한 메모리 펜스를 피할 수 있기 때문에 이러한 속도 향상이 가능합니다.

내 강조

거기에 제시된 동기 부여 사용 사례는 rcu_dereference()Linux 커널에서 가져온 것입니다.


Load-consume은 load-acquire와 매우 유사하지만, load-consume에 따라 데이터에 의존하는 식 평가에만 발생 전 관계를 유도한다는 점이 다릅니다. 표현식을 래핑하면 kill_dependency더 이상로드 소비에서 종속성을 전달하지 않는 값이 생성됩니다.

주요 사용 사례는 작성자가 데이터 구조를 순차적으로 생성 한 다음 공유 포인터를 새 구조로 스윙하는 것입니다 ( release또는 acq_rel원자 사용). 리더는 load-consume을 사용하여 포인터를 읽고 역 참조를 통해 데이터 구조를 얻습니다. 역 참조는 데이터 종속성을 생성하므로 독자는 초기화 된 데이터를 볼 수 있습니다.

std::atomic<int *> foo {nullptr};
std::atomic<int> bar;

void thread1()
{
    bar = 7;
    int * x = new int {51};
    foo.store(x, std::memory_order_release);
}

void thread2()
{
    int *y = foo.load(std::memory_order_consume)
    if (y)
    {
        assert(*y == 51); //succeeds
        // assert(bar == 7); //undefined behavior - could race with the store to bar 
        // assert(kill_dependency(*y) + bar == 58) // undefined behavior (same reason)
        assert(*y + bar == 58); // succeeds - evaluation of bar pulled into the dependency 
    }
}

부하 소비를 제공하는 데는 두 가지 이유가 있습니다. 주된 이유는 ARM 및 전력 부하가 소비되도록 보장되지만이를 획득으로 전환하려면 추가 펜싱이 필요하기 때문입니다. (x86에서는 모든로드가 획득되므로 소비는 순진한 컴파일에서 직접적인 성능 이점을 제공하지 않습니다.) 두 번째 이유는 컴파일러가 데이터 의존성없이 이후 작업을 소비 전까지 이동할 수 있기 때문에 획득을 위해 수행 할 수 없습니다. . (이러한 최적화를 활성화하는 것이이 모든 메모리 순서를 언어로 구축하는 큰 이유입니다.)

값을 래핑 kill_dependency하면로드 소비 전에 이동할 값에 따라 달라지는 식을 계산할 수 있습니다. 예를 들어 값이 이전에 읽은 배열의 인덱스 일 때 유용합니다.

소비를 사용하면 더 이상 전 이적이지 않은 사전 발생 관계가 생성됩니다 (비순환이 보장되지만). 예를 들어 to barstore는 foo에 대한 저장소 이전 발생하며 y, 이는 bar(주석 처리 된 assert에서) 읽기 전에 발생하는 의 역 참조 전에 발생 하지만 to 저장소 bar는의 읽기 전에 발생하지 않습니다 bar. 이로 인해 발생 전의 정의가 다소 복잡해 지지만 작동 방식을 상상할 수 있습니다 (시퀀싱 된 전으로 시작하여 원하는 수의 release-consume-dataDependency 또는 release-acquire-sequencedBefore 링크를 통해 전파).


Jeff Preshing은이 질문에 대한 훌륭한 블로그 게시물을 가지고 있습니다. 직접 추가 할 수는 없지만 소비와 획득에 대해 궁금해하는 사람은 자신의 게시물을 읽어야한다고 생각합니다.

http://preshing.com/20140709/the-purpose-of-memory_order_consume-in-cpp11/

그는 세 가지 다른 아키텍처에서 해당 벤치 마크 어셈블리 코드와 함께 특정 C ++ 예제를 보여줍니다. 와 비교할 때 memory_order_acquire, memory_order_consume잠재적으로 PowerPC에서 3 배 속도 향상, ARM에서 1.6 배 속도 향상, 어쨌든 강력한 일관성을 가진 x86에서 무시할 수있는 속도 향상을 제공합니다. 잡힌 점은 그가 작성했을 때 실제로 처리 된 GCC만이 획득과 다르게 의미론을 소비하며 아마도 버그 때문일 것입니다. 그럼에도 불구하고 컴파일러 작성자가이를 활용하는 방법을 알아낼 수 있다면 속도 향상이 가능함을 보여줍니다.


실제 답변이 아니고 적절한 답변에 대한 큰 현상금이 없다는 것을 의미하지는 않지만 부분적인 결과를 기록하고 싶습니다.

After staring at 1.10 for a while, and in particular the very helpful note in paragraph 11, I think this isn't actually so hard. The big difference between synchronizes-with (henceforth: s/w) and dependency-ordered-before (dob) is that a happens-before relationship can be established by concatenating sequenced-before (s/b) and s/w arbitrarily, but not so for dob. Note one of the definitions for inter-thread happens before:

A synchronizes-with X and X is sequenced before B

But the analogous statement for A is dependency-ordered before X is missing!

So with release/acquire (i.e. s/w) we can order arbitrary events:

A1    s/b    B1                                            Thread 1
                   s/w
                          C1    s/b    D1                  Thread 2

But now consider an arbitrary sequence of events like this:

A2    s/b    B2                                            Thread 1
                   dob
                          C2    s/b    D2                  Thread 2

In this sequenece, it is still true that A2 happens-before C2 (because A2 is s/b B2 and B2 inter-thread happens before C2 on account of dob; but we could argue that you can never actually tell!). However, it is not true that A2 happens-before D2. The events A2 and D2 are not ordered with respect to one another, unless it actually holds that C2 carries dependency to D2. This is a stricter requirement, and absent that requirement, A2-to-D2 cannot be ordered "across" the release/consume pair.

In other words, a release/consume pair only propagates an ordering of actions which carry a dependency from one to the next. Everything that's not dependent is not ordered across the release/consume pair.

Furthermore, note that the ordering is restored if we append a final, stronger release/acquire pair:

A2    s/b    B2                                                         Th 1
                   dob
                          C2    s/b    D2                               Th 2
                                             s/w
                                                    E2    s/b    F2     Th 3

Now, by the quoted rule, D2 inter-thread happens before F2, and therefore so do C2 and B2, and so A2 happens-before F2. But note that there is still no ordering between A2 and D2 — the ordering is only between A2 and later events.

In summary and in closing, dependency carrying is a strict subset of general sequencing, and release/consume pairs provide an ordering only among actions that carry dependency. As long as no stronger ordering is required (e.g. by passing through a release/acquire pair), there is theoretically a potential for additional optimization, since everything that is not in the dependency chain may be reordered freely.


Maybe here is an example that makes sense?

std::atomic<int> foo(0);

int x = 0;

void thread1()
{
    x = 51;
    foo.store(10, std::memory_order_release);
}

void thread2()
{
    if (foo.load(std::memory_order_acquire) == 10)
    {
        assert(x == 51);
    }
}

As written, the code is race-free and the assertion will hold, because the release/acquire pair orderes the store x = 51 before the load in the assertion. However, by changing "acquire" into "consume", this would no longer be true and the program would have a data race on x, since x = 51 carries no dependency into the store to foo. The optimization point is that this store can be reordered freely without concern to what foo is doing, because there is no dependency.

참고URL : https://stackoverflow.com/questions/19609964/how-do-acquire-and-consume-memory-orders-differ-and-when-is-consume-prefe

반응형