텍스트 에디터를 만들었더니, 고쳐진 건 언어 쪽이었다
직접 만든 언어 Mere로 파일을 읽지 않는 텍스트 에디터를 만들었다. 208 MB가 0.5초·24.7 MB에 열린다. 하지만 수확은 에디터가 아니었다. 열한 개의 발견 중 열 개가 언어 본체로 들어갔고, 그중 다섯은 기존 테스트가 구조적으로 볼 수 없는 곳에 있었다. 두 달 전에 공개한 에디터가 실제 터미널에서는 저장도 종료도 할 수 없었다는 사실까지 포함해서.
Mere는 내가 나를 위해 만드는 작은 언어다. 인터프리터와 네 개의 컴파일 백엔드를 가지고 있다. 이 글은 그 언어로 텍스트 에디터를 만든 이틀간의 이야기다.
에디터는 미완성으로 끝났다. 검색이 없다. 선택도 복사도 없다. 키는 여섯 개와 화살표뿐이다. 그런데도 이 이틀은 언어를 여섯 군데 더 튼튼하게 만들었다. 그리고 그중 다섯은 기존 테스트 스위트가 구조적으로 볼 수 없는 곳에 있었다.
측정에 근거한 글이다. 아래 숫자는 모두 이 기계나 CI에서 나온 것이고, 불리한 숫자도 유리한 숫자와 같은 표에 올려두었다. 내가 도중에 세 번 틀린 것도 적는다.
왜 에디터였나
7월에 같은 언어로 kilo 풍 에디터를 한 번 만든 적이 있다. 223줄이었고, 그때는 의도적으로 “언어에 아무것도 더하지 않는” 것을 노린 probe였다. 이미 있는 기능만으로 쓸 수 있는지, 쓰면서 무엇이 아픈지를 재기 위해서였다.
이번에는 반대를 노렸다. “에디터에 필요한데 이 언어에 없는 것은 무엇인가.” 축은 둘로 좁혔다. 거대한 파일과 언어 서버다.
손을 대기 전에 전부 측정했다
먼저 조사를 했다. 그리고 처음 계획 중 세 항목이 사라졌다. 필요 없었기 때문이다.
| 예상 | 측정 결과 |
|---|---|
| 언어 서버에 장수명 파이프라는 새 기능이 필요하다 | 필요 없다. socketpair를 쓰면 자식 프로세스가 “이미 수락한 TCP 연결”과 같은 모양이 된다 |
| 이벤트 루프에 새 기능이 필요하다 | 필요 없다. poll(2) 래퍼가 이미 있고, 임의의 fd를 받는다 |
| 개행 스캔에 SIMD가 필요하다 | 필요 없다. clang이 스칼라 루프를 자동 벡터화해서 차이가 나지 않는다 |
socketpair 쪽이 가장 깔끔했다. Mere의 tcp_read / tcp_write는 사실
read(2) / write(2) 그 자체이고 소켓 전용이 아니다. 그래서 자식 프로세스를
socketpair 한쪽 끝에 연결하면 기존 소켓 호출이 그대로 쓰인다.
컴파일러 변경은 0, C는 25줄이면 됐다.
그리고 “필요 없다”를 알게 된 대신, 계획에 없던 버그가 세 건 나왔다.
1. 위치 지정 읽기가 한 바이트씩 읽고 있었다
에디터의 설계는 piece table이다. 파일은 읽어들이지 않고, “원본 파일의 여기서 여기까지”라는 조각의 목록으로 가진다. 3 GB 파일을 열어도 조각은 하나다.
그러려면 파일의 일부를 오프셋으로 읽어야 한다. Mere에는 file_pread가 있었다.
다만 반환하는 것이 정수 벡터 — “한 바이트가 박싱된 정수 하나” — 였고,
게다가 C 백엔드는 fgetc로 한 바이트씩 읽고 있었다.
208 MB를 256 KiB 페이지로 통과시키면:
| 실시간 | 최대 RSS | |
|---|---|---|
file_pread |
3.64 s | 10.1 MB |
파일 전체를 읽는 read_bytes |
0.33 s | 210 MB |
메모리냐 속도냐의 양자택일이었다.
쓰기 쪽에는 바이트 문자열 버전이 이미 있었다. 여덟 달 전에 다른 dogfood가 “한 바이트씩 정수에 담는 건 이상하다”며 추가한 것이다. 읽기 쪽 쌍둥이만 없었을 뿐이었다.
추가한 결과:
| 실시간 | 최대 RSS | |
|---|---|---|
file_pread_bytes |
0.36 s | 1.8 MB |
열 배 빠르고 메모리는 5.6분의 1. 그리고 양자택일이 사라졌다.
왜 여덟 달 동안 아무도 몰랐나. file_pread를 처음 요구한 것은 B-tree dogfood였고,
그쪽은 “페이지를 숫자로 색인”하므로 벡터가 옳았다.
파일을 훑어 읽는 사용자가 나타나야 비로소 모양이 다르다는 것을 알 수 있다.
WebAssembly 백엔드에서는 반대 일이 벌어지고 있었다. 호스트 쪽 import는
원래 바이트 포인터를 반환하고 있었고, file_pread는 그 뒤에 변환을 하나
더하고 있었을 뿐이다. 싼 쪽이 원래 밑에 있었다.
2. 영역이 해제한 메모리를 재사용하지 않고 있었다
이번 작업에서 가장 “읽어서는 알 수 없는” 것이었다.
Mere에는 region R { ... }라는 구문이 있다. 블록 안에서 확보한 것은 블록을
나갈 때 한꺼번에 해제된다. 에디터는 타건마다 화면을 조립하므로, 이것이 없으면
20,000번 다시 그리는 데 5.9 GB에 이른다(측정값). 있으면 2.0 MB다.
그런데 블록 안에서 큰 값을 만드는 루프에서 상태가 이상했다. 5 MiB 값을 40번 만들면 216 MB에 도달했다.
첫 판단은 “블록이 회수하지 않고 있다”였다. 이것이 틀렸다.
생성된 C에 카운터를 넣고 돌렸더니 이렇게 나왔다.
block_release=40 freed_chain=40 big_allocs=40
40번 해제하고 있었다. 장부는 맞았다. 그런데도 프로세스는 계속 불어났다.
일어나지 않고 있던 것은 재사용이었다. 해제 처리가 영역을 통째로 버리고 1 MiB로 다시 만들기 때문에, 다음 반복은 또 malloc에 몇 MB를 요구한다. 그리고 malloc은 같은 페이지를 돌려주지 않는다.
가장 큰 블록만 남기도록 했더니 13.7 MB로 평탄해졌다.
| 값 크기 | 변경 전 | 변경 후 |
|---|---|---|
| 4 MiB | 8.6 MB | 11.3 MB |
| 5 MiB | 107 MB | 13.4 MB |
| 8 MiB | 167 MB | 19.5 MB |
| 16 MiB | 327 MB | 35.9 MB |
RSS가 반복 횟수가 아니라 한 반복 분량으로 스케일하게 되었다. 4 MiB 행이 대가다 (성장한 영역이 줄어들지 않게 되었다).
여기서 배운 것은 구현 이야기가 아니다. 최대 RSS는 “회수되었는가”에 답하지 않는다. 그것은 “동시에 몇 바이트가 상주했는가”에 답하는 것이고, “보유하고 재사용”과 “해제하고 재취득”은 최댓값이 같다. 답을 낸 것은 카운터였다.
그리고 가설 검증은 컴파일러를 건드리기 전에, 이미 생성된 C를 직접 고쳐서 A/B 했다. 16배 차이는 거기서 나왔다.
3. raw 모드가, 프로그램이 요구한 키를 전달하지 않고 있었다
이것이 가장 부끄럽다.
터미널을 “raw 모드”로 만드는 함수가 있다. 에코를 끄고, 줄 단위 버퍼링을 끈다. 에디터도 게임도 맨 처음에 호출한다.
그런데 이 구현은 ICANON과 ECHO만 끄고 있었다. IXON이 남는다.
IXON이 남아 있으면 Ctrl-S는 XOFF, Ctrl-Q는 XON이다. 터미널의 라인 디시플린이
둘 다 먹어버려서, 프로그램은 어느 바이트도 보지 못한다.
그리고 7월에 공개한 에디터는 Ctrl-S를 저장, Ctrl-Q를 종료라고 써 두었다.
의사 터미널로 구동해서 측정했다:
| Ctrl-S 이후 그려진 바이트 | 0 (터미널이 멈춰 있다) |
| Ctrl-Q 이후 그려진 바이트 | 0, 프로세스 생존, 파일 미변경 |
두 달 동안, 실제 터미널에서는 저장도 종료도 할 수 없는 에디터가 공개되어 있었다.
왜 아무도 몰랐나. 테스트가 파이프 경유였기 때문이다. 파이프에는 라인 디시플린이 없다. 0x13도 0x11도 그대로 도착하고, 전부 정상으로 보인다.
컴파일러를 고치고 다시 빌드하기만 해서 — 에디터의 소스는 한 줄도 바꾸지 않고 — 저장도 종료도 되게 되었다.
같은 족속이 하나 더 있었다. ISIG다. 이것이 남아 있으면 Ctrl-Z는 SUSP가 되어,
undo에 할당해도 조용히 듣지 않는다.
다만 이쪽은 같은 취급을 하지 않았다. ISIG를 끄면 Ctrl-C를 잃는다.
에디터는 치를 수 있는 대가지만 q로 끝나는 게임에는 불필요하고, raw에 접어
넣으면 요구하지 않은 기존의 모든 TUI에서 탈출구를 빼앗는 셈이 된다.
별도의 함수로 추가했다.
4. 라이브러리 함수는 컨테이너를 반환할 수 없다
일본어를 제대로 그리려면 “자소 클러스터” — 독자가 한 글자라고 부르는 단위 — 로
세어야 한다. 👩👩👦는 코드포인트 7개지만 한 글자이고, 폭은 두 칸이다.
클러스터 분할 라이브러리는 이미 있었다. 써 봤더니 프레임마다 약 60 KB가 샜다.
2,000 프레임에 127.8 MB, 완전히 선형. 그것도 region 블록 안에서.
이유는 언어의 설계에 있다. 컨테이너는 그 확보가 호출하는 쪽의 region 블록에
어휘적으로 들어 있지 않을 때 프로그램 수명 영역으로 간다. 그리고
라이브러리 함수는 결코 어휘적으로 들어 있지 않다.
그래서 클러스터 하나당 버퍼 하나는 영원히 돌아오지 않는 버퍼 하나였다.
고치는 방법은 둘이었다. 타입 시스템에 손대거나, 라이브러리가 컨테이너를 쓰지 않게 하거나.
측정해 보니 후자로 충분했다. 클러스터는 코드포인트 한 개에서 몇 개뿐이므로, 소박한 문자열 연결이 치르는 제곱 비용은 “한 클러스터의 길이”에서 멈춘다. 버퍼는 아무것도 사 주지 않고 있었다.
| 2,000 프레임 × 40행의 일본어 | 최대 RSS | 실시간 |
|---|---|---|
| 가변 버퍼 | 127.8 MB (선형) | 0.84 s |
| 그냥 문자열 | 1.6 MB (평탄) | 0.45 s |
78배의 메모리, 게다가 1.9배 빠르다. ICU와의 일치는 8,509개 입력 전부 유지했다.
타입 시스템 쪽은 건드리지 않았다. 조사해 보니 지금의 보수적인 동작 쪽이 옳았기 때문이다. 함수의 타입에 나타나지 않는 확보는 내부만의 것인지 밖으로 공유된 것인지 타입만으로는 구별할 수 없다. 구별하는 것은 다른 장치이고, 그것이 “모르는 것은 오래 사는 쪽으로 넘긴다”고 정하고 있다.
가는 김에, 컴파일러의 주석이 반대를 주장하고 있는 것을 발견해서 고쳤다. “보이지 않는 확보는 스스로 escape할 수 없으므로 묶어도 비용이 없다” — 증인을 만들어 봤더니 escape 했다.
내가 세 번 틀린 것
회고에서 가장 가치 있는 건 여기라고 생각하므로 적어 둔다.
“영역이 회수하지 않고 있다”고 썼다. 실제로는 회수하고 있었다(위 2). 최대 RSS를 “회수되었는가”의 답으로 읽었다. 내 메모에 “최대 RSS는 회수에 답하지 않는다”는 항목이 그대로 있는데, 그것을 밟았다.
“표시 폭 함수가 어디에도 없다”고 썼다. 있었다. 1년 넘게 전부터 표준 라이브러리에 들어 있었고 문서에도 실려 있었다. 나는 contrib 디렉터리와 내장 함수 목록만 보고 prelude를 보지 않았다.
다만 새로 만든 판단 자체는 측정으로 남았다. 17,661개 코드포인트로 비교하면
2,083건(11.8%)이 어긋난다. 기존 것은 손으로 쓴 14개 범위이고, 결합 문자
누락이 1,488건 있다(U+200B ZERO WIDTH SPACE 포함).
표의 자릿수 맞추기에는 충분하지만 커서를 글자 끝에 두는 용도에는 부족하다.
거기서는 오차가 한 칸에 머물지 않기 때문이다.
“없어서 만들었다”가 아니라 “있는 것이 부족해서 만들었다“가 옳은 기술이고, 양쪽 문서에 측정값과 함께 상호 참조를 적었다.
실행 중인 대상을 다시 빌드해서 가짜 회귀를 두 번 만들었다. 테스트 스위트가 돌고 있는 중에 컴파일러를 다시 빌드해서, 존재하지 않는 실패를 보고하게 했다. 단독으로 재현되지 않는 것을 확인하고 가짜임을 알았다. 이것도 내 메모에 있는 함정이었다.
그리고 CI가 내 게이트를 고쳤다
수정을 push했더니 CI가 빨개졌다. 로컬에서는 전부 초록이었는데.
떨어진 것은 내가 쓴 게이트였다. “상한을 넘는 크기의 값은 영역이 보유하지 않고 돌려준다”를 검사하려고, 최대 RSS가 반복 횟수에 비례하는 것을 보고 있었다.
macOS에서는 통과했다. glibc에서는 떨어졌다.
이유는 위 2와 같다. “보유하고 재사용”과 “해제하고 재취득”은 최댓값이 같다. macOS는 큰 해제를 OS에 돌려주므로 우연히 차이가 났다. glibc는 재사용하므로 평탄해진다. 내 게이트는 할당자를 재고 있었지, 컴파일러를 재고 있지 않았다.
기댓값이 아니라 계측기를 바꿨다. 런타임이 “캐시된 영역이 몇 바이트를 안고 있는가”를 보고하게 했다. 프로그램의 함수이지 기계의 함수가 아니다.
| 보유 바이트 | |
|---|---|
| 상한 미만(5 MiB 값) | 8,388,608 (성장한 블록을 보유) |
| 상한 초과(32 MiB 값) | 1,048,576 (돌려주고 다시 만듦) |
숫자
| 기간 | 2일 |
| 컴파일러 본체 변경 | 511줄 |
| 측정과 게이트 | 970줄 |
| 문서 | 307줄 |
| 에디터 | 자작 1,560줄 (Mere 979 / C 115 / 테스트 466) |
고친 양의 거의 두 배를 “다음에 같은 일이 생기면 빨개지는 장치”에 썼다.
처음에는 비효율로 보였다. 하지만 여섯 건 중 다섯 건이 “테스트를 둘 자리가 구조적으로 보이지 않는” 곳에 있었으니 당연한 비율이다. 결함은 대상이 아니라 계측기 쪽에 있었다.
만든 도구는, 의사 터미널로 프로그램을 구동해 “보낸 바이트가 도착했는가”를 묻는
게이트, 생성한 폭 테이블을 다른 구현과 18,226개 지점에서 대조하는 게이트,
그리고 터미널 자체에 ESC[6n으로 묻는 프로브다. 마지막 것이 필요한 이유는
“모호 폭” 문자의 폭이 Unicode의 성질이 아니라 터미널의 성질이기 때문이고,
거기에 답할 수 있는 것은 터미널뿐이다.
전부 망가뜨려 보고 빨개지는 것을 확인해 두었다. 하나는 처음에 빨개지지 않아서 판별하는 입력을 다시 만들었다 — 언어 서버의 JSON 파서가 문자열 안의 생 개행을 통과시킨다는 것을 몰랐다. 따옴표로 바꾸고 나서야 들었다.
그래서, 에디터는 되었나
되지 않았다.
키는 여섯 개와 화살표뿐. 언어 서버는 아홉 개 메서드를 제공하는데 쓰고 있는 것은 진단 표시 하나뿐. 그리고 검색이 없다. 208 MB 로그를 0.5초에 열 수 있어도 그 안을 찾을 수 없다.
된 것은 이것뿐이다:
| 208 MB를 0.5초 / 24.7 MB / 조각 1개로 연다 | 많은 에디터보다 낫다 |
| undo가 파일 크기와 무관하게 O(1) | 조각 목록이 편집 횟수만큼만 있기 때문 |
| 일본어 자릿수 계산과 클러스터 단위 커서 | 이것도 많은 에디터보다 정확하다 |
맨 처음에 “’최고’의 축을 정해 달라”고 내가 썼다. 속도·일본어·언어 서버·확장성 중 무엇을 고르느냐로 만드는 것이 달라진다고. 둘을 골랐다는 것은 에디터로서의 완성도를 버렸다는 뜻이었다.
그래도 만들길 잘한 것
7월의 probe는 “언어에 아무것도 더하지 않는” 것을 노려서 환원이 한 건이었다. 이번에는 열한 건 중 열 건이 언어 본체로 들어갔다. 같은 대상이라도 겨냥을 바꾸면 수확이 달라진다.
그리고 7월의 에디터 자신이 이번 작업의 피해자이자 수혜자였다. 두 달 동안 움직이지 않던 것이 자기 소스를 한 줄도 바꾸지 않고 움직이게 되었다.
dogfood는 “언어를 재는 도구”라고 생각했지만 그것만이 아니었다. 언어의 변경을 받는 쪽이기도 하다. 세 다스쯤 있는 dogfood 중 열세 개는 언어 저장소의 CI로부터 “아직 프로그램인가”를 질문받고 있다. 이번에 그것을 열네 개로 만들었다. 에디터가 지나는 면 — 컴파일러가 구현하지 않은 외부 함수 선언 — 을 지나는 dogfood가 그때까지 하나도 없었기 때문이다.
다음으로 잇는다면 검색부터라고 생각한다. 거대한 파일을 열 수 있는 유일한 에디터가 그 안을 찾지 못하는 것이 가장 이상하므로. 다만 그것을 만들어서 언어가 무엇을 얻는가는 또 다른 질문이다.