自动安装构建和开发SPDK所需的全部依赖项。

sudo scripts/pkgdep.sh

Building

Linux:

./configure
make

./configure 的一些参数可以通过 --help 查看

./configure --help

如:

./configure --with-rdma
make

运行单元测试查看building是否成功

./test/unit/unittest.sh

在运行单元测试时,您将看到几个错误消息,但是它们是测试套件的一部分。脚本末尾的最后一条消息表示成功或失败。
运行SPDK应用程序
在运行SPDK应用程序之前,必须分配一些较大的页面,并且必须从本机内核驱动程序解绑定任何NVMe和I/OAT设备。
SPDK包含一个脚本,可以在Linux上自动执行这个过程。这个脚本应该作为根运行。它只需要在系统上运行一次。

sudo scripts/setup.sh

要将设备重新绑定回内核,可以运行

sudo scripts/setup.sh reset

默认情况下,脚本分配2048MB的大页面。若要更改此分配的内存,请按以下方式指定HUGEMEM(以MB为单位):

sudo HUGEMEM=4096 scripts/setup.sh

在Linux机器上,HUGEMEM将四舍五入到系统默认的巨大页面大小边界。
所有可用的参数都可以通过运行来查看

scripts/setup.sh help

示例代码位于examples目录中。示例是作为构建过程的一部分自动编译的。只需调用任何没有参数的示例来查看帮助输出。如果您的系统启用了它的IOMMU,您可以作为常规用户运行这些示例。如果没有,则需要以特权用户(root)身份运行。
一个很好的例子是examples/nvme/identify/identify,它打印出关于系统上所有nvme设备的信息。
更大、功能更全面的应用程序可以在app目录中找到。这包括iSCSI和NVMe-oF target。

Vagrant 开发环境

Vagrant 提供一种快速方法,使基本的启用NVMe的虚拟机沙箱运行,而不需要任何特殊的硬件。即软件管理虚拟机。支持多样的虚拟环境,eg:ubuntu,centos,freeBSD.
查看所有选项:

scripts/vagrant/create_vbox.sh -h 

这个环境需要Vagrant 1.9.4或更新版本,以及具有匹配的VirtualBox扩展包的VirtualBox 5.1或更新版本。
注意:如果您在企业防火墙后面,在尝试启动VM之前,请在您的环境中设置http_proxy和https_proxy 网络代理。
下面是您已经安装了可选的vagrant模块vagrant-proxyconf示例:

export http_proxy=...
export https_proxy=...
vagrant plugin install vagrant-proxyconf

如果您想使用kvm/libvirt,还应该安装vagrant-libvirt

要使用vagrant创建配置好的VM,需要运行create_vbox.sh脚本。
基本上,该脚本将根据您选择的发行版创建一个新的子目录,将vagrant配置文件(也称为Vagrantfile)复制到其中,并运行vagrant,使用脚本参数定义的一些设置
默认情况下,创建的VM配置为:

  1. 2 vCPUs
  2. 4G of RAM
  3. 2 NICs (1 x NAT - host access, 1 x private network)

为了修改一些高级设置,比如配置和rsync,您可能需要更改Vagrantfile source
要获得额外的支持,可以使用vagrant帮助功能来学习如何销毁、重启等等。
下面是成功启动VM并执行NVMe hello world示例应用程序的示例输出。
vagrant --help

先了解:
PIO
IO端口的编址是独立于系统的地址空间,其实就是一段地址区域,所有外设的地址都映射到这段区域中。就像是一个进程内部的各个变量,公用进程地址空间一样。不同外设的IO端口不同。访问IO端口需要特殊的IO指令,OUT/IN,OUT用于write操作,in用于read操作。在此基础上,操作系统实现了读写不同大小端口的函数。为什么说是不同大小呢??因为前面也说到,IO端口实际上是一段连续的区域,每个端口理论上是字节为单位即8bit,那么要想读写16位的端口只能把相邻的端口进行合并,32位的端口也是如此。

MMIO
IO内存是直接把寄存器的地址空间直接映射到系统地址空间,系统地址空间往往会保留一段内存区用于这种MMIO的映射(当然肯定是位于系统内存区),这样系统可以直接使用普通的访存指令直接访问设备的寄存器,随着计算机内存容量的日益增大,这种方式更是显出独特的优势,在性能至上的理念下,使用MMIO可以最大限度满足日益增长的系统和外设存储的需要。所以当前其实大多数外设都是采用MMIO的方式。

还有一种方式是把IO端口空间映射到内存空间,这样依然可以通过正常的访存指令访问IO端口,但是这种方式下依然受到IO空间大小的制约,可以说并没有解决实际问题。

