SPDK:存储性能开发套件(Storage Performance Development Kit)介绍

目录

什么是SPDK

获取源代码与安装

安装先决条件

configure与make

运行单元测试

变更日志

用户空间驱动程序

从用户空间控制硬件

中断

Threading

用户空间中的直接内存访问(DMA)

IOMMU支持

消息传递和并发

理论

消息传递基础结构

事件框架

C语言的局限性

NAND Flash SSD内部

提交I / O到NVMe设备

NVMe规范

SPDK NVMe驱动程序I / O路径

使用Vhost用户的虚拟化I / O

介绍

量化宽松

设备初始化

I / O路径

SPDK优化

SPDK结构概述

应用领域

库函数

文献资料

例子

头文件

脚本

测验

SPDK移植指南

用户指南

程序员指南


 

什么是SPDK

https://spdk.io/doc/about.html


存储性能开发套件(SPDK)提供了一组工具和库,用于编写高性能,可扩展的用户模式存储应用程序。它通过使用多种关键技术来实现高性能:

  • 将所有必要的驱动程序移到用户空间中,这避免了系统调用,并允许从应用程序进行零拷贝访问。
  • 通过轮询硬件来完成而不是依赖中断,这可以降低总延迟和延迟差异。
  • 避免所有在I / O路径中的锁,而是依靠消息传递。

SPDK的基础是用户空间,轮询模式,异步,无锁的NVMe驱动程序。这提供了零拷贝,高度并行的访问,可从用户空间应用程序直接访问SSD。该驱动程序被编写为具有单个公共标头的C库。有关更多详细信息,请参见NVMe驱动程序

SPDK还提供了完整的块堆栈作为用户空间库,该库执行与操作系统中的块堆栈相同的许多操作。这包括统一不同存储设备之间的接口,排队以处理诸如内存不足或I / O挂起之类的情况以及逻辑卷管理。有关更多信息,请参见块设备用户指南

最后,SPDK 在这些组件之上提供了NVMe-oFiSCSI虚拟主机服务器,这些服务器能够通过网络或其他进程为磁盘提供服务。用于NVMe-oF和iSCSI的标准Linux内核启动器可与这些目标以及具有vhost的QEMU进行交互。与其他实现相比,这些服务器的CPU效率最高可提高一个数量级。这些目标可以用作如何实现高性能存储目标的示例,也可以用作生产部署的基础。

 

 

获取源代码与安装

git clone https://github.com/spdk/spdk
cd spdk
git submodule update --init

安装先决条件

scripts/pkgdep.sh脚本将自动安装构建SPDK所需的最低要求。用--help看到的可选组件安装相关性的信息。

sudo scripts/pkgdep.sh

Option –all将安装SPDK功能所需的所有依赖项。

sudo scripts/pkgdep.sh --all

configure与make

Linux:

./configure
make

配置脚本有很多选项,可以通过运行来查看

./configure --help

请注意,并非默认情况下启用所有功能。例如,默认情况下未启用RDMA支持(因此不支持基于Fabric的NVMe)。您可以通过执行以下操作启用它:

./configure --with-rdma
make

运行单元测试

通过运行单元测试来确认您的构建正常是一个好主意。

./test/unit/unittest.sh

运行单元测试时,您将看到几条错误消息,但它们是测试套件的一部分。脚本末尾的最后一条消息指示成功或失败。

 

变更日志


https://spdk.io/doc/changelog.html

 

用户空间驱动程序


https://spdk.io/doc/userspace.html

从用户空间控制硬件

SPDK的许多文档都讨论了用户空间驱动程序,因此了解技术层面的含义很重要。首先,驱动程序是直接控制连接到计算机的特定设备的软件。其次,操作系统根据特权级别将系统的虚拟内存分为两类地址:内核空间和用户空间。这种分离是通过CPU本身的功能(称为保护环)来实现的,这些功能可以实现内存分离。通常,驱动程序在内核空间中运行(即x86上的ring 0)。SPDK包含设计为在用户空间中运行的驱动程序,但它们仍直接与它们控制的硬件设备接口。

