15. Performance

Takeaway D. 성급한 최적화는 만악의 근원이다.

C의 빠른 성능은 안전성이란 댓가를 치르는데, C는 여러 군데에서 검사를 하는 대신 그를 검증하는 것을 프로그래머에게 떠넘기기 때문이다. 중요한 예는

  • 배열의 경계를 벗어난 범위에 대한 접근
  • 초기화되지 않은 오브젝트에 대한 접근
  • 생애 주기가 끝난 오브젝트에 대한 접근
  • 정수 오버플로우

이는 프로그램의 강제 종료, 데이터의 손실, 잘못된 결과, 중요 정보의 노출, 돈과 인명의 손실을 낳을 수 있다.

Takeaway 3.15.0.1. 안전성과 성능을 맞바꾸지 말라.

최근 C 컴파일러는 많이 좋아졌지만 모든 에러를 다 잡아주지는 못한다. 컴파일러가 잡을 수 없는 에러들은 다음과 같이 대부분 방지할 수 있다.

  • 모든 블록 범위 변수들은 초기화되어야 한다.
  • 동적 할당은 malloc이 아닌 calloc으로 되어야 한다.
  • 동적으로 할당되는 자료 구조의 경우 초기화 함수를 구현하여야 한다.
  • 포인터를 받는 함수는 배열 문법을 써야 한다. 단일 오브젝트에 대해서라면 static 1, 개수를 아는 오브젝트의 모음에 대해서라면 static N, 개수를 모르는 오브젝트의 모음에 대해서라면 가변 길이 배열, 단일 오브젝트일 수 있거나 널 포인터일 수 있는 오브젝트에 대해서라면 포인터 표현.
  • 블록 범위 지역 변수에 대한 주소를 취하면 안 된다. register로 선언함으로써 이를 방지할 수 있다.
  • 루프 변수에 부호 없는 정수형을 사용하고, 범위를 벗어남으로써 발생하는 순환에 대해서는 명시적으로 처리하라.

Takeaway 3.15.0.2. 컴파일러의 최적화는 사용되지 않은 변수 초기화들을 제거한다.

Takeaway 3.15.0.3. 함수의 포인터 인자에 대해 서로 다른 표현을 쓰더라도 바이너리 코드는 같게 나온다.

Takeaway 3.15.0.4. 지역 변수의 주소를 취하지 않는 것은 에일리어싱을 막으므로 컴파일러의 최적화를 돕는다.

이런 룰들로 안전한 구현을 만들고 난 뒤에야 성능 이야기를 할 수 있는 것이다. 프로그래머는 컴파일러의 최적화를 돕기 위한 키워드를 C 프로그램에서 쓸 수 있는데, 이 키워드들은 있던 없던 동작상으로는 차이가 없다는 특징이 있다. 이런 키워드들에는 register, inline, restrict, alignas가 있다.

register는 주소를 취할 수 없게 함으로써 에일리어싱을 막는다.

alignas는 오브젝트를 캐시 경계에 위치시켜 메모리 접근을 개선시킨다.

inline은 짧은 함수가 호출부에 직접적으로 삽입될 수 있도록 돕는다.

restrict는 타입 기반 에일리어싱을 완화시켜 최적화를 돕는데, 잘못 쓰면 성능을 오히려 떨어트린다.

15.1. Inline functions.

모듈화된 코드를 함수로 짜는 것에는 다음과 같은 이점이 있다.

  • 구현과 인터페이스를 깔끔하게 분리한다.
  • 전역 변수를 쓰지 않는다면, 함수가 접근하는 추상 상태는 지역적이므로 호출시 받는 매개변수와 지역 변수에만 의존한다. 따라서 컴파일러가 최적화 기회를 더 많이 잡을 수 있다.

그러나 다음과 같은 단점도 있다.

  • 함수가 호출될 때 스택 프레임에 함수가 잡히고 지역 변수가 초기화되거나 복사되므로 오버헤드가 생긴다. 제어 흐름이 점프하므로 캐시 무효화가 일어날 수 있다.
  • 반환값이 struct일 경우 복사될 수 있다.

함수의 호출부와 호출된 함수가 같은 번역 단위 내에 존재한다면 좋은 컴파일러는 호출된 함수를 인라이닝해서, 즉 함수의 코드를 해당 함수 코드에 대한 직접적 실행으로 대체함으로써 이 단점을 막을 수 있다. 이렇게 되면 함수 호출에 대한 오버헤드가 없어지기 때문이다. 또한, 이럴 경우 인라이닝된 코드는 호출부 내에서 보여지므로, 절대 실행되지 않는 제어 부분을 제거할 수도 있고, 결과를 이미 아는 표현식에 대한 계산을 건너뛸 수도 있고, 함수의 반환 타입을 제한할 수도 있다.

Takeaway 3.15.1.1. 인라이닝은 많은 최적화 기회를 연다.