但是上述方案只适用于在外设和内存进行小数据量的传输时,假如进行大数据量的传输,那么IO端口这种以字节为单位的传输就不用说了,IO内存虽然进行了内存映射,但是其映射的范围大小相对于大量的数据,仍然不值一提,所以即使采用IO内存仍然是满足不了需要,会让CPU大部分时间处理繁琐的映射,极大的浪费了CPU资源。那么这种情况就引入了DMA,直接内存访问。这种方式的传输由DMA控制器控制,CPU给DMA控制器下达传输指令后就转而处理其他的事务,然后DMA控制器就开始进行数据的传输,在完成数据的传输后通过中断的方式通知CPU,这样就可以极大的解放CPU。当然本次讨论的重点不在DMA,所以对于DMA的讨论仅限于此。
`

用户空间驱动

SPDK的很多文档都讨论了用户空间驱动程序,因此从技术层面理解它的含义非常重要。首先,驱动程序是一种软件,它直接控制连接到计算机上的特定设备。其次,操作系统根据特权级别将系统的虚拟内存划分为两类地址——内核空间和用户空间。这种分离是由CPU本身的一些特性提供的,这些特性强制执行称为保护环的内存分离。通常,驱动程序在内核空间中运行(i.e. ring 0 on x86)。
SPDK包含的驱动程序被设计为在用户空间中运行,但是它们仍然直接与它们所控制的硬件设备交互。
为了让SPDK控制一个设备,它必须首先让操作系统放弃控制权。这通常被称为从设备上解绑定内核驱动程序,在Linux上是通过写入sysfs中的文件来完成的。
然后SPDK将驱动程序重新绑定到与Linux捆绑在一起的两个特殊设备驱动程序之一——uio或vfio。这两个驱动程序是“虚拟”驱动程序因为它们主要向操作系统表明设备绑定了一个驱动程序,所以它不会自动尝试重新绑定默认驱动程序
他们实际上并不以任何方式初始化硬件,甚至不知道它是什么类型的设备。
uio和vfio的主要区别在于,vfio能够编写平台的IOMMU,这是确保用户空间驱动程序内存安全的关键硬件。有关详细信息,请参阅用户空间中的直接内存访问(DMA)。
一旦设备与操作系统内核解除绑定,操作系统就不能再使用它了。例如,如果在Linux上解绑定一个NVMe设备,那么与之对应的设备(如/dev/nvme0n1)将会消失。这还意味着,安装在设备上的文件系统也将被删除,内核文件系统不能再与设备交互。实际上,不再涉及整个内核块存储堆栈。相反,SPDK提供了一个典型的操作系统存储堆栈中大多数层的重新镜像image的实现,所有这些层都是可以直接嵌入到应用程序中的C库。这主要包括一个块设备抽象层,但也包括块分配器和类似文件系统的组件
用户空间驱动程序利用uio或vfio中的特性将设备的PCI BAR映射到当前进程,从而允许驱动程序直接执行MMIO(内存映射I/O)。例如,SPDK NVMe驱动程序映射NVMe设备的BAR,然后根据NVMe规范初始化设备,创建队列对,并最终发送I/O
SPDK轮询设备完成,而不是等待中断。这样做的原因有很多:
1)实际上,将中断,路由到用户空间进程中的处理程序对于大多数硬件设计都是不可行的;SPDK中的操作几乎都是异步的,允许用户在完成时提供回调。调用回调函数是为了响应调用函数以轮询是否完成的用户。轮询NVMe设备快因为只有主机内存需要阅读(没有MMIO)检查队列了解位翻转和技术,如英特尔DDIO将确保主机内存检查CPU缓存中存在一个更新后的设备。
NVMe设备为向硬件提交请求公开多个队列。单独的队列可以在没有协调的情况下访问,因此软件可以从并行执行的多个线程向设备发送请求,而不需要锁。
不幸的是,内核驱动程序必须设计为处理来自操作系统或系统上不同进程的许多不同位置的I/O,这些进程的线程拓扑会随着时间而变化**。大多数内核驱动程序选择将硬件队列映射到内核(尽可能接近1:1),然后当一个请求被提交时,它们为当前线程正在运行的内核查找正确的硬件队列**。通常,它们需要在队列周围获得一个锁,或者临时禁用中断,以防止运行在相同内核上的线程抢占,这可能很昂贵。与以前的硬件接口相比,这是一个很大的改进,以前的硬件接口只有一个队列或根本没有队列,但仍然不是最优的。
另一方面,用户空间驱动程序嵌入到单个应用程序中。这个应用程序确切地知道有多少线程(或进程)存在,因为应用程序创建了它们。因此,SPDK驱动程序选择将硬件队列直接公开给应用程序,要求每次只从一个线程访问硬件队列。实际上,应用程序为每个线程分配一个硬件队列(而不是内核驱动程序中每个内核分配一个硬件队列)。这保证了线程可以提交请求,而不需要与系统中的其他线程执行任何类型的协调(即锁定)。

下面尝试解释为什么传递给SPDK的所有数据缓冲区都必须使用spdk_dma_malloc()或它的兄弟节点来分配,以及为什么SPDK依赖于DPDK经过验证的基本功能来实现内存管理。
计算平台通常将物理内存划分为称为页面的4KiB段。它们从可寻址内存的开始将页编号从0到N。
然后,操作系统使用任意复杂的映射将4KiB虚拟内存页面覆盖在这些物理页面之上。有关概述,请参见虚拟内存
物理内存附加在通道上,其中每个内存通道提供一定数量的带宽。为了优化总内存带宽,通常将物理寻址设置为通道之间的自动交错。例如,第0页可能位于第0频道,第1页位于第1频道,第2页位于第2频道,等等。这样,按顺序自动地写入内存就利用了所有可用的通道。实际上,交错是在比整个页面更细粒度的层次上完成的。

现代计算平台在其内存管理单元(MMU)中支持虚拟到物理转换的硬件加速。MMU通常支持多种不同的页面大小。在最近的x86_64系统上,支持4KiB、2MiB和1GiB页面。通常,操作系统默认使用4KiB页面。
NVMe设备使用直接内存访问(DMA)在系统内存之间传输数据。具体地说,它们通过请求数据传输的PCI总线发送消息。
在没有IOMMU的情况下,这些消息包含物理内存地址。这些数据传输在不涉及CPU的情况下进行,而MMU负责使对内存的访问保持连贯。
NVMe设备还可能对这些传输的内存物理布局提出额外的要求。NVMe 1.0规范要求所有物理内存都可以通过所谓的PRP列表来描述。要用PRP列表来描述,内存必须具有以下属性:

  • 内存被分成物理4KiB页,我们将其称为设备页。
  • 第一个设备页可以是从任何4字节对齐地址开始的部分页。它可以扩展到当前物理页面的末尾,但不能超过这个范围
  • 如果有多个设备页,则第一个设备页必须以物理4KiB页边界结束。
  • 最后一个设备页从物理4KiB页边界开始,但不需要在物理4KiB页边界结束。

该规范允许设备页面的大小不超过4KiB,但是在编写本文时,所有已知的设备都使用4KiB。
NVMe 1.1规范增加了对完全灵活的散点收集列表的支持,但是该特性是可选的,目前大多数可用的设备都不支持它。
用户空间驱动程序在常规进程的上下文中运行,因此可以访问虚拟内存。为了正确地编写具有物理地址的设备程序,必须实现一些地址转换方法。
在Linux上做到这一点的最简单方法是从进程中检查/proc/self/pagemap。这个文件包含虚拟地址到物理地址的映射。从Linux 4.0开始,访问这些映射需要root特权。然而,操作系统绝对不能保证虚拟页面到物理页面的映射是静态的。操作系统不知道PCI设备是否直接将数据传输到一组物理地址,因此必须非常小心地将DMA请求与页面移动协调起来。当操作系统将页面标记为无法修改虚拟地址到物理地址的映射时,这称为固定页面。
虚拟到物理的映射也可能发生变化,原因有很多。到目前为止,最常见的原因是将页面交换到磁盘。然而,操作系统还在一个称为压缩的过程中移动页面,压缩过程将相同的虚拟页面折叠到相同的物理页面上以节省内存。一些操作系统还能够进行透明的内存压缩。热添加额外内存的可能性也越来越大,这可能触发物理地址重新平衡以优化交叉
POSIX提供了mlock调用,它强制内存的虚拟页面总是由物理页面支持。实际上,这是禁用交换。但是,这并不保证虚拟到物理地址的映射是静态的。mlock调用不应该与pin调用混淆,而且POSIX并没有定义用于固定内存的API。因此,分配固定内存的机制是特定于操作系统的。
SPDK依赖于DPDK来分配固定的内存。在Linux上,DPDK通过分配较大的页面(默认情况下为2MiB)来实现这一点。Linux内核对待巨页的方式与普通的4KiB页面不同。具体来说,操作系统永远不会改变它们的物理位置。这并不是故意的,因此在未来的版本中,情况可能会发生变化,但是在今天是这样,并且已经持续了许多年了(请参阅IOMMU后面的部分,以获得未来可靠的解决方案)。
通过这个解释,希望现在已经清楚了为什么必须使用spdk_dma_malloc()或它的兄弟节点来分配传递给SPDK的所有数据缓冲区。必须专门分配缓冲区,以便固定它们,并使物理地址是已知的。
许多平台包含一个额外的硬件,称为I/O内存管理单元(IOMMU)。IOMMU与常规MMU非常相似,只是它为外围设备(即PCI总线)提供了虚拟化的地址空间。MMU知道系统上每个进程的虚拟到物理映射,因此IOMMU将特定的设备与其中一个映射关联起来,然后允许用户在其进程中将任意总线地址分配给虚拟地址。然后,通过将总线地址转换为虚拟地址,然后将虚拟地址转换为物理地址,从而将PCI设备和系统内存之间的所有DMA操作通过IOMMU进行转换。这允许操作系统自由地修改虚拟到物理地址的映射,而不破坏正在进行的DMA操作。Linux提供了一个设备驱动程序vfio-pci,允许用户用当前进程配置IOMMU。
这是一个面向未来的、硬件加速的解决方案,用于在用户空间进程内外执行DMA操作,并为SPDK和DPDK的内存管理策略奠定了长期的基础。我们强烈建议使用vfio部署应用程序,并启用IOMMU,这在今天得到了完全支持。

消息传递和并发

SPDK的主要目标之一是随着硬件的增加而线性扩展。这在实践中可能意味着许多事情。例如,从一个SSD移动到两个SSD应该使每秒I/O的数量增加一倍。或者将CPU内核的数量增加一倍,应该可以使计算量增加一倍。甚至将nic的数量增加一倍,网络吞吐量也应该增加一倍。为了实现这一点,软件的设计必须使执行线程尽可能地彼此独立。实际上,这意味着要避免软件锁,甚至原子指令。
传统上,软件通过将一些共享数据放在堆上,用锁保护它,然后让所有执行线程只在需要访问共享数据时获得锁来实现并发。这个模型有很多很好的特性:

  • 将单线程程序转换为多线程程序相对容易,因为您不需要从单线程版本更改数据模型。只需在数据周围添加一个锁。
  • 您可以将程序编写为从上到下读取的同步命令语句列表。
  • 您的线程可以被后台的操作系统调度程序中断并休眠,从而实现CPU资源的高效分时共享。
    不幸的是,随着线程数量的增加,围绕共享数据的锁的争用也会增加。更细粒度的锁定会有所帮助,但也会极大地增加程序的复杂性。即使这样,超过一定数量的高度争用的锁,线程也将花费大部分时间试图获取锁,程序也不会从任何额外的CPU内核中获益。

SPDK采用了完全不同的方法。
SPDK通常会将共享数据分配给一个线程,而不是将共享数据放在一个全局位置,所有线程在获取锁之后都可以访问这个全局位置。
当其他线程希望访问数据时,它们会向拥有这些数据的线程传递一条消息,以代表它们执行操作。
当然,这种策略并不新鲜。
例如,它是Erlang的核心设计原则之一,也是Go中的主要并发机制。
SPDK中的消息通常由一个函数指针和一个指向某个上下文的指针组成,并使用无锁环在线程之间传递。
消息传递通常比大多数软件开发人员的直觉告诉他们的要快得多,这主要是由于缓存效果。
如果一个内核始终如一地访问相同的数据(代表所有其他内核),那么该数据很可能位于离该内核更近的缓存中。
通常,最有效的方法是让每个核心处理位于其本地缓存中的相对较小的数据集,然后在完成时向下一个核心传递一条小消息。
在更极端的情况下,即使消息传递也可能过于昂贵,将为每个线程复制数据。然后线程将只引用它的本地副本。要更改数据,线程之间将发送一条消息,告诉它们在本地副本上执行更新。当数据不经常发生变化,但可能非常频繁地被读取,并且经常在I/O路径中使用时,这是非常棒的。当然,这是以内存大小换取计算效率,所以它的使用仅限于最关键的代码路径。

消息传递基础设施

SPDK提供了几个消息传递基础结构层。例如,SPDK中最基本的库本身不执行任何消息传递,而是枚举关于何时在其文档中调用函数的规则(例如NVMe驱动程序)。然而,大多数库都依赖于SPDK的线程抽象(位于libspdk_thread.a中)。线程抽象提供了一个基本的消息传递框架,并定义了几个关键原语。
首先,spdk_thread是执行线程的抽象,spdk_poller是应该在给定线程上定期调用的函数的抽象。在用户希望与SPDK一起使用的每个系统线程上,必须首先调用spdk_thread_create()。
库还定义了另外两个抽象:spdk_io_device和spdk_io_channel。在实现SPDK的过程中,我们注意到许多不同的库中出现了相同的模式。为了实现消息传递策略,代码将描述具有全局状态的对象,以及与在I/O路径中访问的对象相关联的每个线程上下文,以避免锁定全局状态。这种模式在I/O被提交到阻塞设备的最底层最为明显。这些设备通常公开多个队列,可以将这些队列分配给线程,然后在没有锁的情况下访问它们以提交I/O。为了抽象它,我们将设备泛化为spdk_io_device,并将线程特定的队列泛化为spdk_io_channel。然而,随着时间的推移,这种模式出现在大量与我们最初选择的名称不太匹配的地方。在今天的代码中,spdk_io_device是任何指针,它的惟一性仅取决于它的内存地址,spdk_io_channel是与特定spdk_io_device关联的每个线程上下文。
线程抽象提供了向任何其他线程发送消息、逐个向所有线程发送消息以及向所有线程发送消息的函数,对于给定的io_device,这些线程都有一个io_channel。
事件框架
随着SPDK中的示例应用程序数量的增加,很明显,每个示例中的大部分代码都实现了调用spdk_thread_create()所需的基本消息传递基础设施。这包括为每个内核生成一个线程,将每个线程固定到一个惟一的内核,并在线程之间分配无锁环来传递消息。SPDK提供SPDK事件框架,而不是为每个示例应用程序重新实现该基础结构。这个库处理设置所有消息传递基础设施、安装信号处理程序以干净地关闭、实现周期性轮询器和执行基本的命令行解析。当通过spdk_app_start()启动时,库自动生成所有请求的线程,并将它们固定住,然后调用spdk_thread_create()。这使得实现一个全新的SPDK应用程序变得更加容易,并且是那些刚开始使用SPDK的人的推荐方法。只有具有足够消息传递基础设施的已建立的应用程序才应该考虑直接集成较低层库。
NAND闪存SSD内部组件
在撰写本文时,ssd通常是在NAND闪存之上实现的。在非常高的层次上,这种媒体有几个重要的特性:

  • 媒体被分组到称为NAND模组的芯片上,每个模组可以并行操作。
  • 翻转一点点是一个高度不对称的过程。往一个方向翻转很简单,但是反过来却很难。

NAND Flash媒体被分成大的单元,通常称为擦除块。擦除块的大小与实现高度相关,但可以认为介于1MiB和8MiB之间。对于每个擦除块,可以用位粒度将每个位写入(即将其位从0翻转到1)一次。为了第二次写入擦除块,整个块必须被擦除(即块中的所有位都被翻转回0),这是从上面看出来的不对称部分。擦除一个块会造成相当大的磨损,而且每个块只能擦除有限的次数。
ssd向主机系统公开一个接口,使其看起来好像驱动器由一组固定大小的逻辑块组成,这些逻辑块的大小通常为512B或4KiB。这些块完全是设备固件的逻辑构造,它们不会静态地映射到支持媒体上的某个位置。相反,每次写入逻辑块时,都会选择并写入NAND Flash上的新位置,并更新逻辑块到其物理位置的映射。选择这个位置的算法是整个SSD性能的关键部分,通常称为flash翻译层或FTL。该算法必须正确地分配块以考虑磨损(称为磨损水平),并将它们分散到NAND模具上,以提高总可用性能。最简单的模型是使用类似RAID的算法将每个die上的所有物理介质分组在一起,然后按顺序写入该集合。真正的ssd要复杂得多,但是对于软件开发人员来说,这是一个非常好的简单模型——假设他们只是简单地登录到RAID卷并更新内存中的散列表。
flash翻译层的一个结果是,逻辑块不一定总是与NAND上的物理位置对应。事实上,有一个命令可以清除块的翻译。在NVMe中,这个命令称为deallocate,在SCSI中称为unmap,在SATA中称为trim。当用户试图读取一个没有映射到物理位置的块时,驱动器会做以下两件事之一:

  • 立即成功完成读取请求,而不执行任何数据传输。这是可以接受的,因为驱动器返回的数据并不比用户数据缓冲区中已经存在的数据更有效。
  • 返回所有0作为数据。

选择#1更为常见,对完全释放位置的设备执行读操作通常会显示远远超出驱动器所声称的能力的性能,因为它实际上没有传输任何数据。在做基准测试之前,请先写好所有的代码块!
当ssd被写入时,内部日志最终将消耗所有可用的擦除块。为了继续写作,SSD必须释放其中的一些。这个过程通常称为垃圾收集。所有ssd都保留一定数量的擦除块,这样它们就可以保证有可用来进行垃圾收集的免费擦除块。垃圾收集一般通过以下方式进行:

  • 选择目标擦除块(一个好的心智模型是它选择最近最少使用的擦除块)
  • 遍历擦除块中的每个条目,并确定它是否仍然是一个有效的逻辑块。
  • 通过读取有效的逻辑块并将它们写入另一个擦除块(即日志的当前头)来移动它们
  • 擦除整个擦除块并将其标记为可用。

当第3步可以跳过时,垃圾收集显然要高效得多,因为擦除块已经是空的。有两种方法可以大大提高跳过步骤3的可能性。首先,ssd保留超出其报告容量的额外擦除块(称为过度供应),因此从统计上看,擦除块更有可能不包含有效数据。第二种是软件可以以循环的方式按顺序写入设备上的块,当不再需要旧数据时就将其丢弃。在这种情况下,软件保证最近最少使用的擦除块不会包含任何必须移动的有效数据。
如果工作负载填满了整个设备,那么设备过度配置的数量会极大地影响随机读写工作负载的性能。然而,通常可以通过在软件中为设备保留一定的空间来获得相同的效果。这种理解对于生成一致的基准非常重要。特别是,如果后台垃圾收集跟不上,而驱动器必须切换到按需垃圾收集,那么写操作的延迟将显著增加。因此,在运行一致性基准测试之前,必须强制设备的内部状态进入某个已知状态。这通常是通过连续两次写入设备来完成的,从开始到结束。有关如何强制SSD进入已知的基准测试状态的详细描述,请参阅本文。

提交I/O到NVME设备

NVMe设备允许主机软件(在我们的例子中是SPDK NVMe驱动程序)在主机内存中分配队列对。术语“主机”使用得很多,所以为了澄清这就是NVMe SSD所插入的系统。队列对由两个队列组成——提交队列完成队列。这些队列更准确地描述为具有固定大小条目的圆环。提交队列是一个64字节的命令结构数组,加上两个整数(头和尾索引)完成队列类似于一个由16个字节的完成结构组成的数组,加上两个整数(头和尾索引)。还涉及两个32位寄存器,称为doorbells
通过构造一个64字节的命令,将I/O提交给NVMe设备,将它放在提交队列头索引当前位置的提交队列中,然后将提交队列头的新索引写入提交队列头doorbells寄存器。实际上,将一组命令复制到环中打开的插槽中,然后只写一次doorbells就可以提交整批命令,这是有效的。
最重要的是,命令本身描述操作,如果需要,还描述主机内存中的一个位置,其中包含与该命令关联的主机内存的描述符。这个主机内存是要写在写命令上的数据,或者是要把数据放在读命令上的位置。使用NVMe设备上的DMA引擎将数据传输到该位置或从该位置传输。
完成队列的工作方式类似,但是设备是向环中写入条目的设备。每个条目包含一个“phase”位,在整个环的每个循环中,它在0和1之间切换。当设置队列对来生成中断时,中断包含完成队列头部的索引。然而,SPDK不支持中断,而是轮询相位位以检测是否完成。中断是非常繁重的操作,因此轮询这个相位位通常要高效得多。

现在我们知道了环形结构是如何工作的,让我们来看看SPDK NVMe驱动程序是如何使用它们的。用户将在程序生命周期的早期构建一个队列对,因此这不是“热”路径的一部分。然后,它们将调用spdk_nvme_ns_cmd_read()等函数来执行I/O操作。用户提供数据缓冲区、目标LBA和长度,以及其他信息,比如命令针对哪个NVMe名称空间以及要使用哪个NVMe队列对。最后,用户提供一个回调函数和上下文指针,当在稍后调用spdk_nvme_qpair_process_completions()时发现生成命令的完成时,将调用该函数和上下文指针。
驱动程序的第一阶段是分配一个请求对象来跟踪操作。这些操作是异步的,因此它不能简单地跟踪调用堆栈上请求的状态。在堆上分配一个新的请求对象将非常慢,因此SPDK将一组预先分配的请求对象保存在NVMe队列对对象(struct spdk_nvme_qpair)中。分配给队列对的请求数量大于NVMe提交队列的实际队列深度,因为SPDK支持几个关键的便利特性。第一个是软件排队——SPDK将允许用户提交比硬件队列实际能容纳的更多的请求,SPDK将在软件中自动排队。第二个是分裂。SPDK将出于许多原因拆分一个请求,下面将概述其中的一些原因。请求对象的数量在队列对创建时是可配置的,如果没有指定,SPDK将根据硬件队列深度选择一个合理的数量。

第二阶段是构建64字节的NVMe命令本身。该命令内置在嵌入到请求对象的内存中——而不是直接嵌入到NVMe提交队列插槽中。构建好命令后,SPDK将尝试在NVMe提交队列中获取一个打开的插槽。为提交队列中的每个元素分配一个称为跟踪器的对象。跟踪器是在数组中分配的,因此可以通过索引快速查找它们。跟踪器本身包含一个指向当前占用该槽的请求的指针。当获得特定跟踪器时,命令的CID值将使用跟踪器的索引进行更新。NVMe规范在完成时提供了CID值,因此可以通过使用CID值查找跟踪器,然后跟踪指针来恢复请求。

一旦获得跟踪器(slot),将处理与之关联的数据缓冲区,以构建PRP列表。这本质上是一个NVMe分散收集列表,尽管它有一些限制。用户向SPDK提供缓冲区的虚拟地址,因此SPDK必须执行一个页表查找,以找到支持该虚拟内存的物理地址(pa)或I/O虚拟地址(iova)。一个虚拟连续的内存区域可能不是物理上连续的,因此这可能导致一个包含多个元素的PRP列表。有时这可能会导致一组物理地址实际上不能表示为单个PRP列表,因此SPDK会透明地自动将用户操作分割为两个单独的请求。有关如何管理内存的更多信息,请参见从用户空间直接内存访问(DMA)。
在获得跟踪器之前不会构建PRP列表的原因是,PRP列表描述必须在DMA-able内存中分配,并且可能非常大。由于SPDK通常会分配大量的请求,所以我们不希望分配足够的空间来预构建最坏情况下的PRP列表,特别是考虑到一般情况下根本不需要单独的PRP列表。
每个NVMe命令中都嵌入了两个PRP列表元素,因此,如果请求是4KiB(或者是8KiB并且对齐很好),则不需要单独的PRP列表。分析表明,这段代码对总体CPU使用并不是主要贡献
填写跟踪器后,SPDK将64字节的命令复制到实际的NVMe提交队列插槽中,然后按下提交队列尾部的doorbell,告诉设备去处理它。然后SPDK返回给用户,而不需要等待完成。
用户可以定期调用spdk_nvme_qpair_process_completions()来告诉SPDK检查完成队列。具体来说,它读取下一个预期完成槽的阶段位,当它翻转时,查看CID值以找到指向请求对象的跟踪器。request对象包含用户最初提供的函数指针,然后调用该函数来完成命令。
spdk_nvme_qpair_process_completions()函数将完成推进到下一个槽,直到耗尽了完成,这时它将编写完成队列头门铃让设备知道它可以使用新完成的完成队列槽和回报

使用Vhost-user虚拟化I/O

Virtio设备使用virtqueue高效地传输数据。Virtqueue是一组三种不同的单生产者、单消费者环结构,用于存储通用的scatter-gatter I/O。Virtio在QEMU vm中最常用,QEMU本身公开一个虚拟PCI设备,客户OS使用特定的Virtio PCI驱动程序与它通信。只要涉及Virtio,总是QEMU进程处理所有I/O流量(QEMU(quick emulator)是一款由Fabrice Bellard等人编写的免费的可执行硬件虚拟化的(hardware virtualization)开源托管虚拟机(VMM))。
Vhost是一种通过进程间通信访问设备的协议它使用与Virtio相同的virtqueue布局,允许将Vhost设备 直接映射到 Virtio设备。这允许由SPDK应用程序公开的Vhost设备被QEMU进程中的来宾OS直接访问,该操作系统使用现有的Virtio (PClI)驱动程序。只有配置、I/O提交通知和I/O完成中断通过QEMU管道传输。参见SPDK优化

最初的vhost实现是Linux内核的一部分,使用ioctl接口与用户空间应用程序通信。使SPDK能够公开vhost设备的是vhost -user协议。
Vhost-user规范将协议描述如下:

[Vhost-user protocol]是用来补充ioctl接口的控制Linux内核中的vhost实现。
它实现了控件平面上需要建立与用户空间进程共享的virtqueue相同的主机。
它使用Unix域套接字上的通信来共享文件消息的辅助数据中的描述符。
协议定义了通信的两个方面,主从。主是共享其virtqueue的应用程序,在我们的例子中是QEMU。
slave是virtqueue的消费者。在当前的实现中,QEMU是主服务器,而从服务器则是在用户空间中运行一个软件以太网交换机,
例如Snabbswitch。主从可以是客户机(即连接),也可以是服务器(侦听)在套接字通信中。

SPDK vhost是一个vhost -user从服务器。它公开Unix域套接字并允许外部应用程序连接。
设备初始化
所有初始化和管理信息都使用Vhost-user消息进行交换。连接总是从特性协商开始。主服务器和从服务器都公开了它们实现的特性的列表,在协商之后,它们选择一组公共特性。这些特性大多与实现相关,但也与多队列支持或实时迁移有关。
协商之后,vhost -user驱动程序共享它的内存,以便vhost设备(SPDK)可以直接访问它
内存可以被分割成多个物理上不连续的区域,Vhost-user规范限制了它们的数量——目前为8。
驱动为每个区域发送一条消息,数据如下:

  • 文件描述符-用于mmap
  • 用户地址——用于在Vhost-user消息中进行内存转换(例如,转换vring地址)
  • 访客地址——对于vrings中的缓冲区地址转换(对于QEMU,这是访客地址中的物理地址)
  • 用户偏移量- mmap的正偏移量
  • 尺寸

每次内存更改后,主程序将发送新的内存区域—通常是热插拔/热删除。
前面的映射将被删除
驱动程序也可以请求设备配置,包括磁盘几何形状。
但是,Vhost-SCSI驱动程序不需要实现此功能,因为它们使用公共SCSI I/O来查询底层磁盘
SCSI:小型计算机系统接口(SCSI,Small Computer System Interface)是一种用于计算机及其周边设备之间(硬盘、软驱、光驱、打印机、扫描仪等)系统级接口的独立处理器标准
之后,驱动程序请求支持的最大队列数,并开始发送virtqueue数据,其中包括:

  • 唯一的virtqueue id
  • 最后处理的vring描述符的索引
  • vring地址(来自用户地址空间)
  • 调用描述符(用于在I/O完成后中断驱动程序)
  • 活跃描述符(用于侦听I/O请求——SPDK未使用)

如果协商了多队列特性,则驱动程序必须为 它希望轮询的每个额外队列 发送 特定的 ENABLE消息。
其他队列在初始化后立即轮询

I/O 路径
主进程通过在共享内存中分配适当的缓冲区、填充请求数据,并将这些缓冲区的访客地址放入virtqueue来发送I/O。
Virtqueue通常由一个描述符数组组成,每个I/O都需要转换成一个描述符链。
一个描述符可以是可读的,也可以是可写的,因此每个I/O请求至少包含两个(请求+响应)。
遗留的Virtio实现在virtqueue旁边使用了vring名称,vring名称仍然在代码中的Virtio数据结构中使用。
与struct virtq_desc不同,struct vring_desc更有可能被找到
轮询此描述符链之后的设备,需要将其翻译并转换回原始请求结构。
它需要预先知道请求布局,因此每个设备后端(Vhost-Block/SCSI)都有自己的轮询virtqueue的实现。
对于每个描述符,设备在vhost -user内存区域表中执行查找,并通过gpa_to_vva转换(访客物理地址到vhost虚拟地址)。
SPDK强制将请求和响应数据包含在一个内存区域中
I/O缓冲区没有这样的限制,如果需要,SPDK可以自动执行额外的iovec分割和gpa_to_vva转换。
在形成请求结构之后,SPDK将这样的I/O转发到基础驱动器并轮询完成
一旦I/O完成,SPDK vhost将用适当的数据填充响应缓冲区,并通过在适当的virtqueue的调用描述符上执行eventfd_write来中断客户机。
有多个中断合并特性,但是本文不讨论它们。
SPDK优化
由于其轮询模式的特性,SPDK vhost取消了对I/O提交通知的要求,从而大大提高了vhost服务器的吞吐量,并降低了提交I/O的客户开销。
有两种不同的解决方案可以减轻I/O完成中断开销(irqfd、vDPA),但本文不会讨论这些解决方案。
为了获得最高的性能,可以使用轮询模式Virtio驱动程序,因为它抑制所有I/O完成中断,使I/O路径完全绕过QEMU/KVM开销。

SPDK 结构概述

SPDK由驻留在lib中的一组C库组成,其中包含include/ SPDK中的公共接口头文件,以及应用程序中基于这些库构建的一组应用程序。用户可以在自己的软件中使用C库,也可以部署完整的SPDK应用程序。
SPDK是围绕消息传递而不是锁定设计的,大多数SPDK库都对嵌入其中的应用程序的底层线程模型做了一些假设。
然而,SPDK不遗余力地保持对实际使用的特定消息传递、事件、协例程或轻量级线程框架的不可知。
为此,所有SPDK库都与lib/thread中的抽象库(include/ SPDK /thread.h处的公共接口)进行交互。
任何框架都可以初始化线程抽象并提供回调来实现SPDK库所需的功能。
有关此抽象的更多信息,请参见消息传递和并发。
SPDK构建在POSIX之上,用于大多数操作。
为了使移植到非POSIX环境更容易,所有POSIX头文件都被隔离到include/spdk/stdin .h中
然而,SPDK需要POSIX不提供的许多操作,例如枚举系统上的PCI设备或分配DMA安全的内存。
这些额外的操作都是在名为env的库中抽象的,其公共头位于include/spdk/env.h。
默认情况下,SPDK使用基于DPDK的库实现env接口。但是,可以将该实现交换出去。
有关更多信息,请参阅SPDK移植指南。

lib目录包含SPDK的真正核心。

每个组件都是一个C库,在lib下有自己的目录。

  • 块设备用户指南
  • NVMe 驱动

摘自链接:https://spdk.io/doc

Logo

更多推荐