이 포인터를 사용하면 핫 루프에서 이상한 최적화 해제가 발생합니다.


122

최근에 이상한 최적화 해제 (또는 최적화 기회를 놓친 경우)를 발견했습니다.

3 비트 정수에서 8 비트 정수로 구성된 배열을 효율적으로 풀기 위해이 함수를 고려하십시오. 각 루프 반복에서 16 개의 int를 풉니 다.

void unpack3bit(uint8_t* target, char* source, int size) {
   while(size > 0){
      uint64_t t = *reinterpret_cast<uint64_t*>(source);
      target[0] = t & 0x7;
      target[1] = (t >> 3) & 0x7;
      target[2] = (t >> 6) & 0x7;
      target[3] = (t >> 9) & 0x7;
      target[4] = (t >> 12) & 0x7;
      target[5] = (t >> 15) & 0x7;
      target[6] = (t >> 18) & 0x7;
      target[7] = (t >> 21) & 0x7;
      target[8] = (t >> 24) & 0x7;
      target[9] = (t >> 27) & 0x7;
      target[10] = (t >> 30) & 0x7;
      target[11] = (t >> 33) & 0x7;
      target[12] = (t >> 36) & 0x7;
      target[13] = (t >> 39) & 0x7;
      target[14] = (t >> 42) & 0x7;
      target[15] = (t >> 45) & 0x7;
      source+=6;
      size-=6;
      target+=16;
   }
}

다음은 코드의 일부에 대해 생성 된 어셈블리입니다.

 ...
 367:   48 89 c1                mov    rcx,rax
 36a:   48 c1 e9 09             shr    rcx,0x9
 36e:   83 e1 07                and    ecx,0x7
 371:   48 89 4f 18             mov    QWORD PTR [rdi+0x18],rcx
 375:   48 89 c1                mov    rcx,rax
 378:   48 c1 e9 0c             shr    rcx,0xc
 37c:   83 e1 07                and    ecx,0x7
 37f:   48 89 4f 20             mov    QWORD PTR [rdi+0x20],rcx
 383:   48 89 c1                mov    rcx,rax
 386:   48 c1 e9 0f             shr    rcx,0xf
 38a:   83 e1 07                and    ecx,0x7
 38d:   48 89 4f 28             mov    QWORD PTR [rdi+0x28],rcx
 391:   48 89 c1                mov    rcx,rax
 394:   48 c1 e9 12             shr    rcx,0x12
 398:   83 e1 07                and    ecx,0x7
 39b:   48 89 4f 30             mov    QWORD PTR [rdi+0x30],rcx
 ...

꽤 효율적으로 보입니다. 간단히 a shift right다음에 and, 그리고 a storetarget버퍼로 이동합니다. 하지만 이제 함수를 구조체의 메서드로 변경하면 어떻게되는지 살펴보세요.

struct T{
   uint8_t* target;
   char* source;
   void unpack3bit( int size);
};

void T::unpack3bit(int size) {
        while(size > 0){
           uint64_t t = *reinterpret_cast<uint64_t*>(source);
           target[0] = t & 0x7;
           target[1] = (t >> 3) & 0x7;
           target[2] = (t >> 6) & 0x7;
           target[3] = (t >> 9) & 0x7;
           target[4] = (t >> 12) & 0x7;
           target[5] = (t >> 15) & 0x7;
           target[6] = (t >> 18) & 0x7;
           target[7] = (t >> 21) & 0x7;
           target[8] = (t >> 24) & 0x7;
           target[9] = (t >> 27) & 0x7;
           target[10] = (t >> 30) & 0x7;
           target[11] = (t >> 33) & 0x7;
           target[12] = (t >> 36) & 0x7;
           target[13] = (t >> 39) & 0x7;
           target[14] = (t >> 42) & 0x7;
           target[15] = (t >> 45) & 0x7;
           source+=6;
           size-=6;
           target+=16;
        }
}

