컴파일러 최적화 (1) 상수: 상수 폴딩과 함수 인라이닝

컴파일러 최적화 (1) 상수: 상수 폴딩과 함수 인라이닝

고수준과 저수준

  • 고수준(High level): 컴퓨터 구조에서 고수준이란, 사람에게 가까운 영역을 뜻합니다.

  • 저수준(Low level): 컴퓨터 구조에서 저수준이란, 기계에게 가까운 영역을 뜻합니다. 아래 그림의 계층적인 그림에서 기반이 되는 영역, 즉 기저에 있는 영역들이 저수준이에요.

컴퓨터 구조론에서는 아래 그림처럼 기반 시스템에 가까울수록 저수준, 그 위에 돌아가는 프로그램들은 고수준으로 표현합니다.

하이레벨과 로우레벨로 표현하지만 이는 학습 난이도를 표현하는 개념이 아니라, 기반 시스템과 그 위에 쌓이는 것들의 각 계층을 구분하는 표현입니다.

저수준

  • 하드웨어: 이때 하드웨어는 반도체로 가득한 기계 덩어리를 말한다고 볼 수 있습니다.

  • 펌웨어: 그 기계에 있는 각종 소자 등의 제어를 할 수 있는 가장 기본 동작을 담은 프로그램입니다.

  • 운영체제: 하드웨어와 펌웨어 위에 올라가는 시스템 소프트웨어로, 우리가 흔히 알고 있는 윈도우, 맥, 리눅스 등이 운영체제에 해당합니다. 우리가 컴퓨터를 생각할 때 여기까지를 한 덩어리로 보는 것이고, 여기까지가 기반 시스템, 즉 플랫폼이라고 부를 수 있는 계층입니다.

고수준

  • 응용 프로그램: 메모장, 터미널, 탐색기, 크롬, 인텔리제이, VS Code, 이클립스, 카카오톡, 그림판 등 우리가 흔히 사용하는 프로그램은 대부분 응용 프로그램입니다. 시스템 소프트웨어와 구분해서 부르는 표현입니다. (소프트웨어는 시스템 소프트웨어와 응용 소프트웨어로 분류합니다.)

컴파일

컴파일(compile)은 사람이 작성한 소스 코드를 기계어처럼 컴퓨터가 바로 알 수 있는 명령으로 바꾸는 동작을 뜻하는 개념이었습니다. 이렇게 컴파일을 수행해 주는 도구가 '컴파일러(compiler)'입니다.

  • 넓은 의미: 어떤 언어를 다른 언어로 변환하는 것입니다. 요즘은 이렇게 넓은 의미로 사용합니다.

    • 예를 들어, 타입스크립트를 자바스크립트로 컴파일합니다.

    • 예를 들어, SCSS를 CSS로 컴파일합니다.

    • 보통 동수준 또는 저수준으로 변환하는 방향일 때 컴파일, 저수준에서 고수준으로 변환할 때는 디컴파일이라고 부릅니다.

  • 좁은 의미: 고급 언어를 저급 언어(또는 저수준에 가까운 언어)로 변환하는 것입니다.

    • 고급 언어(고수준 언어)는 사람이 이해하기 쉬운 언어입니다.

    • 저급 언어(저수준 언어)는 기계가 이해하기 쉬운 언어입니다. 좁은 의미로는 프로세서가 바로 이해할 수 있는 언어여야 하기 때문에, 기계어와 어셈블리어가 저급언어에 해당합니다.

따라서 우리가 프로그램을 만들고 실행하는 순서를 요약하면 다음과 같아요. (전통적인 프로세스)

  1. 사람이 프로그램 소스 코드를 작성합니다.

  2. 컴파일러가 이 소스 코드를 해석해서 기계가 알아먹을 수 있는 표현으로 바꿉니다.

  3. 운영체제가 프로그램을 작동합니다.

컴파일러의 최적화

컴파일러의 주요 기능

  • 역할 1: 컴파일러는 입력받은 프로그램(= 명령어들)의 의미를 보존하여 번역해야 합니다.

  • 역할 2: 컴파일러는 입력받은 프로그램을 실용적으로 개선합니다.

현대적인 컴파일러는 프로그래밍 언어의 번역 외에도 '최적화(optimization)' 기능을 탑재합니다.

우리가 작성한 코드(가독성 위주) → 최적화 → 최적화된 코드(성능 위주)


💡 최초의 컴파일러와 최초의 '완전한' 컴파일러

