aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-30 03:19:45 +0100
committereunhhu <[email protected]>2026-09-30 03:19:45 +0100
commit41cb1cca55c67dd2aac2afa2f96bdc09a5d50b78 (patch)
treee2929b52580a1d52544178c18e2f5cfaea50f993 /docs/research
parent385754c2d00ba3f4c74a22f55d4756e101f215b1 (diff)
downloadpure-41cb1cca55c67dd2aac2afa2f96bdc09a5d50b78.tar.gz
pure-41cb1cca55c67dd2aac2afa2f96bdc09a5d50b78.zip
Say where a node's memory goes, and the budget it now keeps (104, 013)
104 measures a node's memory under a receiver that is down and under a snapshot held for long (021's run.py: resident memory against what was posted, 30 s a case). A held snapshot grew the node without end (6.4 to 678 MiB, fixed in d1e8f38); a slow receiver grows it by what was sent, 221 B a send, which is state until delivered, so the budget bounds state and refuses past it. 013 is that budget's design: what it counts (state bytes, deterministic), what an event over it does, why an answer takes its send's room, how links and HTTP wait, what it does not count and those bounds, and the checks. With --memory 4000000 the same load fills it in about 4 s and the node stays at 9.6-9.8 MiB (45.9 MiB and growing without). 012 records the hole the budget found in the link (the full flag), and 103 that its clock fix fell short (385754c). CHECKPOINT gains the row.
Diffstat (limited to 'docs/research')
-rw-r--r--docs/research/103-review-004-defects.md13
-rw-r--r--docs/research/104-where-memory-goes.md98
2 files changed, 111 insertions, 0 deletions
diff --git a/docs/research/103-review-004-defects.md b/docs/research/103-review-004-defects.md
index 3273ab3..e897c41 100644
--- a/docs/research/103-review-004-defects.md
+++ b/docs/research/103-review-004-defects.md
@@ -42,6 +42,19 @@
이어받은 뒤 필드를 재배치한 모듈로 바꾸는 경우만 여전히 타입까지만 본다.
- **시계 검사의 방법.** 이 기기에서 시스템 시계를 바꿀 권한은 없고, 바꿔서도 안 된다. 그래서 `CLOCK_REALTIME`만 한 시간
뒤로 돌리는 LD_PRELOAD 라이브러리를 썼다. Vega의 검사도 Pure가 만든 이 라이브러리를 쓴다(`make check`가 넘긴다).
+- **뒤에 찾은 것(2026-09-30): 시계의 고침이 모자랐다.**
+ - `run_timers`는 마감을 monotonic clock으로 셌지만, 그 마감까지 소유자가 기다리는 `pthread_cond_timedwait`는 여전히 wall
+ clock의 시각까지 기다렸다. HTTP 전달의 다시 보내기 간격과, 저장소의 group commit이 함께 커밋할 스레드를 기다리는 2 ms도
+ 같았다.
+ - 이 라이브러리는 프로세스가 읽는 시각만 옮긴다. 그래서 되돌린 시각으로 센 마감은 커널에게 이미 지난 시각이고, 기다림이
+ 곧바로 끝나 되풀이된다. 검사는 틱이 이어지는 것만 보았고, 그동안 소유자가 한 코어의 64 %를 쓰는 것은 보지 못했다.
+ 실제로 시계를 뒤로 돌리면 반대로 기다림이 한 시간 늘어난다. 타이머가 멈추고, 전달이 한 시간 쉬며, 혼자 커밋하는 요청이
+ 한 시간 걸린다.
+ - 고쳤다. 세 기다림 모두 monotonic clock으로 센다(`core/wait.h`). 라이브러리가 시각을 앞으로도 옮길 수 있게 했고(`CLOCK_SHIFT_S`),
+ check.py `server_clocks`가 한 시간 뒤로, 또 앞으로 옮긴다.
+ - 고치기 전: 뒤로 옮기면 CPU 159 %, 전달 시도 1.2초에 50,886번. 앞으로 옮기면 틱 1, 전달 시도 1번, 세 요청이 함께 커밋한
+ 뒤 혼자 온 커밋이 끝나지 않았다(스무 번째 안에).
+ - 고친 뒤: 두 방향 모두 틱 59, CPU 0 %, 전달 시도 4번, 혼자 온 커밋 2.6 ms.
## 리뷰의 다른 지적
diff --git a/docs/research/104-where-memory-goes.md b/docs/research/104-where-memory-goes.md
new file mode 100644
index 0000000..03749c7
--- /dev/null
+++ b/docs/research/104-where-memory-goes.md
@@ -0,0 +1,98 @@
+# 104 — 메모리가 어디로 가는가: 느린 수신자와 오래 붙든 스냅숏
+
+2026-09-30. [외부 리뷰 004](../outside-feedback/feedback-004.md)의 둘째 권고에서 시작한다. 앱 전체의 메모리 예산을 정하기
+전에, 느린 수신자와 오래 열린 화면 스냅숏이 있을 때 노드의 메모리가 어디로 가는지 잰다.
+
+착수 기준:
+
+- **바꿀 결정.** 앱 전체 메모리 예산([013](../design/013-memory-budget.md))이 무엇을 세고, 넘칠 때 무엇을 할지.
+- **최소 확인.** 두 조건에서 상주 메모리가 무엇에 비례해 느는지 잰다. 한계가 없는 증가가 있으면, 예산보다 먼저 고친다.
+- **중단 기준.** 고친 것이 스냅숏의 읽기(연결 스레드, 표면, 체크포인트)를 하나라도 틀리게 하면 되돌린다.
+
+## 쉬운 말로
+
+- 알아낸 것:
+ - **스냅숏 하나를 오래 붙들면 메모리가 끝없이 늘었다.**
+ - 표면 하나가 항목 2만 개의 스냅숏을 붙든 채 글을 고치게 했다. 노드가 30초 만에 6.4 MiB에서 678 MiB가 됐다(고칠 때마다
+ 3.5 KB).
+ - 스냅숏들이 옛것에서 새것으로 사슬을 이루어, 하나를 붙들면 그 뒤의 모든 판을 붙들었기 때문이다.
+ - 고쳤다. 이제 붙든 스냅숏은 그때의 상태 한 벌만 쥔다. 같은 경우가 6.5 MiB에서 10.4 MiB로 늘고 멈춘다.
+ - **받는 쪽이 늦으면 보낸 것에 비례해 는다.** 이것은 한계가 아니라 설계다. 보낸 것은 전달될 때까지 앱의 상태이기 때문이다.
+ - 보내지 못한 한 건마다 outbox에 221 B가 든다(100 B 글의 주문).
+ - 링크(`pure://`)로 보내면 링크가 따로 사본을 들어 399 B, HTTP로 보내면 534 B다.
+ - 그래서 예산은 "상태의 상한"과 "넘칠 때 거절"로 정해야 한다. 전송의 사본은 줄일 수 있는 상수배다([013](../design/013-memory-budget.md)).
+ - **예산을 주면 멈춘다.** 013의 1단계(`--memory`) 뒤, 받는 쪽 없이 보내는 노드에 상태 4 MB를 주었다. 4초 남짓에 차고,
+ 그 뒤로 보내는 요청은 503 `memory`를 받으며 상주 메모리가 9.6–9.8 MiB에 머문다. 예산이 없으면 30초 동안 45.9 MiB까지 는다.
+- 아직 모르는 것: 체크포인트가 쓰는 동안의 메모리는 따로 재지 않았다.
+
+## 방법
+
+[`research/experiments/021-memory/run.py`](../../research/experiments/021-memory/run.py)가 경우마다 `pure-serve`를 띄운다.
+클라이언트 넷이 연결을 유지한 채 쉬지 않고 보내고, 0.5초마다 노드의 상주 메모리(`/proc/PID/status`의 VmRSS)와 보낸 수를
+적는다. 끝나면 저장소 크기와 노드의 종료 통계를 남긴다.
+
+| 경우 | 앱 | 조건 |
+|---|---|---|
+| outbox | 주문마다 출력으로 보내는 앱 | 출력에 연결이 없다. 보낸 것은 모두 outbox에 남는다 |
+| link-down | 같은 앱 | 출력을 아무도 듣지 않는 포트로 링크했다(`pure://`) |
+| wire-down | 같은 앱 | 출력을 아무도 듣지 않는 포트로 HTTP 전달한다 |
+| unheld | 항목 2만 개(각 100 B 글)를 두고 무작위 항목의 글을 고치는 앱 | 스냅숏을 붙드는 쪽이 없다 |
+| held | 같은 앱 | 표면 하나([hold-surface.c](../../research/experiments/021-memory/hold-surface.c))가 항목이 다 든 스냅숏을 끝까지 붙든다 |
+
+## 결과
+
+30초씩, 이 기기(Raspberry Pi 5)에서 잰 값이다(`research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl`, 고치기
+전의 held는 `2026-09-30-held-before-linux-aarch64.jsonl`: `de2860a`의 pure-serve를 `PURE_SERVE`로 주었다).
+
+| 경우 | 보낸 수 | 상주 메모리 | 한 건마다 | 저장소 |
+|---|---:|---|---:|---:|
+| outbox | 202,763 | 3.0 → 45.7 MiB | 221 B | 34.7 MB |
+| link-down | 198,874 | 8.5 → 84.1 MiB | 399 B | 34.7 MB |
+| wire-down | 198,269 | 3.0 → 104.0 MiB | 534 B | 34.7 MB |
+| unheld | 206,179 | 6.5 → 6.8 MiB | 1.9 B | 19.3 MB |
+| held, 고치기 전(`de2860a`) | 203,791 | 6.4 → 677.8 MiB | 3,455 B | |
+| held | 205,354 | 6.5 → 10.4 MiB | 19.9 B | 19.3 MB |
+
+"한 건마다"는 늘어난 상주 메모리를 보낸 수로 나눈 것이다.
+
+### 스냅숏의 사슬
+
+- 앱의 스냅숏은 옛것이 다음 것의 참조를 쥐는 사슬이었다. 목록이 스냅숏을 뜬 뒤에 놓는 노드와 텍스트는, 그때 가장 새 스냅숏과
+ 함께 풀렸다. 그래서 스냅숏이 풀리는 순서가 옛것부터로 정해졌다.
+- 그런데 스냅숏 하나를 오래 붙들면 그 뒤의 스냅숏이 모두 붙들렸고, 그동안 놓인 노드와 텍스트가 모두 남았다. 표면, 느린 읽기,
+ 체크포인트가 스냅숏 하나를 오래 쥐면 노드의 메모리가 쓰기에 비례해 끝없이 늘었다.
+- 옛 스냅숏이 읽을 수 있는 것은 자기가 떠진 때 이미 있던 노드뿐이다. 그래서 이렇게 바꿨다(`eebdeea`).
+ - 스냅숏에 번호(epoch)를 매긴다. 목록의 사본도 그 번호를 받고, 그 뒤에 만든 노드와 텍스트는 그 번호를 적는다.
+ - 붙들린 스냅숏들은 함께 쓰는 등록부 아래 옛것부터 이어진다.
+ - 스냅숏이 풀릴 때, 그것과 함께 가던 것 가운데 아직 붙들린 바로 앞 스냅숏이 읽을 수 있는 것(그 번호보다 먼저 만든 것)만
+ 그 스냅숏에 넘기고, 나머지는 곧바로 풀어 준다.
+ - 이음은 등록부의 스핀락 아래서 옮기고, 해제는 그 밖에서 한다.
+- 검사: test-module의 `snapshot_held`가 스냅숏 하나를 붙든 채 사건 4만 번과 스냅숏 4만 개를 지나간다. 뒤쪽 2만 번 동안 남은
+ 할당은 0 B다. 옛 방식처럼 붙들게 하면 이 검사가 실패한다. 스냅숏 여덟 개를 무작위 순서로 풀며 내용을 대조하는 기존 검사와
+ 두 스레드의 읽기 검사도 AddressSanitizer 아래에서 통과한다. ThreadSanitizer는 이 커널(가상 주소 47비트)에서 돌지 않는다.
+
+### 느린 수신자
+
+- 보낸 것은 받는 쪽이 저장했다고 할 때까지 앱의 상태다. 그래서 받는 쪽이 꺼져 있으면 outbox는 보낸 만큼 는다. 막을 수 없고,
+ 막아서도 안 된다. 정할 것은 상한과 넘칠 때의 행동이다.
+- outbox 한 건은 221 B였다. 주문의 글 100 B, 출력 이름과 번호, 목록 노드와 할당의 머리다.
+- 전송은 outbox를 한 번 더 복사해 든다. 링크의 보내는 쪽은 자기 ring에(한 건에 178 B 더), HTTP 전달은 자기 큐에(313 B 더)
+ 둔다. 둘 다 저장소에 이미 있는 것의 사본이다.
+
+### 예산을 준 뒤
+
+013의 1단계를 넣은 뒤(`883d64c`), 같은 앱을 같은 부하로 30초씩 다시 쟀다
+(`research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl`).
+
+| 경우 | 받아들임 | 거절(503 `memory`) | 상주 메모리 | 상태 바이트 |
+|---|---:|---:|---|---:|
+| outbox, 예산 없음 | 203,928 | 0 | 3.0 → 45.9 MiB, 계속 는다 | 27,734,208 |
+| budget(`--memory 4000000`) | 29,411 | 177,258 | 3.0 → 9.6 MiB, 6초부터 9.6–9.8 MiB | 3,999,896 |
+
+- 예산은 4초 남짓에 찼다. 그 뒤로 보내는 요청은 곧바로 거절되어, 같은 시간에 받아들임과 거절을 합친 수가 예산 없는 경우와
+ 비슷하다.
+- 늘어난 상주 메모리는 상태 바이트의 1.72배(예산), 1.62배(예산 없음)다. 보낸 것 한 건이 상태 136 B, 상주 221 B다. 013의
+ 중단 기준(3배)보다 낮다.
+- 세는 비용: 가장 작은 사건(수 하나를 더함, 저장소 없음)이 상태 1개인 앱에서 125 → 136 ns(+8 %), 32개인 앱에서 321 →
+ 349 ns(+9 %)다. 처음에는 사건마다 모든 상태를 두 번 걸어 125 → 146 ns(+17 %), 328 → 454 ns(+38 %)였다. 목록과 텍스트만
+ 비트로 골라 걷게 했다(`883d64c`). 예산이 없으면 비용이 없다. HTTP로 받는 쓰기는 한 건에 수십 µs라 처리량에 보이지 않는다.