t/nvmept_trim.py: test multi-range trim
authorVincent Fu <vincent.fu@samsung.com>
Tue, 12 Dec 2023 18:42:20 +0000 (18:42 +0000)
committerVincent Fu <vincent.fu@samsung.com>
Thu, 15 Feb 2024 19:05:12 +0000 (14:05 -0500)
This test script contains some regression tests for existing
functionality and also some tests for the new feature.

The multi-range trim tests basically count the number of requests
submitted and make sure that they are consistent with the block sizes
and number of ranges per request.

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

diff --git a/t/nvmept_trim.py b/t/nvmept_trim.py
new file mode 100755 (executable)
index 0000000..5756838
--- /dev/null
@@ -0,0 +1,586 @@
+#!/usr/bin/env python3
+#
+# Copyright 2024 Samsung Electronics Co., Ltd All Rights Reserved
+#
+# For conditions of distribution and use, see the accompanying COPYING file.
+#
+"""
+# nvmept_trim.py
+#
+# Test fio's io_uring_cmd ioengine with NVMe pass-through dataset management
+# commands that trim multiple ranges.
+#
+# USAGE
+# see python3 nvmept_trim.py --help
+#
+# EXAMPLES
+# python3 t/nvmept_trim.py --dut /dev/ng0n1
+# python3 t/nvmept_trim.py --dut /dev/ng1n1 -f ./fio
+#
+# REQUIREMENTS
+# Python 3.6
+#
+"""
+import os
+import sys
+import time
+import logging
+import argparse
+from pathlib import Path
+from fiotestlib import FioJobCmdTest, run_fio_tests
+from fiotestcommon import SUCCESS_NONZERO
+
+
+class TrimTest(FioJobCmdTest):
+    """
+    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 setup(self, parameters):
+        """Setup a test."""
+
+        fio_args = [
+            "--name=nvmept-trim",
+            "--ioengine=io_uring_cmd",
+            "--cmd_type=nvme",
+            f"--filename={self.fio_opts['filename']}",
+            f"--rw={self.fio_opts['rw']}",
+            f"--output={self.filenames['output']}",
+            f"--output-format={self.fio_opts['output-format']}",
+        ]
+        for opt in ['fixedbufs', 'nonvectored', 'force_async', 'registerfiles',
+                    'sqthread_poll', 'sqthread_poll_cpu', 'hipri', 'nowait',
+                    'time_based', 'runtime', 'verify', 'io_size', 'num_range',
+                    'iodepth', 'iodepth_batch', 'iodepth_batch_complete',
+                    'size', 'rate', 'bs', 'bssplit', 'bsrange', 'randrepeat',
+                    'buffer_pattern', 'verify_pattern', 'verify', 'offset']:
+            if opt in self.fio_opts:
+                option = f"--{opt}={self.fio_opts[opt]}"
+                fio_args.append(option)
+
+        super().setup(fio_args)
+
+
+    def check_result(self):
+
+        super().check_result()
+
+        if 'rw' not in self.fio_opts or \
+                not self.passed or \
+                'json' not in self.fio_opts['output-format']:
+            return
+
+        job = self.json_data['jobs'][0]
+
+        if self.fio_opts['rw'] in ['read', 'randread']:
+            self.passed = self.check_all_ddirs(['read'], job)
+        elif self.fio_opts['rw'] in ['write', 'randwrite']:
+            if 'verify' not in self.fio_opts:
+                self.passed = self.check_all_ddirs(['write'], job)
+            else:
+                self.passed = self.check_all_ddirs(['read', 'write'], job)
+        elif self.fio_opts['rw'] in ['trim', 'randtrim']:
+            self.passed = self.check_all_ddirs(['trim'], job)
+        elif self.fio_opts['rw'] in ['readwrite', 'randrw']:
+            self.passed = self.check_all_ddirs(['read', 'write'], job)
+        elif self.fio_opts['rw'] in ['trimwrite', 'randtrimwrite']:
+            self.passed = self.check_all_ddirs(['trim', 'write'], job)
+        else:
+            logging.error("Unhandled rw value %s", self.fio_opts['rw'])
+            self.passed = False
+
+        if 'iodepth' in self.fio_opts:
+            # We will need to figure something out if any test uses an iodepth
+            # different from 8
+            if job['iodepth_level']['8'] < 95:
+                logging.error("Did not achieve requested iodepth")
+                self.passed = False
+            else:
+                logging.debug("iodepth 8 target met %s", job['iodepth_level']['8'])
+
+
+class RangeTrimTest(TrimTest):
+    """
+    Multi-range trim test class.
+    """
+
+    def get_bs(self):
+        """Calculate block size and determine whether bs will be an average or exact."""
+
+        if 'bs' in self.fio_opts:
+            exact_size = True
+            bs = self.fio_opts['bs']
+        elif 'bssplit' in self.fio_opts:
+            exact_size = False
+            bs = 0
+            total = 0
+            for split in self.fio_opts['bssplit'].split(':'):
+                [blocksize, share] = split.split('/')
+                total += int(share)
+                bs += int(blocksize) * int(share) / 100
+            if total != 100:
+                logging.error("bssplit '%s' total percentage is not 100", self.fio_opts['bssplit'])
+                self.passed = False
+            else:
+                logging.debug("bssplit: average block size is %d", int(bs))
+            # The only check we do here for bssplit is to calculate an average
+            # blocksize and see if the IOPS and bw are consistent
+        elif 'bsrange' in self.fio_opts:
+            exact_size = False
+            [minbs, maxbs] = self.fio_opts['bsrange'].split('-')
+            minbs = int(minbs)
+            maxbs = int(maxbs)
+            bs = int((minbs + maxbs) / 2)
+            logging.debug("bsrange: average block size is %d", int(bs))
+            # The only check we do here for bsrange is to calculate an average
+            # blocksize and see if the IOPS and bw are consistent
+        else:
+            exact_size = True
+            bs = 4096
+
+        return bs, exact_size
+
+
+    def check_result(self):
+        """
+        Make sure that the number of IO requests is consistent with the
+        blocksize and num_range values. In other words, if the blocksize is
+        4KiB and num_range is 2, we should have 128 IO requests to trim 1MiB.
+        """
+        # TODO Enable debug output to check the actual offsets
+
+        super().check_result()
+
+        if not self.passed or 'json' not in self.fio_opts['output-format']:
+            return
+
+        job = self.json_data['jobs'][0]['trim']
+        bs, exact_size = self.get_bs()
+
+        # make sure bw and IOPS are consistent
+        bw = job['bw_bytes']
+        iops = job['iops']
+        runtime = job['runtime']
+
+        calculated = int(bw*runtime/1000)
+        expected = job['io_bytes']
+        if abs(calculated - expected) / expected > 0.05:
+            logging.error("Total bytes %d from bw does not match reported total bytes %d",
+                          calculated, expected)
+            self.passed = False
+        else:
+            logging.debug("Total bytes %d from bw matches reported total bytes %d", calculated,
+                          expected)
+
+        calculated = int(iops*runtime/1000*bs*self.fio_opts['num_range'])
+        if abs(calculated - expected) / expected > 0.05:
+            logging.error("Total bytes %d from IOPS does not match reported total bytes %d",
+                          calculated, expected)
+            self.passed = False
+        else:
+            logging.debug("Total bytes %d from IOPS matches reported total bytes %d", calculated,
+                          expected)
+
+        if 'size' in self.fio_opts:
+            io_count = self.fio_opts['size'] / self.fio_opts['num_range'] / bs
+            if exact_size:
+                delta = 0.1
+            else:
+                delta = 0.05*job['total_ios']
+
+            if abs(job['total_ios'] - io_count) > delta:
+                logging.error("Expected numbers of IOs %d does not match actual value %d",
+                              io_count, job['total_ios'])
+                self.passed = False
+            else:
+                logging.debug("Expected numbers of IOs %d matches actual value %d", io_count,
+                              job['total_ios'])
+
+        if 'rate' in self.fio_opts:
+            if abs(bw - self.fio_opts['rate']) / self.fio_opts['rate'] > 0.05:
+                logging.error("Actual rate %f does not match expected rate %f", bw,
+                              self.fio_opts['rate'])
+                self.passed = False
+            else:
+                logging.debug("Actual rate %f matches expeected rate %f", bw, self.fio_opts['rate'])
+
+
+
+TEST_LIST = [
+    # The group of tests below checks existing use cases to make sure there are
+    # no regressions.
+    {
+        "test_id": 1,
+        "fio_opts": {
+            "rw": 'trim',
+            "time_based": 1,
+            "runtime": 3,
+            "output-format": "json",
+            },
+        "test_class": TrimTest,
+    },
+    {
+        "test_id": 2,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "time_based": 1,
+            "runtime": 3,
+            "output-format": "json",
+            },
+        "test_class": TrimTest,
+    },
+    {
+        "test_id": 3,
+        "fio_opts": {
+            "rw": 'trim',
+            "time_based": 1,
+            "runtime": 3,
+            "iodepth": 8,
+            "iodepth_batch": 4,
+            "iodepth_batch_complete": 4,
+            "output-format": "json",
+            },
+        "test_class": TrimTest,
+    },
+    {
+        "test_id": 4,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "time_based": 1,
+            "runtime": 3,
+            "iodepth": 8,
+            "iodepth_batch": 4,
+            "iodepth_batch_complete": 4,
+            "output-format": "json",
+            },
+        "test_class": TrimTest,
+    },
+    {
+        "test_id": 5,
+        "fio_opts": {
+            "rw": 'trimwrite',
+            "time_based": 1,
+            "runtime": 3,
+            "output-format": "json",
+            },
+        "test_class": TrimTest,
+    },
+    {
+        "test_id": 6,
+        "fio_opts": {
+            "rw": 'randtrimwrite',
+            "time_based": 1,
+            "runtime": 3,
+            "output-format": "json",
+            },
+        "test_class": TrimTest,
+    },
+    {
+        "test_id": 7,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "time_based": 1,
+            "runtime": 3,
+            "fixedbufs": 0,
+            "nonvectored": 1,
+            "force_async": 1,
+            "registerfiles": 1,
+            "sqthread_poll": 1,
+            "fixedbuffs": 1,
+            "output-format": "json",
+            },
+        "test_class": TrimTest,
+    },
+    # The group of tests below try out the new functionality
+    {
+        "test_id": 100,
+        "fio_opts": {
+            "rw": 'trim',
+            "num_range": 2,
+            "size": 16*1024*1024,
+            "output-format": "json",
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 101,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 2,
+            "size": 16*1024*1024,
+            "output-format": "json",
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 102,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 256,
+            "size": 64*1024*1024,
+            "output-format": "json",
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 103,
+        "fio_opts": {
+            "rw": 'trim',
+            "num_range": 2,
+            "bs": 16*1024,
+            "size": 32*1024*1024,
+            "output-format": "json",
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 104,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 2,
+            "bs": 16*1024,
+            "size": 32*1024*1024,
+            "output-format": "json",
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 105,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 2,
+            "bssplit": "4096/50:16384/50",
+            "size": 80*1024*1024,
+            "output-format": "json",
+            "randrepeat": 0,
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 106,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 4,
+            "bssplit": "4096/25:8192/25:12288/25:16384/25",
+            "size": 80*1024*1024,
+            "output-format": "json",
+            "randrepeat": 0,
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 107,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 4,
+            "bssplit": "4096/20:8192/20:12288/20:16384/20:20480/20",
+            "size": 72*1024*1024,
+            "output-format": "json",
+            "randrepeat": 0,
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 108,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 2,
+            "bsrange": "4096-16384",
+            "size": 80*1024*1024,
+            "output-format": "json",
+            "randrepeat": 0,
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 109,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 4,
+            "bsrange": "4096-20480",
+            "size": 72*1024*1024,
+            "output-format": "json",
+            "randrepeat": 0,
+            },
+        "test_class": RangeTrimTest,
+    },
+    {
+        "test_id": 110,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "time_based": 1,
+            "runtime": 10,
+            "rate": 1024*1024,
+            "num_range": 2,
+            "output-format": "json",
+            },
+        "test_class": RangeTrimTest,
+    },
+    # All of the tests below should fail
+    # TODO check the error messages resulting from the jobs below
+    {
+        "test_id": 200,
+        "fio_opts": {
+            "rw": 'randtrimwrite',
+            "time_based": 1,
+            "runtime": 10,
+            "rate": 1024*1024,
+            "num_range": 2,
+            "output-format": "normal",
+            },
+        "test_class": RangeTrimTest,
+        "success": SUCCESS_NONZERO,
+    },
+    {
+        "test_id": 201,
+        "fio_opts": {
+            "rw": 'trimwrite',
+            "time_based": 1,
+            "runtime": 10,
+            "rate": 1024*1024,
+            "num_range": 2,
+            "output-format": "normal",
+            },
+        "test_class": RangeTrimTest,
+        "success": SUCCESS_NONZERO,
+    },
+    {
+        "test_id": 202,
+        "fio_opts": {
+            "rw": 'trim',
+            "time_based": 1,
+            "runtime": 10,
+            "num_range": 257,
+            "output-format": "normal",
+            },
+        "test_class": RangeTrimTest,
+        "success": SUCCESS_NONZERO,
+    },
+    # The sequence of jobs below constitute a single test with multiple steps
+    # - write a data pattern
+    # - verify the data pattern
+    # - trim the first half of the LBA space
+    # - verify that the trim'd LBA space no longer returns the original data pattern
+    # - verify that the remaining LBA space has the expected pattern
+    {
+        "test_id": 300,
+        "fio_opts": {
+            "rw": 'write',
+            "output-format": 'json',
+            "buffer_pattern": 0x0f,
+            "size": 256*1024*1024,
+            },
+        "test_class": TrimTest,
+    },
+    {
+        "test_id": 301,
+        "fio_opts": {
+            "rw": 'read',
+            "output-format": 'json',
+            "verify_pattern": 0x0f,
+            "verify": "pattern",
+            "size": 256*1024*1024,
+            },
+        "test_class": TrimTest,
+    },
+    {
+        "test_id": 302,
+        "fio_opts": {
+            "rw": 'randtrim',
+            "num_range": 8,
+            "output-format": 'json',
+            "size": 128*1024*1024,
+            },
+        "test_class": TrimTest,
+    },
+    # The identify namespace data structure has a DLFEAT field which specifies
+    # what happens when reading data from deallocated blocks. There are three
+    # options:
+    # - read behavior not reported
+    # - deallocated logical block returns all bytes 0x0
+    # - deallocated logical block returns all bytes 0xff
+    # The test below merely checks that the original data pattern is not returned.
+    # Source: Figure 97 from
+    # https://nvmexpress.org/wp-content/uploads/NVM-Express-NVM-Command-Set-Specification-1.0c-2022.10.03-Ratified.pdf
+    {
+        "test_id": 303,
+        "fio_opts": {
+            "rw": 'read',
+            "output-format": 'json',
+            "verify_pattern": 0x0f,
+            "verify": "pattern",
+            "size": 128*1024*1024,
+            },
+        "test_class": TrimTest,
+        "success": SUCCESS_NONZERO,
+    },
+    {
+        "test_id": 304,
+        "fio_opts": {
+            "rw": 'read',
+            "output-format": 'json',
+            "verify_pattern": 0x0f,
+            "verify": "pattern",
+            "offset": 128*1024*1024,
+            "size": 128*1024*1024,
+            },
+        "test_class": TrimTest,
+    },
+]
+
+def parse_args():
+    """Parse command-line arguments."""
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-d', '--debug', help='Enable debug messages', action='store_true')
+    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('-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()
+
+    if args.debug:
+        logging.basicConfig(level=logging.DEBUG)
+    else:
+        logging.basicConfig(level=logging.INFO)
+
+    artifact_root = args.artifact_root if args.artifact_root else \
+        f"nvmept-trim-test-{time.strftime('%Y%m%d-%H%M%S')}"
+    os.mkdir(artifact_root)
+    print(f"Artifact directory is {artifact_root}")
+
+    if args.fio:
+        fio_path = str(Path(args.fio).absolute())
+    else:
+        fio_path = 'fio'
+    print(f"fio path is {fio_path}")
+
+    for test in TEST_LIST:
+        test['fio_opts']['filename'] = args.dut
+
+    test_env = {
+              'fio_path': fio_path,
+              'fio_root': str(Path(__file__).absolute().parent.parent),
+              'artifact_root': artifact_root,
+              'basename': 'nvmept-trim',
+              }
+
+    _, failed, _ = run_fio_tests(TEST_LIST, test_env, args)
+    sys.exit(failed)
+
+
+if __name__ == '__main__':
+    main()