scorecard.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. #***************************************************************************
  4. # _ _ ____ _
  5. # Project ___| | | | _ \| |
  6. # / __| | | | |_) | |
  7. # | (__| |_| | _ <| |___
  8. # \___|\___/|_| \_\_____|
  9. #
  10. # Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
  11. #
  12. # This software is licensed as described in the file COPYING, which
  13. # you should have received as part of this distribution. The terms
  14. # are also available at https://curl.se/docs/copyright.html.
  15. #
  16. # You may opt to use, copy, modify, merge, publish, distribute and/or sell
  17. # copies of the Software, and permit persons to whom the Software is
  18. # furnished to do so, under the terms of the COPYING file.
  19. #
  20. # This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
  21. # KIND, either express or implied.
  22. #
  23. # SPDX-License-Identifier: curl
  24. #
  25. ###########################################################################
  26. #
  27. import argparse
  28. import json
  29. import logging
  30. import os
  31. import re
  32. import sys
  33. from statistics import mean
  34. from typing import Dict, Any, Optional, List
  35. from testenv import Env, Httpd, Nghttpx, CurlClient, Caddy, ExecResult
  36. log = logging.getLogger(__name__)
  37. class ScoreCardException(Exception):
  38. pass
  39. class ScoreCard:
  40. def __init__(self, env: Env,
  41. httpd: Optional[Httpd],
  42. nghttpx: Optional[Nghttpx],
  43. caddy: Optional[Caddy],
  44. verbose: int,
  45. curl_verbose: int):
  46. self.verbose = verbose
  47. self.env = env
  48. self.httpd = httpd
  49. self.nghttpx = nghttpx
  50. self.caddy = caddy
  51. self._silent_curl = not curl_verbose
  52. def info(self, msg):
  53. if self.verbose > 0:
  54. sys.stderr.write(msg)
  55. sys.stderr.flush()
  56. def handshakes(self, proto: str) -> Dict[str, Any]:
  57. props = {}
  58. sample_size = 5
  59. self.info(f'TLS Handshake\n')
  60. for authority in [
  61. f'{self.env.authority_for(self.env.domain1, proto)}'
  62. ]:
  63. self.info(' localhost...')
  64. c_samples = []
  65. hs_samples = []
  66. errors = []
  67. for i in range(sample_size):
  68. curl = CurlClient(env=self.env, silent=self._silent_curl)
  69. url = f'https://{authority}/'
  70. r = curl.http_download(urls=[url], alpn_proto=proto,
  71. no_save=True)
  72. if r.exit_code == 0 and len(r.stats) == 1:
  73. c_samples.append(r.stats[0]['time_connect'])
  74. hs_samples.append(r.stats[0]['time_appconnect'])
  75. else:
  76. errors.append(f'exit={r.exit_code}')
  77. props['localhost'] = {
  78. 'ipv4-connect': mean(c_samples),
  79. 'ipv4-handshake': mean(hs_samples),
  80. 'ipv4-errors': errors,
  81. 'ipv6-connect': 0,
  82. 'ipv6-handshake': 0,
  83. 'ipv6-errors': [],
  84. }
  85. self.info('ok.\n')
  86. for authority in [
  87. 'curl.se', 'nghttp2.org',
  88. ]:
  89. self.info(f' {authority}...')
  90. props[authority] = {}
  91. for ipv in ['ipv4', 'ipv6']:
  92. self.info(f'{ipv}...')
  93. c_samples = []
  94. hs_samples = []
  95. errors = []
  96. for i in range(sample_size):
  97. curl = CurlClient(env=self.env, silent=self._silent_curl)
  98. args = [
  99. '--http3-only' if proto == 'h3' else '--http2',
  100. f'--{ipv}', f'https://{authority}/'
  101. ]
  102. r = curl.run_direct(args=args, with_stats=True)
  103. if r.exit_code == 0 and len(r.stats) == 1:
  104. c_samples.append(r.stats[0]['time_connect'])
  105. hs_samples.append(r.stats[0]['time_appconnect'])
  106. else:
  107. errors.append(f'exit={r.exit_code}')
  108. props[authority][f'{ipv}-connect'] = mean(c_samples) \
  109. if len(c_samples) else -1
  110. props[authority][f'{ipv}-handshake'] = mean(hs_samples) \
  111. if len(hs_samples) else -1
  112. props[authority][f'{ipv}-errors'] = errors
  113. self.info('ok.\n')
  114. return props
  115. def _make_docs_file(self, docs_dir: str, fname: str, fsize: int):
  116. fpath = os.path.join(docs_dir, fname)
  117. data1k = 1024*'x'
  118. flen = 0
  119. with open(fpath, 'w') as fd:
  120. while flen < fsize:
  121. fd.write(data1k)
  122. flen += len(data1k)
  123. return flen
  124. def _check_downloads(self, r: ExecResult, count: int):
  125. error = ''
  126. if r.exit_code != 0:
  127. error += f'exit={r.exit_code} '
  128. if r.exit_code != 0 or len(r.stats) != count:
  129. error += f'stats={len(r.stats)}/{count} '
  130. fails = [s for s in r.stats if s['response_code'] != 200]
  131. if len(fails) > 0:
  132. error += f'{len(fails)} failed'
  133. return error if len(error) > 0 else None
  134. def transfer_single(self, url: str, proto: str, count: int):
  135. sample_size = count
  136. count = 1
  137. samples = []
  138. errors = []
  139. self.info(f'single...')
  140. for i in range(sample_size):
  141. curl = CurlClient(env=self.env, silent=self._silent_curl)
  142. r = curl.http_download(urls=[url], alpn_proto=proto, no_save=True,
  143. with_headers=False)
  144. err = self._check_downloads(r, count)
  145. if err:
  146. errors.append(err)
  147. else:
  148. total_size = sum([s['size_download'] for s in r.stats])
  149. samples.append(total_size / r.duration.total_seconds())
  150. return {
  151. 'count': count,
  152. 'samples': sample_size,
  153. 'speed': mean(samples) if len(samples) else -1,
  154. 'errors': errors
  155. }
  156. def transfer_serial(self, url: str, proto: str, count: int):
  157. sample_size = 1
  158. samples = []
  159. errors = []
  160. url = f'{url}?[0-{count - 1}]'
  161. self.info(f'serial...')
  162. for i in range(sample_size):
  163. curl = CurlClient(env=self.env, silent=self._silent_curl)
  164. r = curl.http_download(urls=[url], alpn_proto=proto, no_save=True,
  165. with_headers=False)
  166. err = self._check_downloads(r, count)
  167. if err:
  168. errors.append(err)
  169. else:
  170. total_size = sum([s['size_download'] for s in r.stats])
  171. samples.append(total_size / r.duration.total_seconds())
  172. return {
  173. 'count': count,
  174. 'samples': sample_size,
  175. 'speed': mean(samples) if len(samples) else -1,
  176. 'errors': errors
  177. }
  178. def transfer_parallel(self, url: str, proto: str, count: int):
  179. sample_size = 1
  180. samples = []
  181. errors = []
  182. url = f'{url}?[0-{count - 1}]'
  183. self.info(f'parallel...')
  184. for i in range(sample_size):
  185. curl = CurlClient(env=self.env, silent=self._silent_curl)
  186. r = curl.http_download(urls=[url], alpn_proto=proto, no_save=True,
  187. with_headers=False,
  188. extra_args=['--parallel',
  189. '--parallel-max', str(count)])
  190. err = self._check_downloads(r, count)
  191. if err:
  192. errors.append(err)
  193. else:
  194. total_size = sum([s['size_download'] for s in r.stats])
  195. samples.append(total_size / r.duration.total_seconds())
  196. return {
  197. 'count': count,
  198. 'samples': sample_size,
  199. 'speed': mean(samples) if len(samples) else -1,
  200. 'errors': errors
  201. }
  202. def download_url(self, label: str, url: str, proto: str, count: int):
  203. self.info(f' {count}x{label}: ')
  204. props = {
  205. 'single': self.transfer_single(url=url, proto=proto, count=10),
  206. 'serial': self.transfer_serial(url=url, proto=proto, count=count),
  207. 'parallel': self.transfer_parallel(url=url, proto=proto,
  208. count=count),
  209. }
  210. self.info(f'ok.\n')
  211. return props
  212. def downloads(self, proto: str, count: int,
  213. fsizes: List[int]) -> Dict[str, Any]:
  214. scores = {}
  215. if self.httpd:
  216. if proto == 'h3':
  217. port = self.env.h3_port
  218. via = 'nghttpx'
  219. descr = f'port {port}, proxying httpd'
  220. else:
  221. port = self.env.https_port
  222. via = 'httpd'
  223. descr = f'port {port}'
  224. self.info(f'{via} downloads\n')
  225. scores[via] = {
  226. 'description': descr,
  227. }
  228. for fsize in fsizes:
  229. label = f'{int(fsize / 1024)}KB' if fsize < 1024*1024 else \
  230. f'{int(fsize / (1024 * 1024))}MB'
  231. fname = f'score{label}.data'
  232. self._make_docs_file(docs_dir=self.httpd.docs_dir,
  233. fname=fname, fsize=fsize)
  234. url = f'https://{self.env.domain1}:{port}/{fname}'
  235. results = self.download_url(label=label, url=url,
  236. proto=proto, count=count)
  237. scores[via][label] = results
  238. if self.caddy:
  239. port = self.caddy.port
  240. via = 'caddy'
  241. descr = f'port {port}'
  242. self.info('caddy downloads\n')
  243. scores[via] = {
  244. 'description': descr,
  245. }
  246. for fsize in fsizes:
  247. label = f'{int(fsize / 1024)}KB' if fsize < 1024*1024 else \
  248. f'{int(fsize / (1024 * 1024))}MB'
  249. fname = f'score{label}.data'
  250. self._make_docs_file(docs_dir=self.caddy.docs_dir,
  251. fname=fname, fsize=fsize)
  252. url = f'https://{self.env.domain1}:{port}/{fname}'
  253. results = self.download_url(label=label, url=url,
  254. proto=proto, count=count)
  255. scores[via][label] = results
  256. return scores
  257. def do_requests(self, url: str, proto: str, count: int,
  258. max_parallel: int = 1):
  259. sample_size = 1
  260. samples = []
  261. errors = []
  262. url = f'{url}?[0-{count - 1}]'
  263. extra_args = ['--parallel', '--parallel-max', str(max_parallel)] \
  264. if max_parallel > 1 else []
  265. self.info(f'{max_parallel}...')
  266. for i in range(sample_size):
  267. curl = CurlClient(env=self.env, silent=self._silent_curl)
  268. r = curl.http_download(urls=[url], alpn_proto=proto, no_save=True,
  269. with_headers=False,
  270. extra_args=extra_args)
  271. err = self._check_downloads(r, count)
  272. if err:
  273. errors.append(err)
  274. else:
  275. for _ in r.stats:
  276. samples.append(count / r.duration.total_seconds())
  277. return {
  278. 'count': count,
  279. 'samples': sample_size,
  280. 'speed': mean(samples) if len(samples) else -1,
  281. 'errors': errors
  282. }
  283. def requests_url(self, url: str, proto: str, count: int):
  284. self.info(f' {url}: ')
  285. props = {
  286. 'serial': self.do_requests(url=url, proto=proto, count=count),
  287. 'par-6': self.do_requests(url=url, proto=proto, count=count,
  288. max_parallel=6),
  289. 'par-25': self.do_requests(url=url, proto=proto, count=count,
  290. max_parallel=25),
  291. 'par-50': self.do_requests(url=url, proto=proto, count=count,
  292. max_parallel=50),
  293. 'par-100': self.do_requests(url=url, proto=proto, count=count,
  294. max_parallel=100),
  295. }
  296. self.info(f'ok.\n')
  297. return props
  298. def requests(self, proto: str) -> Dict[str, Any]:
  299. scores = {}
  300. if self.httpd:
  301. if proto == 'h3':
  302. port = self.env.h3_port
  303. via = 'nghttpx'
  304. descr = f'port {port}, proxying httpd'
  305. else:
  306. port = self.env.https_port
  307. via = 'httpd'
  308. descr = f'port {port}'
  309. self.info(f'{via} requests\n')
  310. self._make_docs_file(docs_dir=self.httpd.docs_dir,
  311. fname='reqs10.data', fsize=10*1024)
  312. url1 = f'https://{self.env.domain1}:{port}/reqs10.data'
  313. scores[via] = {
  314. 'description': descr,
  315. '10KB': self.requests_url(url=url1, proto=proto, count=10000),
  316. }
  317. if self.caddy:
  318. port = self.caddy.port
  319. via = 'caddy'
  320. descr = f'port {port}'
  321. self.info('caddy requests\n')
  322. self._make_docs_file(docs_dir=self.caddy.docs_dir,
  323. fname='req10.data', fsize=10 * 1024)
  324. url1 = f'https://{self.env.domain1}:{port}/req10.data'
  325. scores[via] = {
  326. 'description': descr,
  327. '10KB': self.requests_url(url=url1, proto=proto, count=5000),
  328. }
  329. return scores
  330. def score_proto(self, proto: str,
  331. handshakes: bool = True,
  332. downloads: Optional[List[int]] = None,
  333. download_count: int = 50,
  334. requests: bool = True):
  335. self.info(f"scoring {proto}\n")
  336. p = {}
  337. if proto == 'h3':
  338. p['name'] = 'h3'
  339. if not self.env.have_h3_curl():
  340. raise ScoreCardException('curl does not support HTTP/3')
  341. for lib in ['ngtcp2', 'quiche', 'msh3']:
  342. if self.env.curl_uses_lib(lib):
  343. p['implementation'] = lib
  344. break
  345. elif proto == 'h2':
  346. p['name'] = 'h2'
  347. if not self.env.have_h2_curl():
  348. raise ScoreCardException('curl does not support HTTP/2')
  349. for lib in ['nghttp2', 'hyper']:
  350. if self.env.curl_uses_lib(lib):
  351. p['implementation'] = lib
  352. break
  353. elif proto == 'h1' or proto == 'http/1.1':
  354. proto = 'http/1.1'
  355. p['name'] = proto
  356. p['implementation'] = 'hyper' if self.env.curl_uses_lib('hyper')\
  357. else 'native'
  358. else:
  359. raise ScoreCardException(f"unknown protocol: {proto}")
  360. if 'implementation' not in p:
  361. raise ScoreCardException(f'did not recognized {p} lib')
  362. p['version'] = Env.curl_lib_version(p['implementation'])
  363. score = {
  364. 'curl': self.env.curl_fullname(),
  365. 'os': self.env.curl_os(),
  366. 'protocol': p,
  367. }
  368. if handshakes:
  369. score['handshakes'] = self.handshakes(proto=proto)
  370. if downloads and len(downloads) > 0:
  371. score['downloads'] = self.downloads(proto=proto,
  372. count=download_count,
  373. fsizes=downloads)
  374. if requests:
  375. score['requests'] = self.requests(proto=proto)
  376. self.info("\n")
  377. return score
  378. def fmt_ms(self, tval):
  379. return f'{int(tval*1000)} ms' if tval >= 0 else '--'
  380. def fmt_mb(self, val):
  381. return f'{val/(1024*1024):0.000f} MB' if val >= 0 else '--'
  382. def fmt_mbs(self, val):
  383. return f'{val/(1024*1024):0.000f} MB/s' if val >= 0 else '--'
  384. def fmt_reqs(self, val):
  385. return f'{val:0.000f} r/s' if val >= 0 else '--'
  386. def print_score(self, score):
  387. print(f'{score["protocol"]["name"].upper()} in {score["curl"]}')
  388. if 'handshakes' in score:
  389. print(f'{"Handshakes":<24} {"ipv4":25} {"ipv6":28}')
  390. print(f' {"Host":<17} {"Connect":>12} {"Handshake":>12} '
  391. f'{"Connect":>12} {"Handshake":>12} {"Errors":<20}')
  392. for key, val in score["handshakes"].items():
  393. print(f' {key:<17} {self.fmt_ms(val["ipv4-connect"]):>12} '
  394. f'{self.fmt_ms(val["ipv4-handshake"]):>12} '
  395. f'{self.fmt_ms(val["ipv6-connect"]):>12} '
  396. f'{self.fmt_ms(val["ipv6-handshake"]):>12} '
  397. f'{"/".join(val["ipv4-errors"] + val["ipv6-errors"]):<20}'
  398. )
  399. if 'downloads' in score:
  400. print('Downloads')
  401. print(f' {"Server":<8} {"Size":>8} {"Single":>12} {"Serial":>12}'
  402. f' {"Parallel":>12} {"Errors":<20}')
  403. skeys = {}
  404. for dkey, dval in score["downloads"].items():
  405. for k in dval.keys():
  406. skeys[k] = True
  407. for skey in skeys:
  408. for dkey, dval in score["downloads"].items():
  409. if skey in dval:
  410. sval = dval[skey]
  411. if isinstance(sval, str):
  412. continue
  413. errors = []
  414. for key, val in sval.items():
  415. if 'errors' in val:
  416. errors.extend(val['errors'])
  417. print(f' {dkey:<8} {skey:>8} '
  418. f'{self.fmt_mbs(sval["single"]["speed"]):>12} '
  419. f'{self.fmt_mbs(sval["serial"]["speed"]):>12} '
  420. f'{self.fmt_mbs(sval["parallel"]["speed"]):>12} '
  421. f' {"/".join(errors):<20}')
  422. if 'requests' in score:
  423. print('Requests, max in parallel')
  424. print(f' {"Server":<8} {"Size":>8} '
  425. f'{"1 ":>12} {"6 ":>12} {"25 ":>12} '
  426. f'{"50 ":>12} {"100 ":>12} {"Errors":<20}')
  427. for dkey, dval in score["requests"].items():
  428. for skey, sval in dval.items():
  429. if isinstance(sval, str):
  430. continue
  431. errors = []
  432. for key, val in sval.items():
  433. if 'errors' in val:
  434. errors.extend(val['errors'])
  435. line = f' {dkey:<8} {skey:>8} '
  436. for k in sval.keys():
  437. line += f'{self.fmt_reqs(sval[k]["speed"]):>12} '
  438. line += f' {"/".join(errors):<20}'
  439. print(line)
  440. def parse_size(s):
  441. m = re.match(r'(\d+)(mb|kb|gb)?', s, re.IGNORECASE)
  442. if m is None:
  443. raise Exception(f'unrecognized size: {s}')
  444. size = int(m.group(1))
  445. if m.group(2).lower() == 'kb':
  446. size *= 1024
  447. elif m.group(2).lower() == 'mb':
  448. size *= 1024 * 1024
  449. elif m.group(2).lower() == 'gb':
  450. size *= 1024 * 1024 * 1024
  451. return size
  452. def main():
  453. parser = argparse.ArgumentParser(prog='scorecard', description="""
  454. Run a range of tests to give a scorecard for a HTTP protocol
  455. 'h3' or 'h2' implementation in curl.
  456. """)
  457. parser.add_argument("-v", "--verbose", action='count', default=1,
  458. help="log more output on stderr")
  459. parser.add_argument("-j", "--json", action='store_true',
  460. default=False, help="print json instead of text")
  461. parser.add_argument("-H", "--handshakes", action='store_true',
  462. default=False, help="evaluate handshakes only")
  463. parser.add_argument("-d", "--downloads", action='store_true',
  464. default=False, help="evaluate downloads only")
  465. parser.add_argument("--download", action='append', type=str,
  466. default=None, help="evaluate download size")
  467. parser.add_argument("--download-count", action='store', type=int,
  468. default=50, help="perform that many downloads")
  469. parser.add_argument("-r", "--requests", action='store_true',
  470. default=False, help="evaluate requests only")
  471. parser.add_argument("--httpd", action='store_true', default=False,
  472. help="evaluate httpd server only")
  473. parser.add_argument("--caddy", action='store_true', default=False,
  474. help="evaluate caddy server only")
  475. parser.add_argument("--curl-verbose", action='store_true',
  476. default=False, help="run curl with `-v`")
  477. parser.add_argument("protocol", default='h2', nargs='?',
  478. help="Name of protocol to score")
  479. args = parser.parse_args()
  480. if args.verbose > 0:
  481. console = logging.StreamHandler()
  482. console.setLevel(logging.INFO)
  483. console.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
  484. logging.getLogger('').addHandler(console)
  485. protocol = args.protocol
  486. handshakes = True
  487. downloads = [1024*1024, 10*1024*1024, 100*1024*1024]
  488. requests = True
  489. test_httpd = protocol != 'h3'
  490. test_caddy = True
  491. if args.handshakes:
  492. downloads = None
  493. requests = False
  494. if args.downloads:
  495. handshakes = False
  496. requests = False
  497. if args.download:
  498. downloads = sorted([parse_size(x) for x in args.download])
  499. handshakes = False
  500. requests = False
  501. if args.requests:
  502. handshakes = False
  503. downloads = None
  504. if args.caddy:
  505. test_caddy = True
  506. test_httpd = False
  507. if args.httpd:
  508. test_caddy = False
  509. test_httpd = True
  510. rv = 0
  511. env = Env()
  512. env.setup()
  513. env.test_timeout = None
  514. httpd = None
  515. nghttpx = None
  516. caddy = None
  517. try:
  518. if test_httpd:
  519. httpd = Httpd(env=env)
  520. assert httpd.exists(), \
  521. f'httpd not found: {env.httpd}'
  522. httpd.clear_logs()
  523. assert httpd.start()
  524. if 'h3' == protocol:
  525. nghttpx = Nghttpx(env=env)
  526. nghttpx.clear_logs()
  527. assert nghttpx.start()
  528. if test_caddy and env.caddy:
  529. caddy = Caddy(env=env)
  530. caddy.clear_logs()
  531. assert caddy.start()
  532. card = ScoreCard(env=env, httpd=httpd, nghttpx=nghttpx, caddy=caddy,
  533. verbose=args.verbose, curl_verbose=args.curl_verbose)
  534. score = card.score_proto(proto=protocol,
  535. handshakes=handshakes,
  536. downloads=downloads,
  537. download_count=args.download_count,
  538. requests=requests)
  539. if args.json:
  540. print(json.JSONEncoder(indent=2).encode(score))
  541. else:
  542. card.print_score(score)
  543. except ScoreCardException as ex:
  544. sys.stderr.write(f"ERROR: {str(ex)}\n")
  545. rv = 1
  546. except KeyboardInterrupt:
  547. log.warning("aborted")
  548. rv = 1
  549. finally:
  550. if caddy:
  551. caddy.stop()
  552. if nghttpx:
  553. nghttpx.stop(wait_dead=False)
  554. if httpd:
  555. httpd.stop()
  556. sys.exit(rv)
  557. if __name__ == "__main__":
  558. main()