# 075 — HTTP 호스트를 axum과 같은 조건에서 잰다 2026-09-26. 하네스 [`benchmarks/http`](../../benchmarks/http): C 부하 생성기(`load.c`, epoll, 연결 N개, keep-alive 선택), 같은 문서 계약을 axum 0.8로 구현한 기준 서비스(`axum-baseline`), 실행기(`run.py`). 착수 기준: - 바꿀 결정: Pure의 HTTP 호스트(`server.c`)를 문서 계약 검사용 시제품으로 둘지, 일반적인 고성능 백엔드 (axum, drogon) 이상을 목표로 다시 짤지. - 남은 질문: 지금 어디서 지고 어디서 이기는가? 그 차이는 설계의 한계인가, 구현의 공백인가? - 최소 확인: 같은 기기, 같은 코어 배정, 같은 부하 생성기, 같은 계약으로 잰다. 저장 보장 쓰기는 충돌에 안전한 기준(임시 파일·fsync·rename·디렉터리 fsync)과 안전하지 않은 기준을 둘 다 둔다. ## 조건 Raspberry Pi 5(Cortex-A76 4코어, 8 GB), Linux 6.18 AArch64, 저장소는 SD 카드의 ext4. 서버는 CPU 0–2, 부하 생성기는 CPU 3에 고정했다(`taskset`). 문서 64개(64 B)와 1 MiB 문서 하나. 각 5초. Pure의 입력 예산은 64 MiB(기본 4 MiB에서는 1 MiB 읽기 8개가 예산에 막혀 503을 낸다). axum은 tokio 다중 스레드 기본값. PUT은 연결마다 자기 키 앞에 16 B를 넣고 받은 ETag로 다음 `If-Match`를 보낸다. ## 결과 (성공 응답/초, 지연은 µs) | 작업 | Pure 프로그램 서버 | Pure 모듈 서버 | axum 메모리 | axum fdatasync | axum 충돌 안전 | |---|---:|---:|---:|---:|---:| | GET 64 B, 연결 64, 매번 새 연결 | 23,953 (p99 800) | 23,852 (p99 896) | 22,514 (p99 4,096) | 21,994 | 22,184 | | GET 64 B, 연결 64, keep-alive | 23,839 | 23,892 | **117,857** (p99 1,024) | 126,643 | 125,429 | | GET 1 MiB, 연결 8, keep-alive | 1,113 | 1,197 | 1,311 | 1,345 | 1,322 | | PUT 16 B 저장 보장, 연결 64 | 50 (연결 오류 49) | 191 | (51,323, 저장 안 함) | 429 | 296 | | PUT 16 B 저장 보장, 연결 8 | 52 | **171** | (23,512, 저장 안 함) | 74 | 54 | 최대 RSS: Pure 모듈 서버 10.9 MiB, Pure 프로그램 서버 13.4 MiB, axum 15.4–18.3 MiB. 유휴 RSS: Pure 1.7 MiB, axum 2.7 MiB. ## 해석 - **메모리는 이긴다**(최대 RSS 약 40% 적음). 새 연결마다 요청 하나일 때도 이긴다(p99가 4–5배 짧다). - **keep-alive에서 5배 진다.** Pure 호스트는 응답마다 연결을 닫는다. 요청마다 TCP 연결·accept·poll 등록이 반복되어 약 24k/s가 한계다. 게다가 스레드 하나만 쓴다(axum은 3코어). - **동시 저장 쓰기에서 진다.** 연결 슬롯 8개와 listen backlog 8 때문에 64개 연결을 받지 못하고(프로그램 서버는 연결 오류), 묶음 커밋도 한 번에 8개까지다. 커밋 한 번에 fsync가 두 번이다(데이터, 그다음 head). axum은 64개의 fsync를 병렬로 내고 ext4 저널이 그것들을 합친다. 연결 8개에서는 묶음 커밋 덕에 충돌 안전 axum의 3.2배다. - 1 MiB 읽기는 비슷하다(10% 차이). 모듈 서버는 GET 응답을 복사하고, 프로그램 서버는 빌려준다. 모두 설계 방향(소유 스레드, 고정 슬롯, 무복사 전송, 묶음 커밋)의 한계가 아니라 호스트 구현의 공백이다. ## 목표와 순서 (같은 하네스, 같은 기기) 1. HTTP/1.1 지속 연결과 파이프라이닝, epoll, 연결 슬롯 수백 개. 목표: keep-alive GET에서 코어 하나로 axum(3코어)의 절반 이상. 2. 코어마다 소유 스레드 하나(`SO_REUSEPORT` 샤드). 목표: keep-alive GET ≥ axum, p99 ≤ axum. 3. 커밋당 fsync 한 번(head가 새 노드의 체크섬을 담아 복구가 검증), 큰 묶음, fsync와 다음 묶음 적용의 겹침. 목표: 연결 64개의 충돌 안전 PUT ≥ 안전하지 않은 axum(429/s). 4. 모듈 서버의 무복사 GET. 중단 기준: 어떤 단계든 메모리가 axum보다 커지거나, 저장 보장(확인 응답 = 저장됨, `kill -9` 검사)이 깨지면 그 방식은 버린다. ## 1단계 결과: `pure-serve` (소유 스레드 하나) 새 호스트 [`serve.c`](../../serve/serve.c): 지속 연결·파이프라이닝, edge-triggered epoll, 연결 최대 1,024(받을 때 할당), `writev` 응답, 같은 런타임 코어와 묶음 커밋. 같은 조건에서 다시 쟀다. `strace -c`로 보니 GET 하나마다 저장소 draft를 여닫느라 시스템 호출이 9번(파일·디렉터리 열기, `fstat` 두 번, 전체 `mmap`/`munmap`, head 읽기)이었다. 저장소 핸들(`pure_store_open`)이 매핑을 공유하고 열 때 파일이 바뀌었는지만 본다(2번). | 작업 | 이전 Pure | pure-serve, 핸들 전 | **pure-serve** | axum 메모리(3코어) | axum 충돌 안전 | |---|---:|---:|---:|---:|---:| | GET 64 B, keep-alive | 23,892 | 57,971 | **106,447** (p99 736) | 126,160 (p99 768) | 126,865 | | GET 64 B, 매번 새 연결 | 23,852 | 22,912 | 23,315 | 21,947 | 22,703 | | GET 1 MiB, keep-alive | 1,197 | 1,537 | **1,741** | 1,370 | 1,354 | | PUT 저장 보장, 연결 64 | 191 | 1,081 | **1,126** | – | 372 | | PUT 저장 보장, 연결 8 | 171 | 333 | **336** | – | 63 | 최대 RSS: pure-serve 7.3 MiB, axum 17.4–19.6 MiB. - 코어 하나로 axum(코어 3개)의 84%(작은 keep-alive GET), p99는 같다. 큰 읽기는 27% 빠르다. - 저장 보장 쓰기는 충돌 안전 axum의 3.0–5.3배, 안전하지 않은 axum(429/s)보다도 빠르다. - 남은 차이는 코어 수다. 다음은 2단계(코어마다 소유 스레드)다. ## 2단계 결과: 묶음 스냅숏과 코어별 샤드 - **묶음 스냅숏.** 저장소가 바뀌었는지 요청마다 보지 않고 묶음 시작에 한 번 본다(`pure_store_pin`). 다른 샤드의 커밋은 그 응답이 나가기 전에 끝나므로, 응답을 받은 뒤 보낸 요청은 언제나 그 커밋 뒤의 묶음에서 읽힌다. - **샤드.** `--threads N`이면 스레드마다 같은 포트의 리스너(`SO_REUSEPORT`), epoll, 런타임 코어, 앱, 저장소 핸들을 따로 둔다. 공유하는 것은 저장소뿐이다. 저장소의 쓰기 세션은 프로세스 안에서 뮤텍스로 줄을 선다(파일 잠금은 프로세스 단위라 스레드끼리 막지 못하고, 같은 파일의 아무 fd를 닫아도 풀린다). 같은 키의 동시 편집은 리비전이 가른다. 키 없는 상태를 가진 모듈은 한 스레드로만 돈다. | 작업 | pure-serve 1스레드 | pure-serve 3샤드 | axum 메모리(3코어) | axum 충돌 안전 | |---|---:|---:|---:|---:| | GET 64 B, keep-alive | **125,111** (p99 576) | **151,456** (p99 608) | 125,690 (p99 768) | 126,903 | | GET 1 MiB, keep-alive | **1,848** | 1,046 | 1,380 | 1,446 | | PUT 저장 보장, 연결 64 | **869** | 799 | – | 448 | | PUT 저장 보장, 연결 8 | **282** | 134 | – | 68 | 최대 RSS: 1스레드 6.2 MiB, 3샤드 9.8 MiB, axum 14.9–19.4 MiB. SD의 fsync 지연은 흔들림이 커서(p99 수백 ms) 쓰기 수치는 실행마다 20–30% 달라진다. - 작은 읽기는 스레드 하나로 axum(코어 3개)과 같고, 샤드 셋이면 1.2배다. p99는 어느 쪽이든 axum보다 짧다. - 샤드가 해가 되는 곳이 둘 있다. 저장 보장 쓰기는 샤드마다 따로 커밋해 fsync가 차례로 이어지고 묶음이 작아진다. 1 MiB 읽기는 응답마다 1 MiB를 복사해 샤드들이 메모리 대역폭을 다툰다. 그래서 기본은 한 스레드다. - 다음: 샤드들의 커밋을 fsync 한 번으로 합치는 저장소 수준의 그룹 커밋, 무복사 GET, 커밋당 fsync 한 번. ThreadSanitizer는 이 커널(47비트 가상 주소)을 지원하지 않아 쓰지 못했다. 스레드 동시 커밋은 ASan 빌드의 저장소 검사(스레드 넷이 각자와 공유 키에 동시 커밋)와 aup의 3샤드 서버 검사로 확인한다. ## 3단계: 샤드 간 그룹 커밋과 무복사 읽기 - **그룹 커밋.** 같은 때 커밋하는 스레드들은 한 커밋을 나눠 쓴다. 먼저 온 스레드가 리더가 되어 줄 선 요청을 한 head로 공개하고 나머지는 결과를 기다린다. 요청 단위의 원자성은 그대로다(낡은 draft가 있는 요청이나, 같은 그룹의 앞 요청이 바꾼 키를 옛 리비전으로 고치려는 요청은 빠진다). 여러 스레드가 함께 커밋해 왔다면 리더는 최근 그룹 크기만큼 모일 때까지 최대 2 ms 기다린다. 공개를 멈춘 사이 커밋 다섯 건이 정확히 공개 두 번이 되는지 검사한다. 세 샤드, 쓰는 연결 8개에서 묶음 1,234개가 공개 421번으로 나갔다(1스레드와 처리량이 같아졌다: 327/s 대 348/s). - **무복사 읽기.** 저장된 문서를 읽는 응답은 복사하지 않고 저장소 매핑의 익스텐트를 `writev`로 빌려준다. 매핑 참조(`pure_draft_mapping`)를 응답이 끝날 때까지 쥐므로, 보내는 도중 문서가 바뀌거나 파일이 압축으로 교체돼도 읽은 버전 그대로 나간다(aup 검사: 1 MiB를 느린 소켓으로 보내는 동안 다섯 번 바꾼다). | 작업 | pure-serve 1스레드 | pure-serve 3샤드 | axum 메모리 | axum 충돌 안전 | |---|---:|---:|---:|---:| | GET 64 B, keep-alive | **127,802** (p99 544) | **151,620** (p99 608) | 125,690 (p99 768) | 126,903 | | GET 1 MiB, keep-alive | **2,761** | 1,884 | 1,380 | 1,446 | 1 MiB 읽기는 axum의 약 2배(초당 약 2.9 GB)이고, 이 속도에서는 코어 하나로 받는 부하 생성기가 병목에 가깝다. **저장 보장 쓰기 측정의 함정.** 저장 장치는 SD 카드 하나뿐이고, 동기 쓰기가 몇 분 이어지면 내부 정리로 크게 느려진다 (같은 설정이 순서에 따라 연결 8개에서 350/s → 17/s, p99 4.4초). 앞의 표들의 쓰기 수치는 순서 영향을 받았다. 쓰기 비교는 설정 순서를 섞어 여러 번 돌리고, 매번 장치의 fsync 지연을 함께 재서 보고해야 한다. 하네스가 이제 그렇게 한다 (`--rounds`, 라운드마다 서버 순서 회전, 쓰기 직전 장치 탐침). ## 4단계: 커밋당 동기화 한 번 저장소 커밋을 동기화 한 번으로 줄였다([076](076-one-sync-commit.md)). 같은 하네스로 3초 × 3라운드의 중앙값: | PUT 16 B, keep-alive | pure-serve | pure-serve 3샤드 | axum 비안전 | axum 충돌 안전 | |---|---:|---:|---:|---:| | 연결 64 | **2,012** (p50 20.5 ms) | **2,611** (12.3 ms) | 1,040 (41.0 ms) | 583 (69.6 ms) | | 연결 8 | **601** (6.4 ms) | **637** (7.7 ms) | 260 (18.4 ms) | 166 (30.7 ms) | 충돌에 안전한 axum 대비 쓰기 처리량 3.4–4.5배, 봉우리 RSS 3.0–4.6 MiB 대 7.3–9.6 MiB다. 이제 읽기, 1 MiB 읽기, 저장 보장 쓰기 모두에서 axum보다 빠르고 메모리를 덜 쓴다. ## 5단계: drogon 추가, CPU 효율 drogon(C++) 1.9.0으로 같은 계약의 서비스를 만들었다(`benchmarks/http/drogon-baseline`, Debian 패키지를 설치하지 않고 풀어서 빌드). 메모리·fdatasync·충돌 안전 세 모드, IO 스레드 3개다. 처음 잰 값(65k/s)은 잘못이었다. drogon은 IO 루프마다 `SO_REUSEPORT` 리스너를 따로 여는데, 포트 0을 주면 루프마다 다른 포트가 잡혀 부하가 루프 하나에만 갔다. 빈 포트를 먼저 골라 쓰게 고쳤다. 부하 생성기는 코어 하나라 초당 약 15만 건 근처에서 먼저 한계에 닿는다. 그래서 하네스가 서버 CPU 시간(모든 스레드, user+system)당 성공 응답 수(`2xx/cpu-s`)도 기록한다. 아래 표는 서버 순서를 회전한 3라운드(3초씩)의 중앙값이다 ([원시 결과](../../research/experiments/011-http-frameworks/2026-09-26-pi5-sd.jsonl)). 괄호 안은 p99(µs)다. | 작업 | pure-serve | pure-serve 3샤드 | axum | drogon | |---|---:|---:|---:|---:| | GET 64 B keep-alive, 성공/초 | 127,764 (576) | **151,608** (608) | 120,942 (1,152) | 145,269 (736) | | 같은 작업, 성공/CPU-초 | **128,191** | 99,961 | 61,754 | 65,338 | | GET 64 B 연결마다 닫기, 성공/초 | **23,219** | 22,619 | 21,208 | 20,910 | | 같은 작업, 성공/CPU-초 | **41,629** | 36,482 | 18,462 | 18,450 | | GET 1 MiB keep-alive, 성공/초 | **2,794** | 1,751 | 1,372 | 708 | | PUT 충돌 안전, 연결 64, 성공/초 | 2,155 | **2,450** | 421 | 83 | | PUT 충돌 안전, 연결 8, 성공/초 | 548 | **551** | 105 | 41 | | 봉우리 RSS | **5.0 MiB** | 6.1 MiB | 14.4 MiB | 76.5 MiB | axum과 drogon의 GET·RSS는 메모리 모드, PUT은 충돌 안전 모드(임시 파일, fsync, rename, 디렉터리 fsync)다. Pure는 모든 쓰기를 트리 저장소에 커밋한다. 참고로 충돌에 안전하지 않은 fdatasync 모드는 axum 433·101, drogon 146·87이었다. 메모리 모드의 PUT은 axum 58,360·33,767, drogon 60,482·37,802로 내구성이 없어 비교 대상이 아니다. - 작은 GET 처리량은 3샤드가 가장 높다. CPU 효율은 1스레드가 axum·drogon의 약 2배다(부하 생성기 한계 근처에서 3샤드는 스레드끼리 부하를 나누느라 효율이 낮아진다). - 1 MiB GET은 axum의 2배, drogon의 3.9배다. drogon은 본문을 `std::string`으로 여러 번 복사한다. - 충돌 안전 쓰기는 axum의 5.1–5.8배, drogon의 13–30배다. drogon 판은 가장 단순한 핸들러라 동기화하는 동안 이벤트 루프를 막는다(axum 판은 `spawn_blocking`으로 넘긴다). 동기화를 별도 스레드 풀로 옮기면 drogon 값은 나아질 수 있다. 그래도 요청마다 동기화하는 한 axum 판과 같은 한계에 닿는다. - 메모리는 axum의 약 1/3, drogon의 약 1/15다.