August, 2026

Designing a Database for Non Volatile Memory

Designing a high performance NoSQL database using a analytical model of NVM drive throughput.

This article was inspired by AMD's dependency analysis for LLM decode. It assumes familiarity with databases and systems programming. It discusses how NVMe queue depth interacts with database design decisions.

Processors have been getting faster for decades. Unfortunately the time to read and write a file has not kept up. A modern non-volatile memory (NVM) drive answers a single 4 KiB random read in tens of microseconds, a thousand times slower than loading the same data from volatile memory.

Non-volatile memory includes devices such as solid state drives (SSD), M.2 cards and hard disk drives.

Databases are asked to hide this gap. We are going to design one that responds to requests quickly (low latency) and handles many requests at once (high throughput) using a simplified model of NVM drives. We discuss why to shard per core, how to choose a storage format, and how to pipeline requests. We finish with a brief discussion of a request's latency distribution.

Approximate service times, log scale.

The Database API

For the purposes of this article we are going to assume Cassandra's CQL interface. It looks like SQL but is more restrictive, specifically:

  • Rows are divided into partitions, identified by a partition key (a partition contains multiple rows).
  • Rows within a partition are stored in order of the clustering key.
  • Normal reads and writes name a single partition key.
  • There are no joins or foreign keys (schemas are denormalised).
  • Secondary indexes are declared explicitly.

CQL is designed this way so requests can be handled by a single shard. Sharding divides a table's rows into groups, and different shards may be stored on distinct drives or machines. Partition keys let us route a request directly to the shard holding the relevant partition. Some requests do need data from several partitions, and potentially several shards. In this case the request is scattered among the shards and then gathered to form a final answer. Cassandra's data modelling guide may be useful for understanding the implications of Cassandra's design.

Constraining our database's interface allows the following analysis to be more specific, although the same factors apply to most databases.

Model of NVM Drive

An NVM drive is best utilised when it holds many read or write operations at once. A drive contains many independent flash dies. Software which waits for a response before making another operation leaves most of these dies idle. The drive has a queue of operations submitted but not completed.

Rows are dies within the NVM drive. The orange boxes on the left are number of operations in the queue. (a) Submit a single operation and wait for the response before submitting another. (b) Submit four operations at once.

Let the average size of the queue be or the queue depth. Let the time to answer one operation be or the unloaded latency. At best we can complete the outstanding operations every seconds. This is the latency bound, . It is dominant when the drive is mostly idle (and exact by definition when only submitting one operation).

The drive also has a ceiling for how well it can parallelize. However many operations we offer, it retires at most per second. This is the saturation bound and does not depend on usage.

Throughput is constrained by

The two bounds cross at a queue depth called the saturation point.

Below the drive is idle part of the time, and adding operations is free throughput. Above the drive is already saturated, so an arriving operation waits behind the others and latency grows in proportion to .

The heavily simplified model is based on The Operational Analysis of Queueing Network Models.

Example: Micron 7450 PRO

Throughput for a Micron 7450 PRO under a 70/30 mix. QD1 is the operations per second at queue depth one. The grey curve is an approximation of the real drive.

The Micron 7450 PRO is an enterprise NVMe drive. Its 7.68 TB E1.S model answers a 4 KiB random read in at queue depth one, and is rated at 1,000,000 reads per second at queue depth 256. If we approximate our database usage as a 70/30 blend of random reads and writes we are told to expect operations per second. From these numbers we derive from our model that

We expect to be able to issue 33 operations at once before operations start to take longer. The below table illustrates these diminishing returns.

Database design

One shard per core

Our model tells us that we should maximize the queue depth enough to reach the point of diminishing returns. The most straightforward way is multithreading. We divide partition ownership among threads and use the partition key to route each query. To avoid contention we have each thread own it's caches and files. If we have enough connections making requests touching different shards we can may be able to saturate the drive using this technique alone.

One shard per core, each owning a disjoint set of partitions. A request is routed by hashing its partition key. A request naming several partitions is split across several shards.

We can also pin each shard to a core, which tells the operating system not to move the thread to another core. Pinning has the trade-off that a busy shard cannot use an idle core's capacity. This may cause a lower average throughput but allows more consistent latency, we will return to this trade-off when discussing tail latency.