为了使SPDK能够控制设备,它必须首先指示操作系统放弃控制。这通常称为将内核驱动程序与设备解除绑定,在Linux上是通过写入sysfs中的文件来完成的。然后,SPDK将驱动程序重新绑定到与Linux- uiovfio捆绑在一起的两个特殊设备驱动程序之一。这两个驱动程序在某种意义上是“虚拟”驱动程序,因为它们通常会向操作系统表明该设备已绑定了一个驱动程序,因此它不会自动尝试重新绑定默认驱动程序。他们实际上不以任何方式初始化硬件,甚至不了解它是什么类型的设备。uio和vfio之间的主要区别在于vfio能够对平台的IOMMU是确保用户空间驱动程序中内存安全的关键硬件。有关完整详细信息,请参见从用户空间直接内存访问(DMA)

一旦设备与操作系统内核解除绑定,操作系统将无法再使用它。例如,如果在Linux上取消绑定NVMe设备,则与之对应的设备(例如/ dev / nvme0n1)将消失。这还意味着安装在设备上的文件系统也将被删除,内核文件系统将不再与设备交互。实际上,不再涉及整个内核块存储堆栈。取而代之的是,SPDK提供了典型操作系统存储堆栈中大多数层的重新构想的实现,所有实现都是C库,可以直接嵌入到您的应用程序中。这主要包括块设备抽象层,还包括块分配器类似文件系统的组件

用户空间驱动程序利用uio或vfio中的功能将设备的PCI BAR映射到当前进程,这使驱动程序可以直接执行MMIO。例如,SPDK NVMe驱动程序会映射NVMe设备的BAR,然后遵循NVMe规范来初始化设备,创建队列对并最终发送I / O。

中断

SPDK轮询设备是否完成而不是等待中断。这样做的原因有很多:1)实际上,对于大多数硬件设计来说,在用户空间进程中将中断路由到处理程序是不可行的; 2)中断会引入软件抖动,并且由于强制上下文而产生大量开销开关。SPDK中的操作几乎普遍是异步的,并允许用户在完成时提供回调。响应用户调用函数轮询完成情况而调用回调。轮询NVMe设备的速度很快,因为只需读取主机内存(无需MMIO)即可检查队列对是否发生位翻转,并且Intel DDIO等技术将确保更新后的CPU缓存中存在被检查的主机内存。通过设备。

Threading

NVMe设备公开了多个队列,用于将请求提交到硬件。无需协调即可访问单独的队列,因此软件可以从多个并行执行线程向设备发送请求而无需锁定。不幸的是,内核驱动程序必须设计为处理来自操作系统或系统上各种进程的许多不同位置的I / O,并且这些进程的线程拓扑会随着时间而变化。大多数内核驱动程序选择将硬件队列映射到内核(尽可能接近1:1),然后在提交请求时,它们为当前线程恰好在其上运行的任何内核查找正确的硬件队列。他们经常 我们将需要获得围绕队列的锁或暂时禁用中断以防止抢先于同一内核上运行的线程,这可能会很昂贵。与旧的硬件接口相比,这是一个很大的改进,旧的硬件接口只有一个队列或根本没有队列,但仍不总是最佳的。

另一方面,用户空间驱动程序被嵌入到单个应用程序中。该应用程序确切知道存在多少个线程(或进程),因为该应用程序创建了它们。因此,SPDK驱动程序选择将硬件队列直接公开给应用程序,要求一次只能从一个线程访问硬件队列。实际上,应用程序为每个线程分配一个硬件队列(与内核驱动程序中的每个内核一个硬件队列相反)。这样可以保证线程可以提交请求,而不必与系统中的其他线程进行任何形式的协调(即锁定)。

 

 

用户空间中的直接内存访问(DMA)


https://spdk.io/doc/memory.html

以下是尝试解释为什么必须使用spdk_dma_malloc()或其同级对象分配传递给SPDK的所有数据缓冲区的原因,以及为什么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通过分配hugepages(默认情况下为2MiB)来做到这一点。Linux内核对待巨大页面的方式与常规4KiB页面不同。具体来说,操作系统将永远不会更改其物理位置。这不是出于意图,因此将来的版本中可能会发生变化,但这在今天已经存在了很多年(请参阅有关IOMMU的下一节,以提供永不过时的解决方案)。

