자신만의 CPU

다섯 번째 백엔드를 더한다고 하면 능력이 늘어난 것처럼 들린다. 하지만 더 진실에 가까운 이야기는 루프가 닫혔다는 것이다 — 셀프호스트 컴파일러가, 언어 자신이 이미 써 둔 CPU와 만났다. 새로운 힘의 대부분은 새로운 타깃이 아니라, 두 개의 오래된 dogfood가 마침내 만나게 된 '재결합'이었다.

merelanguage-designessayself-hostingrisc-v

Mere에 다섯 번째 백엔드가 돋아났다. 인터프리터, C 백엔드, LLVM, WebAssembly에 더해, 이제 mere -rv는 프로그램을 플랫한 RV32IM 바이너리로 곧장 낮춘다 — 진짜 32비트 RISC-V 기계어이며, 외부 어셈블러도 링커도 필요 없다. 이렇게 적어 놓으면 능력 표에 열 하나를 더한 것처럼 읽힌다. 그 틀은 맞지만 요점을 놓친다. 이 백엔드가 해낸 흥미로운 일은 타깃을 늘린 것이 아니다. 이미 존재하던 두 가지 사이에서 루프를 닫은 것이다. 그리고 둘이 만나는 순간, 이 언어는 자기 자신이 써 낸 기계 위에 설 수 있게 되었다.

백엔드란 의견을 가진 어셈블러다

그 자체로만 보면, RISC-V 코드 생성기는 가장 놀랍지 않은 백엔드다. 값 모델은 이미 정해져 있었다 — 32비트 워드는 정수이거나 힙 포인터 둘 중 하나다 — 호출 규약도, 클로저 레이아웃도, 문자열과 Vec과 Map 런타임도, 한 번 설계해서 각 백엔드마다 다시 구현해 왔다. RV32IM으로 낮추는 일은 결국 레지스터를 고르고 명령어를 인코딩하는 것이다. 두 번의 패스, 옵코드 표, 여섯 형식에 흩뿌려진 즉치 비트들. 앞에 타입 검사기를 세운 어셈블러다. 만약 그것이 이야기의 전부였다면, changelog 한 줄의 값어치는 있어도 에세이의 값어치는 없다.

수고를 들일 가치를 만든 것은, 그 백엔드를 그 다음에 ’무엇을 향하게 할 수 있는가’였다.

이미 있었던 두 개의 반쪽

Mere에는 이미 셀프호스트 컴파일러가 있었다 — 렉서, 파서, Hindley–Milner 타입 검사기, 그리고 WebAssembly 코드 생성기, 모두 Mere로 작성되었고, 저마다 지난 몇 달에 걸쳐 독립된 dogfood로 공개되어 왔다. 그리고 Mere에는 이미 CPU 에뮬레이터의 작은 사다리도 있었다 — CHIP-8, 6502, 게임보이, RV32IM 코어, 이 또한 Mere로 작성되었고, 프로세서를 흉내 낼 때 무엇이 어려운지를 배우기 위해 만들어졌으며, 저마다 외부 레퍼런스와 대조되어 왔다.

관계없는 이유로 짜인 두 무더기의 코드. 다섯 번째 백엔드는 그 둘을 만나게 한 경첩이었다. 셀프호스트 컴파일러를 mere -rv로 컴파일하면 약 380킬로바이트의 RV32IM 바이너리가 나온다. 그 바이너리를 Mere로 작성된 RISC-V 에뮬레이터 위에서 돌리고 — 작은 Mere 프로그램을 건네 컴파일하게 하면 — 나오는 WebAssembly는, 같은 입력으로부터 네이티브 인터프리터가 뱉는 것과 바이트 단위로 일치한다. 컴파일러가, 컴파일하고 있다, 언어 자신이 써 낸 CPU 위에서.

한 호흡에 말하기엔 긴 문장이지만, 그 아래의 구조는 단순하다. 언어 → 그 언어 자신의 백엔드 → 그 언어 자신의 CPU → 그 CPU 위에서 도는 그 언어 자신의 컴파일러. 이 탑의 어느 층이든 같은 하나의 언어로 되어 있다. 대단한 것은 RISC-V 백엔드가 어렵다는 데 있지 않다. 대단한 것은, 부품이 모두 이미 거기 있었고, 저마다 다른 물음에 답하려고 만들어졌으며, 겨우 하나의 수수한 백엔드가 그것들을 한 장의 세로 단면으로 줄 세워 보였다는 데 있다.

세 개의 연산이면 콘솔이 된다

루프가 닫히면서 에뮬레이터는 ’가지고 놀 수 있는 것’이 되기도 했다. 판타지 콘솔에 프레임워크는 필요 없다. 필요한 것은 작은 하드웨어 계약이고, 여기서는 그것이 -rv 백엔드가 날것의 스토어·로드와 시스템 콜로 낮추는 세 개의 연산이다.

  • fb_set x y v — 고정 주소에 있는 64×32 프레임버퍼에 음영을 쓴다.
  • key n — 메모리에 매핑된 입력 레지스터에서 버튼의 눌림 상태를 읽는다.
  • present () — 프레임을 끝내고 호스트에 제어를 돌려준다.