전통적인 C 컴파일러는 정의부를 아는 함수만 인라이닝할 수 있다. 선언부를 아는 것만으로는 불충분하다. 인라이닝을 위해 프로그래머들은 여러 전략을 고심해왔다:

  • 프로젝트의 모든 코드를 하나의 큰 파일에 이어붙여 거대한 단일 번역 단위로 만든다. 이름 충돌과 정의문 사이클을 신경써야 한다는 단점이 있다.
  • 인라이닝되어야 할 함수를 헤더 파일에 두고 그 함수가 필요한 모든 번역 단위에서 해당 헤더를 인클루드시킨다. 각각 번역 단위에서 함수 정의가 중복으로 되는 것을 막기 위해, 해당하는 함수들은 static으로 정의되어야 한다.

첫 번째 방법은 큰 프로젝트에서는 가능하지 않다. 두 번째 방법은 가능하지만, 단점이 있다.

  • 함수가 인라이닝하기에 지나치게 크면 각각의 번역 단위 내에서 따로따로 초기화되어, 최종 실행파일 내에서 중복 코드가 많아지고 실행파일의 크기가 커진다.
  • 서로 다른 번역 단위에서 각각 해당 함수의 포인터를 취할 때 함수 포인터의 주소값이 다르게 나온다.
  • 헤더 내에 정의된 static 함수가 번역 단위 내에서 사용되지 않으면 컴파일러는 사용되지 않은 함수에 대해 경고하는데, 작은 함수를 static으로 헤더에 많이 만들어 놓으면 많은 불필요한 경고가 생긴다.

이 단점을 피하기 위해, C99에서는 inline 키워드를 도입하였다. inline 키워드는 함수의 인라이닝을 강제하지 않는다. 다만 인라이닝될 수 있도록 도울 뿐이다.

  • inline으로 선언된 함수는 다중 심볼 정의 에러 없이 여러 번역 단위에서 사용될 수 있다.
  • inline으로 선언된 함수는 서로 다른 번역 단위에서 각각 해당 함수의 포인터를 취해도 함수 포인터의 주소값이 일치한다.
  • inline으로 선언된 함수가 번역 단위 내에서 사용되지 않으면 컴파일러는 이를 완전히 제거해 코드 크기를 늘리지 않는다.

맨 마지막 룰은 일반적으로 이득이지만 함수의 심볼이 아예 생성되지 않는다는 단점이 있다. 함수의 심볼이 필요할 때가 있는데,

  • 프로그램이 함수의 포인터를 사용하거나 저장할 경우
  • 컴파일러가 보기에 함수가 인라이닝하기엔 너무 크거나 복잡할 경우 (이 기준은 최적화 레벨, 디버그 모드 여부, 함수 내에서의 C 라이브러리 함수 사용 여부에 따라 갈린다)
  • 함수가 라이브러리 내에 포함되어 알 수 없는 프로그램 내에서 링킹될 경우

이에 대처하기 위해, inline 키워드에 대해서는 특별한 룰이 있다.

Takeaway 3.15.1.2. inline 키워드가 없는 선언부를 만드는 것은 현재 번역 단위 내에서 함수 심볼이 생성됨을 보장한다.

Takeaway 3.15.1.3. inline 함수의 정의부는 모든 번역 단위에서 접근될 수 있다.

inline 함수는 해당 함수에 접근 가능한 모든 번역 단위 내에서 인라이닝될 수 있지만, 그 번역 단위들 중 어느 것도 함수 심볼의 생성을 보장하진 않는다. 이를 위해, 단 하나의 번역 단위를 정해서 해당 함수의 선언부를 inline 없이 만들어줘야 한다.

Takeaway 3.15.1.4. inline 함수의 정의부는 헤더 파일에 위치해야 한다.

Takeaway 3.15.1.5. inline 함수의 경우, inline 없는 별도의 선언문을 정확히 1개의 번역 단위 내에 선언한다.

inline 함수의 메커니즘은 컴파일러가 해당 함수를 인라이닝할지를 돕는 데 있다. 컴파일러가 쓰는 휴리스틱들은 대개 매우 뛰어나므로 걱정하지 않아도 된다. inline에 쓰일 수 있는 좋은 후보는 순수 함수들이다. 이들을 헤더 내에 inline으로 선언하면 컴파일러 최적화에 의해 코드 복제가 억제된다. 하지만 inline 함수를 변경하면 프로젝트와 그 사용 프로그램이 전부 재컴파일되어야 한다는 단점도 있다.

Takeaway 3.15.1.6. 변동이 잦지 않고 안정적인 함수만 inline으로 선언하라.

inline 함수는 전역 가시성을 가지므로, 함수 내의 매개변수와 지역 변수들이 우리가 모르는 매크로에 의해 치환되어버릴 수 있다.

Takeaway 3.15.1.7. inline 함수 내의 지역 변수는 별도의 명명법으로 보호해야 한다.