有了这个解释,现在希望可以清楚为什么必须使用spdk_dma_malloc()或其同级对象来分配传递给SPDK的所有数据缓冲区。必须专门分配缓冲区,以便固定它们并知道物理地址。

IOMMU支持

许多平台包含额外的硬件,称为I / O内存管理单元(IOMMU)。IOMMU非常类似于常规MMU,除了它为外围设备(即,在PCI总线上)提供虚拟化的地址空间。MMU知道系统上每个进程的虚拟到物理映射,因此IOMMU将特定设备与这些映射之一关联,然后允许用户分配任意总线地址虚拟地址。然后,通过将总线地址转换为虚拟地址,然后将虚拟地址转换为物理地址,通过IOMMU转换PCI设备和系统内存之间的所有DMA操作。这允许操作系统自由修改虚拟地址到物理地址的映射,而不会中断正在进行的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 Driver)。但是,大多数库都依赖于SPDK的线程抽象,位于libspdk_thread.a。线程抽象提供了一个基本的消息传递框架,并定义了一些关键原语。

首先,spdk_thread是对轻量级,无堆栈执行线程的抽象。下层框架可以spdk_thread通过调用为单个时间片执行spdk_thread_poll()spdk_thread只要在任何给定时间仅在一个系统线程上执行spdk_thread_poll(),较低级别的框架就可以随时在系统线程之间移动spdk_thread。新的轻量级线程可以通过调用随时创建,也可以通过调用spdk_thread_create()销毁spdk_thread_destroy()。轻量级线程是SPDK中线程的基础抽象。

然后,在之上会叠加一些其他抽象spdk_thread。一个是spdk_poller,它是应在给定线程上重复调用的函数的抽象。另一个是an spdk_msg_fn,它是一个函数指针和一个上下文指针,可以通过发送给线程以执行spdk_thread_send_msg()

该库还定义了两个附加的抽象:spdk_io_devicespdk_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_thread_poll()每个spdk_thread()存在的框架。这使得SPDK非常移植到多种异步的,基于事件的框架如的海星libuv

事件框架

为了支持尽可能多的框架,SPDK项目不想为其附带的所有示例应用程序正式选择一个基于事件的异步框架。但是,应用程序当然需要执行异步事件循环才能运行,因此请输入event位于中的框架lib/event。该框架包括轮询和调度轻量级线程,安装信号处理程序以完全关闭以及基本的命令行选项解析之类的事情。仅已建立的应用程序应考虑直接集成较低级别的库。

C语言的局限性

消息传递是有效的,但是会导致异步代码。不幸的是,异步代码是C语言中的一个挑战。通常通过传递在操作完成时调用的函数指针来实现。这会砍断代码,以使其不容易遵循,尤其是通过逻辑分支。最好的解决方案是使用支持期货和承诺的语言,例如C ++,Rust,Go或几乎任何其他高级语言。但是,SPDK是一个低级库,需要非常广泛的兼容性和可移植性,因此我们选择保留普通的C语言。

不过,我们确实有一些建议可以分享。对于简单的回调链,最简单的方法是从下至上编写函数。意思是说,如果函数foo执行一些异步操作并且在bar调用完成函数时,则函数bar执行一些baz在完成时调用函数的操作,写这种函数的好方法是这样的:

void baz(void *ctx) {
        ...
}

void bar(void *ctx) {
        async_op(baz, ctx);
}

void foo(void *ctx) {
        async_op(bar, ctx);
}

不要拆分这些功能-将它们作为一个可以从下至上阅读的好单元。

对于更复杂的回调链,尤其是具有逻辑分支或循环的回调链,最好写出状态机。事实证明,支持期货和承诺的高级语言只是在编译时生成状态机,因此,即使我们没有用C生成状态机的能力,我们仍然可以手工编写它们。例如,这是一个执行foo5次然后调用的回调链,bar实际上是一个异步for循环。

enum states {
        FOO_START = 0,
        FOO_END,
        BAR_START,
        BAR_END
};

struct state_machine {
        enum states state;

        int count;
};

static void
foo_complete(void *ctx)
{
    struct state_machine *sm = ctx;

    sm->state = FOO_END;
    run_state_machine(sm);
}

static void
foo(struct state_machine *sm)
{
    do_async_op(foo_complete, sm);
}