Since I/O operations are much slower than compute we don't want our threads to wait for a response. We could have a thread per request but this requires more work and memory from the OS. We instead make each request's handler into a coroutine: a function that can suspend part-way through, return control to the thread, and resume later from where it stopped. A request suspends it's coroutine when it submits an I/O operation and resumes when the answer arrives. Unfortunately, since we are touching the same shard's shared resources we can't simply overlap the I/O for different request coroutines, this is where pipelining will come in.

B-trees or LSM

The format we store our table's rows deeply effects which I/O operations we make. We will consider two choice, B-Trees or log structured merge (LSM) trees. A B-tree keeps rows in one sorted structure and updates it in place. Its pages form a shallow tree in which each page holds keys and the locations of the pages below it. A log-structured merge tree instead keeps several sorted runs of pages, newest first, and merges them in the background (compaction). The two structures differ in how they utilize I/O.

  • When searching a B-tree, a parent node's page must be read before we know which child page to read.
  • When searching an LSM tree we can load multiple pages at once but we need to issue more reads in total.
  • Modifying a B-tree requires more writes up-front but compaction of an LSM tree requires more writes in total.

Under this analysis the LSM tree allows more drive queue depth () per query but requires more total operations. Our goal will be that with Shard-per-core and other tricks we will already be past and so the LSM tree's extra queue depth will not be worth the increased total operations. We therefore choose a B-tree and work on increasing queue depth within a shard.

Requests Lifecycle

We break read and write requests into smaller components and map the dependencies between them. This will allows us to overlap and combine operations from different queries. The analysis focuses on I/O, which dominates compute cost. Costs are approximated relative to one uncached drive read ().

TagStageI/OCost
nwread the request frame from the socketusually one receive1 to 3
ptidescend one level of the partition or clustering treeone read1 or cached
rwread the row/s the leaf points atusually one read~1 (batched)
ixidescend one level of one secondary indexone read1 or cached
waappend the whole transaction to the logone sequential write, sized by the pages dirtied~1 (batched)
flflush the log to durable mediaone barrier~35 or ~0 with power-loss protection
rswrite the response frameusually one send1 to 3
ckwrite the dirtied pages back to the data fileone write per page, deferred~1 per page

Write for the length of the longest chain of dependent I/O operations and for the total number of I/O operations. A request's latency depends on , the request throughput throughput depends on . The diagrams below show example paths for a read and a write request respectively.

A read of one row. Every stage waits on the one above it, so . This single-row read cannot contribute to without pipelining.

A write against a table with a clustering key and two secondary indexes, one of them a level deeper than the other. The longest chain runs through the deeper index, giving . Counting the log append and the deferred page writes, .

The steps have the following relationships:

  • nw and rs are independent per connection.
  • pti is irreducible. A page must be read before we know which page follows it.
  • rw can be batched and parallelized. Adjacent rows and overflow pages are laid out in order, so they can be issued together.
  • ixi requires rw. Removing the stale index entry requires the old column value.
  • ixi needs a global lock. An index has a global lock which covers every partition, so two requests on different partitions contend for the index.
  • wa can be submitted in parallel with the transaction
  • ck must follow fl. If a page reached the data file before the log covering it was durable, a crash between the two would leave a partially updated file and no record with which to repair it.
  • ck is otherwise unconstrained. Once the log is durable the pages may be written at any time.

Pipelining Between Requests

Two read requests naming different partitions can naturally interleave without coordination. We ensure that a thread does not lock on read and ensure that our clients take advantage by opening multiple connections. Unfortunately we can't pipeline write requests to ensure our tree remains in a consistent state. This means stalling on writes.

We can also trivially pipeline the network calls. The write ahead log can also be pipelined but since we still need to commit before returning to ensure durability. we instead batch with a limit on the number of batched requests.

Tail latency

It was mentioned earlier that sometimes we may trade lower average latency for consistency (low variance).

Two database engines with different latency distributions. The bursty engine has a lower mean latency but a much higher latency.

We reduced variance by:

  • Pinning shards to cores.
  • Choosing B-trees as our storage format.

On the other hand we increased variance by:

  • Batching flushes across requests

There is a trade-off between variance and mean, similar to the trade-off between memory and compute. Our database design allows us to tune this trade-off by, for example, changing the flush batch size.

The database design we justified is plexdb, an experimental database which supports the CQL interface. It is shard-per-core and coroutine-driven, stores rows in B-trees, and pipelines and batches I/O.

Further reading