Benchmarking 201

SSD Benchmarking 201, Part 1: Building a Repeatable Benchmark Job

How a benchmark job file pins block size, read mix, queue depth, ramp, runtime and preconditioning, in Iometer and in fio, so a run can be repeated and checked.

On this page 5 sections

Benchmarking 101, Part 2 met fio only in passing, as the engine under KDiskMark and as the custom job file that Anvil stands in for. Benchmarking 201 starts there. A benchmark result is only as repeatable as the job that produced it. That job sets the transfer size, the share of reads, how random the offsets are, how many I/Os stay in flight, how long the tool waits before it records, and how long it records. Change one of them and the number moves, so nobody can check a result published without its job, not even the person who ran it.

What a benchmark job file fixes: the access spec, the load and the clockA three-panel cover. Left: an access spec written as job file lines, the transfer size, the read share and the random or sequential pattern, as fio names them bs, rwmixread and rw. Center: the load, a grid of worker columns by rows of outstanding I/Os, which fio sets as numjobs times iodepth. Right: the clock, a ramp that is not recorded followed by the recorded run, set by ramp_time and runtime. Drawing is schematic.GUIDE / BENCHMARKING 201, PART 1A job anyone can rerunSPEC · LOAD · CLOCK01 SPEC02 LOAD03 CLOCK[job]bsrwmixreadrwWhat each I/O issize, read share,random or sequentialnumjobs × iodepthHow many in flightworkers timesoutstanding I/Osramprecordedramp_time, runtimeWhen it countswarm up, then recordDrawing is schematic

I ran my enterprise SSD tests as a set of Iometer configuration files called from batch files, and the set still reads like a specification. This part goes through it one setting at a time, gives the fio option that does the same job, and checks a real result from a Micron 9100 MAX 2.4TB against the file that produced it. Steady state and latency percentiles get the next part to themselves.

What a job file pins down

An Iometer job is built from access specifications, and fio writes the same settings as named options. The table pairs them.

Setting Iometer fio
Transfer size size field of the access spec bs, or bssplit for a mix
Share of the I/O at each size % of size weights inside bssplit
Reads against writes % reads rwmixread
Random or sequential % random rw (randread, randwrite, randrw)
Load workers × # of Outstanding I/Os numjobs × iodepth
Warm-up before recording Ramp Up Time ramp_time
Recording time Run Time runtime with time_based

The rows come from the Iometer User’s Guide for version 1.1.0 and the fio documentation at revision 3.42.

My 4K random read spec is one line, with 4,096 byte transfers making up 100% of the I/O at 100% reads and 100% random, aligned to 4,096 bytes. Server mixes take more lines. My File Server spec spreads eight sizes from 512B to 64KiB, 60% of them 4KiB, all at 80% reads. Database is a single 8KiB line at 67% reads, and Email Server is a single 8KiB line at 50%.

What a job file pins down

One File Server spec spreads its I/O over eight transfer sizes

80% reads · 100% random · 4KiB aligned

A horizontal bar chart of the File Server access spec from my Iometer configs. Each of its eight lines sets a transfer size and the share of all I/O issued at that size: 512B 10%, 1KiB 5%, 2KiB 5%, 4KiB 60%, 8KiB 2%, 16KiB 4%, 32KiB 4% and 64KiB 10%. The shares add to 100%, and every line runs at 80% reads, fully random, aligned to 4KiB. The 4K random read spec, Database and Email Server are each a single line, so a mix like File Server is where the size column of the spec matters.I/O size020406080100Share of the spec’s I/O, %512B10%1KiB5%2KiB5%4KiB60%8KiB2%16KiB4%32KiB4%64KiB10%File Server spec line (80% reads)
Figure 1.1 · Each line of the access spec sets a transfer size and its share of the I/O; 4KiB carries 60%, and the eight shares add to 100%. Source: my enterprise test runs, the File Server spec in my Iometer configs.