static void
bar_complete(void *ctx)
{
    struct state_machine *sm = ctx;

    sm->state = BAR_END;
    run_state_machine(sm);
}

static void
bar(struct state_machine *sm)
{
    do_async_op(bar_complete, sm);
}

static void
run_state_machine(struct state_machine *sm)
{
    enum states prev_state;

    do {
        prev_state = sm->state;

        switch (sm->state) {
            case FOO_START:
                foo(sm);
                break;
            case FOO_END:
                /* This is the loop condition */
                if (sm->count++ < 5) {
                    sm->state = FOO_START;
                } else {
                    sm->state = BAR_START;
                }
                break;
            case BAR_START:
                bar(sm);
                break;
            case BAR_END:
                break;
        }
    } while (prev_state != sm->state);
}

void do_async_for(void)
{
        struct state_machine *sm;

        sm = malloc(sizeof(*sm));
        sm->state = FOO_START;
        sm->count = 0;

        run_state_machine(sm);
}

当然,这很复杂,但是run_state_machine可以从上至下读取该函数,以清晰地了解代码中正在发生的事情,而不必逐一遍查每个回调。

 

固态设备(SSD)是复杂的设备,其性能取决于其使用方式。以下描述旨在帮助软件开发人员了解SSD内发生的情况,以便他们提出更好的软件设计。不应将其视为SSD硬件真正工作方式的严格准确指南。

在撰写本文时,SSD通常在NAND闪存之上实现。在很高的层次上,这种媒体具有一些重要的属性:

  • 媒体被分组到称为NAND芯片的芯片上,每个芯片可以并行运行。
  • 略微翻转是一个高度不对称的过程。用一种方法将其翻转很容易,但是将其翻转回去却相当困难。

NAND闪存介质分为大块,通常称为擦除块。擦除块的大小在很大程度上取决于实现,但可以认为介于1MiB和8MiB之间。对于每个擦除块,每个位可以一次以位粒度写入(即,其位从0翻转到1)。为了第二次写入擦除块,必须擦除整个块(即,将块中的所有位都翻转回0)。这是从上方看的不对称部分。擦除一块会导致可测量的磨损,每个块只能擦除有限的次数。

SSD将接口暴露给主机系统,使其看起来好像驱动器由一组固定大小的逻辑块组成,通常大小为512B或4KiB。这些块完全是设备固件的逻辑结构,它们不会静态映射到备份介质上的某个位置。相反,在每次写入逻辑块时,将选择并写入NAND闪存上的新位置,并更新逻辑块到其物理位置的映射。选择此位置的算法是整体SSD性能的关键部分,通常称为闪存转换层或FTL。该算法必须正确分配块以解决磨损(称为磨损均衡))并将其分布在NAND裸片上,以提高总体可用性能。最简单的模型是使用类似于RAID的算法将每个裸片上的所有物理介质分组在一起,然后顺序写入该组。真正的固态硬盘要复杂得多,但是对于软件开发人员来说,这是一个极好的简单模型-想象一下,他们只是在登录RAID卷并更新内存中的哈希表。

闪存转换层的一个后果是逻辑块不一定总是与NAND上的物理位置相对应。实际上,有一条命令可以清除块的翻译。在NVMe中,此命令称为取消分配,在SCSI中此命令称为取消映射,在SATA中此命令称为修剪。当用户尝试读取没有物理位置映射的块时,驱动器将执行以下两项操作之一:

  1. 立即成功完成读取请求,而无需执行任何数据传输。这是可以接受的,因为驱动器将返回的数据比用户数据缓冲区中已有的数据更有效。
  2. 返回全0作为数据。

选择#1更常见,对完全释放的设备执行读取操作通常会显示出远远超出驱动器声称的能力的性能,因为它实际上并未传输任何数据。进行基准测试之前,请先写入所有块!

写入SSD时,内部日志最终将消耗所有可用的擦除块。为了继续写入,SSD必须释放其中一些。此过程通常称为垃圾收集。所有SSD都保留一定数量的擦除块,以便可以保证有可用的擦除块可用于垃圾回收。垃圾收集通常通过以下方式进行:

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

