在前一章 远程过程调用后端 (RPC Backend) 中,我们学习了客户端的"遥控器"------RPC Backend
。我们知道了如何创建它来向远方发送指令。但是,这个遥控器按下的信号,究竟是被谁接收和执行的呢?
如果说客户端的 RPC Backend
是一个遥控器,那么必然需要一台"电视机"来接收信号并播放画面。这台"电视机"就是我们本章的主角------RPC 服务器 (rpc_server) 。它是整个 ggml-rpc
架构中负责干"脏活累活"的幕后英雄。
1. 问题的提出:谁在远方听我号令?
让我们回到最初的场景。你在笔记本上运行一个程序,通过 RPC Backend
发送了一个"计算这个神经网络"的指令。这条指令通过网络飞向了远方的高性能服务器。
那么,在这台服务器上,到底发生了什么?
- 必须有一个程序始终在运行,像一个忠诚的管家,随时准备接收来自网络的请求。
- 这个程序必须能听懂我们客户端发来的"语言"(即通信协议)。
- 当它收到"计算神经网络"的指令时,它必须知道如何调用服务器上真正的硬件资源(比如 NVIDIA GPU)来完成这个任务。
- 完成任务后,它还要把结果(比如"计算成功"或"计算失败")打包好,再发回给我们的笔记本电脑。
这个在服务器上默默等待、接收、执行并响应的程序,就是 rpc_server
。没有它,我们客户端的"遥d控器"就毫无用处。
2. 核心概念:什么是 RPC 服务器?
RPC 服务器 (rpc_server) 是运行在远程机器上的核心服务程序,是整个 RPC 架构的"执行者"。
它就像一个随时待命的"超级管家",不知疲倦地监听着来自客户端的请求。
想象一下,你在一个大型仓库里,用对讲机(客户端
RPC Backend
)下达指令:"请把 A 区的 3 号货物搬到 B 区"。仓库里有一位拿着对讲机、配备了叉车(GPU)的超级搬运工(rpc_server
)。他听到指令后,会立刻开动叉车,精准地完成搬运工作,然后通过对讲机回复你:"任务完成"。
这位"超级管家"或"搬运工"的核心职责包括:
- 监听端口 :它会"竖起耳朵",监听服务器上一个特定的网络端口(例如
18080
),等待客户端前来"敲门"(建立连接)。 - 解析命令:一旦有客户端连接上,它就能解析客户端发来的二进制数据流,理解其意图,比如"分配显存"、"传输张量数据"或"执行计算图"。
- 调用本地后端 :这是它最核心的功能。它会将客户端的请求转换 为对服务器本地 GGML 后端(例如
ggml-backend-cuda
)的调用。所有繁重的计算都由这个本地后端在服务器的 GPU 上完成。 - 返回结果:执行完毕后,它将执行结果(成功、失败或具体数据)打包,通过网络连接返回给客户端。
简而言之,rpc_server
是连接客户端虚拟指令和服务器真实算力之间的关键桥梁。
3. 如何使用:启动并运行服务器
与客户端的代码库不同,rpc_server
通常是一个可以直接运行的可执行程序。你需要在你的高性能服务器上,通过命令行来启动它。
假设你的服务器有一块 NVIDIA GPU,并且你希望 rpc_server
在所有网络接口的 18080
端口上监听,并使用 CUDA 后端来处理计算任务。
你需要在服务器的终端中输入以下命令:
bash
./rpc_server --host 0.0.0.0 --port 18080 --backend CUDA
命令参数解释:
--host 0.0.0.0
:0.0.0.0
是一个特殊的 IP 地址,表示服务器将在其所有可用的网络接口上进行监听。这使得局域网或公网上的客户端都能连接到它。--port 18080
: 指定了服务器监听的端口号。客户端在连接时必须使用相同的端口号。--backend CUDA
: 这是非常关键的参数!它告诉rpc_server
,当收到计算任务时,应该使用本地的 CUDA 后端 来执行。如果你是在一台 Apple Silicon Mac 上,你可能会使用--backend Metal
。
启动后的预期输出:
当你执行上述命令后,你会在服务器的终端上看到类似下面的日志信息,这表示服务器已经成功启动并进入等待状态:
yaml
Starting RPC server v0.2.0
endpoint : 0.0.0.0:18080
local cache : n/a
backend memory : 22874 MB
Accepted client connection, free_mem=22874, total_mem=24576
现在,这个"超级管家"已经准备就绪了。只要它在运行,你的客户端程序就可以随时连接上来,将计算任务卸载到这台服务器上。
4. 幕后探秘:一个请求的生命周期
当客户端调用 ggml_backend_graph_compute
时,服务器内部究竟发生了怎样一番天翻地覆的变化呢?让我们来追踪一个请求的完整生命周期。
高层流程
- 启动与监听 :
rpc_server
程序启动,初始化指定的本地后端(例如 CUDA),然后在指定的端口上创建一个服务器套接字(socket),并调用listen()
开始等待客户端连接。 - 接受连接 :当我们的客户端程序(在前几章中编写的)尝试连接时,服务器通过
accept()
函数接受连接,为这个客户端创建一个专属的通信通道(一个新的 socket)。 - 进入命令循环 :服务器为这个客户端启动一个循环,不断地从这个专属通道中读取数据 (
recv_data
)。 - 接收和分发命令 :服务器首先读取一个字节来确定命令类型(例如
RPC_CMD_GRAPH_COMPUTE
),然后读取后续的数据(例如序列化后的计算图)。接着,它会使用一个巨大的switch
语句,将请求"分发"给对应的处理函数,比如server.graph_compute()
。 - 执行本地计算 :
graph_compute
函数会首先反序列化 接收到的数据,在服务器的内存中重建出ggml_cgraph
结构。然后,它会调用它在启动时初始化的本地 CUDA 后端,执行ggml_backend_graph_compute(cuda_backend, graph)
。这时,服务器的 GPU 开始真正地"火力全开"。 - 发送响应 :GPU 计算完成后,本地 CUDA 后端返回一个状态。
rpc_server
将这个状态打包成一个响应消息,通过专属通道发回 (send_msg
) 给客户端。 - 继续等待:服务器处理完一个请求后,并不会关闭连接,而是回到第 3 步,继续等待这个客户端的下一个命令。
这个过程可以用一个时序图清晰地展示出来:
深入代码
让我们深入 ggml-rpc.cpp
的源码,看看这些步骤是如何在代码中体现的。
首先是服务器的启动和主循环,位于 ggml_backend_rpc_start_server
函数中:
cpp
// 文件: ggml-rpc.cpp
void ggml_backend_rpc_start_server(...) {
// ... (初始化网络) ...
// 1. 创建服务器套接字并开始监听
auto server_socket = create_server_socket(host.c.str(), port);
// ... (错误处理) ...
while (true) {
// 2. 接受一个新的客户端连接
auto client_socket = socket_accept(server_socket->fd);
// ... (错误处理) ...
printf("Accepted client connection\n");
// 3. 为这个客户端处理所有请求
rpc_serve_client(backend, ..., client_socket->fd, ...);
printf("Client connection closed\n");
}
// ...
}
这段代码清晰地展示了服务器如何循环等待并接受客户端连接,然后将每个连接交给 rpc_serve_client
函数处理。
rpc_serve_client
函数是真正的命令处理器,它包含了一个 while
循环和 switch
语句:
cpp
// 文件: ggml-rpc.cpp
static void rpc_serve_client(...) {
rpc_server server(backend, cache_dir);
// ... (处理初始的 HELLO 握手) ...
while (true) {
// 4. 从客户端读取一个命令字节
if (!recv_data(sockfd, &cmd, 1)) {
break; // 如果读取失败,说明连接已断开
}
// 5. 根据命令类型进行分发
switch (cmd) {
// ... 其他 case ...
case RPC_CMD_GRAPH_COMPUTE: {
// 接收计算图数据
std::vector<uint8_t> input;
if (!recv_msg(sockfd, input)) { return; }
// 调用 rpc_server 的方法来处理
rpc_msg_graph_compute_rsp response;
if (!server.graph_compute(input, response)) { return; }
// 发送响应
if (!send_msg(sockfd, &response, sizeof(response))) { return; }
break;
}
// ... 其他 case ...
}
}
}
最后,我们来看看 rpc_server::graph_compute
这个成员函数,它展示了如何将 RPC 请求与本地后端调用联系起来:
cpp
// 文件: ggml-rpc.cpp
bool rpc_server::graph_compute(const std::vector<uint8_t> & input, ...) {
// ... (反序列化 input 数据,在内存中重建出 ggml_cgraph* graph) ...
// 这里省略了复杂的反序列化逻辑
// 6. 调用本地后端执行计算!
// this->backend 就是启动时传入的 CUDA 后端
ggml_status status = ggml_backend_graph_compute(backend, graph);
// 将结果存入 response
response.result = status;
return true;
}
这下,整个流程就完全打通了!客户端的请求通过网络,被服务器的 switch
语句捕获,最终触发了服务器本地 GPU 上的真正计算。
5. 总结与展望
在本章中,我们揭开了 ggml-rpc
系统另一半的神秘面纱------RPC 服务器 (rpc_server)。
- 我们理解了
rpc_server
是一个在远程机器上持续运行的服务程序 ,是所有计算任务的最终执行者。 - 我们学会了如何通过命令行启动
rpc_server
,并指定它使用哪个本地后端(如 CUDA)来工作。 - 我们深入探究了一个客户端请求在服务器端的完整处理流程:从接受连接,到解析命令,再到调用本地后端执行计算,最后返回结果。
至此,我们已经了解了客户端的"遥控器"和服务器端的"执行者"。你可能已经非常好奇了:它们之间通信时,发送的那些二进制数据到底是什么格式?计算图是如何被"打包"成字节流的?命令和响应的结构又是怎样的?
要回答这些问题,我们就必须深入了解它们之间共同遵守的"语言"。