Skip to content

Commit dca2b54

Browse files
ErwanAliasr1anisse
authored andcommitted
hwbench: Adding fio engine
This commit is adding a first cmdline engine_module to execute a single fio command line. This code has been tested on fio-3.19, defining the minimal release version. To enable this mode, engine_module must be set to "cmdline". The expected command line to forward to fio must be provided in the engine_module_parameter_base. The command line will be tweaked by hwbench to ensure: - runtime consistency with other engines : --time_based and --runtime are added - output consistency: --output-format=json+ is added - job naming: --name is adjusted to match hwbench's job name - logs: --write_*_logs are enabled at a 20sec precision - cache invalidation: each benchmark clears the cache to ensure an out-of-cache testing Please note that : - Fio's runtime will inherit automatically from hwbench's runtime value. - --numjobs value will be fed with 'stressor_range' making possible to study the scalability of a device with a minimal code. If one of these values were already present in the engine_module_parameter_base, hwbench will replace them by the values that were computed based on the benchmark descrption. A sample configuration file (configs/fio.conf) is provided as an example, it will: - test /dev/nvme0n1 in a randread 4k profile - two benchmarks are automatically created as per the stressor_range value ("4,6") : -- one with numjobs=4 -- one with numjobs=6 The testing suite is added to ensure a proper parsing and benchmarking job creation. A documentation is also added to detail this first implementation behavior. Signed-off-by: Erwan Velu <[email protected]>
1 parent 87d8a69 commit dca2b54

File tree

12 files changed

+393
-2
lines changed

12 files changed