当可以跳过步骤3时,垃圾收集显然要高效得多,因为擦除块已经为空。有两种方法可以使步骤3更有可能被跳过。首先,SSD保留了超出其报告容量的其他擦除块(称为过量配置),因此从统计上讲,擦除块将更可能不包含有效数据。第二个是软件可以以圆形模式按顺序写入设备上的块,从而在不再需要时丢弃旧数据。在这种情况下,该软件保证最近最少使用的擦除块不会包含任何必须移动的有效数据。

如果某个工作负载正在填满整个设备,那么设备的过度配置可能会极大地影响随机读写工作负载的性能。但是,通常可以通过简单地在软件中在设备上保留给定数量的空间来获得相同的效果。这种理解对于产生一致的基准至关重要。特别是,如果后台垃圾收集无法跟上,并且驱动器必须切换到按需垃圾收集,则写入的延迟将大大增加。因此,在运行基准测试以确保一致性之前,必须将设备的内部状态强制为某种已知状态。这通常是通过从头到尾依次两次写入设备来完成的。SNIA条

 

提交I / O到NVMe设备


https://spdk.io/doc/nvme_spec.html

NVMe规范

NVMe规范描述了用于与存储设备进行交互的硬件接口。该规范包括用于远程存储的网络传输定义,以及用于本地PCIe设备的硬件寄存器布局。此处概述了如何通过SPDK将I / O提交到本地PCIe设备。

NVMe设备允许主机软件(在我们的示例中为SPDK NVMe驱动程序)在主机内存中分配队列对。经常使用“主机”一词,因此来说明该系统是NVMe SSD插入的系统。队列对由两个队列组成-提交队列和完成队列。这些队列被更准确地描述为固定大小条目的圆环。提交队列是64个字节的命令结构的数组,外加2个整数(头和尾索引)。完成队列类似地是一个由16个字节的完成结构加上2个整数(头和尾索引)组成的数组。还涉及两个称为门铃的32位寄存器。

通过构造64字节命令将I / O提交到NVMe设备,将其放入提交队列头索引的当前位置的提交队列中,然后将提交队列头的新索引写入提交队列头门铃寄存器。将整个命令集复制到环中的开放插槽中,然后一次编写门铃来提交整个批次,实际上是有效的。

NVMe规范中有非常详细的命令提交和完成过程描述,可从NVM Express的主页上方便地获得。

最重要的是,命令本身描述了操作,并且在必要时还描述了主机存储器中的位置,该位置包含用于与命令关联的主机存储器的描述符。该主机存储器是要在写命令上写入的数据,或者是将数据放置在读命令上的位置。使用NVMe设备上的DMA引擎将数据传输到该位置或从该位置传输数据。

完成队列的工作原理类似,但设备是将条目写入环的设备。每个条目都包含一个“相位”位,该位在整个环的每个环路中在0和1之间切换。当一个队列对被设置为生成中断时,该中断包含完成队列头的索引。但是,SPDK不启用中断,而是轮询相位位以检测完成。中断是非常繁重的操作,因此轮询此相位位通常会更有效率。

SPDK NVMe驱动程序I / O路径

现在我们知道了环形结构的工作原理,让我们介绍一下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值查找跟踪器,然后跟随指针来恢复请求。

获取跟踪器(插槽)后,将处理与其关联的数据缓冲区以构建PRP列表。从本质上讲,这是一个NVMe分散收集列表,尽管它有更多限制。用户为SPDK提供了缓冲区的虚拟地址,因此SPDK必须去做一个页表查找,以找到支持该虚拟内存的物理地址(pa)或I / O虚拟地址(iova)。实际上连续的内存区域可能在物理上不连续,因此这可能导致PRP列表包含多个元素。有时,这可能会导致一组物理地址实际上不能表示为单个PRP列表,因此SPDK会自动将用户操作透明地拆分为两个单独的请求。有关如何管理内存的更多信息,请参见从用户空间直接内存访问(DMA)

在获得跟踪器之前不构建PRP列表的原因是,必须在可DMA的内存中分配PRP列表描述,并且它可能很大。由于SPDK通常会分配大量请求,因此我们不想分配足够的空间来预先构建最坏情况的PRP列表,尤其是考虑到通常情况下根本不需要单独的PRP列表。

