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