세 번째가 조용히 흥미롭다. present는 하나의 ecall로 컴파일된다. 에뮬레이터는 그것을 보면 실행을 멈추고, 브라우저에 제어를 넘겨 프레임버퍼를 전송하고 키를 읽게 한 뒤, CPU를 ’바로 다음 명령’부터 재개한다. 카트리지의 시점에서 present는 한 프레임만 블록했다가 돌아오는 함수다. 그래서 게임의 메인 루프는 평범한 재귀가 된다 — 입력을 읽고, 움직이고, 그리고, present하고, 재귀한다 — 그리고 플레이어의 위치는 프레임을 가로질러 RISC-V 호출 스택 위에 얹힌다. 전역에도, 호스트 쪽 상태에도 얹히지 않는다. 콘솔의 루프는 코루틴이며, 그 연속(continuation)이란 프레임 사이에만 얼려 둔 기계 자신의 스택에 다름 아니다.

그 에뮬레이터를 WebAssembly 백엔드에 통과시키고, 몇 달 전 게임보이 에뮬레이터 dogfood가 필요로 했던 바로 그 DOM 외부 함수 인터페이스로 <canvas>에 배선하면, 전체가 브라우저 탭 안에서 돈다. Mere가 써 낸 CPU가 Mere가 써 낸 게임을 실행하고, URL로 닿을 수 있다. 또다시 같은 패턴 — ‘늘어난’ 능력의 대부분은 재결합이었다. 입력 배관도, canvas 전송도, 프레임마다의 콜백도, 모두 다른 데모를 위해 이미 존재했다. 새로 쓴 것은 extern 둘과 계약 하나뿐이다.

어느 층이든 제 청구서를 냈다

이 중 무엇도 깔끔하게 얻어지지 않았다. 그리고 바로 거기가 남길 가치가 있는 부분이다. 셀프호스트의 탑은 가차 없는 통합 테스트다. 작은 프로그램에서는 결코 밟지 않는 버그도, 프로그램이 380킬로바이트의 컴파일러가 되면 숨을 곳이 없다. 셀프호스트한 바이너리는 처음에 빈 출력을 냈고, 원인은 겹친 메모리 맵이었다 — 전역 영역이 64킬로바이트 자리에 있었고, 컴파일러의 코드는 88을 넘어 뻗어 있었다. 그래서 전역에 쓰는 순간 코드가 망가졌고, 실행은 잔해로 뛰어들었다. 코드가 64킬로바이트 안에 들어가는 작은 프로그램들은 몇 주 동안 무탈하게 통과하며 그것을 가려 주었다. 한 층 더 아래에서는, 조건 분기의 도달 거리가 ±4킬로바이트뿐이었다. 큰 프로그램의 함수는 그것을 넘어간다. 그래서 모든 분기를, 더 긴 점프를 건너뛰는 형태로 다시 써야 했다.

그리고 가장 오래된 상처가 다시 벌어졌다. 이 연재에는 이미 ‘64메가바이트 절벽’ 편이 있다 — WebAssembly 백엔드의 범프 할당기는 결코 해제하지 않으므로, 충분히 큰 실행은 고정된 선형 메모리의 끝을 밟고 넘어간다. 브라우저 콘솔은 새로운 각도에서 같은 절벽과 만났다. 그 에뮬레이트된 RAM은 8메가바이트의 기계어 워드 배열이고, 그것을 용량 배증식 할당기로 구축하면, 자리를 잡기까지 정점이 1억 8천만 바이트 가까이 치솟는다. 고치는 방법은 영리하지도 뭐하지도 않았다 — 이 모듈만 메모리 천장을 올리고 넘어간다 — 하지만 절벽은 앞선 편이 ’여기 있다’고 적어 둔 바로 그 자리에서 기다리고 있었다. 레이트레이싱과는 아무 상관없는 프로그램에서, 이제 닿게 된 것이다. 한 번 적어 둔 숫자가 계속 값을 치렀다.

닫힌 탑이 실제로 증명하는 것

이것을 과장하기는 쉽다. 같은 언어로 쓰인 CPU 위에서 같은 언어의 컴파일러가 돈다고 해서, 그 언어가 빨라지는 것도, 프로덕션에 쓸 만해지는 것도, 이 용도에 실용적이 되는 것도 아니다 — 에뮬레이트된 컴파일러는 네이티브보다 자릿수가 다르게 느리고, 이런 식으로 소프트웨어를 출시할 사람은 아무도 없다. 닫힌 루프가 사는 것은 더 좁고, 그리고 진짜다. 확신과, 교재다. 확신 — 인터프리터와 에뮬레이트된 RV32IM 바이너리에서 출력이 바이트 단위로 일치한다는 것은, 백엔드와 값 모델과 런타임이 마지막으로 뱉는 1바이트까지 합의하고 있다는 엄격한 교차 검증이기 때문이다. 교재 — 이제 스택 전체가 하나의 언어로 읽히기 때문이다. CPU를 읽고, 그 위에서 도는 컴파일러를 읽고, 그 컴파일러가 조립하는 게임을 읽는다. 맥락 전환 없이, 각 추상이 그 아래 추상의 어디에 얹혀 있는지를 정확히 짚어 낼 수 있다.

그것이 이 일의 정직한 형태다. 다섯 번째 백엔드는 확실히 Mere가 할 수 있는 일을 늘렸다. 하지만 그 ’늘어남’은 새로운 목적지가 아니었다. 그것은 루프였다 — 따로 짜인 두 dogfood가, 마침내 만나도록 허락되었다 — 그리고, 부품이 모두 같은 언어일 때, 소스 파일에서 ’내가 쓴 CPU 위에서 도는 게임’까지의 거리가, 마땅히 그래야 할 길이보다 훨씬 짧다는 발견이었다.

← Back to Notes