aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/075-http-host-against-axum.md
blob: 308363ecb3297f11e8e0ead805d1c64a37c9d8b5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# 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`](../../prototypes/connections/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단계(코어마다 소유 스레드)다.