제공 : 한빛 네트워크
저자 : Mulyadi Santosa
역자 : 조성재
원문 : Getting Familiar with GCC Parameters
gcc (GNU C 컴파일러)는 실제로 컴파일, 어셈블리, 링크 등을 하는 프론트엔드 도구들의 집합입니다. 사용목적은 OS에서 바로 실행 가능한 형식의 파일을 생성하는 것입니다. 리눅스에서 실행 가능한 형식의 파일은 (32비트나 64비트 체계의) x86 시스템에서 ELF (Executable and Linking Format:실행가능하고 링크된 형식)입니다. 그러나 여러분은 여러분들을 위해 사용할 수 있는 몇몇의 gcc 파라메터들을 아십니까? 만약 여러분들이 결과로 출력된 바이너리 파일을 최적화하기 위한 방법을 찾는다거나 세션을 디버깅하기 위해 준비하거나, 단순히 gcc가 소스 코드를 실행가능한 파일로 변환하는 각 단계를 관찰하기 원할 뿐이라도 파라메터들과 친해지는것이 필수입니다. 그러니 읽어주기 바랍니다.
gcc는 한 번이 아닌, 여러 단계로 호출합니다. 이 말의 의미를 밑에 써두었습니다.
- 전처리: 지시자들을 더 이상 포함하지 않는 코드를 생성하는 것. #if 와 같은 것들은 컴파일러가 이해할 수 없으므로, 이것은 실제 코드로 변경되어야 합니다. 매크로들 또한 이 단계에서 순수한 소스코드로 변환되어 처음 코드보다 더 커진 결과 소스를 만듭니다. [1]
- 컴파일: 이것은 전처리가 완료된 코드를 처리하며, 어휘나 문법적으로 분석하고 어셈블리 코드를 생성합니다. 이 단계에서, gcc는 문법검사기로 여러분이 작성한 소스에서 실수를 분석하거나 검출하여 경고나 오류를 출력합니다. 만약 어떤 최적화가 요청되었다면, gcc는 그로 인해 에러가 있을법한 지점을 찾기 위해 분석하고, 더 많은 에러를 만들 것입니다. 이 작업은 최적화를 위해 여러번 읽어들여서 데모를 해보는 식으로 여러번 검출을 하여 작업을 완료합니다. [2]
- 어셈블리: 어셈블러 니모닉을 수용하고 op코드 등을 포함하는 오브젝트 코드를 생성합니다. 컴파일 단계가 op코드를 생성한다는 것은 잘못 알려진 사실입니다. 이 단계의 결과물은 실제로 대상 장치에 의존하는 op 코드들을 포함한 하나 이상의 오브젝트 파일을 생성합니다. [3]
- 링크: 오브젝트 파일들을 최종 실행가능한 파일로 변환합니다. op코드만으로는 OS에서 인식할 수 있는 실행파일을 만드는데 충분하지 않습니다. 그들은 더욱 완벽한 형태로 가공되어야만 합니다. 이 형식은 아시다시피 바이너리 형식으로, OS가 어떻게 바이너리 파일을 읽어들이는지, 위치를 다시 재정렬하는지, 다른 필요한 작업들을 어떻게 하는지 등의 내용을 기술하고 있습니다. ELF는 x86 기반의 리눅스에서 기본 형식입니다. [4]
gcc 파라메터들은 직접적으로 이 4단계로 나누어 설명하며, 간접적으로 다룰 것입니다. 설명을 명료하게 하기 위해, 이 글에서는 다음의 내용으로 구성하겠습니다.
- 최적화와 관련된 파라메터들
- 함수 호출과 관련된 파라메터들
- 디버깅과 관련된 파라메터들
- 전처리와 관련된 파라메터들
이것 이전에, 결과 코드 속을 들여다보는데 도움이 될 만한 관련 도구들을 소개합니다.
- objdump나 readelf 등을 포함하는 ELF 도구 모음. 이것들은 ELF의 정보를 우리에게 분석해줄 것입니다.
- Oprofile, 하드웨어 성능 측정 결과를 수집하는 분석 도구 중 하나입니다. 우리는 코드의 성능과 관련된 부분을 확인하기 위해 이 도구가 필요합니다.
- time, 프로그램의 실행시간을 측정하기 위한 간단한 방법입니다.
다음 설명은 gcc 버전 3.x 버전과 4.x 버전에 적용 가능하며, 아주 일반적인 것입니다. 같이 삽질해보실까요?
코드 최적화와 관련된 파라메터들
gcc는 여러분들에게 몇몇 최적화를 수행할 수 있는 어렵지 않은 방법으로 -O 옵션을 제공합니다. 이 옵션은 여러분의 코드를 더 빠르게 만들거나 코드 크기를 쥐어짜서 작게 만들어줍니다. 5개의 옵션값이 있습니다.
- -O0(O zero)부터 -O3. “0”은 최적화하지 않음을 뜻합니다. 반면에 "3"은 제일 높은 최적화 단계입니다. "1"과 “2”는 이 극단적인 끝 사이에 있습니다. 만약 여러분들이 다른 값없이 -O 만 사용했다면, 이것은 암묵적으로 -O1을 의미합니다.
- -Os. 이것은 gcc 에게 크기를 최적화하라고 지시하는 것입니다. 기본적으로 이것은 -O2 와 비슷합니다만, 크기가 늘어날만한 몇몇 단계를 건너뜁니다.
얼마나 이 옵션들로 속도가 빨라질까요? 좋습니다. 밑의 코드를 써보지요.
#include
int main(int argc, char *argv[])
{
int i,j,k
unsigned long acc=0;
for(i=0;i<10000;i++)
for(j=0;j<5000;j++)
for(k=0;k<4;k++)
acc+=k;
printf("acc = %lun",acc);
return 0;
}
[Listing 1] 최적화 될 간단한 코드
gcc에 (-Os를 제외한) 각각의 -O 옵션을 주어서 4개의 다른 바이너리 파일을 만듭니다. time 도구는 바이너리 파일들의 실행 시간을 기록합니다. 예를 들어 다음과 같이 쓸 수 있습니다.
$ time ./non-optimized
|
No optimization |
-O1 |
-O2 |
-O3 |
real |
0.728 |
0.1 |
0.1 |
0.1 |
user |
0.728 |
0.097 |
0.1 |
0.1 |
sys |
0.000 |
0.002 |
0.000 |
0.000 |
간단한 이유로 다음의 용어를 사용하겠습니다.
- Non-optimized 는 -O0 옵션을 주고 컴파일 한 실행파일 이름
- OptimizedO1 은 -O1 옵션을 주고 컴파일 한 실행파일 이름
- OptimizedO2 는 -O2 옵션을 주고 컴파일 한 실행파일 이름
- OptimizedO3 는 -O3 옵션을 주고 컴파일 한 실행파일 이름
여러분이 보다시피, 최적화되지 않은 경우와 -O1을 사용하여 컴파일된 파일 간에는 7배 정도의 차이가 있습니다. 주의할 점은 실제로 -O1, -O2, -O3 가 추가적인 엄청난 이익을 가져다 주는 것이 아닙니다. 사실 이 결과값들은 거의 비슷합니다. -O1의 마법이 무엇인지 궁금하세요?
소스 코드를 대충 훑어보고나서, 여러분은 그러한 코드가 최적화에 대해 아무것도 할 것 없는 코드라고 예상했을 것입니다. 먼저 Non-optimized 파일과 OptimizedO1 파일을 역어셈블해서 두 버전간의 차이를 보도록 하지요.
$ objdump -D non-optimized
$ objdump -D optimizedO1
(주의: 여러분들은 다른 결과를 얻을 수도 있습니다. 이것은 일반적인 설명으로 사용하십시오.)
Non-optimized |
OptimizedO1 |
mov 0xfffffff4(%ebp),%eax |
add $0x6,%edx |
add %eax,0xfffffff8(%ebp) |
add $0x1,%eax |
addl $0x1,0xfffffff4(%ebp) |
cmp $0x1388,%eax |
cmpl $0x3,0xfffffff4(%ebp) |
|
가장 내부의 반복문(for (k=0;k<4;k++))이 짧은 구문으로 구현되었습니다. 명백히 다른점에 주목하십시오. 최적화하지 않은 코드는 직접 메모리 접근을 통해 메모리 내용을 불러오고 저장하지만, 반면에 OptimizedO1은 CPU를 Accumulator와 반복 카운터로 사용하고 있습니다. 여러분들도 눈치채셨겠지만, 레지스터는 램보다 몇 백배에서 몇 만배는 빠릅니다.
임시 저장공간으로 CPU 레지스터가 충분치 않다면, gcc는 다른 최적화 속임수를 사용합니다. OptimizedO1의 역 어셈블된 코드에서 특별히 main() 함수 안을 보십시오.
......
08048390 :
...
80483a1: b9 00 00 00 00 mov $0x0,%ecx
80483a6: eb 1f jmp 80483c7
80483a8: 81 c1 30 75 00 00 add $0x7530,%ecx
Ox7530은 10진수로 30000입니다. 따라서 반복이 간단하다는 것을 생각할 수 있습니다. 이 코드는 가장 내부와 외부의 반복(("for(j=0;j<5000;j++) ... for(k=0;k<4;k++)") 을 나타냅니다. 왜냐하면 문자 그대로 30000번의 반복을 요청한 것이기 때문입니다. 여러분들은 내부적으로 실제로 3번의 반복만 필요하다는 것을 알아두십시오. k가 0일때, acc는 아직 같습니다. 따라서 첫 번째 반복은 제외할 수 있습니다.
80483ae: 81 f9 00 a3 e1 11 cmp $0x11e1a300,%ecx
80483b4: 74 1a je 80483d0
80483b6: eb 0f jmp 80483c7
음... 이제 이것은 300000000(10000*5000*6)과 비교됩니다. 이것은 모두 3번 반복을 나타냅니다. 반복값이 도달한 후에 우리는 printf()로 바로 이동하여 합계(주소 0x804883d0-0x80483db)값을 출력합니다.
80483b8: 83 c2 06 add $0x6,%edx
80483bb: 83 c0 01 add $0x1,%eax
80483be: 3d 88 13 00 00 cmp $0x1388,%eax
80483c3: 74 e3 je 80483a8
80483c5: eb f1 jmp 80483b8
각 실행에서 accumulator에 6이 추가됩니다. %edx는 매회 반복을 완료한 후 전체 합계를 내장할 것입니다. 세 번째와 네 번째 행은 이것이 5000번 완료된 후임을 우리에게 보여줍니다. 이것은 (이전에 언급한 것처럼) 0x80483a8 주소로 되돌아 갈 것입니다.
우리는 gcc가 이 부분에선 단순한 코드를 만든다는 것으로 결론내릴 수 있습니다. 제일 내부의 반복을 3번 반복하는 대신, 매회 중간 반복에 대해 6을 더해주는 것으로 단순화했습니다. 이 얘기는 단순합니다만, 여러분의 프로그램을 3천만번 반복하는 대신 천만번만 반복하는 것이 됩니다. 이 단순화는 기술적으로 loop unrolling(반복 해제)라고 불리며, -O1/2/3에 의해 적용되는 작업 중 하나입니다. 물론, 여러분이 이러한 것을 알고 계신다면 바로 할 수 있지만, 때때로 gcc가 이런 것들을 검출하고 최적화 한다는 것을 아는 것이 좋습니다.
파라메터 -O2나 -O3는 gcc 또한 분기명령어들을 최적화하기 위해 시도합니다. 이것은 보통 재정렬[5]과 코드 변환을 통해 완료됩니다. 이 프로시져의 목적은 가능한 잘못된 예측을 제거하는 것으로 파이프라인 사용을 개선하는 것입니다. 예를 들어, 우리가 Non-optimized 와 OptimizedO2가 가장 외각 반복을 얼마나 하는지 비교할 수 있습니다.
80483d4: 83 45 ec 01 addl $0x1,0xffffffec(%ebp)
80483d8: 81 7d ec 0f 27 00 00 cmpl $0x270f,0xffffffec(%ebp)
80483df: 7e c4 jle 80483a5
최적화되지 않은 바이너리는 점프를 수행하기 위해 jle 명령을 사용합니다. 이것은 수학적으로 50 퍼센트의 확률로 분기할 것을 의미합니다. 한편 OptimizedO2 버전은 이렇게 씁니다.
80483b4: 81 c1 30 75 00 00 add $0x7530,%ecx
80483ba: 81 f9 00 a3 e1 11 cmp $0x11e1a300,%ecx
80483c0: 75 e1 jne 80483a3
jle 대신에 이제는 jne가 사용되었습니다. 어떤 정수값이 이전 cmp 명령어에서 비교될 수 있다고 가정하면, 여러분은 거의 100%에 가깝게 분기 명령 기회를 끌어올릴 수 있을 것입니다. 이것은 작지만 프로세서에게 어느 부분을 실행할 것인지 명시해 주는데 유용한 힌트입니다. 하지만, 대부분 현대적인 프로세서들은 이러한 종류의 변환이 크게 필요하지 않습니다. 왜냐하면 이 작업을 수행할 정도로 분기 예측기가 똑똑해졌기 때문입니다.
이러한 변환이 얼마나 도움이 될 수 있는지 증명하기 위해, OProfile이 도움이 될 것입니다. Oprofile은 유효상태가 지난 분기명령어들이나 유효상태가 지나고 잘못 예측된 분기들을 기록하도록 수행할 것입니다.
$ opcontrol --event=RETIRED_BRANCHES_MISPREDICTED:1000 --event=RETIRED_BRANCHES:1000;
우리는 Non-optimized와 OptimizedO2를 각각 5번씩 실행합니다. 다음으로 우리는 샘플의 최대와 최소를 얻게됩니다. 우리는 다음의 방정식을 사용해 오측율을 계산합니다.
오측율 = 잘못 예측된 분기 / 유효상태가 지난 분기
이 평균 오측율은 각각의 바이너리 파일에 대해 계산됩니다. Non-optimized는 0.5117 퍼센트를 얻은 반면, OptimizedO2는 0.4323 퍼센트를 얻습니다. (이 경우는 아주 작은 성능 향상입니다.) 실제 성능향상은 실제 상황에 따라 아주 다양합니다. 왜냐하면 gcc 자체적으로 외부 힌트없이 많은 일을 할 수는 없기 때문입니다. 더 자세한 사항은 gcc 온라인 문서에서 __builtin_expect() 에 관하여 읽어주십시오.
역자 조성재님은 현재 오픈소스 데스크탑 환경인 Kool Desktop Environment (KDE) 프로젝트의 한국어 번역 코디네이터와 한국팀의 대표로 활동하고 있습니다.