스택큐힙리스트

gcc 최적화 플래그 -O3은 -O2보다 코드를 느리게 만듭니다. 본문

카테고리 없음

gcc 최적화 플래그 -O3은 -O2보다 코드를 느리게 만듭니다.

스택큐힙리스트 2023. 8. 30. 11:57
반응형

나는이 주제를 - 번역기의 오류입니다! - 찾아봅니다. 그리고 이 코드를 실행하려고 시도합니다. 그런데 이상한 동작을 발견합니다. 만약 나는 이 코드를 '-O3' 최적화 플래그로 컴파일한다면 실행하는 데 '-O3' 정도 걸립니다. 만약 나는 #$$**$&$*$&로 컴파일한다면 실행하는 데 '1.98093 sec' 정도 걸립니다. 동일한 기계와 같은 환경에서 이 코드를 여러 번(5 또는 6번) 실행해보려고 합니다. 기타 소프트웨어(크롬, 스카이프 등)를 모두 닫습니다.

'gcc --version

gcc (Ubuntu 4.9.2-0ubuntu1~14.04) 4.9.2

Copyright (C) 2014 Free Software Foundation, Inc.

This is free software; see the source for copying conditions. There is NO

warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

'

그러니까 이것이 왜 일어나는지 설명해 줄 수 있을까요? 나는 #@**@^&$& 매뉴얼을 읽고, #@^*!#^$$&이 *^**&$&를 포함한다는 것을 알았어요. 도움 주셔서 감사합니다.

번역하세요.

P.S. 코드를 추가해주세요.

'#include

#include

#include

int main()

{

// Generate data

const unsigned arraySize = 32768;

int data[arraySize];

for (unsigned c = 0; c < arraySize; ++c)

data[c] = std::rand() % 256;

// !!! With this, the next loop runs faster

std::sort(data, data + arraySize);

// Test

clock_t start = clock();

long long sum = 0;

for (unsigned i = 0; i < 100000; ++i)

{

// Primary loop

for (unsigned c = 0; c < arraySize; ++c)

{

if (data[c] >= 128)

sum += data[c];

}

}

double elapsedTime = static_cast(clock() - start) / CLOCKS_PER_SEC;

std::cout << elapsedTime << std::endl;

std::cout << sum = << sum << std::endl;

}

'

답변 1

