benchmark_local.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import asyncio
  5. import contextlib
  6. import random
  7. import ssl
  8. import socket
  9. import statistics
  10. import sys
  11. import time
  12. from dataclasses import dataclass
  13. from pathlib import Path
  14. from urllib.parse import urlparse
  15. ROOT = Path(__file__).resolve().parents[2]
  16. if str(ROOT) not in sys.path:
  17. sys.path.insert(0, str(ROOT))
  18. from mynetspeeder.config import Config # noqa: E402
  19. def parse_host_port(text: str) -> tuple[str, int]:
  20. if text.startswith("["):
  21. host, rest = text[1:].split("]", 1)
  22. if not rest.startswith(":"):
  23. raise ValueError(f"无效地址: {text}")
  24. return host, int(rest[1:])
  25. if text.count(":") == 1:
  26. host, port = text.rsplit(":", 1)
  27. return host, int(port)
  28. raise ValueError(f"无效地址: {text}")
  29. def parse_url(text: str) -> tuple[str, int, str, bool]:
  30. parsed = urlparse(text)
  31. if parsed.scheme not in ("http", "https"):
  32. raise ValueError(f"仅支持 http/https: {text}")
  33. if not parsed.hostname:
  34. raise ValueError(f"无效 URL: {text}")
  35. port = parsed.port or (443 if parsed.scheme == "https" else 80)
  36. path = parsed.path or "/"
  37. if parsed.query:
  38. path = f"{path}?{parsed.query}"
  39. return parsed.hostname, port, path, parsed.scheme == "https"
  40. def parse_dns_target(text: str) -> tuple[str, int]:
  41. host, port = parse_host_port(text)
  42. return host, port
  43. def socks5_addr_bytes(host: str, port: int) -> bytes:
  44. try:
  45. return b"\x01" + socket.inet_aton(host) + port.to_bytes(2, "big")
  46. except OSError:
  47. try:
  48. return b"\x04" + socket.inet_pton(socket.AF_INET6, host) + port.to_bytes(2, "big")
  49. except OSError:
  50. raw = host.encode()
  51. return b"\x03" + bytes([len(raw)]) + raw + port.to_bytes(2, "big")
  52. async def socks5_greet(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  53. writer.write(b"\x05\x01\x00")
  54. await writer.drain()
  55. reply = await reader.readexactly(2)
  56. if reply != b"\x05\x00":
  57. raise ConnectionError("SOCKS5 握手失败")
  58. async def socks5_connect(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, host: str, port: int) -> None:
  59. await socks5_greet(reader, writer)
  60. writer.write(b"\x05\x01\x00" + socks5_addr_bytes(host, port))
  61. await writer.drain()
  62. head = await reader.readexactly(4)
  63. if head[1] != 0x00:
  64. raise ConnectionError(f"SOCKS5 CONNECT 失败: {head[1]}")
  65. atyp = head[3]
  66. if atyp == 1:
  67. await reader.readexactly(4 + 2)
  68. elif atyp == 3:
  69. length = (await reader.readexactly(1))[0]
  70. await reader.readexactly(length + 2)
  71. elif atyp == 4:
  72. await reader.readexactly(16 + 2)
  73. async def open_http_connection(host: str, port: int, timeout: float, proxy: tuple[str, int] | None, use_tls: bool) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
  74. if proxy is None:
  75. if use_tls:
  76. return await asyncio.wait_for(asyncio.open_connection(host, port, ssl=ssl.create_default_context(), server_hostname=host), timeout=timeout)
  77. return await asyncio.wait_for(asyncio.open_connection(host, port), timeout=timeout)
  78. proxy_reader, proxy_writer = await asyncio.wait_for(asyncio.open_connection(proxy[0], proxy[1]), timeout=timeout)
  79. await socks5_connect(proxy_reader, proxy_writer, host, port)
  80. if not use_tls:
  81. return proxy_reader, proxy_writer
  82. raw_sock = proxy_writer.get_extra_info("socket")
  83. if raw_sock is None:
  84. raise ConnectionError("SOCKS5 socket unavailable")
  85. dup = raw_sock.dup()
  86. dup.setblocking(False)
  87. proxy_writer.close()
  88. with contextlib.suppress(Exception):
  89. await proxy_writer.wait_closed()
  90. return await asyncio.wait_for(
  91. asyncio.open_connection(ssl=ssl.create_default_context(), sock=dup, server_hostname=host),
  92. timeout=timeout,
  93. )
  94. def percentile(values: list[float], pct: float) -> float:
  95. if not values:
  96. return 0.0
  97. ordered = sorted(values)
  98. if len(ordered) == 1:
  99. return ordered[0]
  100. idx = (len(ordered) - 1) * pct
  101. lo = int(idx)
  102. hi = min(lo + 1, len(ordered) - 1)
  103. weight = idx - lo
  104. return ordered[lo] * (1 - weight) + ordered[hi] * weight
  105. def summarize(values: list[float]) -> dict[str, float]:
  106. if not values:
  107. return {"avg": 0.0, "p50": 0.0, "p95": 0.0, "min": 0.0, "max": 0.0}
  108. return {
  109. "avg": statistics.mean(values),
  110. "p50": percentile(values, 0.50),
  111. "p95": percentile(values, 0.95),
  112. "min": min(values),
  113. "max": max(values),
  114. }
  115. def fmt_ms(value: float) -> str:
  116. return f"{value:.2f}ms"
  117. def fmt_pct(value: float) -> str:
  118. return f"{value:+.1f}%"
  119. def classify_change(diff_pct: float, improvement_threshold: float = 5.0, regression_threshold: float = -5.0) -> str:
  120. if diff_pct <= regression_threshold:
  121. return "提升"
  122. if diff_pct >= improvement_threshold:
  123. return "退化"
  124. return "无明显变化"
  125. def progress_line(done: int, total: int, started_at: float) -> str:
  126. if total <= 0:
  127. return "进度:0%"
  128. elapsed = time.perf_counter() - started_at
  129. ratio = done / total
  130. percent = int(ratio * 100)
  131. if done <= 0:
  132. remain = "估算剩余:--"
  133. else:
  134. total_est = elapsed / ratio
  135. remain = f"估算剩余:{max(0.0, total_est - elapsed):.1f}s"
  136. return f"进度:{done}/{total} ({percent}%),已用 {elapsed:.1f}s,{remain}"
  137. async def run_step_with_heartbeat(step_label: str, total: int, done: int, coro, started_at: float, timeout: float) -> object | None:
  138. task = asyncio.create_task(coro)
  139. print(f" 开始 {step_label}")
  140. step_started = time.perf_counter()
  141. while not task.done():
  142. now = time.perf_counter()
  143. if now - step_started >= timeout:
  144. task.cancel()
  145. with contextlib.suppress(asyncio.CancelledError):
  146. await task
  147. print(f" 超时 {step_label} {progress_line(done, total, started_at)}")
  148. return None
  149. print(f" {step_label} {progress_line(done, total, started_at)}")
  150. await asyncio.sleep(min(1.0, max(0.1, timeout - (now - step_started))))
  151. result = await task
  152. print(f" 完成 {step_label} {progress_line(done, total, started_at)}")
  153. return result
  154. async def run_parallel_steps(step_label: str, total: int, done: int, coros: list[tuple[str, object]], started_at: float, timeout: float) -> list[object | None]:
  155. tasks = [asyncio.create_task(coro) for _, coro in coros]
  156. labels = [label for label, _ in coros]
  157. print(f" 开始 {step_label}")
  158. step_started = time.perf_counter()
  159. while any(not task.done() for task in tasks):
  160. now = time.perf_counter()
  161. if now - step_started >= timeout:
  162. for task in tasks:
  163. if not task.done():
  164. task.cancel()
  165. for task in tasks:
  166. with contextlib.suppress(asyncio.CancelledError):
  167. await task
  168. print(f" 超时 {step_label} {progress_line(done, total, started_at)}")
  169. return [None for _ in tasks]
  170. print(f" {step_label} {progress_line(done, total, started_at)}")
  171. await asyncio.sleep(min(1.0, max(0.1, timeout - (now - step_started))))
  172. results: list[object | None] = []
  173. for label, task in zip(labels, tasks):
  174. result = await task
  175. results.append(result)
  176. print(f" 完成 {step_label} {progress_line(done, total, started_at)}")
  177. return results
  178. def print_section_summary(name: str, direct_avg: float, proxy_avg: float | None, unit: str = "ms") -> None:
  179. if proxy_avg is None:
  180. print(f" {name}: 仅测到直连,均值 {direct_avg:.2f}{unit}")
  181. return
  182. diff = proxy_avg - direct_avg
  183. pct = (diff / direct_avg * 100.0) if direct_avg > 0 else 0.0
  184. verdict = classify_change(pct)
  185. print(f" {name}: 直连 {direct_avg:.2f}{unit},代理 {proxy_avg:.2f}{unit},差值 {diff:+.2f}{unit},变化 {pct:+.1f}%,结论:{verdict}")
  186. def overall_verdict(changes: list[float]) -> str:
  187. if not changes:
  188. return "无可用结果"
  189. score = 0
  190. for change in changes:
  191. if change <= -5.0:
  192. score += 1
  193. elif change >= 5.0:
  194. score -= 1
  195. if score > 0:
  196. return "整体提升"
  197. if score < 0:
  198. return "整体退化"
  199. return "整体无明显变化"
  200. @dataclass
  201. class TcpResult:
  202. connect_ms: float
  203. ttfb_ms: float
  204. total_ms: float
  205. @dataclass
  206. class UdpResult:
  207. associate_ms: float
  208. first_ms: float
  209. total_ms: float
  210. duplicates: int
  211. ok: bool
  212. @dataclass
  213. class HttpResult:
  214. connect_ms: float
  215. ttfb_ms: float
  216. total_ms: float
  217. status: int
  218. ok: bool
  219. error: str = ""
  220. @dataclass
  221. class DnsResult:
  222. query_ms: float
  223. total_ms: float
  224. ok: bool
  225. error: str = ""
  226. async def tcp_echo_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  227. try:
  228. while True:
  229. chunk = await reader.read(65536)
  230. if not chunk:
  231. break
  232. writer.write(chunk)
  233. await writer.drain()
  234. except Exception:
  235. pass
  236. finally:
  237. writer.close()
  238. with contextlib.suppress(Exception):
  239. await writer.wait_closed()
  240. def build_dns_query(name: str, query_id: int | None = None) -> bytes:
  241. if query_id is None:
  242. query_id = random.randint(0, 0xFFFF)
  243. header = query_id.to_bytes(2, "big")
  244. header += b"\x01\x00" # standard query, recursion desired
  245. header += b"\x00\x01" # qdcount
  246. header += b"\x00\x00" # ancount
  247. header += b"\x00\x00" # nscount
  248. header += b"\x00\x00" # arcount
  249. labels = b"".join(bytes([len(part)]) + part.encode() for part in name.strip(".").split(".") if part)
  250. return header + labels + b"\x00" + b"\x00\x01" + b"\x00\x01"
  251. def parse_dns_response(data: bytes) -> tuple[int, bool]:
  252. if len(data) < 12:
  253. raise ValueError("DNS 响应过短")
  254. flags = int.from_bytes(data[2:4], "big")
  255. rcode = flags & 0x000F
  256. qdcount = int.from_bytes(data[4:6], "big")
  257. ancount = int.from_bytes(data[6:8], "big")
  258. return rcode, ancount > 0 or qdcount > 0
  259. async def http_roundtrip(url: str, proxy: tuple[str, int] | None, timeout: float) -> HttpResult:
  260. host, port, path, is_https = parse_url(url)
  261. started = time.perf_counter()
  262. try:
  263. reader, writer = await open_http_connection(host, port, timeout, proxy, is_https)
  264. connect_ms = (time.perf_counter() - started) * 1000
  265. request = (
  266. f"GET {path} HTTP/1.1\r\n"
  267. f"Host: {host}\r\n"
  268. "User-Agent: mynetspeeder-benchmark\r\n"
  269. "Accept: */*\r\n"
  270. "Connection: close\r\n\r\n"
  271. ).encode()
  272. started = time.perf_counter()
  273. writer.write(request)
  274. await writer.drain()
  275. head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=timeout)
  276. first_ms = (time.perf_counter() - started) * 1000
  277. status_line = head.split(b"\r\n", 1)[0].decode(errors="replace")
  278. status = 0
  279. if status_line.startswith("HTTP/"):
  280. parts = status_line.split()
  281. if len(parts) >= 2 and parts[1].isdigit():
  282. status = int(parts[1])
  283. body = await reader.read()
  284. total_ms = (time.perf_counter() - started) * 1000
  285. ok = 200 <= status < 400 and bool(body or head)
  286. return HttpResult(connect_ms, first_ms, total_ms, status, ok)
  287. except Exception:
  288. return HttpResult(0.0, 0.0, 0.0, 0, False, error="http 请求失败")
  289. finally:
  290. with contextlib.suppress(Exception):
  291. writer.close() # type: ignore[name-defined]
  292. await writer.wait_closed() # type: ignore[name-defined]
  293. async def dns_roundtrip(server_host: str, server_port: int, proxy: tuple[str, int] | None, timeout: float) -> DnsResult:
  294. query = build_dns_query("www.google.com")
  295. loop = asyncio.get_running_loop()
  296. started = time.perf_counter()
  297. query_started = started
  298. try:
  299. if proxy is None:
  300. sock = socket.socket(socket.AF_INET6 if ":" in server_host else socket.AF_INET, socket.SOCK_DGRAM)
  301. sock.setblocking(False)
  302. await loop.sock_connect(sock, (server_host, server_port))
  303. try:
  304. await loop.sock_sendall(sock, query)
  305. data = await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=timeout)
  306. finally:
  307. sock.close()
  308. else:
  309. reader, writer, relay, associate_ms = await asyncio.wait_for(socks5_udp_associate(proxy[0], proxy[1]), timeout=timeout)
  310. sock = socket.socket(socket.AF_INET6 if ":" in relay[0] else socket.AF_INET, socket.SOCK_DGRAM)
  311. sock.setblocking(False)
  312. await loop.sock_connect(sock, relay)
  313. try:
  314. packet = b"\x00\x00\x00" + encode_socks_addr(server_host, server_port) + query
  315. await loop.sock_sendall(sock, packet)
  316. data = await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=timeout)
  317. data = strip_socks_udp(data)
  318. finally:
  319. writer.close()
  320. with contextlib.suppress(Exception):
  321. await writer.wait_closed()
  322. sock.close()
  323. query_started = query_started + associate_ms / 1000.0
  324. query_ms = (time.perf_counter() - query_started) * 1000
  325. rcode, ok = parse_dns_response(data)
  326. return DnsResult(query_ms=query_ms, total_ms=(time.perf_counter() - started) * 1000, ok=ok and rcode == 0)
  327. except Exception:
  328. return DnsResult(0.0, 0.0, False, error="dns 查询失败")
  329. async def bench_http(args, proxy: tuple[str, int] | None) -> None:
  330. direct_results: list[HttpResult] = []
  331. proxy_results: list[HttpResult] = []
  332. total_steps = args.count * (2 if proxy is not None else 1)
  333. started_at = time.perf_counter()
  334. print("TCP/HTTPS 正式测试开始")
  335. for index in range(args.count):
  336. step_base = index * (2 if proxy is not None else 1)
  337. if proxy is None:
  338. direct_result = await run_step_with_heartbeat(
  339. f"HTTPS 直连 第{index + 1}步",
  340. total_steps,
  341. step_base + 1,
  342. http_roundtrip(args.http_url, None, args.timeout),
  343. started_at,
  344. timeout=max(1.0, args.timeout + 1.0),
  345. )
  346. if isinstance(direct_result, HttpResult):
  347. direct_results.append(direct_result)
  348. continue
  349. results = await run_parallel_steps(
  350. f"HTTPS 第{index + 1}轮",
  351. total_steps,
  352. step_base + 1,
  353. [
  354. (f"HTTPS 直连 第{index + 1}步", http_roundtrip(args.http_url, None, args.timeout)),
  355. (f"HTTPS 代理 第{index + 1}步", http_roundtrip(args.http_url, proxy, args.timeout)),
  356. ],
  357. started_at,
  358. timeout=max(1.0, args.timeout + 1.0),
  359. )
  360. if isinstance(results[0], HttpResult):
  361. direct_results.append(results[0])
  362. if isinstance(results[1], HttpResult):
  363. proxy_results.append(results[1])
  364. print(f"HTTP 目标: {args.http_url}")
  365. direct_ok = [item for item in direct_results if item.ok]
  366. print(f" 直连: 成功 {len(direct_ok)}/{len(direct_results)},首包均值 {fmt_ms(summarize([item.ttfb_ms for item in direct_ok])['avg'])},总耗时均值 {fmt_ms(summarize([item.total_ms for item in direct_ok])['avg'])}")
  367. if direct_results and not direct_ok:
  368. print(f" 直连失败原因: {direct_results[0].error or '未知'}")
  369. if proxy is None or not proxy_results:
  370. print(" 代理: 未执行或不可用")
  371. return
  372. proxy_ok = [item for item in proxy_results if item.ok]
  373. direct_ttfb = summarize([item.ttfb_ms for item in direct_ok])["avg"]
  374. proxy_ttfb = summarize([item.ttfb_ms for item in proxy_ok])["avg"]
  375. direct_total = summarize([item.total_ms for item in direct_ok])["avg"]
  376. proxy_total = summarize([item.total_ms for item in proxy_ok])["avg"]
  377. ttfb_pct = ((proxy_ttfb - direct_ttfb) / direct_ttfb * 100.0) if direct_ttfb > 0 else 0.0
  378. total_pct = ((proxy_total - direct_total) / direct_total * 100.0) if direct_total > 0 else 0.0
  379. print(f" 代理: 成功 {len(proxy_ok)}/{len(proxy_results)},首包均值 {fmt_ms(proxy_ttfb)},总耗时均值 {fmt_ms(proxy_total)}")
  380. print(f" 对比: 首包变化 {fmt_pct(ttfb_pct)},总耗时变化 {fmt_pct(total_pct)},结论:{classify_change(total_pct)}")
  381. async def bench_dns(args, proxy: tuple[str, int] | None) -> None:
  382. direct_results: list[DnsResult] = []
  383. proxy_results: list[DnsResult] = []
  384. total_steps = args.count * (2 if proxy is not None else 1)
  385. started_at = time.perf_counter()
  386. print("UDP/DNS 正式测试开始")
  387. for index in range(args.count):
  388. step_base = index * (2 if proxy is not None else 1)
  389. if proxy is None:
  390. direct_result = await run_step_with_heartbeat(
  391. f"DNS 直连 第{index + 1}步",
  392. total_steps,
  393. step_base + 1,
  394. dns_roundtrip(args.dns_server_host, args.dns_server_port, None, args.timeout),
  395. started_at,
  396. timeout=max(1.0, args.timeout + 1.0),
  397. )
  398. if isinstance(direct_result, DnsResult):
  399. direct_results.append(direct_result)
  400. continue
  401. results = await run_parallel_steps(
  402. f"DNS 第{index + 1}轮",
  403. total_steps,
  404. step_base + 1,
  405. [
  406. (f"DNS 直连 第{index + 1}步", dns_roundtrip(args.dns_server_host, args.dns_server_port, None, args.timeout)),
  407. (f"DNS 代理 第{index + 1}步", dns_roundtrip(args.dns_server_host, args.dns_server_port, proxy, args.timeout)),
  408. ],
  409. started_at,
  410. timeout=max(1.0, args.timeout + 1.0),
  411. )
  412. if isinstance(results[0], DnsResult):
  413. direct_results.append(results[0])
  414. if isinstance(results[1], DnsResult):
  415. proxy_results.append(results[1])
  416. direct_ok = [item for item in direct_results if item.ok]
  417. print(f"DNS 目标: {args.dns_server_host}:{args.dns_server_port}")
  418. print(f" 直连: 成功 {len(direct_ok)}/{len(direct_results)},查询均值 {fmt_ms(summarize([item.query_ms for item in direct_ok])['avg'])}")
  419. if direct_results and not direct_ok:
  420. print(f" 直连失败原因: {direct_results[0].error or '未知'}")
  421. if proxy is None or not proxy_results:
  422. print(" 代理: 未执行或不可用")
  423. return
  424. proxy_ok = [item for item in proxy_results if item.ok]
  425. direct_query = summarize([item.query_ms for item in direct_ok])["avg"]
  426. proxy_query = summarize([item.query_ms for item in proxy_ok])["avg"]
  427. delta = proxy_query - direct_query
  428. pct = (delta / direct_query * 100.0) if direct_query > 0 else 0.0
  429. print(f" 代理: 成功 {len(proxy_ok)}/{len(proxy_results)},查询均值 {fmt_ms(proxy_query)}")
  430. print(f" 对比: DNS 查询变化 {fmt_pct(pct)},结论:{classify_change(pct)}")
  431. async def bench_tcp(args, target: tuple[str, int], proxy: tuple[str, int] | None) -> None:
  432. direct_results: list[TcpResult] = []
  433. proxy_results: list[TcpResult] = []
  434. warmup_steps = args.warmup * (2 if proxy is not None else 1)
  435. async def warmup_runner(step: int) -> None:
  436. if proxy is None:
  437. with contextlib.suppress(Exception):
  438. await tcp_direct_roundtrip(target[0], target[1], args.payload_size, args.timeout)
  439. return
  440. if step % 2 == 1:
  441. with contextlib.suppress(Exception):
  442. await tcp_direct_roundtrip(target[0], target[1], args.payload_size, args.timeout)
  443. else:
  444. with contextlib.suppress(Exception):
  445. await tcp_proxy_roundtrip(proxy[0], proxy[1], target[0], target[1], args.payload_size, args.timeout)
  446. if warmup_steps > 0:
  447. print("TCP 预热开始")
  448. for step in range(1, warmup_steps + 1):
  449. await warmup_runner(step)
  450. print(f" 预热进度:{step}/{warmup_steps}")
  451. total_steps = args.count * (2 if proxy is not None else 1)
  452. started_at = time.perf_counter()
  453. print("TCP 正式测试开始")
  454. for index in range(args.count):
  455. step_base = index * (2 if proxy is not None else 1)
  456. if proxy is None:
  457. direct_result = await run_step_with_heartbeat(
  458. f"TCP 直连 第{index + 1}步",
  459. total_steps,
  460. step_base + 1,
  461. tcp_direct_roundtrip(target[0], target[1], args.payload_size, args.timeout),
  462. started_at,
  463. timeout=max(1.0, args.timeout + 1.0),
  464. )
  465. if isinstance(direct_result, TcpResult):
  466. direct_results.append(direct_result)
  467. continue
  468. results = await run_parallel_steps(
  469. f"TCP 第{index + 1}轮",
  470. total_steps,
  471. step_base + 1,
  472. [
  473. (f"TCP 直连 第{index + 1}步", tcp_direct_roundtrip(target[0], target[1], args.payload_size, args.timeout)),
  474. (f"TCP 代理 第{index + 1}步", tcp_proxy_roundtrip(proxy[0], proxy[1], target[0], target[1], args.payload_size, args.timeout)),
  475. ],
  476. started_at,
  477. timeout=max(1.0, args.timeout + 1.0),
  478. )
  479. if isinstance(results[0], TcpResult):
  480. direct_results.append(results[0])
  481. if isinstance(results[1], TcpResult):
  482. proxy_results.append(results[1])
  483. direct = {
  484. "connect": summarize([item.connect_ms for item in direct_results]),
  485. "ttfb": summarize([item.ttfb_ms for item in direct_results]),
  486. "total": summarize([item.total_ms for item in direct_results]),
  487. }
  488. print(f"TCP 目标: {target[0]}:{target[1]}")
  489. print(f" 直连: 成功 {len(direct_results)}/{len(direct_results)},连接均值 {fmt_ms(direct['connect']['avg'])},首包均值 {fmt_ms(direct['ttfb']['avg'])},总耗时均值 {fmt_ms(direct['total']['avg'])}")
  490. print(f" 直连: 连接 p50/p95 {fmt_ms(direct['connect']['p50'])} / {fmt_ms(direct['connect']['p95'])},总耗时 p50/p95 {fmt_ms(direct['total']['p50'])} / {fmt_ms(direct['total']['p95'])}")
  491. if proxy is None or not proxy_results:
  492. print(" 代理: 未执行或不可用")
  493. return
  494. proxy_stats = {
  495. "connect": summarize([item.connect_ms for item in proxy_results]),
  496. "ttfb": summarize([item.ttfb_ms for item in proxy_results]),
  497. "total": summarize([item.total_ms for item in proxy_results]),
  498. }
  499. diff = proxy_stats["total"]["avg"] - direct["total"]["avg"]
  500. pct = (diff / direct["total"]["avg"] * 100.0) if direct["total"]["avg"] > 0 else 0.0
  501. print(f" 代理: 成功 {len(proxy_results)}/{len(proxy_results)},连接均值 {fmt_ms(proxy_stats['connect']['avg'])},首包均值 {fmt_ms(proxy_stats['ttfb']['avg'])},总耗时均值 {fmt_ms(proxy_stats['total']['avg'])}")
  502. print(f" 代理: 连接 p50/p95 {fmt_ms(proxy_stats['connect']['p50'])} / {fmt_ms(proxy_stats['connect']['p95'])},总耗时 p50/p95 {fmt_ms(proxy_stats['total']['p50'])} / {fmt_ms(proxy_stats['total']['p95'])}")
  503. print(f" 对比: 代理总耗时相对直连 {fmt_ms(diff)},变化 {fmt_pct(pct)},结论:{classify_change(pct)}")
  504. print_section_summary("连接耗时", direct["connect"]["avg"], proxy_stats["connect"]["avg"])
  505. print_section_summary("首包耗时", direct["ttfb"]["avg"], proxy_stats["ttfb"]["avg"])
  506. print_section_summary("总耗时", direct["total"]["avg"], proxy_stats["total"]["avg"])
  507. print(f" TCP 总结:{overall_verdict([pct])}")
  508. async def bench_udp(args, target: tuple[str, int], proxy: tuple[str, int] | None) -> None:
  509. direct_results: list[UdpResult] = []
  510. proxy_results: list[UdpResult] = []
  511. warmup_steps = args.warmup * (2 if proxy is not None else 1)
  512. async def warmup_runner(step: int) -> None:
  513. if proxy is None:
  514. with contextlib.suppress(Exception):
  515. await udp_direct_roundtrip(target[0], target[1], args.payload_size, args.timeout)
  516. return
  517. if step % 2 == 1:
  518. with contextlib.suppress(Exception):
  519. await udp_direct_roundtrip(target[0], target[1], args.payload_size, args.timeout)
  520. else:
  521. with contextlib.suppress(Exception):
  522. await udp_proxy_roundtrip(proxy[0], proxy[1], target[0], target[1], args.payload_size, args.timeout)
  523. if warmup_steps > 0:
  524. print("UDP 预热开始")
  525. for step in range(1, warmup_steps + 1):
  526. await warmup_runner(step)
  527. print(f" 预热进度:{step}/{warmup_steps}")
  528. total_steps = args.count * (2 if proxy is not None else 1)
  529. started_at = time.perf_counter()
  530. print("UDP 正式测试开始")
  531. for index in range(args.count):
  532. step_base = index * (2 if proxy is not None else 1)
  533. if proxy is None:
  534. direct_result = await run_step_with_heartbeat(
  535. f"UDP 直连 第{index + 1}步",
  536. total_steps,
  537. step_base + 1,
  538. udp_direct_roundtrip(target[0], target[1], args.payload_size, args.timeout),
  539. started_at,
  540. timeout=max(1.0, args.timeout + 1.0),
  541. )
  542. if isinstance(direct_result, UdpResult):
  543. direct_results.append(direct_result)
  544. continue
  545. results = await run_parallel_steps(
  546. f"UDP 第{index + 1}轮",
  547. total_steps,
  548. step_base + 1,
  549. [
  550. (f"UDP 直连 第{index + 1}步", udp_direct_roundtrip(target[0], target[1], args.payload_size, args.timeout)),
  551. (f"UDP 代理 第{index + 1}步", udp_proxy_roundtrip(proxy[0], proxy[1], target[0], target[1], args.payload_size, args.timeout)),
  552. ],
  553. started_at,
  554. timeout=max(1.0, args.timeout + 1.0),
  555. )
  556. if isinstance(results[0], UdpResult):
  557. direct_results.append(results[0])
  558. if isinstance(results[1], UdpResult):
  559. proxy_results.append(results[1])
  560. direct_ok = [item for item in direct_results if item.ok]
  561. print(f"UDP 目标: {target[0]}:{target[1]}")
  562. print(f" 直连: 成功 {len(direct_ok)}/{len(direct_results)},首包均值 {fmt_ms(summarize([item.first_ms for item in direct_ok])['avg'])},重复包 {sum(item.duplicates for item in direct_ok)}")
  563. if proxy is None or not proxy_results:
  564. print(" 代理: 未执行或不可用")
  565. return
  566. proxy_ok = [item for item in proxy_results if item.ok]
  567. proxy_first = summarize([item.first_ms for item in proxy_ok])["avg"]
  568. direct_first = summarize([item.first_ms for item in direct_ok])["avg"]
  569. delta = proxy_first - direct_first
  570. pct = (delta / direct_first * 100.0) if direct_first > 0 else 0.0
  571. print(f" 代理: 成功 {len(proxy_ok)}/{len(proxy_results)},关联均值 {fmt_ms(summarize([item.associate_ms for item in proxy_ok])['avg'])},首包均值 {fmt_ms(proxy_first)},重复包 {sum(item.duplicates for item in proxy_ok)}")
  572. print(f" 对比: 代理首包相对直连 {fmt_ms(delta)},变化 {fmt_pct(pct)},结论:{classify_change(pct)}")
  573. print(f" UDP 总结:{overall_verdict([pct])}")
  574. async def amain(args) -> int:
  575. config = Config.load(args.config) if args.config and Path(args.config).exists() else None
  576. proxy: tuple[str, int] | None = None
  577. if config is not None and config.socks_port > 0:
  578. proxy = (config.socks_host, config.socks_port)
  579. if args.proxy_host and args.proxy_port:
  580. proxy = (args.proxy_host, args.proxy_port)
  581. print("本地基准测试开始")
  582. print(f" 样本数: {args.count},热身: {args.warmup},载荷: {args.payload_size} 字节")
  583. if proxy is not None:
  584. print(f" 代理: {proxy[0]}:{proxy[1]}")
  585. else:
  586. print(" 代理: 未配置或未启动,将只测直连")
  587. print(f" HTTP 目标: {args.http_url}")
  588. print(f" DNS 目标: {args.dns_server_host}:{args.dns_server_port}")
  589. print("")
  590. if args.mode in ("tcp", "all"):
  591. await bench_http(args, proxy)
  592. print("")
  593. if args.mode in ("udp", "all"):
  594. await bench_dns(args, proxy)
  595. return 0
  596. def build_parser() -> argparse.ArgumentParser:
  597. parser = argparse.ArgumentParser(description="mynetspeeder 本地手工基线测试")
  598. parser.add_argument("--config", default="/home/mynetspeeder/config.json", help="配置文件路径,用于读取默认 socks 端口")
  599. parser.add_argument("--proxy-host", default="", help="SOCKS5 代理地址,默认读取 config.json")
  600. parser.add_argument("--proxy-port", type=int, default=0, help="SOCKS5 代理端口,默认读取 config.json")
  601. parser.add_argument("--http-url", default="https://research.google/blog/", help="HTTPS 测试 URL")
  602. parser.add_argument("--dns-server", default="8.8.8.8:53", help="DNS 测试服务器,格式 host:port")
  603. parser.add_argument("--mode", choices=("tcp", "udp", "all"), default="all", help="只测 TCP、只测 UDP 或都测")
  604. parser.add_argument("--count", type=int, default=8, help="正式样本数,默认 8,尽量控制在 2-5 分钟内完成")
  605. parser.add_argument("--warmup", type=int, default=0, help="热身轮数,默认 0;仅用于平滑首次连接抖动")
  606. parser.add_argument("--payload-size", type=int, default=4096, help="每次请求的载荷大小")
  607. parser.add_argument("--timeout", type=float, default=2.0, help="单次请求超时秒数,默认 2.0")
  608. return parser
  609. def main() -> int:
  610. args = build_parser().parse_args()
  611. args.dns_server_host, args.dns_server_port = parse_dns_target(args.dns_server)
  612. return asyncio.run(amain(args))
  613. if __name__ == "__main__":
  614. raise SystemExit(main())