- AST 직접 순회 인터프리터도 값 표현, 인라인 캐시, 객체 모델, watchpoint, 반복적인 세부 최적화만으로 큰 성능 향상 가능
- 성능을 거의 고려하지 않은 Zef 베이스라인은 CPython 3.10보다 35배, Lua 5.4.7보다 80배, QuickJS-ng 0.14.0보다 23배 느렸지만, 21단계 최적화 뒤 16.646배 가속 달성
- 가장 큰 도약은 객체 모델 재설계와 인라인 캐시 결합에서 발생했고, Storage와 Offsets 기반 접근, 캐시된 AST 특수화, 이름 오버라이드 감시용 watchpoint 적용으로 4.55배 향상 이어짐
- 추가 개선으로 문자열 기반 디스패치 제거, Symbol 도입, 인자 전달 구조 변경, getter와 setter 특수화, 해시테이블 단축 경로, 배열 리터럴과
sqrt·toString특수화까지 누적 적용 - Yolo-C++ 이식까지 포함하면 베이스라인 대비 66.962배 빠름 기록, CPython 3.10보다 1.889배 빠르고 QuickJS-ng 0.14.0보다 2.968배 빠르지만 메모리 해제가 없어 장기 실행 워크로드에는 부적합함
도입과 평가 방법론
- 최적화 대상은 AST 직접 순회 인터프리터이며, 재미로 만든 동적 언어 Zef를 Lua, QuickJS, CPython과 경쟁 가능한 수준까지 끌어올리는 목표
- JIT 컴파일러나 성숙한 GC의 미세 조정보다, 기초가 없는 출발점에서도 적용 가능한 최적화에 집중
- 다루는 기법은 값 표현, 인라인 캐싱, 객체 모델, watchpoint, 상식적인 최적화의 반복 적용
- 본문 기법만으로 SSA, GC, 바이트코드, 머신 코드 없이도 큰 성능 향상 달성
- 본문 기준 16배 가속
- 미완성 Yolo-C++ 이식 포함 시 67배 가속
- 성능 평가는 ScriptBench1 벤치마크 스위트 사용
- 포함 벤치마크는 Richards OS scheduler, DeltaBlue constraint solver, N-Body physics simulation, Splay binary tree test
- JavaScript, Python, Lua용 기존 포트 사용
- Splay의 Python과 Lua 포트는 Claude로 생성
- 실험 환경은 Ubuntu 22.04.5, Intel Core Ultra 5 135U, 32GB RAM, Fil-C++ 0.677
- Lua 5.4.7은 GCC 11.4.0으로 컴파일
- QuickJS-ng 0.14.0은 GitHub releases 바이너리 사용
- CPython 3.10은 Ubuntu 기본 제공 버전 사용
- 모든 실험은 무작위로 섞은 30회 실행 평균값 사용
- 대부분 비교는 Fil-C++로 컴파일한 Zef 인터프리터와 Yolo-C 컴파일러로 빌드한 다른 인터프리터들 사이에서 수행
원래 Zef 인터프리터
- 성능을 거의 고려하지 않고 작성됐으며, 성능을 의식한 선택은 두 가지만 있었다고 명시
-
값 표현
- 64비트 tagged value 사용
- 담을 수 있는 값은 double, 32비트 정수,
Object*
- 담을 수 있는 값은 double, 32비트 정수,
- double은
0x1000000000000오프셋 방식으로 표현- JavaScriptCore에서 배운 기법으로 소개
- 문헌에서는 NuN tagging으로 지칭
- 정수와 포인터는 네이티브 표현 사용
- 포인터 값이
0x100000000보다 작지 않다는 가정에 의존 - 위험한 선택이라고 직접 명시
- 대안으로 정수에
0xffff000000000000상위 비트 태그를 둘 수 있었다고 언급
- 포인터 값이
- 이 표현으로 숫자 연산에서 비트 테스트 기반 빠른 경로 구현 가능
- 더 중요한 이점은 숫자에 대한 힙 할당 회피
- 인터프리터를 새로 만들 때는 기본 값 표현을 초기에 잘 선택하는 일이 중요하며, 이후 변경은 매우 어려움
- 동적 타입 언어 구현 출발점으로 32비트 또는 64비트 tagged value 제시
- 64비트 tagged value 사용
-
구현 언어 선택
- 최적화를 충분히 담아낼 수 있는 언어로 C++ 계열 선택
- Java는 저수준 최적화 상한 때문에 선택하지 않겠다고 명시
- Rust는 GC 언어 구현에 필요한 전역 가변 상태와 순환 참조를 갖는 힙 표현 때문에 선택하지 않겠다고 밝힘
- 다중 언어 구성을 감수하거나 많은
unsafe코드를 허용하면 일부 또는 전체에 Rust 사용 가능성 언급
- 다중 언어 구성을 감수하거나 많은
-
성능 엔지니어링 관점의 잘못된 선택
- Fil-C++ 사용
- 빠르게 개발 가능했고 GC를 공짜로 제공
- 메모리 안전성 위반을 진단 정보와 스택 트레이스로 보고
- 정의되지 않은 동작이 없음
- 성능 비용은 보통 약 4배
- 재귀적 AST 워킹 인터프리터
- 여러 곳에서 오버라이드되는 virtual
Node::evaluate메서드 구조
- 여러 곳에서 오버라이드되는 virtual
- 문자열 남용
GetAST 노드가 변수 이름을 설명하는std::string저장- 변수 접근마다 그 문자열 사용
- 해시테이블 남용
Get실행 시 문자열 키로std::unordered_map조회
- 재귀 호출 체인 기반 스코프 탐색
- 거의 모든 구조의 중첩과 클로저 허용
- 함수 F 안의 클래스 A, 클래스 B 안의 함수 G 같은 중첩에서 A의 메서드는 A 필드, F 지역 변수, B 필드, G 지역 변수를 볼 수 있음
- 원래 구현은 이를 서로 다른 스코프 객체를 질의하는 C++ 재귀 함수들로 처리
- Fil-C++ 사용
-
원래 구현의 특성
- 잘못된 선택에도 불구하고 적은 코드로 꽤 복잡한 언어 인터프리터 구현 가능
- 가장 큰 모듈은 parser
- 나머지는 단순하고 명확한 편
-
초기 성능
- 원래 인터프리터는 CPython 3.10보다 35배 느림
-
Lua 5.4.7보다 80배 느림
- QuickJS-ng 0.14.0보다 23배 느림
전체 최적화 진행표
- 표는 Zef Baseline부터 Zef Change #21: No Asserts, 그리고 Zef in Yolo-C++ 까지 성능 변화를 정리
- 비교 열은 vs Zef Baseline, vs Python 3.10, vs Lua 5.4.7, vs QuickJS-ng 0.14.0
- 최종 행 기준 Zef Change #21: No Asserts는 베이스라인 대비 16.646배 빠름
-
Python 3.10보다 2.13배 느림
-
Lua 5.4.7보다 4.781배 느림
- QuickJS-ng 0.14.0보다 1.355배 느림
-
-
Zef in Yolo-C++는 베이스라인 대비66.962배 빠름
-
Python 3.10보다 1.889배 빠름
-
Lua 5.4.7보다 1.189배 느림
- QuickJS-ng 0.14.0보다 2.968배 빠름
-
초기 최적화 단계
-
최적화 #1: 연산자 직접 호출
- 파서가 더 이상 연산자를 연산자 이름을 가진
DotCall노드로 만들지 않고, 각 연산자별 별도 AST 노드 생성 - Zef에서는
a + b와a.add(b)가 동일- 원래는
a + b를DotCall(a, "add")와 인자b로 파싱 - 모든 산술 연산마다 연산자 메서드 이름 문자열 조회 발생
DotCall은 문자열을Value::callMethod로 전달Value::callMethod는 다중 문자열 비교 수행
- 원래는
- 변경 후 파서는
Binary<>,Unary<>노드 생성- 템플릿과 람다를 활용해 연산자별 서로 다른
Node::evaluate오버라이드 제공 - 각 노드는 해당 연산자의
Value빠른 경로 직접 호출 - 예시로
a + b는Binary<lambda for add>::evaluate호출 뒤Value::add호출
- 템플릿과 람다를 활용해 연산자별 서로 다른
- 성능 효과는 17.5% 향상
- 이 시점 성능은 CPython 3.10보다 30배 느림
- Lua 5.4.7보다 67배 느림
- QuickJS-ng 0.14.0보다 19배 느림
- 파서가 더 이상 연산자를 연산자 이름을 가진
-
최적화 #2: RMW 연산자 직접 호출
- 일반 연산자는 빨라졌지만,
a += b같은 RMW 형태는 여전히 문자열 기반 디스패치 사용 - 파서가 각 RMW 케이스용 별도 노드를 생성하도록 변경
- 파서는 LValue 노드가
makeRMW가상 호출을 통해 자신을 RMW로 대체하도록 요청 - RMW로 바뀌는 LValue는 Get, Dot, Subscript
- Get은 변수 읽기
id대응 - Dot은
expr.id대응 - Subscript는
expr[index]대응
- Get은 변수 읽기
- 각 가상 호출은
SPECIALIZE_NEW_RMW매크로 사용- SetRMW는
id += value - DotSetRMW는
expr.id += value - SubscriptRMW는
expr[index] += value
- SetRMW는
- 변경 #1의 연산자 특수화는 람다 디스패치 사용
- RMW는 enum 사용
get,dot,subscript세 경로를 모두 처리하며 enum을 여러 위치에 전달해야 해서 선택- 최종적으로
Value::callRMW<>템플릿 함수가 실제 RMW 연산자 호출 디스패치 수행
- 성능 효과는 3.7% 향상
- 이 시점 성능은 CPython 3.10보다 29배 느림
- Lua 5.4.7보다 65배 느림
- QuickJS-ng 0.14.0보다 18.5배 느림
- 시작점 대비 1.22배 빠름
- 일반 연산자는 빨라졌지만,
-
최적화 #3: IntObject 검사 회피
Value의 빠른 경로가isInt()를 사용하고, 그 내부isIntSlow()가Object::isInt()가상 호출을 수행하는 점이 병목- 원래 값 표현에는 네 가지 경우 존재
- tagged int32
- tagged double
- int32로 표현 불가능한 int64용 IntObject
- 그 외 모든 객체
- IntObject 경우에도 정수 메서드 디스패치를
Value가 담당- 모든 산술 연산 구현을 한 곳, 즉
Value에 두기 위함
- 모든 산술 연산 구현을 한 곳, 즉
- 최적화 후
Value빠른 경로는 int32와 double만 고려- IntObject 처리 로직은
IntObject자신으로 이동 - 메서드 디스패치마다 발생하던
isInt()호출 회피
- IntObject 처리 로직은
- 성능 효과는 1% 향상
- 이 시점 성능은 CPython 3.10보다 29배 느림
- Lua 5.4.7보다 65배 느림
- QuickJS-ng 0.14.0보다 18배 느림
- 시작점 대비 1.23배 빠름
-
최적화 #4: Symbol
- 원래 인터프리터는
std::string을 거의 모든 곳에 사용 - 비용이 큰 문자열 사용 위치는
Context::get,Context::set,Context::callFunction,Value::callMethod,Value::dot,Value::setDot,Value::callOperator<>,Object::callMethod계열 - 이런 구조에서는 단순 해시테이블 조회가 아니라 문자열 키 해시테이블 조회가 되어 실행 중 문자열 해싱과 비교 반복
- 최적화는 문자열 기반 조회를 hash-consed
Symbol객체 포인터로 대체 - 새
Symbol클래스 추가symbol.h,symbol.cpp에 구현- Symbol과 문자열은 상호 변환 가능
- 문자열을 Symbol로 바꿀 때 전역 해시테이블로 hash consing 수행
- 결과적으로
Symbol*포인터 동일성 비교만으로 같은 심볼 여부 판별
- 문자열 리터럴 대신 미리 준비된 심볼 사용
- 예시로
"subscript"대신Symbol::subscript
- 예시로
- 함수 시그니처 다수가
const std::string&대신Symbol*사용으로 변경 - 성능 효과는 18% 향상
- 이 시점 성능은 CPython 3.10보다 24배 느림
- Lua 5.4.7보다 54배 느림
- QuickJS-ng 0.14.0보다 15배 느림
- 시작점 대비 1.46배 빠름
- 원래 인터프리터는
-
최적화 #5: Value 인라인화
- 핵심은 중요한 함수들의 인라인화 허용
- 거의 모든 변경의 중심은 새 헤더
valueinlines.h도입 value.h와 별도 헤더로 분리한 이유는,value.h를 포함해야 하는 헤더들을 사용하기 때문- 성능 효과는 2.8% 향상
- 이 시점 성능은 CPython 3.10보다 24배 느림
- Lua 5.4.7보다 53배 느림
- QuickJS-ng 0.14.0보다 15배 느림
- 시작점 대비 1.5배 빠름
객체 모델과 캐시 구조 재설계
-
최적화 #6: 객체 모델, 인라인 캐시, Watchpoint
Object,ClassObject,Context작동 방식을 대규모 재구성해 객체 할당 비용을 낮추고 접근 시 해시테이블 조회 회피- 이 변경은 객체 모델, 인라인 캐시, watchpoint 세 기능 결합
-
객체 모델
- 이전에는 각 렉시컬 스코프마다
Context객체 할당- 각
Context는 그 스코프 변수들을 담는 해시테이블 보유
- 각
- 객체는 더 복잡한 구조
- 각 객체가 자신이 인스턴스인 클래스들을
Context에 매핑하는 해시테이블 보유
- 각 객체가 자신이 인스턴스인 클래스들을
- 이런 구조가 필요했던 이유는 상속과 중첩 스코프 때문
Bar가Foo를 상속할 때Bar와Foo가 서로 다른 스코프를 클로즈오버- 같은 이름의 서로 다른 private 필드도 가질 수 있음
- 새 구조는
Storage개념 도입- 데이터는 Offsets에 따라 저장
- offset은 어떤
Context가 결정
Context는 여전히 존재하지만 객체나 스코프 생성 시점이 아니라 AST의resolve패스에서 미리 생성- 실제 객체나 스코프 생성 시에는 해당
Context가 계산한 크기에 맞춰 Storage만 할당
- 이전에는 각 렉시컬 스코프마다
-
인라인 캐시
expr.name같은 코드 위치에서 마지막으로 본expr의 동적 타입과name이 해석된 마지막 offset을 기억하는 기법- JIT 문맥에서 주로 설명되는 고전적 기법이지만, 여기서는 인터프리터에 적용
- 기억한 정보는 일반 AST 노드 위에 특수화된 AST 노드를 placement construct하는 방식으로 구현
-
인라인 캐시 구성 요소
CacheRecipe- 특정 접근이 무엇을 했는지, 캐시 가능한지 추적
Context,ClassObject,Package곳곳에CacheRecipe호출 삽입- 접근 과정의 정보 수집
Dot::evaluate같은 AST 평가 함수는 자신이 수행한 다형적 연산에서 얻은CacheRecipe를this와 함께constructCache<>에 전달constructCacheCacheRecipe에 따라 새 AST 노드 특수화 컴파일- 템플릿 기계로 다양한 특수화 AST 노드 생성
- 지역 변수 접근이면 전달받은
storage에 대한 직접 로드 - 마지막으로 본 클래스와 동일한지 확인하는 class check
- 그 뒤 마지막으로 본 함수에 대한 직접 함수 호출
- 필요하면 chain step과 watchpoint 조합
- 캐싱 대상 AST 노드는 각각 cached variant 보유
- 먼저
cache객체로 빠른 호출 시도 cache객체 타입은constructCache<>가 결정
- 먼저
-
watchpoint
- 렉시컬 스코프에 변수
x가 있고 그 안에 클래스Foo가 있으며,Foo메서드가x에 접근하는 예시 제시 Foo내부에x라는 함수나 변수가 없다면 바로 바깥x를 읽을 수 있을 것처럼 보임- 그러나 서브클래스가 getter
x를 추가할 수 있음 - 그 경우 접근 결과는 바깥
x가 아니라 getter가 되어야 함 - 인라인 캐시는 이런 변경 가능성을 처리하기 위해 런타임에
Watchpoint설정 - 예시에서는 이 이름이 오버라이드되었는지 감시하는 watchpoint 사용
- 렉시컬 스코프에 변수
-
세 기능을 동시에 구현한 이유
- 새 객체 모델만으로는 인라인 캐시가 잘 작동하지 않으면 의미 있는 개선이 어려움
- 인라인 캐시도 watchpoint가 없으면 많은 캐시 조건을 안전하게 다루기 어려워 실익이 작음
- 새 객체 모델과 watchpoint는 함께 잘 작동해야 함
-
구현 진행과 어려운 부분
- 시작은 단순한
CacheRecipe버전 작성과, 최종 형태에 가까운 Storage, Offsets 설계부터 진행 - 가장 어려운 작업 중 하나는 intrinsic class 구현 방식 교체
- 배열 예시
- 이전에는
ArrayObject::tryCallMethod가Object::tryCallMethod가상 호출을 가로채는 방식으로 모든 메서드 구현 - 새 객체 모델에서는
Object에 vtable도 없고 가상 메서드도 없음 - 대신
Object::tryCallMethod는object->classObject()->tryCallMethod(object, ...)로 위임 - 따라서
Array메서드를 제공하려면 그 메서드를 가진 Array용 클래스 자체 생성 필요
- 이전에는
- 결과적으로 intrinsic 기능 상당 부분이 구현 전역에 흩어져 있던 구조에서
makerootcontext.cpp중심으로 이동 - 긍정적인 결과로 평가한 이유는 객체의 native/intrinsic 함수에도 인라인 캐시가 그대로 적용되기 때문
- 성능 효과는 4.55배 향상
- 이 시점 성능은 CPython 3.10보다 5.2배 느림
- Lua 5.4.7보다 11.7배 느림
- QuickJS-ng 0.14.0보다 3.3배 느림
- 시작점 대비 6.8배 빠름
- Fil-C++의 손실 폭이 다른 인터프리터 대비 대체로 Fil-C 비용 수준까지 좁혀졌다고 평가
- 시작은 단순한
호출과 접근 경로 최적화
-
최적화 #7: 인자 전달 구조 개선
- 변경 전 Zef 인터프리터는 함수 인자를
const std::optional<std::vector<Value>>&로 전달 optional이 필요했던 이유는 일부 모서리 케이스에서 다음 둘을 구분해야 했기 때문o.gettero.function()
- Zef에서는 대체로 둘 다 함수 호출이지만, 예외로 다음 코드 존재
o.NestedClasso.NestedClass()
- 첫 번째는
NestedClass객체 자체 반환 - 두 번째는 인스턴스 생성
- 따라서 인자가 없는 함수 호출과 getter류 호출로서 인자 배열이 비어 있는 경우를 구분해야 함
- 그러나 기존 구조는 비효율적
- 호출자가
vector할당 - 피호출자가 그 벡터를 복사한 arguments scope를 다시 할당
- 호출자가
- 변경은
Arguments타입 도입- 모양이 피호출자가 만들던 arguments scope와 정확히 동일
- 이제 호출자가 직접 그 형태로 할당
- Yolo-C++에서도 vector backing store
malloc제거로 할당 수 감소 - Fil-C++에서는
std::optional자체가 힙 할당std::optional이 없어도const std::vector<>&전달 역시 할당- 스택 할당되는 것은 힙 할당된다고 명시
- 호출자 쪽이 벡터를 미리 크기 지정하지 않아 여러 번 재할당하던 점도 언급
- 변경의 상당 부분은 함수 시그니처를
Arguments*로 교체하는 작업 - 성능 효과는 1.33배 향상
- 이 시점 성능은 CPython 3.10보다 3.9배 느림
- Lua 5.4.7보다 8.8배 느림
- QuickJS-ng 0.14.0보다 2.5배 느림
- 시작점 대비 9.05배 빠름
- 변경 전 Zef 인터프리터는 함수 인자를
-
최적화 #8: Getter 특수화
- Zef는 Ruby와 유사하게 인스턴스 필드가 기본적으로 private
- 예시
class Foo { my f fn (inF) f = inF }- 생성자에서 받은 값을 인스턴스에만 보이는 지역 변수
f에 저장
- 생성자에서 받은 값을 인스턴스에만 보이는 지역 변수
- 같은 타입 인스턴스라도 다른 객체의
f에는 접근할 수 없음- 예시
fn nope(o) o.f println(Foo(42).nope(Foo(666)))nope안의o.f는o의f에 접근하지 못함
- 예시
- 이유는 필드가 클래스 멤버의 스코프 체인에 나타나는 방식으로 작동하기 때문
o.f는 필드 읽기가 아니라f라는 이름의 메서드 호출 요청
- 그래서 다음 패턴이 자주 등장
my ffn f f- 즉 지역 변수
f를 반환하는 이름f의 메서드
- 더 짧은 문법으로
readable fmy f와fn f f의 축약형
- 많은 메서드 호출이 실질적으로 getter 호출
- 모든 getter가 AST를 평가하며 동작하는 것은 낭비
- 최적화는 getter 특수화
- 중심은
UserFunction - 새
Node::inferGetter메서드로 함수 본문이 단순 getter인지 추론
- 중심은
- 추론 규칙
Block::inferGetter는 자신이 담은 것이 전부 getter로 추론 가능하면 getter로 추론Get::inferGetter는 자신을 getter로 추론하고 로드할 offset 반환Context::tryGetFieldOffsets는 getter가 실행될 렉시컬 스코프에 그 필드가 확실히 존재할 때만 비어 있지 않은Offsets반환UserFunction은 함수 본문이 getter로 추론 가능하면 알려진 offset에서 바로 읽기만 수행하는 특수Function서브클래스로 resolve
- 성능 효과는 5.6% 향상
- 이 시점 성능은 CPython 3.10보다 3.7배 느림
- Lua 5.4.7보다 8.3배 느림
- QuickJS-ng 0.14.0보다 2.4배 느림
- 시작점 대비 9.55배 빠름
-
최적화 #9: Setter 특수화
- setter 추론에서는
fn set_fieldName(newValue) fieldName = newValue패턴 매칭 필요 UserFunction의 추론 단계에서 setter의 매개변수 이름 전달 필요Set의 추론 단계에서는 ClassObject에 대한 쓰기 아님을 확인해야 하며, setter 매개변수가 set의 소스로 사용되는지도 함께 확인 필요- 성능 효과는 3.4% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 3.6배 느림
- Lua 5.4.7보다 8배 느림
- QuickJS-ng 0.14.0보다 2.3배 느림
- 시작점 대비 9.87배 빠름
- setter 추론에서는
-
최적화 #10:
callMethod인라인화- 중요한 함수를 한 줄 변경으로 인라인화
- 성능 효과는 3.2% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 3.5배 느림
- Lua 5.4.7보다 7.8배 느림
- QuickJS-ng 0.14.0보다 2.2배 느림
- 시작점 대비 10.2배 빠름
-
최적화 #11: 해시테이블
- 메서드 호출의 inline cache 미스가 발생하면
ClassObject::tryCallMethod와ClassObject::TryCallMethodDirect를 따라 내려가야 했고, 두 경로 모두 크고 복잡 - 기존 탐색 비용은 계층 깊이에 비례하는 O(hierarchy depth)
- 계층의 각 클래스마다 호출이 멤버 함수로 해석되는지 확인하는 해시테이블 조회 수행
- 계층의 각 클래스마다 호출이 중첩 클래스로 해석되는지 확인하는 해시테이블 조회도 수행
- 새 변경으로 receiver class와 symbol을 키로 사용하는 전역 해시테이블 도입
- 한 번의 조회로 callee 직접 반환
classobject.h에서 전체tryCallMethodSlow로 내려가기 전에 이 전역 테이블 먼저 조회classobject.cpp에서는 성공한 조회 결과를 전역 테이블에 기록- 전역 해시테이블 자체는 비교적 단순한 구현
- 성능 효과는 15% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 3배 느림
- Lua 5.4.7보다 6.8배 느림
- QuickJS-ng 0.14.0보다 1.9배 느림
- 시작점 대비 11.8배 빠름
- 메서드 호출의 inline cache 미스가 발생하면
-
최적화 #12:
std::optional회피- Fil-C++에서는 union 관련 컴파일러 병리 현상 때문에
std::optional이 힙 할당 필요 - 일반적으로 LLVM은 union 메모리 접근 타입을 느슨하게 다루지만, 이것이 invisicaps와 충돌
- union 안의 포인터가 프로그래머 입장에서 예측하기 어렵게 capability를 잃는 경우 발생
- 그 결과 Fil-C에서 프로그래머 실수 없이도 null capability를 가진 객체 역참조 패닉 발생
- 이를 완화하기 위해 Fil-C++ 컴파일러는 union 타입 지역 변수 처리에서 LLVM이 보수적으로 동작하도록 intrinsics 삽입
- 이후
FilPizlonator패스가 자체 escape analysis를 수행해 union 타입 지역 변수를 레지스터 할당 가능하게 만들려 시도- 다만 이 분석은 일반 LLVM의 SROA 분석만큼 완전하지 않음
- 결과적으로
std::optional처럼 union을 포함한 클래스 전달이 Fil-C++에서 메모리 할당으로 이어지는 경우가 많음 - 이번 변경은 hot path에서
std::optional로 이어지는 코드 경로 회피 - 성능 효과는 1.7% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 3배 느림
- Lua 5.4.7보다 6.65배 느림
- QuickJS-ng 0.14.0보다 1.9배 느림
- 시작점 대비 12배 빠름
- Fil-C++에서는 union 관련 컴파일러 병리 현상 때문에
-
최적화 #13: 특수화된 인자
- Zef의 모든 built-in 함수는 1개 또는 2개의 인자를 받으며, 네이티브 구현에서는 이를 담기 위한
Arguments객체 할당 불필요 - setter도 항상 한 개의 인자를 받으며, setter 추론이 이뤄진 경우 specialized setter 구현 역시
Arguments객체 없이 값 인자만 직접 받으면 충분 - 이번 변경으로
ZeroArguments,OneArgument,TwoArguments특수화 인자 타입 도입- callee가 필요로 하지 않는 경우 caller가
Arguments객체 할당 회피
- callee가 필요로 하지 않는 경우 caller가
ZeroArguments는(Arguments*)nullptr와 구분하기 위해 필요- 기존에는
(Arguments*)nullptr를 getter 호출 의미로 사용했고, 그 로직 유지 - 이제
ZeroArguments는 인자 없는 함수 호출 의미
- 기존에는
- 많은 변경이 인자를 받는 함수를 템플릿화하는 작업으로 구성
ZeroArguments,OneArgument,TwoArguments,Arguments*각각에 대해 명시적 인스턴스화 수행- 기존 코드 상당수는
Value::getArg를 인자 추출 헬퍼로 사용했고, 여기에 특수화 인자 오버로드 추가 - 인자를 사용하는 네이티브 코드 변경은 비교적 직선적인 수정
- 성능 효과는 3.8% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 2.9배 느림
- Lua 5.4.7보다 6.4배 느림
- QuickJS-ng 0.14.0보다 1.8배 느림
- 시작점 대비 12.4배 빠름
- Zef의 모든 built-in 함수는 1개 또는 2개의 인자를 받으며, 네이티브 구현에서는 이를 담기 위한
Fil-C 병리 우회와 세부 특수화
-
최적화 #14: 개선된 Value slow path
- 또 다른 Fil-C 병리 현상 우회로 큰 속도 향상 확보
- 변경 전
Value의 out-of-line slow path는Value의 멤버 함수였고, 암묵적인const Value*인자 필요 - 이 구조에서는 caller가
Value를 스택에 할당해야 함 - Fil-C++에서는 모든 스택 할당이 힙 할당
- 따라서 slow path를 호출하는 코드는
Value를 힙에 할당
- 따라서 slow path를 호출하는 코드는
- 변경 후 이 메서드들을
static으로 바꾸고,Value를 값으로 전달- 그 결과 별도 할당 불필요
- 성능 효과는 10% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 2.6배 느림
- Lua 5.4.7보다 5.8배 느림
- QuickJS-ng 0.14.0보다 1.65배 느림
- 시작점 대비 13.6배 빠름
-
최적화 #15:
DotSetRMW중복 제거- 일부 중복 코드 제거 수행
constructCache<>에 의해 특수화되는 템플릿 함수에서 기계어 코드 감소가 유리할 수 있다고 기대- 실제 결과는 성능 영향 없음
-
최적화 #16:
sqrt특수화- inline cache는 원하는 함수로 호출을 잘 라우팅하지만 객체에 대해서만 동작
- 비객체에서는
Binary<>,Unary<>,Value::callRMW<>fast path가 receiver가int또는double인지 확인하는 방식에 의존 - 이 방식은 파서가 인식하는 연산자에만 적용
value.sqrt같은 형태에는 적용되지 않음
- 이번 변경으로
Dot이value.sqrt에 대해 특수화 가능 - 성능 효과는 1.6% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 2.6배 느림
- Lua 5.4.7보다 5.75배 느림
- QuickJS-ng 0.14.0보다 1.6배 느림
- 시작점 대비 13.8배 빠름
-
최적화 #17:
toString특수화- 이전 최적화와 거의 동일한 방식으로
toString특수화 적용 - 이 변경에는
int를 문자열로 변환할 때 발생하는 할당 수 감소 로직 포함 - 성능 효과는 2.7% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 2.5배 느림
- Lua 5.4.7보다 5.6배 느림
- QuickJS-ng 0.14.0보다 1.6배 느림
- 시작점 대비 14.2배 빠름
- 이전 최적화와 거의 동일한 방식으로
-
최적화 #18: 배열 리터럴 특수화
my whatever = [1, 2, 3]같은 코드는 Zef에서 배열이 alias 가능하고 mutable이므로 새 배열 할당 필요- 변경 전에는 실행 때마다 AST를 따라 내려가며
1,2,3을 매번 재귀 평가 - 이번 변경은
ArrayLiteral노드를 상수 배열 할당 경우에 특수화 - 성능 효과는 8.1% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 2.3배 느림
- Lua 5.4.7보다 5.2배 느림
- QuickJS-ng 0.14.0보다 1.5배 느림
- 시작점 대비 15.35배 빠름
-
최적화 #19:
Value::callOperator개선- 이전에
Value를 참조로 전달하지 않아 속도 향상을 얻었던 것과 같은 최적화를callOperatorslow path에도 적용 - 성능 효과는 6.5% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 2.2배 느림
- Lua 5.4.7보다 4.9배 느림
- QuickJS-ng 0.14.0보다 1.4배 느림
- 시작점 대비 16.3배 빠름
- 이전에
-
최적화 #20: 더 나은 C++ 옵션
- Fil-C++에서는 불필요한 RTTI와 libc++ hardening 비활성화
- C++ 코드 자체 변경은 없고 빌드 시스템 설정 변경만 포함
- 성능 효과는 1.8% 향상
- 이 시점 기준 Zef는 CPython 3.10보다 2.1배 느림
- Lua 5.4.7보다 4.8배 느림
- QuickJS-ng 0.14.0보다 1.35배 느림
- 시작점 대비 16.6배 빠름
-
최적화 #21: assert 비활성화
- 마지막 최적화로 assertion 기본 비활성화 적용
- 기존 코드는 Fil-C 전용
ZASSERT매크로 사용- 항상 assert를 수행하는 구조
- 변경 후 내부
ASSERT매크로 사용ASSERTS_ENABLED가 설정된 경우에만 assert 수행
- 이 변경에는 코드가 Yolo-C++에서 빌드되도록 하는 다른 수정도 포함
- 기대와 달리 속도 향상 없음
Yolo-C++ 결과와 한계
- 코드를 Yolo-C++ 로 컴파일한 결과 4배 속도 향상 확보
- 다만 이 방식은 sound하지 않고 suboptimal
- sound하지 않은 이유는 기존 Fil-C++ GC 호출이
calloc호출로 바뀌기 때문 - 그 결과 메모리가 해제되지 않으며, 충분히 오래 실행되는 워크로드에서는 인터프리터가 메모리 고갈에 도달
- ScriptBench1에서는 테스트 시간이 짧아 메모리 고갈 발생 없음
- sound하지 않은 이유는 기존 Fil-C++ GC 호출이
- suboptimal한 이유는 실제 GC 할당기가 glibc 2.35의
calloc보다 빠르기 때문 - 따라서 Yolo-C++ 포트에 실제 GC를 추가하면 4배보다 더 큰 속도 향상 가능성 언급
- 이 실험에는 GCC 11.4.0 사용
- 이 시점 기준 Zef는
-
CPython 3.10보다 1.9배 빠름
-
Lua 5.4.7보다 1.2배 느림
-
QuickJS-ng 0.14.0보다 3배 빠름
- 시작점 대비 67배 빠름
-
원시 벤치마크 데이터
- 벤치마크 실행 시간 단위는 초
- 표에는 각 인터프리터별
nbody,splay,richards,deltablue,geomean포함 -
Python 3.10
nbody0.0364splay0.8326richards0.0822deltablue0.1135geomean0.1296
-
Lua 5.4.7
nbody0.0142splay0.4393richards0.0217deltablue0.0832geomean0.0577
-
QuickJS-ng 0.14.0
nbody0.0214splay0.7090richards0.7193deltablue0.1585geomean0.2036
-
Zef Baseline
nbody2.9573splay13.0286richards1.9251deltablue5.9997geomean4.5927
-
Zef Change #1: Direct Operators
nbody2.1891splay12.0233richards1.6935deltablue5.2331geomean3.9076
-
Zef Change #2: Direct RMWs
nbody2.0130splay11.9987richards1.6367deltablue5.0994geomean3.7677
-
Zef Change #3: Avoid IntObject
nbody1.9922splay11.8824richards1.6220deltablue5.0646geomean3.7339
-
Zef Change #4: Symbols
nbody1.5782splay9.9577richards1.4116deltablue4.4593geomean3.1533
-
Zef Change #5: Value Inline
nbody1.4982splay9.7723richards1.3890deltablue4.3536geomean3.0671
-
Zef Change #6: Object Model and Inline Caches
nbody0.3884splay3.3609richards0.2321deltablue0.6805geomean0.6736
-
Zef Change #7: Arguments
nbody0.3160splay2.6890richards0.1653deltablue0.4738geomean0.5077
-
Zef Change #8: Getters
nbody0.2988splay2.6919richards0.1564deltablue0.4260geomean0.4809
-
Zef Change #9: Setters
nbody0.2850splay2.6690richards0.1514deltablue0.4072geomean0.4651
-
Zef Change #10: callMethod inline
nbody0.2533splay2.6711richards0.1513deltablue0.4032geomean0.4506
-
Zef Change #11: Hashtable
nbody0.1796splay2.6528richards0.1379deltablue0.3551geomean0.3906
-
Zef Change #12: Avoid std::optional
nbody0.1689splay2.6563richards0.1379deltablue0.3518geomean0.3839
-
Zef Change #13: Specialized Arguments
nbody0.1610splay2.5823richards0.1350deltablue0.3372geomean0.3707
-
Zef Change #14: Improved Value Slow Paths
nbody0.1348splay2.5062richards0.1241deltablue0.3076geomean0.3367
-
Zef Change #15: Deduplicated DotSetRMW::evaluate
nbody0.1342splay2.5047richards0.1256deltablue0.3079geomean0.3375
-
Zef Change #16: Fast sqrt
nbody0.1274splay2.5045richards0.1251deltablue0.3060geomean0.3322
-
Zef Change #17: Fast toString
nbody0.1282splay2.2664richards0.1275deltablue0.2964geomean0.3235
-
Zef Change #18: Array Literal Specialization
nbody0.1295splay1.6661richards0.1250deltablue0.2979geomean0.2992
-
Zef Change #19: Value callOperator Optimization
nbody0.1208splay1.6698richards0.1143deltablue0.2713geomean0.2810
-
Zef Change #20: Better C++ Configuration
nbody0.1186splay1.6521richards0.1127deltablue0.2635geomean0.2760
-
Zef Change #21: No Asserts
nbody0.1194splay1.6504richards0.1127deltablue0.2619geomean0.2759
-
Zef in Yolo-C++
nbody0.0233splay0.3992richards0.0309deltablue0.0784geomean0.0686