In fio, rwmixread defaults to 50%, so a randrw job that leaves it out runs half reads without saying so. randrepeat is on by default and seeds the random offsets the same way on every run, which is what lets two runs of one job hit the same pattern.

A job is more than its I/O pattern. Every config in my set still stores the disk targets it was last saved with, four Kingston 400GB drives, so opening a config for a new drive and starting it would test the old ones. My SNIA-style config also names its smallest transfers 512K while the size field says 512 bytes. Only the size field reaches the drive, so that’s the line I check before a run.

Queue depth is workers times outstanding I/Os

Iometer has two settings for load. Its Dynamo program generates the I/O from worker threads, and each worker keeps its own number of outstanding I/Os against each disk. The queue depth (QD) the drive sees is the product of the two. Benchmarking 101, Part 1 counts queue depth per thread, the way CrystalDiskMark labels its tests, and in this series QD always means the total the drive sees. In my low queue depth configs the worker count steps through 1, 2, 4, and 8 with one outstanding I/O each, for QD1 to QD8. From QD16 up, 16 workers stay fixed and each one’s outstanding I/Os step through 1, 2, 4, 8, and 16, for QD16 to QD256, so no worker in the sweep ever holds more than 16.

Queue depth is workers times outstanding I/Os

Queue depth is built two ways: more workers, or more I/Os per worker

Two panels. The left panel, QD1 to QD8, shows four sweep steps as rows of worker boxes, each holding one outstanding I/O: 1 worker gives QD1, 2 workers QD2, 4 workers QD4 and 8 workers QD8. The right panel, QD16 to QD256, shows a grid of 16 worker columns by 16 rows of outstanding I/Os. Filling the first row is 16 workers at 1 outstanding I/O each, QD16; two rows is QD32, four rows QD64, eight rows QD128 and all sixteen rows QD256. The queue depth the drive sees is always workers times outstanding I/Os per worker, and no worker ever holds more than 16.01 QD1 TO QD8Workers step, 1 I/O each02 QD16 TO QD25616 workers, I/Os per worker stepQD11 × 1QD22 × 1QD44 × 1QD88 × 1QD16QD32QD64QD128QD25616 worker columnsrows = I/Os per workerWorker threadOutstanding I/OSweep step
Figure 1.2 · My low queue depth configs add workers at one outstanding I/O each; from QD16 up, 16 workers stay fixed and each one’s outstanding I/Os double. Source: my enterprise test runs, Iometer configs 4KRanReadQD1-8 and 4KRanReadQD16-256.

fio makes the same split. numjobs clones a job into separate threads or processes, and iodepth is the number of I/Os each clone keeps in flight, so numjobs=16 with iodepth=16 is QD256. group_reporting adds the clones into one result. The depth only counts with an asynchronous engine, because fio’s documentation says an iodepth above 1 does nothing for a synchronous one. Windows gets native asynchronous I/O through its default engine windowsaio. On Linux the asynchronous engines are libaio and io_uring, and libaio may only queue with direct=1, which asks for unbuffered I/O.

Iometer’s results file can show whether a run held its depth. Little’s law ties the numbers together. The I/Os in flight equal the I/O operations per second (IOPS) times the average response time. At QD256 in my June 2016 run, the 9100 MAX ran 758,048 IOPS of 4KiB random reads at a 0.337ms average. Dividing 256 by 0.337ms gives about 760,000 IOPS, within 0.3% of what Iometer recorded, so the drive really had 256 I/Os waiting. When the product falls well short of the label, fewer I/Os were in flight than the config asked for, and the run’s queue depth label overstates what the drive saw.

Preconditioning goes in the job

A fresh drive and a used one behave like different devices, which Benchmarking 101, Part 1 covers, so my batch files put the preparation in the same run as the test. Each block size gets its own write precondition right before its sweeps. For 4K and 8K random work that is 6h of random writes at QD256 with 8 workers at 32 outstanding I/Os and no ramp, and the write sweep and then the read sweep follow straight on. Each queue depth step records for 300s after a 30s ramp. In its December 2003 user guide for version 1.1.0, Iometer describes the ramp as a wait after all workers start and before statistics are recorded, and it puts typical ramp times at 15 to 60s.

