최적화
저녁은 하루 중 가장 좋은 시간이다. 오늘 할 일을 다 했으니, 이제 발을 뻗고 즐길 수 있다.
가즈오 이시구로, 『남아있는 나날』
제가 아직 뉴올리언스에 살았다면, 이 장을 라니아프(lagniappe), 즉 고객에게 무료로 주는 작은 추가 선물이라고 불렀을 겁니다. 여러분은 이미 책 한 권과 완전한 가상 머신을 가지고 있지만, 저는 여러분이 clox를 가지고 더 재미있게 해킹하기를 바랍니다. 이번에는 순수한 성능 향상을 목표로 합니다. 두 가지 매우 다른 최적화를 가상 머신에 적용할 것입니다. 이 과정에서 여러분은 언어 구현체(또는 어떤 프로그램이든)의 성능을 측정하고 개선하는 방법을 느끼게 될 것입니다.
30 . 1성능 측정
최적화(Optimization)란 작동하는 애플리케이션의 성능을 개선하는 것을 의미합니다. 최적화된 프로그램은 같은 작업을 수행하지만, 이를 위해 더 적은 자원을 사용합니다. 최적화 시 주로 생각하는 자원은 런타임 속도이지만, 메모리 사용량, 시작 시간, 영구 저장소 크기 또는 네트워크 대역폭을 줄이는 것도 중요할 수 있습니다. 모든 물리적 자원은 어떤 형태로든 비용이 발생하므로(대부분은 낭비되는 인적 시간의 형태이지만), 최적화 작업은 대개 그만한 가치가 있습니다.
컴퓨팅 초창기에는 숙련된 프로그래머가 전체 하드웨어 아키텍처와 컴파일러 파이프라인을 머릿속에 담아두고 프로그램의 성능을 깊이 생각하는 것만으로도 이해할 수 있었던 시절이 있었습니다. 하지만 마이크로코드, 캐시 라인, 분기 예측, 깊은 컴파일러 파이프라인, 거대한 명령어 집합 등으로 인해 그런 시절은 오래 전에 지났습니다. 우리는 C가 "저수준" 언어라고 주장하지만,
printf("Hello, world!");
와 화면에 인사말이 나타나는 과정 사이에는 이제 위험할 정도로 많은 기술 스택이 쌓여 있습니다.
오늘날의 최적화는 경험 과학입니다. 우리 프로그램은 하드웨어 장애물 코스를 질주하는 보더 콜리 같습니다. 그녀가 더 빨리 결승점에 도달하게 하려면, 우리는 그저 앉아서 개 생리학에 대해 골몰하며 깨달음이 올 때까지 기다릴 수 없습니다. 대신 우리는 그녀의 성능을 관찰하고, 어디서 비틀거리는지 확인한 다음, 더 빠른 경로를 찾아주어야 합니다.
민첩성 훈련이 특정 개와 특정 장애물 코스에 맞춰져 있듯이, 우리의 가상 머신 최적화가 모든 Lox 프로그램을 모든 하드웨어에서 더 빠르게 실행할 것이라고 가정할 수는 없습니다. 다른 Lox 프로그램은 VM의 다른 영역에 부하를 주고, 다른 아키텍처는 고유의 강점과 약점을 가집니다.
30 . 1 . 1벤치마크
새로운 기능을 추가할 때, 우리는 테스트를 작성하여 정확성을 검증합니다. 이 테스트는 특정 기능을 사용하고 VM의 동작을 확인하는 Lox 프로그램입니다. 테스트는 의미론을 확정하고 새로운 기능을 추가할 때 기존 기능이 손상되지 않도록 보장합니다. 성능에 관해서도 비슷한 요구 사항이 있습니다.
-
최적화가 실제로 성능을 개선하는지, 그리고 얼마나 개선하는지 어떻게 검증할까요?
-
관련 없는 다른 변경 사항이 성능을 저하시키지 않도록 어떻게 보장할까요?
이러한 목표를 달성하기 위해 작성하는 Lox 프로그램을 벤치마크(benchmark)라고 합니다. 이는 언어 구현체의 특정 부분을 집중적으로 테스트하기 위해 신중하게 제작된 프로그램입니다. 이들은 프로그램이 무엇을 하는지가 아니라, 그것을 수행하는 데 얼마나 오래 걸리는지를 측정합니다.
변경 전후의 벤치마크 성능을 측정하여, 변경 사항이 어떤 영향을 미쳤는지 확인할 수 있습니다. 최적화를 적용하면 모든 테스트는 이전과 동일하게 동작해야 하지만, 벤치마크는 더 빠르게 실행되기를 기대합니다.
일단 전체 벤치마크 스위트를 갖추면, 최적화가 성능을 변경하는지 여부뿐만 아니라 어떤 종류의 코드에서 성능이 변경되는지도 측정할 수 있습니다. 종종 어떤 벤치마크는 빨라지고 다른 벤치마크는 느려지는 것을 발견할 것입니다. 그러면 어떤 종류의 코드를 언어 구현체가 최적화할 것인지에 대해 어려운 결정을 내려야 합니다.
여러분이 작성하기로 선택한 벤치마크 스위트는 그 결정의 핵심 부분입니다. 테스트가 올바른 동작에 대한 여러분의 선택을 나타내듯이, 벤치마크는 성능에 대한 여러분의 우선순위를 구체화한 것입니다. 벤치마크는 어떤 최적화를 구현할지 안내할 것이므로, 신중하게 선택하고 주기적으로 그것이 여러분의 더 큰 목표 달성에 도움이 되는지 되돌아보는 것을 잊지 마세요.
벤치마킹은 미묘한 기술입니다. 테스트와 마찬가지로, 여러분은 구현에 과적합되지 않으면서도 벤치마크가 실제로 여러분이 중요하게 생각하는 코드 경로를 건드리도록 균형을 맞춰야 합니다. 성능을 측정할 때, CPU 스로틀링, 캐싱, 기타 이상한 하드웨어 및 운영 체제 특성으로 인한 변동을 보정해야 합니다. 여기서 벤치마킹에 대한 긴 설교를 하지는 않겠지만, 벤치마킹을 연습을 통해 향상되는 독자적인 기술로 여기세요.
30 . 1 . 2프로파일링
좋습니다, 이제 몇 가지 벤치마크가 생겼습니다. 그것들을 더 빠르게 만들고 싶습니다. 이제 어떻게 해야 할까요? 우선, 여러분이 모든 명백하고 쉬운 작업을 마쳤다고 가정해 봅시다. 올바른 알고리즘과 데이터 구조를 사용하고 있거나, 적어도 공격적으로 잘못된 것을 사용하고 있지는 않을 것입니다. 저는 거대한 정렬되지 않은 배열을 선형 탐색하는 대신 해시 테이블을 사용하는 것을 "최적화"라기보다는 "좋은 소프트웨어 엔지니어링"으로 봅니다.
하드웨어가 너무 복잡하여 우리의 프로그램 성능을 첫 번째 원리부터 추론하기 어렵기 때문에, 우리는 현장으로 나가야 합니다. 그것은 바로 프로파일링(profiling)을 의미합니다. 프로파일러(profiler)는(만약 사용해 본 적이 없다면) 여러분의 프로그램을 실행하고 코드가 실행될 때 하드웨어 자원 사용량을 추적하는 도구입니다. 간단한 프로파일러는 프로그램의 각 함수에서 얼마나 많은 시간이 소요되었는지 보여줍니다. 정교한 프로파일러는 데이터 캐시 미스, 명령어 캐시 미스, 분기 예측 실패, 메모리 할당 및 기타 모든 종류의 측정 항목을 기록합니다.
다양한 운영 체제와 언어를 위한 많은 프로파일러가 있습니다. 어떤 플랫폼에서 프로그래밍하든, 괜찮은 프로파일러에 익숙해지는 것은 가치가 있습니다. 숙달할 필요는 없습니다. 저는 프로그램을 프로파일러에 돌린 지 몇 분 만에, 시행착오를 통해 스스로 발견하려면 며칠이 걸렸을 것을 배웠습니다. 프로파일러는 놀랍고 마법 같은 도구입니다.
30 . 2더 빠른 해시 테이블 프로빙
이제 충분히 거창한 이야기는 접어두고, 성능 차트를 우상향으로 만들어 봅시다. 첫 번째로 수행할 최적화는, 알고 보니, 우리 VM에 적용할 수 있는 가장 작은 변경 사항에 관한 것입니다.
제가 clox의 조상격인 바이트코드 가상 머신을 처음 작동시켰을 때, 저는 모든 자존심 있는 VM 해커가 할 일을 했습니다. 저는 몇 개의 벤치마크를 급조하고, 프로파일러를 켜서 그 스크립트들을 제 인터프리터에 돌려보았습니다. Lox와 같은 동적 타입 언어에서는 사용자 코드의 상당 부분이 필드 접근과 메서드 호출이므로, 제 벤치마크 중 하나는 다음과 같았습니다.
class Zoo { init() { this.aardvark = 1; this.baboon = 1; this.cat = 1; this.donkey = 1; this.elephant = 1; this.fox = 1; } ant() { return this.aardvark; } banana() { return this.baboon; } tuna() { return this.cat; } hay() { return this.donkey; } grass() { return this.elephant; } mouse() { return this.fox; } } var zoo = Zoo(); var sum = 0; var start = clock(); while (sum < 100000000) { sum = sum + zoo.ant() + zoo.banana() + zoo.tuna() + zoo.hay() + zoo.grass() + zoo.mouse(); } print clock() - start; print sum;
벤치마크를 본 적이 없다면, 이것이 터무니없게 보일 수도 있습니다. 무슨 일이 벌어지고 있는 걸까요? 이 프로그램 자체는 유용한 일을 하려는 의도가 없습니다. 대신 이 프로그램은 우리가 관심을 가지는 언어의 부분들(메서드 호출과 필드 접근)을 많이 수행합니다. 필드와 메서드는 해시 테이블에 존재하므로, 이 테이블들에 최소한 몇 개의 흥미로운 키를 채워 넣도록 신경 씁니다. 이 모든 것은 큰 루프 안에 감싸져 있어, 프로파일러가 충분한 실행 시간을 확보하여 사이클이 어디로 가고 있는지 파악할 수 있도록 합니다.
제가 프로파일러에서 무엇을 보았는지 말씀드리기 전에, 잠시 시간을 내어 몇 가지 추측을 해보세요. clox 코드베이스의 어느 부분에서 VM이 대부분의 시간을 보냈다고 생각하시나요? 이전 장에서 작성했던 코드 중 특히 느리다고 의심되는 부분이 있나요?
제가 찾은 것은 다음과 같습니다: 당연히, 가장 많은 inclusive time을 차지하는 함수는 run()입니다. (Inclusive time은 특정 함수와 그 함수가 호출하는 모든 다른 함수에서 소비된 총 시간을 의미합니다. 즉, 함수에 진입했을 때부터 반환될 때까지의 총 시간입니다.) run()은 주된 바이트코드 실행 루프이므로, 모든 것을 구동합니다.
run() 안에서는 OP_POP, OP_RETURN, OP_ADD와 같은 일반적인 명령어에 대한 바이트코드 switch 문 여러 케이스에 작은 시간 조각들이 분산되어 있습니다. 가장 많은 시간을 차지하는 명령어는 OP_GET_GLOBAL이 실행 시간의 17%, OP_GET_PROPERTY가 12%, 그리고 OP_INVOKE가 전체 실행 시간의 무려 42%를 차지합니다.
그럼 최적화해야 할 세 개의 핫스팟이 있는 걸까요? 사실은 아닙니다. 왜냐하면 이 세 명령어는 거의 모든 시간을 동일한 함수인 tableGet() 호출 내부에서 보내기 때문입니다. 이 함수가 전체 실행 시간의 무려 72%를 차지합니다(다시 말하지만, inclusive 시간입니다). 동적 타입 언어에서는 해시 테이블에서 무언가를 찾는 데 상당한 시간을 보낼 것으로 예상합니다. 이는 동적 특성의 일종의 대가입니다. 하지만, 그래도 놀랍습니다.
30 . 2 . 1느린 키 래핑
tableGet()을 살펴보면, 실제 해시 테이블 조회가 일어나는 findEntry() 호출을 감싸는 래퍼라는 것을 알 수 있습니다. 기억을 되살리기 위해, 전체 코드를 여기에 소개합니다.
static Entry* findEntry(Entry* entries, int capacity, ObjString* key) { uint32_t index = key->hash % capacity; Entry* tombstone = NULL; for (;;) { Entry* entry = &entries[index]; if (entry->key == NULL) { if (IS_NIL(entry->value)) { // 비어있는 엔트리. return tombstone != NULL ? tombstone : entry; } else { // 툼스톤을 찾았습니다. if (tombstone == NULL) tombstone = entry; } } else if (entry->key == key) { // 키를 찾았습니다. return entry; } index = (index + 1) % capacity; } }
이전 벤치마크를 실행할 때(적어도 제 머신에서는), VM이 이 함수의 한 줄에서 전체 실행 시간의 70%를 소비했습니다. 어떤 줄일지 짐작이 가시나요? 아니요? 바로 이겁니다.
uint32_t index = key->hash % capacity;
포인터 역참조가 문제는 아닙니다. 문제는 작은 %입니다. 나머지 연산자(modulo operator)가 정말로 느리다는 사실이 밝혀졌습니다. 다른 산술 연산자보다 훨씬 느립니다. 더 좋은 방법이 있을까요?
일반적인 경우, 기본 산술 연산자를 CPU 자체보다 빠르게 사용자 코드로 재구현하는 것은 매우 어렵습니다. 결국, 우리의 C 코드는 CPU 자체의 산술 연산으로 컴파일됩니다. 더 빠르게 만들 수 있는 트릭이 있다면, 칩은 이미 그것을 사용하고 있을 것입니다.
하지만 우리는 CPU보다 우리의 문제에 대해 더 많이 알고 있다는 사실을 활용할 수 있습니다. 여기서 우리는 키 문자열의 해시 코드를 테이블의 엔트리 배열 범위 내에 맞추기 위해 나머지 연산을 사용합니다. 이 배열은 여덟 개의 요소로 시작하여 매번 두 배씩 커집니다. 우리는 테이블의 크기가 항상 2의 거듭제곱이라는 것을 알고 있습니다(CPU와 C 컴파일러는 모릅니다).
우리가 영리한 비트 조작자이기 때문에, 2의 거듭제곱으로 나눈 숫자의 나머지를 더 빠르게 계산하는 방법을 알고 있습니다: 바로 비트 마스킹(bit masking)입니다. 229를 64로 나눈 나머지를 계산한다고 해봅시다. 답은 37인데, 십진수로는 특별히 명확하지 않지만, 이 숫자들을 이진수로 보면 더 분명해집니다.
그림의 왼쪽에서 결과(37)가 단순히 피제수(229)에서 가장 높은 두 비트를 제거한 것임을 알 수 있나요? 그 두 개의 가장 높은 비트는 제수의 단일 1 비트 위치 또는 그 왼쪽에 있는 비트들입니다.
오른쪽에서는 229를 원래 2의 거듭제곱 제수보다 1 작은 63과 비트별 AND 연산을 하여 같은 결과를 얻습니다. 2의 거듭제곱에서 1을 빼면 연속된 1 비트들이 생성됩니다. 그것이 바로 왼쪽의 두 비트를 제거하는 데 필요한 마스크입니다.
다른 말로, 어떤 숫자를 2의 거듭제곱으로 나눈 나머지는 단순히 해당 2의 거듭제곱에서 1을 뺀 값과 비트별 AND 연산을 함으로써 계산할 수 있습니다. 저는 이것이 작동한다는 것을 여러분에게 증명할 만큼 충분히 수학자는 아니지만, 생각해 보면 이해가 될 것입니다. 우리는 느린 나머지 연산자를 매우 빠른 감소(decrement)와 비트별 AND 연산으로 바꿀 수 있습니다. 문제가 되는 코드 줄을 단순히 다음과 같이 변경합니다.
static Entry* findEntry(Entry* entries, int capacity,
ObjString* key) {
in findEntry()
replace 1 line
uint32_t index = key->hash & (capacity - 1);
Entry* tombstone = NULL;
CPU는 비트 연산자를 좋아하므로, 이보다 더 좋게 만들기는 어렵습니다.
우리의 선형 탐색(linear probing search)은 배열의 끝을 감싸서 돌아야 할 수 있으므로, findEntry()에 업데이트할 또 다른 나머지 연산이 있습니다.
// We found the key.
return entry;
}
in findEntry()
replace 1 line
index = (index + 1) & (capacity - 1);
}
대부분의 검색이 감싸지 않으므로 이 줄은 프로파일러에 나타나지 않았습니다.
findEntry() 함수에는 문자열 인터닝을 위한 해시 테이블 조회를 수행하는 자매 함수 tableFindString()이 있습니다. 거기에도 같은 최적화를 적용하는 것이 좋습니다. 이 함수는 문자열을 인터닝할 때만 호출되는데, 우리의 벤치마크에서는 크게 강조되지 않았습니다. 하지만 많은 문자열을 생성하는 Lox 프로그램은 이 변경으로 인해 눈에 띄게 이점을 얻을 수 있습니다.
if (table->count == 0) return NULL;
in tableFindString()
replace 1 line
uint32_t index = hash & (table->capacity - 1);
for (;;) {
Entry* entry = &table->entries[index];
그리고 선형 탐색이 감쌀 때도 마찬가지입니다.
return entry->key;
}
in tableFindString()
replace 1 line
index = (index + 1) & (table->capacity - 1);
}
우리의 수정 사항이 가치가 있었는지 봅시다. 저는 그 동물학 벤치마크를 수정하여 10초 동안 10,000번의 호출을 몇 배치(batch) 실행할 수 있는지 세도록 했습니다. 더 많은 배치는 더 빠른 성능을 의미합니다. 최적화되지 않은 코드를 사용한 제 머신에서는 벤치마크가 3,192 배치를 처리합니다. 이 최적화 후에는 6,249 배치로 증가합니다.
이는 같은 시간 동안 거의 정확히 두 배의 작업을 수행한 것입니다. 우리는 VM을 두 배 더 빠르게 만들었습니다(일반적인 주의사항: 이 벤치마크에서는). 이는 최적화 측면에서 엄청난 승리입니다. 보통은 몇 퍼센트라도 성능을 끌어올릴 수 있다면 기분이 좋습니다. 메서드, 필드, 전역 변수가 Lox 프로그램에서 매우 흔하게 사용되므로, 이 작은 최적화는 전반적인 성능을 향상시킵니다. 거의 모든 Lox 프로그램이 이점을 얻습니다.
자, 이 섹션의 요점은 나머지 연산자가 근본적으로 사악하여 여러분이 작성하는 모든 프로그램에서 제거해야 한다는 것이 아닙니다. 또한 마이크로 최적화가 필수적인 엔지니어링 기술이라는 것도 아닙니다. 성능 문제가 이처럼 좁고 효과적인 해결책을 가지는 경우는 드뭅니다. 우리는 운이 좋았습니다.
요점은 프로파일러가 알려줄 때까지 나머지 연산자가 성능 저하의 원인이라는 것을 몰랐다는 것입니다. 만약 우리가 VM 코드베이스를 맹목적으로 돌아다니며 핫스팟을 추측했다면, 그것을 알아차리지 못했을 가능성이 높습니다. 이것에서 여러분이 얻어가야 할 점은 도구 상자에 프로파일러를 갖는 것이 얼마나 중요한가 하는 것입니다.
이 점을 강조하기 위해, 이제 최적화된 VM에서 원래 벤치마크를 실행하고 프로파일러가 무엇을 보여주는지 살펴봅시다. 제 머신에서 tableGet()은 여전히 실행 시간의 상당 부분을 차지합니다. 이는 동적 타입 언어에서는 예상되는 일입니다. 하지만 전체 실행 시간의 72%에서 35%로 감소했습니다. 이는 우리가 보고 싶었던 것과 훨씬 더 일치하며, 우리의 최적화가 프로그램을 단순히 빠르게 만든 것이 아니라, 우리가 예상한 방식으로 빠르게 만들었음을 보여줍니다. 프로파일러는 문제를 발견하는 것만큼이나 해결책을 검증하는 데 유용합니다.
30 . 3NaN 박싱
다음 최적화는 매우 다른 느낌을 줍니다. 다행히도, 이상한 이름에도 불구하고 이것은 할머니를 때리는 것과는 관련이 없습니다. 다르긴 하지만, 그렇게 다른 것은 아닙니다. 이전 최적화에서는 프로파일러가 문제가 어디에 있는지 알려주었고, 우리는 단순히 약간의 독창성을 발휘하여 해결책을 찾아야 했습니다.
이 최적화는 더 미묘하며, 그 성능 영향은 가상 머신 전반에 더 분산되어 나타납니다. 프로파일러는 이 해결책을 찾는 데 도움을 주지 않을 것입니다. 대신, 이것은 머신 아키텍처의 가장 낮은 수준에 대해 깊이 생각한 누군가에 의해 고안되었습니다.
제목에서 언급했듯이, 이 최적화는 NaN 박싱(NaN boxing) 또는 때로는 NaN 태깅(NaN tagging)이라고 불립니다. 개인적으로는 '박싱'이 힙에 할당된 표현을 암시하는 경향이 있어 후자의 이름을 더 좋아하지만, 전자가 더 널리 사용되는 용어인 것 같습니다. 이 기술은 VM에서 값을 표현하는 방식을 변경합니다.
64비트 머신에서, 우리의 Value 타입은 16바이트를 차지합니다. 이 구조체는 타입 태그와 페이로드를 위한 유니온이라는 두 개의 필드를 가집니다. 유니온에서 가장 큰 필드는 Obj 포인터와 double인데, 둘 다 8바이트입니다. 유니온 필드를 8바이트 경계에 정렬하기 위해, 컴파일러는 태그 뒤에도 패딩을 추가합니다.
그것은 꽤 큽니다. 그것을 줄일 수 있다면, VM은 같은 양의 메모리에 더 많은 값을 담을 수 있을 것입니다. 요즘 대부분의 컴퓨터는 RAM이 풍부하므로, 직접적인 메모리 절약은 큰 문제는 아닙니다. 하지만 더 작은 표현은 더 많은 Value가 캐시 라인에 들어맞는다는 것을 의미합니다. 이는 캐시 미스 감소를 의미하며, 이는 속도에 영향을 미칩니다.
Value가 가장 큰 페이로드 크기에 맞춰 정렬되어야 하고, Lox 숫자 또는 Obj 포인터가 전체 8바이트를 필요로 한다면, 어떻게 더 작게 만들 수 있을까요? Lox와 같은 동적 타입 언어에서는 각 값이 페이로드뿐만 아니라 런타임에 값의 타입을 결정하는 데 필요한 추가 정보를 충분히 가지고 있어야 합니다. 만약 Lox 숫자가 이미 전체 8바이트를 사용하고 있다면, "이것은 숫자이다"라고 런타임에 알려주기 위한 몇 비트를 어디에 숨길 수 있을까요?
이것은 동적 언어 해커들에게는 오래된 문제 중 하나입니다. 정적 타입 언어는 일반적으로 이런 문제가 없기 때문에 특히 그들을 괴롭힙니다. 각 값의 타입은 컴파일 시간에 알려지므로, 런타임에 그것을 추적하기 위한 추가 메모리가 필요하지 않습니다. C 컴파일러가 32비트 int를 컴파일할 때, 결과 변수는 정확히 32비트의 저장 공간을 얻습니다.
동적 언어 개발자들은 정적 언어 진영에 뒤처지는 것을 싫어하기 때문에, 타입 정보와 페이로드를 적은 비트에 담는 매우 영리한 방법들을 고안해냈습니다. NaN 박싱도 그중 하나입니다. 이것은 자바스크립트나 루아처럼 모든 숫자가 배정밀도 부동 소수점인 언어에 특히 적합합니다. Lox도 마찬가지입니다.
30 . 3 . 1숫자인 것과 아닌 것?
최적화를 시작하기 전에, CPU가 부동 소수점 숫자를 어떻게 표현하는지 정말로 이해해야 합니다. 오늘날 거의 모든 머신은 IEEE 754라는 유서 깊은 표준에 인코딩된 동일한 방식을 사용합니다. 이는 일반인들에게 "IEEE 부동 소수점 산술 표준"으로 알려져 있습니다.
컴퓨터의 관점에서, 64비트 배정밀도 IEEE 부동 소수점 숫자는 다음과 같습니다.
-
오른쪽부터 시작하여, 처음 52비트는 가수(fraction), 유효 숫자(mantissa), 또는 유효 비트(significand)입니다. 이들은 숫자의 유효 숫자를 이진 정수 형태로 나타냅니다.
-
그 다음은 11개의 지수(exponent) 비트입니다. 이들은 가수가 소수점(정확히는 이진 소수점)으로부터 얼마나 떨어져 이동했는지를 알려줍니다.
-
가장 높은 비트는 부호 비트(sign bit)이며, 숫자가 양수인지 음수인지를 나타냅니다.
약간 모호하게 들릴 수 있지만, 이 장은 부동 소수점 표현에 대한 심층 분석이 아닙니다. 지수와 가수가 어떻게 함께 작동하는지 알고 싶다면, 제가 쓸 수 있는 것보다 더 나은 설명들이 이미 많이 있습니다.
우리에게 중요한 부분은 이 표준이 특별한 경우의 지수를 정의하고 있다는 것입니다. 모든 지수 비트가 설정되면, 단지 매우 큰 숫자를 나타내는 대신, 그 값은 다른 의미를 가집니다. 이 값들은 "숫자가 아님"(따라서 NaN) 값입니다. 이들은 무한대 또는 0으로 나누는 것과 같은 개념을 나타냅니다.
지수 비트가 모두 설정된 어떤 double 값도 가수의 비트에 관계없이 NaN입니다. 이는 수많은 다른 NaN 비트 패턴이 존재한다는 것을 의미합니다. IEEE 754는 이들을 두 가지 범주로 나눕니다. 가장 높은 가수 비트가 0인 값은 시그널링 NaN(signalling NaNs)이라고 불리며, 다른 값들은 콰이어트 NaN(quiet NaNs)이라고 불립니다. 시그널링 NaN은 0으로 나누는 것과 같은 오류 있는 계산의 결과로 의도됩니다. 칩은 이러한 값 중 하나가 생성되었을 때 이를 감지하여 프로그램을 완전히 중단시킬 수도 있습니다. 그것들을 읽으려고 하면 자폭할 수도 있습니다.
콰이어트 NaN은 사용하기에 더 안전하다고 여겨집니다. 그것들은 유용한 숫자 값을 나타내지 않지만, 적어도 건드린다고 손에 불이 붙지는 않을 것입니다.
모든 지수 비트와 가장 높은 가수 비트가 설정된 모든 double은 콰이어트 NaN입니다. 그러면 52개의 비트가 남습니다. 우리는 인텔의 "QNaN 부동 소수점 미정(QNaN Floating-Point Indefinite)" 값을 밟지 않기 위해 그 중 하나를 피하여 51개의 비트를 남깁니다. 남은 비트들은 무엇이든 될 수 있습니다. 2,251,799,813,685,248개의 고유한 콰이어트 NaN 비트 패턴에 대해 이야기하고 있습니다.
이것은 64비트 double이 모든 다양한 부동 소수점 숫자 값을 저장할 충분한 공간을 가지고 있으며, 또한 우리가 원하는 대로 사용할 수 있는 51비트의 데이터를 위한 공간도 가지고 있다는 것을 의미합니다. 이는 Lox의 nil, true, false 값을 표현하기 위해 몇 가지 비트 패턴을 할당하기에 충분한 공간입니다. 하지만 Obj 포인터는요? 포인터도 전체 64비트가 필요하지 않나요?
다행히도, 우리에게는 또 다른 비장의 카드가 있습니다. 네, 기술적으로 64비트 아키텍처의 포인터는 64비트입니다. 하지만 제가 아는 어떤 아키텍처도 실제로는 그 전체 주소 공간을 사용하지 않습니다. 대신, 오늘날 가장 널리 사용되는 칩들은 항상 하위 48비트만 사용합니다. 남은 16비트는 지정되지 않았거나 항상 0입니다.
51비트가 있다면, 48비트 포인터를 남는 3비트와 함께 그 안에 채워 넣을 수 있습니다. 이 세 비트는 nil, 불리언, Obj 포인터를 구별하기 위한 작은 타입 태그를 저장하기에 충분합니다.
이것이 NaN 박싱입니다. 단일 64비트 double 안에, 모든 다양한 부동 소수점 숫자 값, 포인터, 또는 다른 몇 가지 특별한 센티넬(sentinel) 값을 저장할 수 있습니다. 현재 Value 구조체의 절반 메모리 사용량으로, 모든 충실도를 유지합니다.
이 표현의 특히 좋은 점은 숫자 double 값을 "박싱된" 형태로 변환할 필요가 없다는 것입니다. Lox 숫자는 그저 평범한 64비트 double입니다. Lox는 동적 타입이므로 사용하기 전에 여전히 타입을 확인해야 하지만, "값"에서 "숫자"로 가기 위해 비트 시프트나 포인터 간접 참조를 할 필요는 없습니다.
다른 값 타입의 경우, 물론 변환 단계가 있습니다. 하지만 다행히도, 우리 VM은 값에서 원시 타입으로 가는 모든 메커니즘을 몇 개의 매크로 뒤에 숨깁니다. 그 매크로들을 NaN 박싱을 구현하도록 다시 작성하면, VM의 나머지 부분은 그대로 작동할 것입니다.
30 . 3 . 2조건부 지원
이 새로운 표현 방식의 세부 사항이 아직 머릿속에 명확하지 않을 수 있습니다. 걱정 마세요, 구현 작업을 진행하면서 명확해질 것입니다. 그 전에, 컴파일 시간 스캐폴딩(scaffolding)을 마련할 것입니다.
이전 최적화에서는 이전의 느린 코드를 다시 작성하고 그것으로 끝냈습니다. 이것은 약간 다릅니다. NaN 박싱은 칩이 부동 소수점 숫자와 포인터를 표현하는 방식에 대한 매우 낮은 수준의 세부 사항에 의존합니다. 아마도 여러분이 접할 대부분의 CPU에서 작동할 것입니다, 하지만 완전히 확신할 수는 없습니다.
값 표현 방식 때문에 우리 VM이 특정 아키텍처에 대한 지원을 완전히 잃는다면 매우 좋지 않을 것입니다. 이를 피하기 위해, 우리는 Value의 이전 태그드 유니온 구현과 새로운 NaN-박싱된 형태 둘 다를 지원할 것입니다. 이 플래그를 사용하여 컴파일 시간에 어떤 표현 방식을 사용할지 선택합니다.
#include <stdint.h>
#define NAN_BOXING
#define DEBUG_PRINT_CODE
만약 이것이 정의되어 있다면, VM은 새로운 형태를 사용합니다. 그렇지 않으면, 이전 방식으로 되돌아갑니다. 값 표현 방식의 세부 사항에 신경 쓰는 몇몇 코드 조각들(주로 Value를 감싸고 푸는 몇 개의 매크로)은 이 플래그가 설정되었는지 여부에 따라 달라집니다. VM의 나머지 부분은 평소처럼 계속 작동할 수 있습니다.
대부분의 작업은 "value" 모듈에서 새로운 타입을 위한 섹션을 추가하는 곳에서 발생합니다.
typedef struct ObjString ObjString;
#ifdef NAN_BOXING typedef uint64_t Value; #else
typedef enum {
NaN 박싱이 활성화되면, Value의 실제 타입은 평평한(flat), 부호 없는 64비트 정수입니다. 대신 double을 사용할 수도 있었는데, 그러면 Lox 숫자를 다루는 매크로가 약간 더 간단해졌을 것입니다. 하지만 다른 모든 매크로는 비트 연산을 수행해야 하므로 uint64_t가 훨씬 더 친화적인 타입입니다. 이 모듈 밖에서는 VM의 나머지 부분이 어느 쪽이든 크게 신경 쓰지 않습니다.
이 매크로들을 다시 구현하기 전에, 이전 표현 방식의 정의 끝에 있는 #ifdef의 #else 브랜치를 닫습니다.
#define OBJ_VAL(object) ((Value){VAL_OBJ, {.obj = (Obj*)object}})
#endif
typedef struct {
남은 작업은 단순히 첫 번째 #ifdef 섹션을 #else 쪽에 이미 있는 모든 것들의 새로운 구현으로 채우는 것입니다. 가장 쉬운 것부터 가장 어려운 것까지, 한 번에 하나의 값 타입씩 처리해 나갈 것입니다.
30 . 3 . 3숫자
NaN 박싱에서 가장 직접적인 표현을 가지므로 숫자부터 시작하겠습니다. C double을 NaN-박싱된 clox Value로 "변환"하기 위해, 우리는 비트 하나도 건드릴 필요가 없습니다. 표현 방식이 정확히 동일하기 때문입니다. 하지만 Value를 uint64_t로 정의함으로써 더 어려워진 이 사실을 C 컴파일러에게 납득시켜야 합니다.
컴파일러가 double이라고 생각하는 비트 집합을 uint64_t로 사용하거나, 그 반대로 사용하도록 해야 합니다. 이것을 타입 퍼닝(type punning)이라고 합니다. C와 C++ 프로그래머들은 벨보텀 바지와 8트랙 테이프 시절부터 이 작업을 해왔지만, 언어 사양은 이를 수행하는 여러 방법 중 어떤 것이 공식적으로 승인되었는지 명확히 밝히는 것을 주저했습니다.
저는 C와 C++ 표준 모두에서 지원된다고 믿는, double을 Value로 변환하고 다시 되돌리는 한 가지 방법을 알고 있습니다. 안타깝게도, 이것은 단일 표현식에 맞지 않으므로, 변환 매크로는 헬퍼 함수를 호출해야 합니다. 첫 번째 매크로는 다음과 같습니다.
typedef uint64_t Value;
#define NUMBER_VAL(num) numToValue(num)
#else
그 매크로는 double을 여기로 전달합니다.
#define NUMBER_VAL(num) numToValue(num)
static inline Value numToValue(double num) { Value value; memcpy(&value, &num, sizeof(double)); return value; }
#else
이상하죠? 일련의 바이트를 그 값을 전혀 바꾸지 않고 다른 타입을 가진 것으로 취급하는 방법이 memcpy()라고요? 이것은 끔찍하게 느려 보입니다. 지역 변수를 생성하고, 그 주소를 시스템 호출을 통해 운영 체제에 전달하여 몇 바이트를 복사합니다. 그런 다음 결과(입력과 정확히 동일한 바이트)를 반환합니다. 다행히도, 이것이 타입 퍼닝을 위한 지원되는 관용구이기 때문에 대부분의 컴파일러는 이 패턴을 인식하고 memcpy()를 완전히 최적화하여 없애버립니다.
Lox 숫자를 "언랩핑"하는 것은 거울상과 같습니다.
typedef uint64_t Value;
#define AS_NUMBER(value) valueToNum(value)
#define NUMBER_VAL(num) numToValue(num)
그 매크로는 이 함수를 호출합니다.
#define NUMBER_VAL(num) numToValue(num)
static inline double valueToNum(Value value) { double num; memcpy(&num, &value, sizeof(Value)); return num; }
static inline Value numToValue(double num) {
타입만 바꾼 것을 제외하고는 정확히 동일하게 작동합니다. 다시 말하지만, 컴파일러는 이 모든 것을 제거할 것입니다. memcpy() 호출이 사라지더라도, 컴파일러에게 우리가 어떤 memcpy()를 호출하는지 보여주어야 하므로 include도 필요합니다.
#define clox_value_h
#include <string.h>
#include "common.h"
그것은 결국 C 타입 검사기를 침묵시키는 것 외에는 아무것도 하지 않는 많은 코드였습니다. Lox 숫자에 대한 런타임 타입 테스트는 좀 더 흥미롭습니다. 만약 우리가 가진 것이 정확히 double의 비트뿐이라면, 그것이 double이라는 것을 어떻게 알 수 있을까요? 이제 비트 조작을 할 시간입니다.
typedef uint64_t Value;
#define IS_NUMBER(value) (((value) & QNAN) != QNAN)
#define AS_NUMBER(value) valueToNum(value)
우리는 숫자가 아닌 모든 Value가 특별한 콰이어트 NaN 표현을 사용할 것이라는 것을 알고 있습니다. 그리고 우리는 숫자에서 산술 연산을 수행함으로써 실제로 생성될 수 있는 의미 있는 NaN 표현들을 올바르게 피했다고 가정합니다. 만약 double이 모든 NaN 비트와 콰이어트 NaN 비트, 그리고 추가로 하나의 비트를 설정한다면, 우리는 그것이 다른 타입을 위해 우리가 따로 설정해 둔 비트 패턴 중 하나라고 꽤 확신할 수 있습니다. 이를 확인하기 위해, 우리는 설정된 콰이어트 NaN 비트들을 제외한 모든 비트를 마스킹합니다. 만약 모든 해당 비트들이 설정되어 있다면, 그것은 다른 Lox 타입의 NaN-박싱된 값이어야 합니다. 그렇지 않으면, 실제로 숫자입니다.
콰이어트 NaN 비트 집합은 다음과 같이 선언됩니다.
#ifdef NAN_BOXING
#define QNAN ((uint64_t)0x7ffc000000000000)
typedef uint64_t Value;
C가 이진 리터럴을 지원했다면 좋았을 텐데요. 하지만 변환해 보면, 그 값이 다음 그림과 같다는 것을 알 수 있습니다.
이것은 정확히 모든 지수 비트, 콰이어트 NaN 비트, 그리고 인텔 값을 피하기 위한 하나의 추가 비트입니다.
30 . 3 . 4Nil, true, and false
다음으로 처리할 타입은 nil입니다. nil 값은 하나뿐이므로 이를 표현하기 위해 단일 비트 패턴만 필요하기에 매우 간단합니다. 다른 두 개의 싱글톤 값, 즉 true와 false라는 두 개의 불리언이 있습니다. 이것은 총 세 개의 고유한 비트 패턴을 요구합니다.
두 비트는 네 가지 다른 조합을 제공하며, 이는 충분합니다. 우리는 사용하지 않는 가수 공간의 가장 낮은 두 비트를 "타입 태그"로 사용해서 이 세 싱글톤 값 중 어떤 것을 보고 있는지 결정합니다. 세 가지 타입 태그는 다음과 같이 정의됩니다.
#define QNAN ((uint64_t)0x7ffc000000000000)
#define TAG_NIL 1 // 01. #define TAG_FALSE 2 // 10. #define TAG_TRUE 3 // 11.
typedef uint64_t Value;
따라서 nil의 표현은 우리의 콰이어트 NaN 표현을 정의하는 데 필요한 모든 비트와 nil 타입 태그 비트들을 함께 포함합니다.
코드에서는 비트를 다음과 같이 확인합니다.
#define AS_NUMBER(value) valueToNum(value)
#define NIL_VAL ((Value)(uint64_t)(QNAN | TAG_NIL))
#define NUMBER_VAL(num) numToValue(num)
우리는 단순히 콰이어트 NaN 비트와 타입 태그를 비트별 OR 연산하고, C 컴파일러에게 그 비트들이 무엇을 의미하는지 가르치기 위해 약간의 캐스트를 추가합니다.
nil은 단일 비트 표현만을 가지므로, uint64_t에 대한 등호 연산을 사용하여 Value가 nil인지 확인할 수 있습니다.
typedef uint64_t Value;
#define IS_NIL(value) ((value) == NIL_VAL)
#define IS_NUMBER(value) (((value) & QNAN) != QNAN)
true와 false 값을 어떻게 정의하는지 짐작할 수 있을 것입니다.
#define AS_NUMBER(value) valueToNum(value)
#define FALSE_VAL ((Value)(uint64_t)(QNAN | TAG_FALSE)) #define TRUE_VAL ((Value)(uint64_t)(QNAN | TAG_TRUE))
#define NIL_VAL ((Value)(uint64_t)(QNAN | TAG_NIL))
비트들은 다음과 같습니다.
C bool을 Lox Boolean으로 변환하기 위해, 이 두 싱글톤 값과 오래된 조건 연산자에 의존합니다.
#define AS_NUMBER(value) valueToNum(value)
#define BOOL_VAL(b) ((b) ? TRUE_VAL : FALSE_VAL)
#define FALSE_VAL ((Value)(uint64_t)(QNAN | TAG_FALSE))
아마 더 영리한 비트 연산 방식이 있을 테지만, 제 생각에는 컴파일러가 저보다 더 빠르게 그것을 찾아낼 수 있을 것입니다. 반대 방향으로 가는 것은 더 간단합니다.
#define IS_NUMBER(value) (((value) & QNAN) != QNAN)
#define AS_BOOL(value) ((value) == TRUE_VAL)
#define AS_NUMBER(value) valueToNum(value)
Lox에는 정확히 두 가지 불리언 비트 표현만 존재한다는 것을 알고 있으므로(C에서는 0이 아닌 모든 값이 "참"으로 간주될 수 있는 것과 달리), true가 아니라면 false여야 합니다. 이 매크로는 여러분이 Lox 불리언이라고 아는 Value에만 호출한다고 가정합니다. 이를 확인하기 위해, 매크로가 하나 더 있습니다.
typedef uint64_t Value;
#define IS_BOOL(value) (((value) | 1) == TRUE_VAL)
#define IS_NIL(value) ((value) == NIL_VAL)
좀 이상해 보입니다. 더 명확한 매크로는 다음과 같을 것입니다.
#define IS_BOOL(v) ((v) == TRUE_VAL || (v) == FALSE_VAL)
안타깝게도, 그것은 안전하지 않습니다. 확장된 코드에서 v가 두 번 언급되는데, 이는 해당 표현식에 어떤 부작용이 있다면 두 번 실행될 것임을 의미합니다. 매크로가 별도의 함수를 호출하도록 할 수도 있지만, 으, 정말 귀찮은 일입니다.
대신, 우리는 단 두 가지 유효한 불리언 비트 패턴을 병합하기 위해 값에 1을 비트별 OR 연산합니다. 그렇게 하면 값은 세 가지 잠재적인 상태 중 하나가 됩니다.
-
FALSE_VAL이었고 이제TRUE_VAL로 변환되었습니다. -
TRUE_VAL이었고| 1연산이 아무것도 하지 않아 여전히TRUE_VAL입니다. -
다른, 불리언이 아닌 값입니다.
이 시점에서, 우리는 결과를 TRUE_VAL과 비교하여 첫 두 상태에 있는지 아니면 세 번째 상태에 있는지 간단히 확인할 수 있습니다.
30 . 3 . 5객체
마지막 값 타입이 가장 어렵습니다. 싱글톤 값과 달리, NaN 안에 박싱해야 할 수십억 개의 다른 포인터 값들이 있습니다. 이것은 이러한 특정 NaN들이 Obj 포인터임을 나타내는 일종의 태그와, 주소 자체를 위한 공간이 둘 다 필요하다는 것을 의미합니다.
싱글톤 값에 사용했던 태그 비트들은 제가 포인터 자체를 저장하기로 결정한 영역에 있기 때문에, 그곳에서 값이 객체 참조임을 나타내기 위해 다른 비트를 쉽게 사용할 수 없습니다. 하지만 우리가 사용하지 않는 다른 비트가 있습니다. 모든 NaN 값은 숫자가 아니므로(이름에 나와 있듯이), 부호 비트는 아무것도 사용되지 않습니다. 우리는 그것을 객체를 위한 타입 태그로 사용할 것입니다. 만약 우리의 콰이어트 NaN 중 하나가 부호 비트를 설정했다면, 그것은 Obj 포인터입니다. 그렇지 않으면, 이전 싱글톤 값 중 하나여야 합니다.
부호 비트가 설정되면, 남은 낮은 비트들은 Obj에 대한 포인터를 저장합니다.
원시 Obj 포인터를 Value로 변환하려면, 포인터를 가져와 모든 콰이어트 NaN 비트와 부호 비트를 설정합니다.
#define NUMBER_VAL(num) numToValue(num)
#define OBJ_VAL(obj) \ (Value)(SIGN_BIT | QNAN | (uint64_t)(uintptr_t)(obj))
static inline double valueToNum(Value value) {
포인터 자체는 전체 64비트이며, 원칙적으로는 일부 콰이어트 NaN 및 부호 비트와 겹칠 수 있습니다. 하지만 실제로는, 제가 테스트한 아키텍처들에서는 포인터의 48번째 비트 위 모든 것이 항상 0입니다. 여기에는 많은 캐스팅이 일어나고 있는데, 제가 발견하기로는 가장 까다로운 C 컴파일러 중 일부를 만족시키기 위해 필요하지만, 최종 결과는 단순히 일부 비트를 함께 끼워 넣는 것입니다.
부호 비트는 다음과 같이 정의합니다.
#ifdef NAN_BOXING
#define SIGN_BIT ((uint64_t)0x8000000000000000)
#define QNAN ((uint64_t)0x7ffc000000000000)
Obj 포인터를 다시 얻으려면, 그 모든 추가 비트들을 단순히 마스킹하여 제거합니다.
#define AS_NUMBER(value) valueToNum(value)
#define AS_OBJ(value) \ ((Obj*)(uintptr_t)((value) & ~(SIGN_BIT | QNAN)))
#define BOOL_VAL(b) ((b) ? TRUE_VAL : FALSE_VAL)
물결표(~)는 이전에 충분히 비트 조작을 해보지 않았다면 마주치지 못했을 수도 있지만, 비트별 NOT 연산자입니다. 피연산자의 모든 1과 0을 반전시킵니다. 값을 콰이어트 NaN 비트와 부호 비트의 비트별 부정으로 마스킹함으로써, 해당 비트들을 지우고 포인터 비트만 남깁니다.
마지막 매크로입니다.
#define IS_NUMBER(value) (((value) & QNAN) != QNAN)
#define IS_OBJ(value) \ (((value) & (QNAN | SIGN_BIT)) == (QNAN | SIGN_BIT))
#define AS_BOOL(value) ((value) == TRUE_VAL)
Obj 포인터를 저장하는 Value는 부호 비트가 설정되어 있지만, 모든 음수도 마찬가지입니다. Value가 Obj 포인터인지 확인하려면, 부호 비트와 모든 콰이어트 NaN 비트가 설정되어 있는지 확인해야 합니다. 이는 싱글톤 값의 타입을 감지하는 방식과 유사하지만, 이번에는 부호 비트를 태그로 사용합니다.
30 . 3 . 6Value 함수
VM의 나머지 부분은 보통 Value와 작업할 때 매크로를 통과하므로, 거의 완료되었습니다. 하지만 "value" 모듈에는 Value의 블랙 박스 안을 들여다보고 그 인코딩을 직접 다루는 몇 가지 함수가 있습니다. 그것들도 수정해야 합니다.
첫 번째는 printValue()입니다. 각 값 타입에 대한 별도의 코드가 있습니다. 더 이상 명시적인 타입 enum이 없으므로, 대신 일련의 타입 테스트를 사용하여 각 종류의 값을 처리합니다.
void printValue(Value value) {
in printValue()
#ifdef NAN_BOXING if (IS_BOOL(value)) { printf(AS_BOOL(value) ? "true" : "false"); } else if (IS_NIL(value)) { printf("nil"); } else if (IS_NUMBER(value)) { printf("%g", AS_NUMBER(value)); } else if (IS_OBJ(value)) { printObject(value); } #else
switch (value.type) {
이것은 기술적으로 switch 문보다 약간 느리지만, 실제로 스트림에 쓰는 오버헤드에 비하면 무시할 수 있는 수준입니다.
우리는 여전히 원래의 태그드 유니온 표현을 지원하므로, 이전 코드를 유지하고 #else 조건부 섹션 안에 포함시킵니다.
}
in printValue()
#endif
}
다른 작업은 두 값의 동등성을 테스트하는 것입니다.
bool valuesEqual(Value a, Value b) {
in valuesEqual()
#ifdef NAN_BOXING return a == b; #else
if (a.type != b.type) return false;
그보다 더 간단할 수는 없습니다! 두 비트 표현이 동일하면, 값은 같습니다. 각 싱글톤 값은 고유한 비트 표현을 가지고 자신에게만 같으므로, 싱글톤 값에 대해서는 올바르게 작동합니다. 또한 객체는 동일성(identity)을 동등성(equality)에 사용하므로, Obj 포인터에 대해서도 올바르게 작동합니다. 즉, 두 Obj 참조는 정확히 동일한 객체를 가리킬 때만 같습니다.
숫자에도 대부분 정확합니다. 다른 비트 표현을 가진 대부분의 부동 소수점 숫자는 서로 다른 숫자 값입니다. 하지만 안타깝게도, IEEE 754에는 우리를 넘어뜨릴 함정이 있습니다. 저에게는 완전히 명확하지 않은 이유로, 표준은 NaN 값들이 자기 자신과 같지 않아야 한다고 규정합니다. 이것은 우리가 자체적인 목적으로 사용하는 특별한 콰이어트 NaN에는 문제가 되지 않습니다. 하지만 Lox에서 "진짜" 산술 NaN을 생성하는 것이 가능하며, IEEE 754 숫자를 올바르게 구현하려면, 결과 값은 자기 자신과 같지 않아야 합니다. 더 구체적으로 말하자면:
var nan = 0/0; print nan == nan;
IEEE 754는 이 프로그램이 "false"를 출력해야 한다고 말합니다. 우리의 이전 태그드 유니온 표현 방식에서는 C 컴파일러가 double임을 아는 두 값에 ==를 적용하기 때문에 VAL_NUMBER 케이스에서 올바르게 작동합니다. 따라서 컴파일러는 IEEE 부동 소수점 동등성 비교를 수행하는 올바른 CPU 명령어를 생성합니다.
우리의 새로운 표현 방식은 Value를 uint64_t로 정의함으로써 이를 깨뜨립니다. IEEE 754를 완전히 준수하려면, 이 경우를 처리해야 합니다.
#ifdef NAN_BOXING
in valuesEqual()
if (IS_NUMBER(a) && IS_NUMBER(b)) { return AS_NUMBER(a) == AS_NUMBER(b); }
return a == b;
알아요, 이상하죠. 그리고 두 Lox 값의 동등성을 확인할 때마다 이 타입 테스트를 수행하는 데 성능 비용이 발생합니다. 만약 우리가 약간의 호환성을 희생할 의향이 있다면(NaN이 자기 자신과 같지 않다는 것을 누가 정말로 신경 쓸까요?), 이 부분을 생략할 수 있습니다. 여러분이 얼마나 꼼꼼하게 따를지는 여러분에게 달려 있습니다.
마지막으로, 이전 구현 주변의 조건부 컴파일 섹션을 닫습니다.
}
in valuesEqual()
#endif
}
이것으로 끝입니다. 이 최적화는 완료되었고, clox 가상 머신도 마찬가지입니다. 이것이 책의 마지막 새 코드 줄이었습니다.
30 . 3 . 7성능 평가
코드는 완성되었지만, 이러한 변경 사항으로 실제로 어떤 것을 개선했는지 여부를 여전히 파악해야 합니다. 이와 같은 최적화를 평가하는 것은 이전의 것과는 매우 다릅니다. 이전에는 프로파일러에서 명확하게 보이는 핫스팟이 있었습니다. 코드의 그 부분을 수정했고 핫스팟이 즉시 빨라지는 것을 확인할 수 있었습니다.
값 표현을 변경하는 효과는 더 확산되어 있습니다. 매크로는 사용되는 모든 곳에서 인라인으로 확장되므로, 성능 변경 사항이 코드베이스 전반에 걸쳐 분산되어 있어 많은 프로파일러가 잘 추적하기 어렵습니다. 특히 최적화된 빌드에서는 더욱 그렇습니다.
또한 우리는 변경 사항의 효과에 대해 쉽게 추론할 수 없습니다. 값을 더 작게 만들어서 VM 전반의 캐시 미스를 줄였습니다. 하지만 그 변경이 실제 세계 성능에 미치는 영향은 실행되는 Lox 프로그램의 메모리 사용량에 크게 좌우됩니다. 작은 Lox 마이크로 벤치마크는 메모리에 충분한 값이 분산되어 있지 않아 효과가 눈에 띄지 않을 수 있으며, C 메모리 할당자가 우리에게 넘겨준 주소 같은 것들도 결과에 영향을 미칠 수 있습니다.
우리가 작업을 올바르게 수행했다면, 기본적으로 모든 것이 약간 더 빨라질 것입니다. 특히 더 크고 복잡한 Lox 프로그램에서는 더욱 그렇습니다. 하지만 NaN-박싱 값을 처리할 때 수행하는 추가 비트 연산이 더 나은 메모리 사용으로 인한 이득을 상쇄할 가능성도 있습니다. 이와 같은 성능 작업은 여러분이 VM을 더 좋게 만들었다는 것을 쉽게 증명할 수 없기 때문에 불안감을 줍니다. 단 하나의 정밀하게 목표된 마이크로 벤치마크를 가리키며 "여기, 보이시죠?"라고 말할 수 없습니다.
대신, 우리에게 정말로 필요한 것은 더 큰 벤치마크 스위트입니다. 이상적으로는, 실제 애플리케이션에서 추출된 것이어야 합니다. 물론 Lox와 같은 토이 언어에는 그런 것이 존재하지 않겠지만요. 그러면 이 모든 것들에서 집합적인 성능 변화를 측정할 수 있습니다. 저는 몇 개의 더 큰 Lox 프로그램을 급조하기 위해 최선을 다했습니다. 제 머신에서는 새로운 값 표현 방식이 전반적으로 모든 것을 약 10% 더 빠르게 만드는 것으로 보입니다.
이는 특히 해시 테이블 조회를 더 빠르게 만든 엄청난 효과에 비하면 큰 개선은 아닙니다. 이 최적화를 추가한 주된 이유는 여러분이 경험할 수 있는 특정 종류의 성능 작업에 대한 좋은 예시이고, 솔직히 기술적으로 정말 멋있다고 생각하기 때문입니다. clox를 정말로 빠르게 만들려고 했다면, 이것이 제가 가장 먼저 시도할 것은 아니었을 것입니다. 아마도 다른, 더 쉬운 개선점들이 있을 것입니다.
하지만 만약 모든 쉬운 개선점을 이미 다 활용한 프로그램에서 작업하고 있다면, 어느 시점에는 값 표현 방식을 튜닝하는 것을 고려하고 싶을 수도 있습니다. 이 장이 그 영역에서 여러분이 가질 수 있는 몇 가지 선택지에 빛을 비춰주었기를 바랍니다.
30 . 4다음 단계는?
이제 Lox 언어와 우리의 두 인터프리터에 대한 작업은 여기서 멈추겠습니다. 새로운 언어 기능과 영리한 속도 개선을 추가하며 영원히 그것을 만지작거릴 수 있습니다. 하지만 이 책에서는 이제 우리의 작업이 완료되었다고 말할 자연스러운 지점에 도달했다고 생각합니다. 지난 여러 페이지에서 배운 모든 것을 다시 되풀이하지는 않겠습니다. 여러분은 저와 함께 그 과정을 겪었고 기억하고 있을 것입니다. 대신, 이제 여러분이 어디로 나아갈 수 있을지에 대해 잠시 이야기하고 싶습니다.
여러분의 프로그래밍 언어 여정에서 다음 단계는 무엇인가요?
대부분의 여러분은 아마도 경력의 상당 부분을 컴파일러나 인터프리터 작업에 보내지 않을 것입니다. 그것은 컴퓨터 과학 학계 파이의 꽤 작은 조각이며, 산업 소프트웨어 엔지니어링에서는 훨씬 더 작은 부분입니다. 괜찮습니다. 설령 평생 다시는 컴파일러 작업을 하지 않더라도, 여러분은 분명히 컴파일러를 사용할 것이며, 이 책이 여러분이 사용하는 프로그래밍 언어들이 어떻게 설계되고 구현되는지에 대한 더 나은 이해를 갖추도록 해주었기를 바랍니다.
또한 여러분은 몇 가지 중요하고 기본적인 데이터 구조를 배웠고, 저수준 프로파일링 및 최적화 작업을 연습했습니다. 그러한 전문 지식은 어떤 도메인에서 프로그래밍하든 도움이 됩니다.
또한 제가 여러분에게 문제를 보고 해결하는 새로운 방식을 제공했기를 바랍니다. 언어 작업을 다시는 하지 않더라도, 얼마나 많은 프로그래밍 문제가 언어와 유사하게 보일 수 있는지 발견하고 놀랄 수도 있습니다. 어쩌면 여러분이 작성해야 할 보고서 생성기는 생성기가 "실행"하는 일련의 스택 기반 "명령어"로 모델링될 수 있을 것입니다. 여러분이 렌더링해야 할 사용자 인터페이스는 AST를 순회하는 것과 매우 비슷해 보입니다.
만약 프로그래밍 언어의 깊은 세계로 더 들어가고 싶다면, 탐험할 터널의 가지들에 대한 몇 가지 제안이 있습니다.
-
우리의 간단한 단일 패스 바이트코드 컴파일러는 우리를 주로 런타임 최적화 방향으로 이끌었습니다. 성숙한 언어 구현에서는 컴파일 시간 최적화가 일반적으로 더 중요하며, 컴파일러 최적화 분야는 믿을 수 없을 만큼 풍부합니다. 고전적인 컴파일러 책을 집어 들고, clox 또는 jlox의 프론트엔드를 흥미로운 중간 표현과 최적화 패스를 가진 정교한 컴파일 파이프라인으로 재구축해보세요.
동적 타이핑은 여러분이 얼마나 멀리 갈 수 있는지에 제약을 가하겠지만, 여전히 할 수 있는 많은 일이 있습니다. 아니면 크게 도약하여 Lox에 정적 타입과 타입 검사기를 추가하고 싶을 수도 있습니다. 그것은 분명히 여러분의 프론트엔드에 더 많은 처리할 거리를 제공할 것입니다.
-
이 책에서 저는 정확성을 추구하지만, 특별히 엄격하지는 않습니다. 제 목표는 주로 여러분에게 언어 작업에 대한 직관과 느낌을 주는 것입니다. 더 정밀한 것을 선호한다면, 프로그래밍 언어 학계의 모든 세계가 여러분을 기다리고 있습니다. 언어와 컴파일러는 컴퓨터가 존재하기 전부터 공식적으로 연구되어 왔으므로, 파서 이론, 타입 시스템, 의미론, 형식 논리에 대한 책과 논문은 넘쳐납니다. 이 길을 따라가면 컴퓨터 과학 논문을 읽는 방법도 배우게 될 것이며, 이는 그 자체로 귀중한 기술입니다.
-
아니면, 단순히 언어를 해킹하고 만드는 것을 정말로 즐긴다면, Lox를 가져다가 자신만의 놀이감으로 만들 수 있습니다. 여러분의 눈을 즐겁게 하는 구문으로 변경하세요. 누락된 기능을 추가하거나 마음에 들지 않는 기능을 제거하세요. 새로운 최적화를 집어넣으세요.
결국 여러분은 다른 사람들도 사용할 수 있다고 생각하는 무언가를 만들게 될 수도 있습니다. 그것은 프로그래밍 언어의 인기라는 매우 독특한 세계로 여러분을 이끌 것입니다. 문서, 예제 프로그램, 도구, 유용한 라이브러리를 작성하는 데 엄청난 시간을 보낼 것으로 예상하세요. 이 분야는 사용자들을 확보하기 위해 경쟁하는 언어들로 붐빕니다. 그 공간에서 번성하려면 마케팅 담당자의 모자를 쓰고 판매해야 할 것입니다. 모든 사람이 그런 대외적인 작업을 즐기는 것은 아니지만, 만약 여러분이 그렇다면, 사람들이 여러분의 언어를 사용하여 자신을 표현하는 것을 보는 것은 믿을 수 없을 정도로 보람 있는 일입니다.
아니면 이 책이 여러분의 갈증을 해소했고, 여기서 멈출 수도 있습니다. 어떤 길을 가든, 혹은 가지 않든, 저는 여러분의 마음에 한 가지 교훈이 자리 잡기를 바랍니다. 저처럼, 여러분도 처음에는 프로그래밍 언어에 위축되었을 수도 있습니다. 하지만 이 장들에서 여러분은 우리 일반인들도 직접 부딪히고 한 번에 한 단계씩 나아간다면 정말 어려운 자료도 다룰 수 있다는 것을 보았습니다. 컴파일러와 인터프리터를 다룰 수 있다면, 여러분이 마음먹은 어떤 일이든 할 수 있습니다.
도전 과제
학교 마지막 날 숙제를 내주는 것은 잔인해 보이지만, 여름 방학 동안 정말로 할 일이 있다면:
-
프로파일러를 실행하고, 몇 가지 벤치마크를 돌려 VM의 다른 핫스팟을 찾아보세요. 런타임에서 개선할 수 있는 것이 보이나요?
-
실제 사용자 프로그램의 많은 문자열은 짧은 경우가 많으며, 종종 한두 글자에 불과합니다. clox에서는 문자열을 인터닝(intern)하기 때문에 이 문제가 덜하지만, 대부분의 VM은 그렇지 않습니다. 그렇지 않은 VM의 경우, 작은 문자열 각각에 대해 작은 문자 배열을 힙에 할당하고 그 값을 해당 배열에 대한 포인터로 표현하는 것은 낭비입니다. 종종 포인터가 문자열의 문자들보다 더 클 때가 있습니다. 고전적인 트릭은 문자열의 문자를 값 안에 인라인으로 저장하는, 작은 문자열을 위한 별도의 값 표현을 가지는 것입니다.
clox의 원래 태그드 유니온 표현에서 시작하여 그 최적화를 구현해 보세요. 관련 벤치마크 몇 개를 작성하고 도움이 되는지 확인해 보세요.
-
이 책을 통해 얻은 경험을 되돌아보세요. 어떤 부분이 여러분에게 잘 맞았고, 어떤 부분이 그렇지 않았나요? 상향식(bottom-up) 학습이 더 쉬웠나요, 아니면 하향식(top-down) 학습이 더 쉬웠나요? 삽화가 도움이 되었나요, 아니면 주의를 산만하게 했나요? 비유가 명확하게 했나요, 아니면 혼란스럽게 했나요?
자신의 개인적인 학습 스타일을 더 잘 이해할수록, 지식을 머릿속에 더 효과적으로 입력할 수 있습니다. 여러분이 가장 잘 배우는 방식에 맞춰 가르치는 자료를 특별히 찾을 수 있습니다.