├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── bench_fio ├── README.md ├── __init__.py ├── __main__.py ├── benchlib │ ├── __init__.py │ ├── argparsing.py │ ├── checks.py │ ├── defaultsettings.py │ ├── display.py │ ├── generatefio.py │ ├── network.py │ ├── parseini.py │ ├── runfio.py │ └── supporting.py ├── scripts │ ├── bench-fio.sh │ └── generate_call_graph.sh └── templates │ ├── benchmark.ini │ └── precondition.fio ├── bin ├── bench-fio └── fio-plot ├── docs ├── bench_fio_call_graph.png ├── fio_plot_call_graph.png └── generate_call_graph.sh ├── fio_plot ├── DATAIMPORT.md ├── README.md ├── __init__.py ├── __main__.py ├── fiolib │ ├── __init__.py │ ├── argparsing.py │ ├── bar2d.py │ ├── bar3d.py │ ├── barhistogram.py │ ├── dataimport.py │ ├── dataimport_support.py │ ├── defaultsettings.py │ ├── flightchecks.py │ ├── getdata.py │ ├── graph2d.py │ ├── graph2dsupporting.py │ ├── iniparsing.py │ ├── iniparsing_support.py │ ├── jsonimport.py │ ├── jsonparsing.py │ ├── jsonparsing_support.py │ ├── shared_chart.py │ ├── supporting.py │ ├── table_support.py │ └── tables.py └── templates │ └── fio-plot.ini ├── requirements.txt ├── setup.py └── tests ├── bench_fio_test.py └── test_3d.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # vs 132 | .vscode/* 133 | !.vscode/settings.json 134 | !.vscode/tasks.json 135 | !.vscode/launch.json 136 | !.vscode/extensions.json 137 | *.code-workspace 138 | 139 | # Local History for Visual Studio Code 140 | .history/ 141 | 142 | .DS_Store 143 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Release Notes 2 | 3 | ### Sorry, I've not updated this in a long time. 4 | 5 | ### v1.0.1 (2020-09-30) 6 | 7 | - Added cpu usage table for 2D bar charts (-l and -C) 8 | 9 | - A lot of internal under-the-hood code improvements 10 | 11 | ### v1.0.0 (2020-09-29) 12 | 13 | - New 'compare' option that generates 2D bar charts from multiple datasources to compare results. This allows you to compare the results of different benchmark parameters or different devices. 14 | 15 | - New/changed command line options to control the format of the label when generating compare graphs. These options are --xlabel-depth and --xlabel-parent. 16 | 17 | - The command line parameters supplied to fio-plot are embedded into the PNG file so you can correlate the image back to fio-plot settings and data sources. 18 | 19 | ### v0.9.0 (2020-09-29) 20 | 21 | Initial release, mostly as a fall-back release in case issues in 1.0.0 are found. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stable-slim 2 | WORKDIR . 3 | COPY . . 4 | RUN apt update && apt install python3-pip -y 5 | RUN pip3 install -e . 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft tests 3 | include bench_fio/templates/*.fio 4 | include bench_fio/scripts/*.sh 5 | include CHANGELOG.md 6 | include README.md 7 | include LICENSE 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## fio-plot 2 | 3 | [FIO][fio] is a tool for benchmarking storage devices. FIO helps to assess the storage performance in terms of IOP/s and latency. 4 | 5 | Fio-plot generates charts from FIO storage benchmark data. It can process FIO output in JSON format. It can also process FIO log file output (in CSV format). 6 | It also includes bench-fio, a benchmark tool to automate benchmarking with FIO. Checkout the many examples below. 7 | 8 | [fio]: https://github.com/axboe/fio 9 | 10 | ![barchart][2dchartiodepth] 11 | 12 | To make these charts yourself, you need to follow this process: 13 | 14 | 1. Run your tests, maybe use the included benchmark script [bench-fio][bms] 15 | 2. Determine which information you would like to show 16 | 3. Run fio-plot to generate the images with the appropriate command line options 17 | 18 | [bms]: https://github.com/louwrentius/fio-plot/tree/master/bin 19 | 20 | ## Quick installation guide: 21 | 22 | Ubuntu 18.04+ LTS: please run this command first: 23 | 24 | apt install zlib1g-dev libjpeg-dev python3-pip 25 | 26 | All operating systems: 27 | 28 | pip3 install fio-plot 29 | 30 | If you want to use the benchmark script bench-fio, make sure to install Fio too. 31 | 32 | If you don't want to install fio-plot system-wide, you can make a virtual environment like this: 33 | 34 | cd /desired/path 35 | python3 -m venv fio-plot 36 | source fio-plot/bin/activate 37 | pip3 install fio-plot 38 | 39 | When you source the virtual environment, fio-plot and bench-fio will be in your executable path. 40 | 41 | If you want to install from source, you can clone the repository and run 42 | 43 | python3 setup.py install 44 | 45 | ## Configuration command-line vs. INI 46 | 47 | Fio-plot supports configuration through command-line parameters or using an INI format configuration file. 48 | The examples provided in the following sections use command-line parameters. 49 | 50 | This is how you use an INI configuration file (instead): 51 | 52 | fio-plot /path/to/fio-plot.ini 53 | 54 | An example INI is inclued in the fio_plot/templates/fio-plot.ini file. It looks like this: 55 | 56 | [graphtype] 57 | graphtype = bargraph2d_qd 58 | 59 | [settings] 60 | input_directory = /path/to/benchmarkdata 61 | output_filename = test.png 62 | title = Title 63 | subtitle = 64 | source = https://louwrentius.com 65 | rw = randread 66 | type = 67 | ... 68 | 69 | - The fio-plot --help command explains the usage of the parameters available in the INI. 70 | - You can't use both the INI file and command-line options, you have to pick one. 71 | 72 | ## 2D chart (iodepth) 73 | This kind of chart shows both IOPs and Latency for different queue depths. 74 | ![barchart][2dchartiodepth] 75 | 76 | [2dchartiodepth]: https://louwrentius.com/static/images/fio-plot/fioplot0001.png 77 | 78 | This is the command-line used to generate this graph: 79 | 80 | fio-plot -i INTEL_D3-S4610 --source "https://louwrentius.com" -T "INTEL D3-S4610 SSD on IBM M1015" -l -r randread 81 | 82 | ## 2D chart (numjobs) 83 | This kind of chart shows both IOPs and Latency for diffent simultaneous number of jobs. 84 | ![barchart][2dchartnumjobs] 85 | 86 | [2dchartnumjobs]: https://louwrentius.com/static/images/fio-plot/fioplot0002.png 87 | 88 | This is the command-line used to generate this graph: 89 | 90 | fio-plot -i INTEL_D3-S4610 --source "https://louwrentius.com" -T "INTEL D3-S4610 SSD on IBM M1015" -N -r randread 91 | 92 | ## 2D chart to compare benchmark results 93 | 94 | The compare chart shows the results from multiple different benchmarks in one graph. The graph data is always for a specific queue depth and numjobs values (the examples use qd=1, nj=1 (the default)). 95 | 96 | ![barchart][2dchartcompare] 97 | 98 | [2dchartcompare]: https://louwrentius.com/static/images/fio-plot/fioplot0003.png 99 | 100 | This is the command-line used to generate this graph: 101 | 102 | fio-plot -i INTEL_D3-S4610 SAMSUNG_860_PRO KINGSTON_DC500M SAMSUNG_PM883 --source "https://louwrentius.com" -T "Comparing the performance of various Solid State Drives" -C -r randread --xlabel-parent 0 103 | 104 | It is also possible to group the bars for IOPs and Latency like this: 105 | 106 | ![barchart][2dchartcomparegroup] 107 | 108 | [2dchartcomparegroup]: https://louwrentius.com/static/images/fio-plot/fioplot0004.png 109 | 110 | This is the command-line used to generate this graph: 111 | 112 | fio-plot -i INTEL_D3-S4610 SAMSUNG_860_PRO KINGSTON_DC500M SAMSUNG_PM883 --source "https://louwrentius.com" -T "Comparing the performance of various Solid State Drives" -C -r randread --xlabel-parent 0 --group-bars 113 | 114 | 115 | ## 3D chart 116 | A 3D bar chart that plots both queue depth an numjobs against either latency or IOPs. This example shows IOPs. 117 | 118 | ![3dbarchart][3dbarchartiops] 119 | 120 | [3dbarchartiops]: https://louwrentius.com/static/images/fio-plot/fioplot0005.png 121 | 122 | This is the command-line used to generate this graph: 123 | 124 | fio-plot -i RAID10 --source "https://louwrentius.com" -T "RAID10 performance of 8 x WD Velociraptor 10K RPM" -L -t iops -r randread 125 | 126 | It is also possible to chart the latency: 127 | 128 | ![3dbarchart][3dbarchartlat] 129 | 130 | [3dbarchartlat]: https://louwrentius.com/static/images/fio-plot/fioplot0006.png 131 | 132 | This is the command-line used to generate this graph: 133 | 134 | fio-plot -i RAID10 --source "https://louwrentius.com" -T "RAID10 performance of 8 x WD Velociraptor 10K RPM" -L -t lat -r randread 135 | 136 | ## Line chart based on FIO log data 137 | 138 | Fio records a 'performance trace' of various metrics, such as IOPs and latency over time in plain-text .log 139 | files. If you use the benchmark tool included with fio-plot, this data is logged every 1 second. 140 | 141 | This data can be parsed and graphed over time. In this example, we plot the data for four different solid state drives in one chart. 142 | 143 | ![linechart][linegraph01] 144 | 145 | [linegraph01]: https://louwrentius.com/static/images/fio-plot/fioplot0012.png 146 | 147 | This is the command-line used to generate this graph: 148 | 149 | fio-plot -i INTEL_D3-S4610/ KINGSTON_DC500M/ SAMSUNG_PM883/ SAMSUNG_860_PRO/ --source "https://louwrentius.com" -T "Comparing IOPs performance of multiple SSDs" -g -t iops -r randread --xlabel-parent 0 150 | 151 | It is also possible to chart the latency instead of IOPs. 152 | 153 | ![linechart][linegraph02] 154 | 155 | [linegraph02]: https://louwrentius.com/static/images/fio-plot/fioplot0013.png 156 | 157 | This is the command-line used to generate this graph: 158 | 159 | fio-plot -i INTEL_D3-S4610/ KINGSTON_DC500M/ SAMSUNG_PM883/ SAMSUNG_860_PRO/ --source "https://louwrentius.com" -T "Comparing latency performance of multiple SSDs" -g -t lat -r randread --xlabel-parent 0 160 | 161 | You can also include all information in one graph: 162 | 163 | ![linechart][linegraph03] 164 | 165 | [linegraph03]: https://louwrentius.com/static/images/fio-plot/fioplot0014.png 166 | 167 | This is the command-line used to generate this graph: 168 | 169 | fio-plot -i INTEL_D3-S4610/ KINGSTON_DC500M/ --source "https://louwrentius.com" -T "Comparing performance of multiple SSDs" -g -t iops lat -r randread --xlabel-parent 0 170 | 171 | And this is an example with a single benchmark run, comparing the performance of multiple queue depths. 172 | 173 | ![linechart][linegraph04] 174 | 175 | [linegraph04]: https://louwrentius.com/static/images/fio-plot/fioplot0015.png 176 | 177 | This is the command-line used to generate this graph: 178 | 179 | fio-plot -i INTEL_D3-S4610 --source "https://louwrentius.com" -T "Comparing multiple queue depths" -g -t iops lat -r randread -d 1 8 16 --xlabel-parent 0 180 | 181 | It is also possible to chart a total of the read+write data (iops/bw/lat) with the --draw-total option. This only works for -g style graphs and it requires 182 | a 'randrw' benchmark that is not 100% read, it should contain write data. 183 | 184 | ![linechart][linegraph05] 185 | 186 | [linegraph05]: https://user-images.githubusercontent.com/1312044/215907553-2c075f89-f4f4-4fba-9252-11520d3c5181.png 187 | 188 | This is the command-line used to generate this graph: 189 | 190 | fio-plot -i . -T "TEST" -r randrw -g -t iops --draw-total 191 | 192 | 193 | ## Latency histogram 194 | The FIO JSON output also contains latency histogram data. It's available in a ns, us and ms scale. 195 | 196 | ![histogram][histogram01] 197 | 198 | [histogram01]: https://louwrentius.com/static/images/fio-plot/fioplot0011.png 199 | 200 | This is the command-line used to generate this graph: 201 | 202 | fio-plot -i SAMSUNG_860_PRO/ --source "https://louwrentius.com" -T "Historgram of SSD" -H -r randread -d 16 -n 16 203 | 204 | ## Fio client server mechanism. 205 | 206 | Fio supports a [client-server][cs] model where one fio client can run a benchmark on multiple machines (servers) in parallel. 207 | The bench-fio tool supports this type of benchmark, see the readme for more details. For the fio-plot tool the data 208 | will be rendered based on hostname automatically. 209 | 210 | [cs]: https://fio.readthedocs.io/en/latest/fio_doc.html#client-server 211 | 212 | ![csdemo][csdemo1] 213 | 214 | ![csdemo][csdemo2] 215 | 216 | [csdemo1]: https://louwrentius.com/static/images/fio-client-server-demo.png 217 | [csdemo2]: https://louwrentius.com/static/images/fio-client-server-demo-2.png 218 | 219 | The --include-hosts and --exclude-hosts parameters allow filtering to only display the desired hosts. 220 | 221 | ## Benchmark script 222 | A benchmark script is provided alongside fio-plot, that automates the process of running multiple benchmarks with different parameters. For example, it allows 223 | you to gather data for different queue depths and/or number of simultaneous jobs. The benchmark script shows progress in real-time. 224 | 225 | ████████████████████████████████████████████████████ 226 | +++ Fio Benchmark Script +++ 227 | 228 | Job template: fio-job-template.fio 229 | I/O Engine: libaio 230 | Number of benchmarks: 98 231 | Estimated duration: 1:38:00 232 | Devices to be tested: /dev/md0 233 | Test mode (read/write): randrw 234 | IOdepth to be tested: 1 2 4 8 16 32 64 235 | NumJobs to be tested: 1 2 4 8 16 32 64 236 | Blocksize(s) to be tested: 4k 237 | Mixed workload (% Read): 75 90 238 | 239 | ████████████████████████████████████████████████████ 240 | 4% |█ | - [0:04:02, 1:35:00]-] 241 | 242 | This particular example benchmark was run with these parameters: 243 | 244 | bench-fio --target /dev/md0 --type device --template fio-job-template.fio --mode randrw --output RAID_ARRAY --readmix 75 90 --destructive 245 | 246 | In this example, we run a mixed random read/write benchmark. We have two runs, one with a 75% / 25% read/write mix and one with a 90% / 10% mix. 247 | 248 | You can run the benchmark against an entire device or a file/folder. 249 | Alongside the benchmark script, a Fio job template file is supplied (fio-job-template.fio). This file can be customised as desired. 250 | 251 | For more examples, please consult the separate [README.md][rm] 252 | 253 | [rm]: https://github.com/louwrentius/fio-plot/tree/master/bench_fio#readme 254 | 255 | ## Dependancies 256 | 257 | Fio-plot requires 'matplotlib' and 'numpy' to be installed. 258 | 259 | Please note that Fio-plot requires at least matplotlib version 3.3.0 260 | 261 | Fio-plot also writes metadata to the PNG files using Pillow 262 | 263 | 264 | ## Fio-plot additional example Usage 265 | 266 | ### 2D Bar Charts 267 | 268 | Creating a 2D Bar Chart based on randread data and numjobs = 1 (default). 269 | 270 | fio-plot -i -T "Title" -s https://louwrentius.com -l -r randread 271 | 272 | ![regularbars][regular] 273 | 274 | [regular]: https://louwrentius.com/static/images/iodepthregular.png 275 | 276 | Creating a 2D Bar Chart based on randread data and numjobs = 8. 277 | 278 | fio-plot -i -T "Title" -s https://louwrentius.com -l -n 8 -r randread 279 | 280 | Creating a 2D Bar Chart grouping iops and latency data together: 281 | 282 | fio-plot -i -T "Title" -s https://louwrentius.com -l -r randread --group-bars 283 | 284 | ![groupedbars][grouped] 285 | 286 | [grouped]: https://louwrentius.com/static/images/iodepthgroupbars.png 287 | 288 | ### 3D Bar Chart 289 | 290 | Creating a 3D graph showing IOPS. 291 | 292 | fio-plot -i -T "Title" -s https://louwrentius.com -L -r randread -t iops 293 | 294 | Creating a 3D graph with a subselection of data 295 | 296 | fio-plot -i -T "Title" -s https://louwrentius.com -L -r randread -t iops -J 16 -M 16 297 | 298 | ### 2D Bar Histogram 299 | 300 | Creating a latency histogram with a queue depth of 1 and numjobs is 1. 301 | 302 | fio-plot -i -T "Title" -s https://louwrentius.com -H -r randread -d 1 -n 1 303 | 304 | ### 2D line charts 305 | 306 | Creating a line chart from different benchmark runs in a single folder 307 | 308 | fio-plot -i -T "Test" -g -r randread -t iops lat -d 1 8 16 -n 1 309 | 310 | The same result but if you want markers to help distinguish between lines: 311 | 312 | fio-plot -i -T "Test" -g -r randread -t iops lat -d 1 8 16 -n 1 --enable--markers 313 | 314 | ![markers][markers] 315 | 316 | [markers]: https://louwrentius.com/static/images/enablemarkers.png 317 | 318 | It is also possible to change the line colors with the --colors parameter. 319 | 320 | fio-plot -i -T "Test" -g -r randread -t iops -d 1 2 4 8 --colors xkcd:red xkcd:blue xkcd:green tab:purple 321 | 322 | Please note that you need to specify a color for each line drawn. In this example, four lines are drawn. 323 | 324 | You can find a list of color names [here][cl1]. There is also a list of xkcd colors [here][cl2] (xkcd:'color name'). 325 | 326 | [cl1]: https://matplotlib.org/gallery/color/named_colors.html 327 | [cl2]: https://xkcd.com/color/rgb/ 328 | 329 | ### Comparing two or more benchmarks based on JSON data (2D Bar Chart): 330 | 331 | A simple example where we compare the iops and latency of a particular iodepth and numjobs value: 332 | 333 | fio-plots -i -T "Test" -C -r randwrite -d 8 334 | 335 | ![compare01][compare01] 336 | 337 | [compare01]: https://louwrentius.com/static/images/compareexample01.png 338 | 339 | The bars can also be grouped: 340 | 341 | ![compare03][compare03] 342 | 343 | [compare03]: https://louwrentius.com/static/images/compareexample03.png 344 | 345 | There is also an option (--show-cpu) that includes a table with CPU usage: 346 | 347 | ![comparecpu][comparecpu] 348 | 349 | [comparecpu]: https://louwrentius.com/static/images/comparecpu.png 350 | 351 | It is now also possible to show steady state statistics (--show-ss) if you ran a Fio benchmark with steady state options. 352 | 353 | ![steadystatechart][steadystatechart] 354 | 355 | [steadystatechart]: https://louwrentius.com/static/images/fio-plot/fioplot0016.png 356 | 357 | ### Comparing two or more benchmarks in a single line chart 358 | 359 | Create a line chart based on data from two different folders (but the same benchmark parameters) 360 | 361 | fio-plot -i -T "Test" -g -r randread -t iops lat -d 8 -n 1 362 | 363 | I'm assuming that the benchmark was created with the (included) bench-fio tool. 364 | 365 | For example, you can run a benchmark on a RAID10 setup and store data in folder A. Store the benchmark data for a RAID5 setup in folder B and you can compare the results of both RAID setups in a single Line graph. 366 | 367 | Please note that the folder names are used in the graph to distinguish the datasets. 368 | 369 | [![multipledataset][multipledataset]][multipledataset] 370 | 371 | [multipledataset]: https://louwrentius.com/static/images/fio-plot/fioplot0017.png 372 | 373 | Command used: 374 | 375 | fio-plot -i ./IBM1015/RAID10/4k/ ./IBM1015/RAID5/4k/ -T "Comparing RAID 10 vs. RAID 5 on 10,000 RPM Drives" -s https://louwrentius.com -g -r randread -t iops lat -d 8 -n 1 376 | 377 | If you use the bench-fio tool to generate benchmark data, you may notice that you end up with folders like: 378 | 379 | IBM1015/RAID10/4k 380 | IBM1015/RAID5/4k 381 | 382 | Those parent folders are used to distinguish and identify the lines from each other. The labels are based on the parent folder names as you can see in the graph. By default, we use only one level deep, so in this example only RAID10/4k or RAID5/4k are used. If we want to include the folder above that (IBM1015) we use the --xlabel-parent parameter like so: 383 | 384 | fio-plot -i ./IBM1015/RAID10/4k/ ./IBM1015/RAID5/4k/ -T "Comparing RAID 10 vs. RAID 5 on 10,000 RPM Drives" -s https://louwrentius.com -g -r randread -t iops lat -d 8 -n 1 -w 1 --xlabel-parent 2 385 | 386 | This would look like: 387 | 388 | [![labellength][labellength]][labellength] 389 | 390 | [labellength]: https://louwrentius.com/static/images/fio-plot/fioplot0018.png 391 | 392 | Some additional examples to explain how you can trim the labels to contain exactly the directories you want: 393 | 394 | The default: 395 | 396 | RAID10/4k 397 | 398 | Is equivalent to --xlabel-parent 1 --xlabel-depth 0. So by default, the parent folder is included. 399 | If you strip off the 4k folder with --xlabel-depth 1, you'll notice that the label becomes: 400 | 401 | IBM1015/RAID10 402 | 403 | This is because the default --xlabel-parent is 1 and the index now starts at 'RAID10'. 404 | 405 | If you want to strip off the 4k folder but not include the IBM1015 folder, you need to be explicit about that: 406 | 407 | --xlabel-parent 0 --xlabel-depth 1 408 | 409 | Results in: 410 | 411 | RAID10 412 | 413 | Example: 414 | 415 | ![shortlabel][shortlabel] 416 | 417 | [shortlabel]: https://louwrentius.com/static/images/fio-plot/fioplot0019.png 418 | 419 | ## JSON / LOG file name requirements 420 | 421 | Fio-plot parses the filename of the generated .log files. The format is: 422 | 423 | [rwmode]-iodepth-[iodepth]-numjobs-[numjobs]_[fio generated type].[numbjob job id].log 424 | 425 | An example: 426 | 427 | randwrite-iodepth-8-numjobs-8_lat.1.log 428 | randwrite-iodepth-8-numjobs-8_lat.2.log 429 | randwrite-iodepth-8-numjobs-8_lat.3.log 430 | randwrite-iodepth-8-numjobs-8_lat.4.log 431 | randwrite-iodepth-8-numjobs-8_lat.5.log 432 | randwrite-iodepth-8-numjobs-8_lat.6.log 433 | randwrite-iodepth-8-numjobs-8_lat.7.log 434 | randwrite-iodepth-8-numjobs-8_lat.8.log 435 | 436 | In this example, there are 8 files because numjobs was set to 8. Fio autoamatically generates a file for each job. 437 | It's important that - if you don't use the included benchmark script - to make sure files are generated with the appropriate file name structure. 438 | 439 | 440 | ## PNG metadata 441 | 442 | All settings used to generate the PNG file are incorporated into the PNG file as metadata (tEXT). 443 | This should help you to keep track of the exact parameters and data used to generate the graphs. 444 | This metadata can be viewed with ImageMagick like this: 445 | 446 | identify -verbose filename.png 447 | 448 | This is a fragment of the output: 449 | 450 | Properties: 451 | compare_graph: True 452 | date:create: 2020-09-28T16:27:08+00:00 453 | date:modify: 2020-09-28T16:27:07+00:00 454 | disable_grid: False 455 | dpi: 200 456 | enable_markers: False 457 | filter: ('read', 'write') 458 | histogram: False 459 | input_directory: /Users/MyUserName/data/WDRAID5 /Users/MyUserName/data/WDRAID10 460 | iodepth: 16 461 | bargraph3d: False 462 | latency_iops_2d: False 463 | line_width: 1 464 | loggraph: False 465 | maxdepth: 64 466 | maxjobs: 64 467 | -------------------------------------------------------------------------------- /bench_fio/README.md: -------------------------------------------------------------------------------- 1 | ### Introduction 2 | This benchmark script is provided alongside fio-plot. It automates the process of running multiple benchmarks with different parameters. For example, it allows you to gather data for different queue depths and/or number of simultaneous jobs. The benchmark script shows progress in real-time. 3 | 4 | #### Steady State 5 | It supports the [Fio "steady state"][fioss] feature, that stops a benchmark when the desired steady state is reached for a configured time duration. 6 | 7 | [fioss]: https://github.com/axboe/fio/blob/master/examples/steadystate.fio 8 | 9 | #### SSD Preconditioning 10 | 11 | This benchmark script supports running configure SSD preconditioning jobs that are run before the actual benchmarks are executed. You may even specify for them to run after each benchmark if desired. More information can be found further down into this document. 12 | 13 | ### Example output 14 | 15 | An example with output: 16 | 17 | ./bench_fio --target /dev/md0 --type device --iodepth 1 8 16 --numjobs 8 --mode randrw --output RAID_ARRAY --rwmixread 75 90 18 | 19 | ████████████████████████████████████████████████████ 20 | +++ Fio Benchmark Script +++ 21 | 22 | Job template: fio-job-template.fio 23 | I/O Engine: libaio 24 | Number of benchmarks: 6 25 | Estimated duration: 0:06:00 26 | Devices to be tested: /dev/md0 27 | Test mode (read/write): randrw 28 | IOdepth to be tested: 1 8 16 29 | NumJobs to be tested: 8 30 | Blocksize(s) to be tested: 4k 31 | Mixed workload (% Read): 75 90 32 | 33 | ████████████████████████████████████████████████████ 34 | 100% |█████████████████████████| [0:06:06, 0:00:00]-] 35 | 36 | Tip: Because benchmarks can run a long time, it may be advised to run them 37 | in a 'screen' session. 38 | 39 | ### Example usage 40 | 41 | We benchmark two devices with a randread/randrwite workload. 42 | 43 | ./bench_fio --target /dev/md0 /dev/md1 --type device --mode randread randwrite --output RAID_ARRAY --destructive 44 | 45 | We benchmark one device with a custom set of iodepths and numjobs: 46 | 47 | ./bench_fio --target /dev/md0 --type device --mode randread randwrite --output RAID_ARRAY --iodepth 1 8 16 --numjobs 8 --destructive 48 | 49 | We benchmark one device and pass extra custom parameters. 50 | 51 | ./bench_fio --target /dev/md0 --type device --mode randread randwrite --output RAID_ARRAY --extra-opts norandommap=1 refill_buffers=1 --destructive 52 | 53 | We benchmark using the steady state feature: 54 | 55 | ./bench_fio --target /dev/sda --type device -o test -m randwrite --loops 1 --iodepth 1 8 16 32 --numjobs 1 --ss iops:0.1% --ss-ramp 10 --ss-dur 20 --runtime 60 --destructive 56 | 57 | ### INI configuration file support 58 | 59 | Originally bench_fio was configured purely by concatenating the required command line parameters. 60 | Starting with version 1.0.20 bench_fio supports an INI file format for configuration, similar to FIO. 61 | This is how you can run bench_fio with a INI based configuration file: 62 | 63 | ./bench_fio /path/to/benchmark.ini 64 | 65 | An example configuration file is included in the templates folder called benchmark.ini and contains the following: 66 | 67 | [benchfio] 68 | target = /dev/example 69 | output = benchmark 70 | type = device 71 | mode = randread,randwrite 72 | size = 10G 73 | iodepth = 1,2,4,8,16,32,64 74 | numjobs = 1,2,4,8,16,32,64 75 | direct = 1 76 | engine = libaio 77 | precondition = False 78 | precondition_repeat = False 79 | extra_opts = norandommap=1,refill_buffers=1 80 | runtime = 60 81 | destructive = False 82 | 83 | Please notice that on the command line, multiple arguments are separated by spaces. However, within the INI file, 84 | multiple arguments are separated by a comma. 85 | 86 | ### Extra (custom) Fio parameters 87 | 88 | If you use the bench-fio comand line, extra options can be specified with the --extra-opts parameter like this: 89 | 90 | --extra-opts parameter1=value parameter2=value 91 | 92 | Example: 93 | 94 | --extra-opts norandommap=1 refill_buffers=1 95 | 96 | If the INI file is used to perform bench-fio benchmarks, those extra options can just be added to the file like 97 | a regular fio job file, one per line. 98 | 99 | norandommap = 1 100 | refill_buffers = 1 101 | 102 | You can put any valid fio option in the bench-fio INI file and those will be passed as-is to fio. Such parameters are 103 | marked with an asterix(*) when running bench-fio. 104 | 105 | ### Benchmarking multiple devices in parallel 106 | 107 | By default, a test run will benchmark one device at a time, sequentially. The --parallel option allows multiple devices 108 | to be tested in parallel. This has two benefits, it speeds up testing and it also simulates a particular load on the system. 109 | It could be that benchmarking devices in parallel causes so much CPU impact that this impacts the benchmark results. 110 | Depending on your intentions, this may actually be an interesting outcome or spoil the benchmark results, so keep this in mind. 111 | 112 | Thanks @Zhucan for building this feature. 113 | 114 | ### Fio Client/Server support 115 | 116 | The Fio tool supports a [client-server][clientserver] model where one host can issue a benchmark on just one remote host 117 | up to hundreds of remote hosts. Bench-fio supports this feature with the --remote and --remote-checks options. 118 | 119 | [clientserver]: https://fio.readthedocs.io/en/latest/fio_doc.html#client-server 120 | 121 | The --remote argument requires a file containing one host per line as required by Fio. 122 | 123 | host01 124 | host02 125 | 126 | The host can either be specified as an IP-address or as a DNS name. 127 | 128 | So it would look like: 129 | 130 | --remote /some/path/to/file.txt 131 | 132 | The --remote-checks parameter makes bench-fio check if all hosts are up before starting the benchmark. 133 | Specifically, it checks if TCP port 8765 is open on a host. 134 | 135 | If one of the host fails this check bench-fio will never start actual benchmarks. By default, Fio will start benchmarking 136 | hosts that are network-accessible, but then abort when one or more host are found to be unreachable. 137 | As this may be undesirable, the --remote-checks parameter can avoid this scenario. 138 | 139 | ### Output 140 | 141 | The benchmark data consists of two typtes of data. 142 | 143 | 1. Fio .json output 144 | 2. Fio .log output 145 | 146 | This is an example to clarify the directory structure: 147 | The folder 'RAID_ARRAY' is the folder specified in --output. 148 | 149 | RAID_ARRAY/ <-- folder as specified wit --output 150 | └── md0 <-- device 151 | ├── randrw75 <-- mixed load with % read 152 | │   ├── 4k <-- Block size 153 | │   │   ├── randrw-16-8.json 154 | │   │   ├── randrw-1-8.json 155 | │   │   ├── randrw-8-8.json 156 | │   └── 8k <-- Block size 157 | │   ├── randrw-16-8.json 158 | │   ├── randrw-1-8.json 159 | │   ├── randrw-8-8.json 160 | └── randrw90 161 | ├── 4k 162 | │   ├── randrw-16-8.json 163 | │   ├── randrw-1-8.json 164 | │   ├── randrw-8-8.json 165 | └── 8k 166 | ├── randrw-16-8.json 167 | ├── randrw-1-8.json 168 | ├── randrw-8-8.json 169 | 170 | The .log files are ommitted. 171 | 172 | Please note that mixed workloads will get their own folder to prevent files being overwritten. 173 | Pure read/write/trim workloads will appear in the *device* folder. 174 | 175 | ### SSD Preconditioning 176 | 177 | In order to obtain performance numbers that will actually represent production, it is very important to precondition SSDs. 178 | SSDs perform all kinds of strategies to improve write performance. Under a sustained write load, performance may dramatically deteriorate. To find out how much performance decreases, it's important to test with the SSD completely written over, multiple times. 179 | 180 | The included preconditioning step in this benchmark script overwrites the device twice, to make sure all flash storage is written to. 181 | 182 | More background information about SSD preconditioning [can be found here][snia]. 183 | 184 | [snia]: https://www.snia.org/sites/default/education/tutorials/2011/fall/SolidState/EstherSpanjer_The_Why_How_SSD_Performance_Benchmarking.pdf 185 | 186 | ### Notes on IO queue depth and number of jobs 187 | 188 | As discussed in issue #41 each job has its own I/O queue. If qd=1 and nj=5, you will have 5 IOs in flight. 189 | If you have qd=4 and nj=4 you will have 4 x 4 = 16 IOs in flight. 190 | 191 | ### Benchmarking Ceph RBD images 192 | 193 | If you need to benchmark a Ceph RBD image, some tips: 194 | 195 | The --target should be the RBD image to benchmark 196 | The --ceph-pool parameter should specify the pool 197 | 198 | ### Benchmarking a device using --size instead of --runtime as the benchmark duration 199 | By default, bench_fio uses a --runtime of 60 seconds unless --entire-device is specified or you specify a higher --runtime. 200 | 201 | If you use the --size option with --type device, you must specify --runtime 0 if you want the --size parameter to be honoured. 202 | You can also specify a large --runtime value as an upper bound to to the benchmark duration as fio stops benchmarking when the --size limit is reached. 203 | 204 | ### Installation requirements 205 | 206 | Bench_fio requires Python3. The 'numpy' python module is required. 207 | 208 | pip3 install -r requirements.txt 209 | 210 | You can also use apt/yum to satisfy this requirement. 211 | 212 | ### Usage 213 | 214 | usage: bench-fio [-h] -d TARGET [TARGET ...] -t {device,file,directory,rbd} [-P CEPH_POOL] [-s SIZE] -o OUTPUT 215 | [-b BLOCK_SIZE [BLOCK_SIZE ...]] [--iodepth IODEPTH [IODEPTH ...]] [--numjobs NUMJOBS [NUMJOBS ...]] 216 | [--runtime RUNTIME] [-p] [--precondition-repeat] [--precondition-template PRECONDITION_TEMPLATE] 217 | [-m MODE [MODE ...]] [--rwmixread RWMIXREAD [RWMIXREAD ...]] [-e ENGINE] [--direct DIRECT] 218 | [--loops LOOPS] [--time-based] [--entire-device] [--ss SS] [--ss-dur SS_DUR] [--ss-ramp SS_RAMP] 219 | [--extra-opts EXTRA_OPTS [EXTRA_OPTS ...]] [--invalidate INVALIDATE] [--quiet] 220 | [--loginterval LOGINTERVAL] [--dry-run] [--destructive] [--remote REMOTE] [--remote-checks] 221 | [--remote-timeout REMOTE_TIMEOUT] [--create] [--parallel] 222 | 223 | Automates FIO benchmarking. It can run benchmarks with different iodepths, jobs or other properties. 224 | 225 | options: 226 | -h, --help show this help message and exit 227 | 228 | Generic Settings: 229 | -d TARGET [TARGET ...], --target TARGET [TARGET ...] 230 | 231 | Storage device / directory / file / rbd image (Ceph) to be tested. When the path contains 232 | a colon (:), it must be escaped with a double backslash (\\) or single backslash when 233 | you use single quotes around the path. 234 | Usage example: --target /dev/disk/by-path/pci-0000\\:00\\:1f.2-ata-4.0 235 | -t {device,file,directory,rbd}, --type {device,file,directory,rbd} 236 | Target type, device, file, directory or rbd (Ceph) 237 | -P CEPH_POOL, --ceph-pool CEPH_POOL 238 | Specify the Ceph pool in wich the target rbd image resides. 239 | -s SIZE, --size SIZE File size if target is a file. The value is passed straight to the fio --size parameter. See 240 | the Fio man page for supported syntax. If target is a directory, a file of the specified size 241 | is created per job 242 | -o OUTPUT, --output OUTPUT 243 | Output directory for .json and .log output. If a read/write mix is specified, separate 244 | directories for each mix will be created. 245 | -b BLOCK_SIZE [BLOCK_SIZE ...], --block-size BLOCK_SIZE [BLOCK_SIZE ...] 246 | Specify block size(s). (Default: ['4k'] 247 | --iodepth IODEPTH [IODEPTH ...] 248 | Override default iodepth test series ([1, 2, 4, 8, 16, 32, 64]). Usage example: --iodepth 1 8 249 | 16 250 | --numjobs NUMJOBS [NUMJOBS ...] 251 | Override default number of jobs test series ([1, 2, 4, 8, 16, 32, 64]). Usage example: 252 | --numjobs 1 8 16 253 | --runtime RUNTIME Override the default test runtime per benchmark(default: 60) 254 | -p, --precondition With this option you can specify an SSD precondition workload prior to performing 255 | actualbenchmarks. If you don't precondition SSDs before running a benchmark, results may 256 | notreflect actual real-life performance under sustained load. (default: False). 257 | --precondition-repeat 258 | After every individual benchmark, the preconditioning run is executed (again). (Default: 259 | False). 260 | --precondition-template PRECONDITION_TEMPLATE 261 | The Fio job template containing the precondition 262 | workload(default=/usr/local/lib/python3.10/dist- 263 | packages/fio_plot-1.1.7-py3.10.egg/bench_fio/benchlib/../templates/precondition.fio 264 | -m MODE [MODE ...], --mode MODE [MODE ...] 265 | List of I/O load tests to run (default: ['randread']) 266 | --rwmixread RWMIXREAD [RWMIXREAD ...] 267 | If a mix of read/writes is specified with --testmode, the ratio of reads vs. writes can be 268 | specified with this option. The parameter is an integer and represents the percentage of 269 | reads. A read/write mix of 75%/25% is specified as '75' (default: None). Multiple values can 270 | be specified and separate output directories will be created. This argument is only used if 271 | the benchmark is of type randrw. Otherwise this option is ignored. 272 | -e ENGINE, --engine ENGINE 273 | Select the ioengine to use, see fio --enghelp for an overview of supported engines. (Default: 274 | libaio). 275 | --direct DIRECT Use DIRECT I/O (default: 1) 276 | --loops LOOPS Each individual benchmark is repeated x times (default: 1) 277 | --time-based All benchmarks are time based, even if a test size is specifiedLookt at the Fio time based 278 | option for more information.(default: False). 279 | --entire-device The benchmark will keep running until all sectors are read or written to. Overrides runtime 280 | setting.(default: False). 281 | --ss SS Detect and exit on achieving steady state (spefial Fio feature, 'man fio' for more detials) 282 | (default: False) 283 | --ss-dur SS_DUR Steady state window (default: None) 284 | --ss-ramp SS_RAMP Steady state ramp time (default: None) 285 | --extra-opts EXTRA_OPTS [EXTRA_OPTS ...] 286 | Allows you to add extra options, for example, options that are specific to the selected 287 | ioengine. It can be any other Fio option. Example: --extra-opts norandommap=1 invalidate=0 288 | this can also be specified in the bench-fio ini file 289 | --invalidate INVALIDATE 290 | From the Fio manual: Invalidate buffer-cache for the file prior to starting I/O.(Default: 1) 291 | --quiet The progresbar will be supressed. 292 | --loginterval LOGINTERVAL 293 | Interval that specifies how often stats are logged to the .log files. (Default: 1000 294 | --dry-run Simulates a benchmark, does everything except running Fio. 295 | --destructive Enables benchmarks that write towards the device|file|directory 296 | --remote REMOTE Uses Fio client/server mechanism. Argument requires file with name host.list containing one 297 | host per line. (False). Usage example: --remote host.list 298 | --remote-checks When Fio client/server is used, we run a preflight check if all hosts are up using a TCP port 299 | check before we run the benchmark. Otherwise some hosts start benchmarking until a down host 300 | times out, which may be undesirable. (False). 301 | --remote-timeout REMOTE_TIMEOUT 302 | When Fio client/server is used, we run a preflight check if all hosts are up using a TCP port 303 | check before we run the benchmark. Otherwise some hosts start benchmarking until a down host 304 | times out, which may be undesirable. (False). 305 | --create Create target files if they don't exist. This is the default for fio but not for bench_fio 306 | --parallel Testing devices in parallel. The default for testing devices in sequential 307 | -------------------------------------------------------------------------------- /bench_fio/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | This script is written to automate the process of running multiple 4 | Fio benchmarks. The output of the benchmarks have been tailored to be used with 5 | fio-plot. 6 | 7 | You may also an older script already part of Fio if that better suits your needs: 8 | https://github.com/axboe/fio/blob/master/tools/genfio 9 | The output of this tool may not always fit with the file-name requirements of 10 | fio-plot, depending on the graph type. 11 | """ 12 | 13 | import sys 14 | from .benchlib import ( 15 | checks, 16 | display, 17 | runfio, 18 | supporting, 19 | argparsing, 20 | defaultsettings as defaults, 21 | parseini, 22 | network 23 | ) 24 | 25 | def gather_settings(): 26 | settings = defaults.get_default_settings() 27 | customsettings = parseini.get_settings_from_ini(sys.argv) 28 | #print(customsettings) 29 | if not customsettings: 30 | args = argparsing.check_args(settings) 31 | customsettings = vars(args) 32 | settings = {**settings, **customsettings} 33 | checks.check_settings(settings) 34 | return settings 35 | 36 | def main(): 37 | checks.check_encoding() 38 | checks.check_if_fio_exists() 39 | settings = gather_settings() 40 | network.remote_checks(settings) 41 | tests = supporting.generate_test_list(settings) 42 | display.display_header(settings, tests) 43 | runfio.run_benchmarks(settings, tests) 44 | -------------------------------------------------------------------------------- /bench_fio/__main__.py: -------------------------------------------------------------------------------- 1 | import bench_fio 2 | import sys 3 | 4 | if __name__ == '__main__': 5 | try: 6 | bench_fio.main() 7 | except KeyboardInterrupt: 8 | print("\nControl-C pressed - quitting...") 9 | sys.exit(1) 10 | -------------------------------------------------------------------------------- /bench_fio/benchlib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louwrentius/fio-plot/94842873f25c8eefc8b2ca2feb4d1f25b07c8473/bench_fio/benchlib/__init__.py -------------------------------------------------------------------------------- /bench_fio/benchlib/argparsing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import sys 4 | 5 | 6 | def check_args(settings): 7 | """Some basic error handling.""" 8 | try: 9 | parser = get_arguments(settings) 10 | args = parser.parse_args() 11 | 12 | except OSError: 13 | parser.print_help() 14 | sys.exit(1) 15 | 16 | if len(sys.argv) == 1: 17 | parser.print_help() 18 | sys.exit(2) 19 | 20 | return args 21 | 22 | def get_arguments(settings): 23 | parser = argparse.ArgumentParser( 24 | description="Automates FIO benchmarking. It can run benchmarks \ 25 | with different iodepths, jobs or other properties." 26 | ) 27 | ag = parser.add_argument_group(title="Generic Settings") 28 | ag.add_argument( 29 | "-d", 30 | "--target", 31 | help=( 32 | "Storage device / directory / file / rbd image (Ceph) to be " 33 | "tested. When the path contains a colon (:), it must be escaped " 34 | "with a backslash (\\). " 35 | "Usage example: --target '/dev/disk/by-id/drive-0\\:0'" 36 | ), 37 | required=True, 38 | nargs="+", 39 | type=str, 40 | ) 41 | ag.add_argument( 42 | "-t", 43 | "--type", 44 | help="Target type, device, file, directory or rbd (Ceph)", 45 | choices=["device", "file", "directory", "rbd"], 46 | required=True, 47 | ) 48 | ag.add_argument( 49 | "-P", 50 | "--ceph-pool", 51 | help="Specify the Ceph pool in wich the target rbd image resides.", 52 | type=str, 53 | ) 54 | ag.add_argument( 55 | "-s", 56 | "--size", 57 | help="File size if target is a file. The value is passed straight to the fio --size parameter.\ 58 | See the Fio man page for supported syntax. If target is a directory, a file of the specified size \ 59 | is created per job", 60 | type=str, 61 | ) 62 | ag.add_argument( 63 | "-o", 64 | "--output", 65 | help="Output directory for .json and .log output. If a read/write mix is specified,\ 66 | separate directories for each mix will be created.", 67 | required=True, 68 | ) 69 | ag.add_argument( 70 | "-b", 71 | "--block-size", 72 | help=f"Specify block size(s). (Default: {settings['block_size']}", 73 | default=settings["block_size"], 74 | nargs="+", 75 | ) 76 | ag.add_argument( 77 | "--iodepth", 78 | help=f"Override default iodepth test series\ 79 | ({settings['iodepth']}). Usage example: --iodepth 1 8 16", 80 | nargs="+", 81 | type=int, 82 | default=settings["iodepth"], 83 | ) 84 | ag.add_argument( 85 | "--numjobs", 86 | help=f"Override default number of jobs test series\ 87 | ({settings['numjobs']}). Usage example: --numjobs 1 8 16", 88 | nargs="+", 89 | type=int, 90 | default=settings["numjobs"], 91 | ) 92 | 93 | ag.add_argument( 94 | "--runtime", 95 | help=f"Override the default test runtime per benchmark" 96 | f"(default: {settings['runtime']})", 97 | type=int, 98 | default=settings["runtime"], 99 | ) 100 | 101 | ag.add_argument( 102 | "-p", 103 | "--precondition", 104 | action="store_true", 105 | help=( 106 | "With this option you can specify an SSD precondition workload prior to performing actual" 107 | "benchmarks. If you don't precondition SSDs before running a benchmark, results may not" 108 | f"reflect actual real-life performance under sustained load. (default: {str(settings['precondition'])})." 109 | ), 110 | ) 111 | 112 | ag.add_argument( 113 | "--precondition-repeat", 114 | action="store_true", 115 | help=( 116 | "After every individual benchmark, the preconditioning run is executed (again). (Default: False)." 117 | ), 118 | ) 119 | 120 | ag.add_argument( 121 | "--precondition-template", 122 | help=( 123 | "The Fio job template containing the precondition workload" 124 | f"(default={settings['precondition_template']}" 125 | ), 126 | default=settings['precondition_template'], 127 | type=str, 128 | ) 129 | 130 | ag.add_argument( 131 | "-m", 132 | "--mode", 133 | help=f"List of I/O load tests to run (default: \ 134 | {settings['mode']})", 135 | default=settings["mode"], 136 | nargs="+", 137 | type=str, 138 | ) 139 | ag.add_argument( 140 | "--rwmixread", 141 | help=f"If a mix of read/writes is specified with --testmode, the ratio of\n\ 142 | reads vs. writes can be specified with this option. The parameter is an\n\ 143 | integer and represents the percentage of reads. A read/write mix of 75%%/25%%\n\ 144 | is specified as '75' (default: {settings['rwmixread']}). Multiple values can\n\ 145 | be specified and separate output directories will be created. This argument\n\ 146 | is only used if the benchmark is of type randrw. Otherwise this option is\n\ 147 | ignored.", 148 | nargs="+", 149 | type=int, 150 | default=settings["rwmixread"], 151 | ) 152 | ag.add_argument( 153 | "-e", 154 | "--engine", 155 | help=f"Select the ioengine to use, see fio --enghelp \ 156 | for an overview of supported engines. (Default: {settings['engine']}).", 157 | default=settings["engine"], 158 | ) 159 | ag.add_argument( 160 | "--direct", 161 | help=f"Use DIRECT I/O \ 162 | (default: {settings['direct']})", 163 | type=int, 164 | default=settings["direct"], 165 | ) 166 | 167 | ag.add_argument( 168 | "--loops", 169 | help=f"Each individual benchmark is repeated x times (default: {settings['loops']})", 170 | type=int, 171 | default=settings["loops"], 172 | ) 173 | 174 | ag.add_argument( 175 | "--time-based", 176 | action="store_true", 177 | help=( 178 | "All benchmarks are time based, even if a test size is specified" 179 | "Lookt at the Fio time based option for more information." 180 | f"(default: {str(settings['time_based'])})." 181 | ), 182 | ) 183 | 184 | ag.add_argument( 185 | "--entire-device", 186 | action="store_true", 187 | help=( 188 | "The benchmark will keep running until all sectors are read or written to. Overrides runtime setting." 189 | f"(default: {str(settings['entire_device'])})." 190 | ), 191 | ) 192 | 193 | ag.add_argument( 194 | "--ss", 195 | help=f"Detect and exit on achieving steady state (spefial Fio feature, 'man fio' for more detials) \ 196 | (default: {settings['ss']})", 197 | type=str, 198 | default=settings["ss"], 199 | ) 200 | 201 | ag.add_argument( 202 | "--ss-dur", 203 | help=f"Steady state window \ 204 | (default: {settings['ss_dur']})", 205 | type=int, 206 | default=settings["ss_dur"], 207 | ) 208 | 209 | ag.add_argument( 210 | "--ss-ramp", 211 | help=f"Steady state ramp time \ 212 | (default: {settings['ss_ramp']})", 213 | type=str, 214 | default=settings["ss_ramp"], 215 | ) 216 | 217 | ag.add_argument( 218 | "--extra-opts", 219 | help="Allows you to add extra options, \ 220 | for example, options that are specific to the selected ioengine. It \ 221 | can be any other Fio option. Example: --extra-opts norandommap=1 invalidate=0\ 222 | this can also be specified in the bench-fio ini file", 223 | nargs="+", 224 | ) 225 | ag.add_argument( 226 | "--invalidate", 227 | type=int, 228 | help=f"From the Fio manual: Invalidate buffer-cache for the \ 229 | file prior to starting I/O.(Default: {settings['invalidate']})", 230 | default=settings["invalidate"], 231 | ) 232 | ag.add_argument( 233 | "--quiet", help="The progresbar will be supressed.", action="store_true" 234 | ) 235 | ag.add_argument( 236 | "--loginterval", 237 | help=f"Interval that specifies how often stats are \ 238 | logged to the .log files. (Default: {settings['loginterval']}", 239 | type=int, 240 | default=settings["loginterval"], 241 | ) 242 | ag.add_argument( 243 | "--dry-run", 244 | help="Simulates a benchmark, does everything except running\ 245 | Fio.", 246 | action="store_true", 247 | default=False, 248 | ) 249 | ag.add_argument( 250 | "--destructive", 251 | help="Enables benchmarks that write towards the device|file|directory", 252 | action="store_true", 253 | default=False, 254 | ) 255 | ag.add_argument( 256 | "--remote", 257 | help=f"Uses Fio client/server mechanism. Argument requires file with name host.list\ 258 | containing one host per line.\ 259 | ({settings['remote']}). Usage example: --remote host.list", 260 | type=str, 261 | default=settings["remote"], 262 | ) 263 | ag.add_argument( 264 | "--remote-checks", 265 | help=f"When Fio client/server is used, we run a preflight check if all hosts are up \ 266 | using a TCP port check before we run the benchmark. Otherwise some hosts start benchmarking\ 267 | until a down host times out, which may be undesirable. ({settings['remote_checks']}).", 268 | action="store_true", 269 | default=False, 270 | ) 271 | ag.add_argument( 272 | "--remote-timeout", 273 | help=f"When Fio client/server is used, we run a preflight check if all hosts are up \ 274 | using a TCP port check before we run the benchmark. Otherwise some hosts start benchmarking\ 275 | until a down host times out, which may be undesirable. ({settings['remote_checks']}).", 276 | type=int, 277 | default=settings["remote_timeout"], 278 | ) 279 | ag.add_argument( 280 | "--create", 281 | help="Create target files if they don't exist. This is the default for fio but not for bench_fio", 282 | action="store_true", 283 | default=False, 284 | ) 285 | ag.add_argument( 286 | "--parallel", 287 | help="Testing devices in parallel. The default for testing devices in sequential", 288 | action="store_true", 289 | default=False, 290 | ) 291 | return parser 292 | 293 | def get_argument_description(): 294 | descriptions = { 295 | "target": "Test target(s)", 296 | "template": "Job template", 297 | "engine": "I/O Engine", 298 | "mode": "Test mode (read/write)", 299 | "iodepth": "IOdepth to be tested", 300 | "numjobs": "NumJobs to be tested", 301 | "block_size": "Block size", 302 | "direct": "Direct I/O", 303 | "size": "Specified test data size", 304 | "rwmixread": "Read/write mix in %% read", 305 | "runtime": "Time duration per test (s)", 306 | "extra_opts": "Extra custom options", 307 | "loginterval": "Log interval of perf data (ms)", 308 | "invalidate": "Invalidate buffer cache", 309 | "loops": "Benchmark loops", 310 | "type": "Target type", 311 | "output": "Output folder", 312 | "time_based": "Time based", 313 | "benchmarks": "Number of benchmarks", 314 | "precondition": "Run precondition workload", 315 | "precondition_template": "Precondition template", 316 | "precondition_repeat": "Precondition after each test", 317 | "ss": "Detect steady state", 318 | "ss_dur": "Steady state rolling window", 319 | "ss_ramp": "Steady state rampup", 320 | "entire_device": "Benchmark entire device", 321 | "ceph_pool": "Ceph RBD pool", 322 | "destructive": "Allow destructive writes", 323 | "remote":"Use remote server", 324 | "remote_checks": "Check remote for open TCP port", 325 | "remote_timeout": "Check remote timeout (s)", 326 | "create": "Create if target doesn't exist", 327 | "parallel": "Testing devices in parallel" 328 | } 329 | return descriptions 330 | -------------------------------------------------------------------------------- /bench_fio/benchlib/checks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import shutil 3 | import sys 4 | import os 5 | from pathlib import Path 6 | from . import runfio 7 | 8 | 9 | def check_if_fio_exists(): 10 | command = "fio" 11 | if shutil.which(command) is None: 12 | print("Fio executable not found in path. Is Fio installed?") 13 | print() 14 | sys.exit(1) 15 | 16 | 17 | def check_fio_version(): 18 | """The 3.x series .json format is different from the 2.x series format. 19 | This breaks fio-plot, thus this older version is not supported. 20 | """ 21 | 22 | command = ["fio", "--version"] 23 | result = runfio.run_raw_command(command).stdout 24 | result = result.decode("UTF-8").strip() 25 | if "fio-3" in result: 26 | return True 27 | elif "fio-2" in result: 28 | print(f"Your Fio version ({result}) is not compatible. Please use Fio-3.x") 29 | sys.exit(1) 30 | else: 31 | print("Could not detect Fio version.") 32 | sys.exit(1) 33 | 34 | 35 | def check_encoding(): 36 | try: 37 | print("\u3000") # blank space 38 | except UnicodeEncodeError: 39 | print() 40 | print( 41 | "It seems your default encoding is not UTF-8. This script requires UTF-8." 42 | ) 43 | print( 44 | "You can change the default encoding with 'export PYTHONIOENCODING=UTF-8'" 45 | ) 46 | print("Or you can run the script like: PYTHONIOENCODING=utf-8 ./bench_fio") 47 | print("Changing the default encoding could affect other applications, beware.") 48 | print() 49 | exit(90) 50 | 51 | 52 | def check_target_type(target, settings): 53 | """Validate path and file/directory type and return fio command line parameter.""" 54 | filetype = settings["type"] 55 | types = ["file", "device", "directory", "rbd"] 56 | path_target = Path(target) 57 | 58 | if filetype == "rbd": 59 | return "rbdname" 60 | 61 | if not filetype in types: 62 | print(f"Error, filetype {filetype} is an unknown option.") 63 | exit(123) 64 | 65 | if not os.path.exists(target) and not settings["remote"] and not settings["create"]: 66 | print(f"Benchmark target {filetype} {target} does not exist.") 67 | sys.exit(10) 68 | 69 | check = { 70 | "file": Path.is_file, 71 | "device": Path.is_block_device, 72 | "directory": Path.is_dir, 73 | }[filetype] 74 | 75 | if not settings["remote"] and not settings["create"]: 76 | if check(path_target): 77 | return {"file": "filename", "device": "filename", "directory": "directory"}[ 78 | filetype 79 | ] 80 | else: 81 | print(f"Target {filetype} {target} is not {filetype}.") 82 | sys.exit(10) 83 | else: 84 | return {"file": "filename", "device": "filename", "directory": "directory"}[ 85 | filetype 86 | ] 87 | 88 | 89 | def check_settings(settings): 90 | """Some basic error handling.""" 91 | 92 | check_fio_version() 93 | 94 | if settings["entire_device"]: 95 | settings["runtime"] = None 96 | settings["size"] = "100%" 97 | 98 | if settings["type"] != "device": 99 | print() 100 | print( 101 | "Preconditioning only makes sense for (flash) devices, not files or directories." 102 | ) 103 | print() 104 | sys.exit(9) 105 | 106 | if settings["type"] not in ["device", "rbd"] and not settings["size"]: 107 | print() 108 | print("When the target is a file or directory, --size must be specified.") 109 | print() 110 | sys.exit(4) 111 | 112 | if ( 113 | settings["type"] == "directory" 114 | and not settings["remote"] 115 | and not settings["create"] 116 | ): 117 | for item in settings["target"]: 118 | if not os.path.exists(item): 119 | print(f"\nThe target directory ({item}) doesn't seem to exist.\n") 120 | sys.exit(5) 121 | 122 | if settings["type"] == "rbd": 123 | if not settings["ceph_pool"]: 124 | print( 125 | "\nCeph pool (--ceph-pool) must be specified when target type is rbd.\n" 126 | ) 127 | sys.exit(6) 128 | 129 | if settings["type"] == "rbd" and settings["ceph_pool"]: 130 | if not settings["engine"] == "rbd": 131 | print( 132 | f"\nPlease specify engine 'rbd' when benchmarking Ceph, not {settings['engine']}\n" 133 | ) 134 | sys.exit(7) 135 | 136 | if settings["type"] == "device" and settings["size"] and settings["runtime"] == 60: 137 | print( 138 | "Warning: You've specified the --size parameter with a device target\n\ 139 | --> you may want to set --runtime either to 0 or specify a desired runtime \n" 140 | ) 141 | 142 | if not settings["output"]: 143 | print() 144 | print( 145 | "Must specify mandatory --output parameter (name of benchmark output folder)" 146 | ) 147 | print() 148 | sys.exit(9) 149 | 150 | mixed_count = 0 151 | for mode in settings["mode"]: 152 | writemodes = ["write", "randwrite", "rw", "readwrite", "trimwrite"] 153 | if mode in writemodes and not settings["destructive"]: 154 | print( 155 | f"\n Mode {mode} will overwrite data on {settings['target']} but destructive flag not set.\n" 156 | ) 157 | sys.exit(1) 158 | if mode in settings["mixed"]: 159 | mixed_count += 1 160 | if not settings["rwmixread"]: 161 | print( 162 | "\nIf a mixed (read/write) mode is specified, please specify --rwmixread\n" 163 | ) 164 | sys.exit(8) 165 | if mixed_count > 0: 166 | settings["loop_items"].append("rwmixread") 167 | 168 | if settings["remote"]: 169 | hostlist = os.path.expanduser(settings["remote"]) 170 | settings["remote"] = hostlist 171 | 172 | if not os.path.exists(hostlist): 173 | print(f"The list of remote hosts ({hostlist}) doesn't seem to exist.\n") 174 | sys.exit(5) 175 | 176 | if settings["precondition_template"]: 177 | if not os.path.exists(settings["precondition_template"]): 178 | print( 179 | f"Precondition template ({settings['precondition_template']}) doesn't seem to exist.\n" 180 | ) 181 | sys.exit(5) 182 | 183 | if not settings["precondition"]: 184 | settings["filter_items"].append("precondition_template") 185 | 186 | if settings["loops"] == 0: 187 | print( 188 | "setting loops to 0 is likely not what you want as no benchmarks would be run\n" 189 | ) 190 | print( 191 | "If you want to change the precondition loop count, edit precondition.fio or supply your own config\n" 192 | ) 193 | print("with the parameter --precondition-template") 194 | sys.exit(6) 195 | 196 | if settings["loops"] == 0: 197 | print( 198 | "setting loops to 0 is likely not what you want as no benchmarks would be run\n" 199 | ) 200 | print( 201 | "If you want to change the precondition loop count, edit precondition.fio or supply your own config\n" 202 | ) 203 | print("with the parameter --precondition-template") 204 | sys.exit(6) 205 | -------------------------------------------------------------------------------- /bench_fio/benchlib/defaultsettings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | 5 | def get_default_settings(): 6 | path = os.path.abspath(__file__) 7 | dir_path = os.path.dirname(path) 8 | settings = {} 9 | settings["benchmarks"] = None 10 | settings["target"] = [] 11 | settings["type"] = None 12 | settings["engine"] = "libaio" 13 | settings["mode"] = ["randread"] 14 | settings["size"] = None 15 | settings["block_size"] = ["4k"] 16 | settings["iodepth"] = [1, 2, 4, 8, 16, 32, 64] 17 | settings["numjobs"] = [1, 2, 4, 8, 16, 32, 64] 18 | settings["rwmixread"] = None 19 | settings["runtime"] = 60 20 | settings["loops"] = 1 21 | settings["time_based"] = False 22 | settings["direct"] = 1 23 | settings["dry_run"] = False 24 | settings["precondition"] = False 25 | settings["quiet"] = False 26 | settings["output"] = False 27 | settings["precondition_template"] = os.path.join( 28 | dir_path, "..", "templates", "precondition.fio" 29 | ) 30 | settings["precondition_repeat"] = False 31 | settings["entire_device"] = False 32 | settings["ss"] = False 33 | settings["ss_dur"] = None 34 | settings["ss_ramp"] = None 35 | settings["extra_opts"] = [] 36 | settings["loginterval"] = 1000 37 | settings["mixed"] = ["readwrite", "rw", "randrw"] 38 | settings["invalidate"] = 1 39 | settings["ceph_pool"] = None 40 | settings["destructive"] = False 41 | settings["remote"] = False 42 | settings["remote_checks"] = False 43 | settings["remote_timeout"] = 2 44 | settings["create"] = False 45 | settings["parallel"] = False 46 | settings["loop_items"] = [ 47 | "target", 48 | "mode", 49 | "iodepth", 50 | "numjobs", 51 | "block_size", 52 | ] 53 | settings["filter_items"] = [ 54 | "filter_items", 55 | "loop_items", 56 | "dry_run", 57 | "mixed", 58 | "quiet", 59 | "tmpjobfile", 60 | "exclude_list", 61 | "basename_list", 62 | ] 63 | ### The exclude list is used when generating temporary fio templates. 64 | settings["exclude_list"] = [ 65 | "exclude_list", 66 | "loop_items", 67 | "filter_items", 68 | "precondition_template", 69 | "target", 70 | "output", 71 | "remote", 72 | "remote_checks", 73 | "remote_timeout", 74 | "tmpjobfile", 75 | "type", 76 | "benchmarks", 77 | "entire_device", 78 | "basename_list", 79 | "destructive", 80 | "precondition", 81 | "template", 82 | "create", 83 | "parallel", 84 | "quiet", 85 | ] 86 | settings["basename_list"] = ["precondition_template"] 87 | return settings 88 | 89 | 90 | def map_settings_to_fio(): 91 | """ 92 | At some point we should bite the bullet and make the breaking change 93 | and rename all bench-fio settings to the fio-equivalent. Until then 94 | we use this conversion table. 95 | """ 96 | mapping = { 97 | "mode": "rw", 98 | "engine": "ioengine", 99 | "block_size": "bs", 100 | "ss": "steadystate", 101 | "ss_dur": "steadystate_duration", 102 | "ss_ramp": "steadystate_ramp_time", 103 | "loginterval": "log_avg_msec", 104 | "ceph_pool": "pool", 105 | } 106 | return mapping 107 | -------------------------------------------------------------------------------- /bench_fio/benchlib/display.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import datetime 3 | import os 4 | from rich.style import Style 5 | from rich.table import Table 6 | from rich.console import Console 7 | from rich.text import Text 8 | from . import argparsing as argp 9 | 10 | def parse_settings_for_display(settings): 11 | """ 12 | We are focussing here on making the length of the top/down bars match the length of the data rows. 13 | """ 14 | data = {} 15 | max_length = 0 16 | action = {list: lambda a: " ".join(map(str, a)), str: str, int: str, bool: str} 17 | for k, v in settings.items(): 18 | if v: 19 | if k not in settings["filter_items"]: 20 | if k in settings["basename_list"]: 21 | data[str(k)] = os.path.basename(v) 22 | else: 23 | data[str(k)] = action[type(v)](v) 24 | length = len(data[k]) 25 | if length > max_length: 26 | max_length = length 27 | data["length"] = max_length 28 | return data 29 | 30 | def calculate_duration(settings, tests): 31 | number_of_tests = len(tests) * settings["loops"] 32 | if settings["parallel"]: 33 | number_of_tests = number_of_tests/len(settings["target"]) 34 | time_per_test = settings["runtime"] 35 | if time_per_test: 36 | duration_in_seconds = number_of_tests * time_per_test 37 | duration = str(datetime.timedelta(seconds=duration_in_seconds)) 38 | else: 39 | duration = None 40 | return duration 41 | 42 | def print_dryrun(settings, table): 43 | 44 | if settings["dry_run"]: 45 | table.add_row("Dry Run","True", style="bold green") 46 | 47 | def get_duration(settings, tests): 48 | duration = calculate_duration(settings, tests) 49 | returnvalue = "Unable to estimate (not an error)" 50 | if duration: 51 | returnvalue = duration 52 | return returnvalue 53 | 54 | def print_options(settings, table): 55 | descriptions = argp.get_argument_description() 56 | data = parse_settings_for_display(settings) 57 | for item in settings.keys(): 58 | if item not in settings["filter_items"]: # filter items are internal options that aren't relevant 59 | if item not in descriptions.keys(): 60 | customitem = item + "*" # These are custom fio options so we mark them as such 61 | #print(f"{customitem:<{fl}}: {data[item]:<}") 62 | table.add_row(customitem, data[item]) 63 | else: 64 | description = descriptions[item] 65 | if item in data.keys(): 66 | table.add_row(description, data[item]) 67 | else: 68 | if settings[item]: 69 | table.add_row(description, data[item]) 70 | 71 | 72 | def display_header(settings, tests): 73 | 74 | duration = calculate_duration(settings, tests) 75 | table = Table(title="Bench-fio",title_style=Style(bgcolor="dodger_blue2",bold=True)) 76 | table.add_column(no_wrap=True, header="Setting") 77 | table.add_column(no_wrap=True,justify="left", header="value") 78 | print_dryrun(settings, table) 79 | table.add_row("Estimated Duration",duration) 80 | print_options(settings, table) 81 | console = Console() 82 | console.print(table) -------------------------------------------------------------------------------- /bench_fio/benchlib/generatefio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import configparser 3 | from . import defaultsettings, checks 4 | 5 | 6 | def write_fio_job_file(tmpjobfile, parser): 7 | try: 8 | with open(tmpjobfile, "w") as configfile: 9 | parser.write(configfile, space_around_delimiters=False) 10 | except IOError: 11 | print(f"Failed to write temporary Fio job file at {tmpjobfile}") 12 | 13 | 14 | def filter_options(settings, config, mapping, benchmark, output_directory): 15 | boolean = {"True": 1, "False": 0} 16 | config["FIOJOB"] = {} 17 | for k, v in settings.items(): 18 | key = k 19 | value = v 20 | if ( 21 | key in settings["loop_items"] 22 | ): # This is looping throught the benchmark parameters 23 | value = benchmark[k] 24 | if ( 25 | key in mapping.keys() 26 | ): # This is about translating bench-fio parameters to fio parameters 27 | key = mapping[key] 28 | if isinstance(value, bool): 29 | value = boolean[str(value)] 30 | if key == "type": # we check if we target a file directory or block device 31 | devicetype = checks.check_target_type(benchmark["target_base"], settings) 32 | config["FIOJOB"][devicetype] = benchmark["target"] 33 | if ( 34 | value 35 | and not isinstance(value, list) 36 | and key not in settings["exclude_list"] 37 | ): 38 | config["FIOJOB"][key] = str(value).replace( 39 | "%", "%%" 40 | ) # just add all key values, % character needs replacing 41 | if settings["extra_opts"]: 42 | for item in settings["extra_opts"]: 43 | key, value = item.split("=") 44 | config["FIOJOB"][key] = str(value) 45 | # print(f"key: {key} - Value: {value} - {type(value)}") 46 | 47 | config["FIOJOB"][ 48 | "write_bw_log" 49 | ] = f"{output_directory}/{benchmark['mode']}-iodepth-{benchmark['iodepth']}-numjobs-{benchmark['numjobs']}" 50 | config["FIOJOB"][ 51 | "write_lat_log" 52 | ] = f"{output_directory}/{benchmark['mode']}-iodepth-{benchmark['iodepth']}-numjobs-{benchmark['numjobs']}" 53 | config["FIOJOB"][ 54 | "write_iops_log" 55 | ] = f"{output_directory}/{benchmark['mode']}-iodepth-{benchmark['iodepth']}-numjobs-{benchmark['numjobs']}" 56 | return config 57 | 58 | 59 | def generate_fio_job_file(settings, benchmark, output_directory, tmpjobfile): 60 | config = configparser.ConfigParser() 61 | mapping = defaultsettings.map_settings_to_fio() 62 | config = filter_options(settings, config, mapping, benchmark, output_directory) 63 | write_fio_job_file(tmpjobfile, config) 64 | -------------------------------------------------------------------------------- /bench_fio/benchlib/network.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import socket 3 | import sys 4 | def remote_checks(settings): 5 | if settings["remote"] and settings["remote_checks"]: 6 | print(f"\nWe are checking remote hosts for active fio server at TCP port 8765...") 7 | print(f"\n") 8 | failed_list = [] 9 | with open(settings["remote"], 'r') as file: 10 | hosts = file.read().splitlines() 11 | for host in hosts: 12 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 13 | sock.settimeout(int(settings["remote_timeout"])) #Second Timeout 14 | testresult = sock.connect_ex((host,8765)) 15 | sock.close() 16 | if testresult != 0: 17 | failed_list.append(host) 18 | if failed_list: 19 | for host in failed_list: 20 | print(f"Host {host} is unreachable on TCP port 8765") 21 | print(f"\n") 22 | sys.exit(1) -------------------------------------------------------------------------------- /bench_fio/benchlib/parseini.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import configparser 5 | 6 | def process_options(config): 7 | """ 8 | This function translates 'untyped' options from the ini file into properly typed options. 9 | """ 10 | listtypes = ['target','mode','block_size', 'iodepth', 'numjobs','extra_opts', 'rwmixread'] 11 | booltypes = ['precondition','precondition_repeat','entire_device','time_based','destructive','dry_run','quiet',"remote_checks"] 12 | inttypes = ['loops','runtime'] 13 | returndict = {} 14 | for x in config["benchfio"]: 15 | if x == "output": 16 | # Argparse seems to auto-expand paths, if we import through INI we do it ourselves 17 | returndict[x] = os.path.expanduser(config["benchfio"][x]) 18 | elif x in listtypes: 19 | returndict[x] = config.getlist('benchfio', x) 20 | elif x in booltypes: 21 | returndict[x] = config.getboolean('benchfio', x) 22 | elif x in inttypes: 23 | returndict[x] = config.getint('benchfio', x) 24 | else: 25 | returndict[x] = config["benchfio"][x] 26 | return returndict 27 | 28 | def read_ini_data(args, config): 29 | if len(args) != 2: 30 | return False 31 | if args[1] == "-h" or args[1] == "--help": 32 | return False 33 | else: 34 | filename = args[1] 35 | 36 | if not os.path.isfile(filename): 37 | print(f"Config file {filename} not found.") 38 | sys.exit(1) 39 | 40 | try: 41 | config.read(filename) 42 | except configparser.DuplicateOptionError as e: 43 | print(f"{e}\n") 44 | sys.exit(1) 45 | 46 | return True 47 | 48 | 49 | def get_settings_from_ini(args): 50 | config = configparser.ConfigParser(converters={'list': lambda x: [i.strip() for i in x.split(',')]}) 51 | if read_ini_data(args, config): 52 | returndict = process_options(config) 53 | return returndict 54 | else: 55 | return None -------------------------------------------------------------------------------- /bench_fio/benchlib/runfio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | import os 5 | import copy 6 | from numpy import linspace 7 | import time 8 | from operator import itemgetter 9 | from itertools import groupby 10 | from threading import Thread 11 | from rich.progress import Progress 12 | 13 | from . import supporting, generatefio, defaultsettings 14 | 15 | 16 | def drop_caches(): 17 | command = ["echo", "3", ">", "/proc/sys/vm/drop_caches"] 18 | run_raw_command(command) 19 | 20 | 21 | def handle_error(outputfile): 22 | if outputfile: 23 | if os.path.exists(outputfile): 24 | with open(f"{outputfile}", "r") as input: 25 | data = input.read().splitlines() 26 | for line in data: 27 | print(line) 28 | 29 | 30 | def run_raw_command(command, outputfile=None): 31 | try: 32 | result = subprocess.run( 33 | command, shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE 34 | ) 35 | if result.returncode > 0 or (len(str(result.stderr)) > 3): 36 | stdout = result.stdout.decode("UTF-8").strip() 37 | stderr = result.stderr.decode("UTF-8").strip() 38 | print( 39 | f"\nAn error occurred: stderr: {stderr} - stdout: {stdout} - returncode: {result.returncode} \n" 40 | ) 41 | handle_error( 42 | outputfile 43 | ) # it seems that the JSON output file contains STDERR/STDOUT error data 44 | sys.exit(result.returncode) 45 | except KeyboardInterrupt: 46 | print(f"\n ctrl-c pressed - Aborted by user....\n") 47 | sys.exit(1) 48 | return result 49 | 50 | 51 | def run_fio(settings, benchmark): 52 | # The target may contains a colon (:) in the path and it's escaped 53 | # with a backslash (\) as per the fio manual. The backslash must be 54 | # passed to fio's filename but should be removed when checking the 55 | # existance of the path, or when writing a job file or log file in 56 | # the filesystem. 57 | benchmark.update({"target_base": benchmark['target'].replace("\\", "")}) 58 | tmpjobfile = f"/tmp/{os.path.basename(benchmark['target_base'])}-tmpjobfile.fio" 59 | output_directory = supporting.generate_output_directory(settings, benchmark) 60 | output_file = f"{output_directory}/{benchmark['mode']}-{benchmark['iodepth']}-{benchmark['numjobs']}.json" 61 | generatefio.generate_fio_job_file(settings, benchmark, output_directory, tmpjobfile) 62 | 63 | ### We build up the fio command line here 64 | command = ["fio"] 65 | 66 | command.append("--output-format=json") 67 | command.append(f"--output={output_file}") # fio bug 68 | 69 | if settings["remote"]: 70 | command.append(f"--client={settings['remote']}") 71 | 72 | command.append(tmpjobfile) 73 | # End of command line creation 74 | 75 | if not settings["dry_run"]: 76 | supporting.make_directory(output_directory) 77 | run_raw_command(command, output_file) 78 | if settings["remote"]: 79 | fix_json_file(output_file) # to fix FIO json output bug 80 | 81 | 82 | def fix_json_file(outputfile): 83 | """Fix FIO BUG 84 | See #731 on github 85 | Purely for client server support, proposed solutions don't work 86 | """ 87 | with open(f"{outputfile}", "r") as input: 88 | data = input.readlines() 89 | 90 | with open(f"{outputfile}", "w") as output: 91 | for line in data: 92 | if not line.startswith("<"): 93 | output.write(line) 94 | 95 | 96 | def run_precondition_benchmark(settings, device, run): 97 | if settings["precondition"] and settings["destructive"]: 98 | if not settings["precondition_repeat"] and run > 1: 99 | pass # only run once if precondition_repeat is not set 100 | else: 101 | settings_copy = copy.deepcopy(settings) 102 | settings_copy["template"] = settings["precondition_template"] 103 | settings_copy["runtime"] = None # want to test entire device 104 | settings_copy["time_based"] = False 105 | template = supporting.import_fio_template(settings["precondition_template"]) 106 | benchmark = { 107 | "target": device, 108 | "mode": template["precondition"]["rw"], 109 | "iodepth": template["precondition"]["iodepth"], 110 | "block_size": template["precondition"]["bs"], 111 | "numjobs": template["precondition"]["numjobs"], 112 | "run": run, 113 | } 114 | mapping = defaultsettings.map_settings_to_fio() 115 | for key, value in dict(template["precondition"]).items(): 116 | for x, y in mapping.items(): 117 | if str(key) == str(y): 118 | settings_copy[mapping[x]] = value 119 | else: 120 | settings_copy[key] = value 121 | # print(settings_copy) 122 | run_fio(settings_copy, benchmark) 123 | 124 | elif settings["precondition"] and not settings["destructive"]: 125 | print( 126 | f"\n When running preconditionning, also enable the destructive flag to be 100% sure.\n" 127 | ) 128 | sys.exit(1) 129 | 130 | 131 | def worker(benchmarks, settings, progress): 132 | run = 0 133 | advance = len(benchmarks) 134 | advance += settings["loops"] - 1 135 | if not settings["quiet"]: 136 | task = progress.add_task(description=benchmarks[0]["target"], total=advance) 137 | for benchmark in benchmarks: 138 | loops = 0 139 | while loops < settings["loops"]: 140 | run += 1 141 | loops += 1 142 | run_precondition_benchmark(settings, benchmark["target"], run) 143 | drop_caches() 144 | run_fio(settings, benchmark) 145 | if not settings["quiet"]: 146 | progress.update(task, advance=1) 147 | 148 | 149 | def run_benchmarks(settings, benchmarks): 150 | with Progress() as progress: 151 | if not settings["parallel"]: 152 | worker(benchmarks, settings, progress) 153 | else: 154 | group_benchmarks = [] 155 | for _, items in groupby(benchmarks, key=itemgetter("target")): 156 | group_benchmarks.append(list(items)) 157 | thread_list = [] 158 | for target in range(len(group_benchmarks)): 159 | t = Thread( 160 | target=worker, args=(group_benchmarks[target], settings, progress) 161 | ) 162 | thread_list.append(t) 163 | 164 | for t in thread_list: 165 | t.setDaemon(True) 166 | t.start() 167 | 168 | for t in thread_list: 169 | t.join() 170 | -------------------------------------------------------------------------------- /bench_fio/benchlib/supporting.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import itertools 5 | import configparser 6 | 7 | 8 | def generate_test_list(settings): 9 | """All options that need to be tested are multiplied together. 10 | This creates a full list of all possible benchmark permutations that 11 | need to be run. 12 | """ 13 | 14 | loop_items = settings["loop_items"] 15 | dataset = [] 16 | 17 | for item in loop_items: 18 | result = settings[item] 19 | dataset.append(result) 20 | 21 | benchmark_list = list(itertools.product(*dataset)) 22 | result = [dict(zip(loop_items, item)) for item in benchmark_list] 23 | settings["benchmarks"] = len(result) # Augment display with extra sanity check 24 | return result 25 | 26 | 27 | def convert_dict_vals_to_str(dictionary): 28 | """Convert dictionary to format in uppercase, suitable as env vars.""" 29 | return {k.upper(): str(v) for k, v in dictionary.items()} 30 | 31 | 32 | def make_directory(directory): 33 | try: 34 | if not os.path.exists(directory): 35 | os.makedirs(directory) 36 | except OSError: 37 | print(f"Failed to create {directory}") 38 | sys.exit(1) 39 | 40 | 41 | def generate_output_directory(settings, benchmark): 42 | settings["output"] = os.path.expanduser(settings["output"]) 43 | if benchmark["mode"] in settings["mixed"]: 44 | directory = ( 45 | f"{settings['output']}/{os.path.basename(benchmark['target_base'])}/" 46 | f"{benchmark['mode']}{benchmark['rwmixread']}/{benchmark['block_size']}" 47 | ) 48 | else: 49 | directory = f"{settings['output']}/{os.path.basename(benchmark['target_base'])}/{benchmark['block_size']}" 50 | 51 | if "run" in benchmark.keys(): 52 | directory = directory + f"/run-{benchmark['run']}" 53 | 54 | return directory 55 | 56 | def import_fio_template(template): 57 | config = configparser.ConfigParser() 58 | config.read(template) 59 | return config 60 | -------------------------------------------------------------------------------- /bench_fio/scripts/bench-fio.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | echo 6 | echo "This script has been replaced by bench-fio.py" 7 | echo "Please consider using this script. It's in the same folder." 8 | echo 9 | 10 | if [ $# -ne 5 ] 11 | then 12 | echo "Usage: $0 " 13 | exit 1 14 | fi 15 | 16 | if [ -z ${FIO+x} ] 17 | then 18 | FIO=fio 19 | fi 20 | 21 | 22 | export BLOCKSIZE=4k 23 | export RUNTIME=60 24 | export JOBFILE=$1 25 | export OUTPUT=$2 26 | export DIRECTORY=$3 27 | export FILE=$4 28 | export SIZE=$5 29 | 30 | if [ ! $($FIO --version | grep -i fio-3) ] 31 | then 32 | echo "Fio version 3+ required because fio-plot expects nanosecond precision" 33 | exit 1 34 | fi 35 | 36 | if [ ! -e $JOBFILE ] 37 | then 38 | echo "Fio job file $JOBFILE not found." 39 | exit 1 40 | fi 41 | 42 | if [ ! -e $OUTPUT ] 43 | then 44 | echo "Directory for output $OUTPUT not found." 45 | exit 1 46 | fi 47 | 48 | for RW in randread randwrite 49 | do 50 | for IODEPTH in 1 2 4 8 16 32 51 | do 52 | for NUMJOBS in 1 2 4 8 16 32 53 | do 54 | sync 55 | echo 3 > /proc/sys/vm/drop_caches 56 | echo "=== $DIRECTORY ============================================" 57 | echo "Running benchmark $RW with I/O depth of $IODEPTH and numjobs $NUMJOBS" 58 | export RW 59 | export IODEPTH 60 | export NUMJOBS 61 | $FIO $JOBFILE --output-format=json --output=$OUTPUT/$RW-$IODEPTH-$NUMJOBS.json 62 | done 63 | done 64 | done 65 | -------------------------------------------------------------------------------- /bench_fio/scripts/generate_call_graph.sh: -------------------------------------------------------------------------------- 1 | # Requires pyan3 to be installled (pip3 install pyan3) 2 | # The call graph of bench_fio shows that - at this time - the structure needs improvement 3 | find ../ -not -path "./bench_fio_test.py" -iname "*.py" | xargs pyan3 --dot --colored --no-defines --grouped | dot -Tpng -Granksep=1.5 > call_graph.png 4 | -------------------------------------------------------------------------------- /bench_fio/templates/benchmark.ini: -------------------------------------------------------------------------------- 1 | [benchfio] 2 | target = /dev/example 3 | output = benchmark 4 | type = device 5 | mode = randread,randwrite 6 | size = 10G 7 | iodepth = 1,2,4,8,16,32,64 8 | numjobs = 1,2,4,8,16,32,64 9 | direct = 1 10 | engine = libaio 11 | precondition = False 12 | precondition_repeat = False 13 | runtime = 60 14 | destructive = False 15 | -------------------------------------------------------------------------------- /bench_fio/templates/precondition.fio: -------------------------------------------------------------------------------- 1 | [precondition] 2 | name=precondition 3 | ioengine=libaio 4 | direct=1 5 | bs=1m 6 | rw=write 7 | iodepth=64 8 | numjobs=1 9 | buffered=0 10 | size=100%% 11 | loops=2 12 | randrepeat=0 13 | norandommap=1 14 | refill_buffers=1 -------------------------------------------------------------------------------- /bin/bench-fio: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import bench_fio 4 | 5 | if __name__ == '__main__': 6 | bench_fio.main() 7 | -------------------------------------------------------------------------------- /bin/fio-plot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import fio_plot 4 | 5 | if __name__ == '__main__': 6 | fio_plot.main() 7 | -------------------------------------------------------------------------------- /docs/bench_fio_call_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louwrentius/fio-plot/94842873f25c8eefc8b2ca2feb4d1f25b07c8473/docs/bench_fio_call_graph.png -------------------------------------------------------------------------------- /docs/fio_plot_call_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louwrentius/fio-plot/94842873f25c8eefc8b2ca2feb4d1f25b07c8473/docs/fio_plot_call_graph.png -------------------------------------------------------------------------------- /docs/generate_call_graph.sh: -------------------------------------------------------------------------------- 1 | find ../fio_plot -iname "*.py" | xargs pyan3 --dot --colored --no-defines --grouped | dot -Tpng -Granksep=1.8 > fio_plot_call_graph.png 2 | find ../bench_fio -iname "*.py" | xargs pyan3 --dot --colored --no-defines --grouped | dot -Tpng -Granksep=1.8 > bench_fio_call_graph.png 3 | -------------------------------------------------------------------------------- /fio_plot/DATAIMPORT.md: -------------------------------------------------------------------------------- 1 | ## Purpose of this document 2 | 3 | To help me remember how fio-plot works when it comes to data ingestion. 4 | 5 | ## Data sources 6 | 7 | fio-plot can parse two types of output: JSON and the .log trace data. 8 | The .log trace data is just CSV data which can only be graphed using the -g style graph. 9 | All the other graph type need one or more JSON files to work with. 10 | 11 | Data collection starts with the getdata.py file containing the get_json_data and get_log_data functions to gather one of these types of files. 12 | 13 | When only the log files are charting using the -g graph, fio-plot cheats and tries to read the relevant existing JSON file if it exists, to gather the fio version number. 14 | 15 | ## File name heuristics 16 | 17 | fio-plot expects files to have a certain naming convention, or they won't be found and imported. 18 | This is critical for the .log trace data as there is no identifying information within those files. 19 | 20 | JSON files also require this naming convention, although it should not be required as they can be parsed and then selected/filtered based on their contents. If this is a big deal is another discussion, for now this is how it works. 21 | 22 | ## CSV log data import 23 | 24 | CVS log data import is quite complicated because benchmarks using a numjob value higher than 1 will generate a separate log file for each job. Depending on the metric, like bandwidth or IOPs, data across all files need to be summed to obtain the correct values. 25 | 26 | The dataimport.py file contains all this logic. In particular, the "mergedataset" and "mergesingledataset" functions handle all this parsing. 27 | 28 | ## JSON data import workflow description 29 | 30 | 1. A list of files is found in the source directory 31 | 1. Files that match the rw iodepth and numjobs parameters will be kept 32 | 1. A dataset dictionary initially contains a list of files: 33 | 34 | ``` 35 | {'directory': '/Users/nan03/data/WD10KRPM/RAID10_64K', 36 | 'files': ['/Users/nan03/data/WD10KRPM/RAID10_64K/randread-1-1.json', 37 | '/Users/nan03/data/WD10KRPM/RAID10_64K/randread-16-1.json', 38 | '/Users/nan03/data/WD10KRPM/RAID10_64K/randread-2-1.json', 39 | '/Users/nan03/data/WD10KRPM/RAID10_64K/randread-32-1.json', 40 | '/Users/nan03/data/WD10KRPM/RAID10_64K/randread-4-1.json', 41 | '/Users/nan03/data/WD10KRPM/RAID10_64K/randread-64-1.json', 42 | '/Users/nan03/data/WD10KRPM/RAID10_64K/randread-8-1.json'], 43 | 'rawdata': []} 44 | ``` 45 | 4. Each file is json-imported, validated and then the 'rawdata' list contains a JSON-imported dictionary for each JSON file. The order of the files matches the order of the JSON dictionaries in the rawdata list. 46 | 47 | We have now collected the raw data, but to use it in graphs, we need to further parse and process it into a different format. 48 | 49 | 5. The jsonparsing.py module is responsible for pulling all the relevant data from the imported JSON data and put it in a flat dictionary. 50 | 51 | To support the client/server mechanism of Fio, we need to be able to process JSON files containing multiple jobs. This support has only been build-in specifically for client/server JSON data. 52 | Such data is recognisable as there is no "jobs" key in the JSON data but only "client_stats" which contains individual jobs for each host (server) running FIO. If a benchmark is run with numjobs = 4 (for example) 53 | this means that there will be 4 jobs in the JSON data with benchmark results for a particular host. That data is summed together to provide a total for a particular host. 54 | 55 | The client/server data also contains a special job called "All clients" that sums the benchmark results across all hosts. We get this for free from the JSON so we don't need to calculate this. 56 | This "All clients" data can be filtered out with the --exclude-host or --include-host parameters of fio-plot. 57 | 58 | Be aware that fio-plot does not support "regular" non-client-server jobs containing more than one job-record. 59 | 60 | 6. The build_json_mapping function is fed with the dataset created in step 5 by the jsonparsing code. This is just a list of dictionaries. Each dictionary has a "rawdata" key containing the actual JSON data. The build_json_mapping adds a "data" key to each dictionary in the provided dataset, and the value contains a newly created dictionary of all the data of interest. That dictionary is just a flat dictionary, no nested structure like the original raw JSON. To create this flat dictionary, we use a semi-hard-coded mapping between the values and the "path" in the JSON structure. 61 | This mapping is provided by the get_json_mapping function. 62 | 63 | The data is now ready to be used by the graphing functions, althoug it does require further parsing and formatting. 64 | 65 | ## Formatting the data for mathplotlib 66 | 67 | At this point, the relevant data is stored in individual dictionaries and it is not in a format suitable for matplotlib graphing yet. Imagine we want to create bar chart with iodepth 1,2,4,8,16,32,64 on the x-axis and the iops on the y-axis. 68 | mathplotlib needs a value for each of those iodepths in a list, but this data is inside individual dictionaries, originating from the JSON output. 69 | 70 | The get_record_set and get_record_set_improved functions loop over all iodepths, and then for each iodepth loop over all records to see if they contain relevant data. If so, this data is added to the appropriate list within the 'datadict' dictionary. That dictionary contains the actual data in a format we can almost chart with matplotlib. Almost, because the relevant data must be analised to make sure we apply proper scaling of the graph, which is done by the scale_data function. 71 | 72 | -------------------------------------------------------------------------------- /fio_plot/README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | usage: fio-plot [-h] -i INPUT_DIRECTORY [INPUT_DIRECTORY ...] [-o OUTPUT_FILENAME] -T TITLE [-s SOURCE] (-L | -l | -N | -H | -g | -C) [--disable-grid] [--enable-markers] [--subtitle SUBTITLE] [-d IODEPTH [IODEPTH ...]] [-n NUMJOBS [NUMJOBS ...]] [-M [MAXDEPTH]] [-J [MAXJOBS]] 3 | [-D [DPI]] [-p [PERCENTILE]] -r {read,write,randread,randwrite,randrw,trim,rw,readwrite,randtrim,trimwrite} [-m MAX_Z] [-e MOVING_AVERAGE] [--min-iops MIN_IOPS] [--min-lat MIN_LAT] [-t {bw,iops,lat,slat,clat} [{bw,iops,lat,slat,clat} ...]] 4 | [-f {read,write} [{read,write} ...]] [--truncate-xaxis TRUNCATE_XAXIS] [--xlabel-depth XLABEL_DEPTH] [--xlabel-parent XLABEL_PARENT] [--xlabel-segment-size XLABEL_SEGMENT_SIZE] [--xlabel-single-column] [-w LINE_WIDTH] [--group-bars] [--show-cpu] [--show-data] 5 | [--show-ss] [--table-lines] [--max-lat MAX_LAT] [--max-clat MAX_CLAT] [--max-slat MAX_SLAT] [--max-iops MAX_IOPS] [--max-bw MAX_BW] [--draw-total] [--colors COLORS [COLORS ...]] [--disable-fio-version] [--title-fontsize TITLE_FONTSIZE] 6 | [--subtitle-fontsize SUBTITLE_FONTSIZE] [--source-fontsize SOURCE_FONTSIZE] [--credit-fontsize CREDIT_FONTSIZE] [--table-fontsize TABLE_FONTSIZE] [--include-hosts INCLUDE_HOSTS [INCLUDE_HOSTS ...] | --exclude-hosts EXCLUDE_HOSTS [EXCLUDE_HOSTS ...]] 7 | 8 | Generates charts/graphs from FIO JSON output or logdata. 9 | 10 | options: 11 | -h, --help show this help message and exit 12 | --include-hosts INCLUDE_HOSTS [INCLUDE_HOSTS ...] 13 | Only create graphs for these hosts (when parsing client-server benchmark data) 14 | --exclude-hosts EXCLUDE_HOSTS [EXCLUDE_HOSTS ...] 15 | Graph all hosts except for those listed (when parsing client-server benchmark data) 16 | 17 | Generic Settings: 18 | -i INPUT_DIRECTORY [INPUT_DIRECTORY ...], --input-directory INPUT_DIRECTORY [INPUT_DIRECTORY ...] 19 | input directory where JSON files or log data (CSV) can be found. 20 | -o OUTPUT_FILENAME, --output-filename OUTPUT_FILENAME 21 | Specify output graph filename instead of the generated default. Note that the file type is always png. 22 | -T TITLE, --title TITLE 23 | specifies title to use in charts 24 | -s SOURCE, --source SOURCE 25 | Author 26 | -L, --bargraph3d Generates a 3D-chart with iodepth and numjobs on x/y axis and iops or latency on the z-axis. 27 | -l, --bargraph2d-qd Generates a 2D barchart of IOPs and latency for all queue depths given a particular numjobs value. 28 | -N, --bargraph2d_nj This graph type is like the latency-iops-2d-qd barchart but instead of plotting queue depths for a particular numjobs value, it plots numjobs values for a particular queue depth. 29 | -H, --histogram Generates a latency histogram for a particular queue depth and numjobs value. 30 | -g, --loggraph This option generates a 2D graph of the log data recorded by FIO. 31 | -C, --compare-graph This option generates a bar chart to compare results from different benchmark runs. 32 | --disable-grid Disables the dotted grid in the output graph. 33 | --enable-markers Enable markers for the plot lines when graphing log data. 34 | --subtitle SUBTITLE Specify your own subtitle or leave it blank with double quotes. 35 | -d IODEPTH [IODEPTH ...], --iodepth IODEPTH [IODEPTH ...] 36 | The I/O queue depth to graph. You can specify multiple values separated by spaces. 37 | -n NUMJOBS [NUMJOBS ...], --numjobs NUMJOBS [NUMJOBS ...] 38 | Specifies for which numjob parameter you want the 2d graphs to be generated. You can specify multiple values separated by spaces. 39 | -M [MAXDEPTH], --maxdepth [MAXDEPTH] 40 | Maximum queue depth to graph in 3D graph. 41 | -J [MAXJOBS], --maxjobs [MAXJOBS] 42 | Maximum number of jobs to graph in 3D graph. 43 | -D [DPI], --dpi [DPI] 44 | The chart will be saved with this DPI setting. Higher means larger image. 45 | -p [PERCENTILE], --percentile [PERCENTILE] 46 | Calculate the percentile, default 99.99th. 47 | -r {read,write,randread,randwrite,randrw,trim,rw,readwrite,randtrim,trimwrite}, --rw {read,write,randread,randwrite,randrw,trim,rw,readwrite,randtrim,trimwrite} 48 | Specifies the kind of data you want to graph. 49 | -m MAX_Z, --max-z MAX_Z 50 | Optional maximum value for Z-axis in 3D graph. 51 | -e MOVING_AVERAGE, --moving-average MOVING_AVERAGE 52 | The moving average helps to smooth out graphs, the argument is the size of the moving window (default is None to disable). Be carefull as this setting may smooth out issues you may want to be aware of. 53 | --min-iops MIN_IOPS Optional minimal value for iops axis, default is 0 54 | --min-lat MIN_LAT Optional minimal value for lat axis, default is 0 55 | -t {bw,iops,lat,slat,clat} [{bw,iops,lat,slat,clat} ...], --type {bw,iops,lat,slat,clat} [{bw,iops,lat,slat,clat} ...] 56 | This setting specifies which kind of metric you want to graph. 57 | -f {read,write} [{read,write} ...], --filter {read,write} [{read,write} ...] 58 | filter should be read/write. 59 | --truncate-xaxis TRUNCATE_XAXIS 60 | Force x-axis timeschale to be at most (x) seconds/minutes/hours long (depends on autoscaling). Sometimes devices may take a much longer time to complete than others and for readability it's best to truncate the x-axis. 61 | --xlabel-depth XLABEL_DEPTH 62 | Can be used to truncate the most significant folder name from the label. Often used to strip off folders generated with benchfio (e.g. 4k) 63 | --xlabel-parent XLABEL_PARENT 64 | use the parent folder(s) to make the label unique. The number represents how many folders up should be included. Default is 1. Use a value of 0 to remove parent folder name. 65 | --xlabel-segment-size XLABEL_SEGMENT_SIZE 66 | Truncate label names to make labels fit the graph. Disabled by default. The number represents how many characters per segment are preserved. Used with -g. 67 | --xlabel-single-column 68 | Whether to force a single-column layout in the label table when the number of labels is more than 3. 69 | -w LINE_WIDTH, --line-width LINE_WIDTH 70 | Line width for line graphs. Can be a floating-point value. Used with -g. 71 | --group-bars When using -l or -C, bars are grouped together by iops/lat type. 72 | --show-cpu When using the -C or -l option, a table is added with cpu_usr and cpu_sys data. 73 | --show-data When using the -C -l or -N option, iops/lat data is also shown in table format. It replaces the standard deviation table 74 | --show-ss When using the -C or -l option, a table is added with steadystate data. 75 | --table-lines Draw the lines within a table (cpu/stdev) 76 | --max-lat MAX_LAT Maximum latency value on y-axis 77 | --max-clat MAX_CLAT Maximum completion latency value on y-axis 78 | --max-slat MAX_SLAT Maximum submission latency value on y-axis 79 | --max-iops MAX_IOPS Maximum IOPs value on y-axis 80 | --max-bw MAX_BW Maximum bandwidth on y-axis 81 | --draw-total Draw sum of read + write data in -g chart. Requires randrw benchmark, -f read write option. 82 | --colors COLORS [COLORS ...] 83 | Space separated list of colors (only used with -g). Color names can be found at this page: https://matplotlib.org/3.3.3/gallery/color/named_colors.html(example list: tab:red teal violet yellow). You need as many colors as lines. 84 | --disable-fio-version 85 | Don't display the fio version in the graph. It will also disable the fio-plot credit. 86 | --title-fontsize TITLE_FONTSIZE 87 | Title font size 88 | --subtitle-fontsize SUBTITLE_FONTSIZE 89 | Subtitle font size 90 | --source-fontsize SOURCE_FONTSIZE 91 | Source credit (lower right) font size 92 | --credit-fontsize CREDIT_FONTSIZE 93 | Fio version and Fio-plot credit font size 94 | --table-fontsize TABLE_FONTSIZE 95 | Standard deviation table / CPU table font size 96 | 97 | ``` 98 | -------------------------------------------------------------------------------- /fio_plot/__init__.py: -------------------------------------------------------------------------------- 1 | # Generates graphs from FIO output data for various IO queue depthts 2 | # 3 | # Output in PNG format. 4 | # 5 | # Requires matplotib and numpy. 6 | # 7 | import sys 8 | from .fiolib import ( 9 | argparsing, 10 | flightchecks as checks, 11 | getdata, 12 | iniparsing, 13 | defaultsettings, 14 | ) 15 | 16 | 17 | def get_settings(): 18 | settings = defaultsettings.get_default_settings() 19 | parser = None 20 | settingsfromini = iniparsing.get_settings_from_ini(sys.argv) 21 | if not settingsfromini: 22 | parser = argparsing.set_arguments(settings) 23 | parsersettings = vars(argparsing.get_command_line_arguments(parser)) 24 | settings = {**settings, **parsersettings} 25 | settings["graphtype"] = defaultsettings.get_graphtype(settings) 26 | else: 27 | settings = {**settings, **settingsfromini} 28 | checks.run_preflight_checks(settings) 29 | return [parser, settings] 30 | 31 | 32 | def main(): 33 | option_found = False 34 | rawsettings = get_settings() 35 | settings = rawsettings[1] 36 | parser = rawsettings[0] 37 | routing_dict = getdata.get_routing_dict() 38 | graphtype = settings["graphtype"] 39 | settings = getdata.configure_default_settings(settings, routing_dict, graphtype) 40 | data = routing_dict[graphtype]["get_data"](settings) 41 | routing_dict[graphtype]["function"](settings, data) 42 | option_found = True 43 | checks.post_flight_check(parser, option_found) 44 | -------------------------------------------------------------------------------- /fio_plot/__main__.py: -------------------------------------------------------------------------------- 1 | import fio_plot 2 | 3 | if __name__ == '__main__': 4 | fio_plot.main() 5 | -------------------------------------------------------------------------------- /fio_plot/fiolib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/louwrentius/fio-plot/94842873f25c8eefc8b2ca2feb4d1f25b07c8473/fio_plot/fiolib/__init__.py -------------------------------------------------------------------------------- /fio_plot/fiolib/argparsing.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | def set_arguments(settings): 5 | 6 | """Parses all commandline arguments. Based on argparse.""" 7 | parser = argparse.ArgumentParser( 8 | description="Generates charts/graphs from FIO JSON output or logdata." 9 | ) 10 | ag = parser.add_argument_group(title="Generic Settings") 11 | ag.add_argument( 12 | "-i", 13 | "--input-directory", 14 | nargs="+", 15 | help="input directory where\ 16 | JSON files or log data (CSV) can be found.", 17 | required=True 18 | ) 19 | ag.add_argument( 20 | "-o", 21 | "--output-filename", 22 | help="Specify output graph filename instead of the generated default. Note that the file type is always png.", 23 | default=None 24 | ) 25 | ag.add_argument( 26 | "-T", "--title", help="specifies title to use in charts", required=True 27 | ) 28 | ag.add_argument("-s", "--source", help="Author") 29 | 30 | exclusive_group = ag.add_mutually_exclusive_group(required=True) 31 | exclusive_group.add_argument( 32 | "-L", 33 | "--bargraph3d", 34 | action="store_true", 35 | help="\ 36 | Generates a 3D-chart with iodepth and numjobs on x/y axis and iops or latency on the z-axis.", 37 | ) 38 | exclusive_group.add_argument( 39 | "-l", 40 | "--bargraph2d-qd", 41 | action="store_true", 42 | help="\ 43 | Generates a 2D barchart of IOPs and latency for all queue depths given a particular numjobs value.", 44 | ) 45 | exclusive_group.add_argument( 46 | "-N", 47 | "--bargraph2d_nj", 48 | action="store_true", 49 | help="This graph type is like the \ 50 | latency-iops-2d-qd barchart but instead of plotting queue depths for a particular numjobs value, it plots \ 51 | numjobs values for a particular queue depth.", 52 | ) 53 | exclusive_group.add_argument( 54 | "-H", 55 | "--histogram", 56 | action="store_true", 57 | help="\ 58 | Generates a latency histogram for a particular queue depth and numjobs value.", 59 | ) 60 | exclusive_group.add_argument( 61 | "-g", 62 | "--loggraph", 63 | action="store_true", 64 | help="This option generates a 2D graph of the log data recorded by FIO.", 65 | ) 66 | exclusive_group.add_argument( 67 | "-C", 68 | "--compare-graph", 69 | action="store_true", 70 | help="This option generates a bar chart to compare results from different\ 71 | benchmark runs.", 72 | ) 73 | 74 | ag.add_argument( 75 | "--disable-grid", 76 | action="store_true", 77 | help="\ 78 | Disables the dotted grid in the output graph.", 79 | default=settings["disable_grid"], 80 | ) 81 | ag.add_argument( 82 | "--enable-markers", 83 | action="store_true", 84 | help="\ 85 | Enable markers for the plot lines when graphing log data.", 86 | default=settings["enable_markers"], 87 | ) 88 | ag.add_argument( 89 | "--subtitle", 90 | help="\ 91 | Specify your own subtitle or leave it blank with double quotes.", 92 | type=str, 93 | default=settings["subtitle"], 94 | ) 95 | ag.add_argument( 96 | "-d", 97 | "--iodepth", 98 | type=int, 99 | nargs="+", 100 | default=None, 101 | help="\ 102 | The I/O queue depth to graph. You can specify multiple values separated by spaces.", 103 | ) 104 | ag.add_argument( 105 | "-n", 106 | "--numjobs", 107 | nargs="+", 108 | help="\ 109 | Specifies for which numjob parameter you want the 2d graphs to be\ 110 | generated. You can specify multiple values separated by spaces.", 111 | default=None, 112 | type=int, 113 | ) 114 | ag.add_argument( 115 | "-M", 116 | "--maxdepth", 117 | nargs="?", 118 | default=settings["maxdepth"], 119 | type=int, 120 | help="\ 121 | Maximum queue depth to graph in 3D graph.", 122 | ) 123 | ag.add_argument( 124 | "-J", 125 | "--maxjobs", 126 | help="\ 127 | Maximum number of jobs to graph in 3D graph.", 128 | nargs="?", 129 | default=settings["maxjob"], 130 | type=int, 131 | ) 132 | ag.add_argument( 133 | "-D", 134 | "--dpi", 135 | help="\ 136 | The chart will be saved with this DPI setting. Higher means larger\ 137 | image.", 138 | nargs="?", 139 | default=settings["dpi"], 140 | type=int, 141 | ) 142 | ag.add_argument( 143 | "-p", 144 | "--percentile", 145 | help="\ 146 | Calculate the percentile, default 99.99th.", 147 | nargs="?", 148 | default=settings["percentile"], 149 | type=float, 150 | ) 151 | ag.add_argument( 152 | "-r", 153 | "--rw", 154 | choices=[ 155 | "read", 156 | "write", 157 | "randread", 158 | "randwrite", 159 | "randrw", 160 | "trim", 161 | "rw", 162 | "readwrite", 163 | "randtrim", 164 | "trimwrite", 165 | ], 166 | required=True, 167 | help="Specifies the kind of data you want to graph.", 168 | ) 169 | ag.add_argument( 170 | "-m", 171 | "--max-z", 172 | default=settings["max_z"], 173 | type=int, 174 | help="Optional maximum value for Z-axis in 3D graph.", 175 | ) 176 | ag.add_argument( 177 | "-e", 178 | "--moving-average", 179 | default=settings["moving_average"], 180 | type=int, 181 | help="The moving average helps to smooth out graphs,\ 182 | the argument is the size of the moving window\ 183 | (default is None to disable). Be carefull as this\ 184 | setting may smooth out issues you may want to be aware of.", 185 | ) 186 | ag.add_argument( 187 | "--min-iops", 188 | help=f"Optional minimal value for iops axis, default is {settings['min_iops']}", 189 | type=int, 190 | default=settings["min_iops"], 191 | ) 192 | ag.add_argument( 193 | "--min-lat", 194 | help=f"Optional minimal value for lat axis, default is {settings['min_lat']}", 195 | type=int, 196 | default=settings["min_lat"], 197 | ) 198 | 199 | ag.add_argument( 200 | "-t", 201 | "--type", 202 | nargs="+", 203 | help="\ 204 | This setting specifies which kind of metric you want to graph.", 205 | type=str, 206 | choices=["bw", "iops", "lat", "slat", "clat"], 207 | ) 208 | ag.add_argument( 209 | "-f", 210 | "--filter", 211 | nargs="+", 212 | help="\ 213 | filter should be read/write.", 214 | type=str, 215 | default=["read", "write"], 216 | choices=["read", "write"], 217 | ) 218 | ag.add_argument( 219 | "--truncate-xaxis", 220 | help="Force x-axis timeschale to be at most (x) seconds/minutes/hours long (depends on autoscaling). \ 221 | Sometimes devices may take a much longer time to complete than others and for readability it's \ 222 | best to truncate the x-axis.", 223 | type=int, 224 | default=settings["truncate_xaxis"], 225 | ) 226 | 227 | ag.add_argument( 228 | "--xlabel-depth", 229 | help="\ 230 | Can be used to truncate the most significant folder name from the label. \ 231 | Often used to strip off folders generated with benchfio (e.g. 4k)", 232 | type=int, 233 | default=settings["xlabel_depth"], 234 | ) 235 | ag.add_argument( 236 | "--xlabel-parent", 237 | help="\ 238 | use the parent folder(s) to make the label unique. The number\ 239 | represents how many folders up should be included. Default is 1. Use a value of \ 240 | 0 to remove parent folder name.", 241 | type=int, 242 | default=settings["xlabel_parent"], 243 | ) 244 | ag.add_argument( 245 | "--xlabel-segment-size", 246 | help="\ 247 | Truncate label names to make labels fit the graph. Disabled by default. \ 248 | The number represents how many characters per \ 249 | segment are preserved. Used with -g.", 250 | type=int, 251 | default=settings["xlabel_segment_size"], 252 | ) 253 | ag.add_argument( 254 | "--xlabel-single-column", 255 | help="\ 256 | Whether to force a single-column layout in the label table \ 257 | when the number of labels is more than 3.", 258 | action="store_true", 259 | default=settings["xlabel_single_column"], 260 | ) 261 | ag.add_argument( 262 | "-w", 263 | "--line-width", 264 | help="Line width for line graphs. Can be a floating-point value. Used with -g.", 265 | type=float, 266 | default=settings["line_width"], 267 | ), 268 | ag.add_argument( 269 | "--group-bars", 270 | help="When using -l or -C, bars are grouped together by iops/lat type.", 271 | action="store_true", 272 | default=settings["group_bars"], 273 | ) 274 | ag.add_argument( 275 | "--show-cpu", 276 | help="When using the -C or -l option, a table is added with cpu_usr and cpu_sys data.", 277 | action="store_true", 278 | default=settings["show_cpu"] 279 | ) 280 | ag.add_argument( 281 | "--show-data", 282 | help="When using the -C -l or -N option, iops/lat data is also shown in table format. It replaces \ 283 | the standard deviation table", 284 | action="store_true", 285 | default=settings["show_data"] 286 | ) 287 | ag.add_argument( 288 | "--show-ss", 289 | help="When using the -C or -l option, a table is added with steadystate data.", 290 | action="store_true", 291 | default=settings["show_ss"] 292 | 293 | ) 294 | ag.add_argument( 295 | "--table-lines", 296 | help="Draw the lines within a table (cpu/stdev)", 297 | action="store_true", 298 | default=settings["table_lines"] 299 | ) 300 | ag.add_argument( 301 | "--max-lat", help="Maximum latency value on y-axis", type=int, default=None 302 | ) 303 | ag.add_argument( 304 | "--max-clat", help="Maximum completion latency value on y-axis", type=int, default=None 305 | ) 306 | ag.add_argument( 307 | "--max-slat", help="Maximum submission latency value on y-axis", type=int, default=None 308 | ) 309 | ag.add_argument( 310 | "--max-iops", help="Maximum IOPs value on y-axis", type=int, default=None 311 | ) 312 | ag.add_argument( 313 | "--max-bw", help="Maximum bandwidth on y-axis", type=int, default=None 314 | ) 315 | ag.add_argument( 316 | "--draw-total", 317 | help="Draw sum of read + write data in -g chart. Requires randrw benchmark, -f read write option.", 318 | action="store_true", 319 | default=settings["draw_total"] 320 | ) 321 | ag.add_argument( 322 | "--colors", 323 | help="Space separated list of colors (only used with -g). Color names can be found " 324 | "at this page: https://matplotlib.org/3.3.3/gallery/color/named_colors.html" 325 | "(example list: tab:red teal violet yellow). You need as many colors as lines.", 326 | type=str, 327 | nargs="+", 328 | default=settings["colors"], 329 | ) 330 | ag.add_argument( 331 | "--disable-fio-version", 332 | help="Don't display the fio version in the graph. It will also disable the fio-plot credit.", 333 | action="store_true", 334 | default=settings["disable_fio_version"] 335 | ) 336 | ag.add_argument( 337 | "--title-fontsize", help="Title font size", type=int, default=settings["title_fontsize"] 338 | ) 339 | ag.add_argument( 340 | "--subtitle-fontsize", help="Subtitle font size", type=int, default=settings["subtitle_fontsize"] 341 | ) 342 | ag.add_argument( 343 | "--source-fontsize", help="Source credit (lower right) font size", type=int, default=settings["source_fontsize"] 344 | ) 345 | ag.add_argument( 346 | "--credit-fontsize", help="Fio version and Fio-plot credit font size", type=int, default=settings["credit_fontsize"] 347 | ) 348 | ag.add_argument( 349 | "--table-fontsize", help="Standard deviation table / CPU table font size", type=int, default=settings["table_fontsize"] 350 | ) 351 | group = parser.add_mutually_exclusive_group() 352 | 353 | group.add_argument( 354 | "--include-hosts", 355 | help="Only create graphs for these hosts (when parsing client-server benchmark data)", 356 | type=str, 357 | nargs="+", 358 | default=None, 359 | ) 360 | group.add_argument( 361 | "--exclude-hosts", 362 | help="Graph all hosts except for those listed (when parsing client-server benchmark data)", 363 | type=str, 364 | nargs="+", 365 | default=None, 366 | ) 367 | return parser 368 | 369 | def get_command_line_arguments(parser): 370 | try: 371 | args = parser.parse_args() 372 | except OSError: 373 | parser.print_help() 374 | sys.exit(1) 375 | 376 | if len(sys.argv) == 1: 377 | parser.print_help() 378 | sys.exit(1) 379 | return args 380 | -------------------------------------------------------------------------------- /fio_plot/fiolib/bar2d.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import matplotlib.pyplot as plt 3 | import pprint 4 | 5 | from . import ( 6 | supporting, 7 | shared_chart as shared, 8 | tables, 9 | table_support as ts 10 | ) 11 | 12 | def format_hostname_labels(settings, data): 13 | labels = [] 14 | counter = 1 15 | hostcounter = 0 16 | divide = int(len(data["hostname_series"]) / len(data["x_axis"])) # that int convert should work 17 | for host in data["hostname_series"]: 18 | hostcounter += 1 19 | attr = data["x_axis"][counter-1] 20 | labels.append(f"{host}\n{settings['graphtype'][-2:]} {attr}") 21 | if hostcounter % divide == 0: 22 | counter += 1 23 | return labels 24 | 25 | def set_max_yaxis(settings, axes): 26 | for ax in axes: 27 | if ax.get_ylabel() == "IOPS": 28 | if settings["max_iops"]: 29 | ax.set_ylim(settings["min_iops"],settings["max_iops"]) 30 | if "Latency" in ax.get_ylabel(): 31 | if settings["max_lat"]: 32 | ax.set_ylim(settings["min_lat"],settings["max_lat"]) 33 | 34 | def calculate_font_size(settings, x_axis): 35 | max_label_width = max(ts.get_max_width([x_axis], len(x_axis))) 36 | #print(max_label_width) 37 | fontsize = 0 38 | # 39 | # This hard-coded font sizing is ugly but if somebody knows a better algorithm... 40 | # 41 | cols = len(x_axis) 42 | if settings["group_bars"]: 43 | if max_label_width >= 10: 44 | fontsize = 6 45 | else: 46 | fontsize = 8 47 | else: 48 | if max_label_width >= 10 and cols > 8: 49 | fontsize = 6 50 | else: 51 | fontsize = 8 52 | return fontsize 53 | 54 | def create_bars_and_xlabels(settings, data, ax1, ax3): 55 | 56 | return_data = {"ax1": None, "ax3": None, "rects1": None, "rects2": None} 57 | 58 | iops = data["y1_axis"]["data"] 59 | latency = np.array(data["y2_axis"]["data"], dtype=float) 60 | width = 0.9 61 | 62 | color_iops = "#a8ed63" 63 | color_lat = "#34bafa" 64 | 65 | if settings["group_bars"]: 66 | x_pos1 = np.arange(1, len(iops) + 1, 1) 67 | x_pos2 = np.arange(len(iops) + 1, len(iops) + len(latency) + 1, 1) 68 | 69 | rects1 = ax1.bar(x_pos1, iops, width, color=color_iops) 70 | rects2 = ax3.bar(x_pos2, latency, width, color=color_lat) 71 | 72 | x_axis = data["x_axis"] * 2 73 | ltest = np.arange(1, len(x_axis) + 1, 1) 74 | 75 | else: 76 | x_pos = np.arange(0, (len(iops) * 2), 2) 77 | 78 | rects1 = ax1.bar(x_pos, iops, width, color=color_iops) 79 | rects2 = ax3.bar(x_pos + width, latency, width, color=color_lat) 80 | x_axis = data["x_axis"] 81 | 82 | if "hostname_series" in data.keys(): 83 | if data["hostname_series"]: 84 | x_axis = format_hostname_labels(settings, data) 85 | ltest = np.arange(0.45, (len(iops) * 2), 2) 86 | 87 | ax1.set_ylabel(data["y1_axis"]["format"]) 88 | ax3.set_ylabel(data["y2_axis"]["format"]) 89 | ax1.set_xlabel(settings["label"]) 90 | ax1.set_xticks(ltest) 91 | 92 | set_max_yaxis(settings, [ax1, ax3]) 93 | 94 | fontsize = calculate_font_size(settings, x_axis) 95 | #print(fontsize) 96 | if settings["graphtype"] == "compare_graph": 97 | ax1.set_xticklabels(labels=x_axis, fontsize=fontsize) 98 | elif settings["graphtype"] == "bargraph2d_qd" or settings["graphtype"] == "bargraph2d_nj": 99 | ax1.set_xticklabels(labels=x_axis, fontsize=fontsize,) 100 | else: 101 | ax1.set_xticklabels(labels=x_axis, fontsize=fontsize, rotation=-50) 102 | 103 | return_data["rects1"] = rects1 104 | return_data["rects2"] = rects2 105 | return_data["ax1"] = ax1 106 | return_data["ax3"] = ax3 107 | return_data["fontsize"] = fontsize 108 | return return_data 109 | 110 | 111 | def chart_2dbarchart_jsonlogdata(settings, dataset): 112 | """This function is responsible for drawing iops/latency bars for a 113 | particular iodepth.""" 114 | dataset_types = shared.get_dataset_types(dataset) 115 | data = shared.get_record_set(settings, dataset, dataset_types) 116 | fig, (ax1, ax2) = plt.subplots(nrows=2, gridspec_kw={"height_ratios": [7, 1]}) 117 | ax3 = ax1.twinx() 118 | fig.set_size_inches(10, 6) 119 | plt.margins(x=0.01) 120 | # 121 | # Puts in the credit source (often a name or url) 122 | supporting.plot_source(settings, plt, ax1) 123 | supporting.plot_fio_version(settings, data["fio_version"][0], plt, ax2) 124 | 125 | ax2.axis("off") 126 | 127 | return_data = create_bars_and_xlabels(settings, data, ax1, ax3) 128 | 129 | rects1 = return_data["rects1"] 130 | rects2 = return_data["rects2"] 131 | ax1 = return_data["ax1"] 132 | ax3 = return_data["ax3"] 133 | fontsize = return_data["fontsize"] 134 | 135 | # 136 | # Set title 137 | settings["type"] = "" 138 | settings[settings["query"]] = dataset_types[settings["query"]] 139 | if settings["rw"] == "randrw": 140 | supporting.create_title_and_sub( 141 | settings, 142 | plt, 143 | bs=data["bs"][0], 144 | skip_keys=[settings["query"]], 145 | ) 146 | else: 147 | supporting.create_title_and_sub( 148 | settings, 149 | plt, 150 | bs=data["bs"][0], 151 | skip_keys=[settings["query"], "filter"], 152 | ) 153 | # 154 | # Labeling the top of the bars with their value 155 | shared.autolabel(rects1, ax1) 156 | shared.autolabel(rects2, ax3) 157 | # 158 | # Draw the standard deviation table 159 | if settings["show_data"]: 160 | tables.create_values_table(settings, data, ax2, fontsize) 161 | else: 162 | tables.create_stddev_table(settings, data, ax2, fontsize) 163 | 164 | # 165 | # Draw the cpu usage table if requested 166 | # pprint.pprint(data) 167 | if settings["show_cpu"] and not settings["show_ss"]: 168 | tables.create_cpu_table(settings, data, ax2, fontsize) 169 | 170 | if settings["show_ss"] and not settings["show_cpu"]: 171 | tables.create_steadystate_table(settings, data, ax2, fontsize) 172 | 173 | # 174 | # Create legend 175 | ax2.legend( 176 | (rects1[0], rects2[0]), 177 | (data["y1_axis"]["format"], data["y2_axis"]["format"]), 178 | loc="center left", 179 | frameon=False, 180 | ) 181 | # 182 | # Save graph to PNG file 183 | # 184 | supporting.save_png(settings, plt, fig) 185 | 186 | 187 | def compchart_2dbarchart_jsonlogdata(settings, dataset): 188 | """This function is responsible for creating bar charts that compare data.""" 189 | dataset_types = shared.get_dataset_types(dataset) 190 | data = shared.get_record_set_improved(settings, dataset, dataset_types) 191 | 192 | # pprint.pprint(data) 193 | 194 | fig, (ax1, ax2) = plt.subplots(nrows=2, gridspec_kw={"height_ratios": [7, 1]}) 195 | ax3 = ax1.twinx() 196 | fig.set_size_inches(10, 6) 197 | plt.margins(x=0.01) 198 | 199 | # 200 | # Puts in the credit source (often a name or url) 201 | supporting.plot_source(settings, plt, ax1) 202 | supporting.plot_fio_version(settings, data["fio_version"][0], plt, ax2) 203 | 204 | ax2.axis("off") 205 | 206 | return_data = create_bars_and_xlabels(settings, data, ax1, ax3) 207 | rects1 = return_data["rects1"] 208 | rects2 = return_data["rects2"] 209 | ax1 = return_data["ax1"] 210 | ax3 = return_data["ax3"] 211 | # 212 | # Set title 213 | settings["type"] = "" 214 | settings["iodepth"] = dataset_types["iodepth"] 215 | if settings["rw"] == "randrw": 216 | supporting.create_title_and_sub(settings, plt, skip_keys=["iodepth"]) 217 | else: 218 | supporting.create_title_and_sub(settings, plt, skip_keys=[]) 219 | 220 | # 221 | # Labeling the top of the bars with their value 222 | shared.autolabel(rects1, ax1) 223 | shared.autolabel(rects2, ax3) 224 | fontsize = calculate_font_size(settings, data["x_axis"]) 225 | 226 | if settings["show_data"]: 227 | tables.create_values_table(settings, data, ax2, fontsize) 228 | else: 229 | tables.create_stddev_table(settings, data, ax2, fontsize) 230 | 231 | if settings["show_cpu"] and not settings["show_ss"]: 232 | tables.create_cpu_table(settings, data, ax2, fontsize) 233 | 234 | if settings["show_ss"] and not settings["show_cpu"]: 235 | tables.create_steadystate_table(settings, data, ax2, fontsize) 236 | 237 | # Create legend 238 | ax2.legend( 239 | (rects1[0], rects2[0]), 240 | (data["y1_axis"]["format"], data["y2_axis"]["format"]), 241 | loc="center left", 242 | frameon=False, 243 | ) 244 | 245 | # 246 | # Save graph to PNG file 247 | # 248 | supporting.save_png(settings, plt, fig) 249 | -------------------------------------------------------------------------------- /fio_plot/fiolib/bar3d.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import numpy as np 3 | import matplotlib.pyplot as plt 4 | 5 | # import pprint 6 | import matplotlib as mpl 7 | import pprint 8 | 9 | from matplotlib import cm 10 | from . import ( 11 | supporting, 12 | shared_chart as shared 13 | ) 14 | 15 | 16 | def plot_3d(settings, dataset): 17 | """This function is responsible for plotting the entire 3D plot.""" 18 | 19 | if not settings["type"]: 20 | print("The type of data must be specified with -t (iops/lat/bw).") 21 | exit(1) 22 | 23 | dataset_types = shared.get_dataset_types(dataset) 24 | metric = settings["type"][0] 25 | rw = settings["rw"] 26 | iodepth = dataset_types["iodepth"] 27 | numjobs = dataset_types["numjobs"] 28 | data = shared.get_record_set_3d(settings, dataset, dataset_types, rw, metric) 29 | 30 | fig = plt.figure() 31 | ax1 = fig.add_subplot(projection="3d", elev=25) 32 | fig.set_size_inches(15, 10) 33 | ax1.set_box_aspect((4, 4, 3), zoom=1.2) 34 | 35 | lx = len(dataset_types["iodepth"]) 36 | ly = len(dataset_types["numjobs"]) 37 | 38 | # This code is meant to make the 3D chart to honour the maxjobs and 39 | # the maxdepth command line settings. It won't win any prizes for sure. 40 | if settings["maxjobs"]: 41 | numjobs = [x for x in numjobs if x <= settings["maxjobs"]] 42 | ly = len(numjobs) 43 | if settings["maxdepth"]: 44 | iodepth = [x for x in iodepth if x <= settings["maxdepth"]] 45 | lx = len(iodepth) 46 | if not iodepth: 47 | print( 48 | f"\nDefault maximum iodepth {settings['maxdepth']} is lower than specified iops {settings['iodepth']}." 49 | ) 50 | print( 51 | "\nPlease specify a higher maximum iodepth for the graph with -M like '-M 512'\n" 52 | ) 53 | sys.exit(1) 54 | if settings["maxjobs"] or settings["maxdepth"]: 55 | temp_x = [] 56 | for item in data["values"]: 57 | if len(temp_x) < len(iodepth): 58 | temp_y = [] 59 | for record in item: 60 | if len(temp_y) < len(numjobs): 61 | temp_y.append(record) 62 | temp_x.append(temp_y) 63 | data["iodepth"] = iodepth 64 | data["numjobs"] = numjobs 65 | data["values"] = temp_x 66 | 67 | # Ton of code to scale latency or bandwidth 68 | if metric == "lat" or metric == "bw": 69 | scale_factors = [] 70 | for row in data["values"]: 71 | if metric == "lat": 72 | scale_factor = supporting.get_scale_factor_lat(row) 73 | if metric == "bw": 74 | scale_factor = supporting.get_scale_factor_bw(row) 75 | scale_factors.append(scale_factor) 76 | largest_scale_factor = supporting.get_largest_scale_factor(scale_factors) 77 | # pprint.pprint(largest_scale_factor) 78 | 79 | scaled_values = [] 80 | for row in data["values"]: 81 | result = supporting.scale_yaxis(row, largest_scale_factor) 82 | scaled_values.append(result["data"]) 83 | z_axis_label = largest_scale_factor["label"] 84 | 85 | else: 86 | scaled_values = data["values"] 87 | z_axis_label = metric 88 | 89 | n = np.array(scaled_values, dtype=float) 90 | 91 | if lx < ly: 92 | size = ly * 0.03 # thickness of the bar 93 | else: 94 | size = lx * 0.05 # thickness of the bar 95 | 96 | xpos_orig = np.arange(0, lx, 1) 97 | ypos_orig = np.arange(0, ly, 1) 98 | 99 | xpos = np.arange(0, lx, 1) 100 | ypos = np.arange(0, ly, 1) 101 | xpos, ypos = np.meshgrid(xpos - (size / lx), ypos - (size * (ly / lx))) 102 | 103 | xpos_f = xpos.flatten() # Convert positions to 1D array 104 | ypos_f = ypos.flatten() 105 | 106 | zpos = np.zeros(lx * ly) 107 | 108 | # Positioning and sizing of the bars 109 | dx = size * np.ones_like(zpos) 110 | dy = size * (ly / lx) * np.ones_like(zpos) 111 | dz = n.flatten(order="F") 112 | values = dz / (dz.max() / 1) 113 | 114 | # Configure max value for z-axis 115 | if settings["max_z"]: 116 | ax1.set_zlim(0, settings["max_z"]) 117 | cutoff_values = [] 118 | warning = False 119 | for value in dz: 120 | if value < settings["max_z"]: 121 | cutoff_values.append(value) 122 | else: 123 | warning = True 124 | cutoff_values.append(settings["max_z"]) 125 | dz = np.array(cutoff_values) 126 | if warning: 127 | warning_text = f"WARNING: values above {settings['max_z']} have been cutoff" 128 | print(warning_text) 129 | fig.text(0.55, 0.85, warning_text) 130 | 131 | # Create the 3D chart with positioning and colors 132 | cmap = plt.get_cmap("rainbow", xpos.ravel().shape[0]) 133 | colors = cm.rainbow(values) 134 | ax1.bar3d(xpos_f, ypos_f, zpos, dx, dy, dz, color=colors, zsort="max") 135 | 136 | # Create the color bar to the right 137 | norm = mpl.colors.Normalize(vmin=0, vmax=dz.max()) 138 | sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm) 139 | sm.set_array([]) 140 | res = fig.colorbar(sm, fraction=0.046, pad=0.19, ax=ax1) 141 | res.ax.set_title(z_axis_label) 142 | 143 | # Set tics for x/y axis 144 | float_x = [float(x) for x in (xpos_orig)] 145 | 146 | ax1.xaxis.set_ticks(float_x) 147 | ax1.yaxis.set_ticks(ypos_orig) 148 | ax1.xaxis.set_ticklabels(iodepth) 149 | ax1.yaxis.set_ticklabels(numjobs) 150 | 151 | # axis labels 152 | fontsize = 16 153 | ax1.set_xlabel("iodepth", fontsize=fontsize) 154 | ax1.set_ylabel("numjobs", fontsize=fontsize) 155 | ax1.set_zlabel(z_axis_label, fontsize=fontsize) 156 | 157 | [t.set_verticalalignment("center_baseline") for t in ax1.get_yticklabels()] 158 | [t.set_verticalalignment("center_baseline") for t in ax1.get_xticklabels()] 159 | 160 | ax1.zaxis.labelpad = 25 161 | 162 | tick_label_font_size = 16 163 | for t in ax1.xaxis.get_major_ticks(): 164 | t.label1.set_fontsize(tick_label_font_size) 165 | 166 | for t in ax1.yaxis.get_major_ticks(): 167 | t.label1.set_fontsize(tick_label_font_size) 168 | 169 | ax1.zaxis.set_tick_params(pad=10) 170 | for t in ax1.zaxis.get_major_ticks(): 171 | t.label1.set_fontsize(tick_label_font_size) 172 | 173 | # title 174 | supporting.create_title_and_sub( 175 | settings, 176 | plt, 177 | skip_keys=["iodepth", "numjobs"], 178 | sub_x_offset=0.57, 179 | sub_y_offset=1.15, 180 | ) 181 | 182 | # Source 183 | if settings["source"]: 184 | fig.text(0.65, 0.075, settings["source"], fontsize=settings["source_fontsize"]) 185 | if not settings["disable_fio_version"]: 186 | fio_version = data["fio_version"][0] 187 | fig.text( 188 | 0.05, 189 | 0.075, 190 | f"Fio version: {fio_version}\nGraph generated by fio-plot", 191 | fontsize=settings["credit_fontsize"], 192 | ) 193 | 194 | # 195 | # Save graph to PNG file 196 | # 197 | supporting.save_png(settings, plt, fig) 198 | -------------------------------------------------------------------------------- /fio_plot/fiolib/barhistogram.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import matplotlib.pyplot as plt 3 | 4 | # import pprint 5 | from . import ( 6 | shared_chart as shared, 7 | supporting 8 | ) 9 | 10 | def sort_latency_keys(latency): 11 | """The FIO latency data has latency buckets and those are sorted ascending. 12 | The milisecond data has a >=2000 bucket which cannot be sorted in a 'normal' 13 | way, so it is just stuck on top. This function resturns a list of sorted keys. 14 | """ 15 | placeholder = "" 16 | tmp = [] 17 | for item in latency: 18 | if item == ">=2000": 19 | placeholder = ">=2000" 20 | else: 21 | tmp.append(item) 22 | 23 | tmp.sort(key=int) 24 | if placeholder: 25 | tmp.append(placeholder) 26 | return tmp 27 | 28 | 29 | def sort_latency_data(latency_dict): 30 | """The sorted keys from the sort_latency_keys function are used to create 31 | a sorted list of values, matching the order of the keys.""" 32 | keys = latency_dict.keys() 33 | values = {"keys": None, "values": []} 34 | sorted_keys = sort_latency_keys(keys) 35 | values["keys"] = sorted_keys 36 | for key in sorted_keys: 37 | values["values"].append(latency_dict[key]) 38 | return values 39 | 40 | 41 | def autolabel(rects, axis): 42 | """This function puts a value label on top of a 2d bar. If a bar is so small 43 | it's barely visible, if at all, the label is omitted.""" 44 | fontsize = 6 45 | for rect in rects: 46 | height = rect.get_height() 47 | if height >= 1: 48 | axis.text( 49 | rect.get_x() + rect.get_width() / 2.0, 50 | 1 + height, 51 | "{}%".format(int(height)), 52 | ha="center", 53 | fontsize=fontsize, 54 | ) 55 | elif height > 0.4: 56 | axis.text( 57 | rect.get_x() + rect.get_width() / 2.0, 58 | 1 + height, 59 | "{:3.2f}%".format(height), 60 | ha="center", 61 | fontsize=fontsize, 62 | ) 63 | 64 | 65 | def chart_latency_histogram(settings, dataset): 66 | """This function is responsible to draw the 2D latency histogram, 67 | (a bar chart).""" 68 | 69 | record_set = shared.get_record_set_histogram(settings, dataset) 70 | # We have to sort the data / axis from low to high 71 | 72 | sorted_result_ms = sort_latency_data(record_set["data"]["latency_ms"][0]) 73 | sorted_result_us = sort_latency_data(record_set["data"]["latency_us"][0]) 74 | sorted_result_ns = sort_latency_data(record_set["data"]["latency_ns"][0]) 75 | 76 | # This is just to use easier to understand variable names 77 | x_series = sorted_result_ms["keys"] 78 | y_series1 = sorted_result_ms["values"] 79 | y_series2 = sorted_result_us["values"] 80 | y_series3 = sorted_result_ns["values"] 81 | 82 | # us/ns histogram data is missing 2000/>=2000 fields that ms data has 83 | # so we have to add dummy data to match x-axis size 84 | y_series2.extend([0, 0]) 85 | y_series3.extend([0, 0]) 86 | 87 | # Create the plot 88 | fig, ax1 = plt.subplots() 89 | fig.set_size_inches(10, 6) 90 | 91 | # Make the positioning of the bars for ns/us/ms 92 | x_pos = np.arange(0, len(x_series) * 3, 3) 93 | width = 1 94 | 95 | # how much of the IO falls in a particular latency class ns/us/ms 96 | coverage_ms = round(sum(y_series1), 2) 97 | coverage_us = round(sum(y_series2), 2) 98 | coverage_ns = round(sum(y_series3), 2) 99 | 100 | # Draw the bars 101 | rects1 = ax1.bar(x_pos, y_series1, width, color="r") 102 | rects2 = ax1.bar(x_pos + width, y_series2, width, color="b") 103 | rects3 = ax1.bar(x_pos + width + width, y_series3, width, color="g") 104 | 105 | # Configure the axis and labels 106 | ax1.set_ylabel("Percentage of I/O") 107 | ax1.set_xlabel("Latency") 108 | ax1.set_xticks(x_pos + width / 2) 109 | ax1.set_xticklabels(x_series) 110 | 111 | # Make room for labels by scaling y-axis up (max is 100%) 112 | ax1.set_ylim(0, 100 * 1.1) 113 | 114 | label_ms = "Latency in ms ({0:05.2f}%)".format(coverage_ms) 115 | label_us = "Latency in us ({0:05.2f}%)".format(coverage_us) 116 | label_ns = "Latency in ns ({0:05.2f}%)".format(coverage_ns) 117 | 118 | # Configure the title 119 | settings["type"] = "" 120 | supporting.create_title_and_sub(settings, plt, ["type", "filter"]) 121 | # Configure legend 122 | ax1.legend( 123 | (rects1[0], rects2[0], rects3[0]), 124 | (label_ms, label_us, label_ns), 125 | frameon=False, 126 | loc="best", 127 | ) 128 | 129 | # puts a percentage above each bar (ns/us/ms) 130 | autolabel(rects1, ax1) 131 | autolabel(rects2, ax1) 132 | autolabel(rects3, ax1) 133 | 134 | supporting.plot_source(settings, plt, ax1) 135 | supporting.plot_fio_version(settings, record_set["fio_version"], plt, ax1) 136 | 137 | supporting.save_png(settings, plt, fig) 138 | -------------------------------------------------------------------------------- /fio_plot/fiolib/dataimport.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import csv 4 | import pprint as pprint 5 | import re 6 | import statistics 7 | from pathlib import Path 8 | from . import dataimport_support as ds 9 | 10 | def get_hostname_from_filename(f): 11 | split = f.split(".") 12 | rawhostname = split[3:] 13 | hostname = ".".join(rawhostname) 14 | return hostname 15 | 16 | def list_fio_log_files(directory): 17 | """ 18 | Lists all .log files in a directory. Exits with an error if no files are found. 19 | client/server log files ending in the hostname are also detected. 20 | """ 21 | absolute_dir = os.path.abspath(directory) 22 | files = os.listdir(absolute_dir) 23 | fiologfiles = [] 24 | for f in files: 25 | absolutefilepath = os.path.join(absolute_dir, f) 26 | structure = { "hostname": None, "filename": absolutefilepath} 27 | if f.endswith(".log"): 28 | fiologfiles.append(structure) 29 | elif ".log." in f: 30 | structure["hostname"] = get_hostname_from_filename(f) 31 | fiologfiles.append(structure) 32 | 33 | if len(fiologfiles) == 0: 34 | print( 35 | f"\nCould not find any log files in the specified directory {str(absolute_dir)}" 36 | ) 37 | print("\nAre the correct directories specified?") 38 | print("\nIf so, please check the -d -n and -r parameters.\n") 39 | sys.exit(1) 40 | return fiologfiles 41 | 42 | 43 | def limit_path_part_size(path, length): 44 | parts = path.parts 45 | raw_result = [x[:length] for x in parts] 46 | result = "/".join(raw_result) 47 | return result 48 | 49 | 50 | def return_folder_name(filename, settings, override=False): 51 | segment_size = settings["xlabel_segment_size"] 52 | parent = settings["xlabel_parent"] 53 | xlabeldepth = settings["xlabel_depth"] 54 | raw_path = Path(filename).resolve() 55 | #print(settings) 56 | if override: 57 | raw_path = raw_path.parent 58 | 59 | if xlabeldepth > 0: 60 | raw_path = raw_path.parents[xlabeldepth - 1] 61 | 62 | upperpath = raw_path.parents[parent] 63 | 64 | relative_path = raw_path.relative_to(upperpath) 65 | 66 | relative_path_processed = limit_path_part_size(relative_path, segment_size) 67 | return relative_path_processed 68 | 69 | 70 | def return_filename_filter_string(settings): 71 | """Returns a list of dicts with, a key/value for the search string. 72 | This string is used to filter the log files based on the command line 73 | parameters. 74 | """ 75 | searchstrings = [] 76 | 77 | rw = settings["rw"] 78 | iodepths = settings["iodepth"] 79 | numjobs = settings["numjobs"] 80 | benchtypes = settings["type"] 81 | 82 | for benchtype in benchtypes: 83 | for iodepth in iodepths: 84 | for numjob in numjobs: 85 | searchstring = f"{rw}-iodepth-{iodepth}-numjobs-{numjob}_{benchtype}" 86 | attributes = { 87 | "rw": rw, 88 | "iodepth": iodepth, 89 | "numjobs": numjob, 90 | "type": benchtype, 91 | "searchstring": searchstring, 92 | } 93 | searchstrings.append(attributes) 94 | return searchstrings 95 | 96 | 97 | def filterLogFiles(settings, file_list): 98 | """ 99 | Returns a list of log files that matches the supplied filter string(s). 100 | """ 101 | searchstrings = return_filename_filter_string(settings) 102 | #print(searchstrings) 103 | result = [] 104 | for item in file_list: 105 | for searchstring in searchstrings: 106 | filename = os.path.basename(item["filename"]) 107 | # print(filename) 108 | if re.search(r"^" + searchstring["searchstring"], filename): 109 | data = {"filename": item} 110 | data.update(searchstring) 111 | data["directory"] = return_folder_name(item["filename"], settings, True) 112 | data["hostname"] = item["hostname"] 113 | result.append(data) 114 | if len(result) > 0: 115 | return result 116 | else: 117 | print( 118 | f"\nNo log files found that matches the specified parameter {settings['rw']}\n" 119 | ) 120 | print( 121 | f"Check parameters iodepth {settings['iodepth']} and numjobs {settings['numjobs']}?\n" 122 | ) 123 | exit(1) 124 | 125 | 126 | def mergeSingleDataSet(data, datatype): 127 | """In this function we merge all data for one particular set of files. 128 | For examle, iodepth = 1 and numjobs = 8. The function returns one single 129 | dataset containing the summed/averaged data. 130 | """ 131 | #print("==============") 132 | #for x in data: 133 | # print(f"Merge single dataset - {x['hostname']} - {x['filename']} - {type(x['data'])}") 134 | merged_set = [] 135 | hostdatamerged = {} 136 | regulardatamerged = [] 137 | 138 | for record in data: 139 | if record["hostname"]: 140 | if record["hostname"] not in hostdatamerged.keys(): 141 | hostdatamerged[record["hostname"]] = [] 142 | hostdatamerged[record["hostname"]].append(record) 143 | else: 144 | regulardatamerged.append(record) 145 | 146 | 147 | if hostdatamerged: 148 | for host in hostdatamerged.keys(): 149 | result = ds.newMergeLogDataSet(hostdatamerged[host], datatype, host) 150 | merged_set.append(result) 151 | elif regulardatamerged: 152 | merged_set.append(ds.newMergeLogDataSet(regulardatamerged,datatype)) 153 | else: 154 | print("ERROR") 155 | sys.exit(1) 156 | #merged_set = ds.mergeLogDataSet(data, datatype, hostlist) 157 | return merged_set 158 | 159 | 160 | def get_unique_directories(dataset): 161 | directories = [] 162 | for item in dataset: 163 | dirname = item["directory"] 164 | if dirname not in directories: 165 | directories.append(dirname) 166 | return directories 167 | 168 | 169 | def mergeDataSet(settings, dataset): 170 | """We need to merge multiple datasets, for multiple iodepts and numjob 171 | values. The return is a list of those merged datasets. 172 | 173 | We also take into account if multiple folders are specified to compare 174 | benchmarks results across different runs. 175 | """ 176 | merged_sets = [] 177 | filterstrings = return_filename_filter_string(settings) 178 | directories = get_unique_directories(dataset) 179 | 180 | for directory in directories: 181 | for filterstring in filterstrings: 182 | record = { 183 | "type": filterstring["type"], 184 | "iodepth": filterstring["iodepth"], 185 | "numjobs": filterstring["numjobs"], 186 | "directory": directory, 187 | 188 | } 189 | data = [] 190 | for item in dataset: 191 | if ( 192 | filterstring["searchstring"] in item["searchstring"] 193 | and item["directory"] == directory 194 | ): 195 | data.append(item) 196 | 197 | newdata = mergeSingleDataSet(data, filterstring["type"]) # read write hostname 198 | #print(newdata["hostname"]) 199 | record["data"] = newdata 200 | merged_sets.append(record) 201 | return merged_sets 202 | 203 | 204 | def parse_raw_cvs_data(settings, dataset): 205 | """This function exists mostly because I tried to test the performance 206 | of a 1.44MB floppy drive. The device is so slow that it can't keep up. 207 | This results in records that span multiple seconds, skewing the graphs. 208 | If this is detected, the data is averaged over the interval between records. 209 | """ 210 | new_set = [] 211 | distance_list = [] 212 | for index, item in enumerate(dataset): 213 | if index == 0: 214 | continue 215 | else: 216 | distance = int(item["timestamp"]) - int(dataset[index - 1]["timestamp"]) 217 | distance_list.append(distance) 218 | try: 219 | mean = int(statistics.mean(distance_list)) 220 | except statistics.StatisticsError as e: 221 | print(f"ERROR: {e}") 222 | print("\n Could this be because of an empty log file?\n") 223 | sys.exit(1) 224 | 225 | if mean > 1000: 226 | #print( 227 | # f"\n{supporting.bcolors.WARNING}Warning: > 1000msec log interval found\n" 228 | # f"{supporting.bcolors.ENDC}" 229 | # "\nIf the log_avg_msec parameter used to generate the log data is < 1000 msec\n" 230 | # "it is stronly advised to cross-verify the output of the graph with the\n" 231 | # "appropriate values found in the .json output if available.\n\n" 232 | # "It may be advised to rerun your benchmarks with log_avg_msec = 1000 or higher\n" 233 | # "to achieve correct results.\n\n" 234 | #) 235 | 236 | # log data with a log_avg_msec higher than 1000 msec should be converted back 237 | # to values per 1000 msec 238 | for index, item in enumerate(dataset): 239 | if index == 0: 240 | average_value = int(item["value"]) / int(item["timestamp"]) * 1000 241 | 242 | else: 243 | previous_timestamp = int(dataset[index - 1]["timestamp"]) 244 | distance = int(item["timestamp"]) - previous_timestamp 245 | number_of_seconds = int(distance / 1000) 246 | try: 247 | average_value = int(item["value"]) / distance * mean 248 | except ZeroDivisionError as e: 249 | print(e) 250 | print(f"{item['value']} - {distance} - {mean}") 251 | continue 252 | for x in range(number_of_seconds): 253 | temp_dict = dict(item) 254 | temp_dict["value"] = average_value 255 | temp_dict["timestamp"] = previous_timestamp + x 256 | new_set.append(temp_dict) 257 | return new_set 258 | else: 259 | return dataset 260 | 261 | 262 | def readLogData(settings, inputfile): 263 | """FIO log data is imported as CSV data. The scope is the import of a 264 | single file. 265 | """ 266 | dataset = [] 267 | if os.path.exists(inputfile["filename"]): 268 | #print(inputfile) 269 | with open(inputfile["filename"]) as csv_file: 270 | csv.register_dialect("CustomDialect", skipinitialspace=True, strict=True) 271 | csv_reader = csv.DictReader( 272 | csv_file, 273 | dialect="CustomDialect", 274 | delimiter=",", 275 | fieldnames=["timestamp", "value", "rwt", "blocksize", "offset"], 276 | ) 277 | for item in csv_reader: 278 | dataset.append(item) 279 | dataset = parse_raw_cvs_data(settings, dataset) 280 | return dataset 281 | 282 | 283 | def readLogDataFromFiles(settings, inputfiles): 284 | """Returns a list of imported datasets based on the input files.""" 285 | data = [] 286 | for inputfile in inputfiles: 287 | logdata = readLogData(settings, inputfile["filename"]) 288 | logdict = {"data": logdata, "hostname": inputfile["hostname"]} 289 | logdict.update(inputfile) 290 | data.append(logdict) 291 | return data 292 | -------------------------------------------------------------------------------- /fio_plot/fiolib/dataimport_support.py: -------------------------------------------------------------------------------- 1 | import statistics 2 | 3 | def get_hosts_from_data(data): 4 | hosts = set() 5 | for record in data: 6 | hosts.add(record["hostname"]) 7 | 8 | if all(x is None for x in hosts): 9 | hosts = None 10 | else: 11 | hosts = list(hosts) 12 | return hosts 13 | 14 | 15 | def getMergeOperation(datatype): 16 | """FIO log files with a numjobs larger than 1 generates a separate file 17 | for each job thread. So if numjobs is 8, there will be eight files. 18 | 19 | We need to merge the data from all those job files into one result. 20 | Depending on the type of data, we must sum or average the data. 21 | 22 | This function returns the appropriate function/operation based on the type. 23 | """ 24 | 25 | operationMapping = { 26 | "iops": sum, 27 | "lat": statistics.mean, 28 | "clat": statistics.mean, 29 | "slat": statistics.mean, 30 | "bw": sum, 31 | "timestamp": statistics.mean, 32 | } 33 | 34 | opfunc = operationMapping[datatype] 35 | return opfunc 36 | 37 | 38 | def newMergeLogDataSet(data, datatype, hostname=None): 39 | mergedSet = {"read": [], "write": [], "hostname": hostname} 40 | lookup = {"read": 0, "write": 1} 41 | for rw in ["read", "write"]: 42 | for column in ["timestamp", "value"]: 43 | unmergedSet = [] 44 | for record in data: 45 | templist = [] 46 | for row in record["data"]: 47 | if int(row["rwt"]) == lookup[rw]: 48 | templist.append(int(row[column])) 49 | unmergedSet.append(templist) 50 | if column == "value": 51 | oper = getMergeOperation(datatype) 52 | else: 53 | oper = getMergeOperation(column) 54 | merged = [oper(x) for x in zip(*unmergedSet)] 55 | mergedSet[rw].append(merged) 56 | mergedSet[rw] = list(zip(*mergedSet[rw])) 57 | return mergedSet 58 | 59 | 60 | -------------------------------------------------------------------------------- /fio_plot/fiolib/defaultsettings.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | def get_default_settings(): 4 | settings = {} 5 | settings["disable_grid"] = False 6 | settings["enable_markers"] = False 7 | settings["subtitle"] = None 8 | settings["maxdepth"] = 64 9 | settings["maxjob"] = 64 10 | settings["filter"] = ['read','write'] 11 | settings["type"] = [] 12 | settings["dpi"] = 200 13 | settings["percentile"] = 99.99 14 | settings["moving_average"] = None 15 | settings["max_z"] = None 16 | settings["min_lat"] = 0 17 | settings["min_iops"] = 0 18 | settings["truncate_xaxis"] = None 19 | settings["xlabel_depth"] = 0 20 | settings["xlabel_parent"] = 1 21 | settings["xlabel_segment_size"] = 1000 22 | settings["xlabel_single_column"] = False 23 | settings["line_width"] = 1 24 | settings["group_bars"] = False 25 | settings["show_cpu"] = False 26 | settings["show_data"] = False 27 | settings["show_ss"] = False 28 | settings["table_lines"] = False 29 | settings["max_lat"] = None 30 | settings["max_clat"] = None 31 | settings["max_slat"] = None 32 | settings["max_iops"] = None 33 | settings["max_bw"] = None 34 | settings["draw_total"] = False 35 | settings["colors"] = [None] 36 | settings["disable_fio_version"] = False 37 | settings["title_fontsize"] = 16 38 | settings["subtitle_fontsize"] = 10 39 | settings["source_fontsize"] = 8 40 | settings["credit_fontsize"] = 10 41 | settings["table_fontsize"] = 8 42 | settings["tablecolumn_spacing"] = 0.01 43 | settings["include_hosts"] = None 44 | settings["exclude_hosts"] = None 45 | settings["colors"] = None 46 | return settings 47 | 48 | def get_graphtype(settings): 49 | graphtypes = 'bargraph3d','bargraph2d_qd','bargraph2d_nj','histogram','loggraph','compare_graph' 50 | for x in graphtypes: 51 | if settings[x]: 52 | return x 53 | print("\n None of the graphtypes is enabled, this is probably a bug.\n") 54 | sys.exit(1) 55 | -------------------------------------------------------------------------------- /fio_plot/fiolib/flightchecks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides many different checks before image is generated. 3 | Checks mostly conflicting or missing settings. 4 | """ 5 | import sys 6 | import os 7 | import matplotlib 8 | 9 | 10 | def check_matplotlib_version(requiredversion): 11 | matplotlibversion = matplotlib.__version__ 12 | from pkg_resources import parse_version as V # nice 13 | 14 | if V(matplotlibversion) < V(requiredversion): 15 | print( 16 | f"Matplotlib version {requiredversion} is required but version {matplotlibversion} is installed." 17 | ) 18 | print("I'm sorry, but you'll have to update matplotlib.") 19 | sys.exit(1) 20 | 21 | 22 | def check_if_target_directory_exists(dirpath): 23 | for directory in dirpath: 24 | if not os.path.exists(directory): 25 | print(f"\nDirectory {directory} is not found.\n") 26 | sys.exit(1) 27 | elif not os.path.isdir(directory): 28 | print(f"\nDirecory {directory} is not a directory.\n") 29 | sys.exit(1) 30 | 31 | 32 | def run_preflight_checks(settings): 33 | """This a very large function with all kinds of business logic checks. 34 | I don't have a good idea to clean this up yet, if that is possible.""" 35 | check_matplotlib_version("3.3.0") 36 | check_if_target_directory_exists(settings["input_directory"]) 37 | 38 | if settings["graphtype"] == "loggraph" and not settings["type"]: 39 | print( 40 | "\nIf -g is specified, you must specify the type of data with -t (see help)\n" 41 | ) 42 | sys.exit(1) 43 | try: 44 | if settings["type"][0]: 45 | if ( 46 | not settings["graphtype"] == "loggraph" 47 | and not settings["graphtype"] == "bargraph3d" 48 | ): 49 | print("\n The -t parameter only works with -g or -L style graphs\n") 50 | sys.exit(1) 51 | except TypeError: 52 | pass 53 | 54 | if settings["graphtype"] == "bargraph3d": 55 | if not settings["type"]: 56 | print("\nIf -L is specified (3D Chart) you must specify -t (iops or lat)\n") 57 | sys.exit(1) 58 | 59 | if settings["type"][0] not in ["iops", "lat", "bw"]: 60 | print( 61 | "\nIf -L is specified (3D Chart) you can only select [iops,lat,bw] for -t type\n" 62 | ) 63 | sys.exit(1) 64 | 65 | if len(settings["input_directory"]) > 1: 66 | print("\nIf -L is specified, only one input directory can be used.\n") 67 | sys.exit(1) 68 | 69 | if settings["graphtype"] == "compare_graph": 70 | message = "\nWhen creating a graph to compare values, iodepth or numjobs must be one value.\n" 71 | pm = False 72 | 73 | if settings["iodepth"]: 74 | if len(settings["iodepth"]) > 1: 75 | pm = True 76 | if settings["numjobs"]: 77 | if len(settings["numjobs"]) > 1: 78 | pm = True 79 | if pm: 80 | print(message) 81 | sys.exit(1) 82 | 83 | if len(settings["input_directory"]) < 2: 84 | print( 85 | "\n When you want to compare two datasets, please specify at least two directories with test data \ 86 | using the -i parameter\n" 87 | ) 88 | sys.exit(1) 89 | 90 | if settings["graphtype"] == "bargraph2d_qd": 91 | if len(settings["input_directory"]) > 1: 92 | print("\nIf -l is specified, only one input directory can be used.\n") 93 | sys.exit(1) 94 | 95 | if settings["numjobs"]: 96 | if len(settings["numjobs"]) > 1: 97 | print( 98 | "\n This graph type only supports one particular value for the numjobs parameter. \n \ 99 | Use the 3D graph type (-L) to plot both iodepth and numjobs for either iops or latency.\n" 100 | ) 101 | sys.exit(1) 102 | 103 | if settings["graphtype"] == "bargraph2d_nj": 104 | if len(settings["input_directory"]) > 1: 105 | print("\nIf -l is specified, only one input directory can be used.\n") 106 | sys.exit(1) 107 | 108 | if settings["iodepth"]: 109 | if len(settings["iodepth"]) > 1: 110 | print( 111 | "\n This graph type only supports one particular value for the iodepth parameter. \n \ 112 | Use the 3D graph type (-L) to plot both iodepth and numjobs for either iops or latency.\n" 113 | ) 114 | sys.exit(1) 115 | 116 | if settings["graphtype"] == "histogram": 117 | if len(settings["input_directory"]) > 1: 118 | print("\nIf -l is specified, only one input directory can be used.\n") 119 | sys.exit(1) 120 | 121 | if settings["show_ss"] and settings["show_cpu"]: 122 | print( 123 | "\nYou have to choose between either --show-ss or --show-cpu, you can't display both.\n" 124 | ) 125 | sys.exit(1) 126 | 127 | if ( 128 | not ( 129 | settings["graphtype"] == "bargraph2d_qd" 130 | or settings["graphtype"] == "bargraph2d_nj" 131 | ) 132 | and settings["show_ss"] 133 | ): 134 | print( 135 | "\nThe --show-ss option only works with the 2D bar chart -l or -N graph.\n" 136 | ) 137 | sys.exit(1) 138 | try: 139 | if settings["colors"][0] and not settings["graphtype"] == "loggraph": 140 | print("\nThe --colors option can only be used with the -g 2D line graph.\n") 141 | sys.exit(1) 142 | except TypeError: 143 | pass 144 | 145 | if ( 146 | settings["rw"] == "rw" 147 | and len(settings["filter"]) > 1 148 | and not settings["loggraph"] 149 | ): 150 | print("\n if -r rw is specified, please specify a filter -f read or -f write\n") 151 | sys.exit(1) 152 | 153 | if settings["rw"] == "randrw": 154 | if not settings["filter"][0]: 155 | print( 156 | "When processing randrw data, a -f filter (read/write) must also be specified." 157 | ) 158 | sys.exit(1) 159 | 160 | if not settings["filter"][0]: 161 | print(f"\nNo filter parameter is set, by default it sould be 'read,write'.\n") 162 | sys.exit(1) 163 | 164 | if not len(settings["filter"]) == 2 and settings["draw_total"]: 165 | print( 166 | f'\n When --draw-total is specified, "-f read write" (default) must be specified. \n' 167 | ) 168 | sys.exit(1) 169 | 170 | 171 | def post_flight_check(parser, option_found): 172 | if not option_found: 173 | parser.print_help() 174 | print("Specify -g, -l, -L, -C or -H") 175 | exit(1) 176 | else: 177 | exit(0) 178 | -------------------------------------------------------------------------------- /fio_plot/fiolib/getdata.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | from . import( 4 | dataimport as logdata, 5 | graph2d as graph, 6 | jsonimport, 7 | bar2d, 8 | bar3d, 9 | barhistogram as histogram, 10 | jsonparsing 11 | ) 12 | 13 | 14 | def configure_default_settings(settings, routing_dict, key): 15 | if not settings["iodepth"]: 16 | settings["iodepth"] = routing_dict[key]["iodepth_default"] 17 | if not settings["numjobs"]: 18 | settings["numjobs"] = routing_dict[key]["numjobs_default"] 19 | settings["query"] = routing_dict[key]["query"] 20 | settings["label"] = routing_dict[key]["label"] 21 | return settings 22 | 23 | 24 | def get_log_data(settings): 25 | if not settings["iodepth"]: 26 | settings["iodepth"] = [1] 27 | if not settings["numjobs"]: 28 | settings["numjobs"] = [1] 29 | 30 | benchmarkfiles = [] 31 | for input_dir in settings["input_directory"]: 32 | benchmarkfiles.extend(logdata.list_fio_log_files(input_dir)) 33 | logfiles = logdata.filterLogFiles(settings, benchmarkfiles) 34 | #pprint.pprint(logfiles) 35 | rawdata = logdata.readLogDataFromFiles(settings, logfiles) 36 | #pprint.pprint(rawdata) 37 | merged = logdata.mergeDataSet(settings, rawdata) 38 | #[ print(merged[x]["data"]["hostname"]) for x in range(len(merged))] 39 | #print(merged) 40 | #print(len(merged)) 41 | return merged 42 | 43 | 44 | def get_json_data(settings): 45 | list_of_json_files = jsonimport.list_json_files(settings) 46 | #pprint.pprint(list_of_json_files) 47 | dataset = jsonimport.import_json_dataset(settings, list_of_json_files) 48 | parsed_data = jsonparsing.parse_json_data(settings, dataset) 49 | #pprint.pprint(parsed_data[0]["data"]) 50 | return parsed_data 51 | 52 | 53 | def get_routing_dict(): 54 | routing_dict = { 55 | "loggraph": { 56 | "function": graph.chart_2d_log_data, 57 | "get_data": get_log_data, 58 | "iodepth_default": [1], 59 | "numjobs_default": [1], 60 | "query": None, 61 | "label": None, 62 | }, 63 | "bargraph3d": { 64 | "function": bar3d.plot_3d, 65 | "get_data": get_json_data, 66 | "iodepth_default": [1, 2, 4, 8, 16, 32, 64], 67 | "numjobs_default": [1, 2, 4, 8, 16, 32, 64], 68 | "query": None, 69 | "label": None, 70 | }, 71 | "bargraph2d_qd": { 72 | "function": bar2d.chart_2dbarchart_jsonlogdata, 73 | "get_data": get_json_data, 74 | "iodepth_default": [1, 2, 4, 8, 16, 32, 64], 75 | "numjobs_default": [1], 76 | "query": "iodepth", 77 | "label": "Queue depth", 78 | }, 79 | "bargraph2d_nj": { 80 | "function": bar2d.chart_2dbarchart_jsonlogdata, 81 | "get_data": get_json_data, 82 | "iodepth_default": [1], 83 | "numjobs_default": [1, 2, 4, 8, 16, 32, 64], 84 | "query": "numjobs", 85 | "label": "Number of jobs", 86 | }, 87 | "histogram": { 88 | "function": histogram.chart_latency_histogram, 89 | "get_data": get_json_data, 90 | "iodepth_default": [1], 91 | "numjobs_default": [1], 92 | "query": None, 93 | "label": None, 94 | }, 95 | "compare_graph": { 96 | "function": bar2d.compchart_2dbarchart_jsonlogdata, 97 | "get_data": get_json_data, 98 | "iodepth_default": [1], 99 | "numjobs_default": [1], 100 | "query": None, 101 | "label": None, 102 | }, 103 | } 104 | return routing_dict 105 | -------------------------------------------------------------------------------- /fio_plot/fiolib/graph2d.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import matplotlib.pyplot as plt 3 | import matplotlib.markers as markers 4 | import pprint 5 | 6 | from matplotlib.font_manager import FontProperties 7 | 8 | from . import ( 9 | supporting, 10 | dataimport as logdata, 11 | graph2dsupporting as support2d, 12 | ) 13 | 14 | def make_patch_spines_invisible(ax): 15 | ax.set_frame_on(True) 16 | ax.patch.set_visible(False) 17 | for sp in ax.spines.values(): 18 | sp.set_visible(False) 19 | 20 | 21 | def chart_2d_log_data(settings, dataset): 22 | # 23 | # Raw data must be processed into series data + enriched 24 | # 25 | data = supporting.process_dataset(settings, dataset) 26 | datatypes = data["datatypes"] 27 | #print(data) 28 | # 29 | # Create matplotlib figure and first axis. The 'host' axis is used for 30 | # x-axis and as a basis for the second and third y-axis 31 | # 32 | fig, host = plt.subplots() 33 | fig.set_size_inches(9, 5) 34 | plt.margins(0) 35 | # 36 | # Generates the axis for the graph with a maximum of 3 axis (per type of 37 | # iops,lat,bw) 38 | # 39 | axes = supporting.generate_axes(host, datatypes) 40 | # 41 | # We try to retrieve the fio version and benchmark block size from 42 | # the JSON data 43 | # 44 | jsondata = support2d.get_json_data(settings) 45 | # 46 | # Create title and subtitle 47 | # 48 | if jsondata[0]["data"]: 49 | if "job options" in jsondata[0]["data"][0].keys(): 50 | blocksize = jsondata[0]["data"][0]["job options"]["bs"] 51 | elif "bs" in jsondata[0]["data"][0].keys(): 52 | blocksize = jsondata[0]["data"][0]["bs"] 53 | else: 54 | blocksize = "Please report bug at github.com/louwrentius/fio-plot" 55 | else: 56 | blocksize = None 57 | supporting.create_title_and_sub(settings, bs=blocksize, plt=plt) 58 | 59 | # 60 | # The extra offsets are requred depending on the size of the legend, which 61 | # in turn depends on the number of legend items. 62 | # 63 | 64 | if settings["colors"]: 65 | support2d.validate_colors(settings["colors"]) 66 | 67 | extra_offset = ( 68 | len(datatypes) 69 | * len(settings["iodepth"]) 70 | * len(settings["numjobs"]) 71 | * len(settings["filter"]) 72 | ) 73 | 74 | bottom_offset = 0.18 + (extra_offset / 120) 75 | if "bw" in datatypes and (len(datatypes) > 2): 76 | # 77 | # If the third y-axis is enabled, the graph is ajusted to make room for 78 | # this third y-axis. 79 | # 80 | fig.subplots_adjust(left=0.21) 81 | 82 | try: 83 | fig.subplots_adjust(bottom=bottom_offset) 84 | except ValueError as v: 85 | print(f"\nError: {v} - probably too many lines in the graph.\n") 86 | sys.exit(1) 87 | 88 | ## Get axix limits 89 | 90 | supportdata = { 91 | "lines": [], 92 | "labels": [], 93 | "colors": support2d.get_colors(settings), 94 | "marker_list": list(markers.MarkerStyle.markers.keys()), 95 | "fontP": FontProperties(family="monospace"), 96 | "maximum": supporting.get_highest_maximum(settings, data), 97 | "axes": axes, 98 | "host": host, 99 | "maxlabelsize": support2d.get_max_label_size(settings, data), 100 | } 101 | 102 | supportdata["fontP"].set_size("xx-small") 103 | 104 | # 105 | # Converting the data and drawing the lines 106 | # 107 | for item in data["dataset"]: 108 | for rw in settings["filter"]: 109 | if isinstance(item[rw], dict): 110 | if supporting.filter_hosts(settings, item): 111 | support2d.drawline(settings, item, rw, supportdata) 112 | 113 | # 114 | # Generating the legend 115 | # 116 | values, ncol = support2d.generate_labelset(settings, supportdata) 117 | 118 | host.legend( 119 | supportdata["lines"], 120 | values, 121 | prop=supportdata["fontP"], 122 | bbox_to_anchor=(0.5, -0.18), 123 | loc="upper center", 124 | ncol=ncol, 125 | frameon=False, 126 | ) 127 | 128 | def get_axis_for_label(axes): 129 | axis = list(axes.keys())[0] 130 | ax = axes[axis] 131 | return ax 132 | 133 | # 134 | # A ton of work to get the Fio-version from .json output if it exists. 135 | # 136 | ax = get_axis_for_label(axes) 137 | if jsondata[0]["data"] and not settings["disable_fio_version"]: 138 | fio_version = jsondata[0]["data"][0]["fio_version"] 139 | supporting.plot_fio_version(settings, fio_version, plt, ax, -0.16) 140 | else: 141 | supporting.plot_fio_version(settings, None, plt, ax, -0.16) 142 | 143 | # 144 | # Print source 145 | # 146 | ax = get_axis_for_label(axes) 147 | supporting.plot_source(settings, plt, ax, -0.12) 148 | 149 | # 150 | # Save graph to PNG file 151 | # 152 | supporting.save_png(settings, plt, fig) 153 | -------------------------------------------------------------------------------- /fio_plot/fiolib/graph2dsupporting.py: -------------------------------------------------------------------------------- 1 | import matplotlib.patches as mpatches 2 | import matplotlib.colors as mcolors 3 | import sys 4 | 5 | from . import ( 6 | jsonimport, 7 | jsonparsing, 8 | supporting 9 | ) 10 | 11 | # 12 | # These functions below is just one big mess to get the legend labels to align. 13 | # 14 | def get_json_data(settings): 15 | list_of_json_files = jsonimport.list_json_files(settings, fail=False) 16 | if list_of_json_files: 17 | dataset = jsonimport.import_json_dataset(settings, list_of_json_files) 18 | parsed_data = jsonparsing.parse_json_data(settings, dataset) 19 | return parsed_data 20 | else: 21 | return None 22 | 23 | 24 | def create_label(settings, item): 25 | """ 26 | The label must be unique. With client/server data, when using 27 | multiple source folders, the labels may not be unique by default. 28 | """ 29 | if item["hostname"]: 30 | if len(settings["input_directory"]) > 1 and \ 31 | (settings["xlabel_parent"] == 1 and settings["xlabel_depth"] == 0): 32 | print("WARNING: legend labels are not unique per input directory, use --xlabel-parent and --xlabel-depth to ajust.") 33 | 34 | if len(settings["input_directory"]) > 1 and \ 35 | (settings["xlabel_parent"] != 1 \ 36 | or settings["xlabel_depth"] != 0): 37 | mydir = f"{item['directory']}-{item['hostname']}" 38 | else: 39 | mydir = item["hostname"] 40 | else: 41 | mydir = f"{item['directory']}" 42 | return mydir 43 | 44 | 45 | def get_max_label_size(settings, data): 46 | labels = [] 47 | for item in data["dataset"]: 48 | for rw in settings["filter"]: 49 | if rw in item.keys(): 50 | label = create_label(settings, item) 51 | labels.append(label) 52 | 53 | maxlabelsize = 0 54 | for label in labels: 55 | size = len(label) 56 | if size > maxlabelsize: 57 | maxlabelsize = size 58 | 59 | return maxlabelsize 60 | 61 | 62 | def get_padding(label, maxlabelsize): 63 | size = len(label) 64 | diff = maxlabelsize - size 65 | if diff > 0: 66 | label = label + " " * diff 67 | return label 68 | 69 | 70 | def scale_2dgraph_yaxis(settings, item, rw, maximum): 71 | factordict = {"iops": 1.05, "lat": 1.25, "bw": 1.5, "slat": 1.25, "clat": 1.25 } 72 | 73 | max_y = maximum["total"][item["type"]] * factordict[item["type"]] 74 | 75 | if settings[f"max_{item['type']}"]: 76 | max_y = settings[f"max_{item['type']}"] 77 | 78 | return (0, max_y) 79 | 80 | 81 | def validate_colors(colors): 82 | listofcolors = [] 83 | listofcolors.extend(list(mcolors.TABLEAU_COLORS.keys())) 84 | listofcolors.extend(list(mcolors.CSS4_COLORS.keys())) 85 | listofcolors.extend(list(mcolors.XKCD_COLORS.keys())) 86 | listofcolors.extend(list(mcolors.BASE_COLORS.keys())) 87 | 88 | for color in colors: 89 | if color not in listofcolors: 90 | print( 91 | f"\n Color {color} is not a known color. Please check the spelling.\n" 92 | ) 93 | sys.exit(1) 94 | 95 | def get_color(settings, supportdata): 96 | try: 97 | color = supportdata["colors"].pop(0) 98 | except IndexError: 99 | print( 100 | "\nThere are more lines to draw than there are colors specified. If you used the --colors option: " 101 | "please specify more colors or remove the --colors parameter.\n" 102 | ) 103 | sys.exit(1) 104 | return color 105 | 106 | 107 | def get_colors(settings): 108 | cm = settings["colors"] 109 | if cm: 110 | return cm 111 | else: 112 | colorlist = [] 113 | colorlist.extend(list(mcolors.TABLEAU_COLORS.keys())) 114 | colorlist.extend(list(mcolors.XKCD_COLORS.keys())) 115 | return colorlist 116 | 117 | 118 | def drawline(settings, item, rw, supportdata): 119 | axes = supportdata["axes"] 120 | if settings["enable_markers"]: 121 | marker_value = supportdata["marker_list"].pop(0) 122 | else: 123 | marker_value = None 124 | 125 | if settings["truncate_xaxis"]: 126 | xvalues = item[rw]["xvalues"][:settings["truncate_xaxis"]] 127 | yvalues = item[rw]["yvalues"][:settings["truncate_xaxis"]] 128 | else: 129 | xvalues = item[rw]["xvalues"] 130 | yvalues = item[rw]["yvalues"] 131 | 132 | # 133 | # Use a moving average as configured by the commandline option 134 | # to smooth out the graph for better readability. 135 | # 136 | if settings["moving_average"]: 137 | yvalues = supporting.running_mean(yvalues, settings["moving_average"]) 138 | # 139 | # Plotting the line 140 | # 141 | 142 | dataplot = f"{item['type']}_plot" 143 | color = get_color(settings, supportdata) 144 | axes[dataplot] = axes[item["type"]].plot( 145 | xvalues, 146 | yvalues, 147 | marker=marker_value, 148 | markevery=(len(yvalues) / (len(yvalues) * 10)), 149 | color=color, 150 | label=item[rw]["ylabel"], 151 | linewidth=settings["line_width"], 152 | )[0] 153 | supportdata["host"].set_xlabel(item["xlabel"]) 154 | # 155 | # Set minimum and maximum values for y-axis where applicable. 156 | # 157 | limits = scale_2dgraph_yaxis(settings, item, rw, supportdata["maximum"]) 158 | axes[item["type"]].set_ylim(limits) 159 | # 160 | # Label Axis 161 | # 162 | padding = axes[f"{item['type']}_pos"] 163 | axes[item["type"]].set_ylabel(item[rw]["ylabel"], labelpad=padding) 164 | # 165 | # Add line to legend 166 | # 167 | supportdata["lines"].append(axes[dataplot]) 168 | create_single_label(settings, item, rw, supportdata) 169 | 170 | 171 | def create_single_label(settings, item, rw, supportdata): 172 | # print(maxlabelsize) 173 | mylabel = create_label(settings, item) 174 | mylabel = get_padding(mylabel, supportdata["maxlabelsize"]) 175 | labelset = { 176 | "name": mylabel, 177 | "rw": rw, 178 | "type": item["type"], 179 | "qd": item["iodepth"], 180 | "nj": item["numjobs"], 181 | "mean": item[rw]["mean"], 182 | "std%": item[rw]["stdv"], 183 | f"P{settings['percentile']}": item[rw]["percentile"], 184 | } 185 | # pprint.pprint(labelset) 186 | supportdata["labels"].append(labelset) 187 | 188 | 189 | def generate_labelset(settings, supportdata): 190 | master_padding = { 191 | "name": 0, 192 | "rw": 5, 193 | "type": 4, 194 | "qd": 2, 195 | "nj": 2, 196 | "mean": 0, 197 | "std%": 0, 198 | f"P{settings['percentile']}": 0, 199 | } 200 | 201 | for label in supportdata["labels"]: 202 | for key in label.keys(): 203 | label_length = len(str(label[key])) 204 | master_length = master_padding[key] 205 | if label_length > master_length: 206 | master_padding[key] = label_length 207 | if label_length % 2 != 0: 208 | master_padding[key] = master_padding[key] + 1 209 | 210 | red_patch = mpatches.Patch(color="white", label="Just filler") 211 | supportdata["lines"].insert(0, red_patch) 212 | header = "" 213 | 214 | values = [] 215 | for item in supportdata["labels"]: 216 | line = "" 217 | for key in item.keys(): 218 | line += f"| {item[key]:>{master_padding[key]}} " 219 | values.append(line) 220 | 221 | for column in master_padding.keys(): 222 | size = master_padding[column] 223 | header = f"{header}| {column:<{size}} " 224 | values.insert(0, header) 225 | 226 | ncol = 1 227 | if len(values) > 3 and not settings["xlabel_single_column"]: 228 | ncol = 2 229 | number = len(values) 230 | position = int(number / 2) + 1 231 | supportdata["lines"].insert(position, red_patch) 232 | values.insert(position, header) 233 | 234 | return (values, ncol) 235 | 236 | -------------------------------------------------------------------------------- /fio_plot/fiolib/iniparsing.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from . import iniparsing_support as inisupport 4 | 5 | 6 | def parse_ini_data(config): 7 | """ 8 | This function is atrocious but at this time I haven't found a better, cleaner solution yet. 9 | Maybe all the parameters should be stored in a configuration file that specifies attribute name, type and default value. 10 | """ 11 | listtypes = ['input_directory','filter','colors','type'] 12 | listinttypes = ['iodepth','numjobs'] 13 | integers = ['maxdepth','maxjobs','dpi','max_z','max_lat','max_iops','min_lat','min_iops','max_bw','xlabel_depth','xlabel_parent','xlabel_segment_size','line_width','source_fontsize','subtitle_fontsize','title_fontsize'] 14 | floats = ['percentile'] 15 | booltypes = ['show_cpu','show_ss','table_lines','disable_grid','enable_markers','disable_fio_version','moving_average'] 16 | returndict = {} 17 | for x in ['graphtype', 'settings', 'layout']: 18 | for y in config[x]: 19 | if y in listtypes: 20 | try: 21 | returndict[y] = config.getlist(x,y) 22 | except ValueError: 23 | returndict[y] = None 24 | elif y in listinttypes: 25 | returndict[y] = [ int(item) for item in config.getlist(x,y)] 26 | elif y in integers: 27 | try: 28 | returndict[y] = config.getint(x,y) 29 | except ValueError: 30 | returndict[y] = None 31 | elif y in floats: 32 | try: 33 | returndict[y] = config.getfloat(x,y) 34 | except ValueError: 35 | returndict[y] = None 36 | elif y in booltypes: 37 | try: 38 | returndict[y] = config.getboolean(x,y) 39 | except ValueError: 40 | returndict[y] = None 41 | else: 42 | returndict[y] = config[x][y] 43 | cleaned_dict = inisupport.cleanup_dictionary(returndict) 44 | return cleaned_dict 45 | 46 | def get_settings_from_ini(args): 47 | filename = inisupport.get_ini_filename(args) 48 | inidata = None 49 | if filename: 50 | config = inisupport.read_ini_file(filename) 51 | inidata = parse_ini_data(config) 52 | return inidata -------------------------------------------------------------------------------- /fio_plot/fiolib/iniparsing_support.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import configparser 3 | from pathlib import Path 4 | 5 | 6 | def read_ini_file(filename): 7 | """Read INI file and return data""" 8 | config = configparser.ConfigParser( 9 | converters={"list": lambda x: [i.strip() for i in x.split(",")]} 10 | ) 11 | path = Path(filename) 12 | if path.exists(): 13 | if path.is_file(): 14 | try: 15 | config.read(filename) 16 | return config 17 | except configparser.DuplicateOptionError as e: 18 | print(f"\n{e}\n") 19 | sys.exit(1) 20 | else: 21 | print(f"\nConfig file {filename} is not a file.\n") 22 | sys.exit(1) 23 | else: 24 | print(f"\nConfig file {filename} does not exist\n") 25 | sys.exit(1) 26 | 27 | 28 | def get_ini_filename(args): 29 | filename = None 30 | if len(args) > 1: 31 | if not "-" in args[1][0]: 32 | filename = args[1] 33 | return filename 34 | 35 | 36 | def cleanup_dictionary(returndict): 37 | cleaned_dict = remove_none_values_from_dict(returndict) 38 | cleaned_dict = remove_lists_with_empty_strings_from_dict(cleaned_dict) 39 | return cleaned_dict 40 | 41 | 42 | def remove_none_values_from_dict(returndict): 43 | cleaned_dict = {} 44 | for k, v in returndict.items(): 45 | validated = True 46 | if v is None: 47 | validated = False 48 | else: 49 | if isinstance(v, str): 50 | if len(v) == 0: 51 | validated = False 52 | if validated: 53 | cleaned_dict[k] = returndict[k] 54 | return cleaned_dict 55 | 56 | 57 | def remove_lists_with_empty_strings_from_dict(returndict): 58 | """ 59 | When parsing the INI file, a variable like 'colors' is a list of strings. 60 | Unfortunately the default return value is a list containing a single empty string. 61 | This function is just to clean this up and return None 62 | """ 63 | cleaned_dict = {} 64 | for k, v in returndict.items(): 65 | validated = True 66 | if isinstance(v, list): 67 | if len(v) == 1: 68 | if isinstance(v[0], str): 69 | if len(v[0]) == 0: 70 | # print(k) 71 | validated = False 72 | if validated: 73 | cleaned_dict[k] = v 74 | else: 75 | cleaned_dict[k] = None 76 | return cleaned_dict 77 | -------------------------------------------------------------------------------- /fio_plot/fiolib/jsonimport.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | def validate_json_file(settings, jsondata): 9 | valid = False 10 | keysfound = 0 11 | minimumkeys = 2 12 | validkeys = ["fio version", "global options", "client_stats", "jobs"] 13 | for key in validkeys: 14 | if key in jsondata.keys(): 15 | keysfound += 1 16 | if keysfound >= minimumkeys: 17 | valid = True 18 | return valid 19 | 20 | def filter_json_files(settings, filename): 21 | """A bit of a slow process, but guarantees that we get legal 22 | json files regardless of their names""" 23 | iodepth = None 24 | numjobs = None 25 | with open(filename, 'r') as candidate_file: 26 | try: 27 | candidate_json = json.load(candidate_file) 28 | if validate_json_file(settings, candidate_json): 29 | if "client_stats" in candidate_json.keys(): 30 | job_options = candidate_json["client_stats"][0]["job options"] 31 | elif "global options" in candidate_json.keys(): 32 | job_options = candidate_json["jobs"][0]["job options"] 33 | job_options.update(candidate_json["global options"]) 34 | else: 35 | job_options = candidate_json["jobs"][0]["job options"] 36 | if job_options["rw"] == settings["rw"]: 37 | iodepth = int(job_options["iodepth"]) 38 | numjobs = int(job_options["numjobs"]) 39 | else: 40 | logger.debug(f"{filename} does not appear to be a valid fio json output file, skipping") 41 | except Exception as e: 42 | print(f"\n\nFilename: {filename}") 43 | print(f"Error: {repr(e)}\n") 44 | print("First, open the file and check for errors at the top.\nYou can remove the error lines and the JSON will likely parse\nbut results may not be trustworthy.\nIf there are no error linkes at the top, please report this as a bug\nand please include the JSON file if possible.\n\n") 45 | sys.exit(1) 46 | if iodepth in settings["iodepth"] and numjobs in settings["numjobs"]: 47 | return filename 48 | # else means this file is valid but doesn't match iodepth/numjobs so no else statement 49 | 50 | def list_json_files(settings, fail=True): 51 | """List all JSON files that maches the command line settings.""" 52 | input_directories = [] 53 | for directory in settings["input_directory"]: 54 | absolute_dir = os.path.abspath(directory) 55 | input_dir_struct = {"directory": absolute_dir, "files": []} 56 | input_dir_files = os.listdir(absolute_dir) 57 | for file in input_dir_files: 58 | if file.endswith(".json"): 59 | input_dir_struct["files"].append(os.path.join(absolute_dir, file)) 60 | input_directories.append(input_dir_struct) 61 | 62 | for directory in input_directories: 63 | file_list = [] 64 | for file in directory["files"]: 65 | result = filter_json_files(settings, file) 66 | if result: 67 | file_list.append(result) 68 | 69 | directory["files"] = sorted(file_list) 70 | if not directory["files"] and fail: 71 | print( 72 | f"\nCould not find any (matching) JSON files in the specified directory {str(absolute_dir)}\n" 73 | ) 74 | print("Are the correct directories specified?\n") 75 | print( 76 | f"If so, please check the -d ({settings['iodepth']}) -n ({settings['numjobs']}) and -r ({settings['rw']}) parameters.\n" 77 | ) 78 | sys.exit(1) 79 | return input_directories 80 | 81 | 82 | def import_json_data(filename): 83 | """Returns a dictionary of imported JSON data.""" 84 | with open(filename) as json_data: 85 | try: 86 | d = json.load(json_data) 87 | except json.decoder.JSONDecodeError: 88 | print(f"Failed to JSON parse {filename}") 89 | sys.exit(1) 90 | return d 91 | 92 | 93 | def import_json_dataset(settings, dataset): 94 | """ 95 | The dataset is a list of dicts containing the absolute path and the file list. 96 | We need to add a third key/value pair with the raw ingested data of those files. 97 | """ 98 | for item in dataset: 99 | item["rawdata"] = [] 100 | for f in item["files"]: 101 | item["rawdata"].append(import_json_data(f)) 102 | return dataset 103 | 104 | 105 | -------------------------------------------------------------------------------- /fio_plot/fiolib/jsonparsing.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from . import ( 3 | jsonparsing_support as jsonsupport 4 | ) 5 | 6 | 7 | def printkeys(data, depth=0, maxdepth=3): 8 | """ 9 | For debugging only 10 | """ 11 | if depth <= maxdepth: 12 | if isinstance(data, dict): 13 | for key,value in data.items(): 14 | print(f"{'-' * depth} {key}") 15 | printkeys(value, depth+1) 16 | elif isinstance(data, list): 17 | for item in data: 18 | printkeys(item, depth+1) 19 | 20 | 21 | 22 | def get_json_root_path(record): 23 | rootpath = None 24 | keys = record.keys() 25 | if "jobs" in keys: 26 | rootpath = "jobs" 27 | if "client_stats" in keys: 28 | rootpath = "client_stats" 29 | if rootpath is None: 30 | print("\nNo valid JSON root path found, this should never happen.\n") 31 | return rootpath 32 | 33 | 34 | def get_json_global_options(record): 35 | options = {} 36 | if "global options" in record.keys(): 37 | options = record["global options"] 38 | return options 39 | 40 | 41 | def sort_list_of_dictionaries(data): 42 | sortedlist = sorted(data, key=lambda k: (int(k["iodepth"]), int(k["numjobs"]))) 43 | return sortedlist 44 | 45 | 46 | def process_json_record(settings, directory, record, jsonrootpath, globaloptions): 47 | joboptions = None 48 | hosts = {} 49 | jobs = [] 50 | for job in record[jsonrootpath]: 51 | # This section is just to deal with the "All clients" job included in 52 | # client / server JSON output 53 | # 54 | if job["jobname"] != "All clients": 55 | job["job options"] = {**job["job options"], **globaloptions} 56 | if not joboptions: 57 | joboptions = job["job options"] 58 | else: 59 | job["job options"] = joboptions 60 | job["hostname"] = "All clients" 61 | # 62 | # End of section 63 | # 64 | if jsonsupport.check_for_valid_hostname(job): 65 | hostname = job["hostname"] 66 | if hostname not in hosts.keys(): 67 | hosts[hostname] = [] 68 | row = jsonsupport.return_data_row(settings, job) 69 | row["fio_version"] = record["fio version"] 70 | if hosts: 71 | hosts[hostname].append(row) 72 | else: 73 | jobs.append(row) 74 | 75 | directory["data"].extend(jsonsupport.merge_job_data_hosts_jobs(settings, hosts, jobs)) 76 | 77 | 78 | def parse_json_data(settings, dataset): 79 | """ 80 | This funcion traverses the relevant JSON structure to gather data 81 | and store it in a flat dictionary. We do this for each imported json file. 82 | """ 83 | for directory in dataset: # for directory in list of directories 84 | directory["data"] = [] 85 | for record in directory["rawdata"]: # each record is the raw JSON data of a file in a directory 86 | jsonrootpath = get_json_root_path(record) 87 | globaloptions = get_json_global_options(record) 88 | #for item in record["client_stats"]: 89 | # if "job options" in item.keys(): 90 | # print(item["job options"]["iodepth"]) 91 | process_json_record(settings, directory, record, jsonrootpath, globaloptions) 92 | #print("================================") 93 | #print(directory["data"]) 94 | #for directory in dataset: 95 | # for item in directory["data"]: 96 | # print(item["iodepth"]) 97 | directory["data"] = sort_list_of_dictionaries(directory["data"]) 98 | return dataset 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /fio_plot/fiolib/jsonparsing_support.py: -------------------------------------------------------------------------------- 1 | import statistics 2 | import sys 3 | 4 | def check_for_hostname(record): 5 | keys = record.keys() 6 | if "hostname" in keys: # remember that we merged global options into job options to make them accessible 7 | return True 8 | else: 9 | return False 10 | 11 | def check_for_valid_hostname(record): 12 | result = None 13 | if check_for_hostname(record): 14 | if record["hostname"]: 15 | result = True 16 | else: 17 | result = False 18 | else: 19 | result = False 20 | return result 21 | 22 | def merge_job_filter_hosts(settings, hosts): 23 | """ 24 | The code below is optimized by GPT-4. 25 | """ 26 | included = set(settings.get("include_hosts", []) or []) 27 | excluded = set(settings.get("exclude_hosts", []) or []) 28 | if settings["compare_graph"]: 29 | if not included and not excluded: 30 | print(""" 31 | Please specify an --include-hosts parameter with either 'All clients' 32 | or a hostname as argument (only a single host can be compared). 33 | --include-hosts 'All clients' is likely what you want\n""") 34 | sys.exit(1) 35 | hostlist = included.intersection(hosts) if included else set(hosts) 36 | hostlist -= excluded 37 | return list(hostlist) 38 | 39 | def merge_job_data_from_hosts(settings, hosts): 40 | 41 | result = [] 42 | 43 | hostlist = merge_job_filter_hosts(settings, hosts) 44 | 45 | for host in hostlist: 46 | 47 | iops = [] 48 | bw = [] 49 | lat = [] 50 | 51 | template = { "type": hosts[host][0]["type"], "iodepth": hosts[host][0]["iodepth"], "numjobs": hosts[host][0]["numjobs"], "hostname": host, "fio_version": hosts[host][0]["fio_version"], \ 52 | "rw": hosts[host][0]["rw"], "bs": hosts[host][0]["bs"], "iops_stddev": hosts[host][0]["iops_stddev"],\ 53 | "lat_stddev": hosts[host][0]["lat_stddev"], "cpu_sys": hosts[host][0]["cpu_sys"], "cpu_usr": hosts[host][0]["cpu_usr"] 54 | } 55 | 56 | for job in hosts[host]: 57 | iops.append(job["iops"]) 58 | bw.append(job["bw"]) 59 | lat.append(job["lat"]) 60 | 61 | template["iops"] = sum(iops) 62 | template["bw"] = sum(bw) 63 | template["lat"] = statistics.mean(lat) 64 | result.append(template) 65 | return result 66 | 67 | 68 | def return_data_row(settings, record): 69 | mode = get_record_mode(settings) 70 | data = get_json_mapping(mode, record) 71 | #print("=============") 72 | #print(data) 73 | return data 74 | 75 | def get_record_mode(settings): # any of the rw modes must be translated to read or write 76 | mapping = { 77 | "randrw": settings["filter"][0], 78 | "read": "read", 79 | "write": "write", 80 | "rw": settings["filter"][0], 81 | "readwrite": settings["filter"][0], 82 | "randread": "read", 83 | "randwrite": "write" 84 | } 85 | mode = mapping[settings["rw"]] 86 | return mode 87 | 88 | def get_json_mapping(mode, record): 89 | """This function contains a hard-coded mapping of FIO nested JSON data 90 | to a flat dictionary. 91 | """ 92 | #print(record) 93 | #print(record["job options"].keys()) 94 | dictionary = { 95 | "job options": record["job options"], 96 | "type": mode, 97 | "iodepth": record["job options"]["iodepth"], 98 | "numjobs": record["job options"]["numjobs"], 99 | "bs": record["job options"]["bs"], 100 | "rw": record["job options"]["rw"], 101 | "bw": record[mode]["bw"], 102 | "iops": record[mode]["iops"], 103 | "iops_stddev": record[mode]["iops_stddev"], 104 | "lat": record[mode]["lat_ns"]["mean"], 105 | "lat_stddev": record[mode]["lat_ns"]["stddev"], 106 | "latency_ms": record["latency_ms"], 107 | "latency_us": record["latency_us"], 108 | "latency_ns": record["latency_ns"], 109 | "cpu_usr": record["usr_cpu"], 110 | "cpu_sys": record["sys_cpu"] 111 | 112 | } 113 | 114 | # This is hideous, terrible code, I know. 115 | if check_for_steadystate(record, mode): 116 | dictionary["ss_attained"] = record["steadystate"]["attained"] 117 | dictionary["ss_settings"] = record["job options"]["steadystate"] # remember we merged global options into job options 118 | dictionary["ss_data_bw_mean"] = record["steadystate"]["data"]["bw_mean"] 119 | dictionary["ss_data_iops_mean"] = record["steadystate"]["data"]["iops_mean"] 120 | 121 | else: 122 | dictionary["ss_attained"] = None 123 | dictionary["ss_settings"] = None 124 | dictionary["ss_data_bw_mean"] = None 125 | dictionary["ss_data_iops_mean"] = None 126 | 127 | if check_for_hostname(record): 128 | dictionary["hostname"] = record["hostname"] 129 | 130 | 131 | return dictionary 132 | 133 | def check_for_steadystate(record, mode): 134 | keys = record["job options"].keys() 135 | if "steadystate" in keys: # remember that we merged global options into job options to make them accessible 136 | return True 137 | else: 138 | return False 139 | 140 | def merge_job_data_hosts_jobs(settings, hosts, jobs): 141 | """ 142 | Helper function to forward host data to host function 143 | and job data to job function. 144 | """ 145 | if hosts: 146 | returndata = merge_job_data_from_hosts(settings, hosts) 147 | elif jobs: 148 | returndata = [merge_job_data(jobs)] 149 | return returndata 150 | 151 | 152 | 153 | def merge_job_data(jobs): 154 | iops = [] 155 | bw = [] 156 | lat = [] 157 | cpu_usr = [] 158 | cpu_sys = [] 159 | iops_stddev = [] 160 | lat_stddev = [] 161 | latency_ms = [] 162 | latency_us = [] 163 | latency_ns = [] 164 | 165 | template = { "type": jobs[0]["type"], "iodepth": jobs[0]["iodepth"], "numjobs": jobs[0]["numjobs"], "fio_version": jobs[0]["fio_version"], \ 166 | "rw": jobs[0]["rw"], "bs": jobs[0]["bs"] 167 | } 168 | 169 | for job in jobs: 170 | iops.append(job["iops"]) 171 | bw.append(job["bw"]) 172 | lat.append(job["lat"]) 173 | cpu_usr.append(job["cpu_usr"]) 174 | cpu_sys.append(job["cpu_sys"]) 175 | iops_stddev.append(job["iops_stddev"]) 176 | lat_stddev.append(job["lat_stddev"]) 177 | latency_ms.append(job["latency_ms"]), 178 | latency_us.append(job["latency_us"]), 179 | latency_ns.append(job["latency_ns"]), 180 | 181 | 182 | template["iops"] = sum(iops) 183 | template["bw"] = sum(bw) 184 | template["lat"] = statistics.mean(lat) 185 | template["cpu_usr"] = statistics.mean(cpu_usr) 186 | template["cpu_sys"] = statistics.mean(cpu_sys) 187 | template["iops_stddev"] = statistics.mean(iops_stddev) 188 | template["lat_stddev"] = statistics.mean(lat_stddev) 189 | template["latency_ms"] = latency_ms 190 | template["latency_us"] = latency_us 191 | template["latency_ns"] = latency_ns 192 | 193 | return template 194 | -------------------------------------------------------------------------------- /fio_plot/fiolib/shared_chart.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | import sys 3 | 4 | from operator import itemgetter 5 | import re 6 | from . import( 7 | supporting, 8 | dataimport 9 | ) 10 | 11 | def get_dataset_types(dataset): 12 | """This code is probably insane. 13 | Using only the first item in a list to return because all items should be equal. 14 | If not, a warning is displayed. 15 | """ 16 | dataset_types = {"rw": set(), "iodepth": set(), "numjobs": set()} 17 | operation = {"rw": str, "iodepth": int, "numjobs": int} 18 | 19 | type_list = [] 20 | 21 | for item in dataset: 22 | temp_dict = dataset_types.copy() 23 | for x in dataset_types.keys(): 24 | for y in item["data"]: 25 | temp_dict[x].add(operation[x](y[x])) 26 | temp_dict[x] = sorted(temp_dict[x]) 27 | if len(type_list) > 0: 28 | tmp = type_list[len(type_list) - 1] 29 | if tmp != temp_dict: 30 | print( 31 | "Warning: benchmark data may not contain the same kind of data, comparisons may be impossible." 32 | ) 33 | type_list.append(temp_dict) 34 | # pprint.pprint(type_list) 35 | dataset_types = type_list[0] 36 | return dataset_types 37 | 38 | 39 | def get_record_set_histogram(settings, dataset): 40 | rw = settings["rw"] 41 | iodepth = int(settings["iodepth"][0]) 42 | numjobs = int(settings["numjobs"][0]) 43 | 44 | # pprint.pprint(dataset[0]) 45 | 46 | # fio_version = dataset["data"]["fio version"] 47 | 48 | record_set = { 49 | "iodepth": iodepth, 50 | "numjobs": numjobs, 51 | "data": None, 52 | "fio_version": None, 53 | } 54 | 55 | for record in dataset[0]["data"]: 56 | if ( 57 | (int(record["iodepth"]) == iodepth) 58 | and (int(record["numjobs"]) == numjobs) 59 | and record["rw"] == rw 60 | ): 61 | record_set["data"] = record 62 | record_set["fio_version"] = record["fio_version"] 63 | return record_set 64 | 65 | def validate_get_record_set(settings, mismatch, dataset): 66 | if mismatch == len(dataset): 67 | print(f"\n It seems that none of the data matched your selection criteria.\n \ 68 | Check the filenames of the JSON files check the following parameters \n \ 69 | -r {settings['rw']} -d {settings['iodepth']} -n {settings['numjobs']}\n") 70 | print("\nIf you think everything is correct, feel free to report a bug.\n") 71 | sys.exit(1) 72 | 73 | def get_record_set_3d(settings, dataset, dataset_types, rw, metric): 74 | mismatch = 0 75 | record_set = { 76 | "iodepth": dataset_types["iodepth"], 77 | "numjobs": dataset_types["numjobs"], 78 | "values": [], 79 | "fio_version": [], 80 | } 81 | # pprint.pprint(dataset) 82 | if settings["rw"] == "randrw": 83 | if len(settings["filter"]) > 1 or not settings["filter"]: 84 | print( 85 | "Since we are processing randrw data, you must specify a " 86 | "filter for either read or write data, not both." 87 | ) 88 | exit(1) 89 | 90 | for depth in dataset_types["iodepth"]: 91 | row = [] 92 | for jobs in dataset_types["numjobs"]: 93 | for record in dataset[0]["data"]: 94 | # pprint.pprint(record) 95 | if ( 96 | (int(record["iodepth"]) == int(depth)) 97 | and int(record["numjobs"]) == jobs 98 | and record["rw"] == rw 99 | and record["type"] in settings["filter"] 100 | ): 101 | row.append(record[metric]) 102 | else: 103 | mismatch+=1 104 | record_set["values"].append(supporting.round_metric_series(row)) 105 | record_set["fio_version"].append(dataset[0]["data"][0]["fio_version"]) 106 | validate_get_record_set(settings, mismatch, dataset) 107 | return record_set 108 | 109 | def get_record_set_improved(settings, dataset, dataset_types): 110 | """The supplied dataset, a list of flat dictionaries with data is filtered based 111 | on the parameters as set by the command line. The filtered data is also scaled and rounded. 112 | """ 113 | mismatch = 0 114 | 115 | if settings["rw"] == "randrw" or settings["rw"] == "readwrite": 116 | if len(settings["filter"]) > 1 or not settings["filter"]: 117 | print( 118 | f"Since we are processing {settings['rw']} data, you must specify a" 119 | " filter for either read or write data, not both." 120 | ) 121 | exit(1) 122 | 123 | labels = [] 124 | # This is mostly for debugging purposes. 125 | for record in dataset: 126 | record["label"] = dataimport.return_folder_name(record["directory"], settings) 127 | labels.append(record["label"]) 128 | 129 | datadict = { 130 | "fio_version": [], 131 | "iops_series_raw": [], 132 | "iops_stddev_series_raw": [], 133 | "lat_series_raw": [], 134 | "lat_stddev_series_raw": [], 135 | "cpu": {"cpu_sys": [], "cpu_usr": []}, 136 | "x_axis": labels, 137 | "y1_axis": None, 138 | "y2_axis": None, 139 | } 140 | 141 | depth = settings["iodepth"][0] 142 | numjobs = settings["numjobs"][0] 143 | rw = settings["rw"] 144 | for depth in dataset_types["iodepth"]: 145 | for data in dataset: 146 | # pprint.pprint(data.keys()) 147 | # pprint.pprint(data['directory']) 148 | for record in data["data"]: 149 | #pprint.pprint(record.keys()) 150 | #pprint.pprint(f"-> {record['type']}") 151 | #print(f"{depth} - {record['iodepth']} + {numjobs} - {record['numjobs']} + {record['rw']} + {record['type']}") 152 | #print(f"{settings['filter']}") 153 | if ( 154 | (int(record["iodepth"]) == int(depth)) 155 | and int(record["numjobs"]) == int(numjobs) 156 | and record["rw"] == rw 157 | and record["type"] in settings["filter"] 158 | ): 159 | datadict["fio_version"].append(record["fio_version"]) 160 | datadict["iops_series_raw"].append(record["iops"]) 161 | datadict["lat_series_raw"].append(record["lat"]) 162 | datadict["iops_stddev_series_raw"].append(record["iops_stddev"]) 163 | datadict["lat_stddev_series_raw"].append(record["lat_stddev"]) 164 | datadict["cpu"]["cpu_sys"].append(int(round(record["cpu_sys"], 0))) 165 | datadict["cpu"]["cpu_usr"].append(int(round(record["cpu_usr"], 0))) 166 | else: 167 | mismatch+=1 168 | 169 | validate_get_record_set(settings, mismatch, dataset) 170 | return scale_data(datadict) 171 | 172 | def return_empty_data_dict(settings, dataset_types): 173 | numjobs = settings["numjobs"] 174 | labels = dataset_types[settings["query"]] 175 | #print(labels) 176 | datadict = { 177 | "fio_version": [], 178 | "iops_series_raw": [], 179 | "iops_stddev_series_raw": [], 180 | "lat_series_raw": [], 181 | "lat_stddev_series_raw": [], 182 | "cpu": {"cpu_sys": [], "cpu_usr": []}, 183 | "bs": [], 184 | "x_axis": labels, 185 | "y1_axis": None, 186 | "y2_axis": None, 187 | "numjobs": numjobs, 188 | "ss_settings": [], 189 | "ss_attained": [], 190 | "ss_data_bw_mean": [], 191 | "ss_data_iops_mean": [], 192 | "hostname_series": [] 193 | } 194 | return datadict 195 | 196 | def get_record_set(settings, dataset, dataset_types): 197 | """The supplied dataset, a list of flat dictionaries with data is filtered based 198 | on the parameters as set by the command line. The filtered data is also scaled and rounded. 199 | """ 200 | #for x in dataset: #(DEBUG) 201 | # for y in x["data"]: 202 | # print(y["iodepth"]) 203 | 204 | rw = settings["rw"] 205 | mismatch = 0 206 | 207 | if settings["rw"] == "randrw": 208 | if len(settings["filter"]) > 1 or not settings["filter"]: 209 | print( 210 | "Since we are processing randrw data, you must specify a filter for either" 211 | "read or write data, not both." 212 | ) 213 | exit(1) 214 | 215 | datadict = return_empty_data_dict(settings, dataset_types) 216 | 217 | for record in dataset: 218 | for data in record['data']: 219 | for x in settings["iodepth"]: 220 | for y in settings["numjobs"]: 221 | #print(f"Settings {x} - JSON {data['iodepth']} + {y} - {data['numjobs']} + {data['rw']} + {data['type']}") 222 | #print(f"{settings['filter']}") 223 | #print("=====") 224 | #pprint.pprint(data.keys()) 225 | if ( 226 | (int(data["iodepth"]) == int(x)) 227 | and int(data["numjobs"]) == int(y) 228 | and data["rw"] == rw 229 | and data["type"] in settings["filter"] 230 | ): 231 | #print(f"{x} - {data['iodepth']} + {y} - {data['numjobs']} + {data['rw']} + {data['type']}") 232 | #print(f"{x} - {data['iodepth']} + {y} - {data['numjobs']} + {data['iops']}") 233 | if "hostname" in data.keys(): 234 | if supporting.filter_hosts(settings, data): 235 | datadict["hostname_series"].append(data['hostname']) 236 | else: 237 | continue 238 | 239 | 240 | 241 | datadict["fio_version"].append(data["fio_version"]) 242 | datadict["iops_series_raw"].append(data["iops"]) 243 | datadict["lat_series_raw"].append(data["lat"]) 244 | datadict["bs"].append(data["bs"]) 245 | if "iops_stddev" in data.keys(): 246 | datadict["iops_stddev_series_raw"].append(data["iops_stddev"]) 247 | datadict["lat_stddev_series_raw"].append(data["lat_stddev"]) 248 | 249 | if "cpu_sys" in data.keys(): 250 | datadict["cpu"]["cpu_sys"].append(int(round(data["cpu_sys"], 0))) 251 | datadict["cpu"]["cpu_usr"].append(int(round(data["cpu_usr"], 0))) 252 | 253 | if "ss_attained" in data.keys(): 254 | if data["ss_settings"]: 255 | datadict["ss_settings"].append(str(data["ss_settings"])), 256 | datadict["ss_attained"].append(int(data["ss_attained"])), 257 | datadict["ss_data_bw_mean"].append( 258 | int(round(data["ss_data_bw_mean"], 0)) 259 | ), 260 | datadict["ss_data_iops_mean"].append( 261 | int(round(data["ss_data_iops_mean"], 0)) 262 | ) 263 | else: 264 | mismatch+=1 265 | 266 | validate_get_record_set(settings, mismatch, dataset) 267 | return scale_data(datadict) 268 | 269 | 270 | def scale_data(datadict): 271 | if not datadict['fio_version']: 272 | print(f"\n function scale_data did not receive any data\n") 273 | sys.exit(1) 274 | iops_series_raw = datadict["iops_series_raw"] 275 | iops_stddev_series_raw = datadict["iops_stddev_series_raw"] 276 | lat_series_raw = datadict["lat_series_raw"] 277 | lat_stddev_series_raw = datadict["lat_stddev_series_raw"] 278 | cpu_usr = datadict["cpu"]["cpu_usr"] 279 | cpu_sys = datadict["cpu"]["cpu_sys"] 280 | 281 | if "ss_settings" in datadict.keys(): 282 | ss_data_bw_mean = datadict["ss_data_bw_mean"] 283 | ss_data_iops_mean = datadict["ss_data_iops_mean"] 284 | 285 | # 286 | # Latency data must be scaled, IOPs will not be scaled. 287 | # 288 | 289 | latency_scale_factor = supporting.get_scale_factor_lat(lat_series_raw) 290 | scaled_latency_data = supporting.scale_yaxis(lat_series_raw, latency_scale_factor) 291 | # 292 | # Latency data must be rounded. 293 | # 294 | scaled_latency_data_rounded = supporting.round_metric_series( 295 | scaled_latency_data["data"] 296 | ) 297 | scaled_latency_data["data"] = scaled_latency_data_rounded 298 | # 299 | # Latency stddev must be scaled with same scale factor as the data 300 | # 301 | lat_stdev_scaled = supporting.scale_yaxis( 302 | lat_stddev_series_raw, latency_scale_factor 303 | ) 304 | 305 | lat_stdev_scaled_rounded = supporting.round_metric_series(lat_stdev_scaled["data"]) 306 | 307 | # 308 | # Latency data is converted to percent. 309 | # 310 | lat_stddev_percent = supporting.raw_stddev_to_percent( 311 | scaled_latency_data["data"], lat_stdev_scaled_rounded 312 | ) 313 | 314 | lat_stddev_percent = [int(x) for x in lat_stddev_percent] 315 | 316 | scaled_latency_data["stddev"] = supporting.round_metric_series(lat_stddev_percent) 317 | # 318 | # IOPS data is rounded 319 | iops_series_rounded = supporting.round_metric_series(iops_series_raw) 320 | # 321 | # IOPS stddev is converted to percent 322 | iops_stdev_rounded = supporting.round_metric_series(iops_stddev_series_raw) 323 | iops_stdev_rounded_percent = supporting.raw_stddev_to_percent( 324 | iops_series_rounded, iops_stdev_rounded 325 | ) 326 | iops_stdev_rounded_percent = [int(x) for x in iops_stdev_rounded_percent] 327 | # 328 | # 329 | 330 | # Steady state bandwidth data must be scaled. 331 | if "ss_settings" in datadict.keys(): 332 | if datadict["ss_settings"]: 333 | ss_bw_scalefactor = supporting.get_scale_factor_bw_ss(ss_data_bw_mean) 334 | ss_data_bw_mean = supporting.scale_yaxis(ss_data_bw_mean, ss_bw_scalefactor) 335 | ss_data_bw_mean["data"] = supporting.round_metric_series( 336 | ss_data_bw_mean["data"] 337 | ) 338 | 339 | ss_iops_scalefactor = supporting.get_scale_factor_iops(ss_data_iops_mean) 340 | ss_data_iops_mean = supporting.scale_yaxis( 341 | ss_data_iops_mean, ss_iops_scalefactor 342 | ) 343 | ss_data_iops_mean["data"] = supporting.round_metric_series( 344 | ss_data_iops_mean["data"] 345 | ) 346 | 347 | datadict["y1_axis"] = { 348 | "data": iops_series_rounded, 349 | "format": "IOPS", 350 | "stddev": iops_stdev_rounded_percent, 351 | } 352 | 353 | datadict["y2_axis"] = scaled_latency_data 354 | if cpu_sys and cpu_usr: 355 | datadict["cpu"] = {"cpu_sys": cpu_sys, "cpu_usr": cpu_usr} 356 | 357 | if "ss_settings" in datadict.keys(): 358 | if datadict["ss_settings"]: 359 | datadict["ss_data_bw_mean"] = ss_data_bw_mean 360 | datadict["ss_data_iops_mean"] = ss_data_iops_mean 361 | 362 | return datadict 363 | 364 | 365 | def get_auto_label_font_size(rects): 366 | size = 0 367 | number = len(rects) 368 | if number <= 8: 369 | size = 8 370 | if number > 8 and number < 16: 371 | size = 7 372 | if number >= 16: 373 | size = 6 374 | return size 375 | 376 | 377 | def autolabel(rects, axis): 378 | fontsize = get_auto_label_font_size(rects) 379 | 380 | for rect in rects: 381 | height = rect.get_height() 382 | if height < 10: 383 | formatter = "%.2f" 384 | else: 385 | formatter = "%d" 386 | value = rect.get_x() 387 | 388 | if height >= 10000: 389 | value = int(round(height / 1000, 0)) 390 | formatter = "%dK" 391 | else: 392 | value = height 393 | axis.text( 394 | rect.get_x() + rect.get_width() / 2, 395 | 1.015 * height, 396 | formatter % value, 397 | ha="center", 398 | fontsize=fontsize, 399 | ) 400 | -------------------------------------------------------------------------------- /fio_plot/fiolib/table_support.py: -------------------------------------------------------------------------------- 1 | import matplotlib.font_manager as font_manager 2 | 3 | def get_widest_col(data): 4 | 5 | sizes = [] 6 | for x in data: 7 | s = str(x) 8 | length = len(s) 9 | sizes.append(length) 10 | return sizes 11 | 12 | 13 | def get_max_width(dataset, cols): 14 | matrix = [] 15 | returndata = [] 16 | for item in dataset: 17 | matrix.append(get_widest_col(item)) 18 | 19 | col = 0 20 | while col < cols: 21 | column = 3 22 | for item in matrix: 23 | if item[col] > column: 24 | column = item[col] 25 | returndata.append(column) 26 | col += 1 27 | return returndata 28 | 29 | 30 | def calculate_colwidths(settings, cols, matrix): 31 | collist = [] 32 | for item in matrix: 33 | value = item * settings["tablecolumn_spacing"] 34 | collist.append(value) 35 | return collist 36 | 37 | 38 | def scale_iops(data): 39 | scaled = [] 40 | for x in data: 41 | if len(str(x)) > 4: 42 | scale = int(round((x/1000),0)) 43 | scaled.append(f"{scale}K") 44 | elif len(str(x)) > 5: 45 | scale = round((x/1000000),1) 46 | scaled.append(f"{scale}M") 47 | else: 48 | scaled.append(str(x)) 49 | return scaled 50 | 51 | 52 | def alternate_cell_height(number=2,stepsize=2): 53 | start = 5 54 | stop = start + (number * stepsize) 55 | while True: 56 | for x in range(start, stop, stepsize): 57 | yield x / 10 58 | 59 | 60 | def get_host_metric_data(data): 61 | returndata = [] 62 | counter = 1 63 | hostcounter = 0 64 | divide = int(len(data["hostname_series"]) / len(data["x_axis"])) # that int convert should work 65 | for host in data["hostname_series"]: 66 | hostcounter += 1 67 | metricvalue = data["x_axis"][counter-1] 68 | returndata.append({ "hostname": host, "value": metricvalue }) 69 | if hostcounter % divide == 0: 70 | counter += 1 71 | return returndata 72 | 73 | 74 | def create_data_for_table_with_hostname_data(settings, data, type): 75 | returndata = {} 76 | hostmetric = get_host_metric_data(data) 77 | returndata["hostnames"] = [ x["hostname"] for x in hostmetric ] 78 | returndata["metric"] = [ x["value"] for x in hostmetric ] 79 | returndata["table_vals"] = [ returndata["hostnames"], returndata["metric"], data["y1_axis"][type], data["y2_axis"][type]] 80 | returndata["metricname"] = f"{settings['graphtype'][-2:]}" 81 | return returndata 82 | 83 | 84 | def convert_number_to_yes_no(data): 85 | newlist = [] 86 | lookup = {1: "yes", 0: "no"} 87 | for item in data: 88 | newlist.append(lookup[item]) 89 | return newlist 90 | 91 | def tablelines(settings): 92 | if settings["table_lines"]: 93 | linewidth = 0.25 94 | alpha = 1 95 | else: 96 | alpha = 0 97 | linewidth = 0 98 | return linewidth, alpha 99 | 100 | def get_alternator_value(matrix): 101 | if max(matrix) <= 10: 102 | alternator = alternate_cell_height() 103 | if max(matrix) > 10: 104 | alternator = alternate_cell_height(3,14) 105 | else: 106 | alternator = alternate_cell_height(1,10) 107 | return alternator 108 | 109 | def format_table_cells(settings, table, fontsize, matrix, cols): 110 | linewidth, alpha = tablelines(settings) 111 | counter = 0 112 | alternator = get_alternator_value(matrix) 113 | 114 | for key, cell in table.get_celld().items(): 115 | cell.set_linewidth(linewidth) 116 | flip = next(alternator) # creates the alternating height pattern when the top lables are too long. 117 | ### This section below formats the top row of the table 118 | if counter < (cols): # the first cells up to the number of collums is the top row 119 | cell._text.set_verticalalignment('bottom') # this is required to reduce cell height 120 | cell.set_alpha(alpha) # this is a fix for text being cut off, maybe better solution? 121 | cell.set_fontsize(fontsize) 122 | height = cell.get_height() 123 | cell.set_height(height * flip) # prevens cell text overlap 124 | else: 125 | cell.set_fontsize(settings["table_fontsize"]) 126 | ## This is some trial and error formatting, don't remove the stuff below. 127 | if fontsize == 8 and max(matrix) > 5: 128 | cell.set_width(0.08) 129 | else: 130 | cell.set_width(0.042) 131 | counter += 1 132 | return None -------------------------------------------------------------------------------- /fio_plot/fiolib/tables.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from . import table_support as ts 3 | 4 | 5 | 6 | def create_generic_table(settings, data, table_vals, ax2, rowlabels, location, fontsize): 7 | cols = len(table_vals[0]) 8 | matrix = ts.get_max_width(table_vals, cols) 9 | #print(matrix) 10 | colwidths = ts.calculate_colwidths(settings, cols, matrix) 11 | table = ax2.table( 12 | cellText=table_vals, 13 | loc=location, 14 | rowLabels=rowlabels, 15 | colLoc="center", 16 | colWidths=colwidths, 17 | cellLoc="center", 18 | rasterized=False, 19 | ) 20 | table.auto_set_font_size(False) # Very Small 21 | table.scale(1, 1.2) 22 | #print(settings["table_lines"]) 23 | ts.format_table_cells(settings, table, fontsize, matrix, cols) 24 | 25 | 26 | def create_cpu_table(settings, data, ax2, fontsize): 27 | table_vals = [data["x_axis"], data["cpu"]["cpu_usr"], data["cpu"]["cpu_sys"]] 28 | rowlabels = ["CPU Usage", "cpu_usr %", "cpu_sys %"] 29 | location = "lower center" 30 | create_generic_table(settings, data, table_vals, ax2, rowlabels, location, fontsize) 31 | 32 | 33 | def create_values_table(settings, data, ax2, fontsize): 34 | iops = ts.scale_iops(data["y1_axis"]["data"]) 35 | table_vals = [data["x_axis"], iops, data["y2_axis"]["data"]] 36 | rowlabels = ["IOPs/Lat", data["y1_axis"]["format"], data["y2_axis"]["format"]] 37 | if "hostname_series" in data.keys(): 38 | if data["hostname_series"]: 39 | tabledata = ts.create_data_for_table_with_hostname_data(settings, data, "data") 40 | table_vals = tabledata["table_vals"] 41 | metricname = tabledata["metricname"] 42 | rowlabels = [ "Hostname", metricname , "IOP/s", "Latency"] 43 | location = "lower right" 44 | create_generic_table(settings, data, table_vals, ax2, rowlabels, location, fontsize) 45 | 46 | 47 | def create_stddev_table(settings, data, ax2, fontsize): 48 | if not data["y2_axis"]["stddev"] or settings["show_ss"]: 49 | return None 50 | table_vals = [data["x_axis"], data["y1_axis"]["stddev"], data["y2_axis"]["stddev"]] 51 | table_name = settings["label"] 52 | rowlabels = [table_name, "IOP/s \u03C3 %", "Latency \u03C3 %"] 53 | if "hostname_series" in data.keys(): 54 | if data["hostname_series"]: 55 | tabledata = ts.create_data_for_table_with_hostname_data(settings, data, "stddev") 56 | table_vals = tabledata["table_vals"] 57 | metricname = tabledata["metricname"] 58 | rowlabels = [ "Hostname", metricname , "IOP/s \u03C3 %", "Latency \u03C3 %"] 59 | location = "lower right" 60 | create_generic_table(settings, data, table_vals, ax2, rowlabels, location, fontsize) 61 | 62 | 63 | def create_steadystate_table(settings, data, ax2, fontsize): 64 | # pprint.pprint(data) 65 | ## This error is required until I address this 66 | 67 | if "hostname_series" in data.keys(): 68 | if data["hostname_series"]: 69 | print(f"\n Sorry, the steady state table is not compatible (yet) with client/server data\n") 70 | sys.exit(1) 71 | 72 | if data["ss_attained"]: 73 | data["ss_attained"] = ts.convert_number_to_yes_no(data["ss_attained"]) 74 | table_vals = [ 75 | data["x_axis"], 76 | data["ss_data_bw_mean"]["data"], 77 | data["ss_data_iops_mean"]["data"], 78 | data["ss_attained"], 79 | ] 80 | 81 | rowlabels = [ 82 | "Steady state", 83 | f"BW mean {data['ss_data_bw_mean']['format']}", 84 | f"{data['ss_data_iops_mean']['format']} mean", 85 | f"{data['ss_settings'][0]} attained", 86 | ] 87 | location = "lower center" 88 | create_generic_table(settings, data, table_vals, ax2, rowlabels, location, fontsize) 89 | else: 90 | print( 91 | "\n No steadystate data was found, so the steadystate table cannot be displayed.\n" 92 | ) 93 | -------------------------------------------------------------------------------- /fio_plot/templates/fio-plot.ini: -------------------------------------------------------------------------------- 1 | [graphtype] 2 | graphtype = bargraph2d_qd 3 | # See below for all graph types 4 | # bargraph3d : A 3D graph 5 | # bargraph2d_qd : queue depth on x axis and fixed numjobs setting 6 | # bargraph2d_nj : numjobs on x axis and fixed queue depth 7 | # histogram : for a fixed queue depth and numjobs value 8 | # loggraph : plots the data from the .log output of fio 9 | # compare_graph : compare the benchmarks (compare data in two folders) (JSON only) 10 | 11 | [settings] 12 | input_directory = /path/to/directory 13 | output_filename = 14 | title = Title for this graph 15 | subtitle = 16 | source = https://louwrentius.com 17 | rw = randread 18 | type = 19 | filter = read,write 20 | iodepth = 1,2,4,8,16,32,64 21 | numjobs = 1 22 | maxdepth = 64 23 | maxjobs = 64 24 | dpi = 200 25 | percentile = 99.99 26 | max_z = 27 | max_lat = 28 | max_iops = 29 | max_bw = 30 | moving_average = 31 | 32 | [layout] 33 | title_fontsize = 16 34 | subtitle_fontsize = 10 35 | source_fontsize = 6 36 | credit_fontsize = 8 37 | table_fontsize = 10 38 | xlabel_depth = 39 | xlabel_parent = 40 | xlabel_segment_size = 41 | line_width = 42 | group_bars = 43 | show_cpu = True 44 | show_ss = False 45 | table_lines = False 46 | disable_grid = False 47 | enable_markers = False 48 | disable_fio_version = False 49 | colors = 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | Pillow 3 | pyparsing 4 | matplotlib<=3.8 5 | pyan3 6 | rich 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="fio-plot", 8 | version="1.1.16", 9 | author="louwrentius", 10 | description="Create charts from FIO storage benchmark tool output", 11 | long_description=long_description, 12 | long_description_content_type="text/markdown", 13 | url="https://github.com/louwrentius/fio-plot/", 14 | packages=setuptools.find_packages(), 15 | install_requires=["numpy", "matplotlib", "Pillow", "pyan3", "pyparsing", "rich"], 16 | include_package_data=True, 17 | package_data={"bench_fio": ["templates/*.fio", "scripts/*.sh"]}, 18 | entry_points={ 19 | "console_scripts": ["fio-plot = fio_plot:main", "bench-fio = bench_fio:main"], 20 | }, 21 | scripts=["bin/fio-plot", "bin/bench-fio"], 22 | ) 23 | -------------------------------------------------------------------------------- /tests/bench_fio_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import unittest 3 | 4 | from bench_fio.benchlib import ( 5 | argparsing, 6 | defaultsettings, 7 | display, 8 | supporting 9 | ) 10 | 11 | 12 | class TestFunctions(unittest.TestCase): 13 | def setUp(self): 14 | self.settings = defaultsettings.get_default_settings() 15 | self.settings["target"] = ["device"] 16 | self.settings["type"] = ["directory"] 17 | self.tests = supporting.generate_test_list(self.settings) 18 | self.settings["output"] = "output_directory" 19 | 20 | def test_generate_benchmarks(self): 21 | self.assertEqual(len(supporting.generate_test_list(self.settings)), 98) 22 | 23 | def test_generate_benchmarks_big(self): 24 | self.settings["target"] = ["filea", "fileb", "filec", "filed"] 25 | self.settings["block_size"] = ["4k", "8k", "16k", "32k"] 26 | self.assertEqual(len(supporting.generate_test_list(self.settings)), 1568) 27 | 28 | def test_are_loop_items_lists(self): 29 | for item in self.settings["loop_items"]: 30 | result = self.settings[item] 31 | self.assertTrue(isinstance(result, list)) 32 | 33 | def test_calculate_duration(self): 34 | self.assertEqual( 35 | display.calculate_duration(self.settings, self.tests), "1:38:00" 36 | ) 37 | 38 | def test_generate_output_directory_regular(self): 39 | benchmark = self.tests[0] 40 | self.assertEqual( 41 | supporting.generate_output_directory(self.settings, benchmark), 42 | "output_directory/device/4k", 43 | ) 44 | 45 | def test_generate_output_directory_mixed(self): 46 | self.settings["mode"] = ["rw"] 47 | self.settings["rwmixread"] = [75] 48 | self.settings["loop_items"].append("rwmixread") 49 | tests = supporting.generate_test_list(self.settings) 50 | benchmark = tests[0] 51 | self.assertEqual( 52 | supporting.generate_output_directory(self.settings, benchmark), 53 | "output_directory/device/rw75/4k", 54 | ) 55 | 56 | def test_number_of_settings(self): 57 | filtered_settings = [] 58 | for setting in self.settings.keys(): 59 | if setting not in self.settings["filter_items"]: 60 | filtered_settings.append(str(setting)) 61 | filtered_settings.sort() 62 | descriptions = list((argparsing.get_argument_description()).keys()) 63 | descriptions.sort() 64 | self.assertEqual(len(filtered_settings), len(descriptions)) 65 | 66 | 67 | if __name__ == "__main__": 68 | unittest.main() 69 | -------------------------------------------------------------------------------- /tests/test_3d.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from fio_plot.fiolib.bar3d import plot_3d 3 | 4 | 5 | class Test3D(unittest.TestCase): 6 | def test_correct_bars_drawn(self): 7 | settings = { 8 | "type": ["iops"], 9 | "rw": "read", 10 | "source": "test", 11 | "title": "test", 12 | "subtitle": "", 13 | "filter": ["read", "write"], 14 | # intentionally using prime numbers 15 | "iodepth": [2, 3], 16 | "numjobs": [5, 11], 17 | "maxjobs": 32, 18 | "maxdepth": 32, 19 | "max": None, 20 | "dpi": 200, 21 | "disable_fio_version": 2.0, 22 | "output_filename": "/tmp/test.png" 23 | } 24 | 25 | dataset = [{"data": []}] 26 | for iodepth in settings["iodepth"]: 27 | for numjobs in settings["numjobs"]: 28 | dataset[0]["data"].append( 29 | { 30 | "fio_version": 3.1, 31 | "iodepth": str(iodepth), 32 | "numjobs": str(numjobs), 33 | "rw": "read", 34 | "type": "read", 35 | "iops": iodepth * numjobs, 36 | } 37 | ) 38 | plot_3d(settings, dataset) 39 | 40 | 41 | if __name__ == "__main__": 42 | unittest.main() 43 | --------------------------------------------------------------------------------