이 기사에서는 인기와 널리 사용되는 것으로 유명한 Intel C++ 컴파일러 에 초점을 맞춰 컴파일러 최적화의 잠재력을 조명하는 것을 목표로 합니다.
하이라이트: 컴파일러 최적화란 무엇입니까? | -온 | 아키텍처 대상 | 절차간 최적화 | -fno-앨리어싱 | 컴파일러 최적화 보고서
모든 컴파일러는 상위 수준 소스 코드를 하위 수준 기계 코드로 변환하기 위한 일련의 단계를 실행합니다. 여기에는 어휘 분석, 구문 분석, 의미 분석, 중간 코드 생성(또는 IR), 최적화 및 코드 생성이 포함됩니다.
최적화 단계에서 컴파일러는 더 적은 리소스를 사용하거나 더 빠르게 실행되는 의미상 동일한 출력을 목표로 프로그램을 변환하는 방법을 세심하게 찾습니다. 이 프로세스에 사용되는 기술은 상수 폴딩, 루프 최적화, 함수 인라이닝 및 데드 코드 제거를 포함하지만 이에 국한되지는 않습니다.
개발자는 컴파일 프로세스 중에 컴파일러 플래그 세트를 지정할 수 있습니다. 이는 정보 디버깅 및 프로파일링을 위해 GCC에서 " -g" 또는 "-pg" 와 같은 옵션을 사용하는 것과 유사한 방식입니다. 계속 진행하면서 Intel C++ 컴파일러로 응용 프로그램을 컴파일하는 동안 사용할 수 있는 유사한 컴파일러 플래그에 대해 논의하겠습니다. 이는 코드의 효율성과 성능을 향상시키는 데 도움이 될 수 있습니다.
u(x,y,t)는 시간 t의 (x,y) 지점의 온도입니다.
우리는 본질적으로 가변 크기(해상도라고 함)의 그리드에서 야코비 반복을 수행하는 C++ 코딩을 가지고 있습니다. 기본적으로 그리드 크기가 500이라는 것은 500x500 크기의 행렬을 푸는 것을 의미합니다.
/* * 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]; } } }
MFLOP/s는 "초당 백만 개의 부동 소수점 연산"을 나타냅니다. 부동 소수점 연산 측면에서 컴퓨터나 프로세서의 성능을 수량화하는 데 사용되는 측정 단위입니다. 부동 소수점 연산에는 부동 소수점 형식으로 표현되는 소수 또는 실수를 사용한 수학적 계산이 포함됩니다.
참고 1: 안정적인 결과를 제공하기 위해 각 해상도에 대해 실행 파일을 5번 실행하고 MFLOP/s 값의 평균값을 취합니다.
참고 2: Intel C++ 컴파일러의 기본 최적화는 -O2라는 점에 유의하는 것이 중요합니다. 따라서 소스 코드를 컴파일하는 동안 -O0을 지정하는 것이 중요합니다.
이는 컴파일러 최적화로 시작할 때 가장 일반적으로 사용되는 컴파일러 플래그 중 일부입니다. 이상적인 경우에는 Ofast > O3 > O2 > O1 > O0 의 성능을 보입니다. 그러나 이것이 반드시 일어나는 것은 아닙니다. 이러한 옵션의 중요한 점은 다음과 같습니다.
-O1:
-O2:
-O3:
-빠른:
이러한 모든 최적화가 기본 코드("-O0" 사용)보다 훨씬 빠르다는 것은 분명합니다. 실행 런타임은 기본 사례보다 2~3배 더 낮습니다. MFLOP/s는 어떻습니까??
전반적으로 약간이지만 "-O3"이 가장 성능이 좋습니다.
"- Ofast "(" -no-prec-div -fp-model fast=2 ")에서 사용되는 추가 플래그는 추가 속도 향상을 제공하지 않습니다.
대답은 전략적 컴파일러 플래그에 있습니다. " -xHost ", 더 정확하게는 " -xCORE-AVX512 "와 같은 옵션을 실험하면 시스템 기능의 잠재력을 최대한 활용하고 최적의 성능을 위해 최적화를 맞춤화할 수 있습니다.
-x호스트:
-xCORE-AVX512:
목표: Intel Advanced Vector Extensions 512(AVX-512) 명령어 세트를 활용하는 코드를 생성하도록 컴파일러에 명시적으로 지시합니다.
주요 기능: AVX-512는 AVX2와 같은 이전 버전에 비해 더 넓은 벡터 레지스터와 추가 작업을 제공하는 고급 SIMD(단일 명령어, 다중 데이터) 명령어 세트입니다. 이 플래그를 활성화하면 컴파일러가 이러한 고급 기능을 활용하여 성능을 최적화할 수 있습니다.
고려 사항: 여기서도 이식성이 주범입니다. AVX-512 명령어로 생성된 바이너리는 이 명령어 세트를 지원하지 않는 프로세서에서 최적으로 실행되지 않을 수 있습니다. 전혀 작동하지 않을 수도 있습니다!
기본적으로 " -xCORE-AVX512 "는 프로그램이 zmm 레지스터 사용으로 이점을 얻을 가능성이 거의 없다고 가정합니다. 컴파일러는 성능 향상이 보장되지 않는 한 zmm 레지스터 사용을 방지합니다.
제한 없이 zmm 레지스터를 사용하려는 경우 " "를 높게 설정할 수 있습니다. 우리도 그렇게 할 것입니다.
우후!
주목할만한 부분은 실질적인 수동 개입 없이 애플리케이션 컴파일 프로세스 중에 소수의 컴파일러 플래그를 통합함으로써 이러한 결과를 달성했다는 것입니다.
참고: 하드웨어가 AVX-512를 지원하지 않더라도 걱정하지 마세요. 인텔 C++ 컴파일러는 AVX, AVX-2 및 SSE에 대한 최적화를 지원합니다. 당신이 알아야 할 모든 것이 담겨 있습니다!
IPO는 프로그램 내의 다양한 기능이나 절차 간의 상호 작용에 초점을 맞춘 다단계 프로세스입니다. IPO에는 순방향 대체, 간접 호출 변환, 인라이닝 등 다양한 종류의 최적화가 포함될 수 있습니다.
-ipo:
목표: 컴파일러가 컴파일 중에 개별 소스 파일을 넘어 전체 프로그램을 분석하고 최적화할 수 있도록 프로시저 간 최적화를 가능하게 합니다.
주요 특징: - 전체 프로그램 최적화: “ -ipo ”는 전체 프로그램에서 함수와 프로시저 간의 상호 작용을 고려하여 모든 소스 파일에서 분석 및 최적화를 수행합니다. - 교차 기능 및 교차 모듈 최적화: 플래그는 인라인 기능, 동기화를 용이하게 합니다. 다양한 프로그램 부분에 걸친 최적화 및 데이터 흐름 분석.
고려사항: 별도의 링크 단계가 필요합니다. " -ipo "로 컴파일한 후 최종 실행 파일을 생성하려면 특정 링크 단계가 필요합니다. 컴파일러는 링크하는 동안 전체 프로그램 보기를 기반으로 추가 최적화를 수행합니다.
-ip:
목표: 프로시저 간 분석 전파를 활성화하여 컴파일러가 별도의 링크 단계 없이 일부 프로시저 간 최적화를 수행할 수 있도록 합니다.
주요 기능: - 분석 및 전파: " -ip "를 사용하면 컴파일러가 컴파일 중에 다양한 기능과 모듈에 걸쳐 조사 및 데이터 전파를 수행할 수 있습니다. 그러나 전체 프로그램 보기가 필요한 모든 최적화를 수행하지는 않습니다. - 더 빠른 컴파일: " -ipo "와 달리 " -ip "는 별도의 연결 단계가 필요하지 않으므로 컴파일 시간이 더 빨라집니다. 이는 빠른 피드백이 필수적인 개발 중에 도움이 될 수 있습니다.
고려 사항: 함수 인라인을 포함하여 일부 제한된 프로시저 간 최적화만 발생합니다.
-ipo는 일반적으로 별도의 링크 단계를 포함하지만 컴파일 시간이 길어지므로 보다 광범위한 프로시저 간 최적화 기능을 제공합니다. [ ] -ip는 별도의 링크 단계 없이 일부 프로시저 간 최적화를 수행하는 더 빠른 대안이므로 개발 및 테스트 단계에 적합합니다.[ ]
우리는 성능과 다양한 최적화, 컴파일 시간 또는 실행 파일의 크기에 대해서만 이야기하고 있으므로 " -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가 모두 같은 위치를 가리키면 어떻게 되나요? 이는 앨리어싱된 포인터 의 고전적인 문제가 됩니다 [ ].
최신 컴파일러는 매우 똑똑하며 안전을 보장하기 위해 앨리어싱이 가능할 수 있다고 가정합니다. 그리고 이와 같은 시나리오의 경우 의미 체계와 코드 출력에 영향을 미칠 수 있는 최적화를 피합니다.
우리의 경우 u 와 unew는 서로 다른 메모리 위치이며 서로 다른 값을 저장한다는 것을 알고 있습니다. 따라서 여기서는 앨리어싱이 발생하지 않을 것임을 컴파일러에 쉽게 알릴 수 있습니다.
두 가지 방법이 있습니다. 첫 번째는 C의 " " 키워드 입니다. 하지만 코드를 변경해야 합니다. 우리는 지금은 그것을 원하지 않습니다.
간단한 것 없나요? " -fno-alias "를 시도해 봅시다.
-fno-별칭:
목표: 프로그램에서 앨리어싱을 가정하지 않도록 컴파일러에 지시합니다.
주요 기능: 앨리어싱이 없다고 가정하면 컴파일러는 코드를 더 자유롭게 최적화하여 잠재적으로 성능을 향상시킬 수 있습니다.
고려사항: 개발자는 부당한 앨리어싱의 경우 프로그램이 예상치 못한 출력을 제공할 수 있으므로 이 플래그를 사용할 때 주의해야 합니다.
자, 이제 뭔가 생겼습니다!!!
어셈블리 코드(여기서는 공유되지 않음)와 생성된 컴파일 최적화 보고서( 아래 참조)를 면밀히 조사하면 컴파일러의 및 에 대한 능숙한 적용이 드러납니다. 이러한 변환은 고도로 최적화된 성능에 기여하며, 코드 효율성에 대한 컴파일러 지시문의 중요한 영향을 보여줍니다.
Intel C++ 컴파일러는 사용자가 최적화 목적으로 수행된 모든 조정 사항을 요약하는 최적화 보고서를 생성할 수 있는 유용한 기능을 제공합니다[ ]. 이 포괄적인 보고서는 YAML 파일 형식으로 저장되어 코드 내에서 컴파일러가 적용한 최적화의 세부 목록을 제공합니다. 자세한 설명은 " " 공식 문서를 참조하세요.
마찬가지로 Intel C++ 컴파일러(및 널리 사용되는 모든 컴파일러)도 매우 유용한 기능인 pragma 지시문을 지원합니다. 에서 ivdep, 병렬, simd, 벡터 등과 같은 일부 pragma를 확인해 보는 것이 좋습니다.