From c97e4e1a203d13852d5f7cd4a404f88b44f4064f Mon Sep 17 00:00:00 2001 From: eunhhu Date: Tue, 29 Sep 2026 06:24:43 +0100 Subject: Measure the CPU a record costs a link, against quinn (096) M5 of design 011. At first a link spent eight times quinn's CPU on a small record: scans of the backlog, a system call and allocations a record, 19 bytes of framing. After the seven changes before this one, the transport alone (transport.c; a backlog drained, every record checked) spends per record 0.30 us for 61 bytes (quinn 0.27: 1.06 to 1.12 times over three campaigns, at the design's 10 % line), 2.91 us for 1,013 bytes (quinn 3.88) and 45.2 us for 16,013 bytes (quinn 60.9): 25 % less for the larger ones, and 30 % more records a second. What is left for small records is per packet: the stream's names and the RECORDS head. quinn's numbers carry no confirmation of storing and no exactly once. Between two pure-serve nodes the links spend more per record, woken at the receiving app's pace. Filling the sender showed the outbox is written whole at every save, as the numbers heard were (094): the next thing to change. run.py gains --transport and --drain; the quinn service reads a drain a chunk at a time and its client writes 64 KiB at a time. --- docs/research/096-per-delivery-cpu.md | 122 ++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/research/096-per-delivery-cpu.md (limited to 'docs/research/096-per-delivery-cpu.md') diff --git a/docs/research/096-per-delivery-cpu.md b/docs/research/096-per-delivery-cpu.md new file mode 100644 index 0000000..3a0fecb --- /dev/null +++ b/docs/research/096-per-delivery-cpu.md @@ -0,0 +1,122 @@ +# 096 — 전달당 CPU: 작은 기록에서 QUIC만큼, 큰 기록에서 QUIC보다 적게 + +2026-09-28–29. [설계 011](../design/011-pure-transport.md)의 M5다. [094](094-idle-links.md)와 [095](095-restart-and-first-contact.md)가 +연결이 없어서 얻는 것을 봤다면, 여기서는 활성 링크 하나가 기록 하나에 쓰는 CPU를 QUIC과 댄다. + +착수 기준: + +- **바꿀 결정.** 011의 H4와 가드레일이다. 활성 링크의 전달당 CPU가 QUIC보다 10 % 넘게 나쁘면, 고친 뒤에야 채택한다. +- **최소 확인.** 한 링크에 쌓인 보내기를 푼다. 기록은 64 B, 1 KiB, 16 KiB짜리다. 기록 하나에 보내는 쪽과 받는 쪽이 쓰는 CPU를 + 잰다. quinn은 한 연결의 한 스트림으로 같은 크기의 메시지를 보낸다. 메시지마다 u32 길이를 앞에 붙인다. +- **중단 기준.** 위의 10 %다. + +## 쉬운 말로 + +- 알아낸 것: + - 처음 쟀을 때 Pure 링크는 작은 기록 하나에 quinn의 8배를 썼다. 쌓인 양이 많을수록 느려지는 선형 탐색이 여럿 있었고, 기록마다 + 시스템 호출과 할당을 했다. + - 일곱 번 고친 뒤에는 61 B 기록 하나에 0.30 µs를 쓴다. quinn의 1.06–1.12배로, 가드레일(1.10) 선 위다. + - 1 KiB와 16 KiB 기록에서는 Pure가 quinn보다 25 % 적게 쓰고, 초당 30 % 넘게 더 보낸다. + - 모든 측정에서 넘겨받은 기록은 순서대로 한 번씩, 보낸 바이트 그대로였다. 손실과 재정렬 아래에서도 그렇다. +- 아직 모르는 것: 손실과 재정렬 아래의 효율, 병목에서의 공정성(M6). +- 새로 찾은 문제: 보내는 앱의 outbox가 저장할 때마다 통째로 다시 쓰인다. 쌓인 보내기가 많으면 저장이 그만큼 커진다. +- 사용 경험의 차이: 같은 Pi가 같은 CPU로 더 많은 기기의 기록을 받는다. 무엇보다 기록마다 저장됐다는 확인이 붙는다. quinn + 쪽에는 그것이 없다. + +## 처음 잰 값 + +pure-serve 두 대를 잇고, 보내는 앱에 주문 2만 건을 쌓은 뒤 받는 노드를 띄워 풀었다. 61 B 기록 하나에 보내는 쪽 링크 +스레드가 1.1 µs, 받는 쪽 리스너 스레드가 2.4 µs를 썼다. quinn은 0.43 µs였다(2만 건, 연결 맺기 포함). + +## 고친 것 + +전송만 따로 재는 벤치마크를 먼저 만들었다([transport.c](../../research/experiments/019-idle-links/transport.c)). 링크와 +리스너가 한 프로세스에 있고, 앱도 저장도 없다. 리스너는 넘기는 즉시 저장된 것으로 친다. 아래 수는 61 B 기록 20만 개를 푼 +값이다(보내는 쪽 + 받는 쪽, 기록당). + +| 커밋 | 무엇 | 기록당 CPU | +|---|---|---:| +| 처음(pure-serve, 2만 건) | | 3.5 µs | +| [0f365f0](../../serve/link.c) | 쌓인 것을 훑지 않는다. GSO 묶음과 recvmmsg | 1.0 µs(pure-serve) | +| e19aa30 | 링크 스레드를 잠들었을 때만 한 번 깨운다 | 0.49 µs | +| 54035fe | 작은 기록을 RECORDS 프레임에 담는다(머리 19 B → 1–2 B) | 0.447 µs | +| 803626a | 받을 때 UDP GRO, 리스너의 AES 키는 run이 바뀔 때만 넣는다 | 0.394 µs | +| bebdecb | 받는 쪽: 순서대로 온 기록을 스트림의 줄에 붙여 그대로 넘긴다 | 0.31 µs | +| ba835b2 | 보내는 쪽: 쌓인 기록의 바이트를 한 고리에 둔다 | 0.28 µs | +| dac7ba0 | 패킷에 통째로 들어가는 기록은 조각 수와 상관없이 통째로 보낸다 | 0.28 µs(1,013 B: 3.18 → 2.85 µs) | + +- **훑기.** 보내는 쪽은 매 차례 쌓인 기록의 모든 조각을 두 번 훑어 저장을 기다리는 것이 있는지 봤다. 받는 쪽은 저장된 + 기록을 앞에서 뺄 때마다 뒤의 기록을 모두 당겼다. 둘 다 쌓인 양의 제곱으로 느려진다. 이제 기다리는 조각의 수를 세고, 받는 + 쪽 배열은 머리 위치를 둔다. +- **시스템 호출.** 패킷 하나를 보내는 데 2.4 µs가 들었다. GSO로 54개를 한 번에 보내면 패킷당 0.45 µs다. 가득 찬 패킷은 + PADDING으로 1200 B를 채운다. QUIC과 같은 방법이다. 받는 쪽은 GRO로 묶음을 한 버퍼로 받는다. +- **깨우기.** 기록을 넣을 때마다 eventfd를 써서 링크 스레드를 깨웠다. 스레드가 잠들어 있을 때만 한 번 깨운다. +- **틀.** RECORD 프레임은 기록마다 번호, 크기, 위치, 길이로 19 B를 붙였다. RECORDS 프레임은 첫 번호와 개수를 한 번 적고, + 기록마다 크기를 가변 길이로 적는다. 패킷당 기록이 13.7개에서 17.2개가 됐다. +- **할당과 복사.** 받는 쪽은 기록마다 할당하고, 넘길 때 다시 복사하고, 저장되면 풀었다. 이제 순서대로 온 온전한 기록은 + 전달이 싣는 모양 그대로(u32 크기, 바이트) 스트림의 줄에 붙는다. 줄은 그대로 넘기고, 저장되면 머리를 옮긴다. 보내는 쪽도 + 기록의 바이트를 들어온 순서대로 고리 하나에 두고, 저장되면 머리를 옮긴다. +- **조각.** 1,013 B 기록은 조각(1000 B)보다 커서 두 조각으로 갔고, 받는 쪽은 그것을 느린 길로 모았다. 이제 패킷에 통째로 + 들어가는 기록은 통째로 가서 줄에 곧바로 붙는다. 패킷보다 큰 기록과, 조각 일부를 잃어 다시 보내는 기록만 조각으로 간다. +- **확인.** 벤치마크는 넘겨받은 기록을 모두 확인한다. 순서대로 한 번씩 오는지, 바이트가 보낸 그대로인지 본다. 지연 1 ms, + 손실 3 %, 재정렬 10 %인 네트워크 이름공간에서 세 크기 모두 틀린 것이 없었다. + +## 결과 + +라즈베리 파이 5다. 원시 결과는 `research/experiments/019-idle-links/`의 +[2026-09-29-transport-linux-aarch64.jsonl](../../research/experiments/019-idle-links/2026-09-29-transport-linux-aarch64.jsonl)과 +[2026-09-29-drain-linux-aarch64.jsonl](../../research/experiments/019-idle-links/2026-09-29-drain-linux-aarch64.jsonl)에 있다. + +**전송만.** transport.c와 quinn으로 쌓인 기록을 모두 풀었다. 다섯 라운드의 중앙값이다. Pure 쪽은 넘겨받은 기록을 모두 +확인했고, 틀린 것은 없었다. + +| 기록(메시지) | 개수 | Pure 보내는 쪽 + 받는 쪽 | quinn 클라이언트 + 서비스 | Pure / quinn | 초당(Pure / quinn) | +|---:|---:|---:|---:|---:|---:| +| 61 B | 200,000 | 0.145 + 0.158 = **0.303 µs** | 0.14 + 0.13 = 0.27 µs | 1.12 | 509만 / 772만 | +| 1,013 B | 50,000 | 1.456 + 1.457 = **2.91 µs** | 1.99 + 1.89 = 3.88 µs | 0.75 | 67만 / 51만 | +| 16,013 B | 5,000 | 22.6 + 22.6 = **45.2 µs** | 31.1 + 29.7 = 60.9 µs | 0.74 | 4.4만 / 3.2만 | + +- quinn의 값은 한 스트림에 메시지를 이어 쓰고, 받는 쪽이 길이를 읽으며 세기만 한 것이다. 메시지마다 저장됐다는 확인도, + 정확히 한 번도 없다. Pure의 값에는 기록마다의 번호, 두 단계 확인(받음, 저장됨), 기록 단위의 다시 보내기가 들어 있다. +- 61 B의 비는 측정마다 흔들린다. 이틀 동안 세 번 쟀고 1.06, 1.08, 1.12였다(각 라운드 안에서도 Pure 0.29–0.45 µs, quinn + 0.27–0.32 µs). +- 작은 기록에서 남은 차이는 기록마다의 일보다 패킷마다의 짐에 있다. Pure는 패킷마다 스트림의 이름(STREAM 프레임, 여기서 25 + B)과 RECORDS 머리(10 B)를 싣는다. 그래서 패킷에 기록이 17.4개 든다. quinn은 18.5개다. +- 큰 기록에서 Pure가 적게 쓰는 까닭은 확인하지 않았다. 둘 다 GSO와 GRO를 쓴다. + +**pure-serve 끝단 사이.** 설계의 M5 그대로다. 보내는 앱에 주문 2만 건을 쌓고, 받는 노드를 띄워 풀었다. 받는 앱이 주문마다 +이벤트를 적용하고, 묶음마다 저장한다(tmpfs). 세 라운드의 중앙값이다(16 KiB는 두 라운드다. 셋째 라운드는 앞 라운드의 +저장소가 tmpfs를 채워 멈췄다). + +| 기록 | 초당 | 보내는 쪽(링크 스레드) | 받는 쪽(리스너 스레드) | quinn(전송만) | +|---:|---:|---:|---:|---:| +| 61 B | 108만 | 0.47(0.27) µs | 1.10(0.33) µs | 0.43 µs | +| 1,013 B | 35만 | 2.71(2.22) µs | 4.16(2.18) µs | 3.93 µs | +| 16,013 B | 2.3만 | 43.1(40.6) µs | 59.4(35.7) µs | 61.3 µs | + +- 받는 쪽의 나머지는 앱의 적용과 저장이다. quinn 쪽에는 없는 일이라 견주지 않는다. +- 링크 스레드만 봐도 기록당 CPU가 전송만 쟀을 때보다 크다(1 KiB에 4.4 µs 대 2.9 µs). 다시 보내기 때문은 아니다. 1 KiB에서 풀기 + 중 다시 보낸 것은 받는 노드가 꺼져 있을 때의 몇 개뿐이었다. 받는 앱이 묶음(약 100건)을 저장할 때마다 STORED가 가고, 그때마다 + 링크가 깨어 작은 단위로 일한다. 전송만 잴 때는 받는 쪽이 넘겨받는 즉시 저장된 것으로 친다. +- 채우는 동안 보내는 앱의 저장이 느려졌다. outbox(덜어 내지 않은 보내기)가 저장 때마다 links 값에 통째로 다시 쓰인다. 쌓인 + 양에 비례하는 저장이다. 16 KiB 2만 건이면 저장 한 번이 수백 MB에 이른다. 094의 들은 번호와 같은 모양의 문제다. + +## 판정 + +- **1 KiB와 16 KiB: 통과.** 전송이 기록 하나에 쓰는 CPU가 quinn보다 25 % 적다. 초당 보내는 수는 30 % 넘게 많다. +- **61 B: 가드레일 선 위.** quinn의 1.06–1.12배다. 남은 차이는 패킷마다 싣는 스트림 이름과 RECORDS 머리다. 이것을 줄이면 + 넘는다. 연결 없는 모델에서는 패킷마다 자기가 누구의 어느 스트림인지 말해야 하므로, 이름 대신 짧은 이름표를 쓰는 방법을 따로 + 설계해야 한다. +- H4("활성 링크의 전달당 CPU는 QUIC 기준선보다 나쁘지 않다")는 1 KiB 이상에서 선다. 작은 기록에서는 거의 선다. quinn의 값에는 + 기록마다의 확인과 정확히 한 번이 없다는 점을 함께 본다. + +## 한계와 다음 + +- **outbox의 저장.** 보내는 앱의 outbox가 저장 때마다 통째로 쓰인다. 들은 번호처럼 목록으로 바꿔, 저장이 바뀐 것만 로그에 + 쓰게 한다. 목록에는 이진 바이트 칸이 먼저 필요하다(지금은 텍스트 칸이 UTF-8만 받는다). 다음 일이다. +- **하나씩 넣으며 보내기.** 기록을 하나씩 넣는 스레드와 링크 스레드가 잠금을 다퉈, 기록당 1.5 µs가 든다. pure-serve는 저장 + 한 번의 보내기를 묶어 넣으므로 덜하다. 묶어 넣는 API를 둔다. +- **조각의 크기.** 큰 기록은 1000 B 조각으로 나뉜다. 가장 긴 이름에도 맞게 잡은 값이라 패킷마다 100 B 남짓이 빈다. 조각의 + 경계를 패킷에 맞추면 큰 기록에서 더 줄어든다. +- **손실과 재정렬.** 재정렬을 손실로 잘못 봐서 다시 보내는 것이 많다(093). 이제 한 패킷에 기록이 17개라 잘못 본 손실의 값이 + 커졌다. M6에서 RACK과 적응형 문턱을 본다. -- cgit v1.2.3