diff --git a/benchmark.py b/benchmark.py index 72e39ea4e..908037fa8 100755 --- a/benchmark.py +++ b/benchmark.py @@ -5,19 +5,74 @@ import argparse import fcntl import os import select +import shutil import signal import struct +import subprocess import sys import termios import time from pty import CHILD, fork -from kitty.constants import kitten_exe +from kitty.constants import kitten_exe, kitty_exe from kitty.fast_data_types import ChildMonitor, Screen, safe_pipe from kitty.utils import read_screen_size BENCHMARK_WINDOW_ID = 1 ALL_BENCHMARKS = ('ascii', 'unicode', 'unique_unicode', 'csi', 'images', 'long_escape_codes') +PERF_OUTPUT = '/tmp/kitty-benchmark.perf' + +# Set by the re-exec wrapper so we don't recurse when --perf is in argv. +_UNDER_PERF_ENV = '_KITTY_BENCHMARK_UNDER_PERF' + + +def find_perf() -> str | None: + return shutil.which('perf') + + +def run_perf_reports(perf_exe: str) -> None: + sep = '=' * 70 + print(f'\n{sep}') + print('PERF PROFILING RESULTS') + print(sep) + print(f'Profile data saved to: {PERF_OUTPUT}') + print(f'Re-run interactively: perf report -i {PERF_OUTPUT}\n') + + print('--- Top CPU hotspots (call graph, >=0.5% threshold) ---\n') + subprocess.run( + [ + perf_exe, + 'report', + '--stdio', + '-n', + '--call-graph', + 'fractal,0.5', + '--percent-limit', + '0.5', + '-i', + PERF_OUTPUT, + ], + check=False, + ) + + print('\n--- Per-thread CPU breakdown ---\n') + subprocess.run( + [ + perf_exe, + 'report', + '--stdio', + '-n', + '--sort', + 'overhead,tid,comm,symbol', + '--percent-limit', + '1.0', + '-i', + PERF_OUTPUT, + ], + check=False, + ) + + print(f'\n{sep}\n') def run_parsing_benchmark( @@ -90,6 +145,35 @@ def run_parsing_benchmark( sys.stdout.write(str(screen.linebuf)) +def exec_under_perf(perf_exe: str) -> None: + """Re-exec this script as a child of perf record. + + perf becomes the outer process so it can profile the entire benchmark + run without any subprocess/SIGCHLD conflicts with ChildMonitor. + After the benchmark exits perf finalises its output, then we run + perf report to print the results. + """ + script = os.path.abspath(__file__) + env = {**os.environ, _UNDER_PERF_ENV: '1'} + cmd = [ + perf_exe, + 'record', + '-g', + '-F', + '999', + '--call-graph', + 'dwarf', + '-o', + PERF_OUTPUT, + '--', + kitty_exe(), + '+launch', + script, + ] + sys.argv[1:] + subprocess.run(cmd, env=env, check=False) + run_perf_reports(perf_exe) + + def main() -> None: p = argparse.ArgumentParser(description='Run kitty parsing benchmarks') p.add_argument( @@ -106,7 +190,26 @@ def main() -> None: default=True, help='Use the main screen instead of the alt screen so scrollback speed is also tested (default: enabled)', ) + p.add_argument( + '--perf', + action='store_true', + default=False, + help=( + 'Profile with Linux perf: records at 999 Hz with DWARF call graphs, ' + 'then prints per-thread CPU breakdown and call-graph hotspots before benchmark results. ' + 'Requires perf in PATH with setcap cap_sys_admin,cap_sys_ptrace,cap_syslog=ep /usr/bin/perf' + ), + ) args = p.parse_args() + + if args.perf and not os.environ.get(_UNDER_PERF_ENV): + perf_exe = find_perf() + if perf_exe is None: + print('Warning: perf not found in PATH, running without profiling', file=sys.stderr) + else: + exec_under_perf(perf_exe) + return + benchmarks = tuple(args.benchmarks) if args.benchmarks else ALL_BENCHMARKS run_parsing_benchmark(benchmarks=benchmarks, with_scrollback=args.with_scrollback) diff --git a/docs/performance.rst b/docs/performance.rst index d513fd251..6912a6ab3 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -142,9 +142,15 @@ admittedly biased, eyes). Instrumenting kitty ----------------------- -You can generate detailed per-function performance data using -`gperftools `__. Build |kitty| with -``make profile``. Run kitty and perform the task you want to analyse, for -example, scrolling a large file with :program:`less`. After you quit, function -call statistics will be displayed in *KCachegrind*. Hence, profiling is best done -on Linux which has these tools easily available. +To profile kitty performance on Linux, first install ``perf`` and run:: + + sudo setcap cap_sys_admin,cap_sys_ptrace,cap_syslog=ep /usr/bin/perf + +Then build kitty with:: + + make profile + +Finally get benchmark and profiling results using:: + + ./benchmark.py --perf + diff --git a/kitty/data-types.c b/kitty/data-types.c index 174c87be1..c0f1a421f 100644 --- a/kitty/data-types.c +++ b/kitty/data-types.c @@ -31,10 +31,6 @@ #include #include -#ifdef WITH_PROFILER -#include -#endif - #include "monotonic.h" #ifdef __APPLE__ @@ -351,22 +347,6 @@ pyset_iutf8(PyObject UNUSED *self, PyObject *args) { Py_RETURN_NONE; } -#ifdef WITH_PROFILER -static PyObject * -start_profiler(PyObject UNUSED *self, PyObject *args) { - char *path; - if (!PyArg_ParseTuple(args, "s", &path)) return NULL; - ProfilerStart(path); - Py_RETURN_NONE; -} - -static PyObject * -stop_profiler(PyObject UNUSED *self, PyObject *args UNUSED) { - ProfilerStop(); - Py_RETURN_NONE; -} -#endif - static bool put_tty_in_raw_mode(int fd, const struct termios *termios_p, bool read_with_timeout, int optional_actions) { struct termios raw_termios = *termios_p; @@ -880,10 +860,6 @@ static PyMethodDef module_methods[] = { #ifdef __APPLE__ METHODB(user_cache_dir, METH_NOARGS), METHODB(process_group_map, METH_NOARGS), -#endif -#ifdef WITH_PROFILER - {"start_profiler", (PyCFunction)start_profiler, METH_VARARGS, ""}, - {"stop_profiler", (PyCFunction)stop_profiler, METH_NOARGS, ""}, #endif {NULL, NULL, 0, NULL} /* Sentinel */ }; diff --git a/setup.py b/setup.py index 5020c6101..d1330c1ca 100755 --- a/setup.py +++ b/setup.py @@ -675,7 +675,6 @@ def init_env( if profile: cppflags.append('-DWITH_PROFILER') cflags.append('-g3') - ldflags.append('-lprofiler') if debug or profile: cflags.append('-fno-omit-frame-pointer') @@ -1602,8 +1601,6 @@ def build_launcher(args: Options, launcher_dir: str = '.', bundle_type: str = 's cflags.extend(sanitize_args) ldflags.extend(sanitize_args) libs += ['-lasan'] if not is_macos and env.compiler_type is not CompilerType.clang else [] - if args.profile: - libs.append('-lprofiler') else: cflags.append('-g3' if args.debug else '-O3') if bundle_type.endswith('-freeze'): @@ -2299,7 +2296,7 @@ def option_parser() -> argparse.ArgumentParser: # {{{ ' the Python used to run setup.py is queried for these.', ) p.add_argument('--full', dest='incremental', default=Options.incremental, action='store_false', help='Do a full build, even for unchanged files') - p.add_argument('--profile', default=Options.profile, action='store_true', help='Use the -pg compile flag to add profiling information') + p.add_argument('--profile', default=Options.profile, action='store_true', help='Use compile flags to add profiling information') p.add_argument( '--libdir-name', default=Options.libdir_name, help='The name of the directory inside --prefix in which to store compiled files. Defaults to "lib"' )