'gcc -O3'는 조건에 대해 'cmov'를 사용하여 루프에 의존성 체인을 연장하므로 이는 인텔 샌디브리지 CPU에서 2 개의 uop와 2개의 사이클 지연을 가지게 됩니다. (이는 'Agner Fog's instruction tables' 에 따르면, 'x86' 태그 위키도 참조하십시오). 이것은 'one of the cases where cmov sucks' 입니다.

만약 데이터가 예측할 수 없을 정도로 불안정하다면, 'cmov' 가 이길 수도 있으므로, 컴파일러에게는 이러한 선택이 상당히 합리적인 선택일 것입니다. (하지만, 'compilers may sometimes use branchless code too much' .)

저는 asm을 보고 싶어요 (아름다운 하이라이팅이 있으며 관련 없는 줄은 걸러낼 수 있어요. 하지만 여전히 모든 정렬 코드를 지나서 main()까지 스크롤해야 합니다).

'.L82: # the inner loop from gcc -O3

movsx rcx, DWORD PTR [rdx] # sign-extending load of data[c]

mov rsi, rcx

add rcx, rbx # rcx = sum+data[c]

cmp esi, 127

cmovg rbx, rcx # sum = data[c]>127 ? rcx : sum

add rdx, 4 # pointer-increment

cmp r12, rdx

jne .L82

'

gcc는 ADD 대신에 LEA를 사용하여 MOV를 절약할 수 있었을 것이다.

루프는 ADD->CMOV의 지연시간(3 사이클)에 의해 병목 현상이 발생합니다. 왜냐하면 루프의 한 번 반복에서는 CMO를 사용하여 rbx에 쓰고, 다음 반복에서는 ADD를 사용하여 rbx를 읽기 때문입니다.

루프에는 8개의 퓨즈 도메인 uop만 포함되어 있으므로 2사이클당 하나씩 실행될 수 있습니다. 실행 포트 압력도 'sum' 종속 체인의 지연만큼 심각한 병목 현상은 아니지만 근접합니다 (Sandybridge는 4개가 아닌 3개의 ALU 포트를 가지고 있습니다).

그런데, 루프-캐리어드-디프 체인에서 탈출하기 위해 'sum += (data[c] >= 128 ? data[c] : 0);'로 작성하는 것은 잠재적으로 유용할 수 있습니다. 여전히 많은 명령이 있지만 각 반복에서 발생하는 'cmov'는 독립적입니다. 이는 piles as expected in gcc6.3 -O2 and earlier' , but 입니다. 그러나 gcc7에서는 크리티컬 패스 상에서 a 'cmov'로 디-최적화 됩니다 ( 'https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82666' ). (이전 gcc 버전에서도 자동 벡터화되며 이를 작성하는 'if()' 방식보다 쉽습니다.)

클랭은 원본 소스와 함께 cmov를 비중요 경로에서 제거합니다.

'gcc -O2'은(는) 분기를 사용합니다(gcc5.x 이전 버전을 위해), 이는 데이터가 정렬되어 있기 때문에 예측을 잘합니다. 현대의 CPU는 분기 예측을 사용하여 제어 종속성을 처리하기 때문에 루프 범위의 종속성 체인이 짧아집니다: 딱 한 사이클의 지연인 'add' 입니다.

각 반복에서의 비교 및 분기는 분기 예측과 추측 실행 덕분에 독립적이며, 분기 방향이 확실히 알려지기 전에 실행을 계속할 수 있게 합니다.

'.L83: # The inner loop from gcc -O2

movsx rcx, DWORD PTR [rdx] # load with sign-extension from int32 to int64

cmp ecx, 127

jle .L82 # conditional-jump over the next instruction

add rbp, rcx # sum+=data[c]

.L82:

add rdx, 4

cmp rbx, rdx

jne .L83

'

루프에는 두 개의 루프-이어지는 종속성 체인이 있습니다: 'sum'와 루프 카운터입니다. 'sum' 체인은 0 또는 1 사이클 길이이며, 루프 카운터는 항상 1 사이클입니다. 그러나 Sandybridge에서는 루프가 5개의 결합 도메인 uop이므로 반복당 1 사이클로 실행할 수 없으므로 지연 시간은 병목이 되지 않습니다.

아마도 한 번 반복이 2 사이클 정도로 실행될 것입니다 (분기 명령어 처리량에 병목이 생기기 때문에), 반면 -O3 루프의 경우에는 3 사이클마다 한 번 실행됩니다. 다음 병목은 ALU uop 처리량이 될 것입니다. 4 개의 ALU uop (not-taken 경우)이지만 ALU 포트는 3 개뿐입니다. (ADD는 모든 포트에서 실행할 수 있습니다).

이 파이프라인 분석 예측은 -O3에 대해 약 3초, -O2에 대해 약 2초로 정확히 일치합니다.

하스웰/스카이레이크는 한 주기당 1개의 not-taken 케이스를 실행할 수 있습니다.왜냐하면 taken 분기와 동일한 주기에 not-taken 분기를 실행할 수 있으며 4개의 ALU 포트를 가지고 있기 때문입니다. (또는 약간 적게 실행할 수도 있습니다. 'a 5 uop loop doesn't quite issue at 4 uops every cycle' ).

(방금 테스트한 결과: Skylake @ 3.9GHz는 전체 프로그램의 분기 포함 버전을 1.45초에 실행시키거나, 분기 없는 버전을 1.68초에 실행시킵니다. 그러므로 그 차이는 훨씬 작습니다.)

g++6.3.1은 #$^&**#$&을 사용하며, '-O2'에서도 여전히 그렇지만, g++5.4는 여전히 4.9.2와 같이 작동합니다.

g++6.3.1과 g++5.4 모두 #^@!^@$$& / '-fprofile-use'을 사용하면 '-O3' 에도 분기된 버전이 생성됩니다 ( '-fno-tree-vectorize' 상태에서도).

새로운 gcc의 CMOV 버전 루프는 CMP/CMOV 대신에 'add ecx,-128' / 'cmovge rbx,rdx'를 사용합니다. 그게 좀 이상하지만 아마도 속도를 늦추지는 않을 것입니다. ADD는 플래그와 함께 출력 레지스터에 쓰기 때문에 물리적 레지스터의 수에 대한 압력을 더 가중시킵니다. 그러나 병목 현상이 아닌 한 거의 동등할 것입니다.

새로운 gcc는 -O3 옵션으로 루프를 자동 벡터화한다. 이는 단지 SSE2만 사용해도 상당한 속도 향상을 가져온다. (예: 나의 i7-6700k Skylake는 벡터화된 버전을 0.74초에 실행하여 스칼라보다 약 두 배 빠르다. 또는 AVX2 256비트 벡터를 사용하여 0.35초에 실행된다).

벡터화된 버전은 많은 명령어처럼 보이지만 그렇게 나쁘지 않으며 대부분은 루프 기반 종속 체인의 일부가 아닙니다. 끝 부분에서만 64비트 요소로 언팩해야 합니다. 하지만 조건이 이미 모든 음수 정수를 제로로 만들었을 때는 부호 확장 대신 제로 확장할 수 있다는 것을 인지하지 못하여 두 번이나 'pcmpgtd'을 수행합니다.

답변 2

gcc의 최적화 플래그 -O3은 -O2보다 코드를 느리게 만듭니다. 이 주제에 대해 SEO에 의식한 한국어 에세이를 작성해 보았습니다.

안녕하세요. 오늘은 GCC 컴파일러의 최적화 플래그 -O3와 -O2의 성능 차이에 대해 알아보려고 합니다. 이러한 주제는 많은 프로그래머들에게 영향을 미치는 중요한 주제 중 하나입니다.

최적화 플래그는 컴파일러가 소스 코드를 더 빠르고 효율적으로 실행할 수 있도록 도와주는 매개 변수입니다. GCC에서도 -O2와 -O3은 가장 많이 사용되는 최적화 수준 중 일부입니다.

-O2는 GCC의 기본 최적화 수준이며, 대부분의 경우에 좋은 퍼포먼스를 보여줍니다. 이 최적화 수준은 실행 시간을 최적화하는데 중점을 두고 있습니다. 그러나 -O3은 -O2보다 높은 최적화 수준으로 간주됩니다. -O3은 코드의 실행 속도를 더욱 빠르게 만들기 위해 코드의 크기와 복잡성을 최적화하는데 중점을 둡니다.

이제 여기서 중요한 관점을 살펴보겠습니다. -O3을 사용하면 코드 크기가 더욱 커질 수 있으며, 코드 최적화 작업이 더욱 복잡해질 수 있습니다. 이는 일부 특정한 상황에서 -O3이 -O2보다 실행 시간이 더 길어지는 이유가 될 수 있습니다.

또 다른 이유는 -O3이 코드를 병렬로 실행하도록 자동으로 최적화하기 때문입니다. 모든 코드가 병렬로 실행될 수 있는 것은 아니며, 코드간 종속성이나 메모리 접근 패턴 등 다양한 요인들에 따라 최적화 수준의 선택이 달라질 수 있습니다.

따라서 최적화 플래그를 선택할 때는 성능 향상에 집중하는 것이 아니라, 실제 적용할 코드에 맞는 최적화 수준을 선택해야 합니다. -O3보다 -O2가 더 나은 성능을 보이는 경우도 있을 수 있습니다.

마지막으로 언급할 점은 퍼포먼스 개선을 위해 최적화 플래그를 과도하게 사용하는 것은 오히려 부작용을 초래할 수 있다는 것입니다. 때때로 최적화가 잘못된 방향으로 적용될 수 있기 때문에 주의가 필요합니다.

결론적으로, GCC 컴파일러의 최적화 플래그 -O3과 -O2의 성능 차이는 매우 상황적입니다. 코드의 크기와 복잡성, 병렬 실행 가능성 등 다양한 요소를 고려하여 최적화 수준을 선택해야 합니다. 최적화 플래그의 사용은 성능 향상을 위해 명확한 이점을 가져다주지 않을 수도 있다는 것을 기억해야 합니다.

반응형
Comments