#!/usr/bin/env python # Changes: # ------- # # 6: Improve Left/Right comparison processing time. # # 5: Left/right profile time comparisons. # # 4: --execute command line option. # # 3: Error: Accept any widget. # # 2: Fixed miscalculation of total CPU time. # # 1: First public release. """Layer Profiler - A simple interactive profiling tool. The Layer Profiler is an interface to profile a running program in real time, and browse and compare profile data later. Features ======== Multiple Profiles: The Layer Profiler uses a tabbed interface that allows for opening and recording multiple profiles at the same time. Loading/Saving: You can save profiles to disk, and reimport them for browsing and comparing later. The original program is not required to browse saved profiles. The saved files are in the CPython marshal format. Filtering: Profiles can be filtered, using "foo" to match and "!foo" to exclude. Compare and Highlight: Compare two profiles, highlighting functions with significantly improved or degraded performance. Usage ===== There are three ways to use the Layer Profiler. The first is to run it as a standalone executable. In this case the only thing it is capable of profiling interactively is itself. However, you can still load and search previously saved profiles: $ python -mprofiler saved.profile $ python ./profiler.py saved.profile Secondly, it can be invoked to profile a program which will be executed using excfile(); in this case it will profile the entire program, and do it non-interatively unless the program itself starts a GTK+ main loop. After the program finishes, the Layer Profiler interface will appear. This can be done using: $ python -mprofiler --execute ./myscript.py $ python ./profiler.py --execute ./myscript.py Or you can import it, start a GTK+ main loop, and instantiate a GProfiler window. It can then profile the application that started it. window = MyAppWindow() ... # make your window here button = gtk.Button(_("Profile")) button.connect('clicked', lambda *args: GProfiler().show()) ... # put the button in the window somehow gtk.main() Some convenience methods are provided to use GTK+ in your application. This module has no dependencies outside of PyGTK and CPython's standard library. """ import os import locale import marshal from cProfile import Profile import gobject import pango import gtk __all__ = ["GProfiler", "pyglet", "update"] # If the user hasn't set up gettext translation, fake it. try: _ except NameError: _ = lambda s: s __version__ = 6 __author__ = "Joe Wreschnig" __copyright__ = "Copyright 2009 Joe Wreschnig, Michael Urman" __website__ = "http://code.google.com/p/layer/wiki/LayerProfiler" __license__ = """\ Unless stated otherwise, BSD-style. http://yukkurigames.com/license.txt. TreeViewHints and HintedTreeView: GNU Lesser GPL version 2.1 or later.""" UI = """ """ def Error(title=None, text=None, parent=None): if text is None: text = _("An error occurred.") if parent is not None: parent = parent.get_toplevel() dialog = gtk.MessageDialog(parent=parent, type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK, message_format=title or text) if title: dialog.format_secondary_text(text) dialog.run() dialog.destroy() def Filename(action, parent): d = gtk.FileChooserDialog( parent=parent, action=action, buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_OK, gtk.RESPONSE_ACCEPT)) if d.run() == gtk.RESPONSE_ACCEPT: filename = d.get_filename() else: filename = None d.destroy() return filename # The TreeViewHints and HintedTreeView classes are licensed under the # GNU LGPL and not the 3 clause BSD license used by the rest of Layer. # If this is not acceptable to you, you can simply remove them from # this file, and everything will continue working (but without useful # tooltips). class TreeViewHints(gtk.Window): """Handle 'hints' for treeviews.""" __gsignals__ = dict.fromkeys( ['button-press-event', 'button-release-event', 'motion-notify-event', 'scroll-event'], 'override') def __init__(self): super(TreeViewHints, self).__init__(gtk.WINDOW_POPUP) self.__label = label = gtk.Label() label.set_alignment(0.5, 0.5) self.realize() self.add_events(gtk.gdk.BUTTON_MOTION_MASK | gtk.gdk.BUTTON_PRESS_MASK | gtk.gdk.BUTTON_RELEASE_MASK | gtk.gdk.KEY_PRESS_MASK | gtk.gdk.KEY_RELEASE_MASK | gtk.gdk.ENTER_NOTIFY_MASK | gtk.gdk.LEAVE_NOTIFY_MASK | gtk.gdk.SCROLL_MASK) self.add(label) self.set_app_paintable(True) self.set_resizable(False) self.set_name("gtk-tooltips") self.set_border_width(1) self.connect('expose-event', self.__expose) self.connect('enter-notify-event', self.__enter) self.connect('leave-notify-event', self.__check_undisplay) self.__handlers = {} self.__current_path = self.__current_col = None self.__current_renderer = None def connect_view(self, view): self.__handlers[view] = [ view.connect('motion-notify-event', self.__motion), view.connect('scroll-event', self.__undisplay), view.connect('key-press-event', self.__undisplay), view.connect('destroy', self.disconnect_view), ] def disconnect_view(self, view): try: for handler in self.__handlers[view]: view.disconnect(handler) del self.__handlers[view] except KeyError: pass def __expose(self, widget, event): w, h = self.get_size_request() self.style.paint_flat_box(self.window, gtk.STATE_NORMAL, gtk.SHADOW_OUT, None, self, "tooltip", 0, 0, w, h) def __enter(self, widget, event): # on entry, kill the hiding timeout try: gobject.source_remove(self.__timeout_id) except AttributeError: pass else: del self.__timeout_id def __motion(self, view, event): # trigger over row area, not column headers if event.window is not view.get_bin_window(): return if event.get_state() & gtk.gdk.MODIFIER_MASK: return x, y = map(int, [event.x, event.y]) try: path, col, cellx, celly = view.get_path_at_pos(x, y) except TypeError: return # no hints where no rows exist if self.__current_path == path and self.__current_col == col: return # need to handle more renderers later... try: renderer, = col.get_cell_renderers() except ValueError: return if not isinstance(renderer, gtk.CellRendererText): return if renderer.get_property('ellipsize') == pango.ELLIPSIZE_NONE: return model = view.get_model() col.cell_set_cell_data(model, model.get_iter(path), False, False) cellw = col.cell_get_position(renderer)[1] label = self.__label label.set_ellipsize(pango.ELLIPSIZE_NONE) label.set_text(renderer.get_property('text')) w, h0 = label.get_layout().get_pixel_size() try: markup = renderer.markup except AttributeError: pass else: if isinstance(markup, int): markup = model[path][markup] label.set_markup(markup) w, h1 = label.get_layout().get_pixel_size() if w + 5 < cellw: return # don't display if it doesn't need expansion x, y, cw, h = list(view.get_cell_area(path, col)) self.__dx = x self.__dy = y y += view.get_bin_window().get_position()[1] ox, oy = view.window.get_origin() x += ox; y += oy; w += 5 if gtk.gtk_version >= (2,8,0): w += 1 # width changed in 2.8? screen_width = gtk.gdk.screen_width() x_overflow = min([x, x + w - screen_width]) label.set_ellipsize(pango.ELLIPSIZE_NONE) if x_overflow > 0: self.__dx -= x_overflow x -= x_overflow w = min([w, screen_width]) label.set_ellipsize(pango.ELLIPSIZE_END) if not((x<=int(event.x_root) < x+w) and (y <= int(event.y_root) < y+h)): return # reject if cursor isn't above hint self.__target = view self.__current_renderer = renderer self.__edit_id = renderer.connect('editing-started', self.__undisplay) self.__current_path = path self.__current_col = col self.__time = event.time self.__timeout(id=gobject.timeout_add(100, self.__undisplay)) self.set_size_request(w, h) self.resize(w, h) self.move(x, y) self.show_all() def __check_undisplay(self, ev1, event): if self.__time < event.time + 50: self.__undisplay() def __undisplay(self, *args): if self.__current_renderer and self.__edit_id: self.__current_renderer.disconnect(self.__edit_id) self.__current_renderer = self.__edit_id = None self.__current_path = self.__current_col = None self.hide() def __timeout(self, ev=None, event=None, id=None): try: gobject.source_remove(self.__timeout_id) except AttributeError: pass if id is not None: self.__timeout_id = id def __event(self, event): if event.type != gtk.gdk.SCROLL: event.x += self.__dx event.y += self.__dy # modifying event.window is a necessary evil, made okay because # nobody else should tie to any TreeViewHints events ever. event.window = self.__target.get_bin_window() gtk.main_do_event(event) return True def do_button_press_event(self, event): return self.__event(event) def do_button_release_event(self, event): return self.__event(event) def do_motion_notify_event(self, event): return self.__event(event) def do_scroll_event(self, event): return self.__event(event) class HintedTreeView(gtk.TreeView): """A TreeView that pops up a tooltip for truncated text.""" def __init__(self, *args): super(HintedTreeView, self).__init__(*args) try: tvh = HintedTreeView.hints except AttributeError: tvh = HintedTreeView.hints = TreeViewHints() tvh.connect_view(self) class ProfileModel(gtk.ListStore): def matches(model, column, key, iter): filename = model[iter][1] callname = model[iter][2] if key in filename.lower(): return False elif key in callname.lower(): return False else: return True matches = staticmethod(matches) def __init__(self, stats=None): super(ProfileModel, self).__init__( object, # code str, # filename str, # callable name int, # line number int, # call count int, # recursive call count float, # total time float, # inline time float, # %totaltime delta to left float, # %totaltime delta to right float, # %inlinetime delta to left float, # %inlinetime delta to right ) self.totaltime = 0 self.totalcalls = 0 for stat in (stats or []): self.append(row=stat) self.totalcalls += stat[3] self.totaltime += stat[7] def _get_stats(self): return [tuple(row) for row in self] def _set_stats(self, stats): self.clear() filenames = set() self.totalcalls = 0 self.totaltime = 0 for entry in stats: if isinstance(entry.code, str): filename = ("") callname = entry.code line = 0 else: filename = entry.code.co_filename filenames.add(filename) callname = entry.code.co_name line = entry.code.co_firstlineno self.append(row=[entry.code, filename, callname, line, entry.callcount, entry.reccallcount, entry.totaltime, entry.inlinetime, 0, # %total/call delta to left 0, # %total/call delta to right 0, # %inline/call delta to left 0, # %inline/call delta to right ]) self.totalcalls += entry.callcount self.totaltime += entry.inlinetime def compare(self, othermodel, right=False): other = {} # Filter the other model so it's much faster to look up # and iterate through. for i, row in enumerate(othermodel): other.setdefault((row[1], row[2]), []).append(row) for row in self: best = None for other_row in other.get((row[1], row[2]), []): if (best is None or abs(row[3] - other_row[3]) < abs(row[3] - best[3])): best = other_row if best: # total/call mine = row[6] / row[4] yours = best[6] / best[4] row[8 + right] = (mine - yours) / yours best[8 + (not right)] = (yours - mine) / mine # inline / call mine = row[7] / row[4] yours = best[7] / best[4] row[10 + right] = (mine - yours) / yours best[10 + (not right)] = (yours - mine) / mine stats = property(_get_stats, _set_stats) class FilterEntry(gtk.Entry): def __init__(self, model): super(FilterEntry, self).__init__() self.connect_object('changed', self._compile, model) self.set_tooltip_text( _("Enter search terms. Use '!' as a prefix to exclude terms.")) model.set_visible_func(self.filter) self._include = [] self._exclude = [] def _compile(self, model): self._include = [] self._exclude = [] for token in self.get_text().lower().split(): if token[0] == '!': self._exclude.append(token[1:]) else: self._include.append(token) model.refilter() def filter(self, model, iter): code = model[iter][0] if code is None: return False if isinstance(code, str): text = code else: text = code.co_name + " " + code.co_filename text = text.lower() for token in self._exclude: if token in text: return False for token in self._include: if token not in text: return False return True class ProfilerTab(gtk.VBox): def __init__(self, stats=None): super(ProfilerTab, self).__init__() toolbar = gtk.HBox(spacing=6) self.pack_start(toolbar, expand=False, fill=True) self.model = ProfileModel(stats) self.profile = not stats and Profile() self.compare_left = False self.compare_right = False cleft = gtk.CheckButton(_("\xce\x94_Left")) cright = gtk.CheckButton(_("\xce\x94R_ight")) cleft.connect('toggled', self._toggle_compare_left) cright.connect('toggled', self._toggle_compare_right) toggle = gtk.ToggleButton(gtk.STOCK_MEDIA_RECORD) toggle.set_use_stock(True) toggle.set_active(False) toggle.set_sensitive(bool(self.profile)) toggle.connect('toggled', self.__toggle) toolbar.pack_start(toggle, expand=False) if not self.profile: toggle.set_tooltip_text( _("Loaded profiles cannot resume recording.")) filtered = self.model.filter_new() align = gtk.Alignment(xscale=1.0, yscale=1.0) align.set_padding(0, 0, 6, 0) label = gtk.Label() self.entry = FilterEntry(filtered) label.set_mnemonic_widget(self.entry) label.set_text_with_mnemonic(_("_Filter:")) try: self.view = HintedTreeView(gtk.TreeModelSort(filtered)) except NameError: self.view = gtk.TreeView(gtk.TreeModelSort(filtered)) self.view.connect('row-activated', self.__open_to_line) self.view.set_enable_search(True) self.view.set_search_equal_func(self.model.matches) sw = gtk.ScrolledWindow() sw.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) sw.set_shadow_type(gtk.SHADOW_IN) sw.add(self.view) box = gtk.HBox(spacing=3) box.pack_start(label, expand=False) box.pack_start(self.entry) align.add(box) self.pack_start(sw, expand=True) toolbar.pack_start(align) cell = gtk.CellRendererText() pcell = gtk.CellRendererText() ellipsizecell = gtk.CellRendererText() ellipsizecell.props.ellipsize = pango.ELLIPSIZE_MIDDLE def percent_cdf(column, cell, model, iter): value = model[iter][column.get_sort_column_id()] cell.props.text = "%+.2f%%" % (100 * value) color = int((1.0 - min(abs(value), 0.5)) * 65535) cell.props.background_set = abs(value) > 0.1 if value > 0.1: # got worse cell.props.background_gdk = gtk.gdk.Color(65535, color, color) elif value < -0.1: # got better cell.props.background_gdk = gtk.gdk.Color(color, 65535, color) column = gtk.TreeViewColumn(_("\xe2\x86\x90 \xce\x94Total"), pcell) column.set_cell_data_func(pcell, percent_cdf) column.set_sort_column_id(8) column.props.visible = False self._cleft_total = column self.view.append_column(column) column = gtk.TreeViewColumn(_("\xe2\x86\x90 \xce\x94Inline"), pcell) column.set_cell_data_func(pcell, percent_cdf) column.set_sort_column_id(10) column.props.visible = False self._cleft_inline = column self.view.append_column(column) column = gtk.TreeViewColumn(_("Filename"), ellipsizecell) column.add_attribute(ellipsizecell, 'text', 1) column.set_sort_column_id(1) column.set_resizable(True) column.set_expand(True) self.view.append_column(column) column = gtk.TreeViewColumn(_("Function"), ellipsizecell) column.add_attribute(ellipsizecell, 'text', 2) column.set_sort_column_id(2) column.set_resizable(True) column.set_expand(True) self.view.append_column(column) column = gtk.TreeViewColumn(_("Call #"), cell) column.add_attribute(cell, 'text', 4) column.set_sort_column_id(4) self.view.append_column(column) column = gtk.TreeViewColumn(_("Total"), cell) column.add_attribute(cell, 'text', 6) column.set_sort_column_id(6) self.view.append_column(column) column = gtk.TreeViewColumn(_("Inline"), cell) column.add_attribute(cell, 'text', 7) column.set_sort_column_id(7) self.view.append_column(column) # FIXME: These don't really seem useful. #column = gtk.TreeViewColumn(_("% Total"), cell) #column.set_sort_column_id(6) #column.set_cell_data_func(cell, percent_cdf, "totaltime") #self.view.append_column(column) #column = gtk.TreeViewColumn(_("% Inline"), cell) #column.set_sort_column_id(7) #column.set_cell_data_func(cell, percent_cdf, "totaltime") #self.view.append_column(column) column = gtk.TreeViewColumn(_("\xce\x94Inline \xe2\x86\x92"), pcell) column.set_cell_data_func(pcell, percent_cdf) column.set_sort_column_id(11) column.props.visible = False self._cright_inline = column self.view.append_column(column) column = gtk.TreeViewColumn(_("\xce\x94Total \xe2\x86\x92"), pcell) column.set_cell_data_func(pcell, percent_cdf) column.set_sort_column_id(9) column.props.visible = False self._cright_total = column self.view.append_column(column) self.totalstats = gtk.Statusbar() self.totalstats.props.has_resize_grip = False self.totalstats.props.style.shadow_type = gtk.SHADOW_NONE self.totalstats.set_spacing(12) self.totalstats.pack_end(cright, expand=False) self.totalstats.pack_start(cleft, expand=False) self.totalstats.reorder_child(cleft, 0) self.pack_start(self.totalstats, expand=False) self.entry.grab_focus() self.running = False self.connect('destroy', self.__on_destroy) if stats is not None: self.snapshot() def _toggle_compare_left(self, cb): self.compare_left = cb.get_active() self._cleft_inline.props.visible = self.compare_left self._cleft_total.props.visible = self.compare_left self.get_ancestor(GProfiler).compare() def _toggle_compare_right(self, cb): self.compare_right = cb.get_active() self._cright_inline.props.visible = self.compare_right self._cright_total.props.visible = self.compare_right self.get_ancestor(GProfiler).compare() def __on_destroy(self, *args): self.compare_right = False self.compare_left = False self.stop() def __open_to_line(self, view, path, column): code = view.get_model()[path][0] try: lineno = "+%d" % code.co_firstlineno filename = code.co_filename except AttributeError: pass else: args = ['sensible-editor', lineno, filename] gobject.spawn_async(args, flags=gobject.SPAWN_SEARCH_PATH) def __toggle(self, button): """Turn profiling on if off and vice versa.""" if button.get_active(): self.start() else: self.stop() def snapshot(self): """Update the UI with a snapshot of the current profile state.""" if self.profile: self.model.stats = self.profile.getstats() text = _("%(calls)d calls in %(time)f CPU seconds.") % dict( calls=self.model.totalcalls, time=self.model.totaltime) self.totalstats.pop(0) self.totalstats.push(0, text) def save(self, filename): try: fileobj = file(filename, "wb") fileobj.write(marshal.dumps(self.model.stats)) fileobj.close() except Exception, err: err = str(err).decode(locale.getpreferredencoding(), 'replace') Error(_("Unable to save"), err, parent=self.get_toplevel()) else: self.parent.set_tab_label_text(self, os.path.basename(filename)) def start(self): """Start profiling (adding to existing stats).""" if not self.running and self.profile: self.profile.enable() self.running = True def stop(self): """Stop profiling (but retain stats).""" if self.running and self.profile: self.profile.disable() self.running = False self.snapshot() class GProfiler(gtk.Window): """A window containing tabbed profiling data. The window is not visible by default. You should call .show() on it after it is constructed. """ def __init__(self): """Construct a new Profiler window.""" super(GProfiler, self).__init__() self.set_title(_("Layer Profiler")) self.set_default_size(400, 300) self.add(gtk.VBox()) self.count = 0 self.tabs = [] self.notebook = gtk.Notebook() self.notebook.set_scrollable(True) self.notebook.connect('page-added', self.__page_count_changed) self.notebook.connect('page-removed', self.__page_count_changed) self.ui = gtk.UIManager() actions = gtk.ActionGroup("ProfilerActions") actions.add_actions( [('Profiles', None, _("_Profiles")), ('NewProfile', gtk.STOCK_NEW, None, None, None, self.__new_tab), ('OpenProfile', gtk.STOCK_OPEN, None, None, None, self.__open_tab), ('SaveProfile', gtk.STOCK_SAVE, None, None, None, self.__save_tab), ('CloseProfile', gtk.STOCK_CLOSE, None, None, None, self.__close_tab), ("Quit", gtk.STOCK_QUIT, None, None, None, lambda a: self.destroy()), ("Help", None, _("_Help")), ("About", gtk.STOCK_ABOUT, None, None, None, self.__about), ]) self.ui.insert_action_group(actions, -1) self.ui.add_ui_from_string(UI) self.add_accel_group(self.ui.get_accel_group()) self.child.pack_start(self.ui.get_widget("/Menu"), expand=False) self.child.pack_start(self.notebook) self.__new_tab() self.child.show_all() def compare(self): previous = None for i, tab in enumerate(self.tabs): if previous and previous.compare_right: previous.model.compare(tab.model, right=True) elif previous and tab.compare_left: tab.model.compare(previous.model, right=False) previous = tab def __save_tab(self, *args): i = self.notebook.get_current_page() tab = self.notebook.get_nth_page(i) if tab: filename = Filename(action=gtk.FILE_CHOOSER_ACTION_SAVE, parent=self) if filename: tab.save(filename) def __close_tab(self, *args): i = self.notebook.get_current_page() tab = self.notebook.get_nth_page(i) if tab and self.notebook.get_n_pages() > 1: self.notebook.remove_page(i) tab.destroy() self.tabs.remove(tab) def __page_count_changed(self, notebook, child, pagenum): self.ui.get_widget("/Menu/Profiles/CloseProfile").set_sensitive( notebook.get_n_pages() > 1) self.compare() def __new_tab(self, *args): self.count += 1 label = gtk.Label(_("Profile %d") % (self.count)) tab = ProfilerTab() label.show_all() tab.show_all() self.notebook.set_current_page(self.notebook.append_page(tab, label)) self.tabs.append(tab) def new(self): """Open a new empty profiler tab.""" self.__new_tab() def open(self, filename): """Open a tab to browse the given saved profile.""" try: stats = marshal.load(file(filename, "rb")) self.count += 1 label = gtk.Label(os.path.basename(filename)) tab = ProfilerTab(stats) except Exception, err: err = str(err).decode(locale.getpreferredencoding(), 'replace') Error(_("Unable to open"), err, parent=self.get_toplevel()) else: label.show_all() tab.show_all() self.notebook.set_current_page( self.notebook.append_page(tab, label)) self.ui.get_widget("/Menu/Profiles/CloseProfile").set_sensitive( self.notebook.get_n_pages() > 1) def __open_tab(self, filename): filename = Filename(action=gtk.FILE_CHOOSER_ACTION_OPEN, parent=self) if filename: self.open(filename) def __about(self, *args): a = gtk.AboutDialog() a.set_version(str(__version__)) a.set_copyright(__copyright__) a.set_license("\n".join([__copyright__, __license__])) a.set_website(__website__) a.set_program_name(_("Layer Profiler")) a.set_comments(_("A simple interactive profiling tool.")) a.connect('close', lambda *args: a.destroy()) a.connect('response', lambda *args: a.destroy()) a.show_all() def update(*args, **kwargs): """Hook into a generic main loop. Calling this function once a frame will run GTK+. The arguments are irrelevant. """ i = 0 while gtk.events_pending() and i < 5: gtk.main_iteration() i += 1 def pyglet(): """Hook GTK+ into the pyglet main loop. After calling this you can run GProfiler() as necessary, and it will work correctly within a running pyglet application. """ import pyglet pyglet.clock.schedule(update) if __name__ == "__main__": import sys argv = sys.argv[1:] filenames = [] execute = [] while argv: arg = argv.pop(0) if arg.lower() in ["--version", "-v", "/version", "/v"]: print >>sys.stderr, _( "Layer Profiler %d - A simple interactive profiling tool.") % ( __version__) raise SystemExit elif arg.lower() in ["--help", "-h", "/help", "/h", "/?"]: print _("Usage:\t%s [filename] ... [--execute script.py ...]\n" " or:\tpydoc %s") % ( sys.argv[0], os.path.splitext(os.path.basename(sys.argv[0]))[0]) raise SystemExit elif arg.lower() in ["--execute", "-e", "/execute", "/e", "--exec", "/exec"]: execute = argv[:] argv = [] else: filenames.append(arg) w = GProfiler() for filename in filenames: w.open(filename) w.connect('destroy', gtk.main_quit) w.show() if execute: sys.argv = execute sys.path.insert(0, os.path.dirname(sys.argv[0])) w.tabs[0].start() try: execfile(sys.argv[0]) except StandardError, err: # stop ASAP so the error handler isn't profiled. w.tabs[0].stop() import traceback traceback.print_exc() err = traceback.format_exc().splitlines()[-1] Error(_("Abnormal termination"), _("%s was abnormally terminated. The error was:\n%s") %( sys.argv[0], err.decode(locale.getpreferredencoding(), 'replace'))) w.tabs[0].stop() gtk.main()