通过底层库替换和Linux新特性显著提升FastAPI性能。实用的性能优化技巧,直接可应用于后端项目。
大多数现代网络应用要么构建在缓慢且语义模糊的 REST API 之上,要么采用复杂得没有必要的 gRPC。以 FastAPI 为例,它看起来非常容易上手。我们的目标是做到同样简单,甚至更加简单。
pip install fastapi uvicorn
pip install ucall
from fastapi import FastAPI
import uvicorn
server = FastAPI()
@server.get('/sum')
def sum(a: int, b: int):
return a + b
uvicorn.run(...)
from ucall.posix import Server
# from ucall.uring import Server on 5.19+
server = Server()
@server
def sum(a: int, b: int):
return a + b
server.run()
在较新的 8 核 CPU 上,处理一次微不足道的 FastAPI 调用也需要超过 1 毫秒。就在这段时间里,光已经可以沿着光纤传播 300 公里——就我所在的位置而言,足以到达相邻的城市甚至邻国。那么,UCall 与 FastAPI 和 gRPC 相比表现如何?
所有基准测试均在 AWS 的通用型实例上完成,使用 Ubuntu 22.10 AMI。这是首个搭载 Linux Kernel 5.19 的主要 AMI,而 Linux Kernel 5.19 对网络操作提供了广泛得多的 io_uring 支持。这里给出的具体数据来自配置强劲的 c7g.metal 实例,搭载 Graviton 3 芯片。
🔁 列表示后续请求是否会复用 TCP/IP 连接。
“server”列表示实现服务端所使用的编程语言。
“latency”列表示从发送请求到收到响应所需的时间。μ 表示“微”,因此 μs 表示微秒。
“throughput”列表示由同一台机器上的多个 Client 进程查询同一个 Server 应用时,每秒能够处理的请求数,即 Requests Per Second。
¹ FastAPI 无法通过 WebSockets 处理并发请求。
² 我们尝试使用 gRPC 生成 C++ 后端,但令人怀疑的是,它的测试数据并没有更好。gRPC 还提供了异步选项,但我们没有对其进行测试。
一个只有几千行代码的小型业余项目,怎么可能与两个最成熟的网络库竞争?因为 UCall 站在巨人的肩膀上:
使用 io_uring 实现无中断 IO。
在 5.1+ 上使用 io_uring_prep_read_fixed。
在 5.19+ 上使用 io_uring_prep_accept_direct。
在 5.19+ 上使用 io_uring_register_files_sparse。
在 5.19+ 上可选使用 IORING_SETUP_COOP_TASKRUN。
在 6.0+ 上可选使用 IORING_SETUP_SINGLE_ISSUER。
采用支持手动内存控制的 SIMD 加速解析器:
采用支持手动内存控制的 SIMD 加速解析器。
使用 simdjson 解析 JSON,速度甚至快于 gRPC 解包 ProtoBuf。
使用 Turbo-Base64 解码 Base64 格式的二进制值。
使用 picohttpparser 解析 HTTP Header。
你已经看过一次往返调用的延迟……也看过每秒请求数所代表的吞吐量……还想看看带宽表现吗?亲自试试吧!
@server
def echo(data: bytes):
return data
FastAPI 支持原生类型,而 UCall 还支持 numpy.ndarray、PIL.Image 以及其他自定义类型。当你构建真实应用,或者像我们使用 UForm 那样部署多模态 AI 时,这会非常方便。
from ucall.rich_posix import Server
import uform
server = Server()
model = uform.get_model('unum-cloud/uform-vl-multilingual')
@server
def vectorize(description: str, photo: PIL.Image.Image) -> numpy.ndarray:
image = model.preprocess_image(photo)
tokens = model.preprocess_text(description)
joint_embedding = model.encode_multimodal(image=image, text=tokens)
return joint_embedding.cpu().detach().numpy()
我们还提供了可选的 Client 类,帮助处理这些自定义类型。
from ucall.client import Client
client = Client()
# Explicit JSON-RPC call:
response = client({
'method': 'vectorize',
'params': {
'description': description,
'image': image,
},
'jsonrpc': '2.0',
'id': 100,
})
# Or the same with syntactic sugar:
response = client.vectorize(description=description, image=image)
除了 Python Client,我们还提供了易于使用的命令行界面,它会随 pip install ucall 一同安装。你可以用它调用远程 Server、上传文件,并且它直接支持图像和 NumPy 数组。把前面的示例改写成 Bash 脚本,调用同一台机器上的 Server:
ucall vectorize description='Product description' -i image=./local/path.png
调用远程 Server:
ucall vectorize description='Product description' -i image=./local/path.png --uri 0.0.0.0 -p 8545
使用 ucall -h 查看文档:
usage: ucall [-h] [--uri URI] [--port PORT] [-f [FILE ...]] [-i [IMAGE ...]] [--positional [POSITIONAL ...]] method [kwargs ...]
UCall Client CLI
positional arguments:
method method name
kwargs method arguments
options:
-h, --help show this help message and exit
--uri URI server uri
--port PORT server port
-f [FILE ...], --file [FILE ...]
method positional arguments
-i [IMAGE ...], --image [IMAGE ...]
method positional arguments
--positional [POSITIONAL ...]
method positional arguments
你还可以显式标注类型,以区分整数、浮点数和字符串,避免产生歧义。
ucall auth id=256
ucall auth id:int=256
ucall auth id:str=256
带宽测试就留给感兴趣的朋友吧,不过我们还可以再分享一些数据。总体规律是:不要指望从 Free-Tier 机器中榨出很高的性能。目前 AWS 提供以下选项:采用较旧 Intel 芯片的 t2.micro,以及采用较新 Graviton 2 芯片的 t4g.small。这个库的速度非常快,甚至用不到一个以上的 CPU 核心,因此即便在小型 Free-Tier Server 上,你也能运行一个快速的服务!
在这项测试中,每台 Server 都会承受来自同一可用区内另外 1 台或整整 32 台实例的请求轰炸。如果你想复现这些基准测试,可以查看 GitHub 上的 sum 示例。
pip install ucall
include(FetchContent)
FetchContent_Declare(
ucall
GIT_REPOSITORY https://github.com/unum-cloud/ucall
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(ucall)
include_directories(${ucall_SOURCE_DIR}/include)
与 Python 相比,C 的使用示例要繁琐得多。我们希望让它尽可能轻量,同时在不进行动态内存分配和命名查找的情况下支持可选参数。因此,与 Python 层不同,我们要求用户通过 ucall_param_named_i64() 及其同类函数,手动从调用上下文中提取参数。
#include <cstdio.h>
#include <ucall/ucall.h>
static void sum(ucall_call_t call, ucall_callback_tag_t) {
int64_t a{}, b{};
char printed_sum[256]{};
bool got_a = ucall_param_named_i64(call, "a", 0, &a);
bool got_b = ucall_param_named_i64(call, "b", 0, &b);
if (!got_a || !got_b)
return ucall_call_reply_error_invalid_params(call);
int len = snprintf(printed_sum, 256, "%ll", a + b);
ucall_call_reply_content(call, printed_sum, len);
}
int main(int argc, char** argv) {
ucall_server_t server{};
ucall_config_t config{};
ucall_init(&config, &server);
ucall_add_procedure(server, "sum", &sum, NULL);
ucall_take_calls(server, 0);
ucall_free(server);
return 0;
}
想影响产品路线图或提出功能需求?欢迎加入 Discord 讨论。
与传输方式无关:UDP、TCP,想用什么都可以。
应用层是可选的:可以使用 HTTP,也可以不用。
与 REST API 不同,传递参数只有一种方式。