최초의 컴파일러는 1952년 그레이스 호퍼가 만든 A-0 시스템입니다. A-0 시스템의 역할은 기계어 작성을 단순화하기 위한 자동화 도구에 가까웠습니다. 사용자는 A-0 시스템에 특정 키워드로 작성된 명령어와 내장 라이브러리의 서브루틴(= 함수)을 사용하여 사람들에게 친화적인 고수준 수학 공식을 입력합니다. A-0 시스템은 입력받은 고수준 수학 공식을 머신 코드로 변환하는 자동화 도구였습니다. 단순화한 특정 명령어를 사용하고 라이브러리의 서브루틴을 호출하는 점에서 오늘날 고급 프로그래밍 언어에 영감을 준 시조격입니다.

최초의 완전한 컴파일러는 1957년 IBM의 존 베커스가 만든 포트란 컴파일러입니다. 이 컴파일러는 최적화 기능을 탑재한 완전한 컴파일러였습니다. 고급 프로그래밍 언어 중 최초로 대인기를 끌었다고 인정받는 포트란은, 그 컴파일러가 언어를 번역하는 기능뿐 아니라 최적화하는 기능을 수행함으로써 현대적인 컴파일러의 주요 역할을 충족하여 최초의 완전한 컴파일러로 봅니다.


최적화를 위한 상수 사용

💡 상수를 사용하면 일부가 컴파일타임에 해석되어 최적화됩니다.

컴파일러는 자신이 예측하기 쉬운 것들은 쉽게 최적화할 수 있습니다. 프로그래밍에서 상수 사용은 값의 불변성을 보장하기 때문에, 예측하기 쉬운 상태가 됩니다.

상수 폴딩

상수 폴딩은 값을 미리 계산할 수 있는 것들은 컴파일타임에 미리 계산해 준다는 최적화 개념입니다.

기호 상수를 사용하면 상수 전파도 적용될 수 있습니다. 기호 상수의 값을 컴파일타임에도 알 수 있을 때, 기호 상수를 리터럴 상수로 대체합니다. 이것이 상수 전파(constant propagation)입니다.

상수 전파 후 상수 폴딩으로 간단한 수식 정도는 컴파일타임에 계산해서 결과를 넣습니다.

상수 폴딩 전

public class Example {

    private static final int GAIN = 1024;

    public static final int getCalculatedValue() {
        int a = 10;
        int b = 2 * a + 1;
        return (a * b) + GAIN;
    }
}

상수 폴딩 후

컴파일타임에 GAIN을 알기 때문에 상수 전파가 되고, 식이 모두 계산되어 결과만 남깁니다. 다른 값들은 리터럴 상수를 넣은 지역변수이기 때문에 컴파일러가 미리 계산할 수 있습니다.

// 컴파일 타임 이후 원래 바이트코드 등으로 표현되지만 자바 코드로 봅시다.
public class Example {

    private static final int GAIN = 1024;

    public static final int getCalculatedValue() {
        return 1234;
    }
}

함수 인라이닝

함수를 호출하는 비용도 아낄 수 있도록 함수의 동작을 그대로 함수 호출부로 넣는 작업입니다.

예를 들어 메서드가 상수를 반환할 때는 메서드 호출 대신 그 상수를 넣을 수 있습니다.

인라이닝 전 (위 예시의 함수를 사용함)

public class Main {
    public static void main() {
        System.out.println(Example.getCalculatedValue());
    }
}

인라이닝 후

쉬운 이해를 위해 인라이닝 후 상수 폴딩을 적용했습니다.

public class Main {
    public static void main() {
        System.out.println(1234);
    }
}

상수 전파, 상수 폴딩, 함수 인라이닝의 순서

  • 일부 상수는 함수를 통해 제공되고 있을 수 있습니다. (함수 인라이닝 필요)

  • 일부 함수는 상수 폴딩을 해야 최종 결과로 상수를 반환하는 것을 확인할 수 있습니다.

이처럼 최적화 기법이 서로 종속될 수 있기 때문에, 적용 순서에 따라 최적화 스텝이 달라질 수 있습니다.

(중략 ^^)

일반적으로 함수 인라이닝을 상수 폴딩보다 먼저 수행하는 것이 좋습니다. 함수 인라이닝을 통해 앞으로 필요한 최적화 요소를 같이 발견할 수 있기 때문입니다.

따라서 오늘 나온 개념을 컴파일타임에 적용할 때, 권장하는 순서는 다음과 같습니다.

  1. 함수 인라이닝

  2. 상수 전파

  3. 상수 폴딩