스케일에서 완전 일치를 유지하기

열 줄의 데모는 feature가 존재함을 증명한다. 천 줄의 프로그램은 feature가 조합됨을 증명한다. 천 줄을 넘는 SQL 풍 엔진이, 인터프리터와 세 코드 생성기 모두에서 꼭 같은 바이트를 내야 할 때, 나타나는 버그는 어느 하나의 feature 안에도 없다 —— 조합, 순서, 실제 코드만이 만드는 escape 안에 있다. 그중 몇몇은, 기준 그 자체를 바꿈으로써 고쳐진다.

merebackendsdifferential-testingdogfoodlanguage-design

이제 언어의 구현은 넷이다 —— 인터프리터와 세 코드 생성기 —— 그리고 규칙은, 넷 모두가 바이트 단위로 동일한 출력을 낸다는 것이다. 그 선을 열 줄의 데모에서 유지하기는 쉽다. 이번 회는, 그것을 작지 않은 프로그램에서 유지하는 이야기다: SQL 풍의 엔진, 천 줄을 넘고, 자체 자기 테스트를 수십 개 가진. 거기가, parity가 표어이기를 그치고, 버그를 찾기 시작하는 곳이다. 큰 프로그램은, 작은 것이 하지 않는 방식으로 언어를 행사하기 때문이다.

작은 프로그램은 feature를, 큰 것은 조합을 증명한다

짧은 예는 한 번에 하나의 feature를 행사하기 쉽다 —— 여기에 match, 여기에 클로저, 여기에 레코드. 그것은 feature가 존재하고, 고립되어 올바르게 낮아짐을 확인한다. 그것이 확인할 수 없는 것은, feature가 만날 때 무슨 일이 일어나는가다: 재결속된 변수를 포획하는 클로저가, region 안에서, region보다 오래 사는 컬렉션을 쥐고, variant를 재귀하는 show로 표시된다. 실제 코드는 그런 조합 안에 살고, 버그도 그렇다. 천 줄의 프로그램을 네 backend에서 컴파일하고 동일한 바이트를 요구하는 것은, 그 조합을 자동으로 테스트하는 한 방식이다 —— 그리고 깨진 것은 볼 가치가 있다. 저마다, “같은 의미”의 두 구현이 조용히 벌어진 곳이기 때문이다.

같은 의미가 벌어진 세 가지 방식

한 버그는, C backend가, 자기 자신에서 계산된 값에 이름을 재결속하는 let x = f x를 어떻게 다루는가에 있었다. 명백한 번역은 C 초기화자의 양변에 x를 놓고, 그것을 C는 금한다(변수는 자신의 초기화자에 나타나선 안 된다). 그래서 clang이 거부했다. 인터프리터에는 그런 규칙이 없어 문제없이 평가했다. 수정은, 결속을 임시 변수를 거친 두 단계로 컴파일하여, 새 x가 계산되는 동안에도 옛 x가 보이게 하는 것이었다. 이름을 결코 가리지 않는 데모에는, 이것이 아무것도 나타나지 않는다; 실제 코드가 평범한 let x = step(x) 패턴을 쓰는 순간에 나타난다.

또 하나는 더 미묘하고, Wasm backend의 메모리 모델에 살았다. region은 범프 포인터를 되감음으로써 메모리를 회수한다 —— 하지만 프로그램이 region 안에서 컬렉션을 할당하고, 그것을 region보다 오래 살도록 escape 시키면, 포인터를 되감는 것이, escape한 값이 아직 가리키는 메모리를 해제하고, 다음 할당이 그것을 덮어쓴다. 다른 backend는 escape하는 컬렉션을 따로 관리하여 이에 부딪히지 않았다; Wasm만이 region 할당자를 escape 값과 공유했다. 수정은 Wasm의 region 의미론을 다른 것과 맞췄다. 이것은 바로, region-안-할당하고 escape할 만큼 풍부하게 구조화된 프로그램 없이는 나타날 수 없는 버그다 —— 즉, 현실적인 무언가 없이는.