The server mixes and the SNIA-style rounds first get a sequential fill. That config writes 128KiB sequential data for 2h at QD256, with 4 workers at 64 outstanding I/Os each. Its name says 2x capacity, but it stops on the clock. A 2x fill of a 2.4TB drive is 4.8TB, and finishing that in 7,200s takes at least about 667MB/s of sustained writes, so a drive slower than that ends the fill short of its name. My main batch file runs the fill once before the server mixes, and each mix then runs for 6h in its own pattern at QD256, with 8 workers at 32 outstanding I/Os, right before its sweeps.

Preconditioning goes in the job

The batch file runs preparation and test as one unbroken job

Two lanes read left to right, not to scale. Lane one, 4K and 8K random: a write precondition of 6h at QD256 with 8 workers at 32 outstanding I/Os and no ramp, then the write sweep from QD1 to QD256, then the read sweep. A zoom on one sweep step shows a 30s ramp, when nothing is recorded, followed by 300s that are recorded. Lane two, the SNIA-style job: a 128KiB sequential fill for 2h at QD256 with 4 workers at 64 outstanding I/Os, then the 56-spec config run as round 1, round 2 and on to round 25, back to back.01 4K AND 8K RANDOM02 SNIA-STYLE ROUNDSPreconditionwrites, 6h, QD2568 workers × 32Write sweepQD1 to QD256step by stepRead sweepQD1 to QD256step by stepOne queue depth step330s, no gap between30s300s recordedrampSequential fill128KiB, 2h, QD2564 workers × 641round2round25round· · ·each round: 56 specs × 60s, 16 workers × 16PreparationRecorded testRamp, not recorded
Figure 1.3 · Each lane follows its batch file in order with nobody at the keyboard; boxes show order, not duration. Source: my enterprise test runs, Iometer configs and batch files; drawing not to scale.

Batch files make the sequence repeatable. Each one starts Iometer once per config, each with its own results file, and Iometer’s batch mode then runs the series and records it with nobody at the keyboard. Time stamps in the results files show it. On June 18, 2016, the 9100 MAX’s QD1 to QD8 random write run started at 16:29:26 and its QD16 to QD256 run at 16:51:27, 22 minutes later, which is four 330s steps with no gap between them.

In fio, ramp_time runs the workload before any numbers are logged, and runtime with time_based holds a step for its full time even after it has covered the file. fio can also end a precondition on a measurement instead of a clock. Its steadystate option takes a criterion such as IOPS slope and a limit, and it stops the job once the criterion stays under the limit for the set window.

My SNIA-style job runs the same idea at full length. One config holds 56 access specs, eight block sizes from 512B to 1MiB times seven read shares from 0 to 100%, each run for 60s with 16 workers at 16 outstanding I/Os. Its batch file runs one sequential fill and then the whole config 25 times back to back.

Logging a run so it can be repeated

Iometer’s results file is a CSV with a row per run for the whole test and for each worker and disk. Version 1.1.0 records IOPS, MB/s, average and maximum response time, CPU use, and a latency histogram. Its 21 buckets run from 0 to 50µs up to 10s and over. It also writes the drive’s model string and a time stamp. It has no percentile columns, though the buckets can give rough ones, which the next part works through.

fio reports percentiles itself. clat_percentiles gives completion latency percentiles, percentile_list sets a job’s own list of up to 20, and --output-format=json or json+ writes a file a script can read, the second with the full latency bucket dump. I worked from revision 3.42 of the fio documentation, and options change between versions, so the version belongs with the job. Repeating a run takes the job file, the results file it wrote, the tool and its version, and the drive’s model and firmware, all kept together.

Sweeping all the way to QD256 mattered on the 9100 MAX in my June 2016 runs, where each point is one 300s step. Random reads climbed the whole way, from 6,491 IOPS at QD1 with a 0.153ms average response time to 758,048 at QD256. Random writes stopped climbing at QD16, with 300,697 IOPS there and 311,479 at QD256, 3.6% more, while the average response time rose about 15.5 times, from 0.053ms to 0.822ms. Past QD16 the write numbers measure the queue more than the flash. My reading is that the drive was already writing as fast as it could, so each added I/O only waited longer.

Logging a run so it can be repeated

Reads climbed to QD256; writes stopped climbing at QD16

A line chart of 4KiB random IOPS against total queue depth for the Micron 9100 MAX 2.4TB, with queue depth doubling from 1 to 256 at evenly spaced steps. Reads rise the whole way: 6,491 IOPS at QD1, 66,444 at QD8, 132,818 at QD16, 434,192 at QD64 and 758,048 at QD256. Writes start higher, 16,531 IOPS at QD1, and rise fast to 300,697 at QD16, then flatten: 306,582 at QD32, 308,047 at QD64 and 311,479 at QD256. The read line crosses the write line between QD32 and QD64.4KiB random IOPS0200,000400,000600,000800,0001248163264128256Total queue depth (workers × outstanding I/Os, log scale)758,048311,479300,697132,8184KiB random read4KiB random write
Figure 1.4 · Micron 9100 MAX 2.4TB, 4KiB random, every step of the sweep: reads kept scaling, while writes past QD16 added only waiting time. Source: my enterprise test runs, Micron 9100 MAX 2.4TB, June 2016, recomputed from the raw Iometer results files.

My 2022 job for an E18DC-based enterprise NVMe engineering sample went the other way. One 4KiB random sweep stepped 11 read shares from 0 to 100% in 10% steps across 9 queue depths, from QD1 to QD256, for 99 runs in a single results file. That job runs one spec at every pairing of read share and depth, in place of the server mixes.

The question a job file leaves open

A job file says how a number was made. It doesn’t say whether the drive was ready to be measured. My random preconditions ran for a fixed 6h, and the next part of this series checks that choice against the steady-state rules in the SNIA Solid State Storage Performance Test Specification and against fio’s steadystate option.

The question a job file leaves open

At QD256, 72.0% of reads landed between 200 and 500µs

A bar chart of Iometer’s latency histogram for the QD256 step of the 4KiB random read sweep on the Micron 9100 MAX 2.4TB, as a share of all 227,435,847 reads. 14.6% completed in 100 to 200µs, 72.0% in 200 to 500µs, 12.1% in 0.5 to 1ms, 1.2% in 1 to 2ms and 0.026% in 2 to 5ms. No read took longer than 5ms. The average response time of 0.337ms falls inside the tallest bucket, but the histogram also shows the slow tail an average hides, which is what latency percentiles describe.Share of all reads, %02040608014.6%100 to200µs72.0%200 to500µs12.1%0.5 to1ms1.2%1 to2ms0.026%2 to5msIometer response time bucketQD256 4KiB random reads (16 workers × 16)
Figure 1.5 · Iometer’s histogram from the QD256 read step, 227,435,847 reads in all and none slower than 5ms; the next part turns buckets like these into percentiles. Source: my enterprise test runs, Micron 9100 MAX 2.4TB, June 2016, Iometer results file 4KRanReadQD16-256.

Figure 1.5 is the raw material for that part. In my June 2016 run, the 9100 MAX’s 0.337ms average at QD256 sits inside the 200 to 500µs bucket, which held 72.0% of its reads, and the slowest reads reached the 2 to 5ms bucket. That part turns buckets like these into the percentiles and quality of service (QoS) figures that enterprise buyers compare.

KEEP GOING / NEXT CHAPTERContinue to Part 2

Sources

Primary is the original paper, standard or datasheet. Secondary is a review, a vendor summary or a news report. Measured is my own test data.

Show all 3 sources, 2 primary