yalantinglibs 的coro_rpc 是基于C++20 协程的高性能rpc库,提供了简洁易用的接口,让用户几行代码就可以实现rpc通信,现在coro_rpc 除了支持tcp通信之外还支持了rdma通信(ibverbs)。通过一个简单的例子来感受一下rdma 通信的coro_rpc。
example
启动rpc server
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请求
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 |
---|---|---|---|---|---|
128B | 1 | 0.04 | 24 | 26 | 43394 |
4 | 0.15 | 29 | 44 | 149130 | |
16 | 0.40 | 48 | 61 | 393404 | |
64 | 0.81 | 100 | 134 | 841342 | |
256 | 1.47 | 210 | 256 | 1533744 | |
4K | 1 | 1.21 | 35 | 39 | 37017 |
4 | 4.50 | 37 | 48 | 137317 | |
16 | 11.64 | 62 | 74 | 355264 | |
64 | 24.47 | 112 | 152 | 745242 | |
256 | 42.36 | 244 | 312 | 1318979 | |
32K | 1 | 8.41 | 39 | 41 | 32084 |
4 | 29.91 | 42 | 55 | 114081 | |
16 | 83.73 | 58 | 93 | 319392 | |
64 | 148.66 | 146 | 186 | 565878 | |
256 | 182.74 | 568 | 744 | 697849 | |
256K | 1 | 28.59 | 81 | 90 | 13634 |
4 | 100.07 | 96 | 113 | 47718 | |
16 | 182.58 | 210 | 242 | 87063 | |
64 | 181.70 | 776 | 864 | 87030 | |
256 | 180.98 | 3072 | 3392 | 88359 | |
1M | 1 | 55.08 | 158 | 172 | 6566 |
4 | 161.90 | 236 | 254 | 19299 | |
16 | 183.41 | 832 | 888 | 21864 | |
64 | 184.29 | 2976 | 3104 | 21969 | |
256 | 184.90 | 11648 | 11776 | 22041 | |
8M | 1 | 78.64 | 840 | 1488 | 1171 |
4 | 180.88 | 1536 | 1840 | 2695 | |
16 | 185.01 | 5888 | 6010 | 2756 | |
64 | 185.01 | 23296 | 23552 | 2756 | |
256 | 183.47 | 93184 | 94208 | 2733 |
具体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发送,并利用底层的小包写入合并技术提高吞吐。
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");
}
}