병렬 make 를 이용하여 build 시간 단축하기
개요
대부분의 C/C++ 개발 환경은 빌드 프로세스를 관리하는 make 유틸리티의 버전에 의존하고 있는데 대부분의 엔지니어들은 컴파일 시간을 줄여주는 병렬 기능의 장점을 활용하고 있지 못합니다. 이 글은 병렬 기능의 사용 방법과 설명 그리고 일반적인 문제들에 대한 해결책을 설명합니다.
소개
우리가 일반적으로 사용하고 있는 절차 프로그래밍 언어들( C++, perl, 혹은 Java 같은) 과는 다르게, make의 데이터 흐름 언어는 작업의 특별한 순서를 지정하지 않습니다. 대신 각 단계는 필요한 의존 단계가 완료 된 후에 실행 됩니다. 서로 독립적인 작업에 대한 병렬적인 실행 작업이 가능하지만 불일치하거나 혹은 비정상적이기까지 한 결과를 막기 위해서는 몇가지 사항에 주의를 기울여야 합니다.
좀 더 복잡한 개별 환경에서, 병렬 빌드는 여러개의 머신 혹은 여러개의 네트워크 상에서 분배 될 수 있습니다(종종 병렬 빌드 대신 분산 으로 분류됨). 이러한 작업은 간단하지 않은 준비 작업이 요구 되는데(grid 를 설정하는 것)일반적으로 이것은 관리자 혹은 빌드 마스터 에 의해 처리 되므로 각 엔지니어들은 구현의 상세한 사항에 대해 알 필요가 없습니다.
이 글에서 우리는 현존하고 있는 단독머신 상의 직렬 빌드 환경에서 간단한 케이스를 처리할 것입니다. 즉 단순히 빌드 시간을 줄이는 것을 목표로 합니다. 그러나 이 글의 단순한 상황에서 발생하는 이슈들은 좀 더 일반적인 환경에서도 역시 똑같이 다루어져야 합니다.
이론적 근거
멀티프로세서 머신에서 CPU는 단일 CPU 환경보다 훨씬 빠른 복수개의 컴파일을 수행 할 수 있기 때문에 병렬 make 의 장점이 분명하게 들어 납니다. 또한 단일 프로세서 환경에서도 병렬 make 를 이용해서 좀 더 빠르게 컴파일을 할 수 있습니다. 왜냐하면 복수의 컴파일시에 CPU 와 I/O 요구가 동일한 시간상에서 병렬적으로 이루어 질 수 있기 때문입니다.(전체적은 처리량 향상) 하나의 소스파일이 변화 된것 같은 아주 간단한 상황에서 병렬 make 는 별 도움이 되지 않습니다. 그러나 여러개의 파일(혹은 여러개의 소스들에 의해 사용되어 지는 헤더들) 이 변화 된다면 병렬 빌드는 전체적인 빌드 타임을 감소 시켜 줄 것입니다.
실제로 사용하기
대부분의 make 변형 버전들이 병렬 빌드 기능을 지원하기 때문에, 이 글은 GNU make 를 이용하기로 합니다. 왜냐하면 일반적으로 사용이 가능하고 또 널리 이용되고 있기 때문입니다. 다른 make 유틸리티들은 아래에서 보여지는 것 처럼 몇몇 문법적인 단축키를 제공합니다; 그러나 이러한 기능의 사용은 Makefile의 호환성을 저하 시킬 수 있습니다.
gmake 를 이용한 빌드 작업은 -j 플래그를 이용해서 수행할 수 있습니다:
$ gmake -j 4
작업의 숫자 를 지정하는 실행 문법은 다른 병렬 dmake, pmake, qmake, 혹은 mwmake 에 따라 달라 질 수 있습니다. 하지만 동작은 유사합니다.
결과는 컴파일러, 옵션, 컴파일되고 있는 언어에 따라 유동적이고, 소스 파일들이 로컬에 존재하는지 원격에 존재 하는지에 따라 달라 집니다. 그러나 가장 일반적인 방법은 병렬 작업의 숫자를 머신의 사용 가능한 CPU 갯수의 1.5배 정도로 조정하는 것이 보통입니다.
만약 이러한 초기 실행이 잘 먹힌다면(일반적으로 그럴 것임) 여러분은 단일 프로세스 환경에서 줄어든 빌드 타임을 보기 시작할 것입니다. 또한 복수프로세서를 가진 머신에서는 훨씬 더 단축된 결과를 볼 수 있습니다. 그러나 잘 먹히지 않는다면 다음 섹션에서의 병렬화에서 발생하는 문제점을 인식하고 수정하는 방법을 통해서 도움을 받을 수 있을 것입니다.
문제 1: 잠재적인 의존성
발생 가능한 가장 일반적인 오류는 Makefile 내에서 볼 수 있는 사용자의 부주의로 인해 명시되지 않은 의존성에 의해 발생됩니다.(Makefile의 버그) 이것은 직렬 빌드 환경에서 알아채지 못한채 진행 될 수 있지만 병렬 빌드에서는 오류를 발생 시킵니다.
예를 들어 다른 오브젝트를 스캔해서 특정한 명명 규칙에 맞는 함수들을 찾은 후에 이것을 이용해서 유저 인터페이스의 메뉴를 구현하는 모듈을 가지고 있는 어플리케이션을 생각해 봅시다. 복잡한 make 규칙은 다음과 같을 것입니다:
OBJECTS=app.o helper.o utility.o …
application: ${OBJECTS} menu.o
cc -o application ${OBJECTS} menu.o
# Automatically generate menu.c from the other modules
menu.o:
nm ${OBJECTS} | pattern_match_and_gen_code > menu.c
cc -c menu.c
여기서 미묘한 점이 있는데 직렬 질드에서는 make 가 application 의 의존성 목록에서 왼쪽에서 오른쪽으로 작업을 시작할 것입니다. 첫번째로 ${OBJECTS} 의 모든 오브젝트들을 빌드 하고 나서 menu.o 를 빌드하는 규칙을 적용할 것입니다. 이러한 직렬 환경에서는 문제가 없습니다 왜냐하면 menu.o 가 처리될때 필요한 ${OBJECTS} 의 모든 오브젝트들은 이미 제자리에 있을 것이기 떄문입니다. 그러나 병렬 빌드에서 make 는 병렬 환경에서 모든 의존성들을 처리할 수 있으므로 menu.o 의 생성과 컴파일은 모든 오브젝트 들이 준비 되기 전에 시작 될 수 있습니다.
문제가 악화 되는데 그것은 오류가 간헐적으로 발생되기 때문입니다. 왜냐하면 이것은 병렬적인 레이스 컨디션이기 때문에 어떤때에는 정상적으로 작동하고 어떤때에는 실패할 것입니다. 게으른 사용자는 Makefile 에 버그가 있다는 것을 인식하지 못한채 단순히 make 를 불신할 것입니다.
여러분도 이러한 상황을 인식 할 수 있습니다. 왜냐하면 직렬 빌드는 성공하지만 병렬빌드가 실패 했습니다.(보통 몇몇 의존성들이 생성되지 않아서 발생하는 오류임) 그러고 나서 재시도를 하면 빌드는 성공 합니다.
이러한 일을 벗어나기 위해 몇몇 엔지니어들은 “gmake;gmake” 와 같은 복잡한 명령어를 즐겨 사용하여 병렬 빌드가 성공하도록 합니다. 그러나 좀더 적절한 해결 방법은 의존성을 명시적으로 지정해서 make 에 내장된 데이타 흐름 분석이 모든 타겟을 적절한 순서대로 처리하도록 하는 것입니다. 다음의 예는 ${OBJECTS} 가 meno.o 의존성 리스트에 추가 됐습니다:
application: ${OBJECTS} menu.o
cc -o application ${OBJECTS} menu.o
menu.o: ${OBJECTS}
nm ${OBJECTS} | pattern_match_and_gen_code > menu.c
cc -c menu.c
문제 2: 임시 파일들의 재사용
또 다른 일반적인 문제점은 중간체 파일들의 재사용에서 발생될 수 있습니다. 예를 들어 yacc 를 이용해서 두개의 독립적인 파서를 생성하는 어플리케이션을 생각해 봅시다. 병렬 빌드의 이슈들을 고려하지 않은체 Makefile 작성자는 의도하지 않게 yacc 가 두가지 타겟들 모두에 동일한 중간 소스 파일들 사용하도록 합니다:
application: application.o parser1.o parser2.o
cc -o $* $<
parser1.o: parser1.y
yacc parser1.y
cc -o $* -c y.tab.c
parser2.o: parser2.y
yacc parser2.y
cc -o $* -c y.tab.c
이 문제는 기본적인 make 규칙을 이용할 때 좀 더 포착하기 어렵게 됩니다. 이러한 경우 명시적으로 보여지는 것 보다(이전 예제 처럼), 이러한 중간체 파일들은 다음과 같은 일반적인 빌드 룰을 가진 시스템 기본 파일(예를 들어 솔라리스에서는 /usr/share/lib/make/make.rules) 에 의해 참조 됩니다:
.y.o:
$(YACC.y) $<
$(COMPILE.c) -o $@ y.tab.c
$(RM) y.tab.c
직렬 빌드 상에서는 아무런 문제도 발생하지 않습니다 왜냐하면 중간 파일 y.tab.c 는 각각의 빌드 룰에 의해 사용된 후 버려지기 때문입니다. 그러나 병렬 빌드에서는 두개의 룰이 서로 충돌하게 됩니다(만약 둘이 동시에 동일한 중간 파일에 기록하려고 시도할 때).
이러한 특수한 문제는 해결이 간단합니다. 왜냐하면 yacc 가 옵션 -b 를 제공함으로써 중간 파일의 이름을 변경 할 수 있고 그러므로 각각의 실행때 중간 파일의 이름을 유일한 이름으로 변경시켜 충돌을 막을 수 있도록 합니다. 유사하게 중간 파일이 쉘 스크립트에 의해 생성되었다면 이것들은 쉽게 일반화 시킬 수 있습니다. 최악에 상황에서의 한가지 해결 방법은 하나의 타겟을 유일한 이름을 가진 서브디렉토리(즉 임시디렉토리) 안에서 빌드 하는 것입니다:
parser2.o: parser2.y
mkdir _temp; cd _temp; yacc ../parser2.y
cc -o $* -c _temp/y.tab.c
$(RM) -rf _temp
문제 3: 리소스 고갈
만약 머신이 여러분이 요청한 병렬화 숫자 보다 더 적은 숫자로 동작하도록 설정되어 있다면 여러분은 가상 혹은 물리 메모리의 부족 현상을 겪게 될 것입니다.
첫번째로 다음과 같은 메세지가 나타납니다:
Fatal error: fork failed: Not enough space
그리고 두번째로 컴파일 속도의 엄청난 저하를 보게 됩니다 (왜냐하면 컴파일러가 디스크로 페이징을 하기 때문에).
해결방법은 병렬화의 숫자를 줄이는 것입니다 (혹은 메모리의 증설).
약간 다르지만 관련이 있는 상황으로는 여러분의 병렬 빌드가 복수 유저 머신에서 제한된 공유 양 보다 더 많은 자원을 사용하는 경우 입니다. 이러한 경우 gmake 는 -l 옵션을 지원함으로써 로드 평균의 한계를 기반으로 병렬화 수준을 제한합니다.
문제 4: 직렬로 작동하는 툴
대두분의 경우에서 컴파일러 자체가 병렬 빌드를 제한할 수 있습니다. 특히 구버전의 썬 C++ 컴파일러는 템플릿 캐쉬를 접근할때 직렬적으로 동작합니다.
이 문제의 해결 방법으로는 템플릿 캐쉬의 사용을 자제하는 것입니다:
다른 템플릿 메카니즘을 사용하는 썬 스튜디오 8로 업그레이드
구버전의 컴파일러에 템플릿 캐시의 사용을 무시할 수 있는 옵션을 사용하여 작업(예를 들어 -instances=static) (C++ User’s Guide 를 참고 바람)
문제 5: 구버전의 gmake 와 NFS 사용
gmake 의 아주 오래된 구버전은 종종 NFS로 마운트된 디렉토리에서 실행되는 병렬 빌드 작업에서 오류를 발생시킵니다.(직렬 빌드에서는 정상적임) 이러한 오류는 다음과 같은 결과를 보고 합니다:
소스 파일중에 하나가 생성되지 않음:
gmake: *** No way to make target ‘some_source.c’. Stop.
gmake: *** Waiting for unfinished jobs….
stat 이 인터럽트 됨:
gmake: stat: some_source.c: Interrupted system call
그러나 ls 를 이용해 디렉토리를 직접 검사해 보면 소스파일이 존재 함을 확인 할 수 있습니다. 만약 gmake 가 재실행 되면 빌드가 좀 더 진행 될 것입니다. 결국 빌드를 완료하기 위해 여러번의 실행이 필요합니다.
이 문제를 해결하기 위해서는 gmake 의 좀 더 최신 버전, 예를 들어 3.79 (혹은 더 최신 을 사용하시기 바랍니다..
요약
이러한 최적화는 일반적으로 구현하기 쉽고 단독 프로세서 그리고 멀티 프로세서 환경에서 빌드 타임을 눈에 띄게 줄여 줍니다. 정리작업은 일반적으로 Makefile 을 복잡하게 만들지 않고 좀더 복잡한 분산 빌드를 위한 첫번째 단계로 꼭 필요 합니다.