2
0

scorecard.py 34 KB

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