aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research/102-adoption-and-spec.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/research/102-adoption-and-spec.md')
-rw-r--r--docs/research/102-adoption-and-spec.md253
1 files changed, 253 insertions, 0 deletions
diff --git a/docs/research/102-adoption-and-spec.md b/docs/research/102-adoption-and-spec.md
new file mode 100644
index 0000000..b7e04db
--- /dev/null
+++ b/docs/research/102-adoption-and-spec.md
@@ -0,0 +1,253 @@
+# 102 — 채택과 명세: 명세가 찾은 구멍 넷, ngtcp2와의 M6
+
+2026-09-29. [설계 011](../design/011-pure-transport.md)의 5단계다. 링크를 노드 사이 전달의 기본으로 정하고, 다른 구현이 맞붙을
+수 있는 명세 [012](../design/012-link-protocol.md)를 쓴다.
+
+착수 기준:
+
+- **바꿀 결정.** 노드 사이 전달의 기본을 연결 없는 링크로 정한다. 명세를 문서로 둔다.
+- **최소 확인.**
+ - 명세의 바이트와 규칙이 코드와 같다. 명세만 보고 따로 쓴 받는 쪽이 실제 노드와 맞붙어야 한다.
+ - [101](101-remaining-limits.md)이 남긴 공백을 채운다. M6(손실, 재정렬, 병목)를 ngtcp2와도 잰다.
+- **중단 기준.**
+ - 독립 구현이 맞붙지 않으면, 명세나 코드를 고친 뒤에 채택한다.
+ - M6에서 ngtcp2보다 가드레일(011: 10 %)을 넘게 나쁘면 채택을 미룬다.
+
+## 쉬운 말로
+
+- 알아낸 것:
+ - **명세.** 012가 머리, 키 유도, 프레임 13종, 두 쪽의 규칙, 명세의 값과 지역 선택, 보안 성질을 적는다.
+ - 명세만 보고 파이썬으로 받는 쪽을 따로 썼다. `cryptography` 패키지만 쓰고 `link/`의 코드는 쓰지 않았다.
+ - 그 받는 쪽이 `pure-serve` 노드와 맞붙는다. 주문 200건이 모두 저장되고, 첫 패킷 하나 뒤로는 두 쪽이 따로 셈한 에폭 1로
+ 봉인된다.
+ - **명세를 쓰다가 구멍 넷을 찾았다.** 규칙을 문장으로 옮기니 코드가 말하지 않던 경우가 드러났다.
+ - 받는 쪽이 받지 않는 스트림(허용되지 않은 source, 없는 입력, 다른 shape)을 보내는 쪽은 아무 말도 듣지 못했다. 쌓인 기록을
+ 매초 끝없이 다시 보냈고, 운영자는 아무것도 보지 못했다. 이제 REFUSED로 이유를 듣고, 1초에서 64초까지 늘어나는 간격으로
+ 패킷 하나씩만 시도한다.
+ - 보내는 쪽은 64 MiB까지의 기록을 받았지만, 받는 쪽은 16 MiB까지만 쥘 수 있었다. 그 사이 크기의 기록은 영원히 다시 갔다.
+ - 받는 쪽은 피어마다 run 자리를 둘만 두었다. 한 노드의 출력 셋이 같은 노드로 가면 run들이 서로 자리를 밀어내, 답이 엉뚱한
+ run으로 갔다. 검사에서 run 키를 3번이 아니라 7번 만들었고, 저장까지 16 ms가 아니라 1,016 ms가 걸렸다.
+ - 메모는 피어마다 run 하나의 에폭만 들었다. 그 출력 셋이 에폭 1에 있을 때 받는 쪽이 다시 뜨면 둘이 LOST를 받고 에폭을 다시
+ 시작했다. 이제 LOST도 X25519도 없다.
+ - **허가.** 받는 쪽의 허가가 기록 1,024건으로 고정되어, 링크 하나가 RTT마다 1,024건 넘게 나를 수 없었다. 100 ms 경로에서
+ 61 B 기록은 초당 1만 건이 끝이었다(경로가 나를 수 있는 양의 1/9). 이제 허가를 받는 쪽 메모리에 맞추어, 같은 경로에서 초당
+ 5만 6천 건이다.
+ - **M6와 ngtcp2.** 세 구현을 같은 병목(50 Mbit/s), 같은 경로에서 번갈아 쟀다.
+ - 재정렬만 있을 때 Pure가 가장 많이 나른다(45 Mbit/s). ngtcp2는 29–47, quinn은 1–2다.
+ - 손실만 있을 때는 셋이 같다.
+ - 손실도 재정렬도 없을 때는 Pure가 3–5 % 적다. 전부 틀의 바이트다. Pure는 패킷마다 틀이 약 112 B, QUIC은 약 41 B다.
+ - 손실과 재정렬이 함께 있을 때는 ngtcp2가 1.3–3.6배 나른다. ngtcp2는 재정렬로 잘못 본 손실을 되돌리면서, 그 앞의 진짜 손실이
+ 줄인 창까지 함께 되돌린다. ngtcp2의 로그와 코드로 확인했다(아래). Pure는 한 에피소드의 손실이 모두 늦게 온 것으로 밝혀질
+ 때만 되돌린다.
+- 아직 모르는 것:
+ - 다른 사람이 명세만으로 만든 구현은 아직 없다. 파이썬 받는 쪽은 명세를 쓴 사람이 썼다. 다만 코드를 옮기지 않고 명세의
+ 문장대로 썼다.
+ - 보내는 쪽의 독립 구현은 없다. 파이썬 쪽은 받는 쪽이다.
+- 사용 경험의 차이:
+ - 잘못 배선한 링크는 보내는 노드의 로그 한 줄로 드러난다. HTTP 전달이 받는 쪽의 거절을 알리는 것과 같은 모양이다. 종료
+ 통계에도 `link_refused`와 `listener_refused`로 남는다.
+ - 한 노드의 여러 출력을 같은 노드로 보내도 된다. 받는 쪽이 다시 떠도 모두 그대로 이어진다.
+
+## 한 것
+
+### 명세 012
+
+- 012는 바이트 하나하나를 코드와 맞춰 가며 썼다.
+ - 머리 21 B: 판과 에폭, 키 id(FNV-1a), run, 패킷 번호.
+ - 키 유도: HKDF-SHA256의 salt와 info 문자열, 누가 from이고 누가 to인지, 어느 run이 들어가는지.
+ - 논스, 에폭의 사슬, 프레임 13종의 배치.
+- 판은 선 위에서 협상하지 않는다. 판은 받는 쪽의 키와 함께 배선에서 온다. 다음 판은 판 번호와 키 유도 문자열을 함께 바꾸므로,
+ 판이 다른 두 구현은 서로의 패킷을 열 수 없다.
+- 값은 둘로 나눴다. 상대가 알아야 하는 명세의 값(데이터그램, 허가, 증폭 한도, LOST의 크기 조건)과, 한쪽이 혼자 정해도 되는 지역
+ 선택(혼잡 제어, PTO, 비율 한도, 쉬는 시간)이다.
+- 선 위의 알림은 LOST와 REFUSED 둘이고, 둘 다 인증된다. 그 밖의 실패에는 답하지 않는다.
+
+### 독립 구현
+
+`tests/check.py`의 `server_links_independent`가 파이썬 받는 쪽이다. 명세의 다음 부분을 모두 거친다.
+
+- 정적 비밀(X25519의 32 B 그대로), 패킷 키(HKDF, info), 논스, 머리를 추가 인증 데이터로 쓰는 AES-128-GCM.
+- 프레임 읽기: STREAM, RECORDS, RECORD, OFFER, RESPONSE, PING, PADDING.
+- 답 만들기: CHALLENGE, ACCEPT(되풀이 4 B), ACK(구간), STORED. 답 run과 보내는 쪽 run을 넣은 답의 키.
+- 에폭 1의 비밀: 파이썬 쪽의 새 키 쌍과 노드의 OFFER로 E(1)을 따로 셈한다. 노드의 패킷 199개가 그 키로 열린다.
+- 새 주소의 확인(CHALLENGE, RESPONSE)과 증폭 한도(3배).
+
+다섯 번 돌려 모두 같았다. 0.8초에 끝난다.
+
+### REFUSED
+
+- **받는 쪽.** STREAM을 받지 않으면 이유와 함께 그 run의 다음 답에 REFUSED(0x0c)를 넣는다. 한 차례에 한 번이다.
+ - 이유 1: 그 이름의 메시지 입력이 없다.
+ - 이유 2: 입력의 shape가 다르다.
+ - 이유 3: 이 키는 그 source로 보낼 수 없다.
+ - 호스트의 입력 콜백이 1과 2를 가른다(`-PURE_LINK_OTHER_SHAPE`). 전의 `-1`은 그대로 1이다.
+- **보내는 쪽.** 기록을 보내지 않고 기다린다. 기다린 뒤 기록을 실은 패킷 하나로 시도한다.
+ - 기다림은 1초에서 시작해 두 배씩, 64초까지 는다.
+ - 그 패킷을 확인한 답에 REFUSED가 없으면 거절이 끝난다. STORED를 들어도 끝난다.
+- **처음 만든 판의 구멍.** 처음에는 STORED를 들어야만 거절을 풀었다. 그런데 시도한 패킷이 큰 기록의 앞 조각만 실으면, 받는
+ 쪽은 저장할 것이 없어 STORED를 보내지 않는다. 보내는 쪽은 STORED만 기다리며 멈췄다. 검사가 이것을 잡았다. 그래서 "시도한 패킷
+ 자체가 REFUSED 없이 확인됨"으로 푼다. PING만 든 탐침의 확인으로 잘못 풀지 않도록, 시도한 패킷의 번호가 확인됐는지를 본다.
+- **검사.**
+ - test-link: 세 가지 이유가 각각 90초에 7번 거절되고, 패킷은 9개다. 입력이 없다가 10초에 생긴 스트림은 그 뒤 첫 시도(15초)에
+ 풀려 200건이 모두 저장된다.
+ - check.py: 없는 입력으로 링크를 건 노드가 로그에 한 번 알리고, 2.5초 동안 패킷 2개만 보낸다.
+- REFUSED 전에는 같은 90초에 거절된 스트림 하나가 패킷 22,593–23,225개를 보냈고, 기록을 40,570–52,976번 다시 보냈다. 받았다고
+ 들은 기록이 1초 안에 저장되지 않으면 모두 다시 보내는 규칙이 끝없이 돌았다. 옛 커밋(78231dc)의 라이브러리에 같은 검사를
+ 붙여 쟀다.
+
+### 기록의 크기
+
+- 보내는 쪽의 상한(64 MiB)과 받는 쪽이 한 스트림에서 쥐는 양(16 MiB)이 달랐다. 16 MiB가 넘는 기록은 받는 쪽이 버리고, 보내는
+ 쪽은 저장 소식이 없으니 다시 보냈다.
+- 이제 둘 다 `PURE_LINK_RECORD_MOST`(16 MiB)다. 명세의 값이다. test-link가 16 MiB 기록 하나를 한 번에 보내고(가상 59 ms),
+ 1 B 더 큰 것은 큐에 받지 않는 것을 본다.
+
+### 피어의 run 자리
+
+- 받는 쪽은 피어마다 run 자리 둘을 두었다. 보내는 쪽의 run과, 그것이 다시 떴을 때의 새 run을 위한 것이었다.
+- 그런데 한 노드의 출력 여럿이 같은 노드로 가면, 출력마다 보내는 쪽이 따로 있고 run도 따로다. 셋이면 자리 둘을 서로 밀어낸다.
+ - 밀려난 run의 패킷은 키를 다시 만든다.
+ - 답 목록이 가리키던 자리를 다른 run이 차지해, 답이 엉뚱한 run으로 간다.
+- 이제 가장 오래 조용한 run도 아직 살아 있으면(쉬는 시간 안에 보냈으면) 자리를 하나 더 만든다. 피어당 64개까지다. 자리는
+ 피어가 쉬어 놓일 때까지 그대로 두므로, 가리키는 것들이 늘 맞다.
+- test-link: 한 키로 세 출력이 1,000건씩 보낸다. 고치기 전에는 run 키를 7번 만들었고, 저장까지 가상 1,016 ms가 걸렸다. 고친
+ 뒤에는 3번, 16 ms다.
+
+### 메모의 곁 자리
+
+- 메모는 피어의 자리에 run 하나의 에폭만 두었다. 여러 run이 함께 보내는 피어는 받는 쪽이 다시 뜬 뒤 마지막으로 받아들인 run
+ 말고는 모두 LOST를 받았다. 에폭 0으로 돌아가므로, 기록이 한 왕복 동안 정적 키 아래로 가고 X25519가 더 든다.
+- 이제 피어의 자리에 든 run이 아직 보내는 동안, 다른 run의 에폭은 곁의 자리에 둔다.
+ - 곁 자리는 1,024개이고 파일 머리에 있다(160 KiB). 피어와 run으로 두 자리를 찾아, 가진 것, 빈 것, 첫째 순으로 고른다.
+ - run이 피어의 자리로 옮겨 가면 곁 자리를 지운다. 에폭의 비밀은 다음 에폭이 덮을 때까지만 둔다는 규칙을 지키려는 것이다.
+ - 형식이 바뀌어 메모는 PLM3이다. PLM2 메모는 처음부터 다시 만든다(피어마다 X25519 한 번).
+- test-link: 세 출력이 모두 에폭 1에 든 뒤 받는 쪽을 kill -9처럼 다시 띄운다. 고치기 전에는 둘이 LOST를 받고 수락 한 번(비밀
+ 2개)이 들었다. 고친 뒤에는 둘 다 0이다.
+
+### 허가를 기록의 크기에 맞춤
+
+M6의 100 ms 경로에서 Pure가 20 ms 때보다 패킷을 28 % 더 보냈다(107,968 대 84,106). goodput은 거의 같았고 손실은 0이었다.
+패킷이 덜 찼다는 뜻이다.
+
+- **원인.** 받는 쪽의 허가가 기록 1,024건으로 고정되어 있었다.
+ - 100 ms에 50 Mbit/s이면 대역폭×지연과 병목 큐에 든 기록이 1,024건을 넘는다.
+ - 그래서 보내는 쪽은 STORED가 조금씩 연 만큼만, 한두 건씩 든 패킷을 보냈다.
+- **더 큰 한계.** 링크 하나가 RTT마다 1,024건을 넘게 나를 수 없었다. 시뮬레이터(가상 시간, 실제 프로토콜 코드)로 쟀다.
+
+ | 기록 | RTT 10 ms | RTT 100 ms |
+ |---|---:|---:|
+ | 61 B | 90,814건/s(44 Mbit/s, 대역폭에 걸림) | 9,953건/s(4.9 Mbit/s) |
+ | 1 KiB, 보낸 패킷 | 74,890 | 99,477 |
+
+ 61 B 기록은 100 ms 경로에서 대역폭과 상관없이 초당 1만 건이 상한이었다. 경로가 나를 수 있는 양의 1/9이다.
+- **고침.** 받는 쪽은 메모리 한도(스트림당 16 MiB)를 따로 지키고 있다. 그러니 허가를 건수로 고정할 까닭이 없다.
+ - 허가는 남은 자리를 지난 전달의 기록당 바이트로 나눈 것이다. 1–65,536건이고, 전달이 없으면 전처럼 1,024건이다.
+ - 받는 범위는 들은 번호 + 65,536까지다. 보내는 쪽이 따르는 상한과 같다.
+ - 메모리를 넘는 기록은 전처럼 받지 않고, 보내는 쪽이 다시 보낸다.
+- **고친 뒤.**
+
+ | | 전 | 후 |
+ |---|---:|---:|
+ | 시뮬레이터, 61 B, RTT 100 ms | 9,953건/s | 56,296건/s |
+ | 시뮬레이터, 61 B, RTT 10 ms | 90,814건/s, 손실 0 | 87,130건/s, 손실 1,371 |
+ | 시뮬레이터, 1 KiB, RTT 100 ms | 44.1 Mbit/s, 패킷 99,477 | 44.0 Mbit/s, 패킷 75,938 |
+ | M6, 1 KiB, RTT 100 ms, 손실 0 | 44.9 Mbit/s, 패킷 107,968 | 45.4 Mbit/s, 패킷 83,566 |
+
+ - 이제는 허가가 아니라 혼잡 창이 병목의 큐를 채운다. 그래서 큐에서 버려지는 손실이 생긴다. 전에는 허가가 뜻하지 않게 창을
+ 대신 묶어 그것을 가렸다.
+
+### M6: ngtcp2
+
+- 101의 [drain.c](../../research/experiments/019-idle-links/ngtcp2/drain.c)에 quinn 하네스와 같은 두 모드를 더했다.
+ - 속도 맞춤: 초당 RATE개, 각 메시지 앞 8 B에 쓴 시각을 넣는다.
+ - 시간 제한: SECONDS가 지나면 더 쓰지 않는다.
+ - 받는 쪽은 quinn과 같은 줄을 낸다. 100 ms마다 읽은 메시지 수와, 속도를 맞췄을 때 걸린 시간이다.
+- ngtcp2는 스트림 바이트를 확인될 때까지 앱이 들고 있어야 한다. 속도 맞춤 모드는 모든 메시지 자리를 미리 잡아 두고, 제 시각에
+ 시각을 찍어 스트림의 끝을 늘린다.
+- 020의 run.py가 ngtcp2를 세 번째로 돌린다. 같은 병목 뒤의 포트 40005다. 경우마다 Pure, quinn, ngtcp2를 번갈아 돌린다.
+- **하네스의 구멍.** 처음 돌렸을 때 재정렬이 있는 경우에서 ngtcp2가 멈췄다.
+ - OpenSSL 3.5의 ClientHello는 기본으로 ML-KEM 혼합 키를 실어 Initial 패킷 둘로 나뉜다.
+ - 경로가 둘째를 먼저 가져다주면 ngtcp2의 서버는 Retry를 요구하는데, 하네스가 그것을 처리하지 않았다.
+ - 클라이언트가 X25519만 내게 했다. Pure의 링크도 X25519로 합의한다. 서버는 붙들 수 없는 연결을 놓고, 클라이언트의 다음
+ Initial로 새로 시작한다.
+
+## 결과
+
+**M6** (두 라운드, 경우마다 세 구현을 번갈아, 라운드마다의 값;
+`research/experiments/020-lossy-paths/2026-09-29-paths-ngtcp2-linux-aarch64.jsonl`). Pure의 값은 허가를 고치기 전이다. 고친
+뒤 100 ms 행은 45.4, 45.4, 1.5, 1.4, 0.5, 0.6 Mbit/s이고, 나머지 행은 잡음 안에서 같다.
+
+대용량(1 KiB 기록, Mbit/s):
+
+| RTT | 손실 | 흔들림 | Pure | quinn | ngtcp2 |
+|---:|---:|---:|---|---|---|
+| 20 | 0 % | 0 ms | 45.4 / 45.4 | 46.8 / 46.8 | 47.2 / 47.2 |
+| 20 | 0 % | 3 ms | 45.4 / 45.4 | 1.9 / 2.1 | 40.0 / 39.8 |
+| 20 | 1 % | 0 ms | 6.6 / 7.0 | 6.9 / 7.6 | 7.2 / 7.5 |
+| 20 | 1 % | 3 ms | 7.0 / 7.6 | 1.6 / 1.8 | 25.5 / 26.2 |
+| 20 | 5 % | 0 ms | 2.8 / 3.0 | 2.8 / 3.1 | 3.0 / 3.3 |
+| 20 | 5 % | 3 ms | 3.1 / 3.1 | 1.7 / 1.4 | 6.0 / 9.3 |
+| 100 | 0 % | 0 ms | 44.9 / 44.9 | 47.3 / 47.3 | 47.2 / 47.2 |
+| 100 | 0 % | 3 ms | 45.2 / 45.2 | 1.0 / 1.3 | 29.1 / 47.2 |
+| 100 | 1 % | 0 ms | 1.3 / 1.6 | 1.6 / 1.3 | 1.7 / 1.2 |
+| 100 | 1 % | 3 ms | 1.4 / 1.4 | 0.8 / 0.8 | 1.8 / 2.5 |
+| 100 | 5 % | 0 ms | 0.6 / 0.6 | 0.6 / 0.5 | 0.6 / 0.7 |
+| 100 | 5 % | 3 ms | 0.6 / 0.7 | 0.4 / 0.4 | 1.2 / 0.9 |
+
+속도를 맞춘 전송(초당 50건, 쓴 때부터 받는 쪽이 넘길 때까지 p50 / p99 / 최대 ms, 두 라운드의 중앙값):
+
+| RTT | 손실 | Pure | quinn | ngtcp2 |
+|---:|---:|---|---|---|
+| 20 | 1 % | 10.0 / 50.1 / 60.2 | 10.1 / 50.3 / 70.4 | 10.0 / 40.1 / 53.2 |
+| 20 | 5 % | 10.0 / 61.7 / 110.1 | 10.1 / 84.0 / 122.6 | 10.0 / 71.2 / 110.6 |
+| 100 | 1 % | 50.0 / 130.1 / 230.1 | 50.1 / 180.3 / 236.3 | 50.0 / 173.7 / 210.5 |
+| 100 | 5 % | 60.2 / 411.5 / 467.9 | 70.7 / 406.3 / 490.5 | 71.9 / 292.0 / 366.8 |
+
+TCP CUBIC 옆에서의 몫(흔들림 1 ms, 라운드마다): 20 ms에서 Pure 0.53, 0.47, ngtcp2 0.52, 0.47, TCP끼리 0.53, 0.50이다.
+100 ms에서 Pure 0.57, 0.58, ngtcp2 0.61, 0.56, TCP끼리 0.47, 0.49다. quinn은 흔들림 1 ms에도 무너져 0.02–0.07이다.
+
+**ngtcp2가 손실과 재정렬 아래에서 더 나르는 까닭.** 무작위 손실 1 %, RTT 20 ms에서 Reno 계열이 낼 수 있는 이론값(Mathis 식)은
+약 7 Mbit/s다. 재정렬이 없을 때는 셋 다 거기에 있다. 재정렬이 더해지면 ngtcp2만 25 Mbit/s로 오른다.
+
+- ngtcp2의 CUBIC은 혼잡 사건 때 되돌릴 창을 저장한다. 그런데 지금 창이 저장된 값보다 클 때만 덮어쓰고, 잘못 본 손실을
+ 되돌릴 때에만 지운다(`lib/ngtcp2_cc.c`, 1.25.0의 478행과 551행).
+- 진짜 손실의 패킷은 PTO의 3배가 지나면 손실 목록에서 빠진다(`ngtcp2_conn_remove_lost_pkt`). 그 뒤에 재정렬로 잘못 본 손실
+ 하나가 늦게 확인되면, 목록의 손실이 모두 확인된 것으로 보고 저장된 창을 되살린다. 저장된 창은 그 앞의 진짜 손실이 줄이기
+ 전의 것이다.
+- 로그로 셌다(5초 × 2, 같은 경로).
+ - 재정렬이 없으면 창을 55번 줄이고 한 번도 되돌리지 않는다.
+ - 재정렬이 있으면 226번 줄이고 34번 되돌린다. 34번 가운데 22번은 창을 바로 앞 감소 전보다 크게 되돌렸다. 그 앞의 진짜 손실이
+ 줄인 창까지 지운 것이다.
+- 그러니 이 행의 차이는 손실에 반응하지 않은 몫이다. 경로를 함께 쓰는 흐름이 있으면 그만큼 덜 비킨다. Pure는 한 에피소드의
+ 손실이 모두 늦게 온 것으로 밝혀질 때만 되돌린다(097).
+
+**틀의 바이트.** 손실도 재정렬도 없는 병목에서 Pure는 ngtcp2보다 3–5 % 적게 나른다. 셈하면 전부 틀이다.
+
+- Pure의 가득 찬 1,452 B 패킷의 틀은 약 112 B다.
+ - 머리와 태그 37 B
+ - STREAM 25 B(이름 두 개)
+ - 앞 패킷에서 이어진 조각의 RECORD 19 B
+ - RECORDS 10 B와 크기 2 B
+ - 다음 패킷으로 넘어가는 조각의 RECORD 19 B
+- QUIC은 짧은 머리와 태그 약 27 B, STREAM 프레임 약 8 B, 메시지마다 길이 4 B로 약 41 B다.
+- 셈으로는 Pure가 ngtcp2의 0.95배를 나른다. 잰 값은 0.96배다.
+
+## 판정
+
+- 명세가 코드와 같다는 것을 독립 구현으로 보였다. 파이썬 받는 쪽이 에폭 바꿈까지 맞붙는다.
+- 명세를 쓰다가 찾은 구멍 넷과 M6가 드러낸 허가의 한계를 고쳤다. 각각 옛 코드에서 실패하고 새 코드에서 통과하는 검사가 있다.
+- M6에서 ngtcp2와 대었다. 재정렬 아래에서 Pure가 가장 많이 나르고, 손실만 있을 때 셋이 같다. 틀의 바이트로 3–5 % 뒤지고,
+ 손실과 재정렬이 겹칠 때 ngtcp2가 더 나르는 것은 손실에 덜 반응해서다. 011의 가드레일(TCP와의 몫 ±25 %)을 셋 다 지킨다
+ (quinn은 흔들림에 무너진다).
+- 채택 결정(011 5단계)은 그대로다.
+
+## 한계와 다음
+
+- 파이썬 받는 쪽은 명세를 쓴 사람이 썼다. 명세의 모호함은 다른 사람이 구현할 때 더 드러날 것이다.
+- 보내는 쪽의 독립 구현은 없다.
+- 틀의 바이트는 판 1에서 줄일 수 있다. 약 112 B를 약 45 B로 줄이면 QUIC과 비긴다.
+ - 받는 쪽이 그 run의 스트림을 안다는 것을 들은 뒤에는 STREAM을 뺀다(101에서 시도했던 설계, 25 B).
+ - 패킷의 기록 바이트를 조각이든 온전한 기록이든 한 프레임으로 잇는다(약 40 B).
+ - 패킷 번호를 줄여 싣는다(약 6 B).
+- 100 ms, 5 % 손실의 속도 맞춤 전송에서 p99는 ngtcp2가 낮다(292 대 412 ms). 라운드마다 크게 흔들리는 경우이고(097에서도
+ Pure 330–884 ms, quinn 310–752 ms), 원인은 아직 보지 않았다.