inline 함수는 해당 함수가 속하는 번역 단위가 존재하지 않는다.

Takeaway 3.15.1.8. inline 함수는 static 함수의 식별자에 접근할 수 없다.

Takeaway 3.15.1.9. inline 함수는 수정가능한 static 오브젝트의 식별자에 정의하거나 접근할 수 없다.

중요한 점은, 이 제한은 식별자에만 해당된다는 것이다. static 오브젝트나 함수의 포인터를 inline 함수에 전달해 주는 것은 문제가 없다.

15.2. Using restrict qualifiers.

포인터에 대한 restrict 키워드의 기본 아이디어는 해당 포인터가 가리키는 오브젝트를 가리키는 포인터는 그 포인터 하나뿐이라는 것이다. 따라서 컴파일러는 오브젝트에 대한 변경 시도가 오로지 해당 포인터를 통해서만 된다고 가정할 수 있다. 다시 말해, restrict 키워드는 해당 스코프 내에서 오브젝트가 에일리어싱되지 않는다는 것을 컴파일러에게 알린다.

Takeaway 3.15.2.1. restrict 한정자가 붙은 포인터는 배타적 접근을 제공해야 한다.

이 특성은 호출자 쪽에서 보장해야 한다.

Takeaway 3.15.2.2. restrict 한정자는 함수의 호출자를 제한시킨다.

memcpy와 memmove의 차이점을 보자.

void* memcpy(void* restrict s1, void const* restrict s2, size_t n);
void* memmove(void* s1, const void* s2, size_t n);

memcpy는 두 포인터가 restrict 조건이 걸려 있으므로 두 포인터를 통한 접근이 상호 배타적이어야 하고 두 포인터가 가리키는 오브젝트가 겹치면 안 된다. 이 가정은 컴파일러의 최적화를 돕는다. 반면에 memmove의 경우 s1과 s2의 값이 같을 수도 있고 오브젝트가 겹칠 수도 있으며 구현은 이 상황에 대처해야 한다. 이는 더 비효율적일 수는 있지만 더 일반적이게 된다.

포인터 에일리어싱은 서로 다른 기반 타입일 경우 일어나지 않는다는 가정을 두는데, 한 쪽이 문자 타입이면 예외를 적용받는다. 그래서 fputs나 printf는 에일리어싱을 막기 위해 restrict 한정자를 둔다.

int fputs(const char* restrict s, FILE* restrict stream);
int printf(const char* restrict format, ...);
int fprintf(FILE* restrict stream, const char* restrict format, ...);

따라서 다음 코드는 정의되지 않은 행동이다.

char const* format = "format printing itself: %s\n";
printf(format, format); // restrict violation

char const* format2 = "First two bytes in stdin object: %.2s\n";
char const* bytes = (char*) stdin // legal cast to char
fprintf(stdin, format, bytes) // restrict violation

이런 코드를 실제로 쓰는 이는 별로 없겠지만 문자 타입은 에일리어싱에 대한 특별한 룰을 적용받기 때문에 문자열 처리 함수의 최적화를 위해서는 restrict 한정자가 필요할 수 있다는 것은 기억할 필요가 있다.

15.3. Measurement and inspection.

Takeaway E. 코드의 성능을 추정하지 말라. 엄밀하게 검증하여라.

Takeaway 3.15.3.1. 알고리즘의 복잡도는 증명이 필요하다.

Takeaway 3.15.3.2. 코드의 성능 평가는 측정을 필요로 한다.

Takeaway 3.15.3.3. 모든 측정은 그 자체만으로 편향을 일으킨다.

예를 들어 시간을 측정하느라 호출하는 timespec_get의 경우 이 호출을 추가하는 것만으로도 최적화 기회를 잃어버리게 할 수 있고 시스템 호출을 함으로써 프로세스나 태스크 스케쥴링에 영향을 주고 데이터 캐시도 무효화시킬 수 있다.

Takeaway 3.15.3.4. 측정은 컴파일 타임과 런타임 특성을 변화시킨다.

Takeaway 3.15.3.5. 런타임의 상대표준편차는 낮아야 한다.

Takeaway 3.15.3.6. 분산이나 비대칭도를 계산할 때 고차 모멘트를 이용하는 것이 간단하고 빠르다.

Takeaway 3.15.3.7. 런타임 측정은 통계적으로 뒷받침되어야 한다.

요점 정리

  • 성능을 안전성과 맞바꾸지 말라.
  • inline은 작은 순수 함수를 제자리에서 최적화하는 좋은 도구이다.
  • restrict는 함수 인자의 에일리어싱을 대처하는 데 도움을 준다. 이는 조심해서 써야 하는데, 컴파일 타임에 강제할 수 없는 조건을 함수 호출자에 요구하기 때문이다.
  • 성능 향상 주장은 충분한 통계적 측정으로 뒷받침되어야 한다.

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Google photo

Google의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중