在上一章中,我们学习了后端注册机制 (Backend Registration),了解了 GGML 框架如何像发现插件一样,知道 "RPC" 这种后端类型的存在。结合第一章 远程计算设备 (RPC Device)的知识,我们现在已经能在本地创建一个指向远程服务器的"快捷方式"了。
但光有"快捷方式"还不够。我们如何通过这个快捷方式,真正地向远程服务器下达指令,比如"请帮我分配一块 GPU 显存"或者"请帮我运行这个复杂的计算任务"呢?
这时,我们就需要一个功能更强大的工具------一个"遥控器"。这个"遥控器"就是本章的主角:远程过程调用后端 (RPC Backend)。
1. 问题的提出:我需要一个"遥控器"
回到我们最初的场景:你的笔记本电脑算力不足,而远处有一台强大的服务器。
- 通过远程计算设备 (RPC Device),我们创建了一个"快捷方式",让我们的应用程序知道了这台服务器的存在。
- 通过后端注册机制 (Backend Registration),GGML 框架管理并识别了这种"远程"类型的设备。
现在,我们面临着最关键的一步:如何使用它?我们需要一个能与远程服务器实时通信、发送具体指令并接收结果的实体。这个实体不仅要能建立连接,还要能理解"计算图"这样的高级概念,并将其翻译成网络另一端的服务器能懂的语言。
这正是 RPC Backend
的职责。它充当了客户端与服务器之间的"总指挥"和"翻译官"。
2. 核心概念:什么是 RPC 后端?
远程过程调用后端 (RPC Backend) 是客户端的核心抽象,代表了与远程计算服务器的一个活动连接和指令通道。
如果说 RPC Device
是一个静态的"快捷方式",那么 RPC Backend
就是一个动态的"遥控器"。
当你拿起这个"遥控器"(创建
RPC Backend
实例),你就建立了一个与远程服务器的专属通信会话。通过按下遥控器上的按钮(调用后端函数),你可以命令服务器执行各种操作。
这个"遥控器"主要负责以下几项工作:
- 管理连接:它持有与服务器的底层网络套接字(socket)连接,负责所有的数据收发。
- 执行命令 :它提供了一系列标准化的函数接口,让你能像调用本地函数一样,触发远程操作。最重要的操作就是
graph_compute
(执行计算图)。 - 序列化与反序列化 :当你命令它"执行这个计算图"时,它会负责将本地内存中的计算图结构(
ggml_cgraph
)打包成二进制数据流(序列化),通过网络发送给服务器。这个过程对用户是完全透明的。 - 隐藏网络细节 :你完全不需要关心 TCP/IP 协议、端口号、数据包格式等底层网络细节。所有这些都被
RPC Backend
优雅地封装好了。
总而言之,RPC Backend
是你与远程算力进行交互的唯一入口。
3. 如何使用:创建并连接到 RPC 后端
ggml-rpc
提供了一个非常直观的函数来创建这个"遥控器":ggml_backend_rpc_init
。
假设远程服务器地址依然是 192.168.1.100:18080
。
c
#include "ggml-rpc.h"
#include <stdio.h>
int main() {
const char * endpoint = "192.168.1.100:18080";
// 初始化 RPC 后端,获取一个"遥控器"句柄
ggml_backend_t rpc_backend = ggml_backend_rpc_init(endpoint);
if (rpc_backend) {
// 使用标准的 GGML 函数获取后端名称
printf("成功创建 RPC 后端,连接到: %s\n", ggml_backend_get_name(rpc_backend));
// 在这里,我们就可以使用 rpc_backend 来分配远程内存、执行计算了
// ...
// 使用完毕后,释放后端资源
ggml_backend_free(rpc_backend);
printf("RPC 后端已释放。\n");
} else {
printf("创建 RPC 后端失败。\n");
}
return 0;
}
代码解释:
ggml_backend_rpc_init(endpoint)
函数接收服务器地址作为参数,并返回一个ggml_backend_t
类型的句柄。这个句柄就是我们所说的"遥控器"。- 我们可以对这个句柄使用 GGML 的标准后端函数,例如
ggml_backend_get_name
,来获取它的信息。 - 使用完毕后,务必调用
ggml_backend_free
来释放资源,这会隐式地断开与服务器的连接。
预期输出:
ini
成功创建 RPC 后端,连接到: RPC[192.168.1.100:18080]
RPC 后端已释放。
拿到 rpc_backend
句柄后,你就拥有了指挥远程服务器的能力。虽然我们在这个简单的例子里没有执行实际的计算,但创建这个后端实例是所有后续操作的前提。
4. 幕后探秘:init
时发生了什么?
你可能会好奇,调用 ggml_backend_rpc_init
时,是不是立刻就和服务器建立了 TCP 连接?答案是:不一定。
和创建 RPC Device
类似,RPC Backend
的初始化也遵循"延迟连接"或"按需连接"的策略,以提高效率。
高层流程
- 调用
ggml_backend_rpc_init
:- 这个函数并不会立即建立网络连接。
- 它首先在内存中创建一个
ggml_backend
结构体,这个结构体就是返回给你的句柄。 - 它在内部创建了一个
ggml_backend_rpc_context
,用于保存服务器的地址endpoint
等信息。 - 最关键的一步是,它将一组特殊的 RPC 接口函数 (
ggml_backend_rpc_interface
) 关联到了这个ggml_backend
结构体上。这些函数(如ggml_backend_rpc_graph_compute
)才是真正知道如何与服务器通信的实现。
- 首次执行需要通信的操作 (例如
ggml_backend_graph_compute
):- 当你第一次调用一个需要与服务器交互的函数时,
ggml-rpc
客户端会检查是否存在一个到该服务器的活动连接。 - 如果不存在,它会此时才尝试与服务器建立网络连接。
- 连接成功后,它会将你的指令(例如,序列化后的计算图)发送出去。
- 等待服务器响应,并将结果返回给你。
- 当你第一次调用一个需要与服务器交互的函数时,
让我们用一个时序图来清晰地展示这个过程:
2. 关联 RPC 接口函数
3. **此时不建立网络连接** ggml-rpc客户端-->>应用程序: 返回后端句柄 (ggml_backend_t) deactivate ggml-rpc客户端 应用程序->>ggml-rpc客户端: 调用 ggml_backend_graph_compute(后端句柄, cgraph) activate ggml-rpc客户端 Note right of ggml-rpc客户端: 这是第一个需要通信的命令 ggml-rpc客户端->>ggml-rpc服务端: 建立网络连接 (按需首次连接) activate ggml-rpc服务端 ggml-rpc客户端->>ggml-rpc服务端: 序列化 cgraph 并发送 COMPUTE_GRAPH 请求 ggml-rpc服务端->>ggml-rpc服务端: 在远程 GPU 上执行计算 ggml-rpc服务端-->>ggml-rpc客户端: 响应:返回计算状态 deactivate ggml-rpc服务端 ggml-rpc客户端-->>应用程序: 返回计算状态 deactivate ggml-rpc客户端
深入代码
让我们深入 ggml-rpc.cpp
源码,看看这一切是如何实现的。
首先是 ggml_backend_rpc_init
函数:
cpp
// 文件: ggml-rpc.cpp
ggml_backend_t ggml_backend_rpc_init(const char * endpoint) {
// 1. 创建上下文,存储 endpoint 和名称
ggml_backend_rpc_context * ctx = new ggml_backend_rpc_context {
/* .endpoint = */ endpoint,
/* .name = */ "RPC[" + std::string(endpoint) + "]",
};
// 2. 创建后端结构体,并关联 RPC 的接口函数
ggml_backend_t backend = new ggml_backend {
/* .guid = */ ggml_backend_rpc_guid(), // RPC 后端的唯一标识符
/* .iface = */ ggml_backend_rpc_interface, // 核心!关联操作函数
/* .device = */ ggml_backend_rpc_add_device(endpoint),
/* .context = */ ctx
};
return backend;
}
如我们所分析的,这段代码的核心就是创建结构体和上下文,并将 .iface
字段指向 ggml_backend_rpc_interface
。这里没有任何网络操作。
ggml_backend_rpc_interface
是什么呢?它是一个包含了所有后端操作函数指针的结构体:
cpp
// 文件: ggml-rpc.cpp
static ggml_backend_i ggml_backend_rpc_interface = {
/* .get_name = */ ggml_backend_rpc_name,
/* .free = */ ggml_backend_rpc_free,
// ... 其他函数指针 ...
/* .graph_compute = */ ggml_backend_rpc_graph_compute, // 关键的计算函数
// ... 其他函数指针 ...
};
当我们调用 ggml_backend_graph_compute(rpc_backend, ...)
时,GGML 框架实际上会通过 rpc_backend->iface.graph_compute(...)
调用到我们上面指定的 ggml_backend_rpc_graph_compute
函数。
我们再来看看这个函数的简化版实现,看看网络通信是如何被触发的:
cpp
// 文件: ggml-rpc.cpp
static enum ggml_status ggml_backend_rpc_graph_compute(ggml_backend_t backend, ggml_cgraph * cgraph) {
ggml_backend_rpc_context * rpc_ctx = (ggml_backend_rpc_context *)backend->context;
// 1. 获取到服务器的 socket 连接 (如果不存在则创建)
auto sock = get_socket(rpc_ctx->endpoint);
// 2. 将计算图 cgraph 序列化成字节流
std::vector<uint8_t> input;
serialize_graph(cgraph, input);
// 3. 通过 socket 发送 RPC 命令和数据
rpc_msg_graph_compute_rsp response;
bool status = send_rpc_cmd(sock, RPC_CMD_GRAPH_COMPUTE, input.data(), input.size(), &response, sizeof(response));
// ... 处理响应 ...
return (enum ggml_status)response.result;
}
这下就非常清楚了!真正的网络操作发生在具体的命令函数内部:获取连接 -> 打包数据 -> 发送命令。这种设计将职责清晰地分离开来,使得整个系统既高效又易于维护。
5. 总结与展望
在本章中,我们认识了 ggml-rpc
的核心交互工具------远程过程调用后端 (RPC Backend)。
- 我们理解了它就像一个与远程服务器通信的"遥控器",是执行所有远程操作的入口。
- 我们学会了使用
ggml_backend_rpc_init
来创建一个RPC Backend
实例,为后续的计算任务做好准备。 - 我们还深入探究了其"按需连接"的内部机制,明白了
init
和实际的网络通信是分离的,具体命令的执行才会触发数据交换。
到目前为止,我们已经完整地学习了 ggml-rpc
客户端的几个核心组件。我们知道如何注册后端类型,如何创建设备代理,以及如何初始化后端"遥控器"来发送指令。
但是,这个"遥控器"发出的信号,究竟被谁接收了呢?网络另一端的服务器是如何监听这些请求,并调用真正的 GPU 来完成计算的?
是时候揭开服务器端的神秘面纱了。下一章,我们将一同探索 ggml-rpc
的另一半------那个在远方默默等待指令的强大引擎。