생성 된 어셈블리가 완전히 동일해야한다고 생각했지만 그렇지 않습니다. 다음은 그 일부입니다.

...
 2b3:   48 c1 e9 15             shr    rcx,0x15
 2b7:   83 e1 07                and    ecx,0x7
 2ba:   88 4a 07                mov    BYTE PTR [rdx+0x7],cl
 2bd:   48 89 c1                mov    rcx,rax
 2c0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2c3:   48 c1 e9 18             shr    rcx,0x18
 2c7:   83 e1 07                and    ecx,0x7
 2ca:   88 4a 08                mov    BYTE PTR [rdx+0x8],cl
 2cd:   48 89 c1                mov    rcx,rax
 2d0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2d3:   48 c1 e9 1b             shr    rcx,0x1b
 2d7:   83 e1 07                and    ecx,0x7
 2da:   88 4a 09                mov    BYTE PTR [rdx+0x9],cl
 2dd:   48 89 c1                mov    rcx,rax
 2e0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2e3:   48 c1 e9 1e             shr    rcx,0x1e
 2e7:   83 e1 07                and    ecx,0x7
 2ea:   88 4a 0a                mov    BYTE PTR [rdx+0xa],cl
 2ed:   48 89 c1                mov    rcx,rax
 2f0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 ...

보시다시피 load각 교대 ( mov rdx,QWORD PTR [rdi]) 전에 메모리에서 추가 중복 을 도입했습니다 . target포인터 (이제는 지역 변수 대신 멤버 임)를 저장하기 전에 항상 다시로드해야하는 것처럼 보입니다 .이로 인해 코드가 상당히 느려집니다 (내 측정에서 약 15 %).

먼저 C ++ 메모리 모델이 멤버 포인터가 레지스터에 저장되지 않고 다시로드되어야한다고 강제 할 수 있다고 생각했지만, 실행 가능한 많은 최적화를 불가능하게 만들기 때문에 이것은 어색한 선택처럼 보였습니다. 그래서 컴파일러가target 여기 레지스터에 .

멤버 포인터를 지역 변수에 직접 캐싱 해 보았습니다.

void T::unpack3bit(int size) {
    while(size > 0){
       uint64_t t = *reinterpret_cast<uint64_t*>(source);
       uint8_t* target = this->target; // << ptr cached in local variable
       target[0] = t & 0x7;
       target[1] = (t >> 3) & 0x7;
       target[2] = (t >> 6) & 0x7;
       target[3] = (t >> 9) & 0x7;
       target[4] = (t >> 12) & 0x7;
       target[5] = (t >> 15) & 0x7;
       target[6] = (t >> 18) & 0x7;
       target[7] = (t >> 21) & 0x7;
       target[8] = (t >> 24) & 0x7;
       target[9] = (t >> 27) & 0x7;
       target[10] = (t >> 30) & 0x7;
       target[11] = (t >> 33) & 0x7;
       target[12] = (t >> 36) & 0x7;
       target[13] = (t >> 39) & 0x7;
       target[14] = (t >> 42) & 0x7;
       target[15] = (t >> 45) & 0x7;
       source+=6;
       size-=6;
       this->target+=16;
    }
}

이 코드는 또한 추가 저장소없이 "좋은"어셈블러를 생성합니다. 그래서 내 생각은 : 컴파일러는 구조체의 멤버 포인터의로드를 끌어 올릴 수 없기 때문에 그러한 "핫 포인터"는 항상 지역 변수에 저장되어야합니다.

  • 그렇다면 컴파일러가 이러한로드를 최적화 할 수없는 이유는 무엇입니까?
  • 이것을 금지하는 것이 C ++ 메모리 모델입니까? 아니면 단순히 내 컴파일러의 단점입니까?
  • 내 추측이 맞습니까? 아니면 최적화를 수행 할 수없는 정확한 이유는 무엇입니까?

