This commit / feature adds completion latency histogram output to fio, piggybacking
[fio.git] / tools / hist / fiologparser_hist.py
1 #!/usr/bin/env python2.7
2 """ 
3     Utility for converting *_clat_hist* files generated by fio into latency statistics.
4     
5     Example usage:
6     
7             $ fiologparser_hist.py *_clat_hist*
8             end-time, samples, min, avg, median, 90%, 95%, 99%, max
9             1000, 15, 192, 1678.107, 1788.859, 1856.076, 1880.040, 1899.208, 1888.000
10             2000, 43, 152, 1642.368, 1714.099, 1816.659, 1845.552, 1888.131, 1888.000
11             4000, 39, 1152, 1546.962, 1545.785, 1627.192, 1640.019, 1691.204, 1744
12             ...
13     
14     Notes:
15
16     * end-times are calculated to be uniform increments of the --interval value given,
17       regardless of when histogram samples are reported. Of note:
18         
19         * Intervals with no samples are omitted. In the example above this means
20           "no statistics from 2 to 3 seconds" and "39 samples influenced the statistics
21           of the interval from 3 to 4 seconds".
22         
23         * Intervals with a single sample will have the same value for all statistics
24         
25     * The number of samples is unweighted, corresponding to the total number of samples
26       which have any effect whatsoever on the interval.
27
28     * Min statistics are computed using value of the lower boundary of the first bin
29       (in increasing bin order) with non-zero samples in it. Similarly for max,
30       we take the upper boundary of the last bin with non-zero samples in it.
31       This is semantically identical to taking the 0th and 100th percentiles with a
32       50% bin-width buffer (because percentiles are computed using mid-points of
33       the bins). This enforces the following nice properties:
34
35         * min <= 50th <= 90th <= 95th <= 99th <= max
36
37         * min and max are strict lower and upper bounds on the actual
38           min / max seen by fio (and reported in *_clat.* with averaging turned off).
39
40     * Average statistics use a standard weighted arithmetic mean.
41
42     * Percentile statistics are computed using the weighted percentile method as
43       described here: https://en.wikipedia.org/wiki/Percentile#Weighted_percentile
44       See weights() method for details on how weights are computed for individual
45       samples. In process_interval() we further multiply by the height of each bin
46       to get weighted histograms.
47     
48     * We convert files given on the command line, assumed to be fio histogram files,
49       on-the-fly into their corresponding differenced files i.e. non-cumulative histograms
50       because fio outputs cumulative histograms, but we want histograms corresponding
51       to individual time intervals. An individual histogram file can contain the cumulative
52       histograms for multiple different r/w directions (notably when --rw=randrw). This
53       is accounted for by tracking each r/w direction separately. In the statistics
54       reported we ultimately merge *all* histograms (regardless of r/w direction).
55
56     * The value of *_GROUP_NR in stat.h (and *_BITS) determines how many latency bins
57       fio outputs when histogramming is enabled. Namely for the current default of
58       GROUP_NR=19, we get 1,216 bins with a maximum latency of approximately 17
59       seconds. For certain applications this may not be sufficient. With GROUP_NR=24
60       we have 1,536 bins, giving us a maximum latency of 541 seconds (~ 9 minutes). If
61       you expect your application to experience latencies greater than 17 seconds,
62       you will need to recompile fio with a larger GROUP_NR, e.g. with:
63         
64             sed -i.bak 's/^#define FIO_IO_U_PLAT_GROUP_NR 19\n/#define FIO_IO_U_PLAT_GROUP_NR 24/g' stat.h
65             make fio
66             
67       Quick reference table for the max latency corresponding to a sampling of
68       values for GROUP_NR:
69             
70             GROUP_NR | # bins | max latency bin value
71             19       | 1216   | 16.9 sec
72             20       | 1280   | 33.8 sec
73             21       | 1344   | 67.6 sec
74             22       | 1408   | 2  min, 15 sec
75             23       | 1472   | 4  min, 32 sec
76             24       | 1536   | 9  min, 4  sec
77             25       | 1600   | 18 min, 8  sec
78             26       | 1664   | 36 min, 16 sec
79       
80     * At present this program automatically detects the number of histogram bins in
81       the log files, and adjusts the bin latency values accordingly. In particular if
82       you use the --log_hist_coarseness parameter of fio, you get output files with
83       a number of bins according to the following table (note that the first
84       row is identical to the table above):
85
86       coarse \ GROUP_NR
87                   19     20    21     22     23     24     25     26
88              -------------------------------------------------------
89             0  [[ 1216,  1280,  1344,  1408,  1472,  1536,  1600,  1664],
90             1   [  608,   640,   672,   704,   736,   768,   800,   832],
91             2   [  304,   320,   336,   352,   368,   384,   400,   416],
92             3   [  152,   160,   168,   176,   184,   192,   200,   208],
93             4   [   76,    80,    84,    88,    92,    96,   100,   104],
94             5   [   38,    40,    42,    44,    46,    48,    50,    52],
95             6   [   19,    20,    21,    22,    23,    24,    25,    26],
96             7   [  N/A,    10,   N/A,    11,   N/A,    12,   N/A,    13],
97             8   [  N/A,     5,   N/A,   N/A,   N/A,     6,   N/A,   N/A]]
98
99       For other values of GROUP_NR and coarseness, this table can be computed like this:    
100         
101             bins = [1216,1280,1344,1408,1472,1536,1600,1664]
102             max_coarse = 8
103             fncn = lambda z: list(map(lambda x: z/2**x if z % 2**x == 0 else nan, range(max_coarse + 1)))
104             np.transpose(list(map(fncn, bins)))
105       
106       Also note that you can achieve the same downsampling / log file size reduction
107       by pre-processing (before inputting into this script) with half_bins.py.
108
109     * If you have not adjusted GROUP_NR for your (high latency) application, then you
110       will see the percentiles computed by this tool max out at the max latency bin
111       value as in the first table above, and in this plot (where GROUP_NR=19 and thus we see
112       a max latency of ~16.7 seconds in the red line):
113
114             https://www.cronburg.com/fio/max_latency_bin_value_bug.png
115     
116     * Motivation for, design decisions, and the implementation process are
117       described in further detail here:
118
119             https://www.cronburg.com/fio/cloud-latency-problem-measurement/
120
121     @author Karl Cronburg <karl.cronburg@gmail.com>
122 """
123 import os
124 import sys
125 import pandas
126 import numpy as np
127
128 err = sys.stderr.write
129
130 def weighted_percentile(percs, vs, ws):
131     """ Use linear interpolation to calculate the weighted percentile.
132         
133         Value and weight arrays are first sorted by value. The cumulative
134         distribution function (cdf) is then computed, after which np.interp
135         finds the two values closest to our desired weighted percentile(s)
136         and linearly interpolates them.
137         
138         percs  :: List of percentiles we want to calculate
139         vs     :: Array of values we are computing the percentile of
140         ws     :: Array of weights for our corresponding values
141         return :: Array of percentiles
142     """
143     idx = np.argsort(vs)
144     vs, ws = vs[idx], ws[idx] # weights and values sorted by value
145     cdf = 100 * (ws.cumsum() - ws / 2.0) / ws.sum()
146     return np.interp(percs, cdf, vs) # linear interpolation
147
148 def weights(start_ts, end_ts, start, end):
149     """ Calculate weights based on fraction of sample falling in the
150         given interval [start,end]. Weights computed using vector / array
151         computation instead of for-loops.
152     
153         Note that samples with zero time length are effectively ignored
154         (we set their weight to zero).
155
156         start_ts :: Array of start times for a set of samples
157         end_ts   :: Array of end times for a set of samples
158         start    :: int
159         end      :: int
160         return   :: Array of weights
161     """
162     sbounds = np.maximum(start_ts, start).astype(float)
163     ebounds = np.minimum(end_ts,   end).astype(float)
164     ws = (ebounds - sbounds) / (end_ts - start_ts)
165     if np.any(np.isnan(ws)):
166       err("WARNING: zero-length sample(s) detected. Log file corrupt"
167           " / bad time values? Ignoring these samples.\n")
168     ws[np.where(np.isnan(ws))] = 0.0;
169     return ws
170
171 def weighted_average(vs, ws):
172     return np.sum(vs * ws) / np.sum(ws)
173
174 columns = ["end-time", "samples", "min", "avg", "median", "90%", "95%", "99%", "max"]
175 percs   = [50, 90, 95, 99]
176
177 def fmt_float_list(ctx, num=1):
178   """ Return a comma separated list of float formatters to the required number
179       of decimal places. For instance:
180
181         fmt_float_list(ctx.decimals=4, num=3) == "%.4f, %.4f, %.4f"
182   """
183   return ', '.join(["%%.%df" % ctx.decimals] * num)
184
185 # Default values - see beginning of main() for how we detect number columns in
186 # the input files:
187 __HIST_COLUMNS = 1216
188 __NON_HIST_COLUMNS = 3
189 __TOTAL_COLUMNS = __HIST_COLUMNS + __NON_HIST_COLUMNS
190     
191 def sequential_diffs(head_row, times, rws, hists):
192     """ Take the difference of sequential (in time) histograms with the same
193         r/w direction, returning a new array of differenced histograms.  """
194     result = np.empty(shape=(0, __HIST_COLUMNS))
195     result_times = np.empty(shape=(1, 0))
196     for i in range(8):
197         idx = np.where(rws == i)
198         diff = np.diff(np.append(head_row[i], hists[idx], axis=0), axis=0).astype(int)
199         result = np.append(diff, result, axis=0)
200         result_times = np.append(times[idx], result_times)
201     idx = np.argsort(result_times)
202     return result[idx]
203
204 def read_chunk(head_row, rdr, sz):
205     """ Read the next chunk of size sz from the given reader, computing the
206         differences across neighboring histogram samples.
207     """
208     try:
209         """ StopIteration occurs when the pandas reader is empty, and AttributeError
210             occurs if rdr is None due to the file being empty. """
211         new_arr = rdr.read().values
212     except (StopIteration, AttributeError):
213         return None    
214
215     """ Extract array of just the times, and histograms matrix without times column.
216         Then, take the sequential difference of each of the rows in the histogram
217         matrix. This is necessary because fio outputs *cumulative* histograms as
218         opposed to histograms with counts just for a particular interval. """
219     times, rws, szs = new_arr[:,0], new_arr[:,1], new_arr[:,2]
220     hists = new_arr[:,__NON_HIST_COLUMNS:]
221     hists_diff   = sequential_diffs(head_row, times, rws, hists)
222     times = times.reshape((len(times),1))
223     arr = np.append(times, hists_diff, axis=1)
224
225     """ hists[-1] will be the row we need to start our differencing with the
226         next time we call read_chunk() on the same rdr """
227     return arr, hists[-1]
228
229 def get_min(fps, arrs):
230     """ Find the file with the current first row with the smallest start time """
231     return min([fp for fp in fps if not arrs[fp] is None], key=lambda fp: arrs.get(fp)[0][0][0])
232
233 def histogram_generator(ctx, fps, sz):
234     
235     """ head_row for a particular file keeps track of the last (cumulative)
236         histogram we read so that we have a reference point to subtract off
237         when computing sequential differences. """
238     head_row  = np.zeros(shape=(1, __HIST_COLUMNS))
239     head_rows = {fp: {i: head_row for i in range(8)} for fp in fps}
240
241     # Create a chunked pandas reader for each of the files:
242     rdrs = {}
243     for fp in fps:
244         try:
245             rdrs[fp] = pandas.read_csv(fp, dtype=int, header=None, chunksize=sz)
246         except ValueError as e:
247             if e.message == 'No columns to parse from file':
248                 if not ctx.nowarn: sys.stderr.write("WARNING: Empty input file encountered.\n")
249                 rdrs[fp] = None
250             else:
251                 raise(e)
252
253     # Initial histograms and corresponding head_rows:
254     arrs = {fp: read_chunk(head_rows[fp], rdr, sz) for fp,rdr in rdrs.items()}
255     while True:
256
257         try:
258             """ ValueError occurs when nothing more to read """
259             fp = get_min(fps, arrs)
260         except ValueError:
261             return
262         arr, head_row = arrs[fp]
263         yield np.insert(arr[0], 1, fps.index(fp))
264         arrs[fp] = arr[1:], head_row
265         head_rows[fp] = head_row
266
267         if arrs[fp][0].shape[0] == 0:
268             arrs[fp] = read_chunk(head_rows[fp], rdrs[fp], sz)
269
270 def _plat_idx_to_val(idx, edge=0.5, FIO_IO_U_PLAT_BITS=6, FIO_IO_U_PLAT_VAL=64):
271     """ Taken from fio's stat.c for calculating the latency value of a bin
272         from that bin's index.
273         
274             idx  : the value of the index into the histogram bins
275             edge : fractional value in the range [0,1]** indicating how far into
276             the bin we wish to compute the latency value of.
277         
278         ** edge = 0.0 and 1.0 computes the lower and upper latency bounds
279            respectively of the given bin index. """
280
281     # MSB <= (FIO_IO_U_PLAT_BITS-1), cannot be rounded off. Use
282     # all bits of the sample as index
283     if (idx < (FIO_IO_U_PLAT_VAL << 1)):
284         return idx 
285
286     # Find the group and compute the minimum value of that group
287     error_bits = (idx >> FIO_IO_U_PLAT_BITS) - 1 
288     base = 1 << (error_bits + FIO_IO_U_PLAT_BITS)
289
290     # Find its bucket number of the group
291     k = idx % FIO_IO_U_PLAT_VAL
292
293     # Return the mean (if edge=0.5) of the range of the bucket
294     return base + ((k + edge) * (1 << error_bits))
295     
296 def plat_idx_to_val_coarse(idx, coarseness, edge=0.5):
297     """ Converts the given *coarse* index into a non-coarse index as used by fio
298         in stat.h:plat_idx_to_val(), subsequently computing the appropriate
299         latency value for that bin.
300         """
301
302     # Multiply the index by the power of 2 coarseness to get the bin
303     # bin index with a max of 1536 bins (FIO_IO_U_PLAT_GROUP_NR = 24 in stat.h)
304     stride = 1 << coarseness
305     idx = idx * stride
306     lower = _plat_idx_to_val(idx, edge=0.0)
307     upper = _plat_idx_to_val(idx + stride, edge=1.0)
308     return lower + (upper - lower) * edge
309
310 def print_all_stats(ctx, end, mn, ss_cnt, vs, ws, mx):
311     ps = weighted_percentile(percs, vs, ws)
312
313     avg = weighted_average(vs, ws)
314     values = [mn, avg] + list(ps) + [mx]
315     row = [end, ss_cnt] + map(lambda x: float(x) / ctx.divisor, values)
316     fmt = "%d, %d, %d, " + fmt_float_list(ctx, 5) + ", %d"
317     print (fmt % tuple(row))
318
319 def update_extreme(val, fncn, new_val):
320     """ Calculate min / max in the presence of None values """
321     if val is None: return new_val
322     else: return fncn(val, new_val)
323
324 # See beginning of main() for how bin_vals are computed
325 bin_vals = []
326 lower_bin_vals = [] # lower edge of each bin
327 upper_bin_vals = [] # upper edge of each bin 
328
329 def process_interval(ctx, samples, iStart, iEnd):
330     """ Construct the weighted histogram for the given interval by scanning
331         through all the histograms and figuring out which of their bins have
332         samples with latencies which overlap with the given interval
333         [iStart,iEnd].
334     """
335     
336     times, files, hists = samples[:,0], samples[:,1], samples[:,2:]
337     iHist = np.zeros(__HIST_COLUMNS)
338     ss_cnt = 0 # number of samples affecting this interval
339     mn_bin_val, mx_bin_val = None, None
340
341     for end_time,file,hist in zip(times,files,hists):
342             
343         # Only look at bins of the current histogram sample which
344         # started before the end of the current time interval [start,end]
345         start_times = (end_time - 0.5 * ctx.interval) - bin_vals / 1000.0
346         idx = np.where(start_times < iEnd)
347         s_ts, l_bvs, u_bvs, hs = start_times[idx], lower_bin_vals[idx], upper_bin_vals[idx], hist[idx]
348
349         # Increment current interval histogram by weighted values of future histogram:
350         ws = hs * weights(s_ts, end_time, iStart, iEnd)
351         iHist[idx] += ws
352     
353         # Update total number of samples affecting current interval histogram:
354         ss_cnt += np.sum(hs)
355         
356         # Update min and max bin values seen if necessary:
357         idx = np.where(hs != 0)[0]
358         if idx.size > 0:
359             mn_bin_val = update_extreme(mn_bin_val, min, l_bvs[max(0,           idx[0]  - 1)])
360             mx_bin_val = update_extreme(mx_bin_val, max, u_bvs[min(len(hs) - 1, idx[-1] + 1)])
361
362     if ss_cnt > 0: print_all_stats(ctx, iEnd, mn_bin_val, ss_cnt, bin_vals, iHist, mx_bin_val)
363
364 def guess_max_from_bins(ctx, hist_cols):
365     """ Try to guess the GROUP_NR from given # of histogram
366         columns seen in an input file """
367     max_coarse = 8
368     if ctx.group_nr < 19 or ctx.group_nr > 26:
369         bins = [ctx.group_nr * (1 << 6)]
370     else:
371         bins = [1216,1280,1344,1408,1472,1536,1600,1664]
372     coarses = range(max_coarse + 1)
373     fncn = lambda z: list(map(lambda x: z/2**x if z % 2**x == 0 else -10, coarses))
374     
375     arr = np.transpose(list(map(fncn, bins)))
376     idx = np.where(arr == hist_cols)
377     if len(idx[1]) == 0:
378         table = repr(arr.astype(int)).replace('-10', 'N/A').replace('array','     ')
379         err("Unable to determine bin values from input clat_hist files. Namely \n"
380             "the first line of file '%s' " % ctx.FILE[0] + "has %d \n" % (__TOTAL_COLUMNS,) +
381             "columns of which we assume %d " % (hist_cols,) + "correspond to histogram bins. \n"
382             "This number needs to be equal to one of the following numbers:\n\n"
383             + table + "\n\n"
384             "Possible reasons and corresponding solutions:\n"
385             "  - Input file(s) does not contain histograms.\n"
386             "  - You recompiled fio with a different GROUP_NR. If so please specify this\n"
387             "    new GROUP_NR on the command line with --group_nr\n")
388         exit(1)
389     return bins[idx[1][0]]
390
391 def main(ctx):
392
393     # Automatically detect how many columns are in the input files,
394     # calculate the corresponding 'coarseness' parameter used to generate
395     # those files, and calculate the appropriate bin latency values:
396     with open(ctx.FILE[0], 'r') as fp:
397         global bin_vals,lower_bin_vals,upper_bin_vals,__HIST_COLUMNS,__TOTAL_COLUMNS
398         __TOTAL_COLUMNS = len(fp.readline().split(','))
399         __HIST_COLUMNS = __TOTAL_COLUMNS - __NON_HIST_COLUMNS
400
401         max_cols = guess_max_from_bins(ctx, __HIST_COLUMNS)
402         coarseness = int(np.log2(float(max_cols) / __HIST_COLUMNS))
403         bin_vals = np.array(map(lambda x: plat_idx_to_val_coarse(x, coarseness), np.arange(__HIST_COLUMNS)), dtype=float)
404         lower_bin_vals = np.array(map(lambda x: plat_idx_to_val_coarse(x, coarseness, 0.0), np.arange(__HIST_COLUMNS)), dtype=float)
405         upper_bin_vals = np.array(map(lambda x: plat_idx_to_val_coarse(x, coarseness, 1.0), np.arange(__HIST_COLUMNS)), dtype=float)
406
407     fps = [open(f, 'r') for f in ctx.FILE]
408     gen = histogram_generator(ctx, fps, ctx.buff_size)
409
410     print(', '.join(columns))
411
412     try:
413         start, end = 0, ctx.interval
414         arr = np.empty(shape=(0,__TOTAL_COLUMNS - 1))
415         more_data = True
416         while more_data or len(arr) > 0:
417             
418             # Read up to ctx.max_latency (default 20 seconds) of data from end of current interval.
419             while len(arr) == 0 or arr[-1][0] < ctx.max_latency * 1000 + end:
420                 try:
421                     new_arr = next(gen)
422                 except StopIteration:
423                     more_data = False
424                     break
425                 arr = np.append(arr, new_arr.reshape((1,__TOTAL_COLUMNS - 1)), axis=0)
426             arr = arr.astype(int)
427             
428             if arr.size > 0:
429                 process_interval(ctx, arr, start, end)
430                 
431                 # Update arr to throw away samples we no longer need - samples which
432                 # end before the start of the next interval, i.e. the end of the
433                 # current interval:
434                 idx = np.where(arr[:,0] > end)
435                 arr = arr[idx]
436             
437             start += ctx.interval
438             end = start + ctx.interval
439     finally:
440         map(lambda f: f.close(), fps)
441
442
443 if __name__ == '__main__':
444     import argparse
445     p = argparse.ArgumentParser()
446     arg = p.add_argument
447     arg("FILE", help='space separated list of latency log filenames', nargs='+')
448     arg('--buff_size',
449         default=10000,
450         type=int,
451         help='number of samples to buffer into numpy at a time')
452
453     arg('--max_latency',
454         default=20,
455         type=float,
456         help='number of seconds of data to process at a time')
457
458     arg('-i', '--interval',
459         default=1000,
460         type=int,
461         help='interval width (ms)')
462
463     arg('-d', '--divisor',
464         required=False,
465         type=int,
466         default=1,
467         help='divide the results by this value.')
468
469     arg('--decimals',
470         default=3,
471         type=int,
472         help='number of decimal places to print floats to')
473
474     arg('--nowarn',
475         dest='nowarn',
476         action='store_false',
477         default=True,
478         help='do not print any warning messages to stderr')
479
480     arg('--group_nr',
481         default=19,
482         type=int,
483         help='FIO_IO_U_PLAT_GROUP_NR as defined in stat.h')
484
485     main(p.parse_args())
486