diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/design/008-module-format.md | 27 | ||||
| -rw-r--r-- | docs/research/080-snapshot-reads.md | 135 |
2 files changed, 153 insertions, 9 deletions
diff --git a/docs/design/008-module-format.md b/docs/design/008-module-format.md index be658dd..b77ec9c 100644 --- a/docs/design/008-module-format.md +++ b/docs/design/008-module-format.md @@ -291,16 +291,22 @@ text·리스트가 남을 때만 위치를 유지한다. ingress, 요청에서 빌린 PUT 응답). 효과는 비동기 인터페이스(`pure_runtime_take`/`complete`)로 소유 스레드에서 처리한다. - **이벤트 한 바퀴가 묶음 하나다.** 준비된 요청을 모두 지연 저장 모드의 앱에 차례로 적용하고, 저장 한 번 **뒤에** 응답한다. 저장이 실패하면 묶음의 읽기까지 모두 실패한다. 같은 문서 키가 다시 오거나 문서 수가 한 저장의 한도를 - 넘으려 하면 묶음을 먼저 끝낸다. 보기와 앱 상태의 이벤트는 어느 묶음에나 들어간다. 그래서 묶음 안의 모든 요청은 - 자기 묶음의 저장으로 저장되거나 그것과 함께 실패하고, 보기는 저장될 상태만 내보낸다. 한 연결의 요청은 앞 요청에 - 답한 뒤에 읽으므로 쓴 것을 곧 읽는다. + 넘으려 하면 묶음을 먼저 끝낸다. 앱 상태의 이벤트는 어느 묶음에나 들어간다. 그래서 묶음 안의 모든 요청은 + 자기 묶음의 저장으로 저장되거나 그것과 함께 실패한다. 보기는 저장된 상태만 내보낸다(아래 스냅숏). 한 연결의 + 요청은 앞 요청에 답한 뒤에 읽으므로 쓴 것을 곧 읽는다. - **저장소 핸들.** 문서와 상태를 저장소 핸들(`pure_store_data`)로 열어 파일 매핑 하나를 공유하고, 묶음마다 한 번만 저장소가 바뀌었는지 본다. - **연결 스레드와 소유자.** `--threads N`은 스레드마다 같은 포트의 리스너(`SO_REUSEPORT`), epoll, 런타임 코어를 둔다. 키별 문서만 가진 모듈은 스레드마다 앱과 저장소 핸들을 두고 저장소 말고는 아무것도 나누지 않는다. 상태를 가진 - 모듈은 상태의 소유자가 하나여야 하므로, 스레드 하나가 더 앱을 소유한다. 연결 스레드는 요청을 받고 해석하고 답을 - 쓰는 일(시스템 호출이 대부분이다)을 나눠 맡고, 준비된 요청을 소유자에게 넘긴다. 소유자는 모든 스레드의 요청을 한 - 묶음으로 적용하고 저장 한 번 뒤에 각 스레드에 돌려주며(eventfd로 깨운다), 스레드는 돌려받은 것을 완료하고 답한다. + 모듈은 상태의 소유자가 하나여야 하므로, 연결 스레드가 하나여도 따로 소유자 스레드가 앱을 돌린다. 연결 스레드는 + 요청을 받고 해석하고 답을 쓰는 일(시스템 호출이 대부분이다)을 나눠 맡고, 입력과 문서 요청을 소유자에게 넘긴다. + 소유자는 모든 스레드의 요청을 한 묶음으로 적용하고 저장 한 번 뒤에 각 스레드에 돌려주며(eventfd로 깨운다), + 스레드는 돌려받은 것을 완료하고 답한다. +- **보기는 스냅숏에서.** 소유자는 저장이 성공할 때마다, 답을 돌려주기 전에 저장된 상태의 스냅숏을 게시한다. + 연결 스레드는 라운드마다(돌려받은 답을 처리한 뒤) 최신 스냅숏을 잡아 자기 읽기용 앱으로 그 라운드의 보기를 모두 + 평가하고 답을 모아 보낸다. 입력은 그보다 먼저 소유자에게 넘겨, 소유자가 그동안 적용하고 저장한다. 그래서 쓰기의 + 답을 받은 뒤의 읽기는 그 쓰기를 보고, 저장되지 않았거나 실패로 되돌린 상태는 아무도 보지 않으며, 소유자가 + 동기화하는 동안에도 읽기는 기다리지 않는다. 스냅숏을 만들지 못하면 보기도 소유자가 답한다. 작업 슬롯의 라우트와 입력은 완료될 때까지 그 스레드의 런타임 코어가 붙잡고, 빌려준 저장소 매핑의 참조 수는 원자적이다. 저장한 묶음 뒤에는 새 요청이 100 µs 동안 끊길 때까지(최대 1 ms) 모은다. 답을 받은 클라이언트의 다음 요청이 한 저장에 들어가게 하는 것으로, 한 스레드에서는 답하는 시간이 그 구실을 한다(묶음당 이벤트 30 → 50, p50은 @@ -316,9 +322,12 @@ text·리스트가 남을 때만 위치를 유지한다. 상태 코드·ETag·오류·본문이 같고, 동시 편집이 커밋을 나누어 쓰며, `kill -9` 뒤에도 확인 응답한 편집이 모두 남는다. 앱 라우트는 키 있는 리스트 앱에 무작위 POST(쿼리 필드와 본문을 가진 메시지, text, 키)와 GET(수, text, 표의 쪽)을 800번 보내 상태 코드·오류·본문·JSON 행이 파이썬 모델과 같고, 멈췄다 다시 띄워도, 여섯 클라이언트가 보내는 중에 -`kill -9`로 죽여도 확인 응답한 이벤트(약 4,200개)가 모두 남는다. 한 스레드와 소유자 모드 모두 확인한다. 같은 서비스를 -axum·drogon으로 손수 쓴 것과의 비교는 [연구 077](../research/077-apps-as-http-services.md): 내구성 있는 쓰기 약 76배, -읽기는 drogon과 같고 axum보다 빠르며(20행 JSON 145k/s 대 95k/s), CPU와 메모리가 가장 적다. +`kill -9`로 죽여도 확인 응답한 이벤트(약 4,200개)가 모두 남는다. 그동안 다른 연결의 읽기는 먼저 확인 응답된 쓰기를 +모두 보고, 합계가 뒤로 가지 않으며, 다시 띄운 서버의 합계는 읽기가 본 어떤 합계 이상이다. 연결 스레드 하나와 셋에서 +모두 확인한다. 같은 서비스를 axum·drogon으로 손수 쓴 것과의 비교는 [연구 077](../research/077-apps-as-http-services.md): +내구성 있는 쓰기 약 76배, 읽기는 drogon과 같고 axum보다 빠르며(20행 JSON 145k/s 대 95k/s), CPU와 메모리가 가장 적다. +쓰기와 읽기를 함께 보내면 읽기가 149k/s(p99 0.6 ms)로, 잠금을 쥔 채 동기화하는 axum·drogon(1.1–1.3k/s, p99 +111–156 ms)과 동기화하지 않는 메모리 판(74–83k/s)보다 많다([연구 080](../research/080-snapshot-reads.md)). 아직 모듈 앱을 런타임 코어의 효과 단위로 세분하지는 않았다(`runtime.c`의 `definition`은 앱 전체다). 호스트가 직접 호출한다. diff --git a/docs/research/080-snapshot-reads.md b/docs/research/080-snapshot-reads.md new file mode 100644 index 0000000..1300632 --- /dev/null +++ b/docs/research/080-snapshot-reads.md @@ -0,0 +1,135 @@ +# 080 — 읽기는 저장된 스냅숏에서: 소유자가 동기화해도 기다리지 않는다 + +2026-09-26. [로드맵](../roadmap.md) 단기 3 "읽기 독립화". 설계와 구현 기록. + +착수 기준: + +- 바꿀 결정: 앱 보기를 읽는 요청이 소유자의 묶음(쓰기와 동기화)을 거칠지, 연결 스레드가 마지막으로 저장된 상태를 + 직접 읽을지. +- 남은 질문: 쓰기가 이어지는 동안 읽기는 동기화 한 번(SD에서 수 ms)만큼 기다렸다. 읽기 먼저(소유자의 라운드에서 + 읽기를 쓰기 앞에 두기)로 p99는 55 → 13 ms가 됐지만, 소유자가 동기화에 묶인 동안 도착한 읽기는 여전히 기다린다. + 저장된 상태의 읽기 전용 사본을 게시하면 읽기가 동기화와 무관해지는가? 그 사본을 만드는 비용은? +- 최소 확인: 쓰기 8연결과 읽기 56연결을 섞은 부하에서 읽기의 p50·p99와 두 처리량을 전후로 잰다. 읽기가 저장되지 않은 + 상태를 보지 않는다는 것, 저장 실패로 되돌린 상태를 보지 않는다는 것을 검사한다. +- 중단 기준: 읽기가 한 번이라도 저장되지 않은 상태를 보이면 되돌린다. + +## 의심한 관례 + +| 관례 | 이 설계 | +|---|---| +| 읽기와 쓰기가 한 상태를 나누면 잠금을 나눈다. 쓰기가 동기화하는 동안 읽기는 기다린다 | 저장이 끝날 때마다 저장된 상태의 불변 스냅숏을 게시한다. 읽기는 스냅숏을 잡는 순간 말고는 소유자와 아무것도 나누지 않는다 | +| 스냅숏은 상태의 복사라서 상태가 클수록 비싸다 | text는 저장소 매핑의 그 버전을 가리키고, 리스트는 항목을 공유한다. 바뀐 항목만 다음에 바뀔 때 복사된다 | +| 여러 버전을 읽는 것(MVCC)은 데이터베이스의 기능이다 | 앱이 선언한 상태 전부(수, text, 리스트)가 버전을 가진다. 앱 코드도 모듈 형식도 그대로다 | + +## 설계 + +- **스냅숏은 읽기 전용 앱 상태다.** 저장이 성공하면 `pure_app_snapshot`이 수·커서·인덱스 값의 사본, text 상태마다 + 얼린 draft, 리스트마다 공유 사본으로 스냅숏을 만든다. 바뀌지 않은 리스트는 앞 스냅숏의 사본을 그대로 쓴다(리스트의 + 번호와 변경 번호가 같을 때). 저장을 기다리는 변경이 있거나, text가 저장소에 다 있지 않으면(상태를 맡는 저장 호스트가 + 없을 때) 만들지 않는다. +- **text는 복사하지 않는다.** 저장 직후 text draft의 버전은 모두 저장소 매핑에 있다. `pure_draft_freeze`는 그 버전을 + 가리키는 읽기 전용 draft를 만들고 매핑을 참조한다. 트리 읽기는 불변 트리를 읽기만 하므로 여러 스레드가 동시에 읽을 + 수 있고, 닫기는 매핑 참조를 놓는 것(원자적)이라 어느 스레드에서나 된다. 보기의 대여([079](079-lent-views.md))도 + 그대로 된다. +- **리스트는 항목을 공유한다.** 공유 사본은 항목 포인터 배열만 복사한다. 항목마다 만들어질 때의 공유 횟수를 적어 + 두고, 마지막 공유 전에 만들어진 항목은 다시 바꾸지 않는다. 앱이 그런 항목의 필드를 바꾸면 항목을 복사해 바꾸고, + 되돌리기 기록은 옛 항목을 가진다. 커밋된 뒤 리스트가 놓은 공유 항목(지운 것, 복사로 바뀐 것, 교체나 멈춤으로 + 버린 리스트의 것)은 해제하지 않고 모아 둔다. +- **스냅숏은 오래된 것부터 사라진다.** 스냅숏은 다음 스냅숏을 붙잡는 사슬이다. 어떤 스냅숏이 최신이던 동안 놓인 + 항목은 그 스냅숏과 그보다 오래된 스냅숏만 볼 수 있으므로, 그 스냅숏과 함께 해제된다. 참조 수는 원자적이고, 마지막 + 참조를 놓는 스레드(대개 연결 스레드)가 해제한다. +- **읽는 쪽은 자기 앱을 가진다.** 연결 스레드마다 스냅숏에 묶인 `PureApp`(계산 공간과 걸러진 행의 캐시)을 두고 + (`pure_app_read`), 기존 보기 함수(`pure_app_view`, `pure_app_cell` …)를 그대로 쓴다. +- **게시 순서.** 저장 → 스냅숏 게시 → 답 돌려주기. 연결 스레드는 라운드마다, 소유자가 돌려준 답을 처리한 뒤에 최신 + 스냅숏을 잡는다. 그래서 쓰기의 답을 받은 뒤의 읽기는 그 쓰기를 본다(한 연결에서도, 답을 전해 들은 다른 연결에서도). + 저장이 실패하면 앱은 마지막 저장의 상태로 돌아가고, 그것은 이미 게시된 스냅숏과 같다. 스냅숏을 만들지 못하면 게시를 + 거두고 보기도 소유자가 답한다. +- **상태를 가진 앱은 언제나 소유자를 둔다.** 연결 스레드가 하나여도 소유자 스레드가 따로 있어 동기화가 읽기를 막지 + 않는다. 연결 스레드는 라운드의 입력과 문서 요청을 먼저 소유자에게 넘기고(소유자는 그동안 적용하고 저장한다), 보기를 + 모두 평가한 뒤 답을 모아 보낸다. 문서 읽기(`GET /objects`)는 지금처럼 소유자가 답한다(저장소가 이미 버전으로 읽는다). + +## 장치가 말한 것 + +Raspberry Pi 5, 서버는 CPU 0–2, 부하는 CPU 3, 저장소는 SD 카드의 ext4. 점수판 앱([077](077-apps-as-http-services.md)), +`run.py --suite app`. `pure-serve-N`은 연결 스레드 N개와 소유자 스레드 하나다. + +**쓰기와 읽기를 함께** (`mixed`: 내구성 점수 POST 8연결과 합계 GET 56연결을 부하 생성기 둘로, 5초, 3회전 중앙값): + +| 서버 | POST/s | GET/s | GET p50 | GET p99 | +|---|---:|---:|---:|---:| +| pure-serve | 1,739 | **106,874** | 0.48 ms | 0.74 ms | +| pure-serve-2 | 2,017 | **149,219** | 0.34 ms | 0.61 ms | +| pure-serve-3 | 2,145 | **147,666** | 0.35 ms | 0.61 ms | +| axum 내구 | 177 | 1,266 | 35 ms | 111 ms | +| drogon 내구 | 181 | 1,058 | 41 ms | 156 ms | +| axum 메모리 | 51,619 | 82,847 | 0.40 ms | 6.1 ms | +| drogon 메모리 | 69,431 | 73,602 | 0.34 ms | 4.6 ms | + +손으로 쓴 두 서비스는 점수판 잠금을 쥔 채 `fdatasync`하므로 읽기도 동기화를 기다린다. 흔한 작성법이다. 메모리 +모드는 동기화하지 않지만 쓰기 5–7만 건과 CPU를 나눈다. + +같은 부하에서 이전 빌드(소유자가 읽기도 답하던 10dbf6e)와 번갈아 잰 3회전: + +| 연결 스레드 | 읽기/s 전 → 후 | 읽기 p99 전 → 후 | 쓰기/s 전 → 후 | +|---|---|---|---| +| 1 | 10,422–13,002 → **108,891–110,001** | 16–29 ms → **0.67 ms** | 1,467–1,821 → 1,766–1,978 | +| 2 | 14,747–16,040 → **144,886–147,167** | 13–16 ms → **0.61–0.67 ms** | 2,092–2,281 → 1,862–2,097 | +| 3 | 14,531–15,863 → **146,568–148,601** | 15–24 ms → **0.61 ms** | 2,060–2,257 → 1,986–2,277 | + +쓰기는 SD의 동기화 편차 안에서 같다. + +**나머지 앱 작업** (3회전 중앙값, 괄호는 077): + +| 작업 | pure-serve | pure-serve-2 | pure-serve-3 | +|---|---:|---:|---:| +| 점수 POST, 64연결 | 16,052 (12,896) | 10,415 (9,437) | 15,571 (13,692) | +| 점수 POST, 8연결 | 2,034 (1,452) | 2,072 (1,952) | 2,118 (2,132) | +| 합계 GET, 64연결 | 111,824 (115,354) | 154,259 (148,544) | 152,294 (147,354) | +| 20행 GET, 64연결 | 85,113 (87,312) | 149,414 (144,844) | 148,663 (140,672) | + +- 여러 연결 스레드의 읽기는 소유자를 거치지 않아 서버 CPU 1초당 답이 늘었다: 합계 GET 75,944 → 98,158, 20행 GET + 64,591 → 79,370(pure-serve-2). +- 연결 스레드 하나의 읽기는 이전 빌드와 번갈아 재면 같다(20행 88.1–88.6k 대 88.2–89.4k/s, 합계 112.3–112.5k 대 + 113.4–113.8k/s). 077의 수치와의 차이는 날마다의 편차다. +- 최대 RSS: 3.0 MiB(pure-serve), 3.6 MiB(-2), 4.1 MiB(-3). + +**스냅숏의 비용.** 저장 뒤 한 항목만 바뀐 점수판에서 스냅숏 만들기(200번 평균): 항목 64개 0.4 µs, 1,024개 1.2 µs, +16,384개 21.5 µs, 65,536개(한도) 87 µs. 같은 저장은 3.0–4.1 ms다. 옛 스냅숏을 놓는 데 1.4 µs 이하. 바뀐 리스트의 항목 +포인터 배열을 복사하는 것이 대부분이다. `pure-serve`의 코드는 4.3 KB(190.8 → 195.1 KB) 늘었다. 대부분 리스트의 +복사 후 쓰기와 스냅숏 함수다. + +## 찾은 것 + +- **평가와 전송을 번갈아 하면 느리다.** 처음에는 보기를 평가할 때마다 바로 답했다. 연결 스레드 하나에서 20행 GET이 이전 + 빌드보다 8% 느렸다(88.9k → 81.9k/s, 요청당 약 1 µs). 이전 빌드의 묶음 실행은 라운드의 요청을 모두 평가한 뒤 답을 + 모아 보냈다. 같게 하자 차이가 없어졌다. 평가(인터프리터, 리스트)와 `writev`(커널의 TCP 경로)를 번갈아 오가면 두 쪽의 + 코드와 데이터가 서로를 캐시에서 밀어내는 것으로 보인다. +- **쓰기를 먼저 넘긴다.** 한 라운드에 읽기 50개와 쓰기 1개가 있으면, 읽기를 다 답한 뒤 쓰기를 넘길 때 쓰기가 그만큼 + 늦게 저장을 시작한다. 입력을 먼저 소유자에게 넘기고 보기를 평가하게 했다. +- **스레드 하나는 이제 읽기로 가득 찬다.** 전에는 동기화가 읽기를 초당 1만여 건으로 묶었고, 그래서 한 스레드가 쉬는 + 시간이 많았다. 이제 읽기 56연결이 한 스레드를 채워도 쓰기는 같은 범위를 지켰다(중앙값 1,789 → 1,945). + +## 검증 + +- `test-module` 스냅숏: 키 있는 리스트 앱 3,000단계와 text 앱 300단계에서 저장마다 만든 스냅숏이 앱과 같다(상태와 + 모든 보기). 무작위로 붙잡아 둔 옛 스냅숏 8개는 항목이 바뀌고(복사 후 쓰기) 지워지고, 모듈이 키 없는 리스트로 + 바뀌었다 돌아오고, 앱이 멈추고 저장소가 닫힌 뒤에도 만들 때 그대로다. 놓인 공유 항목 450개가 스냅숏과 함께 해제된다. + 저장을 기다리는 변경이 있으면 스냅숏이 없고, 실패한 저장 뒤의 스냅숏은 실패 전과 같다. 상태를 메모리에만 두는 앱은 + 스냅숏을 만들지 않는다. 두 스레드가 최신 스냅숏을 읽는 동안(2만–4만 번) 앱이 계속 적용하고 게시한다. ASan·UBSan· + LeakSanitizer 빌드에서 같다. +- aup `make check`: 무작위 요청 800개의 파이썬 모델 대조가 연결 스레드 하나와 셋에서 같다. 여섯 클라이언트가 쓰는 동안 + `kill -9`하는 검사에 다른 연결의 읽기 둘을 더했다. 읽기마다 요청 전에 확인 응답된 점수를 모두 보고, 합계가 뒤로 + 가지 않으며, 다시 띄운 서버의 합계는 읽기가 본 어떤 합계 이상이다(저장되지 않은 것을 보지 않았다). 빌려준 보기의 + 회수(079)도 그대로다. `pure-serve`의 ASan·UBSan 빌드로 같은 서버 검사를 통과하고, 섞인 부하 뒤 정상 종료에서 누수가 + 없다. +- 게시를 답 뒤로 미룬 빌드를 일부러 만들면 모델 대조가 곧바로 실패한다(쓰기 직후의 GET이 옛 상태를 본다). +- 이 기기에서는 ThreadSanitizer가 돌지 않는다(VMA 47비트). 스레드를 건너는 것: 스냅숏·리스트 사본·매핑의 참조 수는 + 원자적이고(놓기는 acquire-release), 스냅숏이 게시된 뒤 소유자가 그 스냅숏에 쓰는 것(놓인 항목, 다음 스냅숏)은 읽는 + 쪽이 보지 않는 필드이며, 해제하는 스레드는 소유자가 참조를 놓은 뒤에 그것을 읽는다. + +## 다음 결정 + +- **저장과 적용의 겹치기**(로드맵 단기 2). 읽기가 소유자를 기다리지 않으니, 이제 소유자는 앞 묶음의 동기화가 다른 + 스레드에서 도는 동안 다음 묶음을 적용할 수 있다. 그때 게시할 스냅숏은 소유자의 현재 상태가 아니라 동기화한 묶음이 + 끝난 상태여야 한다. 저장을 시작할 때 만들고, 동기화가 끝날 때 게시하면 된다. 트리 저장소의 쓰기와 동기화 분리가 + 먼저 필요하다. |
