Commit | Line | Data |
---|---|---|
d61215e0 | 1 | #!/usr/bin/env python3 |
13bc47b6 VF |
2 | import uuid |
3 | import time | |
69bb4b00 | 4 | import errno |
d61215e0 EV |
5 | from graphviz import Digraph |
6 | import argparse | |
7 | import configparser | |
8 | import os | |
9 | ||
10 | config_file = None | |
11 | fio_file = None | |
12 | ||
13 | ||
14 | def get_section_option(section_name, option_name, default=None): | |
15 | global fio_file | |
16 | if fio_file.has_option(section_name, option_name): | |
17 | return fio_file[section_name][option_name] | |
18 | return default | |
19 | ||
20 | ||
21 | def get_config_option(section_name, option_name, default=None): | |
22 | global config_file | |
23 | if config_file.has_option(section_name, option_name): | |
24 | return config_file[section_name][option_name] | |
25 | return default | |
26 | ||
27 | ||
28 | def get_header_color(keyword='fio_jobs', default_color='black'): | |
29 | return get_config_option(keyword, 'header_color', default_color) | |
30 | ||
31 | ||
32 | def get_shape_color(keyword='fio_jobs', default_color='black'): | |
33 | return get_config_option(keyword, 'shape_color', default_color) | |
34 | ||
35 | ||
36 | def get_text_color(keyword='fio_jobs', default_color='black'): | |
37 | return get_config_option(keyword, 'text_color', default_color) | |
38 | ||
39 | ||
40 | def get_cluster_color(keyword='fio_jobs', default_color='gray92'): | |
41 | return get_config_option(keyword, 'cluster_color', default_color) | |
42 | ||
43 | ||
44 | def get_header(keyword='fio_jobs'): | |
45 | return get_config_option(keyword, 'header') | |
46 | ||
47 | ||
48 | def get_shape(keyword='fio_jobs'): | |
49 | return get_config_option(keyword, 'shape', 'box') | |
50 | ||
51 | ||
52 | def get_style(keyword='fio_jobs'): | |
53 | return get_config_option(keyword, 'style', 'rounded') | |
54 | ||
55 | ||
56 | def get_cluster_style(keyword='fio_jobs'): | |
57 | return get_config_option(keyword, 'cluster_style', 'filled') | |
58 | ||
59 | ||
60 | def get_specific_options(engine): | |
61 | if not engine: | |
62 | return '' | |
63 | return get_config_option('ioengine_{}'.format(engine), 'specific_options', '').split(' ') | |
64 | ||
65 | ||
66 | def render_option(section, label, display, option, color_override=None): | |
67 | # These options are already shown with graphical helpers, no need to report them directly | |
68 | skip_list = ['size', 'stonewall', 'runtime', 'time_based', | |
69 | 'numjobs', 'wait_for', 'wait_for_previous'] | |
70 | # If the option doesn't exist or if a special handling is already done | |
71 | # don't render it, just return the current state | |
72 | if option in skip_list or option not in section: | |
73 | return label, display | |
74 | display = option | |
75 | if section[option]: | |
76 | display = '{} = {}'.format(display, section[option]) | |
77 | ||
78 | # Adding jobs's options into the box, darkgreen is the default color | |
79 | if color_override: | |
80 | color = color_override | |
81 | else: | |
82 | color = get_text_color(option, get_text_color('fio_jobs', 'darkgreen')) | |
83 | label += get_config_option('fio_jobs', | |
84 | 'item_style').format(color, display) | |
85 | return label, display | |
86 | ||
87 | ||
88 | def render_options(fio_file, section_name): | |
89 | """Render all options of a section.""" | |
90 | display = section_name | |
91 | section = fio_file[section_name] | |
92 | ||
93 | # Add a multiplier to the section_name if numjobs is set | |
94 | numjobs = int(get_section_option(section_name, 'numjobs', '1')) | |
95 | if numjobs > 1: | |
96 | display = display + \ | |
97 | get_style('numjobs').format( | |
98 | get_text_color('numjobs'), numjobs) | |
99 | ||
100 | # Header of the box | |
101 | label = get_config_option('fio_jobs', 'title_style').format(display) | |
102 | ||
103 | # Let's parse all the options of the current fio thread | |
104 | # Some needs to be printed on top or bottom of the job to ease the read | |
105 | to_early_print = ['exec_prerun', 'ioengine'] | |
106 | to_late_print = ['exec_postrun'] | |
107 | ||
108 | # Let's print the options on top of the box | |
109 | for early_print in to_early_print: | |
110 | label, display = render_option( | |
111 | section, label, display, early_print) | |
112 | ||
113 | current_io_engine = get_section_option( | |
114 | section_name, 'ioengine', None) | |
115 | if current_io_engine: | |
116 | # Let's print all specifics options for this engine | |
117 | for specific_option in sorted(get_specific_options(current_io_engine)): | |
118 | label, display = render_option( | |
119 | section, label, display, specific_option, get_config_option('ioengine', 'specific_options_color')) | |
120 | ||
121 | # Let's print generic options sorted by name | |
122 | for option in sorted(section): | |
123 | if option in to_early_print or option in to_late_print or option in get_specific_options(current_io_engine): | |
124 | continue | |
125 | label, display = render_option(section, label, display, option) | |
126 | ||
127 | # let's print options on the bottom of the box | |
128 | for late_print in to_late_print: | |
129 | label, display = render_option( | |
130 | section, label, display, late_print) | |
131 | ||
132 | # End of the box content | |
133 | label += '</table>>' | |
134 | return label | |
135 | ||
136 | ||
137 | def render_section(current_graph, fio_file, section_name, label): | |
138 | """Render the section.""" | |
139 | attr = None | |
140 | section = fio_file[section_name] | |
141 | ||
142 | # Let's render the box associated to a job | |
143 | current_graph.node(section_name, label, | |
144 | shape=get_shape(), | |
145 | color=get_shape_color(), | |
146 | style=get_style()) | |
147 | ||
148 | # Let's report the duration of the jobs with a self-loop arrow | |
149 | if 'runtime' in section and 'time_based' in section: | |
150 | attr = 'runtime={}'.format(section['runtime']) | |
151 | elif 'size' in section: | |
152 | attr = 'size={}'.format(section['size']) | |
153 | if attr: | |
154 | current_graph.edge(section_name, section_name, attr) | |
155 | ||
156 | ||
157 | def create_sub_graph(name): | |
158 | """Return a new graph.""" | |
159 | # We need to put 'cluster' in the name to ensure graphviz consider it as a cluster | |
160 | cluster_name = 'cluster_' + name | |
161 | # Unset the main graph labels to avoid a recopy in each subgraph | |
162 | attr = {} | |
163 | attr['label'] = '' | |
164 | new_graph = Digraph(name=cluster_name, graph_attr=attr) | |
165 | new_graph.attr(style=get_cluster_style(), | |
166 | color=get_cluster_color()) | |
167 | return new_graph | |
168 | ||
169 | ||
170 | def create_legend(): | |
171 | """Return a legend.""" | |
172 | html_table = "<<table border='0' cellborder='1' cellspacing='0' cellpadding='4'>" | |
173 | html_table += '<tr><td COLSPAN="2"><b>Legend</b></td></tr>' | |
174 | legend_item = '<tr> <td>{}</td> <td><font color="{}">{}</font></td></tr>"' | |
175 | legend_bgcolor_item = '<tr><td>{}</td><td BGCOLOR="{}"></td></tr>' | |
176 | html_table += legend_item.format('numjobs', | |
177 | get_text_color('numjobs'), 'x numjobs') | |
178 | html_table += legend_item.format('generic option', | |
179 | get_text_color(), 'generic option') | |
180 | html_table += legend_item.format('ioengine option', | |
181 | get_text_color('ioengine'), 'ioengine option') | |
182 | html_table += legend_bgcolor_item.format('job', get_shape_color()) | |
183 | html_table += legend_bgcolor_item.format( | |
184 | 'execution group', get_cluster_color()) | |
185 | html_table += '</table>>' | |
186 | legend = Digraph('html_table') | |
187 | legend.node('legend', shape='none', label=html_table) | |
188 | return legend | |
189 | ||
190 | ||
191 | def fio_to_graphviz(filename, format): | |
192 | """Compute the graphviz graph from the fio file.""" | |
193 | ||
194 | # Let's read the fio file | |
195 | global fio_file | |
196 | fio_file = configparser.RawConfigParser( | |
197 | allow_no_value=True, | |
198 | default_section="global", | |
199 | inline_comment_prefixes="'#', ';'") | |
200 | fio_file.read(filename) | |
201 | ||
202 | # Prepare the main graph object | |
203 | # Let's define the header of the document | |
204 | attrs = {} | |
205 | attrs['labelloc'] = 't' | |
206 | attrs['label'] = get_header().format( | |
207 | get_header_color(), os.path.basename(filename)) | |
208 | main_graph = Digraph(engine='dot', graph_attr=attrs, format=format) | |
209 | ||
210 | # Let's add a legend | |
211 | main_graph.subgraph(create_legend()) | |
212 | ||
213 | # By default all jobs are run in parallel and depends on "global" | |
214 | depends_on = fio_file.default_section | |
215 | ||
216 | # The previous section is by default the global section | |
217 | previous_section = fio_file.default_section | |
218 | ||
219 | current_graph = main_graph | |
220 | ||
221 | # The first job will be a new execution group | |
222 | new_execution_group = True | |
223 | ||
fc002f14 | 224 | # Let's iterate on all sections to create links between them |
d61215e0 EV |
225 | for section_name in fio_file.sections(): |
226 | # The current section | |
227 | section = fio_file[section_name] | |
228 | ||
229 | # If the current section is waiting the previous job | |
230 | if ('stonewall' or 'wait_for_previous') in section: | |
231 | # let's remember what was the previous job we depend on | |
232 | depends_on = previous_section | |
233 | new_execution_group = True | |
234 | elif 'wait_for' in section: | |
235 | # This sections depends on a named section pointed by wait_for | |
236 | depends_on = section['wait_for'] | |
237 | new_execution_group = True | |
238 | ||
239 | if new_execution_group: | |
240 | # Let's link the current graph with the main one | |
241 | main_graph.subgraph(current_graph) | |
242 | # Let's create a new graph to represent all the incoming jobs running at the same time | |
243 | current_graph = create_sub_graph(section_name) | |
244 | ||
245 | # Let's render the current section in its execution group | |
246 | render_section(current_graph, fio_file, section_name, | |
247 | render_options(fio_file, section_name)) | |
248 | ||
249 | # Let's trace the link between this job and the one it depends on | |
250 | # If we depend on 'global', we can avoid doing adding an arrow as we don't want to see 'global' | |
251 | if depends_on != fio_file.default_section: | |
252 | current_graph.edge(depends_on, section_name) | |
253 | ||
254 | # The current section become the parent of the next one | |
255 | previous_section = section_name | |
256 | ||
257 | # We are by default in the same execution group | |
258 | new_execution_group = False | |
259 | ||
260 | # The last subgraph isn't rendered yet | |
261 | main_graph.subgraph(current_graph) | |
262 | ||
263 | # Let's return the main graphviz object | |
264 | return main_graph | |
265 | ||
266 | ||
267 | def setup_commandline(): | |
268 | "Prepare the command line." | |
269 | parser = argparse.ArgumentParser() | |
270 | parser.add_argument('--file', action='store', | |
271 | type=str, | |
272 | required=True, | |
273 | help='the fio file to graph') | |
274 | parser.add_argument('--output', action='store', | |
275 | type=str, | |
276 | help='the output filename') | |
277 | parser.add_argument('--format', action='store', | |
278 | type=str, | |
279 | default='png', | |
0d672d86 | 280 | help='the output format (see https://graphviz.org/docs/outputs/)') |
d61215e0 EV |
281 | parser.add_argument('--view', action='store_true', |
282 | default=False, | |
283 | help='view the graph') | |
284 | parser.add_argument('--keep', action='store_true', | |
285 | default=False, | |
286 | help='keep the graphviz script file') | |
287 | parser.add_argument('--config', action='store', | |
288 | type=str, | |
d61215e0 EV |
289 | help='the configuration filename') |
290 | args = parser.parse_args() | |
291 | return args | |
292 | ||
293 | ||
294 | def main(): | |
295 | global config_file | |
296 | args = setup_commandline() | |
69bb4b00 | 297 | |
69bb4b00 VF |
298 | if args.config is None: |
299 | if os.path.exists('fiograph.conf'): | |
300 | config_filename = 'fiograph.conf' | |
301 | else: | |
302 | config_filename = os.path.join(os.path.dirname(__file__), 'fiograph.conf') | |
303 | if not os.path.exists(config_filename): | |
304 | raise FileNotFoundError("Cannot locate configuration file") | |
305 | else: | |
306 | config_filename = args.config | |
d61215e0 | 307 | config_file = configparser.RawConfigParser(allow_no_value=True) |
69bb4b00 VF |
308 | config_file.read(config_filename) |
309 | ||
13bc47b6 VF |
310 | temp_filename = uuid.uuid4().hex |
311 | image_filename = fio_to_graphviz(args.file, args.format).render(temp_filename, view=args.view) | |
312 | ||
313 | output_filename_stub = args.file | |
314 | if args.output: | |
315 | output_filename = args.output | |
316 | else: | |
317 | if output_filename_stub.endswith('.fio'): | |
318 | output_filename_stub = output_filename_stub[:-4] | |
319 | output_filename = image_filename.replace(temp_filename, output_filename_stub) | |
320 | if args.view: | |
321 | time.sleep(1) | |
322 | # allow time for the file to be opened before renaming it | |
323 | os.rename(image_filename, output_filename) | |
324 | ||
d61215e0 | 325 | if not args.keep: |
13bc47b6 VF |
326 | os.remove(temp_filename) |
327 | else: | |
328 | os.rename(temp_filename, output_filename_stub + '.gv') | |
d61215e0 EV |
329 | |
330 | ||
331 | main() |