Skip to content
On this page

yalantinglibs 的coro_rpc 是基于C++20 协程的高性能rpc库,提供了简洁易用的接口,让用户几行代码就可以实现rpc通信,现在coro_rpc 除了支持tcp通信之外还支持了rdma通信(ibverbs)。通过一个简单的例子来感受一下rdma 通信的coro_rpc。

example

启动rpc server

cpp
std::string_view echo(std::string str) { return str; }
coro_rpc_server server(/*thread_number*/ std::thread::hardware_concurrency(), /*port*/ 9000);
server.register_handler<echo>();
server.init_ibv();// 初始化rdma 资源
server.start();

client 发送rpc请求

cpp
Lazy<void> async_request() {
  coro_rpc_client client{};
  client.init_ibv(); // 初始化rdma 资源
  co_await client.connect("127.0.0.1:9000");
  auto result = co_await client.call<echo>("hello rdma");
  assert(result.value() == "hello rdma");
}

int main() {
  syncAwait(async_request());
}

几行代码就可以完成基于rdma 通信的rpc server和client了。如果用户需要设置更多rdma相关的参数,则可以在调用init_ibv时传入config对象,在该对象中设置ibverbs相关的各种参数。详见文档

如果要启用tcp通信该怎么做呢?不调用init_ibv() 即可,默认就是tcp通信,调用了init_ibv()之后才是rdma通信。

benchmark

在180Gb rdma(RoCE V2)带宽环境,两台主机之间对coro_rpc 做了一些性能测试,在高并发小包场景下qps可以到150w;发送稍大的数据包时(256K以上) 不到10个并发就可以轻松打满带宽。

请求数据大小并发数吞吐(Gb/s)P90(us)P99(us)qps
128B10.04242643394
40.152944149130
160.404861393404
640.81100134841342
2561.472102561533744
4K11.21353937017
44.503748137317
1611.646274355264
6424.47112152745242
25642.362443121318979
32K18.41394132084
429.914255114081
1683.735893319392
64148.66146186565878
256182.74568744697849
256K128.59819013634
4100.079611347718
16182.5821024287063
64181.7077686487030
256180.983072339288359
1M155.081581726566
4161.9023625419299
16183.4183288821864
64184.292976310421969
256184.90116481177622041
8M178.6484014881171
4180.88153618402695
16185.01588860102756
64185.0123296235522756
256183.4793184942082733

具体benchmark的代码在这里

RDMA性能优化

RDMA内存池

rdma请求需要预先注册内存收发数据。在实际测试中,注册rdma内存的开销耗远大于内存拷贝。相比每次发送或接收数据的时候注册rdma内存。一个更合理的方式是,使用内存池缓存已经注册好的rdma内存。每次发起请求时,将数据分成多片接收/发送,每一片数据的最大长度恰好是预先注册好的内存长度,并从内存池中取出注册好的内存,并在内存块和实际数据地址之间做一次拷贝。

RNR与接收缓冲区队列

RDMA直接操作远端内存,当远端内存未准备好时,就会触发一次RNR错误,对RNR错误,要么断开连接,要么睡眠一段时间。显然避免RNR错误是提高RDMA传输性能和稳定度的关键。

coro_rpc采用如下策略解决RNR问题:对于每个连接,我们都准备一个接收缓冲区队列。队列中含若干块内存(默认8块*256KB),每当收到一块数据传输完成的通知时,立即补充一块新的内存到缓冲区队列中,并该块内存提交到RDMA的接收队列中。

发送缓冲区队列

在发送链路中,最朴素的思路是,我们先将数据拷贝到RDMA缓冲区,再将其提交到RDMA的发送队列。当数据写入到对端后,再重复上述步骤发送下一块数据。

上述步骤有两个瓶颈,第一个是如何并行化内存拷贝和网络传输,第二个是网卡发送完一块数据,再到CPU提交下一块数据的这段时间,网卡实际上处于空闲状态,未能最大化利用带宽。

为了提高发送数据,我们需要引入发送缓冲区的概念。每次读写,我们不等待对端完成写入,而是在将内存提交到RDMA的发送队列后就立即完成发送,让上层代码发送下一个请求/数据块,直到未完成发送的数据达到发送缓冲区队列的上限。此时我们才等待发送请求完成,随后将新的内存块提交到RDMA发送队列中。

对于大数据包,使用上述算法可以同时进行内存拷贝和网络传输,同时由于同时发送多块数据,网卡发送完一片数据到应用层提交新数据块的这段时间,网卡可以发送另外一块待发送的数据,从而最大化的利用了带宽。

小包写入合并

rdma在发送小数据包时吞吐量相对较低。对于小包请求,一个既能提高吞吐又不引入额外延迟的思路是将多个小包合并为大数据包。假如应用层提交了一个发送请求,且此时发送队列已满,则数据不会立即发送到远端,而是暂存在缓冲区中。此时假如应用层又提交了下一个请求,则我们可以将这次请求的数据合并写入到上一次数据暂存的缓冲区中,从而实现数据的合并发送。

inline data

某些rdma网卡对于小数据包,可以通过inline data的方式发送数据,它不需要注册rdma 内存,同时可以获得更好的传输性能。coro_rpc在数据包小于256字节并且网卡支持inline data的情况下,会采用该方式发送数据。

内存占用控制

RDMA通信需要自己管理内存缓冲区。目前,coro_rpc默认使用的内存片大小是256KB。接收缓冲区初始大小为8,发送缓冲区上限为2,因此单连接的内存占用为10*256KB约为2.5MB。用户可以通过调整缓冲区的大小和buffer大小来控制内存的占用。

此外,RDMA内存池同样提供水位配置,用于控制内存占用上限。当RDMA内存池的水位过高时,尝试从该内存池中获取新内存的连接会失败并关闭。

使用连接池

高并发场景下,可以通过coro_rpc提供的连接池复用连接,这可以避免重复创建连接。此外,由于coro_rpc支持连接复用,我们可以将多个小数据包请求提交到同一个连接中,实现pipeline发送,并利用底层的小包写入合并技术提高吞吐。

cpp
auto pool = coro_io::client_pool<coro_rpc::coro_rpc_client>::create(
    conf.url, pool_conf);
auto ret = co_await pool->send_request(
    [&](coro_io::client_reuse_hint, coro_rpc::coro_rpc_client& client) {
        return client.send_request<echo>("hello");
    });
if (ret.has_value()) {
    auto result = co_await std::move(ret.value());
    if (result.has_value()) {
        assert(result.value()=="hello"); 
    }
}

This website is released under the MIT License.