Go 컴파일과정의 이해 feat. Go 성능 최적화 가이드

오랭·2025년 1월 14일
0


책 Go 성능 최적화 가이드이 4장에서는 컴파일 과정이 나온다. 실행파일이 나오기까지의 과정이 대충이라도 알고 싶어서 복잡해 보이지만 정리를 시작했다. 정리를 하다보니 책에 있는 내용과 더불어 없는 내용도 일부 추가했다. 모르는 내용이 많아 사실 내 옵시디언에서는 수많은 링크와 알아듣기 쉽도록 예제를 달아두었으나 여기서는 생략했다. 😅 내용이 너무 많다 🤮

1. 파싱 (Parsing) : 구문 분석

Go 소스 코드를 구문 분석하여 토큰 (Token) 으로 분할.
크게 문법검사, 구조분석, 코드변환의 기반이 된다.

▸ token : 소스 코드를 구성하는 가장 작은 의미 단위
예시 :

토큰유형(Token Type)설명
func키워드(Keyword)함수 정의 시작을 나타냄
add식별자(Identifier)함수 이름
(구분자(Delimiter)매개변수 목록 시작
.........

2. 추상 구문 트리 (Abstract Syntax Tree, AST) 생성

코드의 논리적 관계를 표현하며 코드 흐름의 제어 구조를 명확히 한다.
이후 AST는 컴파일러의 후속 작업에서 사용되어 최적화 작업의 기반이 된다.

▸ AST : 컴파일러가 소스 코드를 처리할 때, 코드를 이해하고 분석하기 위해 소스 코드의 문법적 구조를 트리 형태로 표현.

Go 코드

func add(a int, b int) int {
    return a + b
}

추상 구문 트리(AST)

Function (add)
├── Parameters
│   ├── a : int
│   └── b : int
├── ReturnType : int
└── Body
    └── Return
        └── Add
            ├── Variable : a
            └── Variable : b

😉 부가 작업

  1. 타입검사
    • 변수와 함수의 타입이 올바른지 확인.
    • 선언되었지만 사용되지 않은 변수를 감지.
  2. 스코프 분석 (Scope Analysis) 문법적 scope 내
    • 변수, 함수, 상수 등이 코드에서 유효한 범위(scope) 를 정의하고 관리하는 작업

3. 초기 최적화 및 이스케이프 분석 (Escape Analysis)

🏃‍♂️ 최적화 관련 작업

  1. 불필요한 코드 제거
  2. 함수 인라인(Function Inlining) 수행

🧠 메모리 관리 및 안전성

  1. 이스케이프 분석(Escape Analysis)
  2. 경계 검사(Bounds Check)
  3. 널 포인터 검사(Nil Pointer Check)

🔎 코드 분석 및 정적 검증

  1. 불변성 분석(Immutability Analysis)
    • 변수나 데이터 구조가 변경되지 않음을 컴파일 타임에 분석 실행중
    • 데이터 복사를 줄이고 최적화된 코드 생성

📌 이스케이프 분석(Escape Analysis)
컴파일러가 변수가 함수의 범위를 벗어나는지 여부를 판단하고, 이를 기반으로 스택 또는 힙에 적절히 메모리를 할당하는 과정이다.

4. 정적 단일 할당(Static Single Assignment, SSA) 변환

AST에 대한 초기 최적화 후 트리는 SSA(Static Single Assignment) 형태로 변환된다. SSA는 변수의 값을 한 번만 할당하는 방식으로, 이후의 추가적인 최적화를 쉽게 만든다.

  • 불필요한 변수 제거와 같은 최적화 작업이 여기서 추가로 수행.
  • (필요 시) [[컨트롤 플로우 그래프(Control Flow Graph, CFG)]] 생성

정적 단일 할당(Static Single Assignment, SSA)

5. 하드웨어와 무관한 최적화 규칙

코드적으로 어디서든 유효하게 적용될 소스코드의 논리적인 구조와 표현을 단순화 혹은 개선하는 작업을 수행한다.

  1. 하드웨어에 의존하지 않는 최적화 규칙 적용

    • y := 0 * x 같은 문장을 y := 0으로 단순화
    • 반복 계산 제거 또는 정적으로 계산 가능한 값을 컴파일 시간에 미리 처리
  2. 불필요한 변수 제거 및 상수 전파

    • 상수를 컴파일 시간에 계산하여 실행 시간 연산을 줄임
      • 예: x := 2 + 3을 컴파일 시점에 x := 5로 변환
  3. 루프 전개

    • 반복 횟수가 고정된 루프를 컴파일 타임에 전개하여 실행 속도를 향상
      • 예: for i := 0; i < 4; i++ → 반복문을 4개의 연속된 코드로 변환
  4. 데드 코드 제거

    • 실행되지 않는 코드와 사용되지 않는 변수 또는 함수 제거
  5. 메모리 안전성 보장

    • 이스케이프 분석
      • 변수의 메모리 할당 위치(스택 또는 힙)를 결정
        • 예: 로컬 변수는 스택에 할당되지만, 함수 외부로 전달되거나 고루틴에서 공유되면 힙에 할당
    • 스택 오버플로우 검사
      • 고루틴의 스택 크기가 초과될 가능성을 미리 검사
      • 동적 스택 확장이 필요한 코드 삽입

6. 기계어 생성

genssa 함수를 호출하여 SSA를 기반으로 기계어(ISA) 명령어로 변환한다. Go 컴파일러는 타겟 CPU 아키텍처(x86, ARM 등)와 운영체제에 적합한 명령어를 생성한다.

  • 타겟 아키텍처별 레지스터 할당 최적화
    • 컴파일러가 타겟 CPU의 레지스터 활용을 극대화하도록 명령어를 최적화.
    • 예: x86 vs ARM 아키텍처에서 최적화 전략이 다름.
  • 🧠 메모리 안전성 보장
    • 런타임 검사 삽입
      • 컴파일 중에 경계 검사, 널 포인터 참조 등 모든 메모리 안정성이 보장되지 않는 경우에 대해 런타임 검사 코드를 삽입.
      • 런타임에 가비지 컬렉터를 활성화하고 필요할 때 동작하도록 설정.

📌 genssa 함수
genssa는 SSA 형태의 중간 표현을 기반으로 최종 실행 가능한 기계어 코드를 생성하는 Go 컴파일러의 중요한 단계이다. 하드웨어와 운영체제의 특성을 반영해 최적화된 기계어를 생성하며, 프로그램 실행 성능을 결정짓는 핵심 역할을 맡고있다.

7. ISA와 운영체제에 더욱 특화된 최적화

하드웨어나 운영체제의 고유한 기능을 최대한 활용하여 성능을 높이기 위하여 타겟 하드웨어의 특성에 따라 추가 최적화를 수행한다. 타겟이 되는 CPU의 명령어 집합(Instruction Set Architecture, ISA)에 맞는 명령을 생성하는것이라고 보면 된다. 과정에서 SIMD(단일 명령 다중 데이터)와 같은 CPU 기능을 활용한다.

  • 동적 브랜치 예측 최적화
    • CPU가 자주 호출되는 브랜치(조건문)의 실행 경로를 예측.
    • 예측이 틀릴 경우, 페널티를 최소화하는 추가 최적화 적용.

동적 브랜치 예측 최적화
컴파일러가 브랜치 정보를 CPU에 제공하고, CPU가 히스토리 기반 예측과 투기적 실행으로 브랜치 비용을 줄이는 과정임. Go 컴파일러는 타겟 CPU의 특성을 활용해 이러한 최적화를 지원함.

8. 목적 파일(Object File) 생성

최종 '목적파일'은 일반적으로 파일 접미사가 .a인 Go archive라는 tar파일로 압축된다. 각 패키지에 대한 아카이브파일은 Go 링커나 다른 링커에서 단일 실행파일로 결합하는 데 사용 가능하다. 해당 아카이브 파일을 일반적으로 바이너리 파일 (binary file)이라고 한다.

목적파일에는 아래의 정보를 포함한다.

  • 디버깅 정보(DWARF)
    • 디버깅을 돕기 위해 함수 이름, 변수 정보, 라인 번호 등의 메타데이터 추가.
  • Go 전용 메타데이터 포함
    • 가비지 컬렉터 및 런타임에서 사용하는 스택 정보와 Go 특유의 함수 호출 규칙 제공.
  • 외부 심볼 테이블 생성
    • 링커가 참조할 수 있는 외부 심볼(예: 전역 함수, 변수)에 대한 정보를 포함.
  • 컴파일러 전용 힌트 추가]
    • 최적화에 필요한 컴파일러 내부 메타데이터를 목적 파일에 포함.

