최근에 이상한 최적화 해제 (또는 최적화 기회를 놓친 경우)를 발견했습니다.
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 store
가 target
버퍼로 이동합니다. 하지만 이제 함수를 구조체의 메서드로 변경하면 어떻게되는지 살펴보세요.
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
이러한로드가 단순히 루프 외부로 들어올 수 있더라도 의 구성원은 항상 매장 전에 다시로드해야하는 것 같습니다 . 이러한 추가 저장소를 제거하려면 주로 핫 코드 위에 선언 된 지역 변수에 포인터를 직접 캐싱하여 많은 코드를 다시 작성해야합니다. 그러나 나는 항상 지역 변수에 포인터를 캐싱하는 것과 같은 세부 사항을 조작하는 것이 컴파일러가 매우 영리해진 요즘 조기 최적화에 적합하다고 생각했습니다. 그러나 여기에서 내가 틀린 것 같다 . 핫 루프에서 멤버 포인터를 캐싱하는 것은 필요한 수동 최적화 기술인 것 같습니다.
this->
것은 단지 구문상의 설탕입니다. 문제는 변수의 특성 (로컬 대 멤버)과 컴파일러가이 사실에서 추론하는 것들과 관련이 있습니다.