scorecard: add perf support on linux
No binary (build failed)
—
(log yok)
| @@ -59,15 +59,9 @@ If you have configured curl with `--with-test-danted=<danted-path>` for a | |
| with arguments `--socks4` or `--socks5` to test performance with a SOCKS proxy | |
| involved. (Note: this does not work for HTTP/3) | |
| - | ## dtrace |
| - | |
| - | With the `--dtrace` option, scorecard produces a dtrace sample of the user stacks in `tests/http/gen/curl/curl.user_stacks`. On many platforms, `dtrace` requires **special permissions**. It is therefore invoked via `sudo` and you should make sure that sudo works for the run without prompting for a password. |
| - | |
| - | Note: the file is the trace of the last curl invocation by scorecard. Use the parameters to narrow down the runs to the particular case you are interested in. |
| - | |
| ## flame graphs | |
| - | With the excellent [Flame Graph](https://github.com/brendangregg/FlameGraph) by Brendan Gregg, scorecard can turn the `dtrace` samples into an interactive SVG. Set the environment variable `FLAMEGRAPH` to the location of your clone of that project and invoked scorecard with the `--flame` option. Like |
| + | With the excellent [Flame Graph](https://github.com/brendangregg/FlameGraph) by Brendan Gregg, scorecard can turn `perf`/`dtrace` samples into an interactive SVG. Either clone the `Flamegraph` repository next to your `curl` project or set the environment variable `FLAMEGRAPH` to the location of your clone. Then run scorecard with the `--flame` option, like |
| ``` | |
| curl> FLAMEGRAPH=/Users/sei/projects/FlameGraph python3 tests/http/scorecard.py \ | |
| @@ -75,4 +69,13 @@ curl> FLAMEGRAPH=/Users/sei/projects/FlameGraph python3 tests/http/scorecard.py | |
| ``` | |
| and the SVG of the run is in `tests/http/gen/curl/curl.flamegraph.svg`. You can open that in Firefox and zoom in/out of stacks of interest. | |
| - | Note: as with `dtrace`, the flame graph is for the last invocation of curl done by scorecard. |
| + | The flame graph is about the last run of `curl`. That is why you should add scorecard arguments |
| + | that restrict measurements to a single run. |
| + | |
| + | ### Measures/Privileges |
| + | |
| + | The `--flame` option uses `perf` on linux and `dtrace` on macOS. Since both tools require special |
| + | privileges, they are run via the `sudo` command by scorecard. This means you need to issue a |
| + | `sudo` recently enough before running scorecard, so no new password check is needed. |
| + | |
| + | There is no support right now for measurements on other platforms. |
| @@ -195,7 +195,6 @@ class ScoreRunner: | |
| curl_verbose: int, | |
| download_parallel: int = 0, | |
| server_addr: Optional[str] = None, | |
| - | with_dtrace: bool = False, |
| with_flame: bool = False, | |
| socks_args: Optional[List[str]] = None, | |
| limit_rate: Optional[str] = None): | |
| @@ -207,7 +206,6 @@ class ScoreRunner: | |
| self.server_port = server_port | |
| self._silent_curl = not curl_verbose | |
| self._download_parallel = download_parallel | |
| - | self._with_dtrace = with_dtrace |
| self._with_flame = with_flame | |
| self._socks_args = socks_args | |
| self._limit_rate = limit_rate | |
| @@ -220,7 +218,6 @@ class ScoreRunner: | |
| def mk_curl_client(self): | |
| return CurlClient(env=self.env, silent=self._silent_curl, | |
| server_addr=self.server_addr, | |
| - | with_dtrace=self._with_dtrace, |
| with_flame=self._with_flame, | |
| socks_args=self._socks_args) | |
| @@ -726,7 +723,6 @@ def run_score(args, protocol): | |
| verbose=args.verbose, | |
| curl_verbose=args.curl_verbose, | |
| download_parallel=args.download_parallel, | |
| - | with_dtrace=args.dtrace, |
| with_flame=args.flame, | |
| socks_args=socks_args, | |
| limit_rate=args.limit_rate) | |
| @@ -754,7 +750,6 @@ def run_score(args, protocol): | |
| server_port=server_port, | |
| verbose=args.verbose, curl_verbose=args.curl_verbose, | |
| download_parallel=args.download_parallel, | |
| - | with_dtrace=args.dtrace, |
| with_flame=args.flame, | |
| socks_args=socks_args, | |
| limit_rate=args.limit_rate) | |
| @@ -782,7 +777,7 @@ def run_score(args, protocol): | |
| server_port=server_port, | |
| verbose=args.verbose, curl_verbose=args.curl_verbose, | |
| download_parallel=args.download_parallel, | |
| - | with_dtrace=args.dtrace, |
| + | with_flame=args.flame, |
| socks_args=socks_args, | |
| limit_rate=args.limit_rate) | |
| card.setup_resources(server_docs, downloads) | |
| @@ -864,10 +859,8 @@ def main(): | |
| help="only start the servers") | |
| parser.add_argument("--remote", action='store', type=str, | |
| default=None, help="score against the remote server at <ip>:<port>") | |
| - | parser.add_argument("--dtrace", action='store_true', |
| - | default = False, help="produce dtrace of curl") |
| parser.add_argument("--flame", action='store_true', | |
| - | default = False, help="produce a flame graph on curl, implies --dtrace") |
| + | default = False, help="produce a flame graph on curl") |
| parser.add_argument("--limit-rate", action='store', type=str, | |
| default=None, help="use curl's --limit-rate") | |
| @@ -108,6 +108,42 @@ class RunProfile: | |
| f'stats={self.stats}]' | |
| + | class PerfProfile: |
| + | |
| + | def __init__(self, pid: int, run_dir): |
| + | self._pid = pid |
| + | self._run_dir = run_dir |
| + | self._proc = None |
| + | self._rc = 0 |
| + | self._file = os.path.join(self._run_dir, 'curl.perf_stacks') |
| + | |
| + | def start(self): |
| + | if os.path.exists(self._file): |
| + | os.remove(self._file) |
| + | args = [ |
| + | 'sudo', 'perf', 'record', '-F', '99', '-p', f'{self._pid}', |
| + | '-g', '--', 'sleep', '60' |
| + | ] |
| + | self._proc = subprocess.Popen(args, text=True, cwd=self._run_dir, shell=False) |
| + | assert self._proc |
| + | |
| + | def finish(self): |
| + | if self._proc: |
| + | self._proc.terminate() |
| + | self._rc = self._proc.returncode |
| + | with open(self._file, 'w') as cout: |
| + | p = subprocess.run([ |
| + | 'sudo', 'perf', 'script' |
| + | ], stdout=cout, cwd=self._run_dir, shell=False) |
| + | rc = p.returncode |
| + | if rc != 0: |
| + | raise Exception(f'perf returned error {rc}') |
| + | |
| + | @property |
| + | def file(self): |
| + | return self._file |
| + | |
| + | |
| class DTraceProfile: | |
| def __init__(self, pid: int, run_dir): | |
| @@ -115,7 +151,7 @@ class DTraceProfile: | |
| self._run_dir = run_dir | |
| self._proc = None | |
| self._rc = 0 | |
| - | self._file = os.path.join(self._run_dir, 'curl.user_stacks') |
| + | self._file = os.path.join(self._run_dir, 'curl.dtrace_stacks') |
| def start(self): | |
| if os.path.exists(self._file): | |
| @@ -503,6 +539,7 @@ class CurlClient: | |
| run_env: Optional[Dict[str, str]] = None, | |
| server_addr: Optional[str] = None, | |
| with_dtrace: bool = False, | |
| + | with_perf: bool = False, |
| with_flame: bool = False, | |
| socks_args: Optional[List[str]] = None): | |
| self.env = env | |
| @@ -514,9 +551,21 @@ class CurlClient: | |
| self._headerfile = f'{self._run_dir}/curl.headers' | |
| self._log_path = f'{self._run_dir}/curl.log' | |
| self._with_dtrace = with_dtrace | |
| + | self._with_perf = with_perf |
| self._with_flame = with_flame | |
| + | self._fg_dir = None |
| if self._with_flame: | |
| - | self._with_dtrace = True |
| + | self._fg_dir = os.path.join(self.env.project_dir, '../FlameGraph') |
| + | if 'FLAMEGRAPH' in os.environ: |
| + | self._fg_dir = os.environ['FLAMEGRAPH'] |
| + | if not os.path.exists(self._fg_dir): |
| + | raise Exception(f'FlameGraph checkout not found in {self._fg_dir}, set env variable FLAMEGRAPH') |
| + | if sys.platform.startswith('linux'): |
| + | self._with_perf = True |
| + | elif sys.platform.startswith('darwin'): |
| + | self._with_dtrace = True |
| + | else: |
| + | raise Exception(f'flame graphs unsupported on {sys.platform}') |
| self._socks_args = socks_args | |
| self._silent = silent | |
| self._run_env = run_env | |
| @@ -809,6 +858,7 @@ class CurlClient: | |
| exception = None | |
| profile = None | |
| tcpdump = None | |
| + | perf = None |
| dtrace = None | |
| if with_tcpdump: | |
| tcpdump = RunTcpDump(self.env, self._run_dir) | |
| @@ -826,7 +876,10 @@ class CurlClient: | |
| profile = RunProfile(p.pid, started_at, self._run_dir) | |
| if intext is not None and False: | |
| p.communicate(input=intext.encode(), timeout=1) | |
| - | if self._with_dtrace: |
| + | if self._with_perf: |
| + | perf = PerfProfile(p.pid, self._run_dir) |
| + | perf.start() |
| + | elif self._with_dtrace: |
| dtrace = DTraceProfile(p.pid, self._run_dir) | |
| dtrace.start() | |
| ptimeout = 0.0 | |
| @@ -860,10 +913,12 @@ class CurlClient: | |
| ended_at = datetime.now() | |
| if tcpdump: | |
| tcpdump.finish() | |
| + | if perf: |
| + | perf.finish() |
| if dtrace: | |
| dtrace.finish() | |
| - | if self._with_flame and dtrace: |
| - | self._generate_flame(dtrace, args) |
| + | if self._with_flame: |
| + | self._generate_flame(args, dtrace=dtrace, perf=perf) |
| coutput = open(self._stdoutfile).readlines() | |
| cerrput = open(self._stderrfile).readlines() | |
| return ExecResult(args=args, exit_code=exitcode, exception=exception, | |
| @@ -1004,37 +1059,59 @@ class CurlClient: | |
| fin_response(response) | |
| return r | |
| - | def _generate_flame(self, dtrace: DTraceProfile, curl_args: List[str]): |
| - | log.info('generating flame graph from dtrace for this run') |
| + | def _perf_collapse(self, perf: PerfProfile, file_err): |
| + | if not os.path.exists(perf.file): |
| + | raise Exception(f'dtrace output file does not exist: {perf.file}') |
| + | fg_collapse = os.path.join(self._fg_dir, 'stackcollapse-perf.pl') |
| + | if not os.path.exists(fg_collapse): |
| + | raise Exception(f'FlameGraph script not found: {fg_collapse}') |
| + | stacks_collapsed = f'{perf.file}.collapsed' |
| + | log.info(f'collapsing stacks into {stacks_collapsed}') |
| + | with open(stacks_collapsed, 'w') as cout, open(file_err, 'w') as cerr: |
| + | p = subprocess.run([ |
| + | fg_collapse, perf.file |
| + | ], stdout=cout, stderr=cerr, cwd=self._run_dir, shell=False) |
| + | rc = p.returncode |
| + | if rc != 0: |
| + | raise Exception(f'{fg_collapse} returned error {rc}') |
| + | return stacks_collapsed |
| + | |
| + | def _dtrace_collapse(self, dtrace: DTraceProfile, file_err): |
| if not os.path.exists(dtrace.file): | |
| raise Exception(f'dtrace output file does not exist: {dtrace.file}') | |
| - | if 'FLAMEGRAPH' not in os.environ: |
| - | raise Exception('Env variable FLAMEGRAPH not set') |
| - | fg_dir = os.environ['FLAMEGRAPH'] |
| - | if not os.path.exists(fg_dir): |
| - | raise Exception(f'FlameGraph directory not found: {fg_dir}') |
| - | |
| - | fg_collapse = os.path.join(fg_dir, 'stackcollapse.pl') |
| + | fg_collapse = os.path.join(self._fg_dir, 'stackcollapse.pl') |
| if not os.path.exists(fg_collapse): | |
| raise Exception(f'FlameGraph script not found: {fg_collapse}') | |
| - | |
| - | fg_gen_flame = os.path.join(fg_dir, 'flamegraph.pl') |
| - | if not os.path.exists(fg_gen_flame): |
| - | raise Exception(f'FlameGraph script not found: {fg_gen_flame}') |
| - | |
| - | file_collapsed = f'{dtrace.file}.collapsed' |
| - | file_svg = os.path.join(self._run_dir, 'curl.flamegraph.svg') |
| - | file_err = os.path.join(self._run_dir, 'curl.flamegraph.stderr') |
| - | log.info('waiting a sec for dtrace to finish flusheing its buffers') |
| - | time.sleep(1) |
| - | log.info(f'collapsing stacks into {file_collapsed}') |
| - | with open(file_collapsed, 'w') as cout, open(file_err, 'w') as cerr: |
| + | stacks_collapsed = f'{dtrace.file}.collapsed' |
| + | log.info(f'collapsing stacks into {stacks_collapsed}') |
| + | with open(stacks_collapsed, 'w') as cout, open(file_err, 'a') as cerr: |
| p = subprocess.run([ | |
| fg_collapse, dtrace.file | |
| ], stdout=cout, stderr=cerr, cwd=self._run_dir, shell=False) | |
| rc = p.returncode | |
| if rc != 0: | |
| raise Exception(f'{fg_collapse} returned error {rc}') | |
| + | return stacks_collapsed |
| + | |
| + | def _generate_flame(self, curl_args: List[str], |
| + | dtrace: Optional[DTraceProfile] = None, |
| + | perf: Optional[PerfProfile] = None): |
| + | fg_gen_flame = os.path.join(self._fg_dir, 'flamegraph.pl') |
| + | file_svg = os.path.join(self._run_dir, 'curl.flamegraph.svg') |
| + | if not os.path.exists(fg_gen_flame): |
| + | raise Exception(f'FlameGraph script not found: {fg_gen_flame}') |
| + | |
| + | log.info('waiting a sec for perf/dtrace to finish flushing') |
| + | time.sleep(2) |
| + | log.info('generating flame graph for this run') |
| + | file_err = os.path.join(self._run_dir, 'curl.flamegraph.stderr') |
| + | if perf: |
| + | stacks_collapsed = self._perf_collapse(perf, file_err) |
| + | elif dtrace: |
| + | stacks_collapsed = self._dtrace_collapse(dtrace, file_err) |
| + | else: |
| + | raise Exception('no stacks measure given') |
| + | |
| log.info(f'generating graph into {file_svg}') | |
| cmdline = ' '.join(curl_args) | |
| if len(cmdline) > 80: | |
| @@ -1043,11 +1120,11 @@ class CurlClient: | |
| else: | |
| title = cmdline | |
| subtitle = '' | |
| - | with open(file_svg, 'w') as cout, open(file_err, 'w') as cerr: |
| + | with open(file_svg, 'w') as cout, open(file_err, 'a') as cerr: |
| p = subprocess.run([ | |
| fg_gen_flame, '--colors', 'green', | |
| '--title', title, '--subtitle', subtitle, | |
| - | file_collapsed |
| + | stacks_collapsed |
| ], stdout=cout, stderr=cerr, cwd=self._run_dir, shell=False) | |
| rc = p.returncode | |
| if rc != 0: | |