每个NVMe命令中都嵌入了两个PRP列表元素,因此,如果请求是4KiB(或者如果是8KiB并且已完美对齐),则不需要单独的PRP列表。分析表明,代码的这一部分不是CPU总体使用的主要贡献者。

填写好跟踪器后,SPDK将64字节命令复制到实际的NVMe提交队列插槽中,然后按一下提交队列尾部的门铃,以通知设备对其进行处理。然后,SPDK将返回给用户,而无需等待完成。

用户可以定期调用spdk_nvme_qpair_process_completions()以告知SPDK检查完成队列。具体来说,它读取下一个预期完成时隙的相位位,并在翻转时查看CID值以找到跟踪器,该跟踪器指向请求对象。请求对象包含用户最初提供的功能指针,然后调用该功能指针以完成命令。

spdk_nvme_qpair_process_completions()功能将继续前进到下一个完成插槽,直到用完了完成为止,这时它将写入完成队列头门铃,以使设备知道可以将完成队列插槽用于新的完成并返回。

 

使用Vhost用户的虚拟化I / O

https://spdk.io/doc/vhost_processing.html


介绍

本文档旨在概述Vhost在幕后的工作方式。为了便于阅读,本文档中使用的代码段可能已经简化,因此不应用作API或实现参考。

Virtio规范中读取:

virtio和virtio规范的目的在于,虚拟环境和guest虚拟机应具有用于虚拟设备的简单,高效,标准和可扩展的机制,而不是针对每个环境或每个OS的机制。
 
Virtio设备使用virtqueue来有效地传输数据。Virtqueue是一组三种不同的单一生产者,单一消费者的环形结构的集合,旨在存储通用的分散分类I / O。Virtio最常用于QEMU VM中,其中QEMU本身公开了虚拟PCI设备,而来宾OS使用特定的Virtio PCI驱动程序与其通信。仅涉及Virtio,始终由QEMU进程处理所有I / O流量。

Vhost是用于通过进程间通信访问设备的协议。它使用与Virtio相同的virtqueue布局,以允许将Vhost设备直接映射到Virtio设备。这样一来,来宾操作系统就可以使用现有的Virtio(PCI)驱动程序,直接在QEMU进程中通过来宾操作系统直接访问由SPDK应用程序公开的Vhost设备。仅通过QEMU传递配置,I / O提交通知和I / O完成中断。另请参阅SPDK优化

最初的vhost实现是Linux内核的一部分,并使用ioctl接口与用户空间应用程序进行通信。SPDK可以公开虚拟主机设备的原因是虚拟主机用户协议。

所述虚拟主机用户规范描述了协议如下:

[虚拟主机用户协议]旨在补充用于控制Linux内核中虚拟主机实现的ioctl接口。 它实现了与同一主机上的用户空间进程建立虚拟队列共享所需的控制平面。 它使用Unix域套接字上的通信来共享消息辅助数据中的文件描述符。
该协议定义了通信的两个方面,主方和从方。 Master是共享其美德的应用程序,在我们的案例中是QEMU。 奴隶是美德的消费者。
在当前实现中,QEMU是Master,而Slave则是要在用户空间中运行的软件以太网交换机,例如Snabbswitch。
主机和从机可以是套接字通信中的客户端(即连接)或服务器(侦听)。

SPDK vhost是Vhost用户的从属服务器。它公开了Unix域套接字,并允许外部应用程序进行连接。

量化宽松

主要的Vhost用户用例之一是QEMU中的网络(DPDK)或存储(SPDK)卸载。下图显示了基于QEMU的VM如何与SPDK Vhost-SCSI设备进行通信。

 

设备初始化

所有初始化和管理信息都使用Vhost用户消息进行交换。连接始终从功能协商开始。主设备和从设备都公开了其已实现功能的列表,经协商后,他们选择了一组通用的功能。这些功能大多数与实现相关,但也要考虑例如多队列支持或实时迁移。

协商之后,Vhost用户驱动程序共享其内存,以便vhost设备(SPDK)可以直接访问它。内存可以分为多个物理上不连续的区域,并且Vhost用户规范对其数量进行了限制-当前为8。驱动程序为每个区域发送一条消息,其中包含以下数据:

  • 文件描述符-用于mmap
  • 用户地址-用于Vhost用户消息中的内存转换(例如,翻译vring地址)
  • 来宾地址-用于缓冲区的地址转换(对于QEMU,这是来宾内部的物理地址)
  • 用户偏移量-mmap的正偏移量
  • 尺寸

