t/nvmept_trim.py: test multi-range trim
[fio.git] / t / nvmept_trim.py
1 #!/usr/bin/env python3
2 #
3 # Copyright 2024 Samsung Electronics Co., Ltd All Rights Reserved
4 #
5 # For conditions of distribution and use, see the accompanying COPYING file.
6 #
7 """
8 # nvmept_trim.py
9 #
10 # Test fio's io_uring_cmd ioengine with NVMe pass-through dataset management
11 # commands that trim multiple ranges.
12 #
13 # USAGE
14 # see python3 nvmept_trim.py --help
15 #
16 # EXAMPLES
17 # python3 t/nvmept_trim.py --dut /dev/ng0n1
18 # python3 t/nvmept_trim.py --dut /dev/ng1n1 -f ./fio
19 #
20 # REQUIREMENTS
21 # Python 3.6
22 #
23 """
24 import os
25 import sys
26 import time
27 import logging
28 import argparse
29 from pathlib import Path
30 from fiotestlib import FioJobCmdTest, run_fio_tests
31 from fiotestcommon import SUCCESS_NONZERO
32
33
34 class TrimTest(FioJobCmdTest):
35     """
36     NVMe pass-through test class. Check to make sure output for selected data
37     direction(s) is non-zero and that zero data appears for other directions.
38     """
39
40     def setup(self, parameters):
41         """Setup a test."""
42
43         fio_args = [
44             "--name=nvmept-trim",
45             "--ioengine=io_uring_cmd",
46             "--cmd_type=nvme",
47             f"--filename={self.fio_opts['filename']}",
48             f"--rw={self.fio_opts['rw']}",
49             f"--output={self.filenames['output']}",
50             f"--output-format={self.fio_opts['output-format']}",
51         ]
52         for opt in ['fixedbufs', 'nonvectored', 'force_async', 'registerfiles',
53                     'sqthread_poll', 'sqthread_poll_cpu', 'hipri', 'nowait',
54                     'time_based', 'runtime', 'verify', 'io_size', 'num_range',
55                     'iodepth', 'iodepth_batch', 'iodepth_batch_complete',
56                     'size', 'rate', 'bs', 'bssplit', 'bsrange', 'randrepeat',
57                     'buffer_pattern', 'verify_pattern', 'verify', 'offset']:
58             if opt in self.fio_opts:
59                 option = f"--{opt}={self.fio_opts[opt]}"
60                 fio_args.append(option)
61
62         super().setup(fio_args)
63
64
65     def check_result(self):
66
67         super().check_result()
68
69         if 'rw' not in self.fio_opts or \
70                 not self.passed or \
71                 'json' not in self.fio_opts['output-format']:
72             return
73
74         job = self.json_data['jobs'][0]
75
76         if self.fio_opts['rw'] in ['read', 'randread']:
77             self.passed = self.check_all_ddirs(['read'], job)
78         elif self.fio_opts['rw'] in ['write', 'randwrite']:
79             if 'verify' not in self.fio_opts:
80                 self.passed = self.check_all_ddirs(['write'], job)
81             else:
82                 self.passed = self.check_all_ddirs(['read', 'write'], job)
83         elif self.fio_opts['rw'] in ['trim', 'randtrim']:
84             self.passed = self.check_all_ddirs(['trim'], job)
85         elif self.fio_opts['rw'] in ['readwrite', 'randrw']:
86             self.passed = self.check_all_ddirs(['read', 'write'], job)
87         elif self.fio_opts['rw'] in ['trimwrite', 'randtrimwrite']:
88             self.passed = self.check_all_ddirs(['trim', 'write'], job)
89         else:
90             logging.error("Unhandled rw value %s", self.fio_opts['rw'])
91             self.passed = False
92
93         if 'iodepth' in self.fio_opts:
94             # We will need to figure something out if any test uses an iodepth
95             # different from 8
96             if job['iodepth_level']['8'] < 95:
97                 logging.error("Did not achieve requested iodepth")
98                 self.passed = False
99             else:
100                 logging.debug("iodepth 8 target met %s", job['iodepth_level']['8'])
101
102
103 class RangeTrimTest(TrimTest):
104     """
105     Multi-range trim test class.
106     """
107
108     def get_bs(self):
109         """Calculate block size and determine whether bs will be an average or exact."""
110
111         if 'bs' in self.fio_opts:
112             exact_size = True
113             bs = self.fio_opts['bs']
114         elif 'bssplit' in self.fio_opts:
115             exact_size = False
116             bs = 0
117             total = 0
118             for split in self.fio_opts['bssplit'].split(':'):
119                 [blocksize, share] = split.split('/')
120                 total += int(share)
121                 bs += int(blocksize) * int(share) / 100
122             if total != 100:
123                 logging.error("bssplit '%s' total percentage is not 100", self.fio_opts['bssplit'])
124                 self.passed = False
125             else:
126                 logging.debug("bssplit: average block size is %d", int(bs))
127             # The only check we do here for bssplit is to calculate an average
128             # blocksize and see if the IOPS and bw are consistent
129         elif 'bsrange' in self.fio_opts:
130             exact_size = False
131             [minbs, maxbs] = self.fio_opts['bsrange'].split('-')
132             minbs = int(minbs)
133             maxbs = int(maxbs)
134             bs = int((minbs + maxbs) / 2)
135             logging.debug("bsrange: average block size is %d", int(bs))
136             # The only check we do here for bsrange is to calculate an average
137             # blocksize and see if the IOPS and bw are consistent
138         else:
139             exact_size = True
140             bs = 4096
141
142         return bs, exact_size
143
144
145     def check_result(self):
146         """
147         Make sure that the number of IO requests is consistent with the
148         blocksize and num_range values. In other words, if the blocksize is
149         4KiB and num_range is 2, we should have 128 IO requests to trim 1MiB.
150         """
151         # TODO Enable debug output to check the actual offsets
152
153         super().check_result()
154
155         if not self.passed or 'json' not in self.fio_opts['output-format']:
156             return
157
158         job = self.json_data['jobs'][0]['trim']
159         bs, exact_size = self.get_bs()
160
161         # make sure bw and IOPS are consistent
162         bw = job['bw_bytes']
163         iops = job['iops']
164         runtime = job['runtime']
165
166         calculated = int(bw*runtime/1000)
167         expected = job['io_bytes']
168         if abs(calculated - expected) / expected > 0.05:
169             logging.error("Total bytes %d from bw does not match reported total bytes %d",
170                           calculated, expected)
171             self.passed = False
172         else:
173             logging.debug("Total bytes %d from bw matches reported total bytes %d", calculated,
174                           expected)
175
176         calculated = int(iops*runtime/1000*bs*self.fio_opts['num_range'])
177         if abs(calculated - expected) / expected > 0.05:
178             logging.error("Total bytes %d from IOPS does not match reported total bytes %d",
179                           calculated, expected)
180             self.passed = False
181         else:
182             logging.debug("Total bytes %d from IOPS matches reported total bytes %d", calculated,
183                           expected)
184
185         if 'size' in self.fio_opts:
186             io_count = self.fio_opts['size'] / self.fio_opts['num_range'] / bs
187             if exact_size:
188                 delta = 0.1
189             else:
190                 delta = 0.05*job['total_ios']
191
192             if abs(job['total_ios'] - io_count) > delta:
193                 logging.error("Expected numbers of IOs %d does not match actual value %d",
194                               io_count, job['total_ios'])
195                 self.passed = False
196             else:
197                 logging.debug("Expected numbers of IOs %d matches actual value %d", io_count,
198                               job['total_ios'])
199
200         if 'rate' in self.fio_opts:
201             if abs(bw - self.fio_opts['rate']) / self.fio_opts['rate'] > 0.05:
202                 logging.error("Actual rate %f does not match expected rate %f", bw,
203                               self.fio_opts['rate'])
204                 self.passed = False
205             else:
206                 logging.debug("Actual rate %f matches expeected rate %f", bw, self.fio_opts['rate'])
207
208
209
210 TEST_LIST = [
211     # The group of tests below checks existing use cases to make sure there are
212     # no regressions.
213     {
214         "test_id": 1,
215         "fio_opts": {
216             "rw": 'trim',
217             "time_based": 1,
218             "runtime": 3,
219             "output-format": "json",
220             },
221         "test_class": TrimTest,
222     },
223     {
224         "test_id": 2,
225         "fio_opts": {
226             "rw": 'randtrim',
227             "time_based": 1,
228             "runtime": 3,
229             "output-format": "json",
230             },
231         "test_class": TrimTest,
232     },
233     {
234         "test_id": 3,
235         "fio_opts": {
236             "rw": 'trim',
237             "time_based": 1,
238             "runtime": 3,
239             "iodepth": 8,
240             "iodepth_batch": 4,
241             "iodepth_batch_complete": 4,
242             "output-format": "json",
243             },
244         "test_class": TrimTest,
245     },
246     {
247         "test_id": 4,
248         "fio_opts": {
249             "rw": 'randtrim',
250             "time_based": 1,
251             "runtime": 3,
252             "iodepth": 8,
253             "iodepth_batch": 4,
254             "iodepth_batch_complete": 4,
255             "output-format": "json",
256             },
257         "test_class": TrimTest,
258     },
259     {
260         "test_id": 5,
261         "fio_opts": {
262             "rw": 'trimwrite',
263             "time_based": 1,
264             "runtime": 3,
265             "output-format": "json",
266             },
267         "test_class": TrimTest,
268     },
269     {
270         "test_id": 6,
271         "fio_opts": {
272             "rw": 'randtrimwrite',
273             "time_based": 1,
274             "runtime": 3,
275             "output-format": "json",
276             },
277         "test_class": TrimTest,
278     },
279     {
280         "test_id": 7,
281         "fio_opts": {
282             "rw": 'randtrim',
283             "time_based": 1,
284             "runtime": 3,
285             "fixedbufs": 0,
286             "nonvectored": 1,
287             "force_async": 1,
288             "registerfiles": 1,
289             "sqthread_poll": 1,
290             "fixedbuffs": 1,
291             "output-format": "json",
292             },
293         "test_class": TrimTest,
294     },
295     # The group of tests below try out the new functionality
296     {
297         "test_id": 100,
298         "fio_opts": {
299             "rw": 'trim',
300             "num_range": 2,
301             "size": 16*1024*1024,
302             "output-format": "json",
303             },
304         "test_class": RangeTrimTest,
305     },
306     {
307         "test_id": 101,
308         "fio_opts": {
309             "rw": 'randtrim',
310             "num_range": 2,
311             "size": 16*1024*1024,
312             "output-format": "json",
313             },
314         "test_class": RangeTrimTest,
315     },
316     {
317         "test_id": 102,
318         "fio_opts": {
319             "rw": 'randtrim',
320             "num_range": 256,
321             "size": 64*1024*1024,
322             "output-format": "json",
323             },
324         "test_class": RangeTrimTest,
325     },
326     {
327         "test_id": 103,
328         "fio_opts": {
329             "rw": 'trim',
330             "num_range": 2,
331             "bs": 16*1024,
332             "size": 32*1024*1024,
333             "output-format": "json",
334             },
335         "test_class": RangeTrimTest,
336     },
337     {
338         "test_id": 104,
339         "fio_opts": {
340             "rw": 'randtrim',
341             "num_range": 2,
342             "bs": 16*1024,
343             "size": 32*1024*1024,
344             "output-format": "json",
345             },
346         "test_class": RangeTrimTest,
347     },
348     {
349         "test_id": 105,
350         "fio_opts": {
351             "rw": 'randtrim',
352             "num_range": 2,
353             "bssplit": "4096/50:16384/50",
354             "size": 80*1024*1024,
355             "output-format": "json",
356             "randrepeat": 0,
357             },
358         "test_class": RangeTrimTest,
359     },
360     {
361         "test_id": 106,
362         "fio_opts": {
363             "rw": 'randtrim',
364             "num_range": 4,
365             "bssplit": "4096/25:8192/25:12288/25:16384/25",
366             "size": 80*1024*1024,
367             "output-format": "json",
368             "randrepeat": 0,
369             },
370         "test_class": RangeTrimTest,
371     },
372     {
373         "test_id": 107,
374         "fio_opts": {
375             "rw": 'randtrim',
376             "num_range": 4,
377             "bssplit": "4096/20:8192/20:12288/20:16384/20:20480/20",
378             "size": 72*1024*1024,
379             "output-format": "json",
380             "randrepeat": 0,
381             },
382         "test_class": RangeTrimTest,
383     },
384     {
385         "test_id": 108,
386         "fio_opts": {
387             "rw": 'randtrim',
388             "num_range": 2,
389             "bsrange": "4096-16384",
390             "size": 80*1024*1024,
391             "output-format": "json",
392             "randrepeat": 0,
393             },
394         "test_class": RangeTrimTest,
395     },
396     {
397         "test_id": 109,
398         "fio_opts": {
399             "rw": 'randtrim',
400             "num_range": 4,
401             "bsrange": "4096-20480",
402             "size": 72*1024*1024,
403             "output-format": "json",
404             "randrepeat": 0,
405             },
406         "test_class": RangeTrimTest,
407     },
408     {
409         "test_id": 110,
410         "fio_opts": {
411             "rw": 'randtrim',
412             "time_based": 1,
413             "runtime": 10,
414             "rate": 1024*1024,
415             "num_range": 2,
416             "output-format": "json",
417             },
418         "test_class": RangeTrimTest,
419     },
420     # All of the tests below should fail
421     # TODO check the error messages resulting from the jobs below
422     {
423         "test_id": 200,
424         "fio_opts": {
425             "rw": 'randtrimwrite',
426             "time_based": 1,
427             "runtime": 10,
428             "rate": 1024*1024,
429             "num_range": 2,
430             "output-format": "normal",
431             },
432         "test_class": RangeTrimTest,
433         "success": SUCCESS_NONZERO,
434     },
435     {
436         "test_id": 201,
437         "fio_opts": {
438             "rw": 'trimwrite',
439             "time_based": 1,
440             "runtime": 10,
441             "rate": 1024*1024,
442             "num_range": 2,
443             "output-format": "normal",
444             },
445         "test_class": RangeTrimTest,
446         "success": SUCCESS_NONZERO,
447     },
448     {
449         "test_id": 202,
450         "fio_opts": {
451             "rw": 'trim',
452             "time_based": 1,
453             "runtime": 10,
454             "num_range": 257,
455             "output-format": "normal",
456             },
457         "test_class": RangeTrimTest,
458         "success": SUCCESS_NONZERO,
459     },
460     # The sequence of jobs below constitute a single test with multiple steps
461     # - write a data pattern
462     # - verify the data pattern
463     # - trim the first half of the LBA space
464     # - verify that the trim'd LBA space no longer returns the original data pattern
465     # - verify that the remaining LBA space has the expected pattern
466     {
467         "test_id": 300,
468         "fio_opts": {
469             "rw": 'write',
470             "output-format": 'json',
471             "buffer_pattern": 0x0f,
472             "size": 256*1024*1024,
473             },
474         "test_class": TrimTest,
475     },
476     {
477         "test_id": 301,
478         "fio_opts": {
479             "rw": 'read',
480             "output-format": 'json',
481             "verify_pattern": 0x0f,
482             "verify": "pattern",
483             "size": 256*1024*1024,
484             },
485         "test_class": TrimTest,
486     },
487     {
488         "test_id": 302,
489         "fio_opts": {
490             "rw": 'randtrim',
491             "num_range": 8,
492             "output-format": 'json',
493             "size": 128*1024*1024,
494             },
495         "test_class": TrimTest,
496     },
497     # The identify namespace data structure has a DLFEAT field which specifies
498     # what happens when reading data from deallocated blocks. There are three
499     # options:
500     # - read behavior not reported
501     # - deallocated logical block returns all bytes 0x0
502     # - deallocated logical block returns all bytes 0xff
503     # The test below merely checks that the original data pattern is not returned.
504     # Source: Figure 97 from
505     # https://nvmexpress.org/wp-content/uploads/NVM-Express-NVM-Command-Set-Specification-1.0c-2022.10.03-Ratified.pdf
506     {
507         "test_id": 303,
508         "fio_opts": {
509             "rw": 'read',
510             "output-format": 'json',
511             "verify_pattern": 0x0f,
512             "verify": "pattern",
513             "size": 128*1024*1024,
514             },
515         "test_class": TrimTest,
516         "success": SUCCESS_NONZERO,
517     },
518     {
519         "test_id": 304,
520         "fio_opts": {
521             "rw": 'read',
522             "output-format": 'json',
523             "verify_pattern": 0x0f,
524             "verify": "pattern",
525             "offset": 128*1024*1024,
526             "size": 128*1024*1024,
527             },
528         "test_class": TrimTest,
529     },
530 ]
531
532 def parse_args():
533     """Parse command-line arguments."""
534
535     parser = argparse.ArgumentParser()
536     parser.add_argument('-d', '--debug', help='Enable debug messages', action='store_true')
537     parser.add_argument('-f', '--fio', help='path to file executable (e.g., ./fio)')
538     parser.add_argument('-a', '--artifact-root', help='artifact root directory')
539     parser.add_argument('-s', '--skip', nargs='+', type=int,
540                         help='list of test(s) to skip')
541     parser.add_argument('-o', '--run-only', nargs='+', type=int,
542                         help='list of test(s) to run, skipping all others')
543     parser.add_argument('--dut', help='target NVMe character device to test '
544                         '(e.g., /dev/ng0n1). WARNING: THIS IS A DESTRUCTIVE TEST', required=True)
545     args = parser.parse_args()
546
547     return args
548
549
550 def main():
551     """Run tests using fio's io_uring_cmd ioengine to send NVMe pass through commands."""
552
553     args = parse_args()
554
555     if args.debug:
556         logging.basicConfig(level=logging.DEBUG)
557     else:
558         logging.basicConfig(level=logging.INFO)
559
560     artifact_root = args.artifact_root if args.artifact_root else \
561         f"nvmept-trim-test-{time.strftime('%Y%m%d-%H%M%S')}"
562     os.mkdir(artifact_root)
563     print(f"Artifact directory is {artifact_root}")
564
565     if args.fio:
566         fio_path = str(Path(args.fio).absolute())
567     else:
568         fio_path = 'fio'
569     print(f"fio path is {fio_path}")
570
571     for test in TEST_LIST:
572         test['fio_opts']['filename'] = args.dut
573
574     test_env = {
575               'fio_path': fio_path,
576               'fio_root': str(Path(__file__).absolute().parent.parent),
577               'artifact_root': artifact_root,
578               'basename': 'nvmept-trim',
579               }
580
581     _, failed, _ = run_fio_tests(TEST_LIST, test_env, args)
582     sys.exit(failed)
583
584
585 if __name__ == '__main__':
586     main()