Compiler, interpreter
컴파일러 동작 방식
컴파일러 동작방식은 소스코드 -> 컴파일 -> 링킹 으로 이루어진다.
hello_world.c
#include <stdio.h> // 표준 입출력 헤더 파일 포함
int main() {
printf("hello, world\n"); // "hello, world" 출력
return 0; // 프로그램 종료
}
소스코드 분석
컴파일러는 소스 코드를 분석하여 구문(syntax)과 의미(semantics)를 검사과정을 거친다.
중간코드 생성
컴파일러가 사용하는 추상적인 코드 형태로, 구문분석이 완료되면 IR 로 코드를 변환한다. 좀더 추상화된 코드로 변환한다. 컴파일러 설계에 따라 다른데, 대표적인 LLVM IR 은 이렇다고 한다.
; ModuleID = 'hello_world.c'
source_filename = "hello_world.c"
@.str = private unnamed_addr constant [14 x i8] c"hello, world\0A\00", align 1
; Function Attrs: noinline nounwind optnone uwtable
define i32 @main() #0 {
entry:
%0 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([14 x i8], [14 x i8]* @.str, i32 0, i32 0))
ret i32 0
}
declare i32 @printf(i8*, ...) #1
최적화
불필요한 연산이 제거되고, 코드가 재구성되는 최적화 작업이 있다.
목적코드 생성
목적코드를 생성한다.
hello_world.o
.section .rodata
.LC0:
.string "hello, world\n"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf
movl $0, %eax
popq %rbp
ret
링킹
소스코드 모든 파일을 목적코드로 변환한것을, 실행파일로 합쳐주는 링킹 작업을 진행한다. 목적언어는 파일 별로 만들어주기 때문에, 링킹을 통해 한번에 실행 할 수 있는 파일을 만들어준다.
이 방식에 따라 정적링킹, 동적링킹이 달라진다.
정적 링킹 (.a, archive)
정적 링킹은 컴파일 이후 실행파일이 생성된다. 실행 파일이 모든 필요한 라이브러리를 가지고 있기 때문에, 각 프로그램 실행파일은 라이브러리의 복사본을 포함한다. C++라이브러리를 가진 프로그램 50개가 동시에 실행되면, 메모리에는 C++ 라이브러리가 50개가 들어가서, 코드 중복이 발생할 수 있다.
동적 링킹 (.so, shared object)
이런 문제를 해결하기 위해 동적 링킹이 나왔다.
동적 링킹은 실행파일을 만들 때 필요한 라이브러리를 포함하지 않는다. 대신, 라이브러리 위치와 이름에 대한 정보만 포함하여 메모리에 로드하고, 실행하면 라이브러리가 따로 메모리에 올라간다(Shared Library). 라이브러리는 시스템의 여러 프로그램에 동시에 접근하고 사용됩니다.
따라서 새로운 버전이 나왔을 때도 라이브러리만 업데이트 해주면 된다. (DLL)
인터프리터 동작 방식
인터프리터는 소스코드를 바로 해석하면서 프로그램을 실행하는 방식이다. 소스코드를 토큰화하고, 관련 코드를 바이트코드로 만든다.
Python 인터프리터 동작 방식
인터프리터는 소스코드를 바로 해석하면서 프로그램을 실행하는 방식이다.
def hello():
a = 1
b = 2
c = a+b
print(c)
소스코드를 분석 및 토큰화한다.
소스 파일을 읽고, 코드를 토큰화합니다.
예를 들어 num = 1은 num, =, 1 같은 토큰으로 분리됩니다.
a
=
1
b
=
1
...
구문 분석 및 바이트코드 생성 (컴파일)
구문이 올바르면, Python interpreter 는 이를 바이트코드로 컴파일한다. 인터프리터가 효율적으로 실행하도록 설계된 명령어 집합의 한 형태이다.
49 0 LOAD_CONST 1 (1)
2 STORE_FAST 0 (a)
50 4 LOAD_CONST 2 (2)
6 STORE_FAST 1 (b)
51 8 LOAD_FAST 0 (a)
10 LOAD_FAST 1 (b)
12 BINARY_ADD
14 STORE_FAST 2 (c)
52 16 LOAD_GLOBAL 0 (print)
18 LOAD_FAST 2 (c)
20 CALL_FUNCTION 1
22 POP_TOP
24 LOAD_CONST 0 (None)
26 RETURN_VALUE
바이트 코드 저장
.pyc라는 파일에 저장하여, 이후에는 컴파일 없이 실행이 가능하다.
프로그램 실행
프로그램 실행할 때 바이트코드가 메모리에 적재된다.
그리고 요청이 들어오면 한 줄씩 작업을 실행하는데, 그 한줄씩 읽고 CPU 가 이해할 수 있는 명령어를 CPU에 요청한다.
명령실행
그럼 CPU는 PVM 이 전달한 명령을 실행하고 결과값을 전달한다.
결과 반환
PVM을 통해 Python 으로 전달하여 스크립트 상에서 다음 단계의 명령 수행이나 결과 출력 등에 사용된다. PVM 에서는 바이트코드를 한줄한줄읽는 인터프리터 방식으로 작업을 실행한다.
그럼 파이썬은 인터프리터라 할 수 없지않나.. 결합한 느낌인데 , JVM 이랑은 어떤 차이가 있는걸까?
자바 동작 과정
컴파일 단계
compiler 가 (javac) .class 의 자바 바이트코드로 변경해준다.
실행 단계
JVM 이 .class 파일을 읽어 실행한다. 이때 바이트 코드를 한줄한줄 읽는 인터프리터 방식으로 구현이 되어있다.
JIT 컴파일러
- 자바 성능향상을 위한 JIT 컴파일러 도입
- 자주 실행되는 바이트 코드 부분을 네이티브로 컴파일 한 과정
- 관련 캐시를 저장하고, 존재하면 해당 네이티브 언어를 직접 CPU에서 실행한다.
왜 python보다 Java의 컴파일 속도가 더 느릴까?
콜드 스타트의 문제가 있다.
Java는 정적 강타입 언어이다. 변수, 표현식, 반환 값 등이 컴파일 시점에 타입이 체크되어야 한다. 따라서 컴파일 과정에서 이 일치 여부를 파악하기 때문에 Python 보다 느리다.
Python 은 동적 타입 언어여서, 타입 결정이 런타임 환경에서 이루어진다.그래서 자바가 느리고, 런타임 과정에서 PVM 이 인터프리터를 한 줄씩 실행할때 관련 환경 타입을 지정하기 때문에 런타임 성능이 느려진다.
정적 강타입 언어, 동적 타입 언어이기 때문에 Python은 인터프리터에서 하는 역할이 많아서 인터프리터 언어라고하고, Java의 경우에는 compile 과정에서 해당 작업을 마치기 때문에 가상머신을 더 사용하는 것 같다.
python 에 JIT 가 없는 이유
JIT방식은 런타임 환경에서 바이트코드를 기계어로 번역하고, 중복되는 부분이 있으면 인터프리터가 다시 번역하지 않도록 캐싱해주는 방식이다. Python 의 경우 LLVM 이슈나 그런 문제로 인해 적용되지 못했다. c,c++익스텐션을 사용하면 extention 된 기계어와 bytecode를 JIT로 만드는 것이 어렵다고만 알고 있고, 3.13 에서 copy and patch 알고리즘을 통해 현재 실험적으로 적용중이다.
-
copy-and-patch
바이트코드를 기계어로 컴파일하는 것을 템플릿의 집합으로 수행함