컴퓨터를 느리게 하는 창 [CSAPP Chap 5]

여기서 논하는 내용들은 의외로 꼼수라고 느껴질정도로 별거 아닌 기법이지만 그런 플로우들이 많이 사용 될 때 아낄 수 있는 cost는 무시하지 못한다고 생각한다. 우리는 그런 최적화를 유발하는 일이 뭐가 있는지부터 알아본다.


우리가 아무리 멋진 최적화 기법을 알고 있어도, 컴파일러가 왜 코드를 마음대로 최적화하지 못하는지 이해하는 것이 중요하다. 보통은 두 가지 원인이 있다.

  • 메모리 앨리어싱 (memory aliasing)
  • 함수 호출 (function calls)

 

메모리 앨리어싱 | Memory Aliasing

메모리 앨리어싱은 서로 다른 이름(포인터)이 사실은 동일한 메모리 위치를 가리키는 상황을 말한다.
앨리어스(alias)가 '별명'이나 '가명'이라는 뜻인 걸 생각하면 이름에서 유추하기가 괜찮다.

컴파일러는 코드를 분석할 때, 두 포인터가 서로 다른 메모리를 가리키는지, 아니면 같은 곳의 '별명'인지 확신 할 수 없다. 그래서 혹시 모를 상황에 대비해 매우 보수적으로 접근하게 된다.

예를 들어, *p1에 어떤 값을 쓰는 코드가 있다고 해보자.
만약 다른 포인터 *p2p1과 같은 주소를 가리키고 있다면, *p1의 변경은 *p2의 값에도 영향을 줄 것이다.

컴파일러는 이 가능성을 절대 무시할 수 없기 때문에,
메모리 읽기/쓰기 순서를 마음대로 바꾸거나 불필요해 보이는 연산을 생략하는 최적화를 섣불리 시도 할 수 없다.

그렇다면 컴파일러가 이런 보수적인 태도를 취해야만 하는 상황에서,
아래 코드의 두 줄은 서로 실행 순서를 바꿀 수 있을까?

// x, y는 int* 타입의 포인터라고 가정합니다.
*x = *x + *y;  // 1번 라인
*y = *x + *y;  // 2번 라인
  • 앨리어싱이 없는 경우 (x와 y가 다름):x = 1, y = 2 라고 가정해보면..
    1. x = *x + *y; -> x1 + 2 = 3이 된다.
    2. y = *x + *y; -> y3 + 2 = 5가 된다. 최종: x = 3, y = 5
  • 앨리어싱이 발생한 경우 (x와 y가 같음):x와 y가 같은 메모리를 가리키고, 그 값이 1이라고 가정해보자.
    1. x = *x + *y; -> x1 + 1 = 2가 되었다. (y2가 되었다.)
    2. y = *x + *y; -> y2 + 2 = 4가 되었다. (x4가 되었다.) 최종: x = 4, y = 4

 

함수 호출 | Function Calls

컴파일러에게 함수는 일종의 '블랙박스'와 같다.

함수를 호출하면 프로그램의 제어권이 잠시 그 함수로 넘어가는데,
컴파일러는 그 함수 안에서 무슨 일이 일어날지 예측하기 어렵다.
예를 들어 이런 일이 일어난다:

  • 함수가 전역 변수의 값을 바꿀 수도 있다.
  • (Side effect 라고 부르는) 우리가 전달한 포인터가 가리키는 메모리의 내용을 수정할 수도 있다.

이런 불확실성 때문에 컴파일러는 함수 호출 전후의 코드 순서를 마음대로 바꾸거나,
레지스터에 저장해 둔 값을 함수 호출 후에도 그대로 사용할 수 있다고 가정 할 수 없다.
그래서 함수 호출이 있으면 최적화에 매우 소극적으로 변한다.

그렇다면 아래와 같은 코드에서,
컴파일러는 왜 strlen(s)를 루프 시작 전에 한 번만 계산하는 최적화를 섣불리 적용하기 어려울까?

for (int i = 0; i < strlen(s); i++) {
    lower_case(s);
}

컴파일러 입장에서는 함수가 '블랙박스'라고 했다.
컴파일러는 이 함수가 구체적으로 무슨 일을 하는지 들여다보지 않는다. 그냥 '그런 함수가 있다' 고만 인지할 뿐이다.

void lower_case(char *s) {
    // ... 다른 문자들을 소문자로 바꾸는 코드 ...

    // 그런데 만약 문자열의 중간을 잘라버리는 코드가 있다면?
    if (some_condition) {
        s[5] = '\0'; // 문자열의 5번째 인덱스 뒤를 잘라버린다.
    }
}

만약 이런 코드가 lower_case 함수 내부에 숨어있다면, 루프가 돌 때마다 s의 길이가 바뀔 여지가 생긴 것이다.
strlen(s)의 결과값이 계속 달라지는 것이다.

컴파일러는 lower_case 함수가 이런 ‘흉악한 짓’을 하지 않을 것이라고 확신 할 수 없다.

따라서 안전을 위해 매번 루프의 조건을 확인할 때마다 strlen(s)을 다시 호출해서 길이를 새로 계산하는, 비효율적이지만 안전한 방법을 택한다.