RDMA CM以及非CM托管CQ创建QP

已经做了一段时间的RDMA相关开发了,陆续总结几篇相关文章。

RDMA是Remote Direct Memory Access的简称,目前主流的RDMA实现大致可以分为IB、RoCE、iWarp三类。

0x00 RDMA CM背景介绍

通过RDMA的verbs接口,我们可以创建不同类型的QP,如下:

  • RC: Reliable Connection,可靠连接,可以理解为基于msg的TCP实现,有点像SCTP
  • UC: Unreliable Connection,不可靠的连接,但和RC一样,需要握手
  • UD: Unreliable Datagram,不可靠数据报,类似UDP
  • RD: Reliable Datagram,顾名思义,应该还没有大量使用

由于UD目前仅支持RDMA send/recv语义,相较于write/read语义的延时稍高,因为需要对端CPU介入,合理开发,时延差距应该在1us内,至少在我所知的场景中应用不多,比较适合探测场景,比如rping,当然CM也是基于UD实现的。

RC和UC都是基于连接的,需要建联操作,该操作可以通过RDMA CM库完成,CM即Connection Manager的缩写。

如上文所述,CM是基于UD实现的,主要实现包含在ib cm/iw cm内核模块中,用户态的librdmacm只是一个agent,基于cm event进行驱动。

稍作总结,使用CM的优缺点如下:

  • 优点:

    • 避免重复性的工作
    • 经过生产环境验证的细节实现,如可靠传输、超时机制等
    • 生命周期比用户态长,可以较为妥善解决进程异常关闭场景的断连问题
  • 缺点:

    • 性能较差,单server cm约300~500连接/s
    • 进程结束时,需要断连,导致进程hang,处理速率约1500 qp/s
    • 不支持REUSEPORT
    • 主要功能在内核模块中实现,不利于二次开发和运维部署(毕竟大家都怕panic)

除常规的建连/断连类的操作外,CM还封装了一系列的verbs接口:

  • 创建/销毁memory region
  • 创建/销毁qp和cq
  • 获取CM事件

总体而言,CM是在尽可能模拟TCP和UDP的操作,大概是作者意识到了verbs接口的复杂性,但从实现细节上来考虑,和传统的Socket API依旧存在不小区别。也正因为这个原因,你才能在librdmacm库中找到rsocket API,但听我一句劝,千万别在生产环境使用rsocket。

0x01 非CM托管CQ创建QP

就我个人而言,我更倾向于仅使用CM进行建联及部分的断链操作。在本文的需求中,我们会自行创建CQ,因为这些CQ可能需要做跨QP共享,同时又需要使用rdma_create_qp()进行qp的创建。

先来看下rdma_create_qp()的函数声明:

int rdma_create_qp(struct rdma_cm_id *id, struct ibv_pd *pd, struct ibv_qp_init_attr
*qp_init_attr);
  • id是通过cm创建的,可以理解为它是内核cm的一个userspace cm agent,所以你可以在librdmacm的源码中找到很多以ucma_为前缀的函数名,因为ucma正是它的缩写。
  • pd可以是自行创建的,亦可使用cm内置的,当需要依赖后者时,传入NULL即可。
  • qp_init_attr即qp的初始化配置,按需填写即可。
int rdma_create_qp_ex(struct rdma_cm_id *id,
              struct ibv_qp_init_attr_ex *attr) {
    struct cma_id_private *id_priv;
    struct ibv_qp *qp;
    int ret;
    ...
    ret = ucma_create_cqs(id, attr->send_cq || id->send_cq ? 0 : attr->cap.max_send_wr,
                  attr->recv_cq || id->recv_cq ? 0 : attr->cap.max_recv_wr);
    if (ret)
        return ret;
        ...
    qp = ibv_create_qp_ex(id->verbs, attr);
    if (!qp) {
        ret = -1;
        goto err1;
    }

        ...

    id->pd = qp->pd;
    id->qp = qp;
    return 0;
err2:
    ibv_destroy_qp(qp);
err1:
    ucma_destroy_cqs(id);
    return ret;
}

根据上述函数实现来看,我们只要在id或者attr其中之一填写CQ指针,即可避免CM自行创建CQ。

然而实际测试下来并非如此,当遇到QP创建失败的场景时,例如ENOMEM,均会调用ucma_destroy_cqs()函数,如下代码所示,但凡我们在id字段中填写了CQ指针,都会触发CQ的销毁操作。

static void ucma_destroy_cqs(struct rdma_cm_id *id)
{
    if (id->qp_type == IBV_QPT_XRC_RECV && id->srq)
        return;

    if (id->recv_cq) {
        ibv_destroy_cq(id->recv_cq);
                ...
        id->recv_cq = NULL;
    }

    if (id->recv_cq_channel) {
        ibv_destroy_comp_channel(id->recv_cq_channel);
        ...
        id->recv_cq_channel = NULL;
    }
}

结合上述描述以及代码实现,CM创建CQ的行为就比较明显了:

  • 如果id和attr字段中均未填写CQ信息,此时CM负责创建CQ
  • 只要id填写了CQ信息,那么该CQ的销毁权就归CM所有

请注意,rdma_destroy_qp()的逻辑也是如此,因此为了满足本文的需求,我们需要保持id中的CQ和CC指针均为NULL,但需要在attr中准确填写相关信息,否则QP是无法被创建的。

0xFF 总结

坦白说,本文所描述的问题还是较难发现的,除非创建大量的QP,触发ibv_create_qp()报错,进而导致连锁问题,通常在测试环境较难发现,若是带着该问题上线,就是妥妥的一个故障。

那么,归根到底,CM的实现是否合理?就我个人而言,显然是不合理的,理由如下:

  1. manual中没有描述
  2. 既然CQ并非rdma_create_qp()函数创建,那么该函数调用失败后,也不应由其销毁
  3. CM中并没有单独的CQ销毁API,谁能想到rdma_create_qp()函数竟然会销毁CQ ???
说两句: