scorecard.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878
  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, NghttpxQuic, RunProfile
  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. download_parallel: int = 0):
  47. self.verbose = verbose
  48. self.env = env
  49. self.httpd = httpd
  50. self.nghttpx = nghttpx
  51. self.caddy = caddy
  52. self._silent_curl = not curl_verbose
  53. self._download_parallel = download_parallel
  54. def info(self, msg):
  55. if self.verbose > 0:
  56. sys.stderr.write(msg)
  57. sys.stderr.flush()
  58. def handshakes(self, proto: str) -> Dict[str, Any]:
  59. props = {}
  60. sample_size = 5
  61. self.info(f'TLS Handshake\n')
  62. for authority in [
  63. 'curl.se', 'google.com', 'cloudflare.com', 'nghttp2.org'
  64. ]:
  65. self.info(f' {authority}...')
  66. props[authority] = {}
  67. for ipv in ['ipv4', 'ipv6']:
  68. self.info(f'{ipv}...')
  69. c_samples = []
  70. hs_samples = []
  71. errors = []
  72. for i in range(sample_size):
  73. curl = CurlClient(env=self.env, silent=self._silent_curl)
  74. args = [
  75. '--http3-only' if proto == 'h3' else '--http2',
  76. f'--{ipv}', f'https://{authority}/'
  77. ]
  78. r = curl.run_direct(args=args, with_stats=True)
  79. if r.exit_code == 0 and len(r.stats) == 1:
  80. c_samples.append(r.stats[0]['time_connect'])
  81. hs_samples.append(r.stats[0]['time_appconnect'])
  82. else:
  83. errors.append(f'exit={r.exit_code}')
  84. props[authority][f'{ipv}-connect'] = mean(c_samples) \
  85. if len(c_samples) else -1
  86. props[authority][f'{ipv}-handshake'] = mean(hs_samples) \
  87. if len(hs_samples) else -1
  88. props[authority][f'{ipv}-errors'] = errors
  89. self.info('ok.\n')
  90. return props
  91. def _make_docs_file(self, docs_dir: str, fname: str, fsize: int):
  92. fpath = os.path.join(docs_dir, fname)
  93. data1k = 1024*'x'
  94. flen = 0
  95. with open(fpath, 'w') as fd:
  96. while flen < fsize:
  97. fd.write(data1k)
  98. flen += len(data1k)
  99. return fpath
  100. def _check_downloads(self, r: ExecResult, count: int):
  101. error = ''
  102. if r.exit_code != 0:
  103. error += f'exit={r.exit_code} '
  104. if r.exit_code != 0 or len(r.stats) != count:
  105. error += f'stats={len(r.stats)}/{count} '
  106. fails = [s for s in r.stats if s['response_code'] != 200]
  107. if len(fails) > 0:
  108. error += f'{len(fails)} failed'
  109. return error if len(error) > 0 else None
  110. def transfer_single(self, url: str, proto: str, count: int):
  111. sample_size = count
  112. count = 1
  113. samples = []
  114. errors = []
  115. profiles = []
  116. self.info(f'single...')
  117. for i in range(sample_size):
  118. curl = CurlClient(env=self.env, silent=self._silent_curl)
  119. r = curl.http_download(urls=[url], alpn_proto=proto, no_save=True,
  120. with_headers=False, with_profile=True)
  121. err = self._check_downloads(r, count)
  122. if err:
  123. errors.append(err)
  124. else:
  125. total_size = sum([s['size_download'] for s in r.stats])
  126. samples.append(total_size / r.duration.total_seconds())
  127. profiles.append(r.profile)
  128. return {
  129. 'count': count,
  130. 'samples': sample_size,
  131. 'max-parallel': 1,
  132. 'speed': mean(samples) if len(samples) else -1,
  133. 'errors': errors,
  134. 'stats': RunProfile.AverageStats(profiles),
  135. }
  136. def transfer_serial(self, url: str, proto: str, count: int):
  137. sample_size = 1
  138. samples = []
  139. errors = []
  140. profiles = []
  141. url = f'{url}?[0-{count - 1}]'
  142. self.info(f'serial...')
  143. for i in range(sample_size):
  144. curl = CurlClient(env=self.env, silent=self._silent_curl)
  145. r = curl.http_download(urls=[url], alpn_proto=proto, no_save=True,
  146. with_headers=False, with_profile=True)
  147. err = self._check_downloads(r, count)
  148. if err:
  149. errors.append(err)
  150. else:
  151. total_size = sum([s['size_download'] for s in r.stats])
  152. samples.append(total_size / r.duration.total_seconds())
  153. profiles.append(r.profile)
  154. return {
  155. 'count': count,
  156. 'samples': sample_size,
  157. 'max-parallel': 1,
  158. 'speed': mean(samples) if len(samples) else -1,
  159. 'errors': errors,
  160. 'stats': RunProfile.AverageStats(profiles),
  161. }
  162. def transfer_parallel(self, url: str, proto: str, count: int):
  163. sample_size = 1
  164. samples = []
  165. errors = []
  166. profiles = []
  167. max_parallel = self._download_parallel if self._download_parallel > 0 else count
  168. url = f'{url}?[0-{count - 1}]'
  169. self.info(f'parallel...')
  170. for i in range(sample_size):
  171. curl = CurlClient(env=self.env, silent=self._silent_curl)
  172. r = curl.http_download(urls=[url], alpn_proto=proto, no_save=True,
  173. with_headers=False,
  174. with_profile=True,
  175. extra_args=['--parallel',
  176. '--parallel-max', str(max_parallel)])
  177. err = self._check_downloads(r, count)
  178. if err:
  179. errors.append(err)
  180. else:
  181. total_size = sum([s['size_download'] for s in r.stats])
  182. samples.append(total_size / r.duration.total_seconds())
  183. profiles.append(r.profile)
  184. return {
  185. 'count': count,
  186. 'samples': sample_size,
  187. 'max-parallel': max_parallel,
  188. 'speed': mean(samples) if len(samples) else -1,
  189. 'errors': errors,
  190. 'stats': RunProfile.AverageStats(profiles),
  191. }
  192. def download_url(self, label: str, url: str, proto: str, count: int):
  193. self.info(f' {count}x{label}: ')
  194. props = {
  195. 'single': self.transfer_single(url=url, proto=proto, count=10),
  196. }
  197. if count > 1:
  198. props['serial'] = self.transfer_serial(url=url, proto=proto,
  199. count=count)
  200. props['parallel'] = self.transfer_parallel(url=url, proto=proto,
  201. count=count)
  202. self.info(f'ok.\n')
  203. return props
  204. def downloads(self, proto: str, count: int,
  205. fsizes: List[int]) -> Dict[str, Any]:
  206. scores = {}
  207. if self.httpd:
  208. if proto == 'h3':
  209. port = self.env.h3_port
  210. via = 'nghttpx'
  211. descr = f'port {port}, proxying httpd'
  212. else:
  213. port = self.env.https_port
  214. via = 'httpd'
  215. descr = f'port {port}'
  216. self.info(f'{via} downloads\n')
  217. scores[via] = {
  218. 'description': descr,
  219. }
  220. for fsize in fsizes:
  221. label = self.fmt_size(fsize)
  222. fname = f'score{label}.data'
  223. self._make_docs_file(docs_dir=self.httpd.docs_dir,
  224. fname=fname, fsize=fsize)
  225. url = f'https://{self.env.domain1}:{port}/{fname}'
  226. results = self.download_url(label=label, url=url,
  227. proto=proto, count=count)
  228. scores[via][label] = results
  229. if self.caddy:
  230. port = self.caddy.port
  231. via = 'caddy'
  232. descr = f'port {port}'
  233. self.info('caddy downloads\n')
  234. scores[via] = {
  235. 'description': descr,
  236. }
  237. for fsize in fsizes:
  238. label = self.fmt_size(fsize)
  239. fname = f'score{label}.data'
  240. self._make_docs_file(docs_dir=self.caddy.docs_dir,
  241. fname=fname, fsize=fsize)
  242. url = f'https://{self.env.domain1}:{port}/{fname}'
  243. results = self.download_url(label=label, url=url,
  244. proto=proto, count=count)
  245. scores[via][label] = results
  246. return scores
  247. def _check_uploads(self, r: ExecResult, count: int):
  248. error = ''
  249. if r.exit_code != 0:
  250. error += f'exit={r.exit_code} '
  251. if r.exit_code != 0 or len(r.stats) != count:
  252. error += f'stats={len(r.stats)}/{count} '
  253. fails = [s for s in r.stats if s['response_code'] != 200]
  254. if len(fails) > 0:
  255. error += f'{len(fails)} failed'
  256. for f in fails:
  257. error += f'[{f["response_code"]}]'
  258. return error if len(error) > 0 else None
  259. def upload_single(self, url: str, proto: str, fpath: str, count: int):
  260. sample_size = count
  261. count = 1
  262. samples = []
  263. errors = []
  264. profiles = []
  265. self.info(f'single...')
  266. for i in range(sample_size):
  267. curl = CurlClient(env=self.env, silent=self._silent_curl)
  268. r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=proto,
  269. with_headers=False, with_profile=True)
  270. err = self._check_uploads(r, count)
  271. if err:
  272. errors.append(err)
  273. else:
  274. total_size = sum([s['size_upload'] for s in r.stats])
  275. samples.append(total_size / r.duration.total_seconds())
  276. profiles.append(r.profile)
  277. return {
  278. 'count': count,
  279. 'samples': sample_size,
  280. 'max-parallel': 1,
  281. 'speed': mean(samples) if len(samples) else -1,
  282. 'errors': errors,
  283. 'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
  284. }
  285. def upload_serial(self, url: str, proto: str, fpath: str, count: int):
  286. sample_size = 1
  287. samples = []
  288. errors = []
  289. profiles = []
  290. url = f'{url}?id=[0-{count - 1}]'
  291. self.info(f'serial...')
  292. for i in range(sample_size):
  293. curl = CurlClient(env=self.env, silent=self._silent_curl)
  294. r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=proto,
  295. with_headers=False, with_profile=True)
  296. err = self._check_uploads(r, count)
  297. if err:
  298. errors.append(err)
  299. else:
  300. total_size = sum([s['size_upload'] for s in r.stats])
  301. samples.append(total_size / r.duration.total_seconds())
  302. profiles.append(r.profile)
  303. return {
  304. 'count': count,
  305. 'samples': sample_size,
  306. 'max-parallel': 1,
  307. 'speed': mean(samples) if len(samples) else -1,
  308. 'errors': errors,
  309. 'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
  310. }
  311. def upload_parallel(self, url: str, proto: str, fpath: str, count: int):
  312. sample_size = 1
  313. samples = []
  314. errors = []
  315. profiles = []
  316. max_parallel = count
  317. url = f'{url}?id=[0-{count - 1}]'
  318. self.info(f'parallel...')
  319. for i in range(sample_size):
  320. curl = CurlClient(env=self.env, silent=self._silent_curl)
  321. r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=proto,
  322. with_headers=False, with_profile=True,
  323. extra_args=[
  324. '--parallel',
  325. '--parallel-max', str(max_parallel)
  326. ])
  327. err = self._check_uploads(r, count)
  328. if err:
  329. errors.append(err)
  330. else:
  331. total_size = sum([s['size_upload'] for s in r.stats])
  332. samples.append(total_size / r.duration.total_seconds())
  333. profiles.append(r.profile)
  334. return {
  335. 'count': count,
  336. 'samples': sample_size,
  337. 'max-parallel': max_parallel,
  338. 'speed': mean(samples) if len(samples) else -1,
  339. 'errors': errors,
  340. 'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
  341. }
  342. def upload_url(self, label: str, url: str, fpath: str, proto: str, count: int):
  343. self.info(f' {count}x{label}: ')
  344. props = {
  345. 'single': self.upload_single(url=url, proto=proto, fpath=fpath,
  346. count=10),
  347. }
  348. if count > 1:
  349. props['serial'] = self.upload_serial(url=url, proto=proto,
  350. fpath=fpath, count=count)
  351. props['parallel'] = self.upload_parallel(url=url, proto=proto,
  352. fpath=fpath, count=count)
  353. self.info(f'ok.\n')
  354. return props
  355. def uploads(self, proto: str, count: int,
  356. fsizes: List[int]) -> Dict[str, Any]:
  357. scores = {}
  358. if self.httpd:
  359. if proto == 'h3':
  360. port = self.env.h3_port
  361. via = 'nghttpx'
  362. descr = f'port {port}, proxying httpd'
  363. else:
  364. port = self.env.https_port
  365. via = 'httpd'
  366. descr = f'port {port}'
  367. self.info(f'{via} uploads\n')
  368. scores[via] = {
  369. 'description': descr,
  370. }
  371. for fsize in fsizes:
  372. label = self.fmt_size(fsize)
  373. fname = f'upload{label}.data'
  374. fpath = self._make_docs_file(docs_dir=self.env.gen_dir,
  375. fname=fname, fsize=fsize)
  376. url = f'https://{self.env.domain1}:{port}/curltest/put'
  377. results = self.upload_url(label=label, url=url, fpath=fpath,
  378. proto=proto, count=count)
  379. scores[via][label] = results
  380. if self.caddy:
  381. port = self.caddy.port
  382. via = 'caddy'
  383. descr = f'port {port}'
  384. self.info('caddy uploads\n')
  385. scores[via] = {
  386. 'description': descr,
  387. }
  388. for fsize in fsizes:
  389. label = self.fmt_size(fsize)
  390. fname = f'upload{label}.data'
  391. fpath = self._make_docs_file(docs_dir=self.env.gen_dir,
  392. fname=fname, fsize=fsize)
  393. url = f'https://{self.env.domain2}:{port}/curltest/put'
  394. results = self.upload_url(label=label, url=url, fpath=fpath,
  395. proto=proto, count=count)
  396. scores[via][label] = results
  397. return scores
  398. def do_requests(self, url: str, proto: str, count: int,
  399. max_parallel: int = 1):
  400. sample_size = 1
  401. samples = []
  402. errors = []
  403. profiles = []
  404. url = f'{url}?[0-{count - 1}]'
  405. extra_args = ['--parallel', '--parallel-max', str(max_parallel)] \
  406. if max_parallel > 1 else []
  407. self.info(f'{max_parallel}...')
  408. for i in range(sample_size):
  409. curl = CurlClient(env=self.env, silent=self._silent_curl)
  410. r = curl.http_download(urls=[url], alpn_proto=proto, no_save=True,
  411. with_headers=False, with_profile=True,
  412. extra_args=extra_args)
  413. err = self._check_downloads(r, count)
  414. if err:
  415. errors.append(err)
  416. else:
  417. for _ in r.stats:
  418. samples.append(count / r.duration.total_seconds())
  419. profiles.append(r.profile)
  420. return {
  421. 'count': count,
  422. 'samples': sample_size,
  423. 'speed': mean(samples) if len(samples) else -1,
  424. 'errors': errors,
  425. 'stats': RunProfile.AverageStats(profiles),
  426. }
  427. def requests_url(self, url: str, proto: str, count: int):
  428. self.info(f' {url}: ')
  429. props = {
  430. '1': self.do_requests(url=url, proto=proto, count=count),
  431. '6': self.do_requests(url=url, proto=proto, count=count,
  432. max_parallel=6),
  433. '25': self.do_requests(url=url, proto=proto, count=count,
  434. max_parallel=25),
  435. '50': self.do_requests(url=url, proto=proto, count=count,
  436. max_parallel=50),
  437. '100': self.do_requests(url=url, proto=proto, count=count,
  438. max_parallel=100),
  439. }
  440. self.info(f'ok.\n')
  441. return props
  442. def requests(self, proto: str, req_count) -> Dict[str, Any]:
  443. scores = {}
  444. if self.httpd:
  445. if proto == 'h3':
  446. port = self.env.h3_port
  447. via = 'nghttpx'
  448. descr = f'port {port}, proxying httpd'
  449. else:
  450. port = self.env.https_port
  451. via = 'httpd'
  452. descr = f'port {port}'
  453. self.info(f'{via} requests\n')
  454. self._make_docs_file(docs_dir=self.httpd.docs_dir,
  455. fname='reqs10.data', fsize=10*1024)
  456. url1 = f'https://{self.env.domain1}:{port}/reqs10.data'
  457. scores[via] = {
  458. 'description': descr,
  459. 'count': req_count,
  460. '10KB': self.requests_url(url=url1, proto=proto, count=req_count),
  461. }
  462. if self.caddy:
  463. port = self.caddy.port
  464. via = 'caddy'
  465. descr = f'port {port}'
  466. self.info('caddy requests\n')
  467. self._make_docs_file(docs_dir=self.caddy.docs_dir,
  468. fname='req10.data', fsize=10 * 1024)
  469. url1 = f'https://{self.env.domain1}:{port}/req10.data'
  470. scores[via] = {
  471. 'description': descr,
  472. 'count': req_count,
  473. '10KB': self.requests_url(url=url1, proto=proto, count=req_count),
  474. }
  475. return scores
  476. def score_proto(self, proto: str,
  477. handshakes: bool = True,
  478. downloads: Optional[List[int]] = None,
  479. download_count: int = 50,
  480. uploads: Optional[List[int]] = None,
  481. upload_count: int = 50,
  482. req_count=5000,
  483. requests: bool = True):
  484. self.info(f"scoring {proto}\n")
  485. p = {}
  486. if proto == 'h3':
  487. p['name'] = 'h3'
  488. if not self.env.have_h3_curl():
  489. raise ScoreCardException('curl does not support HTTP/3')
  490. for lib in ['ngtcp2', 'quiche', 'msh3', 'nghttp3']:
  491. if self.env.curl_uses_lib(lib):
  492. p['implementation'] = lib
  493. break
  494. elif proto == 'h2':
  495. p['name'] = 'h2'
  496. if not self.env.have_h2_curl():
  497. raise ScoreCardException('curl does not support HTTP/2')
  498. for lib in ['nghttp2', 'hyper']:
  499. if self.env.curl_uses_lib(lib):
  500. p['implementation'] = lib
  501. break
  502. elif proto == 'h1' or proto == 'http/1.1':
  503. proto = 'http/1.1'
  504. p['name'] = proto
  505. p['implementation'] = 'hyper' if self.env.curl_uses_lib('hyper')\
  506. else 'native'
  507. else:
  508. raise ScoreCardException(f"unknown protocol: {proto}")
  509. if 'implementation' not in p:
  510. raise ScoreCardException(f'did not recognized {p} lib')
  511. p['version'] = Env.curl_lib_version(p['implementation'])
  512. score = {
  513. 'curl': self.env.curl_fullname(),
  514. 'os': self.env.curl_os(),
  515. 'protocol': p,
  516. }
  517. if handshakes:
  518. score['handshakes'] = self.handshakes(proto=proto)
  519. if downloads and len(downloads) > 0:
  520. score['downloads'] = self.downloads(proto=proto,
  521. count=download_count,
  522. fsizes=downloads)
  523. if uploads and len(uploads) > 0:
  524. score['uploads'] = self.uploads(proto=proto,
  525. count=upload_count,
  526. fsizes=uploads)
  527. if requests:
  528. score['requests'] = self.requests(proto=proto, req_count=req_count)
  529. self.info("\n")
  530. return score
  531. def fmt_ms(self, tval):
  532. return f'{int(tval*1000)} ms' if tval >= 0 else '--'
  533. def fmt_size(self, val):
  534. if val >= (1024*1024*1024):
  535. return f'{val / (1024*1024*1024):0.000f}GB'
  536. elif val >= (1024 * 1024):
  537. return f'{val / (1024*1024):0.000f}MB'
  538. elif val >= 1024:
  539. return f'{val / 1024:0.000f}KB'
  540. else:
  541. return f'{val:0.000f}B'
  542. def fmt_mbs(self, val):
  543. return f'{val/(1024*1024):0.000f} MB/s' if val >= 0 else '--'
  544. def fmt_reqs(self, val):
  545. return f'{val:0.000f} r/s' if val >= 0 else '--'
  546. def print_score(self, score):
  547. print(f'{score["protocol"]["name"].upper()} in {score["curl"]}')
  548. if 'handshakes' in score:
  549. print(f'{"Handshakes":<24} {"ipv4":25} {"ipv6":28}')
  550. print(f' {"Host":<17} {"Connect":>12} {"Handshake":>12} '
  551. f'{"Connect":>12} {"Handshake":>12} {"Errors":<20}')
  552. for key, val in score["handshakes"].items():
  553. print(f' {key:<17} {self.fmt_ms(val["ipv4-connect"]):>12} '
  554. f'{self.fmt_ms(val["ipv4-handshake"]):>12} '
  555. f'{self.fmt_ms(val["ipv6-connect"]):>12} '
  556. f'{self.fmt_ms(val["ipv6-handshake"]):>12} '
  557. f'{"/".join(val["ipv4-errors"] + val["ipv6-errors"]):<20}'
  558. )
  559. if 'downloads' in score:
  560. # get the key names of all sizes and measurements made
  561. sizes = []
  562. measures = []
  563. m_names = {}
  564. mcol_width = 12
  565. mcol_sw = 17
  566. for server, server_score in score['downloads'].items():
  567. for sskey, ssval in server_score.items():
  568. if isinstance(ssval, str):
  569. continue
  570. if sskey not in sizes:
  571. sizes.append(sskey)
  572. for mkey, mval in server_score[sskey].items():
  573. if mkey not in measures:
  574. measures.append(mkey)
  575. m_names[mkey] = f'{mkey}({mval["count"]}x{mval["max-parallel"]})'
  576. print('Downloads')
  577. print(f' {"Server":<8} {"Size":>8}', end='')
  578. for m in measures: print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
  579. print(f' {"Errors":^20}')
  580. for server in score['downloads']:
  581. for size in sizes:
  582. size_score = score['downloads'][server][size]
  583. print(f' {server:<8} {size:>8}', end='')
  584. errors = []
  585. for key, val in size_score.items():
  586. if 'errors' in val:
  587. errors.extend(val['errors'])
  588. for m in measures:
  589. if m in size_score:
  590. print(f' {self.fmt_mbs(size_score[m]["speed"]):>{mcol_width}}', end='')
  591. s = f'[{size_score[m]["stats"]["cpu"]:>.1f}%'\
  592. f'/{self.fmt_size(size_score[m]["stats"]["rss"])}]'
  593. print(f' {s:<{mcol_sw}}', end='')
  594. else:
  595. print(' '*mcol_width, end='')
  596. if len(errors):
  597. print(f' {"/".join(errors):<20}')
  598. else:
  599. print(f' {"-":^20}')
  600. if 'uploads' in score:
  601. # get the key names of all sizes and measurements made
  602. sizes = []
  603. measures = []
  604. m_names = {}
  605. mcol_width = 12
  606. mcol_sw = 17
  607. for server, server_score in score['uploads'].items():
  608. for sskey, ssval in server_score.items():
  609. if isinstance(ssval, str):
  610. continue
  611. if sskey not in sizes:
  612. sizes.append(sskey)
  613. for mkey, mval in server_score[sskey].items():
  614. if mkey not in measures:
  615. measures.append(mkey)
  616. m_names[mkey] = f'{mkey}({mval["count"]}x{mval["max-parallel"]})'
  617. print('Uploads')
  618. print(f' {"Server":<8} {"Size":>8}', end='')
  619. for m in measures: print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
  620. print(f' {"Errors":^20}')
  621. for server in score['uploads']:
  622. for size in sizes:
  623. size_score = score['uploads'][server][size]
  624. print(f' {server:<8} {size:>8}', end='')
  625. errors = []
  626. for key, val in size_score.items():
  627. if 'errors' in val:
  628. errors.extend(val['errors'])
  629. for m in measures:
  630. if m in size_score:
  631. print(f' {self.fmt_mbs(size_score[m]["speed"]):>{mcol_width}}', end='')
  632. stats = size_score[m]["stats"]
  633. if 'cpu' in stats:
  634. s = f'[{stats["cpu"]:>.1f}%/{self.fmt_size(stats["rss"])}]'
  635. else:
  636. s = '[???/???]'
  637. print(f' {s:<{mcol_sw}}', end='')
  638. else:
  639. print(' '*mcol_width, end='')
  640. if len(errors):
  641. print(f' {"/".join(errors):<20}')
  642. else:
  643. print(f' {"-":^20}')
  644. if 'requests' in score:
  645. sizes = []
  646. measures = []
  647. m_names = {}
  648. mcol_width = 9
  649. mcol_sw = 13
  650. for server in score['requests']:
  651. server_score = score['requests'][server]
  652. for sskey, ssval in server_score.items():
  653. if isinstance(ssval, str) or isinstance(ssval, int):
  654. continue
  655. if sskey not in sizes:
  656. sizes.append(sskey)
  657. for mkey, mval in server_score[sskey].items():
  658. if mkey not in measures:
  659. measures.append(mkey)
  660. m_names[mkey] = f'{mkey}'
  661. print('Requests, max in parallel')
  662. print(f' {"Server":<8} {"Size":>6} {"Reqs":>6}', end='')
  663. for m in measures: print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
  664. print(f' {"Errors":^10}')
  665. for server in score['requests']:
  666. for size in sizes:
  667. size_score = score['requests'][server][size]
  668. count = score['requests'][server]['count']
  669. print(f' {server:<8} {size:>6} {count:>6}', end='')
  670. errors = []
  671. for key, val in size_score.items():
  672. if 'errors' in val:
  673. errors.extend(val['errors'])
  674. for m in measures:
  675. if m in size_score:
  676. print(f' {self.fmt_reqs(size_score[m]["speed"]):>{mcol_width}}', end='')
  677. s = f'[{size_score[m]["stats"]["cpu"]:>.1f}%'\
  678. f'/{self.fmt_size(size_score[m]["stats"]["rss"])}]'
  679. print(f' {s:<{mcol_sw}}', end='')
  680. else:
  681. print(' '*mcol_width, end='')
  682. if len(errors):
  683. print(f' {"/".join(errors):<10}')
  684. else:
  685. print(f' {"-":^10}')
  686. def parse_size(s):
  687. m = re.match(r'(\d+)(mb|kb|gb)?', s, re.IGNORECASE)
  688. if m is None:
  689. raise Exception(f'unrecognized size: {s}')
  690. size = int(m.group(1))
  691. if not m.group(2):
  692. pass
  693. elif m.group(2).lower() == 'kb':
  694. size *= 1024
  695. elif m.group(2).lower() == 'mb':
  696. size *= 1024 * 1024
  697. elif m.group(2).lower() == 'gb':
  698. size *= 1024 * 1024 * 1024
  699. return size
  700. def main():
  701. parser = argparse.ArgumentParser(prog='scorecard', description="""
  702. Run a range of tests to give a scorecard for a HTTP protocol
  703. 'h3' or 'h2' implementation in curl.
  704. """)
  705. parser.add_argument("-v", "--verbose", action='count', default=1,
  706. help="log more output on stderr")
  707. parser.add_argument("-j", "--json", action='store_true',
  708. default=False, help="print json instead of text")
  709. parser.add_argument("-H", "--handshakes", action='store_true',
  710. default=False, help="evaluate handshakes only")
  711. parser.add_argument("-d", "--downloads", action='store_true',
  712. default=False, help="evaluate downloads")
  713. parser.add_argument("--download", action='append', type=str,
  714. default=None, help="evaluate download size")
  715. parser.add_argument("--download-count", action='store', type=int,
  716. default=50, help="perform that many downloads")
  717. parser.add_argument("--download-parallel", action='store', type=int,
  718. default=0, help="perform that many downloads in parallel (default all)")
  719. parser.add_argument("-u", "--uploads", action='store_true',
  720. default=False, help="evaluate uploads")
  721. parser.add_argument("--upload", action='append', type=str,
  722. default=None, help="evaluate upload size")
  723. parser.add_argument("--upload-count", action='store', type=int,
  724. default=50, help="perform that many uploads")
  725. parser.add_argument("-r", "--requests", action='store_true',
  726. default=False, help="evaluate requests")
  727. parser.add_argument("--request-count", action='store', type=int,
  728. default=5000, help="perform that many requests")
  729. parser.add_argument("--httpd", action='store_true', default=False,
  730. help="evaluate httpd server only")
  731. parser.add_argument("--caddy", action='store_true', default=False,
  732. help="evaluate caddy server only")
  733. parser.add_argument("--curl-verbose", action='store_true',
  734. default=False, help="run curl with `-v`")
  735. parser.add_argument("protocol", default='h2', nargs='?',
  736. help="Name of protocol to score")
  737. args = parser.parse_args()
  738. if args.verbose > 0:
  739. console = logging.StreamHandler()
  740. console.setLevel(logging.INFO)
  741. console.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
  742. logging.getLogger('').addHandler(console)
  743. protocol = args.protocol
  744. handshakes = True
  745. downloads = [1024 * 1024, 10 * 1024 * 1024, 100 * 1024 * 1024]
  746. if args.download is not None:
  747. downloads = []
  748. for x in args.download:
  749. downloads.extend([parse_size(s) for s in x.split(',')])
  750. uploads = [1024 * 1024, 10 * 1024 * 1024, 100 * 1024 * 1024]
  751. if args.upload is not None:
  752. uploads = []
  753. for x in args.upload:
  754. uploads.extend([parse_size(s) for s in x.split(',')])
  755. requests = True
  756. if args.downloads or args.uploads or args.requests or args.handshakes:
  757. handshakes = args.handshakes
  758. if not args.downloads:
  759. downloads = None
  760. if not args.uploads:
  761. uploads = None
  762. requests = args.requests
  763. test_httpd = protocol != 'h3'
  764. test_caddy = True
  765. if args.caddy or args.httpd:
  766. test_caddy = args.caddy
  767. test_httpd = args.httpd
  768. rv = 0
  769. env = Env()
  770. env.setup()
  771. env.test_timeout = None
  772. httpd = None
  773. nghttpx = None
  774. caddy = None
  775. try:
  776. if test_httpd or (test_caddy and uploads):
  777. print(f'httpd: {env.httpd_version()}, http:{env.http_port} https:{env.https_port}')
  778. httpd = Httpd(env=env)
  779. assert httpd.exists(), \
  780. f'httpd not found: {env.httpd}'
  781. httpd.clear_logs()
  782. assert httpd.start()
  783. if test_httpd and 'h3' == protocol:
  784. nghttpx = NghttpxQuic(env=env)
  785. nghttpx.clear_logs()
  786. assert nghttpx.start()
  787. if test_caddy and env.caddy:
  788. print(f'Caddy: {env.caddy_version()}, http:{env.caddy_http_port} https:{env.caddy_https_port}')
  789. caddy = Caddy(env=env)
  790. caddy.clear_logs()
  791. assert caddy.start()
  792. card = ScoreCard(env=env, httpd=httpd if test_httpd else None,
  793. nghttpx=nghttpx, caddy=caddy if test_caddy else None,
  794. verbose=args.verbose, curl_verbose=args.curl_verbose,
  795. download_parallel=args.download_parallel)
  796. score = card.score_proto(proto=protocol,
  797. handshakes=handshakes,
  798. downloads=downloads,
  799. download_count=args.download_count,
  800. uploads=uploads,
  801. upload_count=args.upload_count,
  802. req_count=args.request_count,
  803. requests=requests)
  804. if args.json:
  805. print(json.JSONEncoder(indent=2).encode(score))
  806. else:
  807. card.print_score(score)
  808. except ScoreCardException as ex:
  809. sys.stderr.write(f"ERROR: {str(ex)}\n")
  810. rv = 1
  811. except KeyboardInterrupt:
  812. log.warning("aborted")
  813. rv = 1
  814. finally:
  815. if caddy:
  816. caddy.stop()
  817. if nghttpx:
  818. nghttpx.stop(wait_dead=False)
  819. if httpd:
  820. httpd.stop()
  821. sys.exit(rv)
  822. if __name__ == "__main__":
  823. main()