RDMA高级特性
MR
Memory Region (MR):用户通过ibv_reg_mr向对端暴露一块内存(iova+va+len+key),iova是对端来访问所用地址,va是本地访问所用地址,它们都需在硬件里翻译为PA,所以注册MR涉及建立MTT地址翻译表。同时也需为MR指定访问权限如可读可写,这涉及建立MPT表。MR所涵盖的物理内存需要在注册时PIN住以避免DMA访问swapped out的页。注册MR可以指定MR的访问权限(local/remote read/write)。MR注册好之后会返回LKEY和RKEY,LKEY用于自己访问自己,RKEY用于别人访问自己。一片内存区可以多次注册MR,每次可以设置不同的访问权限,每次都会返回不同的LKEY和RKEY。
Memory window (MW):MW允许用户更灵活的控制远端对本地内存的访问:动态的授予和收回远端对MR的访问权限,给不同的远端以不同的访问权限,为MR内的不同range的小块内存授予不同的访问权限。注册MR时需要同时建立表和安全保护表,但注册MW仅需建立安全保护表,所以建立MW可以直接在用户态与硬件通信完成而不需要经过内核。 ibv_bind_mw用于建立MW,它其实也是向QP里post了一个请求。
点击(此处)折叠或打开
-
mr = ibv_reg_mr(pd, addr, len, access);
-
mw = ibv_alloc_mw(pd, IBV_MW_TYPE_1);
-
-
struct ibv_mw_bind bind = {.wr_id = xxx, .send_flags = xxx,
-
.bind_info = {.mr = mr, .addr = x, .length = y, .mw_access_flags = READ/WRITE}, };
- ibv_bind_mw(qp, mw, bind);
Zero based MR:MR注册后正常情况下对端是拿本端的VA地址来访问,但这容易泄露某些信息或者VA在进程重启后会变化。此时可调用ibv_reg_mr创建一个zero based MR (access flags |= IBV_ACCESS_ZERO_BASED),这样对端拿来访问的地址会在本端HCA被当成是MR的偏移,而不是一个具体的VA。
MR with IOVA:有的时候用户希望指定对端用来访问本地MR的地址,此即IOVA。 ibv_reg_mr_iova(pd, addr, length, iova, access) 在注册MR时同时指定IOVA,当对端用地址X来访问MR时,它真正访问的本地进程VA是 (X - iova + addr)。
User-mode memory registration (UMR)
l
将多块非连续的MR拼接成一个VA连续的MR如上图所示,我们之前创建了3个常规得MR:MR1(green), MR2(purple), MR3(red),现在我们想从这三个MR中各抽取一部分拼接起来形成一个新的连续的MR:{BANNED}中国第一块是MR1(v0-v1)部分,第二块是MR2(v2-v3)部分,第三块是MR3(v4-v5)部分。这个新的MR有一个新的base VA地址,长度是3个小块的长度之和。这样虽然内部是不连续的,但在外部访问者看来这个MR是连续的。
l
将一个MR内有规律非连续的块拼接成一个连续的MR如上图所示,当我们做一个矩阵的转置时需要把一列的元素拼成新的行,这个行就成了新的连续的MR。老矩阵的列元素一般可以用<基地址(base address), 元素间距(stride),元素长度(block size),元素数量(repeat count)>来描述。
l
将多个MR拼接成新的相互交织的连续MR如上图所示,2个老矩阵的列相互交织形成新的列,这是一个新的VA连续的MR,有它自己的新的base address和length。
点击(此处)折叠或打开
Address translation: memory translation table(MTT)
1. 使用mkey.index索引mkey context table得到mkey context。
2. Key检查:对比mkey.key和mkey context里的key是否一致。
3. PD检查:对比发起内存访问的QP里的PD和mkey context里的PD是否一致。
4. 地址范围检查:mkey context对应了一个MR,里面包含address和length两个字段用于指明MR的范围,这里需要检查当前正在进行的内存访问是否在这个MR范围内。
5. 访问权限检查:根据mkey context内的local/remote read/write/atomic权限检查当前操作是否合法。
6. 当上面的检查通过时,我们就可以使用mkey context里的mtt handle了,它是MTT表的基地址。
7. 访问MTT表需要一个索引MTT index=当前访问地址accessing VA - mkey_context.va得到,其中mkey_context.va是MR的起始地址。另外需要考虑PA所指向的page大小,当page为4K时,上面的MTT index还要右移12位。
8. 用上面的index索引MTT表就能得到一个MTT entry,它里面包含一个PA地址,即page number页号。如果page为4KB则{BANNED}最佳终的PA = (page number << 12) + (accessing VA & 0xfff)。
None-pin RDMA
PINNED RDMA的问题
1,注册MR时PIN住物理内存这使得可注册的内存空间受限于物理内存大小。
2,应用程序必须有锁住内存的权限。
3,持续的在软硬件之间同步地址翻译表是一个很耗时的操作。这是因为malloc/mmap/stack都会导致页表变化。
4,注册本身需要在HCA建立地址映射表,而get_user_pages以及填写页表都是耗时操作,这导致注册MR很慢。
计算内存区和通信内存区
很多程序并不知道未来的数据会放在哪个VA范围,也不知道未来需要使用的内存会有多大,这就导致它不能提前注册MR,只能在快要用RDMA发送数据时才注册MR,而注册MR很慢又会阻塞计算。应用也不能将整个进程的虚拟地址空间注册为MR,因为没有这么多的物理内存可以PIN住。这种矛盾就产生了计算内存区和通信内存区的区分:计算内存区是程序运行时动态扩张的,其大小和范围无法提前预测,而通信内存区是提前注册好的MR,其物理内存是PIN住的。应用使用RDMA发送数据时要先将数据从计算内存区copy至通信内存区。这是一个很大的开销。
方案一:on-demand paging (ODP)
注册MR时指定IBV_ACCESS_ON_DEMAND标识则创建ODP MR,其初始地址翻译表里VA对应的物理页并不存在,因此设备首次访问MR VA时会产生IO page fault(IOPF),HCA驱动处理此IOPF并换入所需物理页更新HCA里的地址翻译表,则下次设备DMA时不再发生IOPF。若操作系统决定swap out VA对应的物理页,也会由HCA驱动更新地址翻译表将VA对应entry置为page none-present。
方案二:shared virtual address (SVA)
SVA的思想是让设备完全共享进程的地址空间,让IOMMU和MMU共享同一张页表,这样进程malloc出来的内存不用做任何注册就能直接被设备所访问。它的好处是IO page fault的处理可以复用大部分CPU page fault的处理逻辑,同时由于共享页表也节约了IOMMU另建页表所需的额外内存。因为进程地址空间完全对设备可见,所以也消除了通信内存区和计算内存区的差异。
因为共享页表后进程地址空间完全对设备可见,此时若要做安全保护则需要在设备内部设立MPT表,因为这一步只涉及安全保护不涉及建立翻译页表,所以完全可以在用户态和硬件之间完成,无需内核介入,这类似memory window。另一个问题是共享页表后,IOMMU需要保持与CPU的页表兼容性,无法单独设计页表格式以优化性能。CPU一般是随机访问,而设备的访问多集中在某一块区域,大多数情况下设备页表访问不需要经历4级页目录,而是一次DDR访问搞定翻译。这是SVA带来的缺陷。
点击(此处)折叠或打开
-
iommu_dev_enable_feature(dev, IOMMU_DEV_FEAT_IOPF);
-
iommu_dev_enable_feature(dev, IOMMU_DEV_FEAT_SVA);
-
sva = iommu_sva_bind_device(dev, current->mm, NULL);
- pasid = iommu_sva_get_pasid(sva); /*经描述符传递给设备*/
共同的挑战:IO page fault + TLB invalidation
Inline data
Normal flow
1.填充要发送的数据
2.填充WQE描述符
3.敲doorbell通知硬件有数据要发送
4.硬件通过DMA读取WQE
5.硬件通过DMA读取要发送的数据
Direct WQE
更详细的说Direct WQE携带了相应的pi指针,同时还包含了当前WQE的内容,非Direct WQE只有pi指针,需要设备主动发起一次WQE的PCIE读操作,Direct WQE方式减少了一次PCIE读操作。(非Direct WQE时,WQE是在host ddr中,需要设备DMA读一次,而Direct WQE是CPU直接将WQE写到硬件MMIO空间上)
Inline-Send
ibv_post_send时带上IBV_SEND_INLINE标识,如果要发送的数据小于128字节则填WQE时会将这部分数据直接append在WQE的后面。这样硬件DMA WQE时就顺便将data也读出来了,这样就省去了单独DMA data的操作。
Inline-Receive
与发送类似,如果接收的是一个小数据,则没有必要将其放入RQ的receive buffer中,而是可以直接将其放入CQE中。这可以省去硬件将数据DMA至RQ SGE list的过程。使用ibv_exp_create_qp创建QP时指定max_inl_recv即可开启此功能。
Hardware doorbell VS software doorbell
Doorbell 的用途
Doorbell的目的是为了让软件告诉硬件queue里有新的WQE需要处理。硬件收到doorbell并不是立即处理,而是将doorbell转换为一个doorbell message暂存下来,等到调度时机到来再处理。硬件处理WQE是以batch方式进行的,一般会一次DMA读取8个WQEBB(每个64字节)。
Push: hardware doorbell
软件往SQ里添加了新的WQE,硬件并不知道,所以需要一个机制让软件通知硬件queue里有新的WQE需要处理,这就是hardware doorbell。Hardware doorbell本身是设备的一个MMIO寄存器,一旦写这个地址硬件就能感知。写入的值包含QPN和producer index(PI)。Doorbell是以page为单位分配的,每个page内可以包含多个doorbell。需要注意,由于doorbell page以页为单位分配,硬件并不关心doorbell地址的低12位,因此这里的低12位可以用于携带额外的信息,比如queue type(SQ/EQ/CQ等)。
在verbs编程中,doorbell 是随着context的创建而分配的,context内再创建QP/CQ时都会在这个doorbell page内分配具体的doorbell并关联至这个QP。当一个context关联的QP太多时可以再分配一个doorbell page。一个context支持多个doorbell可以有效地支持多个线程使用不同的QP并敲不同的doorbell,以避免并发性问题。
由于ring doorbell是一个异步过程,软件可能在返回后继续写入WQE继续敲doorbell,而此时硬件可能还没有开始处理WQE,所以会产生针对同一个doorbell的多次写入。所以硬件拿到一个doorbell message时并不表示它是{BANNED}最佳新的PI。另外,用户敲doorbell太频繁时也会导致doorbell message丢失。
Pull: software doorbell
Hardware doorbell只能告诉硬件有WQE要处理,但却不能告诉硬件{BANNED}最佳新的PI,因为软件会多次敲同一个doorbell而硬件并不会立即处理这些doorbell message,另外硬件以batch的方式读取WQE,可能会读到owner还不是硬件的WQE,所以硬件需要一个方式知道{BANNED}最佳新的PI值,这就是software doorbell的作用。Software doorbell是一个8字节的内存缓冲区,它的物理地址会被写入QPC以告诉硬件。软件每次更新完WQE都需要将{BANNED}最佳新的PI值写入software doorbell,由于是override所以硬件读这块内存总能得到{BANNED}最佳新的PI值,从而判断DMA读取到的WQE哪些是有效的。
Stride send/receive
Stride send
Stride receive
Stride receive是与stride send对称的操作,用于将本地一段连续的数据发送到对端,且在对端以有规律非连续的形式存放。同样在接收端也用base address, block size, stride size, repeat count四个参数描述存放位置。
Streaming RDMA
Current problem: 传统的QP receive WQE类似UDP,每个接收到的消息都会消耗一个receive buffer,这里有2个问题:如果进来的消息比receive buffer大则无法接收只能被丢弃,而如果它比receive buffer小又会造成接收缓冲区的浪费。由于我们无法预测进来的消息的大小,所以只能按{BANNED}最佳大尺寸分配接收缓冲区。传统的QP receive WQE会有如下2方面的问题:
1. receive buffer的利用率很低:即使大部分消息很小而只有个别大消息,我们也要按{BANNED}最佳大尺寸分配接收缓冲区,这就造成接收缓冲区的有效利用率很低。极端的说,如果我们按64KB分配接收缓冲区,而大部分消息是64B的,则内存利用率只有0.1%。这样的后果是小包突发时由于内存不足而导致丢包。
2. 频繁读取receive WQE会成为性能瓶颈:每收到一个消息就需要从host memory取一个receive WQE, 一个WQE是64B,而如果消息也是64B,那么相当于有效内存读写只有50%,这在很多场景下是性能瓶颈。
Streaming receive是说每个进来的消息只消费它自身的大小而不是整个receive WQE,接收缓冲区剩下的部分可以接收下一个消息,因此一个receive WQE可以接收多个消息只要它能容纳。这就解决了接收缓冲区利用率低和频繁读取receive WQE的问题。
Streaming receive时每个消息还是会上报一个CQE,所以CQE中要指明消息放在WQE receive buffer的起始和终止位置。一个消息只能放在一个WQE receive buffer中而不能跨WQE。注意这样做了之后,CQE的数量将要大于RQ中WQE的数量,所以创建CQ时需要加大深度。另外的一个策略是使receive buffer size更大而让RQ深度变小。
Tag Matching and Rendezvous Offload
Tag matching offload
MPI send操作除了指定通信组communicator和通信对象dest之外,还会指定一个tag表示用作某个特定用途的数据。而MPI receive也会指定(source, tag, communicator),这称之为一个envelope信封,表示消息的接收者信息。对于常规的网卡来说它只会接收报文放在一个通用的缓冲区里,而解析envelope的动作需要CPU进行,解析出tag之后再将数据搬运至tag关联的真正的缓冲区,这就引入了一次额外的copy,并且因为它是靠CPU进行的,所以非常耗CPU资源。
这里的矛盾在于常规网卡不touchPayload里的用户数据,所以理解不了envelope,这正是tag matching offload需要做的事:软件将tag matching list告诉网卡,网卡从接收报文的固定位置处读取envelope解析出tag,然后跟软件下发的tag matching list逐一比较,若命中则将接收的报文写入tag指向的缓冲区,并通过CQ告知软件。这样软件收到通知时数据已被放置在{BANNED}最佳终目的地,而无需CPU再次搬运。
点击(此处)折叠或打开
-
struct ibv_srq *srq = ibv_create_srq_ex(context, &attr);//attr.srq_type = IBV_SRQT_TM;
-
//create the RC/DC QP, set qp_init_attr->srq to point to srq
-
struct ibv_qp *qp = ibv_create_qp(pd, &qp_init_attr);
-
ibv_post_srq_recv(srq, &wr, &bad_wr); //for the unexpected buffers
-
// Create ops_WRs *wr in struct ibv_ops_wr with information for the tm_data structure
-
ibv_post_srq_ops(srq, wr, bad_wr); // Use opcode IBV_WR_TM_ADD
- ibv_poll_cq_ex(srq->cq, 1, wc);
Rendezvous offload
发送端发一个小消息给接收端时直接将数据放在报文里带过去,这称之为eager protocol。但若是一个大消息则可能因为接收侧没有这么大的接收缓冲区而失败,所以另一种做法是先发送一个小的控制消息rndv给接收侧告诉它真正消息的地址和长度以及key,接收侧会在接收缓冲区ready后反向来读数据。 Rendezvous protocol可以用软件实现也可以由硬件实现,但硬件实现可以让Rendezvous send变成类似read/write的单边操作,节省CPU资源。
点击(此处)折叠或打开
-
qp = ibv_create_qp(pd, &qp_init_attr);
-
// create ibv_tm_info *tm with header information (TM, RNDV, DC)
-
int size = ibv_pack_tm_info(buf, tm);
-
// merge buf with the payload – create work requests *wr
-
ibv_post_send(qp, wr, bad_wr);
-
// If the protocol is RENDEZVOUS, wait for the final (fin) message.
- ibv_poll_cq_ex(send_cq, 1, wc);
SRQ
为什么需要Shared Receive Queue (SRQ)
但是正如我们前文所说,{BANNED}中国第一种方法由于是为{BANNED}最佳坏情况准备的,大部分时候有大量的RQ WQE处于空闲状态未被使用,这对内存是一种极大地浪费;第二种方法虽然不用下发那么多RQ WQE了,但是流控是有代价的,即会增加通信时延。
而SRQ通过允许很多QP共享接收WQE(以及用于存放数据的内存空间)来解决了上面的问题。当任何一个QP收到消息后,硬件会从SRQ中取出一个WQE,根据其内容存放接收到的数据,然后硬件通过Completion Queue来返回接收任务的完成信息给对应的上层用户。
SRQ使用流程
点击(此处)折叠或打开
-
attr = {.attr = {.max_wr = rx_depth, .max_sge = 1}};
-
srq = ibv_create_srq(pd, &attr);
-
srq_attr = {.max_wr=1024, .max_sge=8, .srq_limit=800};
-
ibv_modify_srq(srq, &srq_attr, isrq_attr_mask);
-
-
for (i = 0; i < num_qp; ++i) {
-
init_attr = { .send_cq = cq, .recv_cq = cq, .srq = srq,
-
.cap = {.max_send_wr = 1, .max_send_sge = 1},
-
.qp_type = IBV_QPT_RC };
-
qp[i] = ibv_create_qp(ctx->pd, &init_attr);
-
}
-
-
sge_list = {.addr = buf, .length = size, .lkey = lkey};
-
wr = {.wr_id = PINGPONG_RECV_WRID, .sg_list = &list, .num_sge = 1};
- ibv_post_srq_recv(srq, &wr, &bad_wr);
other processing is the same with normal QP
SRQ Limit
SRQ可以设置一个水线/阈值,当队列中剩余的WQE数量小于水线时,这个SRQ会就上报一个异步事件。提醒用户“队列中的WQE快用完了,请下发更多WQE以防没有地方接收新的数据”。这个水线/阈值就被称为SRQ Limit,这个上报的事件就被称为SRQ Limit Reached。
XRC
为什么需要The Extended Reliable Connected Transport Service (XRC)
当前的计算节点一般都有多核,因此可以运行多进程。在这样的计算节点组成的集群中,如果想用RC连接建立full mesh的全连接拓扑时,每个节点就需要建立N*p*p个QP(这里假设集群有N个节点,每个节点上有p个进程,需要让任何2个进程都连通)。当集群扩张,N和p同时增长时,一个节点所需的RC QP资源将变得不可接受。
XRC的思想是当一个进程想与某个远程节点的p个进程通信时不需要跟各个进程建立p个连接而只需要跟对端节点建立一个连接,连接上传输的报文携带了对端目的进程号(XRC SRQ),报文到达连接对端(XRC TGT QP)时根据进程号分发至各个进程对应的XRC SRQ。这样源端进程只需要创建一个源端连接(XRC INI QP)就能跟对端所有进程通信了,这样所需总的QP数量就会除以p。
核心概念
XRC INI QP:XRC发起端QP,是XRC操作的源端队列,用于发出XRC操作,但它没有接收XRC操作的功能,对比常规RC QP来说可以认为它是只有SQ没有RQ。XRC操作在对端由XRC TGT QP处理。
XRC TGT QP:XRC接收端QP,它处理XRC操作将其分发至报文SRQ number对应的SRQ。XRC TGT QP只能接收XRC操作,但它没有发出XRC操作的功能,对比常规RC QP来说可以认为它是只有RQ没有SQ。XRC操作在对端由XRC INI QP发出。
XRC SRQ:接收缓冲区(receive WQE)被放在XRC SRQ中以接收XRC请求,XRC请求中携带了XRC SRQ number,所以XRC TGT QP收到报文后会从报文指定的XRC SRQ中取receive WQE来存放XRC请求。
XRC domain:用于关联XRC TGT QP和XRC SRQ,XRC报文只能指定与XRC TGT QP在同一domain内的XRC SRQ,否则报文会被丢弃。这起到了隔离资源的作用,防止攻击报文随意指定XRC SRQ。
XRC INI QP和XRC TGT QP是一一对应的,host2上的每个进程在远端节点host0上都有自己对应的XRC TGT QP。XRC的共享体现在一个XRC TGT QP可以分发至多个XRC SRQ。一个进程一般只有一个XRC SRQ,它可以接收多个XRC TGT QP来的包。
XRC使用流程
点击(此处)折叠或打开
-
fd = open("/tmp/xrc_domain", RDONLY | CREAT);
-
xrcd_attr.fd = fd;
-
xrcd = ibv_open_xrcd(ctx, &xrcd_attr);
-
-
cq = ibv_create_cq(ctx, rx_depth, NULL, NULL, 0);
-
attr = {.srq_type = IBV_SRQT_XRC, .xrcd = xrcd, .cq = cq, .pd = pd};
-
srq = ibv_create_srq_ex(ctx, &attr);
-
-
init = {.qp_type = IBV_QPT_XRC_RECV, .comp_mask = ATTR_XRCD, .xrcd = xrcd};
-
recv_qp = ibv_create_qp_ex(ctx, &init);
-
ibv_modify_qp(recv_qp, IBV_QP_STATE|IBV_QP_ACCESS_FLAGS);
-
-
init = {.qp_type = IBV_QPT_XRC_SEND, .send_cq = cq, .pd = pd};
-
send_qp = ibv_create_qp_ex(ctx, &init);
-
ibv_modify_qp(recv_qp, IBV_QP_STATE|IBV_QP_ACCESS_FLAGS);
-
-
ibv_post_srq_recv(ctx.srq, &wr, &bad_wr);
- ibv_post_send(send_qp, wr = {sge, IBV_WR_SEND, srqn}, &bad_wr);
DCT
为什么需要Dynamically Connected Transport (DCT)
UD虽然扩展性很好,但是不支持read/write单边语义。RC虽然支持read/write单边语义,但是扩展性不好。DCT的初衷就是融合2者的优点,保持RC的read/write单边语义和可靠连接特性,同时像UD一样用一个QP去跟多个远端通信,保持良好的可扩展性。DCT一般用于稀疏数据场景。
什么是DCT
DCT具有非对称的API:DC在发送侧的部分称为DC initiator(DCI),在接收侧的部分称为DC target(DCT)。DCI和DCT不过是特殊类型的QP,它们依然遵循基本的QP操作,比如post send/receive。
DC意味着临时连接,在DCI上发送的每个send-WR都携带了目的地址信息,如果DCI当前连接的对端不是send-WR里携带的对端,则它会首先断开当前的连接,再连接到send-WR里携带的对端。只要后续的send-WR里携带的都是当前已连接对端,则都可以复用当前已建立的连接。如果DCI在一段指定的时间内都没有发送操作则也会断开当前连接。注意DCT每次临时建立的是一个RC可靠连接。
DCT的池化:每个DCT有一个responders(DCRs)池,新进的DC连接会在这个池里分配一个DCR。当池资源不足时DCT会向发起新建连接的DCI回复connection NAK(CNAK),同时丢弃来自这个DCI的后续报文。
DCI的池化:当我们需要跟多个对端通信时,为避免一个DCI频繁建立/断开连接从而影响性能,一般需要建立一个
半握手:{BANNED}中国第一个建链报文之后不等ACK就发数据报文,能有效减少小包时延。全握手:类似TCP三次握手,能减少潜在的竞争条件。
create QP
点击(此处)折叠或打开
-
struct mlx5dv_qp_init_attr dv_init_attr = {0};
-
struct ibv_qp_init_attr_ex init_attr = {0};
-
-
init_attr.qp_type = IBV_QPT_DRIVER;
-
init_attr.send_cq = send_cq;
-
init_attr.recv_cq = recv_cq;
-
init_attr.pd = pd;
-
-
if (initiator) {/** DCI **/
-
init_attr.comp_mask |= IBV_QP_INIT_ATTR_SEND_OPS_FLAGS | IBV_QP_INIT_ATTR_PD;
-
init_attr.send_ops_flags |= IBV_QP_EX_WITH_SEND;
-
-
dv_init_attr.comp_mask |= MLX5DV_QP_INIT_ATTR_MASK_DC |
-
MLX5DV_QP_INIT_ATTR_MASK_QP_CREATE_FLAGS;
-
dv_init_attr.create_flags |= MLX5DV_QP_CREATE_DISABLE_SCATTER_TO_CQE;
-
dv_init_attr.dc_init_attr.dc_type = MLX5DV_DCTYPE_DCI;
-
} else {/** DCT **/
-
init_attr.comp_mask |= IBV_QP_INIT_ATTR_PD;
-
init_attr.srq = srq;
-
dv_init_attr.comp_mask = MLX5DV_QP_INIT_ATTR_MASK_DC;
-
dv_init_attr.dc_init_attr.dc_type = MLX5DV_DCTYPE_DCT;
-
dv_init_attr.dc_init_attr.dct_access_key = DC_KEY;
-
}
-
- qp = mlx5dv_create_qp(context, &init_attr, &dv_init_attr);
12.4 DCI post send
点击(此处)折叠或打开