diff options
| author | eunhhu <[email protected]> | 2026-09-27 19:11:45 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-27 19:11:45 +0100 |
| commit | 313c4fa1720a0019c87083d3523b6643606c5aa6 (patch) | |
| tree | 1b3759ec5b56be6278e08485c21f6b6aee1afbe8 /docs/research | |
| parent | 8019260926dcebe4f4be99cb5f92f84fbc9cd6d3 (diff) | |
| download | pure-313c4fa1720a0019c87083d3523b6643606c5aa6.tar.gz pure-313c4fa1720a0019c87083d3523b6643606c5aa6.zip | |
Record the small reads and idle memory work, next to axum and drogon (087)
What the app model, snapshot reads and delivery had cost small reads
(126.7k -> 116.1k GETs a second) and idle memory (2.0 -> 4.5 MiB), how
the sampler found it, and the four steps and the crypto split that took
it back: 139.9k and 2.3 MiB. Measured again today beside axum 0.8.9 and
drogon 1.9.0 on the same Pi: a one-thread pure-serve answers small GETs
at 139k/s (2.2x axum and drogon a CPU second), 1 MiB GETs at 2,362/s
(axum 1,304, drogon 754), durable app writes at 21.3k/s (both about
200), with the least idle memory of the three.
Diffstat (limited to 'docs/research')
| -rw-r--r-- | docs/research/087-small-reads-and-idle-memory.md | 120 |
1 files changed, 120 insertions, 0 deletions
diff --git a/docs/research/087-small-reads-and-idle-memory.md b/docs/research/087-small-reads-and-idle-memory.md new file mode 100644 index 0000000..4cb861c --- /dev/null +++ b/docs/research/087-small-reads-and-idle-memory.md @@ -0,0 +1,120 @@ +# 087 — 작은 읽기와 유휴 메모리: 기능이 쌓이며 늘어난 비용을 되돌리기 + +2026-09-27. 봉인한 전달([086](086-sealed-sends.md))이 요청 경로에 비용을 더하지 않는지 A/B로 재다가, 두 빌드의 절대값이 모두 +기록(2026-09-26, `156e1ad`의 [011 결과](../../research/experiments/011-http-frameworks/2026-09-26-pi5-sd.jsonl))보다 +낮은 것을 보고 추적한 것. + +착수 기준: + +- 바꿀 결정: 앱 모델, 스냅숏 읽기, 전달이 붙는 동안 작은 GET이 느려지고 유휴 메모리가 는 것을 그 기능들의 값으로 받아들일지, + 원인을 찾아 되돌릴지. +- 최소 확인: 같은 기기에서 옛 빌드와 번갈아 재서 차이가 잡음 밖인지 본다. 원인은 프로파일로 가린다. 고친 것마다 따로 잰다. +- 중단 기준: 고친 것이 다른 작업(1 MiB 읽기, 내구 쓰기, 앱 읽기)을 잡음 밖으로 나쁘게 하면 되돌린다. + +## 차이 + +64 B 문서 GET, 64연결 keep-alive, 한 스레드(`benchmarks/http/run.py`, 서버 CPU 0–2, 부하 CPU 3): + +| | GET/s | 유휴 RSS | 최대 RSS | +|---|---:|---:|---:| +| `156e1ad` (09-26) | 126.7k | 2,040 KiB | 2,560 KiB | +| `04a8510` (086) | 116.1k | 4,488 KiB | 5,136 KiB | + +한 커밋의 일이 아니었다. 중간 빌드들은 126.7k → 122.4k(`647d4b7`) → 120.2k(`8026654`) → 118.4k(`8398a1e`) → 116.5k로, +기능이 붙을 때마다 조금씩 줄었다. 이런 것은 이분 탐색보다 지금 경로를 프로파일해서 줄이는 편이 맞다. + +## 도구 + +이 기기에는 perf가 없어서 [sample.c](../../benchmarks/sample.c)를 만들었다. 명령을 실행해 모든 스레드의 사용자 시간을 50 µs마다 +표본으로 뜬다(`perf_event_open`의 task clock). 상속되는 스레드별 이벤트에는 커널이 링을 매핑해 주지 않아서 CPU마다 이벤트를 +하나씩 연다. 표본은 매핑된 파일의 ELF 심볼 표로 이름을 붙인다. 심볼을 뺀 libc는 같은 빌드의 디버그 파일을 build id로 찾는다 +(Raspberry Pi 저장소의 `libc6-dbg`를 설치하지 않고 풀었다). arm64에서는 링크 레지스터도 받는다. 그래서 공유 라이브러리의 말단 +함수(memcpy, strcmp)에 쓴 시간을 부른 함수별로 다시 볼 수 있다. + +같은 부하 5초 동안 전은 사용자 1.34 s·시스템 3.65 s, 뒤는 1.77 s·3.22 s였다. 요청당 사용자 시간이 2.2 µs에서 3.2 µs로 +늘었고, 그 1 µs가 차이였다. 나눠 보면 이렇다. + +- **한 버스트에 같은 키가 두 번 들지 않게 하는 검사**가 두 층에 있었고 둘 다 선형이었다. 앱은 대기 중인 키를 strcmp로 훑었고, + 호스트는 요청마다 앞선 요청들을 같은 방식으로 훑었다. 읽기도 이 검사를 거친다. 64연결이면 요청마다 약 31번, 두 번씩이다. + `execute`와 `message`, 그 안의 strcmp를 합쳐 사용자 시간의 약 10%였다. +- **시계**: 연결의 기한을 읽기와 쓰기마다 `clock_gettime`으로 새로 셌고, 이벤트마다 벽시계를 다시 읽었다. 요청마다 4–5번, + 5.7%였다. +- **응답 머리의 printf**: `snprintf` 세 번의 형식 해석이 사용자 시간의 약 16%였다(`__printf_buffer`, `strchrnul`, + `_itoa_word`). 이것은 libc에 이름이 붙기 전에는 이름 없는 libc 시간 28%에 묻혀 있었다. +- **답한 뒤의 recv**: 연결은 답한 뒤 다음 요청을 읽으러 recv를 불렀고 거의 매번 EAGAIN을 받았다. 요청마다 실패하는 시스템 + 호출이 하나였다. strace로는 보이지 않는다. strace가 서버를 느리게 해서 다음 요청이 이미 와 있기 때문이다. + +## 고친 것 + +| | 64 B GET/s | p50 | p99 | +|---|---:|---:|---:| +| `156e1ad` (09-26) | 127.0k | 480 µs | 576 µs | +| 전 (`04a8510`) | 116.1k | 512 µs | 672 µs | +| + 버스트의 키를 한 번에 찾기 (`93faf3a`) | 121.8k | 496 µs | 640 µs | +| + 시계를 한 라운드에 한 번 (`fbbf6f6`) | 124.1k | 496 µs | 640 µs | +| + printf 없는 응답 머리 (`8f82c27`) | 131.9k | 464 µs | 576 µs | +| + 짧게 읽으면 멈추기 (`242343e`) | **139.9k** | **448 µs** | **512 µs** | + +여섯 빌드를 라운드마다 순서를 돌려 다섯 라운드씩 잰 중앙값이다. + +- **키 색인.** 앱이 대기 중인 문서를 키로 색인한다(FNV-1a 해시의 열린 주소법, 1 KiB). `pure_app_pending`이 한 번에 답하고 + 호스트는 그것을 묻는다. 현재 이벤트의 문서가 대기 중인지는 포인터로 훑는 대신 플래그로 안다. 버스트는 전과 같은 곳에서 + 나뉜다. 다만 읽기를 먼저 적용하는 소유자에서 같은 라운드에 읽은 키를 고치면, 앱이 혼자 저장하던 곳에서 이제 호스트가 버스트를 + 나눈다. 단위 검사가 200개 키에서 색인을 확인한다. +- **시계.** 연결 스레드는 `epoll_wait`에서 돌아올 때마다 단조 시계를 한 번 읽고, 기한은 그것에서 센다(기본 10초, 1초마다 + 점검한다). 한 라운드의 이벤트는 라운드가 시작한 시각을 함께 쓴다. +- **응답 머리.** 부분마다 제자리에 쓴다. 바이트는 전과 같다. 버퍼 끝에서 잘리므로, `snprintf`가 돌려주던 "썼을 길이"로 버퍼 + 밖을 보낼 수 있던 틈도 없어졌다. +- **짧은 읽기.** 요청한 것보다 적게 읽었으면 소켓이 빈 것이다. 엣지 트리거 epoll이 다음 바이트가 오면 알려 준다(epoll(7), + nginx도 이렇게 한다). 그래서 EAGAIN을 받으러 가지 않는다. 상대가 제 쪽을 닫았으면 전처럼 끝까지 읽는다. + +**다른 작업.** 마지막 빌드와 전을 번갈아 세 라운드씩 쟀다. 앱의 합계 GET은 111.5k → 125.4k/s, 20행 페이지는 86.2k → 94.3k, +쓰기와 섞인 읽기는 109.1k → 121.7k다. 1 MiB 읽기는 같다. 단계마다 여섯 라운드씩 잰 중앙값이 2,668–2,716/s이고 라운드 범위가 +±15%다. 내구 쓰기도 같다. 점수 8연결은 3,974 → 4,035/s, 섞인 쓰기는 2,955 → 3,115/s다. 연결마다 닫는 GET도 23.2k → 23.5k로 +같다. + +## 유휴 메모리 + +유휴 RSS의 절반은 OpenSSL이었다. 아무 일도 하지 않는 프로그램에 `-lssl -lcrypto`를 링크만 해도 RSS가 0.98 → 3.28 MiB가 되고, +그 프로세스만의 더티 페이지가 656 KiB 는다(라이브러리의 재배치한 데이터). 그런데 대부분의 서버는 https 수신자에 닿지도 않고 +봉인하지도 않는다. + +OpenSSL을 쓰는 코드(TLS 클라이언트, 봉인)를 `pure-crypto.so`로 따로 빌드했다(`serve/crypto-openssl.c`, 함수 표 하나를 +내보낸다). pure-serve는 https URL의 전달선을 열거나, 키를 읽거나, 봉인을 연결할 때 처음 실행 파일 옆의 그것을 연다 +(`serve/crypto.c`). 없으면 시작할 때 알린다. OpenSSL이 필요 없는 것(키의 hex, 이 CPU가 봉인할 스위트)은 pure-serve에 남았다. + +| 유휴 pure-serve | RSS | PSS | 자기만의 더티 | +|---|---:|---:|---:| +| OpenSSL을 링크(전) | 4,496 kB | 1,639 kB | 1,296 kB | +| OpenSSL을 정적으로 링크 | 3,840 kB | 2,292 kB | 2,272 kB | +| 쓸 때만 매핑(지금) | **2,320 kB** | **881 kB** | **864 kB** | + +정적 링크는 RSS만 조금 줄인다. 자기만의 메모리는 오히려 두 배가 된다. OpenSSL의 표를 모두 재배치하기 때문이다(바이너리는 +7.9 MB). 봉인이나 TLS를 쓰는 서버는 전처럼 OpenSSL을 매핑한다. + +## axum·drogon 옆에서 + +같은 기기에서 `a8d6b1b`를 axum 0.8.9, drogon 1.9.0과 다시 쟀다(세 라운드, 서버 순서를 돌린다; [읽기](../../research/experiments/011-http-frameworks/2026-09-27-pi5-sd-reads.jsonl), +[앱](../../research/experiments/011-http-frameworks/2026-09-27-pi5-sd-app.jsonl)). pure-serve는 한 스레드, `-2`·`-3`은 연결 +스레드 수다. axum과 drogon은 코어 셋을 모두 쓴다. + +| | pure-serve | pure-serve-2/3 | axum | drogon | +|---|---:|---:|---:|---:| +| 64 B GET, keep-alive (/s, CPU 1초당) | 139.3k, 139.7k | 150.5k, 99.2k | 127.0k, 62.6k | 146.7k, 62.6k | +| 64 B GET, 연결마다 닫기 | 23.2k | 23.3k | 22.2k | 21.2k | +| 1 MiB GET | **2,362** | 1,851 | 1,304 | 754 | +| 앱 합계 GET | 125.7k | **153.2k** | 136.0k | 150.5k | +| 앱 20행 페이지 | 93.5k | **147.6k** | 92.9k | 138.4k | +| 내구 쓰기 64연결 | **21.3k** | 21.9k | 203 | 199 | +| 내구 쓰기와 섞인 읽기 | 119.3k | 144.6k | 1,363 | 1,125 | +| 유휴 RSS | **2,320 KiB** | 2,752–3,152 KiB | 2,768 KiB | 10,912 KiB | +| 최대 RSS(문서, 앱) | 5,760, 3,152 KiB | 7,440, 3,824 KiB | 9,696, 6,608 KiB | 78,816, 11,920 KiB | + +axum과 drogon의 앱 읽기는 메모리 판(내구 쓰기 없음)의 값이다. 한 스레드의 pure-serve가 CPU 1초당 axum과 drogon의 2.2배를 +답하고, 유휴 메모리는 셋 중 가장 작다. + +## 다음 + +- 이제 CPU의 72%가 커널이다(루프백 TCP, 요청마다 recv와 writev). 연결마다 요청이 하나씩이라 응답을 모아 쓸 여지가 작다. +- 사용자 시간의 나머지: 저장소 조회(카탈로그와 문서 트리) 약 23%, 요청 머리 해석 약 15%, 요청마다의 할당(드래프트, 익스텐트 + 배열) 약 8%. |
