t/nvmept.py: test script for io_uring_cmd NVMe pass through
authorVincent Fu <vincent.fu@samsung.com>
Fri, 26 May 2023 23:05:12 +0000 (23:05 +0000)
committerVincent Fu <vincent.fu@samsung.com>
Thu, 1 Jun 2023 18:11:33 +0000 (14:11 -0400)
Test basic functionality of the io_uring_cmd ioengine with NVMe
pass-through commands.

This script starts with basic sequential and random read, write, trim,
readwrite, and trimwrite workloads. It also runs sequential and random
write workloads with verification. Finally it runs random read and write
workloads with almost all of the ioengine options enabled. The only
ioengine option not exercised is hipri because the NVMe driver must be
loaded with poll_queues set for this to work.

Since this is a destructive test the target device must be specified on
the command line.

Signed-off-by: Vincent Fu <vincent.fu@samsung.com>
t/nvmept.py [new file with mode: 0755]

diff --git a/t/nvmept.py b/t/nvmept.py
new file mode 100755 (executable)
index 0000000..a25192f
--- /dev/null
@@ -0,0 +1,414 @@
+#!/usr/bin/env python3
+"""
+# nvmept.py
+#
+# Test fio's io_uring_cmd ioengine with NVMe pass-through commands.
+#
+# USAGE
+# see python3 nvmept.py --help
+#
+# EXAMPLES
+# python3 t/nvmept.py --dut /dev/ng0n1
+# python3 t/nvmept.py --dut /dev/ng1n1 -f ./fio
+#
+# REQUIREMENTS
+# Python 3.6
+#
+"""
+import os
+import sys
+import json
+import time
+import locale
+import argparse
+import subprocess
+from pathlib import Path
+
+class FioTest():
+    """fio test."""
+
+    def __init__(self, artifact_root, test_opts, debug):
+        """
+        artifact_root   root directory for artifacts (subdirectory will be created under here)
+        test            test specification
+        """
+        self.artifact_root = artifact_root
+        self.test_opts = test_opts
+        self.debug = debug
+        self.filename_stub = None
+        self.filenames = {}
+        self.json_data = None
+
+        self.test_dir = os.path.abspath(os.path.join(self.artifact_root,
+                                     f"{self.test_opts['test_id']:03d}"))
+        if not os.path.exists(self.test_dir):
+            os.mkdir(self.test_dir)
+
+        self.filename_stub = f"pt{self.test_opts['test_id']:03d}"
+        self.filenames['command'] = os.path.join(self.test_dir, f"{self.filename_stub}.command")
+        self.filenames['stdout'] = os.path.join(self.test_dir, f"{self.filename_stub}.stdout")
+        self.filenames['stderr'] = os.path.join(self.test_dir, f"{self.filename_stub}.stderr")
+        self.filenames['exitcode'] = os.path.join(self.test_dir, f"{self.filename_stub}.exitcode")
+        self.filenames['output'] = os.path.join(self.test_dir, f"{self.filename_stub}.output")
+
+    def run_fio(self, fio_path):
+        """Run a test."""
+
+        fio_args = [
+            "--name=nvmept",
+            "--ioengine=io_uring_cmd",
+            "--cmd_type=nvme",
+            "--iodepth=8",
+            "--iodepth_batch=4",
+            "--iodepth_batch_complete=4",
+            f"--filename={self.test_opts['filename']}",
+            f"--rw={self.test_opts['rw']}",
+            f"--output={self.filenames['output']}",
+            f"--output-format={self.test_opts['output-format']}",
+        ]
+        for opt in ['fixedbufs', 'nonvectored', 'force_async', 'registerfiles',
+                    'sqthread_poll', 'sqthread_poll_cpu', 'hipri', 'nowait',
+                    'time_based', 'runtime', 'verify', 'io_size']:
+            if opt in self.test_opts:
+                option = f"--{opt}={self.test_opts[opt]}"
+                fio_args.append(option)
+
+        command = [fio_path] + fio_args
+        with open(self.filenames['command'], "w+",
+                  encoding=locale.getpreferredencoding()) as command_file:
+            command_file.write(" ".join(command))
+
+        passed = True
+
+        try:
+            with open(self.filenames['stdout'], "w+",
+                      encoding=locale.getpreferredencoding()) as stdout_file, \
+                open(self.filenames['stderr'], "w+",
+                     encoding=locale.getpreferredencoding()) as stderr_file, \
+                open(self.filenames['exitcode'], "w+",
+                     encoding=locale.getpreferredencoding()) as exitcode_file:
+                proc = None
+                # Avoid using subprocess.run() here because when a timeout occurs,
+                # fio will be stopped with SIGKILL. This does not give fio a
+                # chance to clean up and means that child processes may continue
+                # running and submitting IO.
+                proc = subprocess.Popen(command,
+                                        stdout=stdout_file,
+                                        stderr=stderr_file,
+                                        cwd=self.test_dir,
+                                        universal_newlines=True)
+                proc.communicate(timeout=300)
+                exitcode_file.write(f'{proc.returncode}\n')
+                passed &= (proc.returncode == 0)
+        except subprocess.TimeoutExpired:
+            proc.terminate()
+            proc.communicate()
+            assert proc.poll()
+            print("Timeout expired")
+            passed = False
+        except Exception:
+            if proc:
+                if not proc.poll():
+                    proc.terminate()
+                    proc.communicate()
+            print(f"Exception: {sys.exc_info()}")
+            passed = False
+
+        if passed:
+            if 'output-format' in self.test_opts and 'json' in \
+                    self.test_opts['output-format']:
+                if not self.get_json():
+                    print('Unable to decode JSON data')
+                    passed = False
+
+        return passed
+
+    def get_json(self):
+        """Convert fio JSON output into a python JSON object"""
+
+        filename = self.filenames['output']
+        with open(filename, 'r', encoding=locale.getpreferredencoding()) as file:
+            file_data = file.read()
+
+        #
+        # Sometimes fio informational messages are included at the top of the
+        # JSON output, especially under Windows. Try to decode output as JSON
+        # data, lopping off up to the first four lines
+        #
+        lines = file_data.splitlines()
+        for i in range(5):
+            file_data = '\n'.join(lines[i:])
+            try:
+                self.json_data = json.loads(file_data)
+            except json.JSONDecodeError:
+                continue
+            else:
+                return True
+
+        return False
+
+    @staticmethod
+    def check_empty(job):
+        """
+        Make sure JSON data is empty.
+
+        Some data structures should be empty. This function makes sure that they are.
+
+        job         JSON object that we need to check for emptiness
+        """
+
+        return job['total_ios'] == 0 and \
+                job['slat_ns']['N'] == 0 and \
+                job['clat_ns']['N'] == 0 and \
+                job['lat_ns']['N'] == 0
+
+    def check_all_ddirs(self, ddir_nonzero, job):
+        """
+        Iterate over the data directions and check whether each is
+        appropriately empty or not.
+        """
+
+        retval = True
+        ddirlist = ['read', 'write', 'trim']
+
+        for ddir in ddirlist:
+            if ddir in ddir_nonzero:
+                if self.check_empty(job[ddir]):
+                    print(f"Unexpected zero {ddir} data found in output")
+                    retval = False
+            else:
+                if not self.check_empty(job[ddir]):
+                    print(f"Unexpected {ddir} data found in output")
+                    retval = False
+
+        return retval
+
+    def check(self):
+        """Check test output."""
+
+        raise NotImplementedError()
+
+
+class PTTest(FioTest):
+    """
+    NVMe pass-through test class. Check to make sure output for selected data
+    direction(s) is non-zero and that zero data appears for other directions.
+    """
+
+    def check(self):
+        if 'rw' not in self.test_opts:
+            return True
+
+        job = self.json_data['jobs'][0]
+        retval = True
+
+        if self.test_opts['rw'] in ['read', 'randread']:
+            retval = self.check_all_ddirs(['read'], job)
+        elif self.test_opts['rw'] in ['write', 'randwrite']:
+            if 'verify' not in self.test_opts:
+                retval = self.check_all_ddirs(['write'], job)
+            else:
+                retval = self.check_all_ddirs(['read', 'write'], job)
+        elif self.test_opts['rw'] in ['trim', 'randtrim']:
+            retval = self.check_all_ddirs(['trim'], job)
+        elif self.test_opts['rw'] in ['readwrite', 'randrw']:
+            retval = self.check_all_ddirs(['read', 'write'], job)
+        elif self.test_opts['rw'] in ['trimwrite', 'randtrimwrite']:
+            retval = self.check_all_ddirs(['trim', 'write'], job)
+        else:
+            print(f"Unhandled rw value {self.test_opts['rw']}")
+            retval = False
+
+        return retval
+
+
+def parse_args():
+    """Parse command-line arguments."""
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-f', '--fio', help='path to file executable (e.g., ./fio)')
+    parser.add_argument('-a', '--artifact-root', help='artifact root directory')
+    parser.add_argument('-d', '--debug', help='enable debug output', action='store_true')
+    parser.add_argument('-s', '--skip', nargs='+', type=int,
+                        help='list of test(s) to skip')
+    parser.add_argument('-o', '--run-only', nargs='+', type=int,
+                        help='list of test(s) to run, skipping all others')
+    parser.add_argument('--dut', help='target NVMe character device to test '
+                        '(e.g., /dev/ng0n1). WARNING: THIS IS A DESTRUCTIVE TEST', required=True)
+    args = parser.parse_args()
+
+    return args
+
+
+def main():
+    """Run tests using fio's io_uring_cmd ioengine to send NVMe pass through commands."""
+
+    args = parse_args()
+
+    artifact_root = args.artifact_root if args.artifact_root else \
+        f"nvmept-test-{time.strftime('%Y%m%d-%H%M%S')}"
+    os.mkdir(artifact_root)
+    print(f"Artifact directory is {artifact_root}")
+
+    if args.fio:
+        fio = str(Path(args.fio).absolute())
+    else:
+        fio = 'fio'
+    print(f"fio path is {fio}")
+
+    test_list = [
+        {
+            "test_id": 1,
+            "rw": 'read',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 2,
+            "rw": 'randread',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 3,
+            "rw": 'write',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 4,
+            "rw": 'randwrite',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 5,
+            "rw": 'trim',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 6,
+            "rw": 'randtrim',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 7,
+            "rw": 'write',
+            "io_size": 1024*1024,
+            "verify": "crc32c",
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 8,
+            "rw": 'randwrite',
+            "io_size": 1024*1024,
+            "verify": "crc32c",
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 9,
+            "rw": 'readwrite',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 10,
+            "rw": 'randrw',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 11,
+            "rw": 'trimwrite',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 12,
+            "rw": 'randtrimwrite',
+            "timebased": 1,
+            "runtime": 3,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 13,
+            "rw": 'randread',
+            "timebased": 1,
+            "runtime": 3,
+            "fixedbufs": 1,
+            "nonvectored": 1,
+            "force_async": 1,
+            "registerfiles": 1,
+            "sqthread_poll": 1,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+        {
+            "test_id": 14,
+            "rw": 'randwrite',
+            "timebased": 1,
+            "runtime": 3,
+            "fixedbufs": 1,
+            "nonvectored": 1,
+            "force_async": 1,
+            "registerfiles": 1,
+            "sqthread_poll": 1,
+            "output-format": "json",
+            "test_obj": PTTest,
+        },
+    ]
+
+    passed = 0
+    failed = 0
+    skipped = 0
+
+    for test in test_list:
+        if (args.skip and test['test_id'] in args.skip) or \
+           (args.run_only and test['test_id'] not in args.run_only):
+            skipped = skipped + 1
+            outcome = 'SKIPPED (User request)'
+        else:
+            test['filename'] = args.dut
+            test_obj = test['test_obj'](artifact_root, test, args.debug)
+            status = test_obj.run_fio(fio)
+            if status:
+                status = test_obj.check()
+            if status:
+                passed = passed + 1
+                outcome = 'PASSED'
+            else:
+                failed = failed + 1
+                outcome = 'FAILED'
+
+        print(f"**********Test {test['test_id']} {outcome}**********")
+
+    print(f"{passed} tests passed, {failed} failed, {skipped} skipped")
+
+    sys.exit(failed)
+
+
+if __name__ == '__main__':
+    main()