十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RDMA用户态与内核态交互:QP创建与内存注册解析

RDMA用户态与内核态交互:QP创建与内存注册解析 1. 为什么RDMA要把用户态和内核态拆开RDMA这名字说出来容易但真正理解它的人往往都卡在同一个问题上既然RDMA号称“内核旁路”为什么创建QP的时候还要往内核里走一趟网上聊RDMA的文章太多绝大多数都在讲API怎么用——今天建个QP明天发个WR后天轮询一下CQ好像整个RDMA就是用户态那点事。“学习只是用户态”这句话我见过不少人当玩笑说但其实这句话只说对了一半。真正要驾驭RDMA尤其是遇到性能上不去、QP建不起来、内存注册报错的时候不懂用户态和内核态的交互边界你会连排查方向都找不到。这篇文章我会把RDMA里用户态和内核态的分工、交互链路、关键节点全部拆开讲一遍。适合的人有两类一类是刚把libibverbs跑通、想往深里走一步的开发者另一类是已经在用RDMA做存储或者高性能计算、但被各种uverbs报错和性能抖动折磨过的运维和内核爱好者。读完你至少能回答三个问题QP到底是什么、创建QP时内核做了什么、为什么内存注册绕不开内核。1.1 传统网络栈的瓶颈和RDMA的破局思路先说明一个基本事实RDMA的本质不是“快”而是“省”。传统TCP/IP路径下一次数据发送要经过用户缓冲区拷贝到内核socket缓冲区再交给协议栈处理、切到中断上下文、网卡DMA发送接收方向更惨数据先到内核缓冲区再拷贝到用户态。两次拷贝加上多次上下文切换CPU的中段占用是巨大的。RDMA的思路是把数据搬运这件事彻底交给网卡硬件。发送方从用户态直接把数据描述符WQEWork Queue Element写进网卡能访问的队列内存然后戳一下门铃Doorbell网卡自己就去内存里把数据DMA出来发走接收方向网卡直接把数据DMA落到用户态预先注册好的缓冲区里全程不经过内核CPU只在最后轮询完成队列CQ时才知道数据到了。但注意我说的“全程不经过内核”指的是数据面Fast Path。控制面Slow Path里QP的创建、内存注册、地址交换、连接建立这些资源准备工作必须有内核参与。原因很简单网卡是硬件硬件要访问物理内存而用户进程看到的是虚拟地址谁把虚拟地址翻译成物理地址谁保证这些物理页在DMA期间不被换出谁负责下发创建队列的命令给网卡驱动只能是内核。1.2 QP是什么RDMA世界里最核心的对象说“rdma qp是什么”一句话解释QP就是一对硬件队列的抽象一个发送队列Send Queue加一个接收队列Receive Queue合起来叫Queue Pair。你可以把QP类比成网络编程里的socket只不过这个socket的收发队列不在内核里而在网卡和用户态共享的内存里。QP由QP Number唯一标识同一个HCAHost Channel Adapter下每个QP号是唯一的。两个节点通信时需要把对端的QP号、LIDInfiniBand或者GIDRoCE交换过来才能让网卡硬件知道把数据包送到哪个队列。QP有状态机RESET复位→ INIT初始化→ RTRReady to Receive可接收→ RTSReady to Send可发送每一步都由用户态通过Verbs API触发但每一步的落地都由内核执行。后面我会展开细讲这个状态机迁移过程中内核做了哪些事。2. RDMA用户态与内核态的交互路径全拆解2.1 从libibverbs到内核uverbs这条唯一的通道用户态程序使用Verbs APIibv_xxx系列函数这些函数由libibverbs库实现。libibverbs不是一个纯用户态库它的底层会打开一个字符设备——/dev/infiniband/uverbs0然后通过ioctl老版本是write/read新版统一走ioctl向内核提交命令。内核侧对应的模块叫ib_uverbs它是RDMA用户态与内核态的中枢。ib_uverbs收到用户态的命令后会解析命令字然后转调ib_core核心层最后落到具体的设备驱动比如mlx5_ib、irdma。这条链路是单向的用户态→uverbs设备→ib_uverbs→ib_core→驱动。你可以在调试时用strace看这个交互过程。比如执行一个最简单的ibv_devinfo命令strace里能看到类似这样的系统调用strace -e traceioctl ibv_devinfo -d mlx5_0结果里会出现大量针对/dev/infiniband/uverbs0的ioctl调用每个ioctl都对应一个Verbs命令获取设备属性、查询端口、分配PD等。这里有一个容易忽略的观点绝大多数RDMA学习材料只讲到了libibverbs的API层也就是“用户态”这一半。你照着示例代码能跑通但一旦环境变了、驱动版本变了、内核版本变了出问题的时候你只能干瞪眼因为在用户态根本看不到底层发生了什么。理解ib_uverbs这条通道是排查所有RDMA控制面问题的基础。2.2 创建QP时内核到底干了什么以最常见的ibv_create_qp为例整个流程拆开是这样的用户态填写ibv_qp_init_attr结构体指定发送队列深度、接收队列深度、QP类型RC/UC/UD、CQ、SRQ等参数。libibverbs把参数组装成uverbs命令通过ioctl发给内核。ib_uverbs校验参数合法性比如队列深度是不是超过了设备能力上限max_qp_wr、max_sge。调用ib_create_qp进入ib_core再调用驱动的create_qp回调。驱动为QP分配硬件资源——发送队列和接收队列的环形缓冲区Ring Buffer这些缓冲区有的放在内核内存里但为了支持用户态直接写WQE驱动会把发送队列和接收队列的内存通过mmap映射到用户态地址空间。内核把QP Number、门铃页偏移、各个队列的user handle等通过ioctl返回值送回到用户态。用户态随后调用ibv_modify_qp把QP从RESET状态依次迁移到INIT、RTR、RTS。注意第5步这正是RDMA数据面可以绕开内核的关键设计队列的内存被映射到用户态后用户程序发数据时直接往这段内存里塞WQE不需要也不应该再调用任何系统调用。内核只在创建和销毁QP时参与数据收发全程在用户态和硬件之间直接完成。我给一个直观类比内核像个物业公司帮你办理门禁卡、装修好房间映射内存、登记户主信息QP号。办完这些之后你进出房间、搬东西收发数据再也不需要物业在场。物业只在两种情况下再介入你把房子卖给别人销毁QP或者房间的硬件设备出故障了异步事件。2.3 内存注册为什么必须经过内核RDMA要求两端在通信前先把缓冲区“钉”在物理内存里这个操作叫内存注册Memory RegistrationAPI对应ibv_reg_mr。它返回一个lkey/rkey网卡硬件访问这段内存时靠这个key做权限校验。为什么内存注册不能完全放在用户态三个原因第一网卡DMA需要物理地址。用户态程序看到的是虚拟地址只有内核才持有页表能把虚拟地址翻译成物理地址。用户态不可能自己去查页表——查页表本身就是一个特权操作。第二DMA期间物理页不允许被换出。用户态内存可能被换到swap如果DMA正在进行而页面被换走了硬件写入的就是一块已经不属于你的磁盘空间轻则数据错误重则系统崩溃。内核在注册时需要调用get_user_pages把这些页面的引用计数加上去pin住DMA完成后在注销时再释放。第三安全校验和资源管理。内核需要确认用户指的这段地址范围是合法的、可访问的同时受RLIMIT_MEMLOCK限制防止用户无限注册内存把系统物理内存耗尽。在驱动内部pin住的页面会被组织成一个ib_umem结构体并且为了硬件能直接读取还会把这些页面DMA映射成物理地址列表scatter-gather list写入硬件页表。这个过程对用户完全透明但如果你的ulimit -l被设置成了64KB这种很小的值注册几百兆缓冲区时就会报错。3. 核心链路实操用代码走一遍完整的交互流程3.1 环境准备与检查工具动手之前先确认你的环境具备RDMA条件。我以最常见的RoCEv2环境为例# 查看是否有RDMA设备 ibv_devices # 查看设备详细能力 ibv_devinfo -v # 检查内核RDMA子系统 rdma link show # 确认uverbs内核模块已加载 lsmod | grep ib_uverbs如果ibv_devices什么都看不到先检查驱动和固件。如果是容器环境还要确认设备cgroup规则允许访问/dev/infiniband/*并且容器有CAP_SYS_ADMIN或相应的设备权限否则打开uverbs设备会报Operation not permitted。开发库的安装比较简单Debian/Ubuntu用libibverbs-dev、librdmacm-devCentOS/RHEL用libibverbs-devel、librdmacm-devel。3.2 注册内存、创建QP、连接握手下面这段代码是RDMA用户态程序最核心的骨架打开设备、分配PD、注册内存、创建QP。我用最精简的方式写出来重点标注每一步的交互含义。#include infiniband/verbs.h #include stdio.h #include stdlib.h #include string.h #define BUFFER_SIZE 4096 int main(int argc, char *argv[]) { struct ibv_device **dev_list; struct ibv_context *ctx; struct ibv_pd *pd; struct ibv_mr *mr; struct ibv_cq *cq; struct ibv_qp *qp; struct ibv_qp_init_attr qp_init_attr; struct ibv_qp_attr qp_attr; char *buf; // 1. 获取设备列表 dev_list ibv_get_device_list(NULL); if (!dev_list || !dev_list[0]) { fprintf(stderr, No RDMA devices found\n); return -1; } // 2. 打开设备这一步内部会打开 /dev/infiniband/uverbs0 并初始化上下文 ctx ibv_open_device(dev_list[0]); if (!ctx) { fprintf(stderr, Failed to open device\n); return -1; } // 3. 分配保护域PD把后续所有资源归属到这个PD下 pd ibv_alloc_pd(ctx); if (!pd) { fprintf(stderr, Failed to allocate PD\n); return -1; } // 4. 分配用户缓冲区并注册内存 buf malloc(BUFFER_SIZE); memset(buf, 0, BUFFER_SIZE); mr ibv_reg_mr(pd, buf, BUFFER_SIZE, IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_READ | IBV_ACCESS_REMOTE_WRITE); if (!mr) { fprintf(stderr, Failed to register memory (check ulimit -l)\n); return -1; } printf(MR lkey0x%x rkey0x%x\n, mr-lkey, mr-rkey); // 5. 创建CQ cq ibv_create_cq(ctx, 16, NULL, NULL, 0); if (!cq) { fprintf(stderr, Failed to create CQ\n); return -1; } // 6. 创建QP memset(qp_init_attr, 0, sizeof(qp_init_attr)); qp_init_attr.send_cq cq; qp_init_attr.recv_cq cq; qp_init_attr.qp_type IBV_QPT_RC; // 可靠连接 qp_init_attr.cap.max_send_wr 16; // 发送队列深度 qp_init_attr.cap.max_recv_wr 16; // 接收队列深度 qp_init_attr.cap.max_send_sge 1; qp_init_attr.cap.max_recv_sge 1; qp ibv_create_qp(pd, qp_init_attr); if (!qp) { fprintf(stderr, Failed to create QP\n); return -1; } printf(QP number 0x%x\n, qp-qp_num); // 7. QP状态机迁移RESET - INIT memset(qp_attr, 0, sizeof(qp_attr)); qp_attr.qp_state IBV_QPS_INIT; qp_attr.pkey_index 0; qp_attr.port_num 1; qp_attr.qp_access_flags IBV_ACCESS_REMOTE_READ | IBV_ACCESS_REMOTE_WRITE; ibv_modify_qp(qp, qp_attr, IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS); // 8. 这里需要和对端交换 QP号 GID rkey然后继续迁移到 RTR 和 RTS // 对端信息就位后填充 qp_attr并调用 ibv_modify_qp 完成剩余迁移 // 代码省略后面讲实际对接时的要点 // 清理 ibv_destroy_qp(qp); ibv_destroy_cq(cq); ibv_dereg_mr(mr); ibv_dealloc_pd(pd); ibv_close_device(ctx); ibv_free_device_list(dev_list); free(buf); return 0; }注意第4步ibv_reg_mr这一步会触发内核把整个缓冲区pin住。缓冲区越大pin页面的时间越长实测在注册几GB缓冲区时可以观察到明显的毫秒级甚至更高的延迟这是正常的。第6步创建QP时max_send_wr和max_recv_wr如果超过设备能力比如ibv_devinfo里显示max_qp_wr0x6000内核会直接拒绝返回Cannot allocate memory这一点很坑后面讲排查时会再提。第7步和第8步的QP状态迁移是用户态和内核态交互最频繁的阶段。RTR和RTS阶段需要填写对端的QP号、GID、以及接收缓冲区rkey等信息。这些信息如何交换可以用管理平面也可以用ibv_rc_pingpong这种demo程序里自带的socket交换方式。但要注意真正的高性能场景下连接管理建议用librdmacm它会通过内核的rdma_cm模块帮你处理地址解析和连接建立省去自己维护握手逻辑的麻烦。3.3 RDMA CM的用户态与内核态分工librdmacm是另一个绕不开的库它提供的rdma_create_id、rdma_connect等接口底层依赖内核的rdma_cm模块。连接层交互的路径是用户态调用librdmacm → netlink socket发给内核的rdma_cm→ 内核负责ARP解析、路径记录、连接建立最终把对端的QP号等信息交给用户态。我用librdmacm做连接管理时的心得是连接建立这部分的延迟远大于数据面延迟连接是慢路径所以别指望用它来做热路径操作。但在写代码时它确实能把“换QP信息、换GID、换lkey”那堆繁琐步骤全部隐藏掉只留下rdma_create_qp一行代码省心很多。4. 常见问题与排查技巧实录4.1 创建QP失败的典型原因最常遇到的是ibv_create_qp返回NULL但errno没有给足信息。我建议排查时按这个顺序来用ibv_devinfo确认设备能力。max_send_wr、max_recv_wr、max_send_sge、max_recv_sge任何一项超过上限创建都会失败。检查PD和CQ是否属于同一个ibv_context。跨设备创建的PD和CQ搭配在一起建QP内核会直接拒绝。检查是否在CQ被销毁后创建QP。顺序错了会导致野指针这个属于用户态编程问题不是内核问题。排查手段上strace依然是第一利器。strace -f -e traceioctl跑一下你的程序能看到uverbs的ioctl调用序列。如果ioctl返回EINVAL八成是某个参数超限如果返回ENOMEM优先怀疑队列深度太大或系统RDMA资源不足——用rdma res show qp看一下当前设备上活跃的QP数量。4.2 内存注册失败与mlock限制ibv_reg_mr失败99%是RLIMIT_MEMLOCK的限制。这个限制在非root用户下默认值可能只有64KB注册稍微大一点的缓冲区就完蛋。解决办法顺滑的做法是修改limits# 查看当前限制 ulimit -l # 临时调大这里以GB为例 ulimit -l unlimited # 永久生效写入 /etc/security/limits.conf # * soft memlock unlimited # * hard memlock unlimited如果是root跑的程序ibv_reg_mr还会受到vm.max_map_count和内存碎片的影响。注册超大缓冲区时内核要一次性pin大量页面如果系统内存本身就紧张pin的过程会很慢甚至失败这类问题看dmesg通常能看到ib_umem相关告警。另外宿主机上跑虚拟化、容器化场景时如果出现“设备打开成功但注册内存失败”先查容器的/dev/infiniband是否映射完整/sys/class/infiniband是否可读。RDMA设备在容器里的权限模型和普通网卡不一样光有--device是不够的很多情况下需要特权模式或者补IPC_LOCKcapability。4.3 性能上不去的排查思路当你发现RDMA吞吐或延迟异常不要急着怀疑硬件先从“数据面是否真的没有进内核”这个角度排查。下面是我实际用过很多次的定位步骤用perf top观察CPU热点。如果数据收发时CPU花大量时间在entry_SYSCALL_64_after_hwframe上说明你的代码在热路径里误用了系统调用。检查是否误调用了ibv_poll_cq的忙等版本。ibv_poll_cq本身是用户态轮询CQ内存不经过内核但如果你每次poll完都调用usleep或者nanosleep来让出CPU那等于把延迟重新拉回来了。检查发送队列是否填满。如果写了WR但没及时ring doorbell或者每次都单条doorbell导致PCIe读写开销过大可以考虑用IBV_SEND_INLINE或者其他批处理手段。用rdma res show qp -j看看QP状态和硬件计数器确认没有丢包重传。RoCE场景下丢包代价极大一旦出现重传性能会断崖式下跌。还有一个常被忽略的点CPU亲和性和NUMA。RDMA网卡挂在某个NUMA节点上对应的数据结构CQ、QP如果分配在另一个NUMA节点跨片访问会导致每次doorbell和CQ轮询都要走QPI/UPI总线这种开销在低延迟场景下非常明显。用ibv_devinfo能看到设备挂在哪个nodenode_guid不能直接看NUMA但可以通过lstopo或者/sys/class/infiniband/mlx5_0/device/numa_node查看然后把进程绑到同一个NUMA节点上延迟能低不少。我在实际项目里踩过最狠的一个坑是某次升级内核后ib_uverbs模块被拆成了多个子模块旧的启动脚本只加载了mlx5_ib结果ibv_open_device一直失败。折腾了半天才发现ib_uverbs没有自动加载。后来我习惯在服务启动前显式执行modprobe ib_uverbs并且在健康检查脚本里加一步rdma link show避免这种低级但是致命的环境问题。再分享一个排查小技巧当你怀疑某次QP操作的参数有问题时不要只盯用户态代码可以临时开启内核的RDMA动态调试日志echo file ib_uverbs_*.c p /sys/kernel/debug/dynamic_debug/control dmesg -w这样能直接看到内核侧拒绝了什么、在哪个环节出错的。日志看完了记得关闭echo file ib_uverbs_*.c -p /sys/kernel/debug/dynamic_debug/controlRDMA用户态与内核态的交互本质上就是“控制面与数据面的分离哲学”宝贵的CPU时间不能浪费在内核协议栈上但硬件资源的分配与安全边界必须交给内核来守。你不需要背住每个驱动函数的调用链但你需要建立这个心智模型。等你在生产环境里真正遇到一次QP创建失败、一次内存注册超限、一次奇怪的性能回退再回头看这篇文章里说的这些交互路径你会理解为什么这个设计值得被反复咀嚼。
返回列表