사용중인 컴파일러는 최적화 g++ 4.8.2-19ubuntu1와 함께 사용되었습니다 -O3. 또한 clang++ 3.4-1ubuntu3비슷한 결과를 시도 했습니다. Clang은 로컬 target포인터로 메서드를 벡터화 할 수도 있습니다. 그러나this->target 포인터를 사용하면 동일한 결과가 나타납니다. 각 저장 전에 포인터가 추가로로드됩니다.

비슷한 메서드의 어셈블러를 확인한 결과 결과는 동일합니다. this이러한로드가 단순히 루프 외부로 들어올 수 있더라도 의 구성원은 항상 매장 전에 다시로드해야하는 것 같습니다 . 이러한 추가 저장소를 제거하려면 주로 핫 코드 위에 선언 된 지역 변수에 포인터를 직접 캐싱하여 많은 코드를 다시 작성해야합니다. 그러나 나는 항상 지역 변수에 포인터를 캐싱하는 것과 같은 세부 사항을 조작하는 것이 컴파일러가 매우 영리해진 요즘 조기 최적화에 적합하다고 생각했습니다. 그러나 여기에서 내가 틀린 것 같다 . 핫 루프에서 멤버 포인터를 캐싱하는 것은 필요한 수동 최적화 기술인 것 같습니다.


5
왜 이것이 반대표를 받았는지 확실하지 않습니다. 흥미로운 질문입니다. FWIW 솔루션이 비슷한 비 포인터 멤버 변수에서 유사한 최적화 문제를 보았습니다. 즉, 메서드의 수명 동안 멤버 변수를 로컬 변수에 캐시합니다. 앨리어싱 규칙과 관련이 있다고 생각합니까?
Paul R

1
일부 "외부"코드를 통해 멤버가 액세스되지 않도록 할 수 없기 때문에 컴파일러가 최적화하지 않는 것 같습니다. 따라서 멤버를 외부에서 수정할 수있는 경우 액세스 할 때마다 다시로드해야합니다. 휘발성의 일종처럼 간주 될 것으로 보인다 ...
장 - 밥 티스트 Yunès

사용하지 않는 this->것은 단지 구문상의 설탕입니다. 문제는 변수의 특성 (로컬 대 멤버)과 컴파일러가이 사실에서 추론하는 것들과 관련이 있습니다.
Jean-Baptiste Yunès 2014

포인터 별칭과 관련이 있습니까?
Yves Daoust 2014

3
보다 의미론적인 문제로서 "미숙 한 최적화"는 조기 최적화에만 적용됩니다. 즉, 프로파일 링에서 문제가 발견되기 전입니다. 이 경우 부지런히 프로파일 링 및 디 컴파일하고 문제의 원인을 찾고 솔루션을 공식화하고 프로파일 링했습니다. 이 솔루션을 적용하는 것은 절대 "조기"가 아닙니다.
raptortech97

답변:


107

아이러니하게도 this와 사이에 포인터 앨리어싱이 문제인 것 같습니다 this->target. 컴파일러는 초기화 한 다소 음란 한 가능성을 고려하고 있습니다.

this->target = &this

이 경우, 쓰기는하기 this->target[0]의 내용을 변경할 것 this때문에 (그리고,this->target ) .

메모리 앨리어싱 문제는 위에 제한되지 않습니다. 원칙적으로 this->target[XX]주어진 (in) 적절한 값의 사용 XX은 다음을 가리킬 수 있습니다.this 있습니다.

__restrict__키워드로 포인터 변수를 선언하여 해결할 수있는 C에 대해 더 잘 알고 있습니다 .


18
이것을 확인할 수 있습니다! target에서 uint8_t로 변경 uint16_t(엄격한 앨리어싱 규칙이 시작되도록)이 변경되었습니다. 를 사용 uint16_t하면로드가 항상 최적화됩니다.
살충제


3
의 내용을 변경하는 this것은 당신이 의미하는 바가 아닙니다 (변수가 아닙니다). 의 내용을 변경하는 것을 의미합니다 *this.
Marc van Leeuwen 2014 년

@gexicide 마음이 얼마나 엄격한 별칭이 시작되고 문제를 해결하는지 자세히 설명합니까?
HCSF

33

엄격한 별칭 규칙을 사용하면 char*다른 포인터의 별칭을 지정할 수 있습니다 . 따라서 코드의 첫 번째 부분 인, 및 코드 메서드에서 this->target별칭을 사용할 수 있습니다 this.

target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;

사실이다

this->target[0] = t & 0x7;
this->target[1] = (t >> 3) & 0x7;
this->target[2] = (t >> 6) & 0x7;

this수정할 때 수정 될 수 this->target내용.

일단 this->target로컬 변수에 캐시 되면 별칭은 더 이상 로컬 변수로 가능하지 않습니다.


1
그래서, 우리는 일반적으로 말할 수있다 : 당신이 때마다 char*또는 void*당신의 구조체에, 그것을 쓰기 전에 로컬 변수에 캐시해야?
gexicide 2014

5
실제로 char*는 회원으로 필요하지 않은 을 사용할 때 입니다.
Jarod42

24

여기서 문제는 우리가 char *를 통해 별칭을 지정할 수 있으며 귀하의 경우 컴파일러 최적화를 방지 하는 엄격한 별칭 입니다 . 정의되지 않은 동작 인 다른 유형의 포인터를 통해 별칭을 지정할 수 없습니다. 일반적으로 사용자가 호환되지 않는 포인터 유형을 통해 별칭을 지정 하려는이 문제를 볼 수 있습니다. .

uint8_tunsigned char 로 구현하는 것이 합리적 이며 Coliru 에서 cstdint를 보면 다음과 같이 uint8_t 를 typedef 하는 stdint.h 가 포함 됩니다.

typedef unsigned char       uint8_t;

char가 아닌 다른 유형을 사용한 경우 컴파일러는 최적화 할 수 있어야합니다.

이것은 C ++ 표준 섹션 3.10 Lvalues ​​및 rvalues 초안에서 다룹니다 .

프로그램이 다음 유형 중 하나가 아닌 다른 glvalue를 통해 객체의 저장된 값에 액세스하려고하면 동작이 정의되지 않습니다.

다음 글 머리 기호를 포함합니다.

  • char 또는 unsigned char 유형.

참고 로 uint8_t ≠ unsigned char언제입니까? 라는 질문 에 가능한 해결 방법 에 대한 의견을 게시했습니다 . 권장 사항은 다음과 같습니다.

그러나 간단한 해결 방법은 restrict 키워드를 사용하거나 주소를 가져 오지 않은 지역 변수에 포인터를 복사하여 컴파일러가 uint8_t 객체가 별칭을 지정할 수 있는지 여부에 대해 걱정할 필요가 없도록하는 것입니다.

C ++는 restrict 키워드를 지원하지 않으므로 컴파일러 확장에 의존 해야합니다. 예를 들어 gcc는 __restrict__를 사용 하므로 완전히 이식 가능하지는 않지만 다른 제안은 그래야합니다.


이것은 컴파일러가 T 유형의 객체에 대한 두 액세스 사이 또는 그러한 액세스와 루프 / 함수의 시작 또는 끝을 가정 할 수 있도록 허용하는 규칙보다 최적화 프로그램에 대해 표준이 더 나쁜 곳의 예입니다. 이것이 발생하면, 중간 작업이 해당 객체 (또는 그에 대한 포인터 / 참조)를 사용하여 포인터 또는 다른 객체에 대한 참조를 파생하지 않는 한 저장소에 대한 모든 액세스는 동일한 객체를 사용합니다 . 이러한 규칙은 바이트 시퀀스로 작동하는 코드의 성능을 저하시킬 수있는 "문자 유형 예외"의 필요성을 제거합니다.
supercat
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.