每次更改内存后,主服务器都会发送新的内存区域-通常是热插拔/热删除。以前的映射将被删除。

驱动程序还可以请求设备配置,例如磁盘几何形状。但是,Vhost-SCSI驱动程序不需要实现此功能,因为它们使用通用的SCSI I / O来查询基础磁盘。

之后,驱动程序请求支持的最大队列数,并开始发送虚拟队列数据,该数据包括:

  • 唯一的虚拟ID
  • 最后处理的vring描述符的索引
  • vring地址(从用户地址空间)
  • 调用描述符(用于在I / O完成后中断驱动程序)
  • 踢描述符(侦听I / O请求-SPDK未使用)

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

I / O路径

主机通过在共享内存中分配适当的缓冲区,填充请求数据并将这些缓冲区的客户地址放入虚拟队列中来发送I / O。

Virtio-Block请求如下所示。

struct virtio_blk_req {
        uint32_t type; // READ, WRITE, FLUSH (read-only)
        uint64_t offset; // offset in the disk (read-only)
        struct iovec buffers[]; // scatter-gatter list (read/write)
        uint8_t status; // I/O completion status (write-only)
};

Virtio-SCSI请求如下。

struct virtio_scsi_req_cmd {
  struct virtio_scsi_cmd_req *req; // request data (read-only)
  struct iovec read_only_buffers[]; // scatter-gatter list for WRITE I/Os
  struct virtio_scsi_cmd_resp *resp; // response data (write-only)
  struct iovec write_only_buffers[]; // scatter-gatter list for READ I/Os
}

Virtqueue通常由一个描述符数组组成,每个I / O需要转换为此类描述符链。单个描述符可以是可读或可写的,因此每个I / O请求至少包含两个(请求+响应)。

struct virtq_desc {
        /* Address (guest-physical). */
        le64 addr;
        /* Length. */
        le32 len;
/* This marks a buffer as continuing via the next field. */
#define VIRTQ_DESC_F_NEXT   1
/* This marks a buffer as device write-only (otherwise device read-only). */
#define VIRTQ_DESC_F_WRITE     2
        /* The flags as indicated above. */
        le16 flags;
        /* Next field if flags & NEXT */
        le16 next;
};

传统的Virtio实现将名称vring与virtqueue一起使用,并且名称vring仍在代码内部的virtio数据结构中使用。而不是struct virtq_descstruct vring_desc更有可能被发现。

轮询此描述符链后的设备需要将其转换并转换回原始请求结构。它需要预先知道请求的布局,因此每个设备后端(Vhost-Block / SCSI)都有自己的实现来轮询虚拟队列。对于每个描述符,设备都会在Vhost用户存储区域表中执行查找,并执行gpa_to_vva转换(来宾物理地址到vhost虚拟地址)。SPDK强制将请求和响应数据包含在单个内存区域中。I / O缓冲区没有这样的限制,如果需要,SPDK可以自动执行附加的iovec拆分和gpa_to_vva转换。形成请求结构后,SPDK将此类I / O转发到基础驱动器,并轮询完成情况。I / O完成后,SPDK vhost用适当的数据填充响应缓冲区,并通过在调用描述符上执行eventfd_write以确保适当的虚拟队列来中断来宾。涉及多个中断合并功能,但本文档中不再讨论。

SPDK优化

由于SPDK vhost具有轮询模式,因此它消除了对I / O提交通知的要求,从而大大提高了vhost服务器的吞吐量,并减少了提交I / O的来宾开销。存在几种不同的解决方案来减轻I / O完成中断的开销(irqfd,vDPA),但是本文中将不讨论这些解决方案。为了获得最高性能,可以使用轮询模式的Virtio驱动程序,因为它抑制了所有I / O完成中断,从而使I / O路径完全绕过QEMU / KVM开销。

 


SPDK由驻留在lib其中的公共接口头文件组成的一组C库以及在NET中include/spdk基于这些库构建的一组应用程序组成app。用户可以在其软件中使用C库或部署完整的SPDK应用程序。

