Skip to content

Commit bacb676

Browse files
authored
Add initial version of benchmark experiment runner (#1266)
In order to investigate performance in Mountpoint, we want to be able to vary different parameters. In fact, it can be very useful to vary these parameters together to see how performance (such as sequential read throughput) changes as we vary two parameters together. This change introduces a new benchmark running script which uses the Python framework Hydra to enumerate combinations of parameters, and then execute some function with each combination. The script manages the lifecycle of the `mount-s3` file system and collecting data into an output folder. The change currently does not reuse the FIO definitions used by our regression benchmarks. In the mid-term, these should be reconciled. This pull request (PR) supersedes a previous PR: #986. ### Does this change impact existing behavior? No, this adds a new benchmark runner and benchmark definitions. This does not impact the Mountpoint file system. ### Does this change need a changelog entry? Does it require a version change? No, no impact to Mountpoint file system or crates. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license and I agree to the terms of the [Developer Certificate of Origin (DCO)](https://developercertificate.org/). --------- Signed-off-by: Daniel Carl Jones <[email protected]>
1 parent d2a50bb commit bacb676

10 files changed

+475
-0
lines changed

benchmark/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/multirun/
2+
/outputs/

benchmark/.python-version

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.11

benchmark/README.md

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Benchmark experiment runner
2+
3+
This project allows to perform some Mountpoint benchmarks with different variables,
4+
such that a number of experiments can be run with ease and the logs
5+
and results be collected in a directory for each experiment run.
6+
7+
The Python script `benchmark.py` handles the setup and teardown for each experiment.
8+
The experiment configuration space is managed using [Hydra](https://hydra.cc/).
9+
Configurations in `conf/` describe which values to configure to run experiments over a set of parameters
10+
such as the maximum count of Mountpoint FUSE workers,
11+
number of application workers reading from unique file handles, etc..
12+
13+
The benchmark script currently supports FIO jobs.
14+
The list is defined in `conf/config.yaml` under the `fio_benchmarks` config entry.
15+
The FIO jobs define what workload they run,
16+
and also use environment variables in the job definition to allow this script to vary parameters.
17+
18+
## Before you start
19+
20+
You should have the environment setup where you want to run the benchmarking experiments.
21+
For instance, this might be an EC2 instance. You also need an S3 bucket to run the workload against.
22+
23+
You should clone this repository to the environment. This tool will build Mountpoint for you.
24+
25+
This project uses [uv](https://github.com/astral-sh/uv) to manage Python environments and dependencies.
26+
27+
Think of `uv` as a close analog of Rust's _cargo_ but for Python.
28+
It will automatically configure a Python virtual environment for you and install the project dependencies.
29+
30+
Assuming `uv` is installed, getting started is (almost) as easy as
31+
running the `benchmark.py` script from this directory!
32+
33+
```sh
34+
uv run benchmark.py --
35+
```
36+
37+
It should tell you that you forgot some arguments for the Python script itself.
38+
39+
## Running the experiment
40+
41+
There are a few variables that are required, such as the S3 bucket used for testing.
42+
You must set this in order to be able to use the benchmark script.
43+
44+
Additionally, you should configure the AWS credentials for Mountpoint.
45+
You might use AWS profiles or set some credentials in the environment.
46+
47+
To run the experiment, you can execute a command like this:
48+
49+
```
50+
uv run benchmark.py -- s3_bucket=amzn-s3-demo-bucket
51+
```
52+
53+
This will run the default experiment, including many different configuration combinations.
54+
Output is written to `multirun/` within directories for the date, time, and experiment number run.
55+
The output directory includes a few different files from an individual experiment run,
56+
including the individual benchmark output `benchmark.log`, FIO output, and Mountpoint logs.

benchmark/benchmark.py

+242
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
from contextlib import contextmanager
2+
from datetime import datetime, timezone
3+
import json
4+
import logging
5+
import os
6+
from os import path
7+
import subprocess
8+
from subprocess import Popen
9+
import tempfile
10+
11+
import hydra
12+
from omegaconf import DictConfig
13+
14+
logging.basicConfig(
15+
level=os.environ.get('LOGLEVEL', 'INFO').upper()
16+
)
17+
18+
log = logging.getLogger(__name__)
19+
20+
MOUNT_DIRECTORY = "s3"
21+
MP_LOGS_DIRECTORY = "mp_logs/"
22+
23+
24+
@contextmanager
25+
def _mounted_bucket(
26+
cfg: DictConfig,
27+
):
28+
"""
29+
Mounts the S3 bucket, providing metadata about the successful mount.
30+
31+
Context manager allows use of `with` clause, automatically unmounting the bucket.
32+
"""
33+
mount_dir = tempfile.mkdtemp(suffix=".mountpoint-s3")
34+
mount_metadata = _mount_mp(cfg, mount_dir)
35+
try:
36+
yield mount_metadata
37+
finally:
38+
try:
39+
subprocess.check_output(["umount", mount_dir])
40+
log.debug(f"{mount_dir} unmounted")
41+
os.rmdir(mount_dir)
42+
except Exception:
43+
log.error(f"Error cleaning up Mountpoint at {mount_dir}:", exc_info=True)
44+
45+
46+
class MountError(Exception):
47+
pass
48+
49+
50+
def _mount_mp(
51+
cfg: DictConfig,
52+
mount_dir: str,
53+
) -> dict[str, any] | MountError | subprocess.CalledProcessError:
54+
"""
55+
Mount an S3 bucket using Mountpoint,
56+
using the configuration to apply Mountpoint arguments.
57+
58+
Returns Mountpoint version string.
59+
"""
60+
61+
if cfg['mountpoint_binary'] is None:
62+
mountpoint_args = [
63+
"cargo",
64+
"run",
65+
"--quiet",
66+
"--release",
67+
"--",
68+
]
69+
else:
70+
mountpoint_args = [cfg['mountpoint_binary']]
71+
72+
os.makedirs(MP_LOGS_DIRECTORY, exist_ok=True)
73+
74+
bucket = cfg['s3_bucket']
75+
76+
mountpoint_version_output = subprocess \
77+
.check_output([
78+
*mountpoint_args,
79+
"--version"
80+
]) \
81+
.decode("utf-8")
82+
log.info("Mountpoint version: %s", mountpoint_version_output.strip())
83+
84+
subprocess_args = [
85+
*mountpoint_args,
86+
bucket,
87+
mount_dir,
88+
"--log-metrics",
89+
"--allow-overwrite",
90+
"--allow-delete",
91+
f"--log-directory={MP_LOGS_DIRECTORY}",
92+
]
93+
subprocess_env = {
94+
"PATH": os.environ["PATH"],
95+
}
96+
97+
if cfg['s3_prefix'] is not None:
98+
subprocess_args.append(f"--prefix={cfg['s3_prefix']}")
99+
100+
if cfg['mountpoint_debug']:
101+
subprocess_args.append("--debug")
102+
if cfg['mountpoint_debug_crt']:
103+
subprocess_args.append("--debug-crt")
104+
105+
if cfg["read_part_size"]:
106+
subprocess_args.append(f"--read-part-size={cfg['read_part_size']}")
107+
if cfg["write_part_size"]:
108+
subprocess_args.append(f"--write-part-size={cfg['write_part_size']}")
109+
110+
if cfg['metadata_ttl'] is not None:
111+
subprocess_args.append(f"--metadata-ttl={cfg['metadata_ttl']}")
112+
113+
if cfg['upload_checksums'] is not None:
114+
subprocess_args.append(f"--upload-checksums={cfg['upload_checksums']}")
115+
116+
if cfg['fuse_threads'] is not None:
117+
subprocess_args.append(f"--max-threads={cfg['fuse_threads']}")
118+
119+
log.info(f"Mounting S3 bucket {bucket} with args: %s; env: %s", subprocess_args, subprocess_env)
120+
try:
121+
output = subprocess.check_output(subprocess_args, env=subprocess_env)
122+
except subprocess.CalledProcessError as e:
123+
log.error(f"Error during mounting: {e}")
124+
raise MountError() from e
125+
126+
log.info("Mountpoint output: %s", output.decode("utf-8").strip())
127+
128+
return {
129+
"mount_dir": mount_dir,
130+
"mount_s3_command": " ".join(subprocess_args),
131+
"mount_s3_env": subprocess_env,
132+
"mp_version": mountpoint_version_output.strip(),
133+
}
134+
135+
136+
def _run_fio(cfg: DictConfig, mount_dir: str) -> None:
137+
"""
138+
Run the FIO workload against the file system.
139+
"""
140+
FIO_BINARY = "fio"
141+
fio_job_name = cfg["fio_benchmark"]
142+
fio_output_filepath = f"fio.{fio_job_name}.json"
143+
144+
# TODO: Avoid duplicating/diverging the FIO jobs between `benchmark/fio/` and `mountpoint-s3/scripts/fio/`
145+
fio_job_filepath = hydra.utils.to_absolute_path(f"fio/{fio_job_name}.fio")
146+
subprocess_args = [
147+
FIO_BINARY,
148+
"--eta=never",
149+
"--output-format=json",
150+
f"--output={fio_output_filepath}",
151+
f"--directory={mount_dir}",
152+
fio_job_filepath,
153+
]
154+
subprocess_env = {
155+
"PATH": os.environ["PATH"],
156+
"APP_WORKERS": str(cfg['application_workers']),
157+
"SIZE_GIB": "100",
158+
"DIRECT": "1" if cfg['direct_io'] else "0",
159+
"UNIQUE_DIR": datetime.now(tz=timezone.utc).isoformat(),
160+
# TODO: Confirm assumption that `libaio` should make direct IO go faster.
161+
# TODO: Review if we should use sync or psync. We use `sync` in other benchmarks.
162+
"IO_ENGINE": "libaio" if cfg['direct_io'] else "psync",
163+
}
164+
log.info("Running FIO with args: %s; env: %s", subprocess_args, subprocess_env)
165+
166+
# Use Popen instead of check_output, as we had some issues when trying to attach perf
167+
with Popen(subprocess_args, env=subprocess_env) as process:
168+
exit_code = process.wait()
169+
if exit_code != 0:
170+
log.error(f"FIO process failed with exit code {exit_code}")
171+
raise subprocess.CalledProcessError(exit_code, subprocess_args)
172+
else:
173+
log.info("FIO process completed successfully")
174+
175+
176+
def _collect_logs() -> None:
177+
"""
178+
Collect the Mountpoint log if it exists and move to the output directory.
179+
Mountpoint log filename will be normalized removing the date, etc..
180+
The old log directory is removed.
181+
182+
Fails if more than one log file is found.
183+
"""
184+
logs_directory = path.join(os.getcwd(), MP_LOGS_DIRECTORY)
185+
dir_entries = os.listdir(logs_directory)
186+
187+
if not dir_entries:
188+
log.debug(f"No Mountpoint log files in directory {logs_directory}")
189+
return
190+
191+
assert len(dir_entries) <= 1, f"Expected no more than one log file in {logs_directory}"
192+
193+
old_log_dir = path.join(logs_directory, dir_entries[0])
194+
new_log_path = "mountpoint-s3.log"
195+
log.debug(f"Renaming {old_log_dir} to {new_log_path}")
196+
os.rename(old_log_dir, new_log_path)
197+
os.rmdir(logs_directory)
198+
199+
200+
def _write_metadata(metadata: dict[str, any]) -> None:
201+
with open("metadata.json", "w") as f:
202+
json.dump(metadata, f, default=str)
203+
204+
205+
def _postprocessing(metadata: dict[str, any]) -> None:
206+
_collect_logs()
207+
_write_metadata(metadata)
208+
209+
210+
@hydra.main(version_base=None, config_path="conf", config_name="config")
211+
def run_experiment(cfg: DictConfig) -> None:
212+
"""
213+
At a high level, we want to mount the S3 bucket using Mountpoint,
214+
run a synthetic workload against Mountpoint while capturing metrics and logs,
215+
then end the load and unmount the bucket.
216+
217+
We should collect all of the logs and metric and dump them in the output directory.
218+
"""
219+
log.debug("Experiment starting")
220+
metadata = {
221+
"start_time": datetime.now(tz=timezone.utc),
222+
"success": False,
223+
}
224+
225+
with _mounted_bucket(cfg) as mount_metadata:
226+
metadata.update(mount_metadata)
227+
mount_dir = mount_metadata["mount_dir"]
228+
try:
229+
# TODO: Add resource monitoring during FIO job
230+
_run_fio(cfg, mount_dir)
231+
metadata["success"] = True
232+
except Exception as e:
233+
log.error(f"Error running experiment: {e}")
234+
235+
metadata["end_time"] = datetime.now(tz=timezone.utc)
236+
237+
_postprocessing(metadata)
238+
log.info("Experiment ended")
239+
240+
241+
if __name__ == "__main__":
242+
run_experiment()

benchmark/conf/config.yaml

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# This file (and others in `conf/`) specify some static parameters,
2+
# as well as some which will be 'swept' over for experiments.
3+
4+
defaults:
5+
- _self_
6+
7+
s3_bucket: ???
8+
s3_prefix: !!null
9+
10+
read_part_size: !!null
11+
write_part_size: 16777216 # to allow for uploads of 100GiB
12+
13+
metadata_ttl: "indefinite"
14+
15+
fio_benchmarks: sequential_read #, random_read, sequential_write
16+
17+
# Path to Mountpoint binary. Recommended to use an absolute path.
18+
mountpoint_binary: !!null
19+
mountpoint_debug: false
20+
mountpoint_debug_crt: false
21+
22+
# For overriding upload checksums configured for Mountpoint. Passed as `--upload-checksums` argument.
23+
upload_checksums: !!null
24+
25+
iterations: 1
26+
27+
hydra:
28+
help:
29+
app_name: "Mountpoint sequential read experiment runner"
30+
mode: MULTIRUN
31+
job:
32+
chdir: true
33+
sweeper:
34+
params:
35+
# Maximum number of FUSE threads for Mountpoint. Passed as `--max-threads` argument.
36+
'+fuse_threads': 16, 32, 64, 128
37+
# Number of processes that will be interacting with the file.
38+
'+application_workers': 1, 4, 8, 16, 32, 64, 128, 256
39+
# Configure if application should use Direct IO, skipping the Linux page cache
40+
'+direct_io': false, true
41+
# Don't touch the params below, they are based on settings above.
42+
'+fio_benchmark': "${fio_benchmarks}"
43+
'+iteration': "range(${iterations})"

benchmark/fio/global_incl.fio

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
bs=256k ; no. of bytes
2+
runtime=30s
3+
time_based
4+
group_reporting
5+
; Use multiple threads instead of processes to parallelize IO
6+
thread
7+
size=${SIZE_GIB}Gi
8+
; Do not make this non-zero, otherwise prefetcher can buffer before ramp ends
9+
ramp_time=0s
10+
ioengine=${IO_ENGINE}
11+
numjobs=${APP_WORKERS}
12+
openfiles=${APP_WORKERS}
13+
direct=${DIRECT}

benchmark/fio/sequential_read.fio

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[global]
2+
include global_incl.fio
3+
4+
[sequential_read]
5+
filename_format=j$jobnum_${SIZE_GIB}GiB.bin
6+
size=${SIZE_GIB}Gi
7+
rw=read
8+
fallocate=none

benchmark/fio/sequential_write.fio

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[global]
2+
include global_incl.fio
3+
4+
[sequential_write]
5+
; We use a unique directory to ensure its a new file. There's no way to specify O_TRUNCATE.
6+
filename_format=${UNIQUE_DIR}/$jobname/$jobnum.bin
7+
rw=write
8+
fallocate=none
9+
create_on_open=1
10+
fsync_on_close=1
11+
unlink=1

benchmark/pyproject.toml

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "benchmark"
3+
version = "0.1.0"
4+
description = "Benchmark runner for measuring Mountpoint performance while varying configuration"
5+
readme = "README.md"
6+
requires-python = ">=3.11"
7+
dependencies = [
8+
"hydra-core>=1.3.2",
9+
]

0 commit comments

Comments
 (0)