blob: ee582a75a57995ccc6dcaaf3bbbc71a4c23a00d6 [file] [log] [blame]
# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Library to run fio scripts.
fio_runner launch fio and collect results.
The output dictionary can be add to autotest keyval:
results = {}
results.update(fio_util.fio_runner(job_file, env_vars))
self.write_perf_keyval(results)
Decoding class can be invoked independently.
"""
import json, logging, re, utils
from UserDict import UserDict
class fio_parser_exception(Exception):
"""
Exception class for fio_job_output.
"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
def fio_version(version_line):
"""
Strip 'fio-' prefix from self-reported version
@param version_line: first line of fio output should be version.
@raises fio_parser_exception when prefix isn't "fio-" as expected.
"""
if version_line[0:4] == "fio-":
return version_line[4:]
raise fio_parser_exception('fio version not found: %s' % version_line)
class fio_job_output(UserDict):
"""
Dictionary class to hold the fio output.
This class accepts fio output as a list of values.
"""
def _parse_gen(self, job, field, val):
"""
Parses a regular field and adds it to the dictionary.
@param job: fio job name.
@param field: fio output field name.
@param val: fio output field value.
"""
self[field % job] = val
def _parse_percentile(self, job, field, val):
"""
Parses a percentile field and adds it to the dictionary.
@param job: fio job name.
@param field: fio output field name.
@param val: fio output field value.
"""
prc = float(val.split('=')[0].strip('%'))
self[field % (job, prc)] = val.split('=')[1]
def _append_stats(self, idxs, io, typ):
"""
Appends repetitive statistics fields to self._fio_table.
@param idxs: range of field indexes to use for the map.
@param io: I/O type: rd or wr
@param typ: latency type: submission or completion.
"""
fields = ['_%s_' + '%s_min_%s_lat_usec' % (io, typ),
'_%s_' + '%s_max_%s_lat_usec' % (io, typ),
'_%s_' + '%s_mean_%s_lat_usec' % (io, typ),
'_%s_' + '%s_stdv_%s_lat_usec' % (io, typ)]
for field, idx in zip(fields, idxs):
self._fio_table.append((field, idx, self._parse_gen))
def _append_percentiles(self, idxs, io):
"""
Appends repetitive percentile fields to self._fio_table.
@param idxs: range of field indexes to use for the map.
@param io: I/O type: rd or wr
"""
for i in idxs:
field = '_%s_' + '%s_lat_' % io + '%.2f_percent_usec'
self._fio_table.append((field, i, self._parse_percentile))
def _build_fio_terse_4_table(self):
"""
Creates map from field name to fio output index and parse function.
"""
# General fio Job Info:
self._fio_table.extend([
('_%s_fio_version' , 1, self._parse_gen),
('_%s_groupid' , 3, self._parse_gen),
('_%s_error' , 4, self._parse_gen)])
# Results of READ Status:
self._fio_table.extend([
('_%s_rd_total_io_KB' , 5, self._parse_gen),
('_%s_rd_bw_KB_sec' , 6, self._parse_gen),
('_%s_rd_IOPS' , 7, self._parse_gen),
('_%s_rd_runtime_msec' , 8, self._parse_gen)])
self._append_stats(range(9, 13), 'rd', 'submitted')
self._append_stats(range(13, 17), 'rd', 'completed')
self._append_percentiles(range(17, 37), 'rd')
self._append_stats(range(37, 41), 'rd', 'total')
self._fio_table.extend([
('_%s_rd_min_bw_KB_sec' , 41, self._parse_gen),
('_%s_rd_max_bw_KB_sec' , 42, self._parse_gen),
('_%s_rd_percent' , 43, self._parse_gen),
('_%s_rd_mean_bw_KB_sec' , 44, self._parse_gen),
('_%s_rd_stdev_bw_KB_sec' , 45, self._parse_gen)])
# Results of WRITE Status:
self._fio_table.extend([
('_%s_wr_total_io_KB' , 46, self._parse_gen),
('_%s_wr_bw_KB_sec' , 47, self._parse_gen),
('_%s_wr_IOPS' , 48, self._parse_gen),
('_%s_wr_runtime_msec' , 49, self._parse_gen)])
self._append_stats(range(50, 54), 'wr', 'submitted')
self._append_stats(range(54, 58), 'wr', 'completed')
self._append_percentiles(range(58, 78), 'wr')
self._append_stats(range(78, 82), 'wr', 'total')
self._fio_table.extend([
('_%s_wr_min_bw_KB_sec' , 82, self._parse_gen),
('_%s_wr_max_bw_KB_sec' , 83, self._parse_gen),
('_%s_wr_percent' , 84, self._parse_gen),
('_%s_wr_mean_bw_KB_sec' , 85, self._parse_gen),
('_%s_wr_stdv_bw_KB_sec' , 86, self._parse_gen)])
# Results of TRIM Status:
self._fio_table.extend([
('_%s_tr_total_io_KB' , 87, self._parse_gen),
('_%s_tr_bw_KB_sec' , 88, self._parse_gen),
('_%s_tr_IOPS' , 89, self._parse_gen),
('_%s_tr_runtime_msec' , 90, self._parse_gen)])
self._append_stats(range(91, 95), 'tr', 'submitted')
self._append_stats(range(95, 99), 'tr', 'completed')
self._append_percentiles(range(99, 119), 'tr')
self._append_stats(range(119, 123), 'tr', 'total')
self._fio_table.extend([
('_%s_tr_min_bw_KB_sec' , 123, self._parse_gen),
('_%s_tr_max_bw_KB_sec' , 124, self._parse_gen),
('_%s_tr_percent' , 125, self._parse_gen),
('_%s_tr_mean_bw_KB_sec' , 126, self._parse_gen),
('_%s_tr_stdv_bw_KB_sec' , 127, self._parse_gen)])
# Other Results:
self._fio_table.extend([
('_%s_cpu_usg_usr_percent' , 128, self._parse_gen),
('_%s_cpu_usg_sys_percent' , 129, self._parse_gen),
('_%s_cpu_context_count' , 130, self._parse_gen),
('_%s_major_page_faults' , 131, self._parse_gen),
('_%s_minor_page_faults' , 132, self._parse_gen),
('_%s_io_depth_le_1_percent' , 133, self._parse_gen),
('_%s_io_depth_2_percent' , 134, self._parse_gen),
('_%s_io_depth_4_percent' , 135, self._parse_gen),
('_%s_io_depth_8_percent' , 136, self._parse_gen),
('_%s_io_depth_16_percent' , 137, self._parse_gen),
('_%s_io_depth_32_percent' , 138, self._parse_gen),
('_%s_io_depth_ge_64_percent' , 139, self._parse_gen),
('_%s_io_lats_le_2_usec_percent' , 140, self._parse_gen),
('_%s_io_lats_4_usec_percent' , 141, self._parse_gen),
('_%s_io_lats_10_usec_percent' , 142, self._parse_gen),
('_%s_io_lats_20_usec_percent' , 143, self._parse_gen),
('_%s_io_lats_50_usec_percent' , 144, self._parse_gen),
('_%s_io_lats_100_usec_percent' , 145, self._parse_gen),
('_%s_io_lats_250_usec_percent' , 146, self._parse_gen),
('_%s_io_lats_500_usec_percent' , 147, self._parse_gen),
('_%s_io_lats_750_usec_percent' , 148, self._parse_gen),
('_%s_io_lats_1000_usec_percent' , 149, self._parse_gen),
('_%s_io_lats_le_2_msec_percent' , 150, self._parse_gen),
('_%s_io_lats_4_msec_percent' , 151, self._parse_gen),
('_%s_io_lats_10_msec_percent' , 152, self._parse_gen),
('_%s_io_lats_20_msec_percent' , 153, self._parse_gen),
('_%s_io_lats_50_msec_percent' , 154, self._parse_gen),
('_%s_io_lats_100_msec_percent' , 155, self._parse_gen),
('_%s_io_lats_250_msec_percent' , 156, self._parse_gen),
('_%s_io_lats_500_msec_percent' , 157, self._parse_gen),
('_%s_io_lats_750_msec_percent' , 158, self._parse_gen),
('_%s_io_lats_1000_msec_percent' , 159, self._parse_gen),
('_%s_io_lats_2000_msec_percent' , 160, self._parse_gen),
('_%s_io_lats_gt_2000_msec_percent' , 161, self._parse_gen),
# Disk Utilization: only boot disk is tested
('_%s_disk_name' , 162, self._parse_gen),
('_%s_rd_ios' , 163, self._parse_gen),
('_%s_wr_ios' , 164, self._parse_gen),
('_%s_rd_merges' , 165, self._parse_gen),
('_%s_wr_merges' , 166, self._parse_gen),
('_%s_rd_ticks' , 167, self._parse_gen),
('_%s_wr_ticks' , 168, self._parse_gen),
('_%s_time_in_queue' , 169, self._parse_gen),
('_%s_disk_util_percent' , 170, self._parse_gen)])
def __init__(self, data, prefix=None):
"""
Fills the dictionary object with the fio output upon instantiation.
@param data: fio HOWTO documents list of values from fio output.
@param prefix: name to append for the result key value pair.
@raises fio_parser_exception.
"""
UserDict.__init__(self)
# Check that data parameter.
if len(data) == 0:
raise fio_parser_exception('No fio output supplied.')
# Create table that relates field name to fio output index and
# parsing function to be used for the field.
self._fio_table = []
terse_version = int(data[0])
fio_terse_parser = { 4 : self._build_fio_terse_4_table }
if terse_version in fio_terse_parser:
fio_terse_parser[terse_version]()
else:
raise fio_parser_exception('fio terse version %s unsupported.'
'fio_parser supports terse version %s' %
(terse_version, fio_terse_parser.keys()))
# Fill dictionary object.
self._job_name = data[2]
if prefix:
self._job_name = prefix + '_' + self._job_name
for field, idx, parser in self._fio_table:
# Field 162-170 only reported when we test on block device.
if len(data) <= idx:
break
parser(self._job_name, field, data[idx])
class fio_graph_generator():
"""
Generate graph from fio log that created when specified these options.
- write_bw_log
- write_iops_log
- write_lat_log
The following limitations apply
- Log file name must be in format jobname_testpass
- Graph is generate using Google graph api -> Internet require to view.
"""
html_head = """
<html>
<head>
<script type="text/javascript" src="https://www.google.com/jsapi"></script>
<script type="text/javascript">
google.load("visualization", "1", {packages:["corechart"]});
google.setOnLoadCallback(drawChart);
function drawChart() {
"""
html_tail = """
var chart_div = document.getElementById('chart_div');
var chart = new google.visualization.ScatterChart(chart_div);
chart.draw(data, options);
}
</script>
</head>
<body>
<div id="chart_div" style="width: 100%; height: 100%;"></div>
</body>
</html>
"""
h_title = { True: 'Percentile', False: 'Time (s)' }
v_title = { 'bw' : 'Bandwidth (KB/s)',
'iops': 'IOPs',
'lat' : 'Total latency (us)',
'clat': 'Completion latency (us)',
'slat': 'Submission latency (us)' }
graph_title = { 'bw' : 'bandwidth',
'iops': 'IOPs',
'lat' : 'total latency',
'clat': 'completion latency',
'slat': 'submission latency' }
test_name = ''
test_type = ''
pass_list = ''
@classmethod
def _parse_log_file(cls, file_name, pass_index, pass_count, percentile):
"""
Generate row for google.visualization.DataTable from one log file.
Log file is the one that generated using write_{bw,lat,iops}_log
option in the FIO job file.
The fio log file format is timestamp, value, direction, blocksize
The output format for each row is { c: list of { v: value} }
@param file_name: log file name to read data from
@param pass_index: index of current run pass
@param pass_count: number of all test run passes
@param percentile: flag to use percentile as key instead of timestamp
@return: list of data rows in google.visualization.DataTable format
"""
# Read data from log
with open(file_name, 'r') as f:
data = []
for line in f.readlines():
if not line:
break
t, v, _, _ = [int(x) for x in line.split(', ')]
data.append([t / 1000.0, v])
# Sort & calculate percentile
if percentile:
data.sort(key=lambda x:x[1])
l = len(data)
for i in range(l):
data[i][0] = 100 * (i + 0.5) / l
# Generate the data row
all_row = []
row = [None] * (pass_count + 1)
for d in data:
row[0] = {'v' : '%.3f' % d[0]}
row[pass_index + 1] = {'v': d[1] }
all_row.append({'c': row[:]})
return all_row
@classmethod
def _gen_data_col(cls, pass_list, percentile):
"""
Generate col for google.visualization.DataTable
The output format is list of dict of label and type. In this case,
type is always number.
@param pass_list: list of test run passes
@param percentile: flag to use percentile as key instead of timestamp
@return: list of column in google.visualization.DataTable format
"""
if percentile:
col_name_list = ['percentile'] + pass_list
else:
col_name_list = ['time'] + pass_list
return [{'label': name, 'type': 'number'} for name in col_name_list]
@classmethod
def _gen_data_row(cls, test_name, test_type, pass_list, percentile):
"""
Generate row for google.visualization.DataTable by generate all log
file name and call _parse_log_file for each file
@param test_name: name of current workload. i.e. randwrite
@param test_type: type of value collected for current test. i.e. IOPs
@param pass_list: list of run passes for current test
@param percentile: flag to use percentile as key instead of timestamp
@return: list of data rows in google.visualization.DataTable format
"""
all_row = []
pass_count = len(pass_list)
for pass_index, pass_str in enumerate(pass_list):
log_file_name = str('%s_%s_%s.log' %
(test_name, pass_str, test_type))
all_row.extend(cls._parse_log_file(log_file_name, pass_index,
pass_count, percentile))
return all_row
@classmethod
def _write_data(cls, f, test_name, test_type, pass_list, percentile):
"""
Write google.visualization.DataTable object to output file.
https://developers.google.com/chart/interactive/docs/reference
@param test_name: name of current workload. i.e. randwrite
@param test_type: type of value collected for current test. i.e. IOPs
@param pass_list: list of run passes for current test
@param percentile: flag to use percentile as key instead of timestamp
"""
col = cls._gen_data_col(pass_list, percentile)
row = cls._gen_data_row(test_name, test_type, pass_list, percentile)
data_dict = { 'cols' : col, 'rows' : row}
f.write('var data = new google.visualization.DataTable(')
json.dump(data_dict, f)
f.write(');\n')
@classmethod
def _write_option(cls, f, test_name, test_type, percentile):
"""
Write option to render scatter graph to output file.
https://google-developers.appspot.com/chart/interactive/docs/gallery/scatterchart
@param test_name: name of current workload. i.e. randwrite
@param test_type: type of value collected for current test. i.e. IOPs
@param percentile: flag to use percentile as key instead of timestamp
"""
option = {'pointSize': 1 }
if percentile:
option['title'] = ('Percentile graph of %s for %s workload' %
(cls.graph_title[test_type], test_name))
else:
option['title'] = ('Graph of %s for %s workload over time' %
(cls.graph_title[test_type], test_name))
option['hAxis'] = { 'title': cls.h_title[percentile]}
option['vAxis'] = { 'title': cls.v_title[test_type]}
f.write('var options = ')
json.dump(option, f)
f.write(';\n')
@classmethod
def _write_graph(cls, test_name, test_type, pass_list, percentile=False):
"""
Generate graph for test name / test type
@param test_name: name of current workload. i.e. randwrite
@param test_type: type of value collected for current test. i.e. IOPs
@param pass_list: list of run passes for current test
@param percentile: flag to use percentile as key instead of timestamp
"""
logging.info('fio_graph_generator._write_graph %s %s %s',
test_name, test_type, str(pass_list))
if percentile:
out_file_name = '%s_%s_percentile.html' % (test_name, test_type)
else:
out_file_name = '%s_%s.html' % (test_name, test_type)
with open(out_file_name, 'w') as f:
f.write(cls.html_head)
cls._write_data(f, test_name, test_type, pass_list, percentile)
cls._write_option(f, test_name, test_type, percentile)
f.write(cls.html_tail)
def __init__(self, test_name, test_type, pass_list):
"""
@param test_name: name of current workload. i.e. randwrite
@param test_type: type of value collected for current test. i.e. IOPs
@param pass_list: list of run passes for current test
"""
self.test_name = test_name
self.test_type = test_type
self.pass_list = pass_list
def run(self):
"""
Run the graph generator.
"""
self._write_graph(self.test_name, self.test_type, self.pass_list, False)
self._write_graph(self.test_name, self.test_type, self.pass_list, True)
def fio_parser(lines, prefix=None):
"""Parse the terse fio output
This collects all metrics given by fio and labels them according to unit
of measurement and test case name.
@param lines: text output of terse fio output.
@param prefix: prefix for result keys.
"""
# fio version 2.0.8+ outputs all needed information with --minimal
# Using that instead of the human-readable version, since it's easier
# to parse.
# Following is a partial example of the semicolon-delimited output.
# 3;fio-2.1;quick_write;0;0;0;0;0;0;0;0;0.000000;0.000000;0;0;0.000000;
# 0.000000;1.000000%=0;5.000000%=0;10.000000%=0;20.000000%=0;
# ...
# Refer to the HOWTO file of the fio package for more information.
results = {}
# Extract the values from the test.
for line in lines.splitlines():
# Put the values from the output into an array.
values = line.split(';')
# This check makes sure that we are parsing the actual values
# instead of the job description or possible blank lines.
if len(values) <= 128:
continue
results.update(fio_job_output(values, prefix))
return results
def fio_generate_graph():
"""
Scan for fio log file in output directory and send data to generate each
graph to fio_graph_generator class.
"""
log_types = ['bw', 'iops', 'lat', 'clat', 'slat']
# move fio log to result dir
for log_type in log_types:
logging.info('log_type %s', log_type)
logs = utils.system_output('ls *_%s.log' % log_type, ignore_status=True)
if not logs:
continue
pattern = r"""(?P<jobname>.*)_ # jobname
((?P<runpass>p\d+)_) # pass
(?P<type>bw|iops|lat|clat|slat).log # type
"""
matcher = re.compile(pattern, re.X)
pass_list = []
current_job = ''
for log in logs.split():
match = matcher.match(log)
if not match:
logging.warn('Unknown log file %s', log)
continue
jobname = match.group('jobname')
runpass = match.group('runpass')
# All files for particular job name are group together for create
# graph that can compare performance between result from each pass.
if jobname != current_job:
if pass_list:
fio_graph_generator(current_job, log_type, pass_list).run()
current_job = jobname
pass_list = []
pass_list.append(runpass)
if pass_list:
fio_graph_generator(current_job, log_type, pass_list).run()
cmd = 'mv *_%s.log results' % log_type
utils.run(cmd, ignore_status=True)
utils.run('mv *.html results', ignore_status=True)
def fio_runner(test, job, env_vars,
name_prefix=None,
graph_prefix=None):
"""
Runs fio.
Build a result keyval and performence json.
The JSON would look like:
{"description": "<name_prefix>_<modle>_<size>G",
"graph": "<graph_prefix>_1m_write_wr_lat_99.00_percent_usec",
"higher_is_better": false, "units": "us", "value": "xxxx"}
{...
@param test: test to upload perf value
@param job: fio config file to use
@param env_vars: environment variable fio will substituete in the fio
config file.
@param name_prefix: prefix of the descriptions to use in chrome perfi
dashboard.
@param graph_prefix: prefix of the graph name in chrome perf dashboard
and result keyvals.
@return fio results.
"""
# running fio with ionice -c 3 so it doesn't lock out other
# processes from the disk while it is running.
# If you want to run the fio test for performance purposes,
# take out the ionice and disable hung process detection:
# "echo 0 > /proc/sys/kernel/hung_task_timeout_secs"
# -c 3 = Idle
# Tried lowest priority for "best effort" but still failed
ionice = 'ionice -c 3'
# Using the --minimal flag for easier results parsing
# Newest fio doesn't omit any information in --minimal
# Need to set terse-version to 4 for trim related output
options = ['--minimal', '--terse-version=4']
fio_cmd_line = ' '.join([env_vars, ionice, 'fio',
' '.join(options),
'"' + job + '"'])
fio = utils.run(fio_cmd_line)
logging.debug(fio.stdout)
fio_generate_graph()
filename = re.match('.*FILENAME=(?P<f>[^ ]*)', env_vars).group('f')
diskname = utils.get_disk_from_filename(filename)
if diskname:
model = utils.get_disk_model(diskname)
size = utils.get_disk_size_gb(diskname)
perfdb_name = '%s_%dG' % (model, size)
else:
perfdb_name = filename.replace('/', '_')
if name_prefix:
perfdb_name = name_prefix + '_' + perfdb_name
result = fio_parser(fio.stdout, prefix=perfdb_name)
if not graph_prefix:
graph_prefix = ''
# Upload bw / 99% lat to dashboard
bw_matcher = re.compile('.*(rd|wr)_bw_KB_sec')
lat_matcher = re.compile('.*(rd|wr)_lat_99.00_percent_usec')
# don't log useless stat like 16k_randwrite_rd_bw_KB_sec
skip_matcher = re.compile('.*(trim.*|write.*_rd|read.*_wr)_.*')
for k, v in result.iteritems():
# Remove the prefix for value, and replace it the graph prefix.
k = k.replace('_' + perfdb_name, graph_prefix)
if skip_matcher.match(k):
continue
if bw_matcher.match(k):
test.output_perf_value(description=perfdb_name, graph=k, value=v,
units='KB_per_sec', higher_is_better=True)
elif lat_matcher.match(k):
test.output_perf_value(description=perfdb_name, graph=k, value=v,
units='us', higher_is_better=False)
return result