Skip to content

Commit 8d2ffe3

Browse files
committed
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 439d1b9 commit 8d2ffe3

File tree

12 files changed

+402
-2
lines changed

12 files changed

+402
-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
@@ -123,9 +123,9 @@ def pre_run(self):
123123
cpu_location = ""
124124
if p.get_pinned_cpu():
125125
if isinstance(p.get_pinned_cpu(), (int, str)):
126-
cpu_location = " on CPU {:3d}".format(p.get_pinned_cpu())
126+
cpu_location = " pinned on CPU {:3d}".format(p.get_pinned_cpu())
127127
elif isinstance(p.get_pinned_cpu(), list):
128-
cpu_location = " on CPU [{}]".format(
128+
cpu_location = " pinned on CPU [{}]".format(
129129
h.cpu_list_to_range(p.get_pinned_cpu())
130130
)
131131
else:

hwbench/bench/test_fio.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
self.assertIsNone(bench.validate_parameters())
26+
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+
== "--bs=4k --direct=1 --filename=/dev/nvme0n1 --group_reporting \
32+
--invalidate=1 --iodepth=256 --ioengine=libaio --log_avg_msec=20000 --name=randread_cmdline_0 \
33+
--numjobs=4 --output-format=json+ --readonly --runtime=40 --rw=randread --time_based \
34+
--write_bw_log=fio/randread_cmdline_0_bw.log --write_hist_log=fio/randread_cmdline_0_hist.log \
35+
--write_iops_log=fio/randread_cmdline_0_iops.log --write_lat_log=fio/randread_cmdline_0_lat.log"
36+
)
37+
38+
bench_1 = self.get_bench_parameters(1)
39+
assert (
40+
bench_1.get_engine_module_parameter_base()
41+
== "--bs=4k --direct=1 --filename=/dev/nvme0n1 --group_reporting \
42+
--invalidate=1 --iodepth=256 --ioengine=libaio --log_avg_msec=20000 --name=randread_cmdline_1 \
43+
--numjobs=6 --output-format=json+ --readonly --runtime=40 --rw=randread --time_based \
44+
--write_bw_log=fio/randread_cmdline_1_bw.log --write_hist_log=fio/randread_cmdline_1_hist.log \
45+
--write_iops_log=fio/randread_cmdline_1_iops.log --write_lat_log=fio/randread_cmdline_1_lat.log"
46+
)

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

hwbench/engines/fio.py

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

0 commit comments

Comments
 (0)