Skip to content

Commit

Permalink
Add comparing options.
Browse files Browse the repository at this point in the history
  • Loading branch information
KasiaS999 authored and jrfonseca committed Jun 6, 2024
1 parent 7912159 commit 4d8a733
Show file tree
Hide file tree
Showing 8 changed files with 5,416 additions and 11 deletions.
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ It can:
* prune nodes and edges below a certain threshold;
* use an heuristic to propagate time inside mutually recursive functions;
* use color efficiently to draw attention to hot-spots;
* work on any platform where Python and Graphviz is available, i.e, virtually anywhere.
* work on any platform where Python and Graphviz is available, i.e, virtually anywhere;
* compare two graphs with identical structures for the analysis of performance metrics such as time or function calls.

**If you want an interactive viewer for the graphs generated by _gprof2dot_, check [xdot.py](https://github.com/jrfonseca/xdot.py).**

Expand Down Expand Up @@ -129,7 +130,24 @@ Options:
variety to lower percentages. Values > 1.0 give less
variety to lower percentages
-p FILTER_PATHS, --path=FILTER_PATHS
Filter all modules not in a specified path
Filter all modules not in a specified path
--compare Compare two graphs with identical structure. With this
option two files should be provided.gprof2dot.py
[options] --compare [file1] [file2] ...
--compare-tolerance=TOLERANCE
Tolerance threshold for node difference
(default=0.001%).If the difference is below this value
the nodes are considered identical.
--compare-only-slower
Display comparison only for function which are slower
in second graph.
--compare-only-faster
Display comparison only for function which are faster
in second graph.
--compare-color-by-difference
Color nodes based on the value of the difference.
Nodes with the largest differences represent the hot
spots.
```

## Examples
Expand Down Expand Up @@ -244,6 +262,32 @@ Example usage:
py-spy record -p <pidfile> -f raw -o out.collapse
gprof2dot.py -f collapse out.collapse | dot -Tpng -o output.png

## Compare Example

This image illustrates an example usage of the `--compare` and `--compare-color-by-difference` options.

![Compare](./images/compare_diff.png)

Arrow pointing to the right indicate node where the function performed faster
in the profile provided as the second one (second profile), while arrow
pointing to the left indicate node where the function was faster in the profile
provided as the first one (first profile).

### Node

+-----------------------------+
| function name \
| total time % -/+ total_diff \
| ( self time % ) -/+ self_diff /
| total calls1 / total calls2 /
+-----------------------------+

Where
- `total time %` and `self time %` come from the first profile
- `diff` is calculated as the absolute value of `time in the first profile - time in the second profile`.

> **Note** The compare option has been tested for pstats and callgrind profiles.
## Output

A node in the output graph represents a function and has the following layout:
Expand Down
198 changes: 192 additions & 6 deletions gprof2dot.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ def add(a, b):
def fail(a, b):
assert False

# To enhance readability, labels are rounded to the number of decimal
# places corresponding to the tolerance value.
def round_difference(difference, tolerance):
n = -math.floor(math.log10(tolerance))
return round(difference, n)


def rescale_difference(x, min_val, max_val):
return (x - min_val) / (max_val - min_val)


def min_max_difference(profile1, profile2):
f1_events = [f1[TOTAL_TIME_RATIO] for _, f1 in sorted_iteritems(profile1.functions)]
f2_events = [f2[TOTAL_TIME_RATIO] for _, f2 in sorted_iteritems(profile2.functions)]
if len(f1_events) != len(f2_events):
raise Exception('Number of nodes in provided profiles are not equal')
differences = [abs(f1_events[i] - f2_events[i]) * 100 for i in range(len(f1_events))]

return min(differences), max(differences)


tol = 2 ** -23

Expand Down Expand Up @@ -3252,6 +3272,122 @@ def wrap_function_name(self, name):
show_function_events = [TOTAL_TIME_RATIO, TIME_RATIO]
show_edge_events = [TOTAL_TIME_RATIO, CALLS]

def graphs_compare(self, profile1, profile2, theme, options):
self.begin_graph()

fontname = theme.graph_fontname()
fontcolor = theme.graph_fontcolor()
nodestyle = theme.node_style()

tolerance, only_slower, only_faster, color_by_difference = (
options.tolerance, options.only_slower, options.only_faster, options.color_by_difference)
self.attr('graph', fontname=fontname, ranksep=0.25, nodesep=0.125)
self.attr('node', fontname=fontname, style=nodestyle, fontcolor=fontcolor, width=0, height=0)
self.attr('edge', fontname=fontname)

functions2 = {function.name: function
for _, function in sorted_iteritems(profile2.functions)}
if color_by_difference:
min_diff, max_diff = min_max_difference(profile1, profile2)
for _, function1 in sorted_iteritems(profile1.functions):
labels = []

name = function1.name
try:
function2 = functions2[name]
except KeyError:
raise Exception(f'Provided profiles does not have identical '
f'structure, function that differ {name}')
if self.wrap:
name = self.wrap_function_name(name)
labels.append(name)
weight_difference = 0
shape = 'box'
orientation = '0'
for event in self.show_function_events:
if event in function1.events:
event1 = function1[event]
event2 = function2[event]

difference = abs(event1 - event2) * 100

if event == TOTAL_TIME_RATIO:
weight_difference = difference
if difference >= tolerance:
if event2 > event1 and not only_faster:
shape = 'cds'
label = (f'{event.format(event1)} +'
f' {round_difference(difference, tolerance)}%')
elif event2 < event1 and not only_slower:
orientation = "90"
shape = 'cds'
label = (f'{event.format(event1)} - '
f'{round_difference(difference, tolerance)}%')
else:
# protection to not color by difference if we choose to show only_faster/only_slower
weight_difference = 0
label = event.format(function1[event])
else:
weight_difference = 0
label = event.format(function1[event])
else:
if difference >= tolerance:
if event2 > event1:
label = (f'{event.format(event1)} +'
f' {round_difference(difference, tolerance)}%')
elif event2 < event1:
label = (f'{event.format(event1)} - '
f'{round_difference(difference, tolerance)}%')
else:
label = event.format(function1[event])

labels.append(label)

if function1.called is not None:
labels.append(f"{function1.called} {MULTIPLICATION_SIGN}/ {function2.called} {MULTIPLICATION_SIGN}")

if color_by_difference and weight_difference:
# min and max is calculated whe color_by_difference is true
weight = rescale_difference(weight_difference, min_diff, max_diff)

elif function1.weight is not None and not color_by_difference:
weight = function1.weight
else:
weight = 0.0

label = '\n'.join(labels)

self.node(function1.id,
label=label,
orientation=orientation,
color=self.color(theme.node_bgcolor(weight)),
shape=shape,
fontcolor=self.color(theme.node_fgcolor(weight)),
fontsize="%f" % theme.node_fontsize(weight),
tooltip=function1.filename,
)

calls2 = {call.callee_id: call for _, call in sorted_iteritems(function2.calls)}
for _, call1 in sorted_iteritems(function1.calls):
call2 = calls2[call1.callee_id]
labels = []
for event in self.show_edge_events:
if event in call1.events:
label = f'{event.format(call1[event])} / {event.format(call2[event])}'
labels.append(label)
weight = 0 if color_by_difference else call1.weight
label = '\n'.join(labels)
self.edge(function1.id, call1.callee_id,
label=label,
color=self.color(theme.edge_color(weight)),
fontcolor=self.color(theme.edge_color(weight)),
fontsize="%.2f" % theme.edge_fontsize(weight),
penwidth="%.2f" % theme.edge_penwidth(weight),
labeldistance="%.2f" % theme.edge_penwidth(weight),
arrowsize="%.2f" % theme.edge_arrowsize(weight),
)
self.end_graph()

def graph(self, profile, theme):
self.begin_graph()

Expand Down Expand Up @@ -3543,9 +3679,36 @@ def main(argv=sys.argv[1:]):
'-p', '--path', action="append",
type="string", dest="filter_paths",
help="Filter all modules not in a specified path")
optparser.add_option(
'--compare',
action="store_true",
dest="compare", default=False,
help="Compare two graphs with identical structure. With this option two files should be provided."
"gprof2dot.py [options] --compare [file1] [file2] ...")
optparser.add_option(
'--compare-tolerance',
type="float", dest="tolerance", default=0.001,
help="Tolerance threshold for node difference (default=0.001%)."
"If the difference is below this value the nodes are considered identical.")
optparser.add_option(
'--compare-only-slower',
action="store_true",
dest="only_slower", default=False,
help="Display comparison only for function which are slower in second graph.")
optparser.add_option(
'--compare-only-faster',
action="store_true",
dest="only_faster", default=False,
help="Display comparison only for function which are faster in second graph.")
optparser.add_option(
'--compare-color-by-difference',
action="store_true",
dest="color_by_difference", default=False,
help="Color nodes based on the value of the difference. "
"Nodes with the largest differences represent the hot spots.")
(options, args) = optparser.parse_args(argv)

if len(args) > 1 and options.format != 'pstats':
if len(args) > 1 and options.format != 'pstats' and not options.compare:
optparser.error('incorrect number of arguments')

try:
Expand All @@ -3567,6 +3730,11 @@ def main(argv=sys.argv[1:]):
if Format.stdinInput:
if not args:
fp = sys.stdin
elif options.compare:
fp1 = open(args[0], 'rt', encoding='UTF-8')
fp2 = open(args[1], 'rt', encoding='UTF-8')
parser1 = Format(fp1)
parser2 = Format(fp2)
else:
fp = open(args[0], 'rb')
bom = fp.read(2)
Expand All @@ -3577,17 +3745,25 @@ def main(argv=sys.argv[1:]):
encoding = 'utf-8'
fp.seek(0)
fp = io.TextIOWrapper(fp, encoding=encoding)
parser = Format(fp)
parser = Format(fp)
elif Format.multipleInput:
if not args:
optparser.error('at least a file must be specified for %s input' % options.format)
parser = Format(*args)
if options.compare:
parser1 = Format(args[-2])
parser2 = Format(args[-1])
else:
parser = Format(*args)
else:
if len(args) != 1:
optparser.error('exactly one file must be specified for %s input' % options.format)
parser = Format(args[0])

profile = parser.parse()
if options.compare:
profile1 = parser1.parse()
profile2 = parser2.parse()
else:
profile = parser.parse()

if options.output is None:
output = open(sys.stdout.fileno(), mode='wt', encoding='UTF-8', closefd=False)
Expand All @@ -3603,7 +3779,14 @@ def main(argv=sys.argv[1:]):
if options.show_samples:
dot.show_function_events.append(SAMPLES)

profile.prune(options.node_thres/100.0, options.edge_thres/100.0, options.filter_paths, options.color_nodes_by_selftime)
if options.compare:
profile1.prune(options.node_thres/100.0, options.edge_thres/100.0, options.filter_paths,
options.color_nodes_by_selftime)
profile2.prune(options.node_thres/100.0, options.edge_thres/100.0, options.filter_paths,
options.color_nodes_by_selftime)
else:
profile.prune(options.node_thres/100.0, options.edge_thres/100.0, options.filter_paths,
options.color_nodes_by_selftime)

if options.list_functions:
profile.printFunctionIds(selector=options.list_functions)
Expand All @@ -3622,7 +3805,10 @@ def main(argv=sys.argv[1:]):
sys.exit(1)
profile.prune_leaf(leafIds, options.depth)

dot.graph(profile, theme)
if options.compare:
dot.graphs_compare(profile1, profile2, theme, options)
else:
dot.graph(profile, theme)


if __name__ == '__main__':
Expand Down
Binary file added images/compare_diff.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 4d8a733

Please sign in to comment.