RSS

移动基准测试

随着 gRPC 成为一个更好、更快的 RPC 框架,我们总会被问到:“gRPC 到底有多快?”我们已经有全面的服务端基准测试,但我们没有移动端基准测试。客户端基准测试与服务端略有不同。我们更关注延迟、请求大小等,而非每秒查询数 (QPS)、并发线程数等。因此,我们开发了一个 Android 应用来量化这些因素,并提供可靠的数据支持。

具体来说,我们想进行的基准测试是客户端的 Protobuf 对比 JSON 序列化/反序列化,以及 gRPC 对比 RESTful HTTP JSON 服务。对于序列化基准测试,我们想测量消息大小以及序列化和反序列化的速度。对于 RPC 基准测试,我们想测量端到端请求的延迟和数据包大小。

Protobuf 对比 JSON

为了对 Protobuf 和 JSON 进行基准测试,我们对随机生成的 Protobuf 消息反复进行序列化和反序列化,这些消息可以在这里看到。这些 Protobuf 消息的大小和复杂度差异很大,从几字节到超过 100KB 不等。我们创建了等效的 JSON,并也进行了基准测试。对于 Protobuf 消息,我们有三种主要的序列化和反序列化方法:简单地使用字节数组、Protobuf 自带的输入输出流实现 CodedOutputStream/CodedInputStream,以及 Java 的 ByteArrayOutputStreamByteArrayInputStream。对于 JSON,我们使用了 org.jsonJSONObject。这只有一种序列化和反序列化方法,分别是 toString()new JSONObject()

为了尽可能保持基准测试的准确性,我们将要进行基准测试的代码封装在一个接口中,并简单地将其循环一定次数。这样我们就避免了检查系统时间所花费的任何时间。

interface Action {
    void execute();
}

// Sample benchmark of multiplication
Action a = new Action() {
    @Override
    public void execute() {
        int x = 1000 * 123456;
    }
}

for (int i = 0; i < 100; ++i) {
    a.execute();
}

在运行基准测试之前,我们先运行一个预热(warmup),以清除 JVM 的任何不规律行为,然后计算在设定时间(Protobuf 对比 JSON 基准测试中为 10 秒)内需要运行的迭代次数。为此,我们从 1 次迭代开始,测量该次运行所需的时间,并将其与最小样本时间(我们设置为 2 秒)进行比较。如果迭代次数足够长,我们通过计算估算出运行 10 秒所需的迭代次数。否则,我们将迭代次数乘以 2,然后重复该过程。

// This can be found in ProtobufBenchmarker.java benchmark()
int iterations = 1;
// Time action simply reports the time it takes to run a certain action for that number of iterations
long elapsed = timeAction(action, iterations);
while (elapsed < MIN_SAMPLE_TIME_MS) {
    iterations *= 2;
    elapsed = timeAction(action, iterations);
}
// Estimate number of iterations to run for 10 seconds
iterations = (int) ((TARGET_TIME_MS / (double) elapsed) * iterations);

结果

基准测试在 Protobuf、JSON 和 gzipped JSON 上运行。

我们发现,无论 Protobuf 使用哪种序列化/反序列化方法,其序列化速度始终比 JSON 快约 3 倍。反序列化方面,JSON 对于小消息(<1KB)实际上稍快一些,快约 1.5 倍,但对于较大消息(>15KB),Protobuf 快 2 倍。对于 gzipped JSON,无论大小如何,Protobuf 的序列化速度都远超 5 倍。反序列化方面,对于小消息两者大致相同,但对于较大消息,Protobuf 快约 3 倍。结果可以在 README 中更深入地探索和复现

gRPC 对比 HTTP JSON

为了进行 RPC 调用基准测试,我们想测量端到端延迟和带宽。为此,我们与服务器进行 60 秒的乒乓通信,每次使用相同的消息,并测量延迟和消息大小。消息包含一些供服务器读取的字段和一个字节有效载荷。我们将 gRPC 的一元调用与简单的 RESTful HTTP JSON 服务进行了比较。gRPC 基准测试会创建一个 channel,并启动一个一元调用,该调用在接收到响应后重复,直到 60 秒过去。响应包含一个带有相同有效载荷的 Protobuf 消息。

类似地,对于 HTTP JSON 基准测试,它会向服务器发送一个带有等效 JSON 对象的 POST 请求,服务器返回一个带有相同有效载荷的 JSON 对象。

// This can be found in AsyncClient.java doUnaryCalls()
// Make stub to send unary call
final BenchmarkServiceStub stub = BenchmarkServiceGrpc.newStub(channel);
stub.unaryCall(request, new StreamObserver<SimpleResponse>() {
    long lastCall = System.nanoTime();
    // Do nothing on next
    @Override
    public void onNext(SimpleResponse value) {
    }

    @Override
    public void onError(Throwable t) {
        Status status = Status.fromThrowable(t);
        System.err.println("Encountered an error in unaryCall. Status is " + status);
        t.printStackTrace();

        future.cancel(true);
    }
    // Repeat if time isn't reached
    @Override
    public void onCompleted() {
        long now = System.nanoTime();
        // Record the latencies in microseconds
        histogram.recordValue((now - lastCall) / 1000);
        lastCall = now;

        Context prevCtx = Context.ROOT.attach();
        try {
            if (endTime > now) {
                stub.unaryCall(request, this);
            } else {
                future.done();
            }
        } finally {
            Context.current().detach(prevCtx);
        }
    }
});

同时使用了 HttpUrlConnectionOkHttp 库

只对 gRPC 的一元调用与 HTTP 进行了基准测试,因为流式调用比一元调用快 2 倍以上。此外,HTTP 没有流式传输的等效功能,流式传输是 HTTP/2 特有的功能。

结果

在延迟方面,gRPC 在高达 95% 分位数的情况下快 5-10 倍,端到端请求的平均延迟约为 2 毫秒。在带宽方面,gRPC 对于小请求(100-1000 字节有效载荷)快约 3 倍,对于大请求(10KB-100KB 有效载荷)始终快 2 倍。要复现这些结果或更深入地探索,请查看我们的仓库