移动基准测试
随着 gRPC 成为一个更好更快的 RPC 框架,我们一直被问到:“gRPC 到底快了多少?” 我们已经有了全面的服务器端基准测试,但没有移动端基准测试。对客户端进行基准测试与对服务器进行基准测试有些不同。我们更关注延迟和请求大小等方面,而不那么关注每秒查询数(QPS)和并发线程数等。因此,我们构建了一个 Android 应用程序,以量化这些因素并提供确凿的数据支持。
具体来说,我们要对客户端 Protobuf 与 JSON 的序列化/反序列化以及 gRPC 与 RESTful HTTP JSON 服务进行基准测试。对于序列化基准测试,我们希望测量消息的大小以及序列化和反序列化的速度。对于 RPC 基准测试,我们希望测量端到端请求的延迟和数据包大小。
Protobuf vs. JSON
为了对 Protobuf 和 JSON 进行基准测试,我们对随机生成的 Protobuf 消息重复进行序列化和反序列化,这些消息可以在这里查看。这些 Protobuf 消息的大小和复杂性差异很大,从只有几个字节到超过 100kb 不等。我们还创建了相应的 JSON 等效物并进行了基准测试。对于 Protobuf 消息,我们主要有三种序列化和反序列化方法:直接使用字节数组、Protobuf 自己的输入输出流实现 CodedOutputStream
/CodedInputStream
,以及 Java 的 ByteArrayOutputStream
和 ByteArrayInputStream
。对于 JSON,我们使用了 org.json
的 JSONObject
。它只有一种序列化和反序列化方法,分别是 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();
}
在运行基准测试之前,我们先进行了一次预热,以清除 JVM 的任何异常行为,然后计算在设定时间(Protobuf vs. 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 vs. HTTP JSON
为了对 RPC 调用进行基准测试,我们希望测量端到端延迟和带宽。为此,我们与服务器进行 60 秒的乒乓式传输,每次使用相同的消息,并测量延迟和消息大小。消息包含一些供服务器读取的字段和一个字节有效负载。我们将 gRPC 的一元调用与一个简单的 RESTful HTTP JSON 服务进行比较。gRPC 基准测试会创建一个通道,并启动一个一元调用,该调用在收到响应后会重复进行,直到 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);
}
}
});
使用了 HttpUrlConnection
和 OkHttp 库。
只有 gRPC 的一元调用与 HTTP 进行了基准测试,因为流式调用比一元调用快 2 倍以上。此外,HTTP 没有等效的流式功能,流式是 HTTP/2 特有的功能。
结果
在延迟方面,gRPC 在 95 百分位之前快 5 到 10 倍,端到端请求的平均延迟约为 2 毫秒。在带宽方面,gRPC 对于小型请求(100-1000 字节有效负载)快约 3 倍,对于大型请求(10kb-100kb 有效负载)始终快 2 倍。要复现这些结果或进行更深入的探索,请查看我们的仓库。