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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
|
# 097 — 손실과 재정렬, 병목: 재정렬에 무너지지 않고, 손실에서 QUIC만큼
2026-09-29. [설계 011](../design/011-pure-transport.md)의 M6다. [096](096-per-delivery-cpu.md)이 깨끗한 루프백에서 기록 하나의
CPU를 쟀다면, 여기서는 지연, 손실, 재정렬, 병목이 있는 경로에서 링크 하나가 얼마나 나르는지, 기록 하나가 얼마나 늦는지, 옆의
TCP와 대역을 어떻게 나누는지를 quinn과 댄다.
착수 기준:
- **바꿀 결정.** 011의 가드레일이다. TCP CUBIC과 나눈 몫이 ±25 %를 벗어나면(0.375–0.625 밖), 고친 뒤에야 채택한다. 처리량과
꼬리 지연은 quinn과 댄다.
- **최소 확인.** RTT 20·100 ms, 손실 0·1·5 %, 재정렬, 50 Mbit/s 병목에서 세 가지를 본다. 쌓인 1 KiB 기록을 푸는 처리량, 초당
50건의 꼬리 지연, TCP CUBIC 흐름 옆의 몫이다.
- **중단 기준.** 위의 ±25 %다.
## 쉬운 말로
- 알아낸 것:
- 처음에는 패킷 순서만 바뀌어도 링크가 주저앉았다. 잃은 패킷이 없는데 50 Mbit/s 경로에서 1.5 Mbit/s였다. RFC 9002의 고정
문턱 때문이고, quinn도 같은 규칙을 쓴다.
- 늦게 온 패킷을 알아보고 창을 되돌리게 하니, 순서가 흔들려도 흔들리지 않을 때와 같은 45 Mbit/s를 낸다. 같은 경로에서
quinn은 1–2 Mbit/s다(21–41배).
- 무작위 손실 아래 처리량은 quinn과 같다(0.97–1.07배). 손실과 재정렬이 함께 있으면 quinn의 1.3–3.5배다.
- 초당 50건의 꼬리 지연(p99)은 quinn과 같거나 짧다. RTT 100 ms에 손실 5 %에서 418 대 547 ms다.
- TCP CUBIC 흐름 옆에서 대역의 0.48–0.55를 쓴다. 가드레일(0.375–0.625) 안이다. 같은 경로에서 quinn은 지연이 1 ms만 흔들려도
0.02–0.07로 밀려난다.
- 깨끗한 경로의 처리량만 quinn보다 3–5 % 낮다. 패킷마다 붙는 머리 때문이다.
- 아직 모르는 것: 실제 무선·셀룰러 경로, 흐름이 많거나 RTT가 서로 다를 때, ngtcp2와의 비교, 저장소 옮기기(M3), 보안 검사.
- 사용 경험의 차이: 와이파이나 셀룰러처럼 패킷 순서가 흔들리는 망에서도 링크가 느려지지 않는다. 이 에뮬레이션에서 quinn은
1–3 ms의 흔들림만으로 한 자릿수 Mbit/s로 떨어졌다.
## 한 것
### 프로토콜을 시간 밖으로 꺼냈다
측정 전에 링크를 `link/`로 떼고 둘로 나눴다([009](../design/009-layout.md)). `link/link-protocol.h`는 소켓, 스레드, 시계가 없는
상태다. 패킷과 시각이 들어가고 패킷이 나온다. 그래서 두 쪽을 가상 시간의 네트워크에 놓을 수 있다.
- 시뮬레이터([sim.c](../../research/experiments/020-lossy-paths/sim.c))는 병목(속도, 큐, 드롭테일), 지연, 흔들림(재정렬), 무작위
손실을 흉내 낸다. 같은 씨앗이면 같은 결과가 나온다. 1 KiB 기록 3만 건이 1초 안팎에 끝난다.
- 처음 돌린 결과가 문제를 곧바로 보여 줬다(RTT 20 ms, 50 Mbit/s, 1 KiB).
| 경로 | 처리량 | 실제로 잃은 패킷 | 손실로 판정 | 가장 작은 창 |
|---|---:|---:|---:|---:|
| 깨끗함 | 41 Mbit/s | 0(큐 225) | 225 | 12,000 B |
| 재정렬만(흔들림 5 ms) | 1.5 Mbit/s | 0 | 308 | 2,400 B |
| 손실 1 % | 5.5 Mbit/s | 130 | 49 | 4,244 B |
재정렬만 있고 잃은 것이 없는데, 순서가 바뀐 패킷을 손실로 판정해 창이 최소로 주저앉았다. RFC 9002의 고정 문턱(나중 패킷 3개,
RTT의 9/8) 때문이다. quinn도 같은 규칙을 쓴다.
### 늦게 온 패킷
손실로 판정한 비행을 링에 잠시(RTT 두 번) 남겨 둔다. 그 사이에 확인이 오면 늦게 온 것이다.
- 그 패킷이 나른 부분은 받은 것으로 친다. 아직 다시 보내지 않았으면 다시 보내지 않는다.
- 문턱을 넓힌다. RACK처럼 두 가지다. 나중 패킷 문턱은 실제로 본 재정렬 깊이로, 시간 문턱은 최소 RTT의 4분의 1씩 늘린다. 재정렬
없는 혼잡 사건이 16번 이어지면 반으로 줄인다.
- 혼잡 사건을 되돌린다. 앞 사건의 손실이 정해지기 전에 이어진 사건들을 한 에피소드로 묶는다. 에피소드의 손실이 모두 늦게 온
것이면, 첫 사건 전의 창, 문턱, CUBIC 상태로 돌아간다. 하나라도 진짜 손실이면(링에서 늦은 확인 없이 떠나면) 되돌리지 않는다.
시뮬레이터에서 재정렬만 있을 때의 처리량이 RTT 20 ms에서 1.5 → 43.6 Mbit/s, RTT 100 ms에서 10 → 40.1 Mbit/s가 됐다. 재정렬이
없을 때와 같다.
### CUBIC, 쓰지 않은 창
- 혼잡 회피를 NewReno에서 CUBIC(RFC 9438)으로 바꿨다. quinn의 기본값이고, TCP CUBIC과 나란히 설 때 같은 규칙이어야 한다.
Reno 친화 추정은 RTT마다 0.53 데이터그램이다. 배포된 CUBIC(Linux, quinn)이 그렇게 한다. RFC 9438처럼 이전 최대를 넘은 뒤
1로 올리지는 않는다.
- 창을 다 쓰지 않을 때는(보낼 것이 없거나 받는 쪽 크레딧에 막히면) 창을 키우지 않는다(RFC 9002 §7.8). 전에는 크레딧에 막힌
채로 창이 1만 패킷 넘게 자랐다.
- Reno 추정은 확인이 올 때만큼, 또는 시간이 가는 만큼(RFC 8312) 가운데 앞선 쪽으로 키운다. 앱이 초당 몇 건만 보내는 링크는
확인이 드물어, 손실 뒤 필요한 창으로 돌아가는 데 오래 걸렸다.
### 기록을 패킷 경계에서 자른다
전에는 기록을 1,000 B 조각으로 나눴다. 1 KiB 기록은 패킷에 하나씩만 들어가 패킷의 13 %가 비었다.
- 이제 패킷의 남은 자리에 64 B 이상 들어가면 기록을 그만큼 자른다. 자른 자리는 바뀌지 않는다. 그래서 한 패킷이 나른 것은 늘
온전한 부분들이다.
- 받는 쪽은 조각 표 대신 받은 바이트 범위를 합쳐 둔다. 순서대로 오는 부분은 할당 없이 줄 끝에서 바로 이어 붙인다.
- 루프백에서 패킷이 1 KiB 기록에 8 %, 16 KiB 기록에 10 % 줄었다. 기록 하나의 CPU는 61 B와 1 KiB에서 같고, 16 KiB에서 5 %
줄었다.
### 경로가 나르는 만큼 큰 패킷
1,200 B로 시작한다. RTT를 알면 1,452 B로 채운 PING을 혼자, 조각내지 말라는 표시(DF)를 달고 보낸다(DPLPMTUD, RFC 8899). 확인이
오면 그 크기를 쓴다. 탐침을 잃는 것은 혼잡이 아니다. 1초 뒤 다시, 세 번까지, 그 뒤로는 10분마다 해 본다.
- CUBIC은 데이터그램 단위로 창을 키운다. 그래서 같은 손실률에서 패킷이 큰 쪽이 바이트로 더 빨리 자란다. quinn과의 손실 아래
차이는 대부분 이것이었다.
- GSO를 소켓 옵션이 아니라 묶음마다 제어 메시지로 청한다. 소켓 옵션이 걸려 있으면, 혼자 보낸 1,452 B 탐침을 시스템이
1,200 B와 252 B로 잘라 보냈다. `make check`의 UDP 검사가 이것을 잡았다.
- 루프백에서 패킷이 19–27 % 줄고, 기록 하나의 CPU가 1 KiB에서 12 %, 16 KiB에서 12 % 줄었다([096](096-per-delivery-cpu.md)의
조건, 나누기 전과 번갈아 세 라운드).
### 페이싱
창을 RTT에 걸쳐 1.25배(느린 시작에서 2배)의 빠르기로 보낸다. 한 번에는 2 ms어치(10–256 데이터그램)까지만 보낸다(RFC 9002 §7.7).
보내는 쪽 상태가 다음에 보낼 수 있는 때를 알려 준다.
### 측정 경로
비특권 이름공간의 루프백 하나를 경로로 쓴다([run.py](../../research/experiments/020-lossy-paths/run.py)).
- 방향마다 netem이 지연(RTT의 절반), 흔들림, 손실을 준다. 받는 쪽 포트(40000–40007)로 가는 쪽만 그 뒤에 tbf 병목(50 Mbit/s, 큐는
RTT 한 번어치)을 지난다. 되돌아오는 답과 TCP의 확인은 병목 뒤에 줄 서지 않는다.
- Pure와 quinn은 GSO 없이 보내고, TCP의 세그먼트는 1,500 B를 넘지 않게 한다. 큐 규칙은 GSO 묶음과 TSO 조각을 패킷 하나로
다루기 때문이다.
- 몫을 잴 때는 흐름마다 따로 된 프로세스를 동시에 띄우고, 60초 동안 20초부터 센다. 이때 경로의 지연을 1 ms 흔든다. 흔들림이
없으면 드롭테일 큐가 흐름들의 위상만으로 한쪽 패킷을 여덟 배 더 버린다(Floyd와 Jacobson, 1992).
- 흔들림 없이 쟀을 때 Pure는 TCP에게서 0.63–0.77, quinn에게서 0.69를 가져갔다.
- 같은 조건에서 Pure끼리는 0.47, 큐를 fq_codel로 바꾸면 0.49였다.
- 1 ms 흔들림에서는 RTT 20 ms에서 0.53이다.
- 이 경로를 맞추는 데 측정 여섯 벌을 버렸다. 이유는 이렇다.
- tbf 밑에 netem을 두면 tbf의 큐 한도가 사라진다.
- 답이 데이터 뒤에 줄 선다.
- GSO 묶음이 패킷 하나로 다뤄진다.
- 먼저 띄운 흐름이 몫을 더 가져간다.
- 흔들림이 없으면 위상 효과가 생긴다.
- 하네스가 재정렬을 틀림으로 셌다.
## 결과
라즈베리 파이 5, 비특권 이름공간의 루프백이다. 병목은 50 Mbit/s이고 큐는 RTT 한 번어치다. 번갈아 세 라운드의 중앙값이다.
Pure는 링크와 리스너만 잰다([transport.c](../../research/experiments/019-idle-links/transport.c), 앱과 저장 없음). quinn은 한
연결의 한 스트림이다. 기록은 1,013 B(1 KiB 항목의 주문)다. 원시 결과는 `research/experiments/020-lossy-paths/`의
`2026-09-29-paths-linux-aarch64.jsonl`에 있다. 앞 단계의 결과도 같은 곳에 있다(`-parts-`는 세 라운드, `-mtu-`는 한 라운드).
**쌓인 기록 풀기(Mbit/s, 기록의 바이트).** 흔들림 3 ms면 패킷 순서가 바뀐다.
| RTT | 손실 | 흔들림 | Pure | quinn | Pure/quinn |
|---:|---:|---:|---:|---:|---:|
| 20 ms | 0 | 0 | 45.4 | 46.8 | 0.97 |
| 20 ms | 0 | 3 ms | 45.4 | 2.18 | 20.8 |
| 20 ms | 1 % | 0 | 6.84 | 6.54 | 1.05 |
| 20 ms | 1 % | 3 ms | 7.30 | 2.07 | 3.53 |
| 20 ms | 5 % | 0 | 3.08 | 2.89 | 1.07 |
| 20 ms | 5 % | 3 ms | 3.27 | 1.62 | 2.02 |
| 100 ms | 0 | 0 | 44.9 | 47.3 | 0.95 |
| 100 ms | 0 | 3 ms | 45.2 | 1.11 | 40.8 |
| 100 ms | 1 % | 0 | 1.58 | 1.56 | 1.01 |
| 100 ms | 1 % | 3 ms | 1.43 | 0.82 | 1.74 |
| 100 ms | 5 % | 0 | 0.60 | 0.62 | 0.97 |
| 100 ms | 5 % | 3 ms | 0.65 | 0.49 | 1.33 |
**단계마다, RTT 20 ms에서.** 무엇이 얼마나 바꿨는지다.
| Pure | 깨끗함 | 재정렬 | 손실 1 % | 손실 5 % |
|---|---:|---:|---:|---:|
| 늦은 패킷, CUBIC, 부분까지(세 라운드) | 44.3 | 44.3 | 5.83 | 2.26 |
| + 경로 MTU(한 라운드) | 45.4 | 45.4 | 8.24 | 2.80 |
| + 페이싱, Reno 0.53(세 라운드, 최종) | 45.4 | 45.4 | 6.84 | 3.08 |
| quinn(최종과 같은 때) | 46.8 | 2.18 | 6.54 | 2.89 |
경로 MTU를 쓴 판의 손실 1 % 값(8.24)은 RFC 9438처럼 Reno 추정을 이전 최대 뒤에 RTT마다 1 데이터그램으로 키운 판이다. 배포된
CUBIC과 같게 0.53으로 두니 6.84가 됐다.
**초당 50건의 꼬리 지연(ms, 넣은 때부터 넘겨받은 때까지).** Pure / quinn이다.
| RTT | 손실 | p50 | p99 | 최대 |
|---:|---:|---|---|---|
| 20 ms | 0 | 10 / 10 | 10 / 10 | 10 / 10 |
| 20 ms | 1 % | 10 / 10 | 50 / 50 | 50 / 83 |
| 20 ms | 5 % | 10 / 10 | 70 / 96 | 90 / 137 |
| 100 ms | 0 | 50 / 50 | 50 / 50 | 50 / 50 |
| 100 ms | 1 % | 50 / 50 | 170 / 196 | 190 / 282 |
| 100 ms | 5 % | 70 / 110 | 418 / 547 | 517 / 686 |
잃은 패킷은 다음 패킷의 확인이 오는 순간에 손실로 판정해 곧바로 다시 보낸다(RTT 100 ms, 20 ms 간격이면 보낸 뒤 약 120 ms).
p99가 170 ms인 까닭이다. 시뮬레이터도 같은 값을 준다.
**TCP CUBIC 옆의 몫(60초 가운데 20초부터, 경로 지연 1 ms 흔들림).**
| RTT | Pure | 라운드별 | TCP끼리 | quinn |
|---:|---:|---|---:|---:|
| 20 ms | 0.484 | 0.517, 0.484, 0.483 | 0.518 | 0.065 |
| 100 ms | 0.549 | 0.560, 0.545, 0.549 | 0.482 | 0.018 |
- TCP끼리의 몫도 라운드마다 0.44–0.58 사이에서 움직인다. Pure는 그 범위 안이다.
- quinn은 1 ms 흔들림을 손실로 읽어 창을 줄인다. 같은 흔들림에서 quinn 혼자 몰아 보낼 때도 1–2 Mbit/s였다.
- 큐를 fq_codel로 바꾸면(흐름마다 공정하게 줄 세움, 한 라운드) Pure 0.49·0.48, quinn 0.50·0.50이다.
## 판정
- **가드레일은 통과다.** TCP CUBIC 옆에서 Pure의 몫은 0.48(RTT 20 ms)과 0.55(RTT 100 ms)다. 라운드별로 0.48–0.56으로,
0.375–0.625 안이고 TCP끼리의 흔들림과 같은 범위다.
- **M6의 나머지.**
- 재정렬에서 quinn의 21–41배다.
- 무작위 손실에서는 같다(0.97–1.07배).
- 손실과 재정렬이 함께 있으면 1.3–3.5배다.
- 꼬리 지연은 같거나 짧다.
- 깨끗한 경로에서만 3–5 % 낮다.
- **M5는 더 좋아졌다.** 부분, 경로 MTU, 페이싱을 넣은 뒤 기록 하나의 CPU가 1 KiB와 16 KiB에서 12 % 줄었다. 61 B는 같다
(096의 조건).
- 011의 5단계("M5·M6 가드레일을 잰다")가 끝났다. 남은 것은 4단계다: 에폭 바꿈, 경로 바뀜, 저장소 옮기기(H3, M3), 보안 검사.
## 한계와 다음
- **경로는 에뮬레이션이다.**
- 루프백 하나에 netem과 tbf를 얹었다. 실제 무선·셀룰러, ECMP의 재정렬, 여러 AQM이 아니다.
- 몫은 두 흐름, 같은 RTT만 봤다.
- **무작위 손실 아래는 손실을 혼잡으로 읽는 모든 제어와 같다.** 손실 5 %, RTT 100 ms에서 0.6 Mbit/s다. 넘으려면 모델 기반 제어
(BBR 계열)나 여분 전송이 필요하다. 그것은 가드레일(TCP와의 몫)과 함께 따져야 한다.
- **꼬리 지연은 확인으로 알 수 있는 가장 이른 때에 다시 보낸다.** 더 줄이려면, 앱이 한가해 창이 빌 때 기록을 한 번 더 보내는
여분이 필요하다.
- **깨끗한 경로의 3–5 %.** 패킷마다 스트림 이름(약 25 B)을 싣는다. 096의 짧은 이름표 과제와 같다.
- **경로 MTU는 늘리기만 한다.** 경로가 도중에 좁아지면(블랙홀) 알아채지 못한다. RFC 8899의 블랙홀 탐지는 다음이다.
- **페이싱은 보내는 스레드의 poll(ms 단위)에 묶인다.**
- **QUIC 비교는 quinn 0.11 하나다.** GSO 없이 쟀다. ngtcp2는 아직이다. quinn이 흔들림에 무너진 것은 RFC 9002의 고정 문턱 때문으로
보지만, quinn의 코드로 확인하지는 않았다.
|