32비트로 남은 정수
기능을 더하는 것은 눈에 보이는 일이다. 더하지 않기로 결정하고, 그 이유를 보일 수 있는 것 — 그것이 언어 설계의 더 어려운 절반이다.
Mere는 네 개의 백엔드로 컴파일된다. 그중 셋 — 인터프리터, C 백엔드, LLVM 백엔드 — 은 정수를 64비트로 다룬다. 네 번째인 WebAssembly는 32비트로 다룬다. 다음에 할 일은 저절로 정해지는 듯하다. Wasm의 정수를 64비트로 넓혀서 넷을 맞추면 된다. 나는 여기에 한 세션을 썼고 아무것도 출시하지 않았다. 정작 흥미로운 작업은 “하지 않기로 결정하는” 쪽에 있었기 때문이다. 이 글은 그 결정이 어떻게 내려지는가에 대한 노트다. 결론보다는, 그것을 낳은 방법에 대한.
불일치가 아니라 계약에서 시작한다
“셋은 64, 하나는 32”를 보았을 때의 첫 직감은 그 32를 버그라고 부르는 것이다. 하지만 아니다. Mere는 정수 폭의 통일을 약속한 적이 한 번도 없다. 언어 레퍼런스는 처음부터 분명히 적어 두었다 — C와 LLVM은 64비트 정수, 인터프리터는 호스트의 네이티브 int(보통 63비트), Wasm은 32비트. 32비트에 들어가지 않는 정수 리터럴은 Wasm에서 컴파일 타임 에러가 된다. 범위를 밝히고, 더 넓은 백엔드를 가리키고, 문서로 링크하는 메시지와 함께 — 조용히 잘라내는 것이 아니라.
그러니 “Wasm은 32비트”는 깨진 약속이 아니다. 그것이 약속이다. 기능을 물리치는 첫수는 자신이 실제로 무엇을 약속했는지를 다시 읽는 것이다. “고쳐야 한다”고 느꼈던 것들의 놀랄 만큼 많은 부분이 “이것은 문서화된 범위가 의도대로 작동하는 것일 뿐”으로 녹아 없어진다. 진짜 틈은 조용히 오버플로되는 계산뿐이며 — 그것은 어느 백엔드나 자기 폭에서 하는 일로, C와 그 후손이 50년 동안 해온 것과 같다.
모순처럼 보이는 비대칭
정직하게 답해야 하는 반론은 이것이다. Wasm의 float는 이미 64비트다. float는 박싱되어
있다 — 값은 선형 메모리에 있는 8바이트짜리 f64를 가리키는 포인터이고, 연산은 언박싱하고,
네이티브 f64 명령으로 계산하고, 다시 박싱한다. float에 그 수고를 들였다면, 왜 int에는
들이지 않는가.
둘이 대칭이 아니기 때문이다. 그리고 그것을 꿰뚫어 보는 방법은 각각을 “박싱하지 않으면” 무엇이 일어나는지를 묻는 것이다.
int를 32비트로 두면 같은 의미를 더 좁은 범위에서 얻는다. 거의 모든 프로그램은 영향을
받지 않고, 경계는 드물며, 리터럴이라면 loud(명시적 에러)하다. float를 32비트로 두면 거의 모든
계산에서 반올림이 달라진다 — 0.1조차 같은 방식으로 표현되지 않는다 — 그래서 백엔드 간
결과가 전 영역에서 조용히 어긋난다. float에는 패리티를 지키는 “박싱하지 않는” 선택지가
없었다. int에는 있다. 같은 메커니즘, 반대의 판정. 깨지는 방식이 다르기 때문이다. 여기에 일반
법칙이 숨어 있다 — 같은 장치가 어떤 타입에는 옳고 다른 타입에는 그를 수 있다. 결정적인 물음은
“일관적인가”가 아니라 “내버려 두면 무엇이 깨지는가”다.
대안을 끝까지 설계한다
“float처럼 int도 박싱한다 — 그것이 유일하게 정합적인 방식이다”에서 멈추기는 쉬웠다. 나는 그 문장을 하마터면 쓸 뻔했다. 그것은 틀렸고, 틀렸음을 보여준 것은 처음의 그럴듯한 설계에서 멈추지 않고 설계를 끝까지 해낸 것이었다.
Wasm에 64비트 정수를 주는 방법은 둘이다. float처럼 박싱한다. 아니면 오브젝트 모델의 모든 값
슬롯을 4바이트에서 8바이트로 넓히고 int를 언박싱된 채로 저장한다. box-int는 작고 float와
기분 좋게 대칭이다 — 그리고 이 백엔드가 존재하는 이유 그 자체를 조용히 망친다. Wasm의
할당자는 실행 중에 해제하지 않는 bump 포인터이고, 이 백엔드가 살아 있는 이유는 브라우저다.
오래 도는 페이지, 플레이그라운드, 키 입력마다 정수 연산을 하는 게임. 모든 int를 박싱하면
그 하나하나가 두 번 다시 돌아오지 않는 8바이트를 할당한다. 니치 — 브라우저 탭 안의 bignum
연산 — 를 위한 correctness 수정이 주 용도를 새게 만든다. 이것은 차감하면 마이너스다. 다른
쪽, 8바이트 통일 슬롯은 정말로 옳지만, 코드 생성기의 약 1000곳에 더해 JavaScript 호스트
전체 — 채널까지 포함해 전부를 BigInt로 옮기는 — 를 건드린다. 현재 수요 제로에 대한 거대한
재작성이다.
내가 “유일한 방식”이라 부를 뻔한 대안은 둘 중 나쁜 쪽이었다. 그것을 끝까지 설계한 것이 이 결정을 어깨 으쓱임에서 판단으로 바꿨다.
영수증을 쓴다
기능을 물리치는 일은, 언제라면 받아들일지도 기록해야 비로소 정직해진다. 이 결정은 트리거와 함께 적혀 있다 — 브라우저 안에서 64비트 정수를 정말로 필요로 하는 첫 실제 dogfood가 나타날 때 — 그리고 그날을 위해 손을 뻗어야 할 설계와 함께. 동기가 한 번 실행의 correctness라면 box-int, 오래 도는 실행이나 성능이라면 uniform-i64, 각각에 전제 조건을 명시하며. 임계값이 문서화된 “노”는 성장의 거부가 아니다. 서두른 “예스”보다 작고 날카로운 약속이다.
규율
기능을 출시하는 것은 읽힌다. diff가 있고, 버전이 오르고, 변경 이력에 한 줄이 실린다. 물리치는 것은 밖에서 보면 아무 일도 일어나지 않은 하루처럼 보인다. 하지만 언어는, 흡수한 것과 적어도 같은 만큼, 거절한 것으로 정의된다 — 받아들인 기능은 저마다 다음 독자가 영원히 짊어질 무게다. 32비트로 남은 정수는 채워지기를 기다리는 틈이 아니다. 그것은 결정이다. 뒤에 계약이 있고, 그것을 정당화하는 비대칭이 있고, 백엔드의 진짜 목적에 비추어 측정된 기각된 대안이 있고, 바뀔 수 있는 날을 명시한 영수증이 있다. 그것이 좋은 “노”의 모양이다.
추기: 영수증의 기한이 왔다
이 글을 쓴 다음 날, 트리거가 나타났다 — 우연히 걸려 넘어졌다기보다 절반은 마중 나간 셈이지만,
어느 쪽이든 평범한 프로그램에서였다. 작은 날짜/시간 유틸리티 mdate: Unix 에포크를 밀리초로
건네면 UTC 타임스탬프가 돌아온다, 전부 정수 연산이다. 인터프리터와 네이티브 백엔드에서는 참조
구현과 바이트 단위로 일치한다. 그것을 브라우저 안에서 시계로 만들면, 틀린 시각을 표시하는 게
아니라 — 트랩한다: 오늘의 에포크 밀리초, 약 1.75×10¹²는 32비트에 담기지 않고, float에서
int로 가는 변환이 그것을 거부한다.
이것이 영수증의 조건 그대로다: 진짜 dogfood — 어차피 쓸 종류의 것 — 가 브라우저 안에서 64비트 정수를 필요로 하고, 게다가 오래 도는 실행(한 번이 아니라 시계)이다. 그래서 설계는 이미 명시해 둔 쪽 — box가 아니라 uniform한 64비트 슬롯.
이전의 결정은 틀리지 않았다. 수요가 0인데 그 무게를 지지 않아도 되는 한 달을 샀고, 지금 이 순간을 새로운 논쟁이 아니라 두 줄의 조회로 만들어 주었다. 그것이 바로 영수증의 요점이다: 좋은 노는 영구적이지 않고, 만료 조건이 붙은 결정이며 — 그 규율은, 조건이 발화한 날 그것을 지켜야 비로소 완성된다.