셋째는 문자열 등가에 있었다: 런타임에 만들어진 문자열이, 프로그램의 데이터에 구워진 문자열과 비교될 때, 내용이 아니라 포인터로 비교되고 있어서, 유래가 다른 두 같은 문자열이 조용히 다르다고 테스트되었다. 문자열 리터럴끼리만 비교하는 데모는 결코 그것을 당기지 않는다; 런타임 토큰을 기대 키워드와 비교하는 파서는, 끊임없이 그것을 당긴다.

이 중 어느 것도 극적인 실패가 아니다. 그것들은 조용한 불일치 —— 그럴듯하지만 한 backend에서는 우연히 틀린 출력을 내는 부류 —— 이고, 그것들이 잡힌 유일한 이유는, 출력이 인터프리터에 대해 바이트 단위로 비교되었기 때문이다. 눈으로 보는 검사는 셋 다 지나쳤을 것이다.

수정이 기준으로 향할 때

모든 벌어짐이, codegen 쪽에서 으깨야 할 컴파일러 버그는 아니다. 하나는, 맵이 엔트리를 반복하는 순서에 관한 것이었다. backend들은 일치하지 않았고, 그것은 엄격한 바이트 단위 규칙 아래에서는 실패다 —— 설령 어느 backend의 답도 틀렸다 하기 어렵더라도(맵에 고유한 순서는 없다). 해결은, 순서를 인터프리터에서 고정하여, 삽입 순서로 반복하게 하고, 정의된 답이 하나 있어 네 backend가 그것에 일치하도록 하는 것이었다.

이것은 멈춰 설 가치가 있다. 기준이 무엇을 위한 것인가를 보이기 때문이다. 인터프리터는 신성하지 않다; 그것은 합의된 정의이고, 정의가 미규정일 때 —— “맵의 반복 순서”가 정말로 그렇듯 —— 옳은 한 수는, 네 backend를 우연히 일치하도록 구슬리는 것이 아니라, 다른 모든 것이 재어지는 유일한 곳, 거기서 그것을 못 박는 것일 수 있다. 그리고 기준은 일부러 엄격한 채다: 순서 같은 단지 겉모습의 차이도, 여전히 차이이고, “충분히 가깝다”를 허용하는 것은, parity 규칙이 닫기 위해 존재하는 바로 그 문을 다시 열어 버린다 —— 어느 backend를 썼는가에, 보이는 것을 조용히 바꾸게 하는 것을.

유지하는 데 무엇이 드는가

네 구현을 동일하게 유지하는 것은, 한 번의 성취가 아니다; 그것은 서 있는 비용이다. 현실적인 프로그램 들이 회귀 코퍼스가 된다: 열을 넘고, 최대는 천 줄 초과로, 모든 backend에서 돌려지고 diff된다, 매번. 나중의 변경이 들여온 벌어짐은, 다음에 코퍼스가 돌 때 잡히고, 사용자에게 발견되지 않는다. 그리고 커버리지 장부의 규율이 유지된다 —— 새 feature는, 전 backend에 착지하고 코퍼스가 여전히 동일하게 나올 때까지, 완성이 아니다. “backend는 의미론적 변수가 아니다”의 대가는, 실제 프로그램의 테스트 스위트와, 그 어느 것도 벌어지게 하지 않는 거부로, 계속해서 치러진다.

보답은, 바람이 아니라 사실로 말해질 만큼 구체적이다: 천 줄의 프로그램이, 네 가지 다른 방식으로 —— 인터프리터에 걸어지고, C를 통해 컴파일되고, LLVM을 통해 컴파일되고, WebAssembly로 컴파일되어 —— 동일한 바이트를 표시하는 언어 구현. 그것이, 현실적인 무언가 위에서 현금화된 규율의 전부다.

네 backend 중 셋이, 이제 같은 feature의 사다리를 통과하고, 같은 출력에 유지되었다. 넷째는, 모든 비교에 함께 있었지만, 아직 직접 보지는 않았다. 그리고 그것이, 가장 이례적인 제약을 가진다 —— 선형 메모리를 가지고, 함수 포인터의 네이티브한 개념을 갖지 않는, 샌드박스화된 타깃. 다음 회: WebAssembly backend, 그리고, 웹 페이지 안에서 도는 기계로 언어를 컴파일하는 데 무엇이 드는가.

← Back to Mere: 언어를 만들다