C++ 코드에서 최고의 성능을 얻는 것은 꼼꼼한 프로파일링, 복잡한 메모리 액세스 조정 및 캐시 최적화를 요구하는 어려운 작업일 수 있습니다. 이것을 조금 단순화하는 트릭이 있습니까 ?? 다행히도 올바른 통찰력을 갖고 현재 수행 중인 작업을 알고 있다면 최소한의 노력으로 놀라운 성능 향상을 달성할 수 있는 지름길이 있습니다. 코드 성능을 크게 향상시킬 수 있는 컴파일러 최적화를 시작하세요.
최신 컴파일러는 특히 자동 병렬화에서 최적의 성능을 향한 여정에서 없어서는 안 될 협력자 역할을 합니다. 이러한 정교한 도구는 특히 루프 내에서 복잡한 코드 패턴을 면밀히 조사하고 최적화를 원활하게 실행할 수 있는 능력을 갖추고 있습니다.
이 기사에서는 인기와 널리 사용되는 것으로 유명한 Intel C++ 컴파일러 에 초점을 맞춰 컴파일러 최적화의 잠재력을 조명하는 것을 목표로 합니다.
이 이야기에서 우리는 생각보다 수동 개입이 덜 필요하면서도 코드를 고성능 걸작으로 변환할 수 있는 컴파일러 마법의 계층을 풀어냅니다.
하이라이트: 컴파일러 최적화란 무엇입니까? | -온 | 아키텍처 대상 | 절차간 최적화 | -fno-앨리어싱 | 컴파일러 최적화 보고서
컴파일러 최적화에는 컴파일러가 컴파일 중에 소스 코드에 적용하는 다양한 기술과 변환이 포함됩니다. 그런데 왜? 성능, 효율성 및 경우에 따라 결과 기계어 코드의 크기를 향상합니다. 이러한 최적화는 속도, 메모리 사용량, 에너지 소비 등 코드 실행의 다양한 측면에 영향을 미치는 데 중추적인 역할을 합니다.
모든 컴파일러는 상위 수준 소스 코드를 하위 수준 기계 코드로 변환하기 위한 일련의 단계를 실행합니다. 여기에는 어휘 분석, 구문 분석, 의미 분석, 중간 코드 생성(또는 IR), 최적화 및 코드 생성이 포함됩니다.
최적화 단계에서 컴파일러는 더 적은 리소스를 사용하거나 더 빠르게 실행되는 의미상 동일한 출력을 목표로 프로그램을 변환하는 방법을 세심하게 찾습니다. 이 프로세스에 사용되는 기술은 상수 폴딩, 루프 최적화, 함수 인라이닝 및 데드 코드 제거를 포함하지만 이에 국한되지는 않습니다.
사용 가능한 모든 옵션에 대해 논의하지는 않지만 코드 성능을 향상시킬 수 있는 특정 최적화를 수행하도록 컴파일러에 지시하는 방법에 대해 논의할 것입니다. 그렇다면 해결책은???? 컴파일러 플래그.
개발자는 컴파일 프로세스 중에 컴파일러 플래그 세트를 지정할 수 있습니다. 이는 정보 디버깅 및 프로파일링을 위해 GCC에서 " -g" 또는 "-pg" 와 같은 옵션을 사용하는 것과 유사한 방식입니다. 계속 진행하면서 Intel C++ 컴파일러로 응용 프로그램을 컴파일하는 동안 사용할 수 있는 유사한 컴파일러 플래그에 대해 논의하겠습니다. 이는 코드의 효율성과 성능을 향상시키는 데 도움이 될 수 있습니다.
나는 무미건조한 이론을 탐구하거나 모든 컴파일러 플래그를 나열하는 지루한 문서를 여러분에게 제공하지 않을 것입니다. 대신 이러한 플래그가 작동하는 이유와 방법을 이해해 보겠습니다.
우리는 이것을 어떻게 달성합니까???
Jacobi 반복 계산을 담당하는 최적화되지 않은 C++ 함수를 사용하여 단계별로 각 컴파일러 플래그의 영향을 풀어보겠습니다. 이 탐색 과정에서 최적화 플래그 없음(-O0)부터 시작하여 각 반복을 기본 버전과 체계적으로 비교하여 속도 향상을 측정합니다.
속도 향상(또는 실행 시간)은 Intel® Xeon® Platinum 8174 프로세서 시스템에서 측정되었습니다. 여기서 Jacobi 방법은 직사각형 그리드의 열 분포를 모델링하기 위한 2차원 편미분 방정식(Poisson 방정식)을 해결합니다.
u(x,y,t)는 시간 t의 (x,y) 지점의 온도입니다.
분포가 더 이상 변하지 않을 때 안정 상태를 해결합니다.
일련의 Dirichlet 경계 조건이 경계에 적용되었습니다.
우리는 본질적으로 가변 크기(해상도라고 함)의 그리드에서 야코비 반복을 수행하는 C++ 코딩을 가지고 있습니다. 기본적으로 그리드 크기가 500이라는 것은 500x500 크기의 행렬을 푸는 것을 의미합니다.
Jacobi 반복을 한 번 수행하는 함수는 다음과 같습니다.
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
잔차가 임계값에 도달할 때까지(루프 내부) 야코비 반복을 계속 수행합니다. 잔차 계산과 임계값 평가는 이 함수 외부에서 수행되며 여기서는 중요하지 않습니다. 그럼 이제 방 안의 코끼리에 대해 이야기해 볼까요!
최적화 없이(-O0) 다음과 같은 결과를 얻습니다.
여기서는 MFLOP/s로 성능을 측정합니다. 이것이 우리 비교의 기초가 될 것입니다.
MFLOP/s는 "초당 백만 개의 부동 소수점 연산"을 나타냅니다. 부동 소수점 연산 측면에서 컴퓨터나 프로세서의 성능을 수량화하는 데 사용되는 측정 단위입니다. 부동 소수점 연산에는 부동 소수점 형식으로 표현되는 소수 또는 실수를 사용한 수학적 계산이 포함됩니다.
MFLOP/s는 특히 복잡한 수학적 계산이 널리 사용되는 과학 및 엔지니어링 응용 프로그램에서 벤치마크 또는 성능 지표로 사용되는 경우가 많습니다. MFLOP/s 값이 높을수록 시스템이나 프로세서가 부동 소수점 연산을 수행하는 속도가 빨라집니다.
참고 1: 안정적인 결과를 제공하기 위해 각 해상도에 대해 실행 파일을 5번 실행하고 MFLOP/s 값의 평균값을 취합니다.
참고 2: Intel C++ 컴파일러의 기본 최적화는 -O2라는 점에 유의하는 것이 중요합니다. 따라서 소스 코드를 컴파일하는 동안 -O0을 지정하는 것이 중요합니다.
계속해서 다양한 컴파일러 플래그를 시도할 때 이러한 실행 시간이 어떻게 변하는지 살펴보겠습니다!
이는 컴파일러 최적화로 시작할 때 가장 일반적으로 사용되는 컴파일러 플래그 중 일부입니다. 이상적인 경우에는 Ofast > O3 > O2 > O1 > O0 의 성능을 보입니다. 그러나 이것이 반드시 일어나는 것은 아닙니다. 이러한 옵션의 중요한 점은 다음과 같습니다.
-O1:
-O2:
-O3:
-빠른:
공식 가이드에서는 이러한 옵션이 제공하는 최적화가 정확히 무엇인지 자세히 설명합니다.
Jacobi 코드에서 이러한 옵션을 사용하면 다음과 같은 실행 런타임을 얻을 수 있습니다.
이러한 모든 최적화가 기본 코드("-O0" 사용)보다 훨씬 빠르다는 것은 분명합니다. 실행 런타임은 기본 사례보다 2~3배 더 낮습니다. MFLOP/s는 어떻습니까??
음, 뭔가요!!!
기본 사례의 MFLOP/s와 최적화된 경우에는 큰 차이가 있습니다.
전반적으로 약간이지만 "-O3"이 가장 성능이 좋습니다.
"- Ofast "(" -no-prec-div -fp-model fast=2 ")에서 사용되는 추가 플래그는 추가 속도 향상을 제공하지 않습니다.
머신의 아키텍처는 컴파일러 최적화에 영향을 미치는 중추적인 요소로 두드러집니다. 컴파일러가 사용 가능한 명령어 세트와 하드웨어에서 지원하는 최적화(예: 벡터화 및 SIMD)를 알고 있으면 성능이 크게 향상될 수 있습니다.
예를 들어 내 Skylake 시스템에는 3개의 SIMD 장치(AVX 512 장치 1개와 AVX-2 장치 2개)가 있습니다.
이 지식으로 정말 뭔가를 할 수 있을까요???
대답은 전략적 컴파일러 플래그에 있습니다. " -xHost ", 더 정확하게는 " -xCORE-AVX512 "와 같은 옵션을 실험하면 시스템 기능의 잠재력을 최대한 활용하고 최적의 성능을 위해 최적화를 맞춤화할 수 있습니다.
다음은 이러한 플래그가 무엇인지에 대한 간단한 설명입니다.
-x호스트:
-xCORE-AVX512:
목표: Intel Advanced Vector Extensions 512(AVX-512) 명령어 세트를 활용하는 코드를 생성하도록 컴파일러에 명시적으로 지시합니다.
주요 기능: AVX-512는 AVX2와 같은 이전 버전에 비해 더 넓은 벡터 레지스터와 추가 작업을 제공하는 고급 SIMD(단일 명령어, 다중 데이터) 명령어 세트입니다. 이 플래그를 활성화하면 컴파일러가 이러한 고급 기능을 활용하여 성능을 최적화할 수 있습니다.
고려 사항: 여기서도 이식성이 주범입니다. AVX-512 명령어로 생성된 바이너리는 이 명령어 세트를 지원하지 않는 프로세서에서 최적으로 실행되지 않을 수 있습니다. 전혀 작동하지 않을 수도 있습니다!
AVX-512 세트 명령어는 512비트 폭 레지스터 세트인 Zmm 레지스터를 사용합니다. 이러한 레지스터는 벡터 처리의 기초 역할을 합니다.
기본적으로 " -xCORE-AVX512 "는 프로그램이 zmm 레지스터 사용으로 이점을 얻을 가능성이 거의 없다고 가정합니다. 컴파일러는 성능 향상이 보장되지 않는 한 zmm 레지스터 사용을 방지합니다.
제한 없이 zmm 레지스터를 사용하려는 경우 " -qopt-zmm-usage "를 높게 설정할 수 있습니다. 우리도 그렇게 할 것입니다.
자세한 지침은 공식 가이드를 확인하는 것을 잊지 마세요.
이러한 플래그가 코드에서 어떻게 작동하는지 살펴보겠습니다.
우후!
이제 가장 작은 해상도가 1200MFLOP/s를 넘었습니다. 다른 해상도의 MFLOP/s 값도 증가했습니다.
주목할만한 부분은 실질적인 수동 개입 없이 애플리케이션 컴파일 프로세스 중에 소수의 컴파일러 플래그를 통합함으로써 이러한 결과를 달성했다는 것입니다.
그러나 컴파일된 실행 파일은 동일한 명령어 세트를 사용하는 시스템과만 호환된다는 점을 강조하는 것이 중요합니다.
특정 명령어 세트에 최적화된 코드는 다양한 하드웨어 구성에 걸쳐 이식성을 희생할 수 있으므로 최적화와 이식성 사이의 균형은 분명합니다. 그러니 당신이 무엇을 하고 있는지 꼭 알아두세요!!
참고: 하드웨어가 AVX-512를 지원하지 않더라도 걱정하지 마세요. 인텔 C++ 컴파일러는 AVX, AVX-2 및 SSE에 대한 최적화를 지원합니다. 문서에는 당신이 알아야 할 모든 것이 담겨 있습니다!
프로시저 간 최적화에는 개별 기능의 범위를 넘어 여러 기능이나 프로시저에 걸쳐 코드를 분석하고 변환하는 작업이 포함됩니다.
IPO는 프로그램 내의 다양한 기능이나 절차 간의 상호 작용에 초점을 맞춘 다단계 프로세스입니다. IPO에는 순방향 대체, 간접 호출 변환, 인라이닝 등 다양한 종류의 최적화가 포함될 수 있습니다.
인텔 컴파일러는 단일 파일 컴파일과 다중 파일 컴파일(전체 프로그램 최적화) [ 3 ]이라는 두 가지 일반적인 IPO 유형을 지원합니다. 각각을 수행하는 두 가지 일반적인 컴파일러 플래그가 있습니다.
-ipo:
목표: 컴파일러가 컴파일 중에 개별 소스 파일을 넘어 전체 프로그램을 분석하고 최적화할 수 있도록 프로시저 간 최적화를 가능하게 합니다.
주요 특징: - 전체 프로그램 최적화: “ -ipo ”는 전체 프로그램에서 함수와 프로시저 간의 상호 작용을 고려하여 모든 소스 파일에서 분석 및 최적화를 수행합니다. - 교차 기능 및 교차 모듈 최적화: 플래그는 인라인 기능, 동기화를 용이하게 합니다. 다양한 프로그램 부분에 걸친 최적화 및 데이터 흐름 분석.
고려사항: 별도의 링크 단계가 필요합니다. " -ipo "로 컴파일한 후 최종 실행 파일을 생성하려면 특정 링크 단계가 필요합니다. 컴파일러는 링크하는 동안 전체 프로그램 보기를 기반으로 추가 최적화를 수행합니다.
-ip:
목표: 프로시저 간 분석 전파를 활성화하여 컴파일러가 별도의 링크 단계 없이 일부 프로시저 간 최적화를 수행할 수 있도록 합니다.
주요 기능: - 분석 및 전파: " -ip "를 사용하면 컴파일러가 컴파일 중에 다양한 기능과 모듈에 걸쳐 조사 및 데이터 전파를 수행할 수 있습니다. 그러나 전체 프로그램 보기가 필요한 모든 최적화를 수행하지는 않습니다. - 더 빠른 컴파일: " -ipo "와 달리 " -ip "는 별도의 연결 단계가 필요하지 않으므로 컴파일 시간이 더 빨라집니다. 이는 빠른 피드백이 필수적인 개발 중에 도움이 될 수 있습니다.
고려 사항: 함수 인라인을 포함하여 일부 제한된 프로시저 간 최적화만 발생합니다.
-ipo는 일반적으로 별도의 링크 단계를 포함하지만 컴파일 시간이 길어지므로 보다 광범위한 프로시저 간 최적화 기능을 제공합니다. [ 4 ]
-ip는 별도의 링크 단계 없이 일부 프로시저 간 최적화를 수행하는 더 빠른 대안이므로 개발 및 테스트 단계에 적합합니다.[ 5 ]
우리는 성능과 다양한 최적화, 컴파일 시간 또는 실행 파일의 크기에 대해서만 이야기하고 있으므로 " -ipo "에 중점을 둘 것입니다.
위의 모든 최적화는 하드웨어를 얼마나 잘 알고 얼마나 실험할 것인지에 따라 달라집니다. 하지만 그게 전부는 아닙니다. 컴파일러가 코드를 어떻게 보는지 식별하려고 하면 다른 잠재적인 최적화 방법을 식별할 수 있습니다.
코드를 다시 살펴보겠습니다.
/* * One Jacobi iteration step */ void jacobi(double *u, double *unew, unsigned sizex, unsigned sizey) { int i, j; for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { unew[i * sizex + j] = 0.25 * (u[i * sizex + (j - 1)] + // left u[i * sizex + (j + 1)] + // right u[(i - 1) * sizex + j] + // top u[(i + 1) * sizex + j]); // bottom } } for (j = 1; j < sizex - 1; j++) { for (i = 1; i < sizey - 1; i++) { u[i * sizex + j] = unew[i * sizex + j]; } } }
jacobi() 함수는 두 개의 포인터를 매개변수로 사용하고 중첩된 for 루프 내에서 작업을 수행합니다. 컴파일러가 소스 파일에서 이 함수를 볼 때 매우 주의해야 합니다.
왜??
u를 사용하여 unnew를 계산하는 표현식에는 4개의 이웃 u 값의 평균이 포함됩니다. u 와 unew가 모두 같은 위치를 가리키면 어떻게 되나요? 이는 앨리어싱된 포인터 의 고전적인 문제가 됩니다 [ 7 ].
최신 컴파일러는 매우 똑똑하며 안전을 보장하기 위해 앨리어싱이 가능할 수 있다고 가정합니다. 그리고 이와 같은 시나리오의 경우 의미 체계와 코드 출력에 영향을 미칠 수 있는 최적화를 피합니다.
우리의 경우 u 와 unew는 서로 다른 메모리 위치이며 서로 다른 값을 저장한다는 것을 알고 있습니다. 따라서 여기서는 앨리어싱이 발생하지 않을 것임을 컴파일러에 쉽게 알릴 수 있습니다.
어떻게 해야 할까요?
두 가지 방법이 있습니다. 첫 번째는 C의 " restrict " 키워드 입니다. 하지만 코드를 변경해야 합니다. 우리는 지금은 그것을 원하지 않습니다.
간단한 것 없나요? " -fno-alias "를 시도해 봅시다.
-fno-별칭:
목표: 프로그램에서 앨리어싱을 가정하지 않도록 컴파일러에 지시합니다.
주요 기능: 앨리어싱이 없다고 가정하면 컴파일러는 코드를 더 자유롭게 최적화하여 잠재적으로 성능을 향상시킬 수 있습니다.
고려사항: 개발자는 부당한 앨리어싱의 경우 프로그램이 예상치 못한 출력을 제공할 수 있으므로 이 플래그를 사용할 때 주의해야 합니다.
자세한 내용은 공식 문서 에서 확인할 수 있습니다.
우리 코드에서는 이것이 어떻게 작동합니까?
자, 이제 뭔가 생겼습니다!!!
여기서는 이전 최적화에 비해 거의 3배에 달하는 놀라운 속도 향상을 달성했습니다. 이 부스트의 비결은 무엇입니까?
컴파일러에 앨리어싱을 가정하지 않도록 지시함으로써 강력한 루프 최적화를 자유롭게 실행할 수 있도록 했습니다.
어셈블리 코드(여기서는 공유되지 않음)와 생성된 컴파일 최적화 보고서( 아래 참조)를 면밀히 조사하면 컴파일러의 루프 교환 및 루프 언롤링 에 대한 능숙한 적용이 드러납니다. 이러한 변환은 고도로 최적화된 성능에 기여하며, 코드 효율성에 대한 컴파일러 지시문의 중요한 영향을 보여줍니다.
모든 최적화가 서로 비교하여 수행되는 방식은 다음과 같습니다.
Intel C++ 컴파일러는 사용자가 최적화 목적으로 수행된 모든 조정 사항을 요약하는 최적화 보고서를 생성할 수 있는 유용한 기능을 제공합니다[ 8 ]. 이 포괄적인 보고서는 YAML 파일 형식으로 저장되어 코드 내에서 컴파일러가 적용한 최적화의 세부 목록을 제공합니다. 자세한 설명은 " -qopt-report " 공식 문서를 참조하세요.
실제로 많은 작업을 수행하지 않고도 코드 성능을 크게 향상시킬 수 있는 몇 가지 컴파일러 플래그에 대해 논의했습니다. 유일한 전제 조건은 맹목적으로 아무것도 하지 않는 것입니다. 당신이 무엇을 하고 있는지 확인하세요!!
그러한 컴파일러 플래그는 수백 개가 있으며 이 이야기에서는 소수에 대해 설명합니다. 따라서 선호하는 컴파일러의 공식 컴파일러 가이드(특히 최적화 관련 문서)를 살펴보는 것이 좋습니다.
이러한 컴파일러 플래그 외에도 코드 성능을 놀랍게 향상시킬 수 있는 벡터화, SIMD 내장 함수, 프로필 안내 최적화 및 자동 병렬 처리 와 같은 다양한 기술이 있습니다.
마찬가지로 Intel C++ 컴파일러(및 널리 사용되는 모든 컴파일러)도 매우 유용한 기능인 pragma 지시문을 지원합니다. Intel-Specific Pragma Reference 에서 ivdep, 병렬, simd, 벡터 등과 같은 일부 pragma를 확인해 보는 것이 좋습니다.
[2] University of Kaiserslautern-Landau의 "Elwetritsch"를 사용한 고성능 컴퓨팅(rptu.de)
[6] SPEChpc에서 사용하기 위한 인텔 컴파일러, 최적화 및 기타 플래그
[7] 앨리어싱 — IBM 문서
[8] 인텔® 컴파일러 최적화 보고서
Unsplash 의 Igor Omilaev 가 찍은 특집 사진.
여기에도 게시되었습니다.