diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/research/081-tail-records.md | 104 |
1 files changed, 104 insertions, 0 deletions
diff --git a/docs/research/081-tail-records.md b/docs/research/081-tail-records.md new file mode 100644 index 0000000..fdb6833 --- /dev/null +++ b/docs/research/081-tail-records.md @@ -0,0 +1,104 @@ +# 081 — 헤드를 커밋 바이트 바로 앞에: 동기화가 쓰는 구간을 하나로 + +2026-09-26. [로드맵](../roadmap.md) 단기 2 "저장과 적용의 겹치기"를 재 보다가 바꾼 것. 트리 저장소(`store-tree.c`)의 +파일 형식이 바뀌었다(슬롯 `PURETRE2` → `PURETRE3`, 커밋 레코드 `PUREREC1`). 이전 형식의 파일은 그대로 읽고 이어 쓴다. + +착수 기준: + +- 바꿀 결정: 동기화를 소유자의 경로에서 빼기 위해 저장을 다른 스레드로 넘기고 적용과 겹칠지, 동기화 자체를 줄일지. +- 남은 질문: 소유자의 한 라운드에서 동기화는 몇 %이고 겹쳐서 가릴 수 있는 일은 몇 %인가? 커밋의 동기화가 이 + 장치에서 가장 싼 쓰기(0을 써 둔 곳을 덮어쓰고 `fdatasync`)보다 비싼 까닭은 무엇인가? +- 최소 확인: 라운드를 나눠 재고, 방식별 동기화 비용을 장치에서 번갈아 재고, 바꾼 저장소를 기존 고장·찢김·오라클 + 검사와 새 검사에 통과시킨 뒤 저장소와 HTTP 수준에서 이전 저장소와 번갈아 잰다. +- 중단 기준: 찢긴 커밋이나 잃어버린 커밋을 받아들이는 경우가 하나라도 있으면 되돌린다. + +## 라운드가 쓰는 시간 + +점수판 앱([077](077-apps-as-http-services.md)), 내구성 점수 POST 64연결, 소유자의 라운드 평균(Pi 5, SD 카드의 ext4): + +| | µs | 몫 | +|---|---:|---:| +| `fdatasync` | 3,287 | 71% | +| 다음 요청을 기다리기(답이 클라이언트를 돌아 새 요청이 오고 모이기까지) | 1,149 | 25% | +| 적용, 커밋 쓰기, 게시, 답 돌려주기 | 164 | 4% | + +라운드당 이벤트 61개. 적용을 동기화와 겹쳐 가릴 수 있는 것은 4%다. 게다가 답을 받아야 다음 요청을 보내는 클라이언트 +(닫힌 루프)에서는 앞 동기화 중에 다음 동기화를 준비하면 클라이언트가 두 묶음으로 갈려 동기화 한 번에 담기는 이벤트가 +반으로 준다. 동기화가 라운드를 지배하는 동안에는 모두를 한 번의 동기화에 모으는 지금 방식이 낫다. 그래서 동기화를 +다른 스레드로 넘기기 전에 동기화 자체를 봤다. + +## 장치가 말한 것 + +[076](076-one-sync-commit.md)은 0을 써 둔 꼬리를 덮어써 저널 커밋을 없앴다. 그래도 커밋은 두 곳에 쓴다. 꼬리의 바이트와 +파일 앞 슬롯의 헤드다. 방식별로 40번씩 번갈아 잰 p50(ms, 세 번 반복, [probe.c](../../research/experiments/012-tail-records/probe.c)): + +| 커밋 | 2 KiB | 8 KiB | +|---|---:|---:| +| 바이트, 앞 슬롯에 헤드, `fdatasync` (이전) | 2.40–2.44 | 2.40–2.71 | +| **헤드를 바이트 바로 앞에, `fdatasync`** | **1.19–1.23** | **1.18–1.57** | +| 바이트만, `fdatasync` | 1.20–1.23 | 1.12–1.21 | + +떨어진 두 구간은 한 구간의 두 배 가깝다. 헤드를 바로 앞에 두면 바이트만 쓰는 것과 같다. + +## 바꾼 것 + +- **커밋은 레코드와 바이트다.** 커밋은 공개된 끝에 128 B 레코드(헤드: 순번, 끝, 카탈로그, 바이트의 시작과 + 다이제스트, 레코드 자체의 다이제스트)를 쓰고 바로 뒤에 노드를 쓴 다음 `fdatasync`를 한 번 한다. 한 연속 구간이다. + 다이제스트는 쓰는 동안 갱신되므로 레코드는 노드를 다 쓴 뒤 비워 둔 자리에 쓴다. +- **슬롯은 체크포인트다.** 16커밋마다 헤드를 오래된 슬롯에도 쓴다(그 커밋만 두 구간). 슬롯의 헤드는 이전처럼 자기 + 바이트의 다이제스트가 맞아야 믿는다. +- **최신 커밋 찾기.** 새 슬롯의 믿을 수 있는 헤드에서 시작해, 헤드의 끝에 순번이 하나 큰 레코드가 있고 그 바이트의 + 다이제스트가 맞는 동안 따라간다. 찢긴 레코드나 바이트에서 멈추므로 그 앞 커밋이 최신이다. 쓰는 쪽은 그 위에 쓰고, + 멈춘 자리의 레코드와 바이트를 덮어쓴다. 프로세스는 파일마다 자기가 본 최신 헤드를 기억해 거기서 따라간다(파일의 그 + 자리에 같은 레코드가 그대로 있을 때만). 그래서 커밋마다 슬롯에서 다시 걷지 않는다. +- **새로 고침.** 저장소 핸들은 현재 헤드의 끝에 다음 순번의 레코드가 있는지를 `pread` 하나로 본다. 슬롯의 순번은 + 이전 형식의 커밋을 위해 계속 본다. +- 이전 형식의 파일은 슬롯의 헤드 뒤에 레코드가 없으므로 그대로 읽히고, 새 커밋은 레코드로 이어진다. 이 형식의 슬롯은 + `PURETRE3`이라 이전 빌드는 이 형식을 읽지 않는다. + +## 측정 + +**저장소 단독.** 64개 문서 중 하나의 앞에 16 B를 넣는 커밋 400번 연달아 +([commits.c](../../research/experiments/010-one-sync-commit/commits.c), 이전 저장소 `2ef0d40`과 라운드마다 순서를 바꿔 세 번, +[run.py](../../research/experiments/012-tail-records/run.py), +[원시 결과](../../research/experiments/012-tail-records/2026-09-26-ext4-linux-aarch64.json)): + +| | 헤드를 슬롯에 | 헤드를 레코드로 | +|---|---:|---:| +| 초당 커밋 | 220–233 | **371–396** | +| p50 | 2.48–2.55 ms | **1.26–1.29 ms** | +| p90 | 5.54–6.04 ms | 3.90–4.04 ms | + +**앱 서비스.** 점수판의 내구성 POST, 이전 빌드(2ef0d40)와 번갈아 세 번(4초씩, `run.py --suite app --workloads score`): + +| | 이전 | 레코드 | +|---|---:|---:| +| 64연결, 연결 스레드 1: POST/s | 13,136–16,480 | **21,006–26,080** | +| 64연결, 연결 스레드 3 | 15,127–15,423 | **21,990–25,408** | +| 8연결, 연결 스레드 1 | 2,174–2,344 | **2,912–3,796** | +| 8연결, 연결 스레드 3 | 2,217–2,406 | **3,388–3,870** | +| p50(64연결) | 3.2–3.3 ms | **2.0 ms** | + +소유자의 라운드로 보면 동기화가 평균 3.3 ms에서 1.9 ms로, 한 라운드가 4.6 ms에서 3.0 ms로 줄었다. 동기화의 p50은 +1.24 ms로 탐침과 같다. 평균을 끌어올리는 것은 4 ms를 넘는 4%의 동기화(전체 동기화 시간의 33%)다. 꼬리를 늘린 뒤의 +동기화(5초에 13번, 새로 쓴 0과 새 크기를 함께 내보낸다), 압축(2번), 체크포인트(16커밋마다, 두 구간), 그리고 장치의 +잡음이다. 문서 서버의 내구성 PUT은 같은 빌드끼리도 p99가 0.1–1.1 s로 흔들려(장치 상태) 차이를 가려내지 못했다. 같은 +범위다. + +## 검증 + +- `test-tree-store`: 기존 검사(무작위 오라클, 실패 단계, 단계마다 죽는 프로세스, 잃어버린 커밋, 동기화 중인 헤드, + 압축, 손상, 커밋당 동기화 한 번과 파일 크기 불변)가 그대로 통과한다. 찢긴 헤드 검사는 이제 두 가지다. 새 슬롯을 찢어도 + 다른 슬롯에서 레코드를 따라가 최신 커밋을 찾고, 최신 레코드를 찢으면 새 프로세스는 그 앞 커밋을 보며 다음 쓰기가 찢긴 + 자리에 쓴다. 새 검사 `records`: 42커밋 뒤 슬롯의 헤드는 33이고, 헤드를 모르는 새 프로세스도 최신 커밋을 읽으며, 37번째 + 커밋의 레코드를 손상시키면 36에서 멈추고 다음 커밋이 그 위에 쓴다. ASan·UBSan 빌드에서도 같다. +- `check-tree`(저장소 위의 런타임·splice·비동기·실제 PTY 편집기 검사), `test-module`(상태 영속, 단계마다 죽는 앱), + aup `make check`(모델 대조, `kill -9` 뒤 확인 응답 보존, 저장한 상태로 다시 시작), Vega `make check`가 통과한다. + +## 다음 결정 + +- **꼬리 늘리기와 압축을 커밋 밖으로.** 동기화 시간의 3분의 1이 드문 느린 동기화다. 0을 써 넣는 일과 압축을 저장이 + 없는 틈이나 다른 스레드에서 하면 그만큼 줄 것이다. 다만 0을 새로 쓴 쪽이 아직 내구성이 없는데 커밋이 그 위에 쓰면 안 + 되므로, 늘린 꼬리를 먼저 동기화하는 순서가 필요하다. +- **저장과 적용의 겹치기**는 열린 부하(답을 기다리지 않고 요청이 오는 경우)에서 다시 잰다. 닫힌 루프에서는 위의 까닭으로 + 얻을 것이 없다. |
