From 62f4c51920129216ea779118c341379b5e9f4d7c Mon Sep 17 00:00:00 2001 From: eunhhu Date: Sat, 26 Sep 2026 17:39:03 +0100 Subject: Record the app service against axum and drogon (077) The scoreboard, 22 lines of aup served by its views and inputs, against the same service written by hand in axum and drogon on the Pi: durable posts about 76 times as many, reads level with drogon and ahead of axum with the least CPU and memory. The note keeps what the profile showed on the way (the kernel's share of a single thread, integers through printf, an owner that saved too early) and what comes next. run.py gains pure-serve-2 and waits for a server that prints its port before it listens. --- docs/research/077-apps-as-http-services.md | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/research/077-apps-as-http-services.md (limited to 'docs/research/077-apps-as-http-services.md') diff --git a/docs/research/077-apps-as-http-services.md b/docs/research/077-apps-as-http-services.md new file mode 100644 index 0000000..27e69fd --- /dev/null +++ b/docs/research/077-apps-as-http-services.md @@ -0,0 +1,93 @@ +# 077 — 앱이 곧 HTTP 서비스: 보기와 입력이 라우트이고, 상태의 소유자는 하나다 + +2026-09-26. `pure-serve`가 aup 앱의 보기와 입력을 HTTP로 내놓는다. 상태를 가진 앱을 여러 연결 스레드로 돌린다. + +착수 기준: + +- 바꿀 결정: Pure 앱을 백엔드로 쓰려면 라우터, 핸들러, 직렬화, 저장 코드를 따로 써야 하는지, 앱 명세 하나가 + 그대로 HTTP 서비스가 되는지. 상태를 가진 앱은 스레드 하나에 머무는지, 여러 코어를 쓰는지. +- 남은 질문: 같은 서비스를 axum·drogon으로 손수 쓴 것과 같은 기기에서 비교해, 내구성 있는 쓰기와 읽기와 메모리에서 + 어디에 서는가? 상태의 소유자를 하나로 두고도 읽기가 코어를 따라 늘어나는가? +- 최소 확인: 무작위 요청을 파이썬 모델과 대조하고(한 스레드와 소유자 모드 모두), 확인 응답한 이벤트가 `kill -9` 뒤에도 + 남는지 보고, 3회전 벤치마크의 중앙값을 기준선과 나란히 둔다. +- 중단 기준: 모델과 한 번이라도 다르거나, 확인 응답한 이벤트를 하나라도 잃거나, 읽기가 axum보다 느리면 다시 본다. + +## 의심한 관례 + +| 관례 | Pure | +|---|---| +| API는 라우터에 핸들러를 등록해 만든다 | 보기가 GET, 입력이 POST다. 쓸 라우트가 없다 | +| 응답은 핸들러가 직렬화한다 | 수·text 보기는 그대로, 표는 JSON 행. 요청한 쪽의 칸만 계산한다(`?from=&count=`) | +| 쓰기의 내구성은 앱이 데이터베이스로 얻는다 | 모든 이벤트가 트랜잭션이고, 답은 저장 뒤에 나간다. 요청 여럿이 저장 한 번을 나눈다 | +| 상태를 여러 스레드가 공유하려면 잠금이 필요하다 | 상태의 소유자는 스레드 하나다. 연결 스레드는 요청을 넘기고 답을 받아 쓴다. 앱에는 잠금이 없다 | + +## 점수판 + +같은 계약을 세 가지로 구현했다. `POST /inputs/score?points=N`(본문은 이름)은 그 이름의 점수를 올리고 이름을 답한다. +`GET /views/total`은 합계, `GET /views/leaders?from=0&count=20`은 이름순 20행을 JSON으로 답한다. + +- aup([scores.aup](../../benchmarks/http/scores.aup)): 22줄. 키 있는 리스트 하나, 메시지 입력 하나, 보기 둘. +- axum·drogon: 약 100줄씩. 맵과 잠금, 추가 전용 로그(요청마다 `fdatasync`하고, 시작할 때 재생한다), JSON 직렬화, 쿼리 해석. + +Raspberry Pi 5, 서버는 CPU 0–2, 부하는 CPU 3, 저장소는 SD 카드의 ext4. 3회전 중앙값(4초씩, 회전마다 서버 순서를 돌리고 +새 저장소를 쓴다). `pure-serve-N`은 연결 스레드 N개와 소유자 스레드 하나다. + +| 작업 | pure-serve | pure-serve-2 | pure-serve-3 | axum 메모리 | axum 내구 | drogon 메모리 | drogon 내구 | +|---|---:|---:|---:|---:|---:|---:|---:| +| 점수 POST, 64연결 | 12,896 | 9,437 | **13,692** | 129,122 | 180 | 146,112 | 172 | +| 점수 POST, 8연결 | 1,452 | 1,952 | **2,132** | 135,152 | 188 | 138,398 | 160 | +| 합계 GET, 64연결 | 115,354 | **148,544** | 147,354 | 136,114 | 136,659 | 149,140 | 147,900 | +| 20행 GET, 64연결 | 87,312 | **144,844** | 140,672 | 94,370 | 95,186 | 144,471 | 144,336 | + +초당 2xx. 서버 CPU 1초당 답(2xx/cpu-s): 합계 GET은 pure-serve-2 75,944, axum 68,690, drogon 68,616. 20행 GET은 +64,591, 51,802, 53,562. 최대 RSS: pure-serve-2 3.4 MiB, axum 6.2–6.4 MiB, drogon 11.6 MiB. + +- **내구성 있는 쓰기는 약 76배다.** 손으로 쓴 서비스는 요청마다 동기화한다. 그것이 이런 서비스를 쓰는 보통의 방법이다. + Pure는 준비된 요청을 묶어 저장 한 번에 담는다. 앱이 그룹 커밋을 설계할 필요가 없다. 메모리 모드의 axum·drogon이 + 빠른 것은 답한 쓰기를 잃을 수 있어서다. Pure에는 그런 모드가 없다. +- **읽기는 drogon과 같고 axum보다 빠르다.** 64연결 합계 GET은 부하 생성기(코어 하나)의 한계 근처라 서버 CPU당 답이 서버를 + 가른다. 두 읽기 모두 Pure가 CPU를 가장 적게 쓴다(20행 GET에서 22–25% 적다). +- **메모리는 가장 적다.** 소유자 스레드를 더해도 최대 3.4–4.2 MiB다. + +## 찾은 것 + +- **한 스레드는 시간 대부분을 커널에서 쓴다.** 20행 GET 하나에 시스템 호출은 `writev` 한 번과 `recv` 한 번이고(묶음마다 + `epoll_wait`와 `fstat` 한 번), 사용자 공간은 CPU의 약 20%였다. 상태를 가진 앱이 스레드 하나에 머물면 이 비용을 한 코어가 + 다 진다. 그래서 연결 스레드가 받기·해석·쓰기를 나누고 소유자는 상태만 만지게 했다. 20행 GET이 87k에서 145k/s가 됐다. +- **정수를 printf로 만들고 있었다.** 소유자가 20행 요청 하나에 8.8 µs를 썼다. 칸 계산은 40칸에 2 µs(칸당 50 ns)였다. 나머지 + 대부분은 `snprintf("%.0f")`였다. 정수를 직접 자릿수로 만들자(21 ns, 무작위 정수 262만 개에서 printf와 같다) 5.5 µs가 + 됐다. 열 이름 조각을 요청마다 한 번 만들고 버퍼를 한 번에 잡자 4.4 µs가 됐다. 문자열 보간(`"{n}"`)도 같은 함수를 쓰므로 + 모든 앱이 함께 빨라졌다. +- **소유자는 너무 일찍 저장을 시작했다.** 소유자 모드의 내구성 POST가 한 스레드보다 30% 느렸다. 저장 한 번에 담긴 이벤트가 + 50개에서 30개로 줄어 있었다. 한 스레드는 답을 쓰는 동안 클라이언트가 다음 요청을 보내므로 다음 묶음에 그것이 + 들어간다. 소유자는 연결 스레드 하나의 요청이 도착하자마자 다음 묶음을 시작했다. 답한 스레드가 한 바퀴 돌 때까지 + 기다려 봤지만 효과가 없었다(30 → 32개). 클라이언트가 답을 받고 다음 요청을 보내는 시간은 소유자가 볼 수 없기 때문이다. + 저장한 묶음 뒤에는 새 요청이 100 µs 동안 끊길 때까지(최대 1 ms) 모으게 했다. 그러자 50개가 되었고 처리량은 한 스레드와 + 같거나 높아졌다. 요청이 기다리는 동기화 횟수가 줄어 p50은 5.4 ms에서 3.5 ms로 오히려 짧아졌다. 읽기만 한 묶음 뒤에는 + 모으지 않으므로 읽기는 그대로다. + +## 검증 + +aup `make check`: 키 있는 리스트 앱에 무작위 요청 800개(쿼리 필드와 본문을 가진 메시지, text, 키 누름, 수·text 보기, 표의 +쪽, 없는 이름과 잘못된 쿼리)를 보내 상태 코드·오류 이름·본문·JSON 행이 파이썬 모델과 같다. 한 스레드와 연결 스레드 셋 + +소유자에서 모두 확인한다. 여섯 클라이언트가 보내는 중에 `kill -9`로 죽여도, 확인 응답한 이벤트(각 3,500–4,200개)가 다시 +시작한 뒤 모두 남는다. ASan·UBSan 빌드에서 같은 검사와, 클라이언트 열둘이 쓰기·읽기·실패를 섞어 보내는 부하(요청 3,600개)를 +통과했다. 이 기기(VMA 47비트)에서는 ThreadSanitizer가 돌지 않는다. 대신 스레드를 건너는 것을 하나씩 확인했다. + +- 소유자가 읽는 요청의 키와 입력: 연결 스레드의 런타임 코어가 완료까지 작업 슬롯을 붙잡는다(연결이 먼저 닫혀도). +- 돌려주는 결과: 잠금 아래 넘긴다. +- 빌려준 저장소 매핑: 참조 수가 원자적이다. +- 보기 결과 버퍼: 스레드를 건너 해제된다(malloc). + +문서 서버 경로는 그대로다. 같은 날 한 회전 결과: 64 B GET 148.5k/s(pure-serve-3), 1 MiB GET 2,786/s, 내구성 PUT 2,088/s. +[075](075-http-host-against-axum.md)와 같다. + +## 다음 결정 + +- **저장과 적용을 겹치기.** 소유자는 저장(동기화)하는 동안 다음 요청을 적용하지 못한다. 저장을 다른 스레드에 넘기고, 다음 + 묶음을 적용하는 동안 앞 묶음의 동기화가 끝나면 답하게 하면 동기화 지연이 처리량에서 빠진다. 트리 저장소의 "쓰기와 + 동기화" 분리가 먼저 필요하다. +- **읽기를 소유자 밖에서.** 보기는 상태를 바꾸지 않는다. 저장된 버전을 연결 스레드가 직접 읽게 하면(버전 있는 상태) 소유자가 + 동기화하는 동안에도 읽기가 기다리지 않는다. +- **메시지의 여러 text 필드와 JSON 본문.** 지금 메시지는 u32 필드와 bytes 꼬리 하나다. HTTP API에서는 필드 여럿을 JSON으로 + 받는 편이 자연스럽다. -- cgit v1.2.3