+393
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ The current version of hwbench supports 3 different engines.
2626
- [stress-ng](https://github.com/ColinIanKing/stress-ng): no need to present this very popular low-level benchmarking tool
2727
- spike: a custom engine used to make fans spike. Very useful to study the cooling strategy of a server.
2828
- sleep: a stupid sleep call used to observe how the system is behaving in idle mode
29+
- [fio](https://github.com/axboe/fio): a flexible storage benchmarking tool, see [documentation](./documentation/fio.md)
2930

3031
Benchmark performance metrics are extracted and saved for later analysis.
3132

configs/fio.conf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This configuration will :
2+
# - test /dev/nvme0n1 in 4k randread for 40 seconds
3+
# -- first with 4 stressors
4+
# -- then with 6 stressors
5+
[global]
6+
runtime=40
7+
monitor=all
8+
9+
[randread_cmdline]
10+
engine=fio
11+
engine_module=cmdline
12+
engine_module_parameter_base=--filename=/dev/nvme0n1 --direct=1 --rw=randread --bs=4k --ioengine=libaio --iodepth=256 --group_reporting --readonly
13+
hosting_cpu_cores=all
14+
hosting_cpu_cores_scaling=none
15+
stressor_range=4,6

documentation/fio.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# FIO
2+
3+
hwbench can use [fio](https://github.com/axboe/fio) to perform storage benchmarking.
4+
The current implementation requires fio >= 3.19.
5+
6+
# Concept
7+
Fio is operated in three(3) different modes by selecting the `engine_module` directive.
8+
9+
10+
## Command line
11+
12+
When `engine_module=cmdline` is used, the content of `engine_module_parameter_base` will be passed directly to fio with some limitations.
13+
14+
The following fio keywords are automatically defined, or replaced if present, by hwbench :
15+
16+
- `--runtime`: set to match the exact duration of the current hwbench benchmark.
17+
- `--time_based`: it's mandatory to have a benchmark lasting `runtime` seconds.
18+
- `--output-format`: hwbench need the output to be set in `json+` for an easy integration.
19+
- `--name`: hwbench will use the current job name to ensure its unique over the runs.
20+
- `--numjobs`: defined by `stressor_range`, can be set as a unique value or a list of values. Each value will generate a new benchmark.
21+
- `--write_{bw|lat|hist|iops}_logs`: hwbench will automatically collect the performance logs to let hwgraph doing time-based graphs.
22+
- `--invalidate`: hwbench ensure that every benchmark will be done out of cache.
23+
24+
### Sample configuration file
25+
26+
The following job defines two benchmarks on the same device (nvme0n1).
27+
28+
The `randread_cmdline` job will create :
29+
- `randread_cmdline_0` benchmark with ``numjobs=4`` extracted from `stressor_range` list
30+
- `randread_cmdline_1` benchmark with ``numjobs=6`` extracted from `stressor_range` list
31+
32+
```
33+
[randread_cmdline]
34+
runtime=600
35+
engine=fio
36+
engine_module=cmdline
37+
engine_module_parameter_base=--filename=/dev/nvme0n1 --direct=1 --rw=randread --bs=4k --ioengine=libaio --iodepth=256 --group_reporting --readonly
38+
hosting_cpu_cores=all
39+
hosting_cpu_cores_scaling=none
40+
stressor_range=4,6
41+
```
42+
43+
Please note the `hosting_cpu_cores` only selects a set of cores to pin fio. A possible usage would be using a list of cores with a `hosting_cpu_cores_scaling` to study the performance of the same storage device from different NUMA domains.
44+
45+
## External file execution
46+
Hwbench execute an already existing fio job file.
47+
48+
Not yet implemented.
49+
50+
## Automatic job definition
51+
Hwbench automatically creates jobs based on some hardware detection and profiles.
52+
53+
Not yet implemented.

hwbench/bench/benchmark.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ def pre_run(self):
115115
cpu_location = ""
116116
if p.get_pinned_cpu():
117117
if isinstance(p.get_pinned_cpu(), (int, str)):
118-
cpu_location = f" on CPU {p.get_pinned_cpu():3d}"
118+
cpu_location = f" pinned on CPU {p.get_pinned_cpu():3d}"
119119
elif isinstance(p.get_pinned_cpu(), list):
120-
cpu_location = f" on CPU [{h.cpu_list_to_range(p.get_pinned_cpu())}]"
120+
cpu_location = f" pinned on CPU [{h.cpu_list_to_range(p.get_pinned_cpu())}]"
121121
else:
122122
h.fatal(f"Unsupported get_pinned_cpu() format :{type(p.get_pinned_cpu())}")
123123

hwbench/bench/test_fio.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from . import test_benchmarks_common as tbc
2+
3+
4+
class TestFio(tbc.TestCommon):
5+
def __init__(self, *args, **kwargs):
6+
super().__init__(*args, **kwargs)
7+
self.load_mocked_hardware(
8+
cpucores="./hwbench/tests/parsing/cpu_cores/v2321",
9+
cpuinfo="./hwbench/tests/parsing/cpu_info/v2321",
10+
numa="./hwbench/tests/parsing/numa/8domainsllc",
11+
)
12+
self.load_benches("./hwbench/config/fio.conf")
13+
self.parse_jobs_config()
14+
self.QUADRANT0 = list(range(0, 16)) + list(range(64, 80))
15+
self.QUADRANT1 = list(range(16, 32)) + list(range(80, 96))
16+
self.ALL = list(range(0, 128))
17+
18+
def test_fio(self):
19+
"""Check fio syntax."""
20+
assert self.benches.count_benchmarks() == 2
21+
assert self.benches.count_jobs() == 1
22+
assert self.benches.runtime() == 80
23+
24+
for bench in self.benches.benchs:
25+
assert bench.validate_parameters() is None
26+
assert bench.get_parameters().get_name() == "randread_cmdline"
27+
28+
bench_0 = self.get_bench_parameters(0)
29+
assert (
30+
bench_0.get_engine_module_parameter_base()
31+
== "--filename=/dev/nvme0n1 --direct=1 --rw=randread --bs=4k --ioengine=libaio --iodepth=256 --group_reporting --readonly --runtime=40 --time_based --output-format=json+ --numjobs=4 --name=randread_cmdline_0 --invalidate=1 --log_avg_msec=20000 --write_bw_log=fio/randread_cmdline_0_bw.log --write_lat_log=fio/randread_cmdline_0_lat.log --write_hist_log=fio/randread_cmdline_0_hist.log --write_iops_log=fio/randread_cmdline_0_iops.log"
32+
)
33+
34+
bench_1 = self.get_bench_parameters(1)
35+
assert (
36+
bench_1.get_engine_module_parameter_base()
37+
== "--filename=/dev/nvme0n1 --direct=1 --rw=randread --bs=4k --ioengine=libaio --iodepth=256 --group_reporting --readonly --runtime=40 --time_based --output-format=json+ --numjobs=6 --name=randread_cmdline_1 --invalidate=1 --log_avg_msec=20000 --write_bw_log=fio/randread_cmdline_1_bw.log --write_lat_log=fio/randread_cmdline_1_lat.log --write_hist_log=fio/randread_cmdline_1_hist.log --write_iops_log=fio/randread_cmdline_1_iops.log"
38+
)

hwbench/config/fio.conf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# This configuration will :
2+
# - test /dev/nvme0n1 in 4k randread for 40 seconds
3+
# -- first with 4 stressors
4+
# -- then with 6 stressors
5+
#
6+
# As runtime is set to 30s by the user, it should be replaced by runtime=40 defined by hardware bench
7+
[global]
8+
runtime=40
9+
monitor=all
10+
11+
[randread_cmdline]
12+
engine=fio
13+
engine_module=cmdline
14+
engine_module_parameter_base=--filename=/dev/nvme0n1 --direct=1 --rw=randread --bs=4k --name TOBEREMOVED --ioengine=libaio --iodepth=256 --group_reporting --readonly --runtime=30 --time_based --time_based --numjobs=10 --name=plop --name _REMOVE_ME
15+
hosting_cpu_cores=all
16+
hosting_cpu_cores_scaling=none
17+
stressor_range=4,6
18+

hwbench/config/test_parse_fio.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
5+
from hwbench.bench import test_benchmarks_common as tbc
6+
from hwbench.environment.mock import MockHardware
7+
8+
9+
class TestParseConfig(tbc.TestCommon):
10+
def __init__(self, *args, **kwargs):
11+
super().__init__(*args, **kwargs)
12+
self.hw = MockHardware()
13+
self.load_benches("./hwbench/config/fio.conf")
14+
15+
def test_sections_name(self):
16+
"""Check if sections names are properly detected."""
17+
sections = self.get_jobs_config().get_sections()
18+
assert sections == [
19+
"randread_cmdline",
20+
]
21+
22+
def test_keywords(self):
23+
"""Check if all keywords are valid."""
24+
try:
25+
with patch("hwbench.utils.helpers.is_binary_available") as iba:
26+
iba.return_value = True
27+
self.get_jobs_config().validate_sections()
28+
except Exception as exc:
29+
pytest.fail(f"'validate_sections' detected a syntax error {exc}")

hwbench/engines/fio.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import json
2+
import pathlib
3+
from typing import Any
4+
5+
from hwbench.bench.benchmark import ExternalBench
6+
from hwbench.bench.engine import EngineBase, EngineModuleBase
7+
from hwbench.bench.parameters import BenchmarkParameters
8+
from hwbench.utils.helpers import fatal
9+
10+
11+
class EngineModuleCmdline(EngineModuleBase):
12+
"""This class implements the EngineModuleBase for fio"""
13+
14+
def __init__(self, engine: EngineBase, engine_module_name: str, fake_stdout=None):
15+
super().__init__(engine, engine_module_name)
16+
self.engine_module_name = engine_module_name
17+
self.load_module_parameter(fake_stdout)
18+
19+
def load_module_parameter(self, fake_stdout=None):
20+
# if needed add module parameters to your module
21+
self.add_module_parameter("cmdline")
22+
23+
def validate_module_parameters(self, p: BenchmarkParameters):
24+
msg = super().validate_module_parameters(p)
25+
FioCmdLine(self, p).parse_parameters()
26+
return msg
27+
28+
def run_cmd(self, p: BenchmarkParameters):
29+
return FioCmdLine(self, p).run_cmd()
30+
31+
def run(self, p: BenchmarkParameters):
32+
return FioCmdLine(self, p).run()
33+
34+
def fully_skipped_job(self, p) -> bool:
35+
return FioCmdLine(self, p).fully_skipped_job()
36+
37+
38+
class Engine(EngineBase):
39+
"""The main fio class."""
40+
41+
def __init__(self, fake_stdout=None):
42+
super().__init__("fio", "fio")
43+
self.add_module(EngineModuleCmdline(self, "cmdline", fake_stdout))
44+
45+
def run_cmd_version(self) -> list[str]:
46+
return [
47+
self.get_binary(),
48+
"--version",
49+
]
50+
51+
def run_cmd(self) -> list[str]:
52+
return []
53+
54+
def parse_version(self, stdout: bytes, _stderr: bytes) -> bytes:
55+
self.version = stdout.split(b"-")[1].strip()
56+
return self.version
57+
58+
def version_major(self) -> int:
59+
if self.version:
60+
return int(self.version.split(b".")[0])
61+
return 0
62+
63+
def version_minor(self) -> int:
64+
if self.version:
65+
return int(self.version.split(b".")[1])
66+
return 0
67+
68+
def parse_cmd(self, stdout: bytes, stderr: bytes):
69+
return {}
70+
71+
72+
class Fio(ExternalBench):
73+
"""The Fio stressor."""
74+
75+
def __init__(self, engine_module: EngineModuleBase, parameters: BenchmarkParameters):
76+
ExternalBench.__init__(self, engine_module, parameters)
77+
self.parameters = parameters
78+
self.engine_module = engine_module
79+
self.log_avg_msec = 20000 # write_*_log are averaged at 20sec
80+
self._parse_parameters()
81+
# Tests can skip this part
82+
if isinstance(parameters.out_dir, pathlib.PosixPath):
83+
parameters.out_dir.joinpath("fio").mkdir(parents=True, exist_ok=True)
84+
85+
def version_compatible(self) -> bool:
86+
engine = self.engine_module.get_engine()
87+
return engine.version_major() >= 3 and engine.version_minor() >= 19
88+
89+
def _parse_parameters(self):
90+
self.runtime = self.parameters.runtime
91+
if self.runtime * 1000 < self.log_avg_msec:
92+
fatal(f"Fio runtime cannot be lower than the average log time ({self.log_avg_msec}).")
93+
94+
def need_skip_because_version(self):
95+
if self.skip:
96+
# we already skipped this benchmark, we can't know the reason anymore
97+
# because we might not have run the version command.
98+
return ["echo", "skipped benchmark"]
99+
if not self.version_compatible():
100+
print(f"WARNING: skipping benchmark {self.name}, needs fio >= 3.19")
101+
self.skip = True
102+
return ["echo", "skipped benchmark"]
103+
return None
104+
105+
def run_cmd(self) -> list[str]:
106+
skip = self.need_skip_because_version()
107+
if skip:
108+
return skip
109+
110+
# Let's build the command line to run the tool
111+
args = [
112+
self.engine_module.get_engine().get_binary(),
113+
]
114+
115+
return self.get_taskset(args)
116+
117+
def get_default_fio_command_line(self, args: list) -> list:
118+
"""Return the default fio arguments"""
119+
120+
def remove_arg(args, item) -> list:
121+
if isinstance(item, str):
122+
return [arg for arg in args if arg != item]
123+
else:
124+
# We need to ensure that value based items are having the right value
125+
# This avoid a case where the user already defined a value we need to control
126+
i = 0
127+
while i < len(args):
128+
arg = args[i]
129+
removed = ""
130+
if arg == item[0]:
131+
removed = args.pop(i)
132+
removed += " " + args.pop(i)
133+
elif arg.startswith(f"{item[0]}="):
134+
removed = args.pop(i)
135+
if removed:
136+
print(
137+
f"{self.parameters.get_name_with_position()}: Fio parameter {item[0]} is now set to {item[1]} (was: {removed})"
138+
)
139+
else:
140+
i += 1
141+
return args
142+
143+
name = self.parameters.get_name_with_position()
144+
enforced_items = [
145+
["--runtime", f"{self.parameters.get_runtime()}"],
146+
"--time_based",
147+
["--output-format", "json+"],
148+
["--numjobs", self.parameters.get_engine_instances_count()],
149+
["--name", name],
150+
["--invalidate", 1],
151+
["--log_avg_msec", self.log_avg_msec],
152+
]
153+
for log_type in ["bw", "lat", "hist", "iops"]:
154+
enforced_items.append([f"--write_{log_type}_log", f"fio/{name}_{log_type}.log"])
155+
156+
for enforced_item in enforced_items:
157+
args = remove_arg(args, enforced_item)
158+
if isinstance(enforced_item, str):
159+
args.append(enforced_item)
160+
else:
161+
args.append(f"{enforced_item[0]}={enforced_item[1]}")
162+
163+
return args
164+
165+
def parse_cmd(self, stdout: bytes, stderr: bytes) -> dict[str, Any]:
166+
if self.skip:
167+
return self.parameters.get_result_format() | self.empty_result()
168+
try:
169+
ret = json.loads(stdout)
170+
except json.decoder.JSONDecodeError:
171+
print(f"{self.parameters.get_name_with_position()}: Cannot load fio's JSON output")
172+
print(f"stdout:\n{stdout.decode()}\nstderr:{stderr.decode()}")
173+
return self.parameters.get_result_format() | self.empty_result()
174+
175+
return {"fio_results": ret} | self.parameters.get_result_format()
176+
177+
@property
178+
def name(self) -> str:
179+
return self.engine_module.get_engine().get_name()
180+
181+
def run_cmd_version(self) -> list[str]:
182+
return self.engine_module.get_engine().run_cmd_version()
183+
184+
def parse_version(self, stdout: bytes, _stderr: bytes) -> bytes:
185+
return self.engine_module.get_engine().parse_version(stdout, _stderr)
186+
187+
def empy_result(self):
188+
"""Default empty results for fio"""
189+
return {
190+
"effective_runtime": 0,
191+
"skipped": self.skip,
192+
"fio_results": {"jobs": []},
193+
}
194+
195+
196+
class FioCmdLine(Fio):
197+
def parse_parameters(self):
198+
"""Removing fio arguments set by the engine"""
199+
# We need to ensure we have a proper fio command line
200+
# Let's remove duplicated and enforce some
201+
args = self.parameters.get_engine_module_parameter_base().split()
202+
203+
# Overriding empb to represent the real executed command.
204+
# The list is having unique members and sorted to ensure a constant string representation.
205+
self.parameters.engine_module_parameter_base = " ".join(list(self.get_default_fio_command_line(args)))
206+
207+
def run_cmd(self) -> list[str]:
208+
# Let's build the command line to run the tool
209+
return super().run_cmd() + self.parameters.get_engine_module_parameter_base().split()

0 commit comments

Comments
 (0)