SPDK是围绕消息传递而不是锁定而设计的,并且大多数SPDK库都对其嵌入的应用程序的基础线程模型做出了一些假设。但是,SPDK竭尽全力以确保与实际使用的特定消息传递,事件,协同例程或轻量级线程框架无关。为此,所有SPDK库都与lib/thread(位于的公共接口include/spdk/thread.h)中的抽象库进行交互。任何框架都可以初始化线程抽象,并提供回调以实现SPDK库所需的功能。有关此抽象的更多信息,请参见消息传递和并发

对于大多数操作,SPDK构建在POSIX之上。为了简化向非POSIX环境的移植,所有POSIX标头都隔离到中include/spdk/stdinc.h。但是,SPDK需要POSIX不提供的许多操作,例如枚举系统上的PCI设备或分配对DMA安全的内存。所有这些附加操作都在称为env的公共头文件位于的库中抽象include/spdk/env.h。默认情况下,SPDK env使用基于DPDK的库来实现接口。但是,该实现可以换出。有关其他信息,请参见《SPDK移植指南》

应用领域

app顶级目录包含全面的应用,SPDK组件建造出来。有关完整概述,请参见SPDK应用程序概述

SPDK应用程序通常可以使用少量配置选项启动。然后使用JSON-RPC执行应用程序的完整配置。有关其他信息,请参见JSON-RPC方法

库函数

lib目录包含SPDK的真正核心。每个组件都是一个C库,在目录下有自己的目录lib。一些关键库是:

文献资料

doc顶级目录包含所有SPDK的文档。API文档是直接从代码中使用Doxygen创建的,但是更多常规文章和较长的说明位于该目录以及Doxygen配置文件中。

要构建文档,只需make在doc目录中键入。

例子

examples顶级目录包含一组旨在被用于参考的例子。这些不同于正在执行可以合理部署的“实际”任务的应用程序。这些示例要么经过精心设计以展示SPDK的某些方面,要么被认为不够完整,无法保证将它们标记为完整的SPDK应用程序。

这是学习SPDK如何工作的好地方。特别要注意examples/nvme/hello_world

头文件

include目录是所有头文件所在的位置。公共API都放置在的spdk子目录中include,我们强烈建议应用程序将其包含路径设置为顶级include目录,并通过spdk/像这样的前缀包含标头:

#include "spdk/nvme.h"

此处的大多数标题与lib目录中的库相对应。但是,有一些头文件是独立的。他们是:

还有一个spdk_internal目录,其中包含SPDK中的库广泛包含的头文件,但是这些头文件不是公共API的一部分,因此不会安装在用户的系统上。

脚本

scripts目录包含用于许多操作的便捷脚本。最重要的两个是check_format.sh,它们将使用astyle和pep8根据我们定义的约定检查C,C ++和Python编码样式,并setup.sh从内核驱动程序绑定和取消绑定设备。

测验

test目录包含对SPDK组件的所有测试,子目录反映了整个存储库的结构。测试是单元测试和功能测试的混合。

 

 

SPDK移植指南

https://spdk.io/doc/porting.html


SPDK通过实现移植到新的环境,ENV库接口。在ENV接口提供API驱动程序来分配物理上连续的和固定的内存,执行PCI操作(配置周期和映射条),虚拟到物理地址转换和管理存储器池。在ENV API被定义在包括/ spdk / env.h

SPDK包括基于Data Plane Development Kit(DPDK)的env库的默认实现。可以在中找到该DPDK实现。lib/env_dpdk

目前仅Linux和FreeBSD支持DPDK。想要在其他操作系统上或在DPDK以外的用户空间驱动程序框架中使用SPDK的用户将需要实现新版本的env库。通过更新CONFIG中的以下行,可以将新的实现集成到SPDK构建中:

CONFIG_ENV?=$(SPDK_ROOT_DIR)/lib/env_dpdk

 

用户指南


https://spdk.io/doc/user_guides.html

 

 

程序员指南


https://spdk.io/doc/prog_guides.html

 

 

 

 

 

 

©️2020 CSDN 皮肤主题: 酷酷鲨 设计师:CSDN官方博客 返回首页