diff options
| -rw-r--r-- | CHECKPOINT.md | 3 | ||||
| -rw-r--r-- | docs/design/012-link-protocol.md | 4 | ||||
| -rw-r--r-- | docs/design/013-memory-budget.md | 95 | ||||
| -rw-r--r-- | docs/research/103-review-004-defects.md | 13 | ||||
| -rw-r--r-- | docs/research/104-where-memory-goes.md | 98 | ||||
| -rw-r--r-- | research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl | 2 | ||||
| -rw-r--r-- | research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl | 1 | ||||
| -rw-r--r-- | research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl | 5 | ||||
| -rw-r--r-- | research/experiments/021-memory/hold-surface.c | 32 | ||||
| -rw-r--r-- | research/experiments/021-memory/run.py | 221 |
10 files changed, 473 insertions, 1 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md index e6e9fa7..7692742 100644 --- a/CHECKPOINT.md +++ b/CHECKPOINT.md @@ -18,6 +18,7 @@ | HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문과 그 안의 그룹 객체를 제자리에서 읽는다, 라우터 코드 없음). 앱의 `send`는 이벤트와 함께 저장되고, `--send 출력=URL`로 다른 앱이나 HTTP 끝점에 순서대로 묶어 전해지며, 받는 앱은 번호마다 한 번 적용한다(서버를 죽였다 살려도 정확히 한 번). `--seal 출력=공개키`면 받는 노드의 키로 봉인해(HPKE, 전달선마다 세션 하나, 보내는 노드의 키로 인증) 사이의 중계가 읽지도 바꾸지도 못하고, 받는 노드는 `--sender 이름=키`로 보낸 이를 가린다(봉인 전달 462k/s, 봉인 없이 468k/s). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [082](docs/research/082-json-in-place.md), [083](docs/research/083-delivery-over-http.md), [086](docs/research/086-sealed-sends.md), [088](docs/research/088-sealed-sessions.md) | | 성능(Pi 5) | 2026-09-27, 같은 기기에서 axum·drogon과 함께 쟀다. 문서 서버(한 스레드): 64 B GET 139k/s(CPU 1초당 140k; axum 127k·63k, drogon 147k·63k), 1 MiB GET 2,362/s(axum 1,304, drogon 754), 충돌 안전 PUT은 axum-atomic의 5.1–5.8배. 앱 서비스: 내구성 있는 쓰기 64연결 21.3k/s(axum·drogon 내구 약 200/s), 합계 GET 126k/s·두 스레드 153k(axum 136k, drogon 151k), 20행 페이지 두 스레드 148k(axum 93k, drogon 138k), 쓰기와 섞인 읽기 119k/s(axum·drogon 내구 1.1–1.4k/s). 유휴 RSS 2.3 MiB(axum 2.8, drogon 10.9), 최대 3.2–5.8 MiB(axum 6.6–9.7, drogon 11.9–78.8) | [075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [081](docs/research/081-tail-records.md), [087](docs/research/087-small-reads-and-idle-memory.md) | | 노드 사이 전달 | 연결 없는 링크가 기본이다(`--send 출력=pure://호스트:포트/입력`, 2026-09-29 채택). 두 노드의 키로 첫 패킷부터 봉인해 보내고 핸드셰이크가 없다. 어느 쪽을 kill -9 해도, 받는 노드의 저장소를 다른 주소로 옮겨도 다음 패킷으로 이어지며 잃거나 겹친 기록이 없다(기기 1만 개가 초당 2,000건을 보내는 중에 저장이 멈춘 100 ms 칸 0). 쉬는 링크는 받는 노드 메모리 132 B(quinn 연결의 1/289). 에폭마다 키를 바꿔 전방 비밀성을 얻는다. 전달당 CPU는 1 KiB 이상에서 quinn·ngtcp2보다 25 % 넘게 적고 61 B에서 비긴다. 손실·재정렬·병목 경로(M6)에서 재정렬 아래 quinn·ngtcp2보다 많이 나르고, 무작위 손실 아래 같으며, 깨끗한 병목에서 틀의 바이트로 3–5 % 적다. 명세가 있고, 명세만 보고 쓴 파이썬 받는 쪽이 노드와 맞붙는다 | [설계 011](docs/design/011-pure-transport.md), [명세 012](docs/design/012-link-protocol.md), [093](docs/research/093-connectionless-link.md)–[102](docs/research/102-adoption-and-spec.md) | +| 메모리 한도 | 앱마다 상태의 예산이 있다(`--memory`). 목록의 항목, 텍스트, outbox, 들은 번호를 저장 형식의 바이트로 센다(어느 기기에서나 같다). 예산을 넘기며 늘리는 사건은 거절하고(503 `memory`) 줄이는 사건은 받는다. 받는 쪽이 늦으면 outbox가 차서 클라이언트까지 역압이 가고, 가득 찬 받는 쪽은 링크로 알려 보내는 쪽이 다시 보내지 않는다. 답(`--reply`)은 자기 보내기의 자리를 쓴다. 상태 4 MB를 준 노드는 부하 아래 상주 9.6–9.8 MiB에 멈춘다(예산 없이 30초에 45.9 MiB). 오래 붙든 스냅숏은 그때 상태 한 벌만 쥔다(전: 30초에 678 MiB) | [설계 013](docs/design/013-memory-budget.md), [104](docs/research/104-where-memory-goes.md) | | 표면과 메신저 | 사람을 위한 표면이 노드 안에서 돈다(`pure-serve --surface`): 마지막 저장의 스냅숏을 제자리에서 읽고, 친 키와 글자는 HTTP 요청과 같은 줄로 소유자에게 간다. Vega의 디스플레이·픽셀이 그 표면이다. 두 노드의 메신저(Vega `examples/messenger.aup`)는 서로에게 봉인·인증해 보내고, 한 줄이 상대 노드에 보이기까지 RAM 저장소에서 p50 0.79 ms·p99 1.1 ms, SD 카드에서 p50 3.5 ms(꼬리는 SD의 동기화) | [설계 010](docs/design/010-surfaces.md), [089](docs/research/089-messenger.md) | | 표 크기의 상태 | 목록이 영속 B+트리다(변경·키 탐색·저장본 위치 O(log n), 스냅숏 O(1), 1,600만 항목, 런타임 상태 1 GiB). pgbench의 TPC-B 같은 이체(세 표의 행과 이력, 한 트랜잭션)가 10만 계좌에서 RAM 24.5k/s(전: 3만 계좌 3.2k/s), SD 카드 내구 1,339/s(SQLite WAL FULL 84–153/s) | [090](docs/research/090-tables.md) | | 일반 트랜잭션 | TPC-C의 다섯 트랜잭션을 aup 앱 하나로 쓴다(여러 표 읽기·쓰기, 조각 훑기, 중복 없는 셈, 실패하면 통째 되돌림). 같은 입력에서 SQLite와 아홉 표의 모든 행이 같다. W=1에서 RAM 초당 39,977건(SQLite FULL 9,529, 트랜잭션당 CPU 44–49 µs, HTTP·JSON 포함), SD 카드 내구 4,502건(SQLite FULL 50, NORMAL 372) | [092](docs/research/092-tpcc.md) | @@ -28,7 +29,7 @@ 블록을 받지 않는다(저장소 커밋 +21%, 저널 커밋 −59%, [085](docs/research/085-cycle-sized-files.md)). 남은 것: macOS 검증, `bool`·`i64`, 압축을 없애는 제자리 재사용 저장소(085 다음), 응답을 기다리는 출력의 묶음 전달(083), 앱 -라우트의 인증·TLS(문서 피어의 TLS는 있다), aup·Vega의 배포 묶음. +라우트의 인증·TLS(문서 피어의 TLS는 있다), aup·Vega의 배포 묶음, 전송 사본의 창과 체크포인트 동안의 메모리(013의 2·3단계). ## 공개할 작업물과 완료 증거 diff --git a/docs/design/012-link-protocol.md b/docs/design/012-link-protocol.md index f2e9820..99d7a91 100644 --- a/docs/design/012-link-protocol.md +++ b/docs/design/012-link-protocol.md @@ -346,3 +346,7 @@ LOST는 그 패킷의 번호를 담아 에폭 0의 키로 봉인한다. 답 run� 갔다. 기록의 상한을 16 MiB로 맞췄다. - 받는 쪽은 피어마다 run 자리를 둘만 두었다. 한 노드의 출력 셋 이상이 같은 노드로 가면 run들이 서로 자리를 밀어내어, 답이 다른 run으로 갔다. 살아 있는 run이 더 오면 자리를 늘린다. +- 앱의 메모리 예산([013](013-memory-budget.md))을 더하며 하나를 더 찾았다. 앱이 가득 차 전달을 거절하면, 받는 쪽은 같은 + 기록을 곧바로 다시 넘겨 한 코어를 태웠고(2초에 거절 27,809번), 보내는 쪽은 받은 것이 1초째 저장되지 않자 창 전체를 매초 + 다시 보냈다(시뮬레이션에서 5초 동안 21,640건). 가득 참 깃발(2)과 다시 넘기는 간격을 더했다. 같은 조건에서 거절은 7번, + 다시 보낸 기록은 0건이다. diff --git a/docs/design/013-memory-budget.md b/docs/design/013-memory-budget.md new file mode 100644 index 0000000..fe8736a --- /dev/null +++ b/docs/design/013-memory-budget.md @@ -0,0 +1,95 @@ +# 013 — 앱 전체 메모리 예산 + +작성일: 2026-09-30. 상태: 1단계 구현. +[외부 리뷰 004](../outside-feedback/feedback-004.md)의 둘째 권고에서 시작한다. 측정은 [104](../research/104-where-memory-goes.md)에 있다. + +지금의 `--budget`은 요청 payload의 접수만 묶는다. 앱의 목록, outbox, 붙들린 스냅숏, 전송의 사본은 아무것도 묶지 않는다. 작은 +기기에서 오래 도는 노드라면 무엇이 얼마나 쓰는지 정해져 있어야 하고, 넘칠 때 무엇을 하는지도 정해져 있어야 한다. + +## 결정 + +앱마다 메모리 예산이 하나 있다(`pure-serve --memory BYTES`). 지금은 노드 하나가 앱 하나다. + +**예산이 세는 것: 상태 바이트.** + +- 목록 상태의 항목: 저장 형식의 바이트다(수 8 B, 글 4 B와 그 길이). 목록이 늘 알고 있는 값이다. +- 텍스트 상태의 길이 +- outbox(보냈지만 받는 쪽이 아직 저장하지 않은 것)와 들은 번호 목록 + +이 수는 결정적이다. 같은 앱과 같은 입력이면 어느 기기에서나 같다. 상주 메모리는 이것의 약 1.6–1.8배다(104: outbox 한 건이 +상주 221 B에 상태 136 B, 4 MB 예산에서 1.72배). 예산을 상주 메모리로 세면 할당기와 기기에 따라 달라지고, 검사할 수 없다. + +**넘칠 때: 상태를 늘리는 사건을 거절한다.** + +- 사건이 끝났을 때 상태 바이트가 예산을 넘고 사건 전보다 늘었으면, 그 사건은 fault `memory`로 실패한다. 바꾼 것은 다른 실패한 + 사건처럼 모두 되돌린다. +- 상태를 줄이거나 그대로 두는 사건, 보기, 읽기는 예산을 넘은 채로도 된다. 그래야 줄일 수 있다. +- HTTP로는 503 `memory`다. 요청 payload의 예산(503 `payload_budget`)처럼 "잠시 뒤 다시"의 뜻이다. +- 다른 노드에서 온 전달이 이렇게 거절되면, 들은 번호로 세지 않는다. 앱이 거절한 메시지(한 번 전달되고 끝남)와 달리, 이것은 + 일시적이다. 보내는 쪽이 다시 보낸다. + - 링크(`pure://`): 받는 쪽이 기록을 쥔 채 100 ms에서 두 배씩 500 ms까지 쉬었다가 다시 넘긴다. 그동안 보내는 쪽에 앱이 가득 + 찼다고 알린다(STORED의 깃발 2, [012](012-link-protocol.md)). 보내는 쪽은 쥐고 있는 것을 다시 보내지 않는다. + - HTTP: 503 `memory`를 받은 보내는 쪽이 100 ms에서 두 배씩 10초까지 쉬었다가 다시 보낸다(전부터의 규칙). + +**답은 자기 보내기의 자리를 쓴다.** HTTP로 보낸 것의 답(`--reply`)은 그 보내기를 outbox에서 지우는 일과 한 사건이다 +(`pure_app_answer`). + +- 따로 두면 멈출 수 있다. outbox가 예산을 채운 앱에서, 받은 것을 적어 두는 답은 예산을 넘으니 거절된다. 그런데 보내기는 답이 + 적용된 뒤에야 outbox를 떠난다. 그러면 답도 보내기도 영영 그대로다. +- 한 사건이면 답은 보내기가 쥐던 자리만큼 늘 수 있다. 그보다 많이 쥐는 답은 예산을 넘으면 기다린다. 잃지 않고, 두 번 + 적용되지 않으며, 그 뒤의 답도 순서대로 기다린다. 앱이 자리를 비우면(줄이는 사건) 100 ms 안에 들어간다. + +**느린 수신자: outbox도 상태다.** + +- 받는 쪽이 늦거나 꺼져 있으면 outbox가 예산을 채운다. 그 뒤로는 보내는 사건이 거절된다. 이것이 앱의 클라이언트까지 닿는 + 역압(backpressure)이다. +- 받는 쪽이 저장해 outbox가 줄면 다시 받는다. +- 보낸 것을 버리거나 디스크로만 넘기지 않는다. 보낸 것은 전달될 때까지 앱의 상태이고, 목록은 메모리의 상태이며 저장소는 그 + 기록이다. 메모리보다 큰 데이터는 문서(키로 여는 모음, 트리 저장소)가 맡는다. 문서는 저장소에 있고 읽기는 빌린 extent라서, + 예산에 들지 않는다. +- 두 노드가 서로에게 보내고 둘 다 가득 차면, 서로 상대가 비우기를 기다린다. 링크의 outbox는 받는 쪽이 저장하면 비므로 요청이 + 응답을 기다리지는 않는다. 그러나 응답을 받는 사건이 늘기만 하면 두 앱이 함께 멈출 수 있다. 응답을 받는 사건이 요청의 + 기록을 지우게 쓰면 그 사건은 줄이는 사건이라 늘 된다. 런타임이 대신 풀 수 없는 앱의 설계다. + +**세지 않는 것과 그 상한.** 007의 원칙대로 세지 않는 것은 그 상한을 적는다. + +| 무엇 | 상한 | 이유 | +|---|---|---| +| 붙들린 스냅숏 | 붙든 판마다 상태 한 벌. 붙드는 쪽(연결 스레드, 표면, 체크포인트)이 H이면 최악 (1 + H) × 예산 | 104에서 고쳤다. 전에는 한이 없었다 | +| 전송의 사본 | 링크 ring과 HTTP 큐가 outbox를 한 번 더 든다(한 건에 0.8배, 1.4배) | 2단계에서 창으로 묶는다 | +| 요청 payload | `--budget` | 지금의 접수 예산 | +| 체크포인트 | 이미지를 쓰는 동안 스냅숏 하나 | 붙들린 스냅숏의 한 경우 | + +## 단계 + +1. 상태 바이트를 세고, `--memory`로 예산을 두고, 넘칠 때 사건을 거절한다. 전달의 거절은 들은 번호로 세지 않는다. +2. 전송의 창: 링크와 HTTP 전달이 outbox 전체가 아니라 창만큼만 받는다. 전달되면 다음을 받는다. +3. 체크포인트가 쓰는 동안의 메모리를 잰다. + +## 검사 + +- test-module `memory_budget`: + - 예산을 넘기는 사건이 거절되고 되돌려진다. + - 줄이는 사건은 넘은 채로도 된다. + - outbox가 차면 보내는 사건이 거절되고, 덜어 내면 다시 된다. + - 전달의 거절은 들은 번호를 올리지 않는다. 자리가 나서 다시 오면 한 번 적용된다. + - 보내기보다 많이 쥐는 답은 예산에서 통째로 거절되고(보내기가 outbox에 남는다) 자리가 나면 된다. 적게 쥐는 답은 예산에서도 + 된다. 다시 시작한 뒤에도 그대로다. +- test-link `full`: 앱이 5초 동안 가득 찬다. 그동안 기록을 다시 넘긴 것은 12번이고, 자리가 난 뒤 0.5초 안에 받는다. 보내는 + 쪽이 다시 보낸 기록은 0건이다(깃발 2가 없으면 21,640건). +- check.py `server_memory`: + - `--memory`를 준 노드의 링크를 아무도 듣지 않는 곳으로 둔다. 13번째 뒤의 주문이 503 `memory`를 받고, 보기는 읽힌다. 받는 + 쪽이 뜨면 outbox가 줄고 다시 200이다. + - 받는 쪽을 가진 것보다 작은 예산으로 다시 띄운다. 링크의 전달과 클라이언트의 주문이 거절되고, 비우는 사건은 된다. 비우면 + 기다리던 주문이 모두 한 번씩 들어온다. 가득 찬 2초 동안 거절은 7번이다(기다림이 없으면 27,809번). +- check.py `server_answers_memory`: 답이 보내기보다 큰 영수증을 남긴다. 영수증이 예산을 채우면 답이 기다리고 주문이 503 + `memory`를 받는다. 비우면 기다리던 답이 모두 한 번씩 적용되고, API는 주문마다 한 번씩만 불렸다. +- 021의 run.py `budget`: 상태 4 MB가 4초 남짓에 차고, 상주 메모리가 9.6–9.8 MiB에 멈춘다(예산 없이 30초에 45.9 MiB). + +## 중단 기준 + +| 조건 | 결정 | +|---|---| +| 상태 바이트를 세는 비용이 사건마다 측정 가능하게 든다(작은 쓰기의 처리량이 잡음 밖으로 준다) | 셈을 목록 안으로 옮긴다. 가장 작은 사건에서 +38 %였던 것을 세는 상태만 걷게 해 +9 %로 줄였다. 처리량에는 보이지 않는다(104) | +| 거절된 전달이 반복해 돌며 CPU를 쓴다 | 전달에 기다림을 둔다(1단계에서 일어났고, 두었다) | +| 예산 안에서 상주 메모리가 상태 바이트의 3배를 넘는다 | 세는 것을 다시 본다. 잰 값은 1.72배다 | 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라 처리량에 보이지 않는다. diff --git a/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl new file mode 100644 index 0000000..9b6fc4d --- /dev/null +++ b/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl @@ -0,0 +1,2 @@ +{"case": "budget", "seconds": 30, "posted": 29411, "refused": 177258, "rss_began_kib": 3072, "rss_end_kib": 9808, "rss_per_post_bytes": 234.5, "store_bytes": 9512871, "samples": [[0.0, 3072, 0], [2.0, 6400, 13775], [4.01, 9248, 27484], [6.01, 9872, 29411], [8.01, 9856, 29411], [10.02, 9936, 29411], [12.02, 9808, 29411], [14.02, 9936, 29411], [16.02, 9872, 29411], [18.03, 9888, 29411], [20.03, 9840, 29411], [22.03, 9856, 29411], [24.04, 10016, 29411], [26.04, 9872, 29411], [28.04, 9808, 29411], [30.05, 9872, 29411], [30.05, 9872, 29411]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 206669, "bursts": 150678, "burst_events": 29411, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 17913, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 150679, "grouped_commits": 150679, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0, "state_bytes": 3999896, "memory_refused": 177258}} +{"case": "outbox", "seconds": 30, "posted": 203928, "refused": 0, "rss_began_kib": 3088, "rss_end_kib": 47040, "rss_per_post_bytes": 220.7, "store_bytes": 34678695, "samples": [[0.0, 3088, 0], [2.0, 6320, 13620], [4.01, 9312, 27246], [6.01, 12160, 41022], [8.01, 15024, 54518], [10.02, 18016, 68266], [12.02, 20928, 82068], [14.02, 23856, 95792], [16.03, 26720, 109011], [18.03, 29680, 122729], [20.03, 32592, 136438], [22.04, 35648, 150137], [24.04, 38432, 163833], [26.05, 41136, 176611], [28.05, 44064, 190213], [30.05, 47040, 203924], [30.05, 47040, 203924]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 203928, "bursts": 122896, "burst_events": 203928, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 122897, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 122897, "grouped_commits": 122897, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0, "state_bytes": 27734208, "memory_refused": 0}} diff --git a/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl new file mode 100644 index 0000000..6036e49 --- /dev/null +++ b/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl @@ -0,0 +1 @@ +{"label": "de2860a", "case": "held", "seconds": 30, "posted": 203791, "refused": 0, "rss_began_kib": 6576, "rss_end_kib": 694096, "rss_per_post_bytes": 3454.6, "store_bytes": 19287708, "samples": [[0.0, 6576, 0], [2.0, 50000, 12778], [4.01, 96624, 26556], [6.01, 143488, 40444], [8.01, 189488, 54067], [10.02, 236320, 67956], [12.02, 283360, 81869], [14.02, 328080, 95138], [16.03, 375056, 109037], [18.03, 419184, 122186], [20.04, 466144, 136107], [22.04, 508992, 148894], [24.04, 555392, 162669], [26.05, 601664, 176432], [28.05, 647600, 190046], [30.05, 694080, 203787], [30.05, 694080, 203787]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 223791, "bursts": 134467, "burst_events": 223791, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 134468, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} diff --git a/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl new file mode 100644 index 0000000..407c273 --- /dev/null +++ b/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl @@ -0,0 +1,5 @@ +{"case": "outbox", "seconds": 30, "posted": 202763, "rss_began_kib": 3088, "rss_end_kib": 46768, "rss_per_post_bytes": 220.6, "store_bytes": 34678695, "samples": [[0.0, 3088, 0], [2.0, 6320, 13658], [4.01, 9248, 27323], [6.01, 12192, 40969], [8.01, 15072, 54665], [10.01, 18064, 68230], [12.02, 20848, 81832], [14.02, 23808, 95269], [16.03, 26752, 108838], [18.03, 29584, 122559], [20.03, 32560, 136189], [22.03, 35520, 149978], [24.04, 38416, 163368], [26.04, 41184, 176553], [28.04, 44000, 189399], [30.04, 46736, 202759], [30.04, 46736, 202759]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 202763, "bursts": 120723, "burst_events": 202763, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 120724, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 120724, "grouped_commits": 120724, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} +{"case": "link-down", "seconds": 30, "posted": 198874, "rss_began_kib": 8688, "rss_end_kib": 86144, "rss_per_post_bytes": 398.8, "store_bytes": 34678695, "samples": [[0.0, 8688, 0], [2.0, 14368, 13447], [4.01, 19536, 26781], [6.01, 24608, 39799], [8.01, 29744, 53146], [10.02, 34928, 66594], [12.02, 40144, 80020], [14.02, 45136, 92957], [16.02, 50240, 106415], [18.03, 55520, 119903], [20.03, 60784, 133388], [22.03, 65600, 145927], [24.04, 70736, 159166], [26.04, 75952, 172647], [28.04, 81152, 186071], [30.04, 86304, 198870], [30.04, 86304, 198870]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 198874, "bursts": 119928, "burst_events": 198874, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 119929, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 119929, "grouped_commits": 119929, "checkpoints": 0, "link_packets": 67, "link_records": 349, "link_resent": 32, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} +{"case": "wire-down", "seconds": 30, "posted": 198269, "rss_began_kib": 3120, "rss_end_kib": 106512, "rss_per_post_bytes": 534.0, "store_bytes": 34678695, "samples": [[0.0, 3120, 0], [2.0, 10512, 13272], [4.01, 17376, 26528], [6.01, 24336, 39975], [8.01, 31440, 53404], [10.02, 38272, 66774], [12.02, 45216, 80113], [14.02, 52000, 92988], [16.03, 58704, 106250], [18.03, 65824, 119668], [20.03, 72640, 133038], [22.03, 79552, 146378], [24.04, 86288, 159271], [26.04, 92880, 171996], [28.04, 99776, 185352], [30.05, 106512, 198265], [30.05, 106512, 198265]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 198269, "bursts": 119629, "burst_events": 198269, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 119630, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 9, "answers": 0, "commit_groups": 119630, "grouped_commits": 119630, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} +{"case": "unheld", "seconds": 30, "posted": 206179, "rss_began_kib": 6608, "rss_end_kib": 6992, "rss_per_post_bytes": 1.9, "store_bytes": 19287708, "samples": [[0.0, 6608, 0], [2.0, 6976, 13925], [4.01, 6976, 27728], [6.01, 6976, 41750], [8.01, 6976, 55762], [10.02, 6992, 69046], [12.02, 6992, 82578], [14.02, 6992, 95917], [16.03, 6992, 109910], [18.03, 6992, 123963], [20.03, 6992, 137643], [22.04, 6992, 151687], [24.04, 6992, 165710], [26.05, 6992, 178567], [28.05, 6992, 192132], [30.05, 6992, 206175], [30.05, 6992, 206175]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 226179, "bursts": 134438, "burst_events": 226179, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 134439, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} +{"case": "held", "seconds": 30, "posted": 205354, "rss_began_kib": 6640, "rss_end_kib": 10640, "rss_per_post_bytes": 19.9, "store_bytes": 19287708, "samples": [[0.0, 6640, 0], [2.0, 9168, 13836], [4.01, 9872, 27868], [6.01, 10240, 41809], [8.01, 10416, 54381], [10.02, 10512, 68479], [12.02, 10560, 82526], [14.02, 10592, 96125], [16.03, 10592, 109730], [18.03, 10592, 123623], [20.03, 10624, 137733], [22.04, 10640, 151837], [24.04, 10640, 164413], [26.04, 10640, 178153], [28.05, 10640, 192298], [30.05, 10640, 205350], [30.05, 10640, 205350]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 225354, "bursts": 132173, "burst_events": 225354, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 132174, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} diff --git a/research/experiments/021-memory/hold-surface.c b/research/experiments/021-memory/hold-surface.c new file mode 100644 index 0000000..c1c2d4b --- /dev/null +++ b/research/experiments/021-memory/hold-surface.c @@ -0,0 +1,32 @@ +/* A surface that holds a snapshot until the server stops: a reader that + * never lets go, for run.py's held case (research/experiments/021-memory). + * It holds the first snapshot whose number view VIEW is at least AT (the + * first snapshot at all without them), and lets every other one go. + * + * pure-serve ... --surface hold-surface.so [-- VIEW AT] */ +#include "surface.h" +#include <poll.h> +#include <stdint.h> +#include <stdlib.h> +#include <unistd.h> + +int pure_surface_run(const PureSurface *s, int argc, char **argv) { + static PureApp reader; + int view = argc > 2 ? pure_module_view(s->module, argv[1]) : -1; + double at = argc > 2 ? atof(argv[2]) : 0, value = 0; + PureSnapshot *held = NULL; + while (!s->stopping(s->host)) { + struct pollfd wait = {s->changes, POLLIN, 0}; + if (poll(&wait, 1, 20) > 0 && (wait.revents & POLLIN)) { + uint64_t saves; + if (read(s->changes, &saves, sizeof(saves)) < 0) { /* cleared already */ } + } + if (held) continue; + PureSnapshot *read_now = s->read(s->host, &reader); + if (!read_now) continue; + if (view < 0 || (pure_app_view(&reader, (uint32_t)view, &value) && value >= at)) held = read_now; + else s->release(s->host, read_now); + } + if (held) s->release(s->host, held); + return 0; +} diff --git a/research/experiments/021-memory/run.py b/research/experiments/021-memory/run.py new file mode 100644 index 0000000..49bb5a9 --- /dev/null +++ b/research/experiments/021-memory/run.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Where a Pure node's memory goes when what it keeps cannot leave (review +004: an app's whole memory budget, not its requests' alone). pure-serve runs +an app while clients post as fast as they may for --seconds; its resident +memory (VmRSS) is read every 0.5 s beside what was posted, and the store's +size at the end. + + outbox the sender's output wired nowhere: every send stays in its outbox + budget the same with --memory 4000000 (docs/design/013-memory-budget.md): + sends past it are refused, 503 memory + link-down the output linked (pure://) to a port no one listens on + wire-down the output sent over HTTP to a port no one listens on + held a list of items whose notes are set again and again, a surface + holding the snapshot of all of them (hold-surface.so) the whole time + unheld the same, no surface + + python3 research/experiments/021-memory/run.py [--seconds 30] [--cases outbox,budget,held] [--out FILE] + [--label NAME] + +PURE_SERVE=PATH runs another build's pure-serve (--label names it in the rows). +""" +import argparse +import http.client +import json +import os +import random +import select +import signal +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +HERE = Path(__file__).resolve().parent +SERVE = Path(os.environ.get('PURE_SERVE', ROOT / 'build/pure-serve')) # another build's, to compare +sys.path.insert(0, str(ROOT / 'tests')) +import check # noqa: E402 + +ITEMS = """record Item { + id = 0 + note: text +} +state items: list of Item by id +input add = message { + id: number + note: text +} +input set = message { + id: number + note: text +} +on add { items.add(Item { id: add.id, note: add.note }) } +on set { items[set.id].note = set.note } +view count = items.count +""" +SEEDED, NOTE, MEMORY = 20000, 100, 4000000 + + +def build(work): + if 'PURE_SERVE' not in os.environ: + subprocess.run(['make', '-s', '-C', str(ROOT), 'build/pure-serve'], check=True) + aup = Path(os.environ.get('AUP', ROOT.parent / 'aup/build/aup')) + for name, text in (('sender', check.LINK_SENDER), ('items', ITEMS)): + (work / f'{name}.aup').write_text(text) + check.compile_module(aup, work / f'{name}.aup', work / f'{name}.pmod') + subprocess.run(['cc', '-O2', '-std=c17', '-Wall', '-Wextra', '-Werror', '-fPIC', '-shared', f'-I{ROOT}/core', + f'-I{ROOT}/serve', str(HERE / 'hold-surface.c'), '-o', str(work / 'hold-surface.so')], check=True) + key = subprocess.Popen([str(SERVE), '--module', str(work / 'items.pmod'), '--store', + str(work / 'key-store'), '--key', str(work / 'node.key'), '--port', '0'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + select.select([key.stdout], [], [], 10) + key.stdout.readline() + key.terminate() + key.communicate(timeout=30) + + +def start(args): + args = [str(a) for a in args] + at = args.index('--') if '--' in args else len(args) # the surface's arguments come last + process = subprocess.Popen(args[:at] + ['--port', '0'] + args[at:], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + ready, _, _ = select.select([process.stdout], [], [], 10) + line = process.stdout.readline() if ready else b'' + assert line, process.stderr.read(2000) if process.poll() is not None else 'no start' + return process, json.loads(line)['port'] + + +def rss_kib(pid): + with open(f'/proc/{pid}/status') as status: + for line in status: + if line.startswith('VmRSS:'): + return int(line.split()[1]) + return 0 + + +def store_bytes(path): + return sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file()) + + +def post_all(port, bodies, stop, counts, index, refused): + """Posts (path, body) from bodies() on one keep-alive connection until + stop: counts[index] the ones taken, refused[index] those refused 503.""" + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30) + while not stop.is_set(): + path, body = bodies() + data = json.dumps(body).encode() + try: + connection.request('POST', path, data, {'Content-Length': str(len(data)), + 'Content-Type': 'application/json'}) + response = connection.getresponse() + response.read() + if response.status == 200: + counts[index] += 1 + elif response.status == 503: + refused[index] += 1 + except OSError: + connection.close() + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30) + connection.close() + + +def run_case(case, work, seconds, clients=4): + store = work / f'store-{case}' + args = [SERVE, '--store', store, '--threads', '2'] + note = 'n' * NOTE + if case in ('outbox', 'budget', 'link-down', 'wire-down'): + args += ['--module', work / 'sender.pmod'] + if case == 'budget': + args += ['--memory', MEMORY] + if case == 'link-down': + args += ['--key', work / 'node.key', '--send', 'ordered=pure://127.0.0.1:9/order', + '--seal', f'ordered={os.urandom(32).hex()}'] + elif case == 'wire-down': + args += ['--send', 'ordered=http://127.0.0.1:9/inputs/order'] + + def bodies(): + return '/inputs/buy', {'item': note, 'count': 1} + else: + args += ['--module', work / 'items.pmod'] + if case == 'held': # the snapshot of all the items, held from then on + args += ['--surface', work / 'hold-surface.so', '--', 'count', str(SEEDED)] + rng = random.Random(7) + + def bodies(): + return '/inputs/set', {'id': rng.randrange(SEEDED), 'note': f'{rng.getrandbits(32):08x}' + note[8:]} + process, port = start(args) + try: + if case in ('held', 'unheld'): # the items first, then the sets + seeded = [0] + done = threading.Event() + batches = [iter(range(k, SEEDED, clients)) for k in range(clients)] + + def seeding(k): + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=30) + for i in batches[k]: + data = json.dumps({'id': i, 'note': note}).encode() + connection.request('POST', '/inputs/add', data, {'Content-Length': str(len(data)), + 'Content-Type': 'application/json'}) + response = connection.getresponse() + response.read() + assert response.status == 200, response.status + connection.close() + threads = [threading.Thread(target=seeding, args=(k,)) for k in range(clients)] + for t in threads: + t.start() + for t in threads: + t.join() + del seeded, done + time.sleep(1.0) # the surface reads the first snapshot after the seeding's saves + stop = threading.Event() + counts, refused = [0] * clients, [0] * clients + began, rss_began = time.time(), rss_kib(process.pid) + samples = [(0.0, rss_began, 0)] + threads = [threading.Thread(target=post_all, args=(port, bodies, stop, counts, k, refused)) + for k in range(clients)] + for t in threads: + t.start() + while time.time() - began < seconds: + time.sleep(0.5) + samples.append((round(time.time() - began, 2), rss_kib(process.pid), sum(counts))) + stop.set() + for t in threads: + t.join() + rss_end = rss_kib(process.pid) + finally: + process.send_signal(signal.SIGTERM) + _, error = process.communicate(timeout=60) + stats = [json.loads(line) for line in error.decode().splitlines() if line.startswith('{"active"')] + posted = sum(counts) + return {'case': case, 'seconds': seconds, 'posted': posted, 'refused': sum(refused), 'rss_began_kib': rss_began, + 'rss_end_kib': rss_end, + 'rss_per_post_bytes': round((rss_end - rss_began) * 1024 / posted, 1) if posted else None, + 'store_bytes': store_bytes(store), 'samples': samples[::4] + [samples[-1]], + 'stats': stats[-1] if stats else None} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--seconds', type=int, default=30) + parser.add_argument('--cases', default='outbox,budget,link-down,wire-down,unheld,held') + parser.add_argument('--out', type=Path, default=None) + parser.add_argument('--label', default=None, help='the build, named in each row') + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='pure-memory-') as directory: + work = Path(directory) + build(work) + for case in args.cases.split(','): + row = run_case(case, work, args.seconds) + if args.label: + row = {'label': args.label, **row} + line = json.dumps(row) + print(line, flush=True) + if args.out: + with open(args.out, 'a') as out: + out.write(line + '\n') + + +if __name__ == '__main__': + main() |