📌 목적 파일바이너리 파일의 한 종류이다.
1. 목적 파일(Object File):

  • 컴파일된 소스 코드의 중간 산출물. 링킹 과정을 거쳐야 실행 가능.
  • 일반적으로 .o, .obj 확장자를 가짐.
  • 특징:
    • 완전한 실행 파일이 아님.
    • 다른 목적 파일이나 라이브러리와 결합해 실행 파일 생성.
  1. 바이너리 파일(Binary File):
  • 기계가 이해할 수 있는 이진 데이터로 구성된 파일.
  • 모든 목적 파일이 바이너리 형식을 가짐.
  • 실행 파일도 바이너리 파일에 포함됨.
    • 목적 파일(.o, .obj)
    • 실행 파일(Windows: .exe, Linux: ELF)
    • 데이터 파일(이미지, 오디오 등)

9. 링킹 (Linking)을 컴파일러가 호출

목적 파일은 이미 생성된 상태(각 패키지나 모듈이 컴파일을 거쳐 독립적으로 생성된 상태)에서 Go 런타임 및 라이브러리를 포함하여 최종 실행 파일(Binary File)을 생성한다.

  • 동적 링킹 지원
    • 라이브러리를 실행 시간에 참조할 수 있도록 동적 링킹 메커니즘 포함.
  • 라이브러리 충돌 해결
    • 동일한 이름의 함수나 변수가 여러 라이브러리에 존재할 때, 우선순위를 지정하거나 충돌을 해결.

📌 Go 언어는 컴파일러와 링커가 긴밀히 통합되어 동작한다. Go의 go build 명령은 이 두 단계를 자동으로 실행하여 컴파일과 링킹이 하나의 흐름으로 작동하기때문에 대부분의 개발자는 명시적으로 링킹을 의식하지 않아도 된다. 그래서 컴파일러 중심으로 설명하다 보면 링킹이 별도로 강조되지 않을 수 있다.

  • 컴파일: 소스 코드를 목적 파일로 변환하는 단계.
  • 링킹: 목적 파일들을 결합하여 실행 가능한 바이너리를 생성하는 단계.

혹시 틀린 내용을 찾으셨다면 지적을 부탁드립니다. 🙏

profile
가보자go 꼭붙잡고

0개의 댓글