LabHub
Get started
배우기 러닝패스 코스

Compilers — Build a Small Language from Start to Finish

Count and Read the Output of gcc and clang

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

gcc 의 -S 어셈블리와 clang 의 LLVM IR 을 함수 단위로 잘라 세는 도구 readasm.py 를 만들고, 그것으로 -O0-O2(-O1)의 차이 — 인라인, 상수 접기, 나눗셈의 곱셈 변환, 꼬리 재귀의 반복문 변환, mem2reg 와 φ — 를 숫자로 확인합니다.

왜 중요한가

최적화가 무엇을 했는지 모르면 성능 문제 앞에서 추측만 하고, -O2 에서만 나는 버그 앞에서 컴파일러를 의심하게 됩니다. 출력을 직접 읽으면 둘 다 확인할 수 있습니다. 채점기는 상수와 나누는 수를 바꾼 변형 C 를 채점할 때 새로 컴파일하므로, 여러분의 도구가 규칙대로 읽어야 통과합니다.

규칙

어셈블리(gcc -S)   맨 앞이 글자·_·. 인 'name:' 줄이 이름표. '.' 으로 시작하면 안쪽 이름표(.L3 등),
                   아니면 함수의 시작. 들여 쓴 줄 가운데 '.' 으로 시작하지 않는 것이 명령. # 뒤는 주석.
                   명령은 앞뒤 공백을 걷고 명령어와 피연산자 사이 공백을 하나로('movl\t$1, %eax' → 'movl $1, %eax')
functions(asm)     {함수: [명령…]} (이름표·지시어 제외) · count(asm, f) 는 그 수(endbr64 포함)
calls(asm, f)      call·callq 의 대상(나온 순서), '@PLT' 는 뗀다
returns_constant   endbr64 를 뺀 몸체가 [mov(l|q) $값, %eax|%rax ; ret] 이면 그 값, [xor %eax, %eax ; ret] 이면 0, 아니면 None
div_magic(asm, f)  f 안 첫 movabs(q) 의 즉시값을 부호 없는 64비트로 본 '0x' + 16자리 소문자 16진수, 없으면 None
back_edges(asm, f) f 안에서 앞서 나온 안쪽 이름표로 가는 j* 명령의 수
IR(clang -emit-llvm)  'define … @이름(' 부터 맨 앞의 '}' 까지. 이름표 줄('3:')·빈 줄 제외, ';' 뒤는 주석
opcode(명령)       '%5 = add nsw i64 …' → add · 'store …' → store · tail/musttail/notail 은 건너뛴다
opcode_counts      {opcode: 수} · ssa_summary(O0 IR, O1 IR, f) → {"O0": {alloca, load, store, phi}, "O1": {…}}

단계

  1. /root/mini/readasm.pyasm_lines(asm)functions(asm) 를 채웁니다. 재료를 먼저 만들어 보세요: gcc -O0 -S -fno-asynchronous-unwind-tables /opt/fixtures/mini/real/opt.c -o opt-O0.s(-O2 도).
  2. mnemonic·count·calls 를 채웁니다 — -O2 에서 square 호출이 사라지는지 보입니다.
  3. returns_constant 를 채웁니다 — sum_to·always·never 가 -O2 에서 무엇으로 접히는지.
  4. div_magic 을 채웁니다 — 채점기가 나누는 수를 바꾼 변형으로도 봅니다.
  5. back_edges 를 채웁니다 — fact 가 -O0 에서는 call, -O2 에서는 반복문.
  6. ir_functions·opcode·opcode_counts 를 채웁니다(clang -O0 -S -emit-llvm …, -O1 도).
  7. ssa_summary 를 채웁니다.
  8. /root/mini/real_report.json 에 여러분의 readasm 으로 읽은 값을 적습니다: fib_O0·fib_O2(opt.c 의 fib 명령 수), fib_mini(8모듈 코드 생성기로 만든 /opt/fixtures/mini/programs/fib.mini 어셈블리의 mini_f_fib 명령 수 — python3 mini.py asm 으로), sum_to_O2, div10_magic, fact_O2_calls(호출 수), fact_O2_back_edges, fib_O1_phi(-O1 IR 의 fib 의 phi 수).

참고

어셈블리를 함수로 자른다

줄마다 # 뒤를 걷고, 맨 앞에서 시작하는 'name:' 을 이름표로 봅니다. '.' 으로 시작하는 이름표는 지금 함수의 안쪽 이름표이고, 아니면 새 함수의 시작입니다. 들여 쓴 줄 가운데 '.' 으로 시작하지 않는 것만 명령이며, split(None, 1) 로 공백을 하나로 줄입니다.

명령 수와 호출

count 는 functions 결과의 길이입니다(endbr64 도 명령입니다). calls 는 첫 낱말이 call 또는 callq 인 명령의 대상을 모으되 '@' 뒤를 뗍니다. -O0 과 -O2 에서 sum_squares 의 호출 목록을 견줘 보세요.

상수 하나로 접힌 함수

endbr64 를 뺀 몸체가 정확히 두 명령이고 두 번째가 ret 일 때만 봅니다. 첫 명령이 mov(l|q) $값, %eax(또는 %rax) 면 그 값, xor 로 %eax 를 자기 자신과 지우면 0 입니다.

나눗셈 대신 곱셈 — 마법의 수

함수의 명령을 앞에서부터 보며 movabs(또는 movabsq) $수, %레지스터 를 찾습니다. gcc 는 부호 있는 10진수로 적으므로 2 ** 64 로 나눈 나머지를 취해 부호 없는 수로 만든 뒤 '0x%016x' 로 적습니다.

재귀가 반복문이 되었나

asm_lines 로 한 함수의 줄을 차례로 보며 지금까지 나온 안쪽 이름표를 모읍니다. j 로 시작하는 명령의 대상이 이미 나온 이름표면 뒤로 가는 점프입니다. -O2 의 fact 에서 call 이 사라지고 이 수가 생깁니다.

LLVM IR 을 함수로 자른다

'define … @이름(' 줄에서 함수를 열고, 맨 앞이 '}' 인 줄에서 닫습니다. 그 사이의 줄은 ';' 뒤를 걷고, 비었거나 '3:' 같은 이름표면 뺍니다. opcode 는 '=' 가 있으면 그 뒤 첫 낱말, tail·musttail·notail 은 건너뜁니다.

스택 칸이 φ 가 되는 것을 센다

두 IR 에서 각각 opcode_counts 를 구해 alloca·load·store·phi 네 가지만 (없으면 0) 뽑습니다. -O0 의 alloca 가 -O1 에서 0 이 되고 phi 가 생기는 것이 7모듈에서 계산한 φ 자리의 실물입니다.

gcc 와 여러분의 컴파일러를 나란히

재료를 다시 만들고(gcc -O0/-O2 -S, clang -O1 -emit-llvm, mini.py asm) 여러분의 readasm 함수로 여덟 값을 계산해 적습니다. 숫자를 손으로 옮기지 마세요 — 채점기가 같은 컴파일러로 다시 읽어 대조합니다.