aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/096-per-delivery-cpu.md
blob: df2718ca41b2ffdad80e904fceb127608907f9a3 (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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# 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가 저장할 때마다 통째로 다시 쓰였다. 목록으로 옮기니 16 KiB 보내기 5천 건을
  저장하는 데 84.8초이던 것이 0.91초가 됐다.
- 사용 경험의 차이: 같은 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](../../link/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). 세 라운드의 중앙값이다. outbox를 목록으로 바꾼 뒤(835c18e)에 쟀다([원시
결과](../../research/experiments/019-idle-links/2026-09-29-drain-outbox-linux-aarch64.jsonl)).

| 기록 | 초당(quinn) | 보내는 쪽(링크 스레드) | 받는 쪽(리스너 스레드) | quinn 클라이언트 + 서비스(전송만) |
|---:|---:|---:|---:|---:|
| 61 B | 111만(643만) | 0.63(0.25) µs | 1.01(0.29) µs | 0.24 + 0.17 = 0.41 µs |
| 1,013 B | 41만(50만) | 2.63(2.08) µs | 3.66(2.09) µs | 2.07 + 1.93 = 4.00 µs |
| 16,013 B | 3.4만(3.3만) | 30.5(29.0) µs | 45.3(29.1) µs | 30.8 + 29.3 = 60.1 µs |

- 받는 쪽의 나머지는 앱의 적용과 저장이다. quinn 쪽에는 없는 일이다. 그런데도 16 KiB에서는 Pure가 저장까지 하며 quinn보다
  초당 더 많이 받는다.
- 링크 스레드만 봐도 기록당 CPU가 전송만 쟀을 때보다 크다(1 KiB에 4.2 µs 대 2.9 µs). 다시 보내기 때문은 아니다. 풀기 중 다시
  보낸 것은 받는 노드가 꺼져 있을 때의 몇 개뿐이었다. 받는 앱이 묶음을 저장할 때마다 STORED가 가고, 그때마다 링크가 깨어 작은
  단위로 일한다. 전송만 잴 때는 받는 쪽이 넘겨받는 즉시 저장된 것으로 친다.
- outbox를 목록으로 바꾸기 전에는 링크 스레드가 16 KiB 기록 하나에 76 µs를 썼고 초당 2.3만 건이었다
  ([그때의 결과](../../research/experiments/019-idle-links/2026-09-29-drain-linux-aarch64.jsonl)). 보내는 앱이 덜어 낼
  때마다 남은 outbox 전체를 다시 저장했기 때문이다.

**outbox의 저장.** 채우는 동안 보내는 앱의 저장이 느렸다. outbox(덜어 내지 않은 보내기)가 저장 때마다 links 값에 통째로 다시
쓰였다. 094의 들은 번호와 같은 모양의 문제다. 들은 번호처럼 런타임 목록 "0sent"로 옮겼다(835c18e). 저장은 바뀐 것(새 보내기,
덜어 낸 것)만 로그에 쓴다. 목록에 이진 바이트 칸을 더했다.

| 보내기를 하나씩 저장 | 전 | 후 |
|---|---:|---:|
| 16 KiB 5천 건 | 84.8초, 저장소 400 MB | 0.91초, 144 MB |
| 61 B 2만 건 | 8.4초 | 2.9초 |

## 판정

- **1 KiB와 16 KiB: 통과.** 전송이 기록 하나에 쓰는 CPU가 quinn보다 25 % 적다. 초당 보내는 수는 30 % 넘게 많다. 끝단
  사이에서도 16 KiB는 받는 쪽이 저장까지 하며 quinn만큼 받는다.
- **61 B: 가드레일 선 위.** quinn의 1.06–1.12배다. 남은 차이는 패킷마다 싣는 스트림 이름과 RECORDS 머리다. 이것을 줄이면
  넘는다. 연결 없는 모델에서는 패킷마다 자기가 누구의 어느 스트림인지 말해야 하므로, 이름 대신 짧은 이름표를 쓰는 방법을 따로
  설계해야 한다.
- H4("활성 링크의 전달당 CPU는 QUIC 기준선보다 나쁘지 않다")는 1 KiB 이상에서 선다. 작은 기록에서는 거의 선다. quinn의 값에는
  기록마다의 확인과 정확히 한 번이 없다는 점을 함께 본다.

## 한계와 다음

- **덜어 내기의 훑기.** outbox는 보낸 순서라 여러 출력의 보내기가 섞인다. 한 출력의 받는 쪽이 오래 멀면, 다른 출력을
  덜어 낼 때마다 앞의 보내기를 훑고 지나간다. 출력이 하나면 문제없다.
- **하나씩 넣으며 보내기.** 기록을 하나씩 넣는 스레드와 링크 스레드가 잠금을 다퉈, 기록당 1.5 µs가 든다. pure-serve는 저장
  한 번의 보내기를 묶어 넣으므로 덜하다. 묶어 넣는 API를 둔다.
- **조각의 크기.** 큰 기록은 1000 B 조각으로 나뉜다. 가장 긴 이름에도 맞게 잡은 값이라 패킷마다 100 B 남짓이 빈다. 조각의
  경계를 패킷에 맞추면 큰 기록에서 더 줄어든다.
- **손실과 재정렬.** 재정렬을 손실로 잘못 봐서 다시 보내는 것이 많다(093). 이제 한 패킷에 기록이 17개라 잘못 본 손실의 값이
  커졌다. M6에서 RACK과 적응형 문턱을 본다.