xref: /cloud-hypervisor/performance-metrics/src/performance_tests.rs (revision 19d36c765fdf00be749d95b3e61028bc302d6d73)
1 // Copyright © 2022 Intel Corporation
2 //
3 // SPDX-License-Identifier: Apache-2.0
4 //
5 
6 // Performance tests
7 
8 use std::path::PathBuf;
9 use std::time::Duration;
10 use std::{fs, thread};
11 
12 use test_infra::{Error as InfraError, *};
13 
14 use crate::{mean, PerformanceTestControl};
15 
16 #[cfg(target_arch = "x86_64")]
17 pub const FOCAL_IMAGE_NAME: &str = "focal-server-cloudimg-amd64-custom-20210609-0.raw";
18 #[cfg(target_arch = "aarch64")]
19 pub const FOCAL_IMAGE_NAME: &str = "focal-server-cloudimg-arm64-custom-20210929-0-update-tool.raw";
20 
21 #[allow(dead_code)]
22 #[derive(Debug)]
23 enum Error {
24     BootTimeParse,
25     Infra(InfraError),
26     RestoreTimeParse,
27 }
28 
29 impl From<InfraError> for Error {
30     fn from(e: InfraError) -> Self {
31         Self::Infra(e)
32     }
33 }
34 
35 const BLK_IO_TEST_IMG: &str = "/var/tmp/ch-blk-io-test.img";
36 
37 pub fn init_tests() {
38     // The test image cannot be created on tmpfs (e.g. /tmp) filesystem,
39     // as tmpfs does not support O_DIRECT
40     assert!(exec_host_command_output(&format!(
41         "dd if=/dev/zero of={BLK_IO_TEST_IMG} bs=1M count=4096"
42     ))
43     .status
44     .success());
45 }
46 
47 pub fn cleanup_tests() {
48     fs::remove_file(BLK_IO_TEST_IMG)
49         .unwrap_or_else(|_| panic!("Failed to remove file '{BLK_IO_TEST_IMG}'."));
50 }
51 
52 // Performance tests are expected to be executed sequentially, so we can
53 // start VM guests with the same IP while putting them on a different
54 // private network. The default constructor "Guest::new()" does not work
55 // well, as we can easily create more than 256 VMs from repeating various
56 // performance tests dozens times in a single run.
57 fn performance_test_new_guest(disk_config: Box<dyn DiskConfig>) -> Guest {
58     Guest::new_from_ip_range(disk_config, "172.19", 0)
59 }
60 
61 const DIRECT_KERNEL_BOOT_CMDLINE: &str =
62     "root=/dev/vda1 console=hvc0 rw systemd.journald.forward_to_console=1";
63 
64 // Creates the path for direct kernel boot and return the path.
65 // For x86_64, this function returns the vmlinux kernel path.
66 // For AArch64, this function returns the PE kernel path.
67 fn direct_kernel_boot_path() -> PathBuf {
68     let mut workload_path = dirs::home_dir().unwrap();
69     workload_path.push("workloads");
70 
71     let mut kernel_path = workload_path;
72     #[cfg(target_arch = "x86_64")]
73     kernel_path.push("vmlinux");
74     #[cfg(target_arch = "aarch64")]
75     kernel_path.push("Image");
76 
77     kernel_path
78 }
79 
80 fn remote_command(api_socket: &str, command: &str, arg: Option<&str>) -> bool {
81     let mut cmd = std::process::Command::new(clh_command("ch-remote"));
82     cmd.args([&format!("--api-socket={}", api_socket), command]);
83 
84     if let Some(arg) = arg {
85         cmd.arg(arg);
86     }
87     let output = cmd.output().unwrap();
88     if output.status.success() {
89         true
90     } else {
91         eprintln!(
92             "Error running ch-remote command: {:?}\nstderr: {}",
93             &cmd,
94             String::from_utf8_lossy(&output.stderr)
95         );
96         false
97     }
98 }
99 
100 pub fn performance_net_throughput(control: &PerformanceTestControl) -> f64 {
101     let test_timeout = control.test_timeout;
102     let (rx, bandwidth) = control.net_control.unwrap();
103 
104     let focal = UbuntuDiskConfig::new(FOCAL_IMAGE_NAME.to_string());
105     let guest = performance_test_new_guest(Box::new(focal));
106 
107     let num_queues = control.num_queues.unwrap();
108     let queue_size = control.queue_size.unwrap();
109     let net_params = format!(
110         "tap=,mac={},ip={},mask=255.255.255.0,num_queues={},queue_size={}",
111         guest.network.guest_mac, guest.network.host_ip, num_queues, queue_size,
112     );
113 
114     let mut child = GuestCommand::new(&guest)
115         .args(["--cpus", &format!("boot={num_queues}")])
116         .args(["--memory", "size=4G"])
117         .args(["--kernel", direct_kernel_boot_path().to_str().unwrap()])
118         .args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE])
119         .default_disks()
120         .args(["--net", net_params.as_str()])
121         .capture_output()
122         .verbosity(VerbosityLevel::Warn)
123         .set_print_cmd(false)
124         .spawn()
125         .unwrap();
126 
127     let r = std::panic::catch_unwind(|| {
128         guest.wait_vm_boot(None).unwrap();
129         measure_virtio_net_throughput(test_timeout, num_queues / 2, &guest, rx, bandwidth).unwrap()
130     });
131 
132     let _ = child.kill();
133     let output = child.wait_with_output().unwrap();
134 
135     match r {
136         Ok(r) => r,
137         Err(e) => {
138             handle_child_output(Err(e), &output);
139             panic!("test failed!");
140         }
141     }
142 }
143 
144 pub fn performance_net_latency(control: &PerformanceTestControl) -> f64 {
145     let focal = UbuntuDiskConfig::new(FOCAL_IMAGE_NAME.to_string());
146     let guest = performance_test_new_guest(Box::new(focal));
147 
148     let num_queues = control.num_queues.unwrap();
149     let queue_size = control.queue_size.unwrap();
150     let net_params = format!(
151         "tap=,mac={},ip={},mask=255.255.255.0,num_queues={},queue_size={}",
152         guest.network.guest_mac, guest.network.host_ip, num_queues, queue_size,
153     );
154 
155     let mut child = GuestCommand::new(&guest)
156         .args(["--cpus", &format!("boot={num_queues}")])
157         .args(["--memory", "size=4G"])
158         .args(["--kernel", direct_kernel_boot_path().to_str().unwrap()])
159         .args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE])
160         .default_disks()
161         .args(["--net", net_params.as_str()])
162         .capture_output()
163         .verbosity(VerbosityLevel::Warn)
164         .set_print_cmd(false)
165         .spawn()
166         .unwrap();
167 
168     let r = std::panic::catch_unwind(|| {
169         guest.wait_vm_boot(None).unwrap();
170 
171         // 'ethr' tool will measure the latency multiple times with provided test time
172         let latency = measure_virtio_net_latency(&guest, control.test_timeout).unwrap();
173         mean(&latency).unwrap()
174     });
175 
176     let _ = child.kill();
177     let output = child.wait_with_output().unwrap();
178 
179     match r {
180         Ok(r) => r,
181         Err(e) => {
182             handle_child_output(Err(e), &output);
183             panic!("test failed!");
184         }
185     }
186 }
187 
188 fn parse_boot_time_output(output: &[u8]) -> Result<f64, Error> {
189     std::panic::catch_unwind(|| {
190         let l: Vec<String> = String::from_utf8_lossy(output)
191             .lines()
192             .filter(|l| l.contains("Debug I/O port: Kernel code"))
193             .map(|l| l.to_string())
194             .collect();
195 
196         assert_eq!(
197             l.len(),
198             2,
199             "Expecting two matching lines for 'Debug I/O port: Kernel code'"
200         );
201 
202         let time_stamp_kernel_start = {
203             let s = l[0].split("--").collect::<Vec<&str>>();
204             assert_eq!(
205                 s.len(),
206                 2,
207                 "Expecting '--' for the matching line of 'Debug I/O port' output"
208             );
209 
210             // Sample output: "[Debug I/O port: Kernel code 0x40] 0.096537 seconds"
211             assert!(
212                 s[1].contains("0x40"),
213                 "Expecting kernel code '0x40' for 'linux_kernel_start' time stamp output"
214             );
215             let t = s[1].split_whitespace().collect::<Vec<&str>>();
216             assert_eq!(
217                 t.len(),
218                 8,
219                 "Expecting exact '8' words from the 'Debug I/O port' output"
220             );
221             assert!(
222                 t[7].eq("seconds"),
223                 "Expecting 'seconds' as the last word of the 'Debug I/O port' output"
224             );
225 
226             t[6].parse::<f64>().unwrap()
227         };
228 
229         let time_stamp_user_start = {
230             let s = l[1].split("--").collect::<Vec<&str>>();
231             assert_eq!(
232                 s.len(),
233                 2,
234                 "Expecting '--' for the matching line of 'Debug I/O port' output"
235             );
236 
237             // Sample output: "Debug I/O port: Kernel code 0x41] 0.198980 seconds"
238             assert!(
239                 s[1].contains("0x41"),
240                 "Expecting kernel code '0x41' for 'linux_kernel_start' time stamp output"
241             );
242             let t = s[1].split_whitespace().collect::<Vec<&str>>();
243             assert_eq!(
244                 t.len(),
245                 8,
246                 "Expecting exact '8' words from the 'Debug I/O port' output"
247             );
248             assert!(
249                 t[7].eq("seconds"),
250                 "Expecting 'seconds' as the last word of the 'Debug I/O port' output"
251             );
252 
253             t[6].parse::<f64>().unwrap()
254         };
255 
256         time_stamp_user_start - time_stamp_kernel_start
257     })
258     .map_err(|_| {
259         eprintln!(
260             "=============== boot-time output ===============\n\n{}\n\n===========end============\n\n",
261             String::from_utf8_lossy(output)
262         );
263         Error::BootTimeParse
264     })
265 }
266 
267 fn measure_boot_time(cmd: &mut GuestCommand, test_timeout: u32) -> Result<f64, Error> {
268     let mut child = cmd
269         .capture_output()
270         .verbosity(VerbosityLevel::Warn)
271         .set_print_cmd(false)
272         .spawn()
273         .unwrap();
274 
275     thread::sleep(Duration::new(test_timeout as u64, 0));
276     let _ = child.kill();
277     let output = child.wait_with_output().unwrap();
278 
279     parse_boot_time_output(&output.stderr).inspect_err(|_| {
280         eprintln!(
281             "\n\n==== Start child stdout ====\n\n{}\n\n==== End child stdout ====",
282             String::from_utf8_lossy(&output.stdout)
283         );
284         eprintln!(
285             "\n\n==== Start child stderr ====\n\n{}\n\n==== End child stderr ====",
286             String::from_utf8_lossy(&output.stderr)
287         );
288     })
289 }
290 
291 pub fn performance_boot_time(control: &PerformanceTestControl) -> f64 {
292     let r = std::panic::catch_unwind(|| {
293         let focal = UbuntuDiskConfig::new(FOCAL_IMAGE_NAME.to_string());
294         let guest = performance_test_new_guest(Box::new(focal));
295         let mut cmd = GuestCommand::new(&guest);
296 
297         let c = cmd
298             .args([
299                 "--cpus",
300                 &format!("boot={}", control.num_boot_vcpus.unwrap_or(1)),
301             ])
302             .args(["--memory", "size=1G"])
303             .args(["--kernel", direct_kernel_boot_path().to_str().unwrap()])
304             .args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE])
305             .args(["--console", "off"])
306             .default_disks();
307 
308         measure_boot_time(c, control.test_timeout).unwrap()
309     });
310 
311     match r {
312         Ok(r) => r,
313         Err(_) => {
314             panic!("test failed!");
315         }
316     }
317 }
318 
319 pub fn performance_boot_time_pmem(control: &PerformanceTestControl) -> f64 {
320     let r = std::panic::catch_unwind(|| {
321         let focal = UbuntuDiskConfig::new(FOCAL_IMAGE_NAME.to_string());
322         let guest = performance_test_new_guest(Box::new(focal));
323         let mut cmd = GuestCommand::new(&guest);
324         let c = cmd
325             .args([
326                 "--cpus",
327                 &format!("boot={}", control.num_boot_vcpus.unwrap_or(1)),
328             ])
329             .args(["--memory", "size=1G,hugepages=on"])
330             .args(["--kernel", direct_kernel_boot_path().to_str().unwrap()])
331             .args(["--cmdline", "root=/dev/pmem0p1 console=ttyS0 quiet rw"])
332             .args(["--console", "off"])
333             .args([
334                 "--pmem",
335                 format!(
336                     "file={}",
337                     guest.disk_config.disk(DiskType::OperatingSystem).unwrap()
338                 )
339                 .as_str(),
340             ]);
341 
342         measure_boot_time(c, control.test_timeout).unwrap()
343     });
344 
345     match r {
346         Ok(r) => r,
347         Err(_) => {
348             panic!("test failed!");
349         }
350     }
351 }
352 
353 pub fn performance_block_io(control: &PerformanceTestControl) -> f64 {
354     let test_timeout = control.test_timeout;
355     let num_queues = control.num_queues.unwrap();
356     let (fio_ops, bandwidth) = control.fio_control.as_ref().unwrap();
357 
358     let focal = UbuntuDiskConfig::new(FOCAL_IMAGE_NAME.to_string());
359     let guest = performance_test_new_guest(Box::new(focal));
360     let api_socket = guest
361         .tmp_dir
362         .as_path()
363         .join("cloud-hypervisor.sock")
364         .to_str()
365         .unwrap()
366         .to_string();
367 
368     let mut child = GuestCommand::new(&guest)
369         .args(["--cpus", &format!("boot={num_queues}")])
370         .args(["--memory", "size=4G"])
371         .args(["--kernel", direct_kernel_boot_path().to_str().unwrap()])
372         .args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE])
373         .args([
374             "--disk",
375             format!(
376                 "path={}",
377                 guest.disk_config.disk(DiskType::OperatingSystem).unwrap()
378             )
379             .as_str(),
380             format!(
381                 "path={}",
382                 guest.disk_config.disk(DiskType::CloudInit).unwrap()
383             )
384             .as_str(),
385             format!("path={BLK_IO_TEST_IMG}").as_str(),
386         ])
387         .default_net()
388         .args(["--api-socket", &api_socket])
389         .capture_output()
390         .verbosity(VerbosityLevel::Warn)
391         .set_print_cmd(false)
392         .spawn()
393         .unwrap();
394 
395     let r = std::panic::catch_unwind(|| {
396         guest.wait_vm_boot(None).unwrap();
397 
398         let fio_command = format!(
399             "sudo fio --filename=/dev/vdc --name=test --output-format=json \
400             --direct=1 --bs=4k --ioengine=io_uring --iodepth=64 \
401             --rw={fio_ops} --runtime={test_timeout} --numjobs={num_queues}"
402         );
403         let output = guest
404             .ssh_command(&fio_command)
405             .map_err(InfraError::SshCommand)
406             .unwrap();
407 
408         // Parse fio output
409         if *bandwidth {
410             parse_fio_output(&output, fio_ops, num_queues).unwrap()
411         } else {
412             parse_fio_output_iops(&output, fio_ops, num_queues).unwrap()
413         }
414     });
415 
416     let _ = child.kill();
417     let output = child.wait_with_output().unwrap();
418 
419     match r {
420         Ok(r) => r,
421         Err(e) => {
422             handle_child_output(Err(e), &output);
423             panic!("test failed!");
424         }
425     }
426 }
427 
428 // Parse the event_monitor file based on the format that each event
429 // is followed by a double newline
430 fn parse_event_file(event_file: &str) -> Vec<serde_json::Value> {
431     let content = fs::read(event_file).unwrap();
432     let mut ret = Vec::new();
433     for entry in String::from_utf8_lossy(&content)
434         .trim()
435         .split("\n\n")
436         .collect::<Vec<&str>>()
437     {
438         ret.push(serde_json::from_str(entry).unwrap());
439     }
440     ret
441 }
442 
443 fn parse_restore_time_output(events: &[serde_json::Value]) -> Result<f64, Error> {
444     for entry in events.iter() {
445         if entry["event"].as_str().unwrap() == "restored" {
446             let duration = entry["timestamp"]["secs"].as_u64().unwrap() as f64 * 1_000f64
447                 + entry["timestamp"]["nanos"].as_u64().unwrap() as f64 / 1_000_000f64;
448             return Ok(duration);
449         }
450     }
451     Err(Error::RestoreTimeParse)
452 }
453 
454 fn measure_restore_time(
455     cmd: &mut GuestCommand,
456     event_file: &str,
457     test_timeout: u32,
458 ) -> Result<f64, Error> {
459     let mut child = cmd
460         .capture_output()
461         .verbosity(VerbosityLevel::Warn)
462         .set_print_cmd(false)
463         .spawn()
464         .unwrap();
465 
466     thread::sleep(Duration::new((test_timeout / 2) as u64, 0));
467     let _ = child.kill();
468     let output = child.wait_with_output().unwrap();
469 
470     let json_events = parse_event_file(event_file);
471 
472     parse_restore_time_output(&json_events).inspect_err(|_| {
473         eprintln!(
474             "\n\n==== Start child stdout ====\n\n{}\n\n==== End child stdout ====\
475             \n\n==== Start child stderr ====\n\n{}\n\n==== End child stderr ====",
476             String::from_utf8_lossy(&output.stdout),
477             String::from_utf8_lossy(&output.stderr)
478         )
479     })
480 }
481 
482 pub fn performance_restore_latency(control: &PerformanceTestControl) -> f64 {
483     let r = std::panic::catch_unwind(|| {
484         let focal = UbuntuDiskConfig::new(FOCAL_IMAGE_NAME.to_string());
485         let guest = performance_test_new_guest(Box::new(focal));
486         let api_socket_source = String::from(
487             guest
488                 .tmp_dir
489                 .as_path()
490                 .join("cloud-hypervisor.sock")
491                 .to_str()
492                 .unwrap(),
493         );
494 
495         let mut child = GuestCommand::new(&guest)
496             .args(["--api-socket", &api_socket_source])
497             .args([
498                 "--cpus",
499                 &format!("boot={}", control.num_boot_vcpus.unwrap_or(1)),
500             ])
501             .args(["--memory", "size=256M"])
502             .args(["--kernel", direct_kernel_boot_path().to_str().unwrap()])
503             .args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE])
504             .args(["--console", "off"])
505             .default_disks()
506             .set_print_cmd(false)
507             .spawn()
508             .unwrap();
509 
510         thread::sleep(Duration::new((control.test_timeout / 2) as u64, 0));
511         let snapshot_dir = String::from(guest.tmp_dir.as_path().join("snapshot").to_str().unwrap());
512         std::fs::create_dir(&snapshot_dir).unwrap();
513         assert!(remote_command(&api_socket_source, "pause", None));
514         assert!(remote_command(
515             &api_socket_source,
516             "snapshot",
517             Some(format!("file://{}", snapshot_dir).as_str()),
518         ));
519 
520         let _ = child.kill();
521 
522         let event_path = String::from(guest.tmp_dir.as_path().join("event.json").to_str().unwrap());
523         let mut cmd = GuestCommand::new(&guest);
524         let c = cmd
525             .args([
526                 "--restore",
527                 format!("source_url=file://{}", snapshot_dir).as_str(),
528             ])
529             .args(["--event-monitor", format!("path={}", event_path).as_str()]);
530 
531         measure_restore_time(c, event_path.as_str(), control.test_timeout).unwrap()
532     });
533 
534     match r {
535         Ok(r) => r,
536         Err(_) => {
537             panic!("test failed!");
538         }
539     }
540 }
541 
542 #[cfg(test)]
543 mod tests {
544     use super::*;
545 
546     #[test]
547     fn test_parse_iperf3_output() {
548         let output = r#"
549 {
550 	"end":	{
551 		"sum_sent":	{
552 			"start":	0,
553 			"end":	5.000196,
554 			"seconds":	5.000196,
555 			"bytes":	14973836248,
556 			"bits_per_second":	23957198874.604115,
557 			"retransmits":	0,
558 			"sender":	false
559 		}
560 	}
561 }
562        "#;
563         assert_eq!(
564             parse_iperf3_output(output.as_bytes(), true, true).unwrap(),
565             23957198874.604115
566         );
567 
568         let output = r#"
569 {
570 	"end":	{
571 		"sum_received":	{
572 			"start":	0,
573 			"end":	5.000626,
574 			"seconds":	5.000626,
575 			"bytes":	24703557800,
576 			"bits_per_second":	39520744482.79,
577 			"sender":	true
578 		}
579 	}
580 }
581               "#;
582         assert_eq!(
583             parse_iperf3_output(output.as_bytes(), false, true).unwrap(),
584             39520744482.79
585         );
586         let output = r#"
587 {
588     "end":	{
589         "sum":  {
590             "start":        0,
591             "end":  5.000036,
592             "seconds":      5.000036,
593             "bytes":        29944971264,
594             "bits_per_second":      47911877363.396217,
595             "jitter_ms":    0.0038609822983198556,
596             "lost_packets": 16,
597             "packets":      913848,
598             "lost_percent": 0.0017508382137948542,
599             "sender":       true
600         }
601     }
602 }
603               "#;
604         assert_eq!(
605             parse_iperf3_output(output.as_bytes(), true, false).unwrap(),
606             182765.08409139456
607         );
608     }
609 
610     #[test]
611     fn test_parse_ethr_latency_output() {
612         let output = r#"{"Time":"2022-02-08T03:52:50Z","Title":"","Type":"INFO","Message":"Using destination: 192.168.249.2, ip: 192.168.249.2, port: 8888"}
613 {"Time":"2022-02-08T03:52:51Z","Title":"","Type":"INFO","Message":"Running latency test: 1000, 1"}
614 {"Time":"2022-02-08T03:52:51Z","Title":"","Type":"LatencyResult","RemoteAddr":"192.168.249.2","Protocol":"TCP","Avg":"80.712us","Min":"61.677us","P50":"257.014us","P90":"74.418us","P95":"107.283us","P99":"119.309us","P999":"142.100us","P9999":"216.341us","Max":"216.341us"}
615 {"Time":"2022-02-08T03:52:52Z","Title":"","Type":"LatencyResult","RemoteAddr":"192.168.249.2","Protocol":"TCP","Avg":"79.826us","Min":"55.129us","P50":"598.996us","P90":"73.849us","P95":"106.552us","P99":"122.152us","P999":"142.459us","P9999":"474.280us","Max":"474.280us"}
616 {"Time":"2022-02-08T03:52:53Z","Title":"","Type":"LatencyResult","RemoteAddr":"192.168.249.2","Protocol":"TCP","Avg":"78.239us","Min":"56.999us","P50":"396.820us","P90":"69.469us","P95":"115.421us","P99":"119.404us","P999":"130.158us","P9999":"258.686us","Max":"258.686us"}"#;
617 
618         let ret = parse_ethr_latency_output(output.as_bytes()).unwrap();
619         let reference = vec![80.712_f64, 79.826_f64, 78.239_f64];
620         assert_eq!(ret, reference);
621     }
622 
623     #[test]
624     fn test_parse_boot_time_output() {
625         let output = r#"
626 cloud-hypervisor: 161.167103ms: <vcpu0> INFO:vmm/src/vm.rs:392 -- [Debug I/O port: Kernel code 0x40] 0.132 seconds
627 cloud-hypervisor: 613.57361ms: <vcpu0> INFO:vmm/src/vm.rs:392 -- [Debug I/O port: Kernel code 0x41] 0.5845 seconds
628         "#;
629 
630         assert_eq!(parse_boot_time_output(output.as_bytes()).unwrap(), 0.4525);
631     }
632     #[test]
633     fn test_parse_restore_time_output() {
634         let output = r#"
635 {
636   "timestamp": {
637     "secs": 0,
638     "nanos": 4664404
639   },
640   "source": "virtio-device",
641   "event": "activated",
642   "properties": {
643     "id": "__rng"
644   }
645 }
646 
647 {
648   "timestamp": {
649     "secs": 0,
650     "nanos": 5505133
651   },
652   "source": "vm",
653   "event": "restored",
654   "properties": null
655 }
656 "#;
657         let mut ret = Vec::new();
658         for entry in String::from(output)
659             .trim()
660             .split("\n\n")
661             .collect::<Vec<&str>>()
662         {
663             ret.push(serde_json::from_str(entry).unwrap());
664         }
665 
666         assert_eq!(parse_restore_time_output(&ret).unwrap(), 5.505133_f64);
667     }
668     #[test]
669     fn test_parse_fio_output() {
670         let output = r#"
671 {
672   "jobs" : [
673     {
674       "read" : {
675         "io_bytes" : 1965273088,
676         "io_kbytes" : 1919212,
677         "bw_bytes" : 392976022,
678         "bw" : 383765,
679         "iops" : 95941.411718,
680         "runtime" : 5001,
681         "total_ios" : 479803,
682         "short_ios" : 0,
683         "drop_ios" : 0
684       }
685     }
686   ]
687 }
688 "#;
689 
690         let bps = 1965273088_f64 / (5001_f64 / 1000_f64);
691         assert_eq!(
692             parse_fio_output(output, &FioOps::RandomRead, 1).unwrap(),
693             bps
694         );
695         assert_eq!(parse_fio_output(output, &FioOps::Read, 1).unwrap(), bps);
696 
697         let output = r#"
698 {
699   "jobs" : [
700     {
701       "write" : {
702         "io_bytes" : 1172783104,
703         "io_kbytes" : 1145296,
704         "bw_bytes" : 234462835,
705         "bw" : 228967,
706         "iops" : 57241.903239,
707         "runtime" : 5002,
708         "total_ios" : 286324,
709         "short_ios" : 0,
710         "drop_ios" : 0
711       }
712     },
713     {
714       "write" : {
715         "io_bytes" : 1172234240,
716         "io_kbytes" : 1144760,
717         "bw_bytes" : 234353106,
718         "bw" : 228860,
719         "iops" : 57215.113954,
720         "runtime" : 5002,
721         "total_ios" : 286190,
722         "short_ios" : 0,
723         "drop_ios" : 0
724       }
725     }
726   ]
727 }
728 "#;
729 
730         let bps = 1172783104_f64 / (5002_f64 / 1000_f64) + 1172234240_f64 / (5002_f64 / 1000_f64);
731         assert_eq!(
732             parse_fio_output(output, &FioOps::RandomWrite, 2).unwrap(),
733             bps
734         );
735         assert_eq!(parse_fio_output(output, &FioOps::Write, 2).unwrap(), bps);
736     }
737 }
738